First cut at group timelines

This commit is contained in:
Hank Grabowski 2022-12-08 13:37:30 -05:00
parent 0704539a47
commit 3ede9ed04c
8 changed files with 236 additions and 78 deletions

View file

@ -8,9 +8,11 @@ import 'models/TimelineIdentifiers.dart';
import 'models/connection.dart'; import 'models/connection.dart';
import 'models/credentials.dart'; import 'models/credentials.dart';
import 'models/exec_error.dart'; import 'models/exec_error.dart';
import 'models/group_data.dart';
import 'models/timeline_entry.dart'; import 'models/timeline_entry.dart';
import 'models/user_notification.dart'; import 'models/user_notification.dart';
import 'serializers/friendica/connection_friendica_extensions.dart'; import 'serializers/friendica/connection_friendica_extensions.dart';
import 'serializers/mastodon/group_data_mastodon_extensions.dart';
import 'serializers/mastodon/notification_mastodon_extension.dart'; import 'serializers/mastodon/notification_mastodon_extension.dart';
import 'serializers/mastodon/timeline_entry_mastodon_extensions.dart'; import 'serializers/mastodon/timeline_entry_mastodon_extensions.dart';
@ -66,6 +68,32 @@ class FriendicaClient {
return response.mapValue((value) => true); return response.mapValue((value) => true);
} }
FutureResult<List<GroupData>, ExecError> getGroups() async {
_logger.finest(() => 'Getting group (Mastodon List) data');
final url = 'https://$serverName/api/v1/lists';
final request = Uri.parse(url);
return (await _getApiListRequest(request).andThenSuccessAsync(
(listsJson) async => listsJson
.map((json) => GroupDataMastodonExtensions.fromJson(json))
.toList()))
.mapError((error) => error is ExecError
? error
: ExecError(type: ErrorType.localError, message: error.toString()));
}
FutureResult<List<GroupData>, ExecError> getMemberGroupsForConnection(
String connectionId) async {
_logger.finest(() =>
'Getting groups (Mastodon Lists) containing connection: $connectionId');
final url = 'https://$serverName/api/v1/accounts/$connectionId/lists';
final request = Uri.parse(url);
return (await _getApiListRequest(request).andThenSuccessAsync(
(listsJson) async => listsJson
.map((json) => GroupDataMastodonExtensions.fromJson(json))
.toList()))
.mapError((error) => error as ExecError);
}
FutureResult<List<TimelineEntry>, ExecError> getUserTimeline( FutureResult<List<TimelineEntry>, ExecError> getUserTimeline(
{String userId = '', int page = 1, int count = 10}) async { {String userId = '', int page = 1, int count = 10}) async {
_logger.finest(() => 'Getting user timeline for $userId'); _logger.finest(() => 'Getting user timeline for $userId');
@ -298,7 +326,8 @@ class FriendicaClient {
return 'timelines/public'; return 'timelines/public';
case TimelineType.local: case TimelineType.local:
return 'timelines/public'; return 'timelines/public';
case TimelineType.tag: case TimelineType.group:
return 'timelines/list/${type.auxData}';
case TimelineType.profile: case TimelineType.profile:
return '/accounts/${type.auxData}/statuses'; return '/accounts/${type.auxData}/statuses';
case TimelineType.self: case TimelineType.self:
@ -311,10 +340,10 @@ class FriendicaClient {
case TimelineType.home: case TimelineType.home:
case TimelineType.global: case TimelineType.global:
case TimelineType.profile: case TimelineType.profile:
case TimelineType.group:
return ''; return '';
case TimelineType.local: case TimelineType.local:
return 'local=true'; return 'local=true';
case TimelineType.tag:
case TimelineType.self: case TimelineType.self:
throw UnimplementedError('These types are not supported yet'); throw UnimplementedError('These types are not supported yet');
} }

View file

@ -2,9 +2,26 @@ enum TimelineType {
home, home,
global, global,
local, local,
tag, group,
profile, profile,
self, self;
String toLabel() {
switch (this) {
case TimelineType.home:
return 'Home';
case TimelineType.global:
return 'Global';
case TimelineType.local:
return 'Local';
case TimelineType.group:
return 'Group';
case TimelineType.profile:
return 'Profile';
case TimelineType.self:
return 'Self';
}
}
} }
class TimelineIdentifiers { class TimelineIdentifiers {
@ -20,7 +37,8 @@ class TimelineIdentifiers {
factory TimelineIdentifiers.home() => factory TimelineIdentifiers.home() =>
TimelineIdentifiers(timeline: TimelineType.home); TimelineIdentifiers(timeline: TimelineType.home);
factory TimelineIdentifiers.profile(String profileId) => TimelineIdentifiers( factory TimelineIdentifiers.profile(String profileId) =>
TimelineIdentifiers(
timeline: TimelineType.profile, timeline: TimelineType.profile,
auxData: profileId, auxData: profileId,
); );
@ -35,10 +53,10 @@ class TimelineIdentifiers {
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
identical(this, other) || identical(this, other) ||
other is TimelineIdentifiers && other is TimelineIdentifiers &&
runtimeType == other.runtimeType && runtimeType == other.runtimeType &&
timeline == other.timeline && timeline == other.timeline &&
auxData == other.auxData; auxData == other.auxData;
@override @override
int get hashCode => timeline.hashCode ^ auxData.hashCode; int get hashCode => timeline.hashCode ^ auxData.hashCode;

View file

@ -0,0 +1,11 @@
class GroupData {
final String id;
final String name;
GroupData(this.id, this.name);
@override
String toString() {
return 'GroupData{id: $id, name: $name}';
}
}

View file

@ -77,57 +77,59 @@ class _EditorScreenState extends State<EditorScreen> {
body: Padding( body: Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: Center( child: Center(
child: Column( child: SingleChildScrollView(
crossAxisAlignment: CrossAxisAlignment.start, child: Column(
mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ mainAxisAlignment: MainAxisAlignment.start,
if (isComment && parentEntry != null) children: [
buildCommentPreview(context, parentEntry!), if (isComment && parentEntry != null)
TextFormField( buildCommentPreview(context, parentEntry!),
controller: spoilerController, TextFormField(
decoration: InputDecoration( controller: spoilerController,
labelText: '$statusType Spoiler Text (optional)', decoration: InputDecoration(
border: OutlineInputBorder( labelText: '$statusType Spoiler Text (optional)',
borderSide: BorderSide( border: OutlineInputBorder(
color: Theme.of(context).backgroundColor, borderSide: BorderSide(
color: Theme.of(context).backgroundColor,
),
borderRadius: BorderRadius.circular(5.0),
), ),
borderRadius: BorderRadius.circular(5.0),
), ),
), ),
), const VerticalPadding(),
const VerticalPadding(), TextFormField(
TextFormField( maxLines: 10,
maxLines: 10, controller: contentController,
controller: contentController, decoration: InputDecoration(
decoration: InputDecoration( labelText: '$statusType Content',
labelText: '$statusType Content', alignLabelWithHint: true,
alignLabelWithHint: true, border: OutlineInputBorder(
border: OutlineInputBorder( borderSide: BorderSide(
borderSide: BorderSide( color: Theme.of(context).backgroundColor,
color: Theme.of(context).backgroundColor, ),
borderRadius: BorderRadius.circular(5.0),
), ),
borderRadius: BorderRadius.circular(5.0),
), ),
), ),
), const VerticalPadding(),
const VerticalPadding(), Row(
Row( mainAxisAlignment: MainAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center, children: [
children: [ ElevatedButton(
ElevatedButton( onPressed: () async => createStatus(context, manager),
onPressed: () async => createStatus(context, manager), child: const Text('Submit'),
child: const Text('Submit'), ),
), const HorizontalPadding(),
const HorizontalPadding(), ElevatedButton(
ElevatedButton( onPressed: () {
onPressed: () { context.pop();
context.pop(); },
}, child: const Text('Cancel'),
child: const Text('Cancel'), ),
), ],
], ),
), ],
], ),
), ),
), ),
), ),

View file

@ -1,10 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
import '../controls/app_bottom_nav_bar.dart'; import '../controls/app_bottom_nav_bar.dart';
import '../controls/padding.dart';
import '../controls/timeline/timeline_panel.dart'; import '../controls/timeline/timeline_panel.dart';
import '../models/TimelineIdentifiers.dart'; import '../models/TimelineIdentifiers.dart';
import '../models/group_data.dart';
import '../services/timeline_manager.dart';
class HomeScreen extends StatefulWidget { class HomeScreen extends StatefulWidget {
const HomeScreen({super.key}); const HomeScreen({super.key});
@ -18,18 +22,58 @@ class _HomeScreenState extends State<HomeScreen> {
final postText = TextEditingController(); final postText = TextEditingController();
var currentType = TimelineType.home; var currentType = TimelineType.home;
GroupData? currentGroup;
final types = [ final types = [
TimelineType.home, TimelineType.home,
TimelineType.global, TimelineType.global,
TimelineType.local, TimelineType.local,
TimelineType.group,
]; ];
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
_logger.finest('Build'); _logger.finest('Build');
final groups = context
.watch<TimelineManager>()
.getGroups()
.getValueOrElse(() => [])
.toList();
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text('Home'), backgroundColor: Theme.of(context).canvasColor,
title: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
DropdownButton<TimelineType>(
value: currentType,
items: types
.map((e) => DropdownMenuItem<TimelineType>(
value: e,
child: Text(e.toLabel()),
))
.toList(),
onChanged: (value) {
setState(() {
currentType = value!;
});
}),
const HorizontalPadding(),
if (currentType == TimelineType.group)
DropdownButton<GroupData>(
value: currentGroup,
items: groups
.map((g) => DropdownMenuItem<GroupData>(
value: g,
child: Text(g.name),
))
.toList(),
onChanged: (value) {
setState(() {
currentGroup = value;
});
}),
],
),
actions: [ actions: [
IconButton( IconButton(
onPressed: () { onPressed: () {
@ -41,23 +85,11 @@ class _HomeScreenState extends State<HomeScreen> {
), ),
body: Column( body: Column(
children: [ children: [
DropdownButton<TimelineType>(
value: currentType,
items: types
.map((e) => DropdownMenuItem<TimelineType>(
value: e,
child: Text(e.name),
))
.toList(),
onChanged: (value) {
setState(() {
currentType = value!;
});
}),
Expanded( Expanded(
child: TimelinePanel( child: TimelinePanel(
timeline: TimelineIdentifiers( timeline: TimelineIdentifiers(
timeline: currentType, timeline: currentType,
auxData: currentGroup?.id ?? '',
), ),
)), )),
], ],

View file

@ -0,0 +1,8 @@
import '../../models/group_data.dart';
extension GroupDataMastodonExtensions on GroupData {
static GroupData fromJson(Map<String, dynamic> json) => GroupData(
json['id'],
json['title'],
);
}

View file

@ -1,11 +1,19 @@
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:result_monad/result_monad.dart'; import 'package:result_monad/result_monad.dart';
import '../globals.dart';
import '../models/connection.dart'; import '../models/connection.dart';
import '../models/exec_error.dart';
import '../models/group_data.dart';
import 'auth_service.dart';
class ConnectionsManager { class ConnectionsManager extends ChangeNotifier {
static final _logger = Logger('$ConnectionsManager');
final _connectionsById = <String, Connection>{}; final _connectionsById = <String, Connection>{};
final _connectionsByName = <String, Connection>{}; final _connectionsByName = <String, Connection>{};
final _connectionsByProfileUrl = <Uri, Connection>{}; final _connectionsByProfileUrl = <Uri, Connection>{};
final _listsForConnection = <String, List<GroupData>>{};
int get length => _connectionsById.length; int get length => _connectionsById.length;
@ -13,6 +21,7 @@ class ConnectionsManager {
_connectionsById.clear(); _connectionsById.clear();
_connectionsByName.clear(); _connectionsByName.clear();
_connectionsByProfileUrl.clear(); _connectionsByProfileUrl.clear();
_listsForConnection.clear();
} }
bool addConnection(Connection connection) { bool addConnection(Connection connection) {
@ -36,6 +45,28 @@ class ConnectionsManager {
return result; return result;
} }
Result<List<GroupData>, ExecError> getListsForUser(String id) {
final result = _listsForConnection[id] ?? [];
_refreshConnectionListData(id);
return Result.ok(result);
}
Future<void> _refreshConnectionListData(String id) async {
_logger.finest('Refreshing member list data for Connection $id');
await getIt<AuthService>()
.currentClient
.andThenAsync((client) => client.getMemberGroupsForConnection(id))
.match(
onSuccess: (lists) {
_listsForConnection[id] = lists;
notifyListeners();
},
onError: (error) {
_logger.severe('Error getting list data for $id: $error');
},
);
}
Result<Connection, String> getById(String id) { Result<Connection, String> getById(String id) {
final result = _connectionsById[id]; final result = _connectionsById[id];

View file

@ -6,8 +6,10 @@ import '../globals.dart';
import '../models/TimelineIdentifiers.dart'; import '../models/TimelineIdentifiers.dart';
import '../models/entry_tree_item.dart'; import '../models/entry_tree_item.dart';
import '../models/exec_error.dart'; import '../models/exec_error.dart';
import '../models/group_data.dart';
import '../models/timeline.dart'; import '../models/timeline.dart';
import '../models/timeline_entry.dart'; import '../models/timeline_entry.dart';
import 'auth_service.dart';
import 'entry_manager_service.dart'; import 'entry_manager_service.dart';
enum TimelineRefreshType { enum TimelineRefreshType {
@ -21,12 +23,43 @@ class TimelineManager extends ChangeNotifier {
final cachedTimelines = <TimelineIdentifiers, Timeline>{}; final cachedTimelines = <TimelineIdentifiers, Timeline>{};
final _groups = <String, GroupData>{};
void clear() { void clear() {
cachedTimelines.clear(); cachedTimelines.clear();
_groups.clear();
getIt<EntryManagerService>().clear(); getIt<EntryManagerService>().clear();
notifyListeners(); notifyListeners();
} }
Result<List<GroupData>, ExecError> getGroups() {
if (_groups.isEmpty) {
_refreshGroupData();
return Result.ok([]);
}
return Result.ok(_groups.values.toList());
}
Future<void> _refreshGroupData() async {
_logger.finest('Refreshing member group data ');
await getIt<AuthService>()
.currentClient
.andThenAsync((client) => client.getGroups())
.match(
onSuccess: (groups) {
_groups.clear();
for (final group in groups) {
_groups[group.id] = group;
}
notifyListeners();
},
onError: (error) {
_logger.severe('Error getting list data: $error');
},
);
}
FutureResult<bool, ExecError> createNewStatus(String text, FutureResult<bool, ExecError> createNewStatus(String text,
{String spoilerText = '', String inReplyToId = ''}) async { {String spoilerText = '', String inReplyToId = ''}) async {
final result = await getIt<EntryManagerService>().createNewStatus( final result = await getIt<EntryManagerService>().createNewStatus(
@ -110,7 +143,9 @@ class TimelineManager extends ChangeNotifier {
late final int highestId; late final int highestId;
switch (refreshType) { switch (refreshType) {
case TimelineRefreshType.refresh: case TimelineRefreshType.refresh:
lowestId = timeline.highestStatusId + 1; lowestId = timeline.highestStatusId == 0
? timeline.highestStatusId
: timeline.highestStatusId + 1;
highestId = 0; highestId = 0;
break; break;
case TimelineRefreshType.loadOlder: case TimelineRefreshType.loadOlder:
@ -153,16 +188,8 @@ class TimelineManager extends ChangeNotifier {
notifyListeners(); notifyListeners();
return result; return result;
} }
// All statuses get dumped into the entity mangager and get full assembled posts out of it
// Timeline keeps track of posts level only so can query timeline manager for those
// Should put backing store on timelines and entity manager so can recover from restart faster // Should put backing store on timelines and entity manager so can recover from restart faster
// Have a purge caches button to start that over from scratch // Have a purge caches button to start that over from scratch
// Should have a contacts manager with backing store as well // Should have a contacts manager with backing store as well
// Timeline view is new control that knows how to load timeline, scrolling around with refresh and get more
// Timeline Item view displays itself and children
// Has "Add Comment" value
// Has like/dislke
// Has reshare/quote reshare (if can get that working somehow)
// If our own has delete // If our own has delete
} }