Implement FeedSource for lemmy feed view

This commit is contained in:
John Wesley 2024-02-20 23:07:22 -05:00
parent abb128016f
commit a9931f2ebd
19 changed files with 188 additions and 212 deletions

View File

@ -28,6 +28,6 @@ void main() async {
// Load user settings
final settingsController = SettingsController();
await settingsController.loadSettings();
print(settingsController.api);
runApp(MyApp(settingsController: settingsController));
}

View File

@ -7,7 +7,6 @@ import 'package:interstellar/src/api/entries.dart';
import 'package:interstellar/src/api/magazines.dart';
import 'package:interstellar/src/api/messages.dart';
import 'package:interstellar/src/api/notifications.dart';
import 'package:interstellar/src/api/oauth.dart';
import 'package:interstellar/src/api/posts.dart';
import 'package:interstellar/src/api/search.dart';
import 'package:interstellar/src/api/users.dart';
@ -19,7 +18,6 @@ class API {
final http.Client httpClient;
final String server;
final KbinAPIOAuth oauth;
final KbinAPIComments comments;
final KbinAPIDomains domains;
final APIThreads entries;
@ -34,8 +32,7 @@ class API {
this.software,
this.httpClient,
this.server,
) : oauth = KbinAPIOAuth(software, httpClient, server),
comments = KbinAPIComments(software, httpClient, server),
) : comments = KbinAPIComments(software, httpClient, server),
domains = KbinAPIDomains(software, httpClient, server),
entries = APIThreads(software, httpClient, server),
magazines = APIMagazines(software, httpClient, server),

View File

@ -23,6 +23,7 @@ class APIThreads {
Future<PostListModel> list(
FeedSource source, {
int? sourceId,
String? page,
FeedSort? sort,
List<String>? langs,
@ -31,7 +32,15 @@ class APIThreads {
switch (software) {
case ServerSoftware.kbin:
case ServerSoftware.mbin:
final path = source.getEntriesPath();
final path = switch (source) {
FeedSource.all => '/api/entries',
FeedSource.subscribed => '/api/entries/subscribed',
FeedSource.moderated => '/api/entries/moderated',
FeedSource.favorited => '/api/entries/favourited',
FeedSource.magazine => '/api/magazine/${sourceId!}/entries',
FeedSource.user => '/api/users/${sourceId!}/entries',
FeedSource.domain => '/api/users/${sourceId!}/entries',
};
final query = queryParams({
'p': page,
'sort': sort?.name,
@ -50,7 +59,17 @@ class APIThreads {
const path = '/api/v3/post/list';
final query = queryParams({
'page_cursor': page,
});
}..addAll(switch (source) {
FeedSource.all => {'type_': 'All'},
FeedSource.subscribed => {'type_': 'Subscribed'},
FeedSource.moderated => {'type_': 'ModeratorView'},
FeedSource.favorited => {'liked_only': 'true'},
FeedSource.magazine => {'community_id': sourceId!.toString()},
FeedSource.user =>
throw Exception('User source not allowed for lemmy'),
FeedSource.domain =>
throw Exception('Domain source not allowed for lemmy'),
}));
final response = await httpClient.get(Uri.https(server, path, query));

View File

@ -1,75 +1,18 @@
enum FeedSort { active, hot, newest, oldest, top, commented }
abstract class FeedSource {
String getEntriesPath();
String? getPostsPath();
enum FeedSource {
all,
subscribed,
moderated,
favorited,
magazine,
user,
domain,
}
class FeedSourceAll implements FeedSource {
const FeedSourceAll();
@override
String getEntriesPath() => '/api/entries';
@override
String getPostsPath() => '/api/posts';
}
class FeedSourceSub implements FeedSource {
const FeedSourceSub();
@override
String getEntriesPath() => '/api/entries/subscribed';
@override
String getPostsPath() => '/api/posts/subscribed';
}
class FeedSourceMod implements FeedSource {
const FeedSourceMod();
@override
String getEntriesPath() => '/api/entries/moderated';
@override
String getPostsPath() => '/api/posts/moderated';
}
class FeedSourceFav implements FeedSource {
const FeedSourceFav();
@override
String getEntriesPath() => '/api/entries/favourited';
@override
String getPostsPath() => '/api/posts/favourited';
}
class FeedSourceMagazine implements FeedSource {
final int id;
const FeedSourceMagazine(this.id);
@override
String getEntriesPath() => '/api/magazine/$id/entries';
@override
String getPostsPath() => '/api/magazine/$id/posts';
}
class FeedSourceUser implements FeedSource {
final int id;
const FeedSourceUser(this.id);
@override
String getEntriesPath() => '/api/users/$id/entries';
@override
String getPostsPath() => '/api/users/$id/posts';
}
class FeedSourceDomain implements FeedSource {
final int id;
const FeedSourceDomain(this.id);
@override
String getEntriesPath() => '/api/domain/$id/entries';
@override
String? getPostsPath() => null;
enum FeedSort {
active,
hot,
newest,
oldest,
top,
commented,
}

View File

@ -47,8 +47,14 @@ class APIMagazines {
case ServerSoftware.lemmy:
const path = '/api/v3/community/list';
final query = queryParams({
'limit': '50',
'listingType': 'All',
'sort': 'TopAll',
'page': page?.toString(),
});
final response = await httpClient.get(Uri.https(server, path));
final response = await httpClient.get(Uri.https(server, path, query));
httpErrorHandler(response, message: 'Failed to load magazines');

View File

@ -1,7 +1,6 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:interstellar/src/screens/settings/settings_controller.dart';
import 'package:interstellar/src/utils/utils.dart';
import 'package:interstellar/src/widgets/redirect_listen.dart';
@ -20,37 +19,25 @@ const oauthScopes = [
'moderate'
];
class KbinAPIOAuth {
final ServerSoftware software;
final http.Client httpClient;
final String server;
Future<String> registerOauthApp(String instanceHost) async {
const path = '/api/client';
KbinAPIOAuth(
this.software,
this.httpClient,
this.server,
final response = await http.post(
Uri.https(instanceHost, path),
headers: {
'Content-Type': 'application/json; charset=UTF-8',
},
body: jsonEncode({
'name': oauthName,
'contactEmail': oauthContact,
'public': true,
'redirectUris': [redirectUri],
'grants': oauthGrants,
'scopes': oauthScopes
}),
);
Future<String> registerApp(String instanceHost) async {
const path = '/api/client';
httpErrorHandler(response, message: 'Failed to register client');
final response = await httpClient.post(
Uri.https(instanceHost, path),
headers: {
'Content-Type': 'application/json; charset=UTF-8',
},
body: jsonEncode({
'name': oauthName,
'contactEmail': oauthContact,
'public': true,
'redirectUris': [redirectUri],
'grants': oauthGrants,
'scopes': oauthScopes
}),
);
httpErrorHandler(response, message: 'Failed to register client');
return (jsonDecode(response.body) as Map<String, dynamic>)['identifier'];
}
return (jsonDecode(response.body) as Map<String, dynamic>)['identifier'];
}

View File

@ -23,16 +23,23 @@ class APIPosts {
Future<PostListModel> list(
FeedSource source, {
int? sourceId,
String? page,
FeedSort? sort,
List<String>? langs,
bool? usePreferredLangs,
}) async {
if (source.getPostsPath() == null) {
throw Exception('Failed to load posts');
}
final path = switch (source) {
FeedSource.all => '/api/posts',
FeedSource.subscribed => '/api/posts/subscribed',
FeedSource.moderated => '/api/posts/moderated',
FeedSource.favorited => '/api/posts/favourited',
FeedSource.magazine => '/api/magazine/${sourceId!}/posts',
FeedSource.user => '/api/users/${sourceId!}/posts',
FeedSource.domain =>
throw Exception('Domain source not allowed for microblog'),
};
final path = source.getPostsPath()!;
final query = queryParams({
'p': page,
'sort': sort?.name,

View File

@ -1,7 +1,6 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:interstellar/src/models/user.dart';
import 'package:interstellar/src/utils/models.dart';
import 'package:interstellar/src/widgets/markdown_mention.dart';
part 'magazine.freezed.dart';
@ -52,31 +51,26 @@ class DetailedMagazineModel with _$DetailedMagazineModel {
required bool? isBlockedByUser,
}) = _DetailedMagazineModel;
factory DetailedMagazineModel.fromKbin(Map<String, Object?> json) {
final magazine = DetailedMagazineModel(
id: json['magazineId'] as int,
name: json['name'] as String,
title: json['title'] as String,
icon: kbinGetImageUrl(json['icon'] as Map<String, Object?>?),
description: json['description'] as String?,
rules: json['rules'] as String?,
moderators: ((json['moderators'] ?? []) as List<dynamic>)
.map((user) => UserModel.fromKbin(user as Map<String, Object?>))
.toList(),
subscriptionsCount: json['subscriptionsCount'] as int,
threadCount: json['entryCount'] as int,
threadCommentCount: json['entryCommentCount'] as int,
microblogCount: json['postCount'] as int,
microblogCommentCount: json['postCommentCount'] as int,
isAdult: json['isAdult'] as bool,
isUserSubscribed: json['isUserSubscribed'] as bool?,
isBlockedByUser: json['isBlockedByUser'] as bool?,
);
magazineMentionCache[magazine.name] = magazine;
return magazine;
}
factory DetailedMagazineModel.fromKbin(Map<String, Object?> json) =>
DetailedMagazineModel(
id: json['magazineId'] as int,
name: json['name'] as String,
title: json['title'] as String,
icon: kbinGetImageUrl(json['icon'] as Map<String, Object?>?),
description: json['description'] as String?,
rules: json['rules'] as String?,
moderators: ((json['moderators'] ?? []) as List<dynamic>)
.map((user) => UserModel.fromKbin(user as Map<String, Object?>))
.toList(),
subscriptionsCount: json['subscriptionsCount'] as int,
threadCount: json['entryCount'] as int,
threadCommentCount: json['entryCommentCount'] as int,
microblogCount: json['postCount'] as int,
microblogCommentCount: json['postCommentCount'] as int,
isAdult: json['isAdult'] as bool,
isUserSubscribed: json['isUserSubscribed'] as bool?,
isBlockedByUser: json['isBlockedByUser'] as bool?,
);
factory DetailedMagazineModel.fromLemmy(Map<String, Object?> json) {
final lemmyCommunity = json['community'] as Map<String, Object?>;
@ -100,8 +94,6 @@ class DetailedMagazineModel with _$DetailedMagazineModel {
isBlockedByUser: json['blocked'] as bool?,
);
magazineMentionCache[magazine.name] = magazine;
return magazine;
}
}

View File

@ -1,6 +1,5 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:interstellar/src/utils/models.dart';
import 'package:interstellar/src/widgets/markdown_mention.dart';
part 'user.freezed.dart';
@ -38,26 +37,20 @@ class DetailedUserModel with _$DetailedUserModel {
required bool? isBlockedByUser,
}) = _DetailedUserModel;
factory DetailedUserModel.fromKbin(Map<String, Object?> json) {
final user = DetailedUserModel(
id: json['userId'] as int,
name: json['username'] as String,
avatar: kbinGetImageUrl(json['avatar'] as Map<String, Object?>?),
cover: kbinGetImageUrl(json['cover'] as Map<String, Object?>?),
createdAt: DateTime.parse(json['createdAt'] as String),
isBot: json['isBot'] as bool,
about: json['about'] as String?,
followersCount: json['followersCount'] as int,
isFollowedByUser: json['isFollowedByUser'] as bool?,
isFollowerOfUser: json['isFollowerOfUser'] as bool?,
isBlockedByUser: json['isBlockedByUser'] as bool?,
);
userMentionCache[
user.name.startsWith('@') ? user.name.substring(1) : user.name] = user;
return user;
}
factory DetailedUserModel.fromKbin(Map<String, Object?> json) =>
DetailedUserModel(
id: json['userId'] as int,
name: json['username'] as String,
avatar: kbinGetImageUrl(json['avatar'] as Map<String, Object?>?),
cover: kbinGetImageUrl(json['cover'] as Map<String, Object?>?),
createdAt: DateTime.parse(json['createdAt'] as String),
isBot: json['isBot'] as bool,
about: json['about'] as String?,
followersCount: json['followersCount'] as int,
isFollowedByUser: json['isFollowedByUser'] as bool?,
isFollowerOfUser: json['isFollowerOfUser'] as bool?,
isBlockedByUser: json['isBlockedByUser'] as bool?,
);
}
@freezed

View File

@ -41,7 +41,8 @@ class _DomainScreenState extends State<DomainScreen> {
@override
Widget build(BuildContext context) {
return FeedScreen(
source: FeedSourceDomain(widget.domainId),
source: FeedSource.domain,
sourceId: widget.domainId,
title: _data?.name ?? '',
details: _data != null
? Padding(

View File

@ -12,42 +12,47 @@ class ExploreScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 3,
length: context.watch<SettingsController>().serverSoftware ==
ServerSoftware.lemmy
? 2
: 4,
child: Scaffold(
appBar: AppBar(
title: Text(
'Explore ${context.watch<SettingsController>().instanceHost}'),
actions: [
IconButton(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const SearchScreen(),
),
);
},
icon: const Icon(Icons.search))
],
bottom: const TabBar(tabs: [
Tab(
bottom: TabBar(tabs: [
const Tab(
text: 'Magazines',
icon: Icon(Icons.article),
),
Tab(
text: 'People',
icon: Icon(Icons.account_circle),
),
Tab(
text: 'Domains',
icon: Icon(Icons.public),
if (context.watch<SettingsController>().serverSoftware !=
ServerSoftware.lemmy)
const Tab(
text: 'People',
icon: Icon(Icons.account_circle),
),
if (context.watch<SettingsController>().serverSoftware !=
ServerSoftware.lemmy)
const Tab(
text: 'Domains',
icon: Icon(Icons.public),
),
const Tab(
text: 'Search',
icon: Icon(Icons.search),
),
]),
),
body: const TabBarView(
body: TabBarView(
children: [
MagazinesScreen(),
UsersScreen(),
DomainsScreen(),
const MagazinesScreen(),
if (context.watch<SettingsController>().serverSoftware !=
ServerSoftware.lemmy)
const UsersScreen(),
if (context.watch<SettingsController>().serverSoftware !=
ServerSoftware.lemmy)
const DomainsScreen(),
const SearchScreen(),
],
),
),

View File

@ -45,7 +45,8 @@ class _MagazineScreenState extends State<MagazineScreen> {
@override
Widget build(BuildContext context) {
return FeedScreen(
source: FeedSourceMagazine(widget.magazineId),
source: FeedSource.magazine,
sourceId: widget.magazineId,
title: _data?.name ?? '',
details: _data != null
? Padding(

View File

@ -22,8 +22,8 @@ class _MagazinesScreenState extends State<MagazinesScreen> {
KbinAPIMagazinesSort sort = KbinAPIMagazinesSort.hot;
String search = "";
final PagingController<String, DetailedMagazineModel> _pagingController =
PagingController(firstPageKey: '1');
final PagingController<int, DetailedMagazineModel> _pagingController =
PagingController(firstPageKey: 1);
@override
void initState() {
@ -32,11 +32,11 @@ class _MagazinesScreenState extends State<MagazinesScreen> {
_pagingController.addPageRequestListener(_fetchPage);
}
Future<void> _fetchPage(String pageKey) async {
Future<void> _fetchPage(int pageKey) async {
try {
final newPage =
await context.read<SettingsController>().api.magazines.list(
page: int.parse(pageKey),
page: pageKey,
filter: filter,
sort: sort,
search: search.isEmpty ? null : search,
@ -50,7 +50,14 @@ class _MagazinesScreenState extends State<MagazinesScreen> {
final newItems =
newPage.items.where((e) => !currentItemIds.contains(e.id)).toList();
_pagingController.appendPage(newItems, newPage.nextPage);
_pagingController.appendPage(
newItems,
context.read<SettingsController>().serverSoftware ==
ServerSoftware.lemmy
? (newPage.items.isEmpty ? null : pageKey + 1)
: (newPage.nextPage == null
? null
: int.parse(newPage.nextPage!)));
} catch (error, st) {
print(error);
print(st);

View File

@ -54,7 +54,8 @@ class _UserScreenState extends State<UserScreen> {
@override
Widget build(BuildContext context) {
return FeedScreen(
source: FeedSourceUser(widget.userId),
source: FeedSource.user,
sourceId: widget.userId,
title: _data?.name ?? '',
details: _data != null
? Column(

View File

@ -13,6 +13,7 @@ import 'package:provider/provider.dart';
class FeedScreen extends StatefulWidget {
final FeedSource? source;
final int? sourceId;
final String? title;
final Widget? details;
final Widget? floatingActionButton;
@ -20,6 +21,7 @@ class FeedScreen extends StatefulWidget {
const FeedScreen({
super.key,
this.source,
this.sourceId,
this.title,
this.details,
this.floatingActionButton,
@ -37,7 +39,8 @@ class _FeedScreenState extends State<FeedScreen> {
void initState() {
super.initState();
_mode = (widget.source ?? const FeedSourceAll()).getPostsPath() != null
_mode = context.read<SettingsController>().serverSoftware !=
ServerSoftware.lemmy
? context.read<SettingsController>().defaultFeedType
: PostType.thread;
_sort = widget.source == null
@ -82,7 +85,10 @@ class _FeedScreenState extends State<FeedScreen> {
),
),
actions: [
if ((widget.source ?? const FeedSourceAll()).getPostsPath() != null)
// Domain FeedSource is not available for microblogs
if (widget.source != FeedSource.domain &&
context.read<SettingsController>().serverSoftware !=
ServerSoftware.lemmy)
Padding(
padding: const EdgeInsets.only(right: 8),
child: IconButton(
@ -156,6 +162,7 @@ class _FeedScreenState extends State<FeedScreen> {
body: widget.source != null
? FeedScreenBody(
source: widget.source!,
sourceId: widget.sourceId,
sort: _sort,
mode: _mode,
details: widget.details,
@ -165,25 +172,25 @@ class _FeedScreenState extends State<FeedScreen> {
TabBarView(
children: [
FeedScreenBody(
source: const FeedSourceSub(),
source: FeedSource.subscribed,
sort: _sort,
mode: _mode,
details: widget.details,
),
FeedScreenBody(
source: const FeedSourceMod(),
source: FeedSource.moderated,
sort: _sort,
mode: _mode,
details: widget.details,
),
FeedScreenBody(
source: const FeedSourceFav(),
source: FeedSource.favorited,
sort: _sort,
mode: _mode,
details: widget.details,
),
FeedScreenBody(
source: const FeedSourceAll(),
source: FeedSource.all,
sort: _sort,
mode: _mode,
details: widget.details,
@ -191,7 +198,7 @@ class _FeedScreenState extends State<FeedScreen> {
],
),
otherwise: FeedScreenBody(
source: const FeedSourceAll(),
source: FeedSource.all,
sort: _sort,
mode: _mode,
details: widget.details,
@ -260,6 +267,7 @@ const SelectionMenu<FeedSort> feedSortSelect = SelectionMenu(
class FeedScreenBody extends StatefulWidget {
final FeedSource source;
final int? sourceId;
final FeedSort sort;
final PostType mode;
final Widget? details;
@ -267,6 +275,7 @@ class FeedScreenBody extends StatefulWidget {
const FeedScreenBody({
super.key,
required this.source,
this.sourceId,
required this.sort,
required this.mode,
this.details,
@ -292,6 +301,7 @@ class _FeedScreenBodyState extends State<FeedScreenBody> {
PostListModel newPage = await (switch (widget.mode) {
PostType.thread => context.read<SettingsController>().api.entries.list(
widget.source,
sourceId: widget.sourceId,
page: pageKey.isEmpty ? null : pageKey,
sort: widget.sort,
usePreferredLangs: whenLoggedIn(context,
@ -300,6 +310,7 @@ class _FeedScreenBodyState extends State<FeedScreenBody> {
),
PostType.microblog => context.read<SettingsController>().api.posts.list(
widget.source,
sourceId: widget.sourceId,
page: pageKey.isEmpty ? null : pageKey,
sort: widget.sort,
usePreferredLangs: whenLoggedIn(context,

View File

@ -54,7 +54,7 @@ class _LoginSelectScreenState extends State<LoginSelectScreen> {
await context
.read<SettingsController>()
.setServer(software, _instanceHostController.text);
.saveServer(software, _instanceHostController.text);
// Check BuildContext
if (!mounted) return;

View File

@ -5,6 +5,7 @@ import 'package:http/http.dart' as http;
import 'package:interstellar/src/api/api.dart';
import 'package:interstellar/src/api/comments.dart';
import 'package:interstellar/src/api/feed_source.dart';
import 'package:interstellar/src/api/oauth.dart';
import 'package:interstellar/src/models/post.dart';
import 'package:interstellar/src/utils/jwt_http_client.dart';
import 'package:interstellar/src/utils/themes.dart';
@ -137,7 +138,7 @@ class SettingsController with ChangeNotifier {
as Map<String, dynamic>)
.map((key, value) => MapEntry(key, Account.fromJson(value)));
_selectedAccount = prefs.getString('selectedAccount') ?? '@kbin.earth';
updateAPI();
await updateAPI();
notifyListeners();
}
@ -300,7 +301,9 @@ class SettingsController with ChangeNotifier {
await prefs.setString('defaultCreateLang', newDefaultCreateLang);
}
Future<void> setServer(ServerSoftware software, String server) async {
Future<void> saveServer(ServerSoftware software, String server) async {
if (_servers.containsKey(server)) return;
_servers[server] = Server(software);
final SharedPreferences prefs = await SharedPreferences.getInstance();
@ -318,7 +321,7 @@ class SettingsController with ChangeNotifier {
throw Exception('Tried to register oauth for lemmy');
}
String oauthIdentifier = await _api.oauth.registerApp(server);
String oauthIdentifier = await registerOauthApp(server);
_servers[server] = Server(software, oauthIdentifier: oauthIdentifier);
final SharedPreferences prefs = await SharedPreferences.getInstance();

View File

@ -99,6 +99,7 @@ class SettingsScreen extends StatelessWidget {
ListTile(
title: const Text('Default Feed Type'),
leading: const Icon(Icons.tab),
enabled: controller.serverSoftware != ServerSoftware.lemmy,
onTap: () async {
controller.updateDefaultFeedType(
await feedTypeSelect.inquireSelection(

View File

@ -150,9 +150,7 @@ class MentionWidgetState extends State<MentionWidget> {
final modifier = widget.name[0];
final split = widget.name.substring(1).split('@');
final name = split[0];
final host = (split.length > 1)
? split[1]
: widget.originInstance;
final host = (split.length > 1) ? split[1] : widget.originInstance;
final cacheKey = '$name@$host';
setState(() {
@ -164,7 +162,9 @@ class MentionWidgetState extends State<MentionWidget> {
if (!userMentionCache.containsKey(cacheKey)) {
userMentionCache[cacheKey] =
await context.read<SettingsController>().api.users.getByName(
'@$name@$host',
host == context.read<SettingsController>().instanceHost
? name
: '@$name@$host',
);
}
final user = userMentionCache[cacheKey]!;
@ -183,7 +183,9 @@ class MentionWidgetState extends State<MentionWidget> {
if (!magazineMentionCache.containsKey(cacheKey)) {
magazineMentionCache[cacheKey] =
await context.read<SettingsController>().api.magazines.getByName(
'$name@$host',
host == context.read<SettingsController>().instanceHost
? name
: '$name@$host',
);
}
final magazine = magazineMentionCache[cacheKey]!;