diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 20475221..fa84c4c8 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -22,6 +22,10 @@ "botAccount": "Bot Account", "newUser": "New User", "cakeDay": "Cake Day", + "description": "Description", + "isNSFW": "Is NSFW", + "isNSFL": "Is NSFL", + "public": "Public", "filter": "Filter by", "filter_all": "All", "filter_allResults": "All results", @@ -107,6 +111,7 @@ "feeds_input": "Add input", "feeds_import": "Import feed", "feeds_selectInputs": "Select inputs", + "feeds_inputs": "Inputs", "feeds_addTo": "Add to feed", "topics": "Topics", "topic": "Topic", @@ -640,6 +645,7 @@ "action_moderateLock": "Lock Post", "save": "Save", "saveChanges": "Save changes", + "saveLocal": "Save locally", "searchTheFediverse": "Search the fediverse", "languages": "Languages", "systemLanguage": "System language", diff --git a/lib/src/api/feed.dart b/lib/src/api/feed.dart index aec0d82b..f71604f3 100644 --- a/lib/src/api/feed.dart +++ b/lib/src/api/feed.dart @@ -35,14 +35,167 @@ class APIFeed { } Future get(int feedId) async { - throw UnimplementedError('Not yet implemented'); + switch (client.software) { + case ServerSoftware.mbin: + throw Exception('Feeds not available on mbin'); + + case ServerSoftware.lemmy: + throw Exception('Feeds not available on lemmy'); + + case ServerSoftware.piefed: + const path = '/feed'; + final query = {'id': feedId.toString()}; + + final response = await client.get(path, queryParams: query); + + return FeedModel.fromPiefed(response.bodyJson); + } } Future getByName(String feedName) async { - throw UnimplementedError('Not yet implemented'); + switch (client.software) { + case ServerSoftware.mbin: + throw Exception('Feeds not available on mbin'); + + case ServerSoftware.lemmy: + throw Exception('Feeds not available on lemmy'); + + case ServerSoftware.piefed: + const path = '/feed'; + final query = {'name': feedName}; + + final response = await client.get(path, queryParams: query); + + return FeedModel.fromPiefed(response.bodyJson); + } } Future subscribe(int feedId, bool state) async { - throw UnimplementedError('Not yet implemented'); + switch (client.software) { + case ServerSoftware.mbin: + throw Exception('Feeds not available on mbin'); + + case ServerSoftware.lemmy: + throw Exception('Feeds not available on lemmy'); + + case ServerSoftware.piefed: + const path = '/feed/follow'; + + final response = await client.post( + path, + body: {'feed_id': feedId, 'follow': state}, + ); + + return FeedModel.fromPiefed(response.bodyJson); + } + } + + Future edit({ + required int feedId, + String? title, + String? description, + String? iconUrl, + String? bannerUrl, + bool? nsfw, + bool? nsfl, + bool? public, + bool? instanceFeed, + bool? showChildPosts, + int? parentId, + List? communities, + }) async { + switch (client.software) { + case ServerSoftware.mbin: + throw Exception('Feeds not available on mbin'); + + case ServerSoftware.lemmy: + throw Exception('Feeds not available on lemmy'); + + case ServerSoftware.piefed: + const path = '/feed'; + + final response = await client.put( + path, + body: { + 'feed_id': feedId, + 'title': ?title, + 'description': ?description, + 'icon_url': ?iconUrl, + 'banner_url': ?bannerUrl, + 'nsfw': ?nsfw, + 'nsfl': ?nsfl, + 'public': ?public, + 'is_instance_feed': ?instanceFeed, + 'show_child_posts': ?showChildPosts, + 'parent_feed_id': ?parentId, + 'communities': ?communities?.join('\n'), + }, + ); + + return FeedModel.fromPiefed(response.bodyJson); + } + } + + Future create({ + required String title, + String? description, + String? iconUrl, + String? bannerUrl, + bool? nsfw, + bool? nsfl, + bool? public, + bool? instanceFeed, + bool? showChildPosts, + int? parentId, + List? communities, + }) async { + switch (client.software) { + case ServerSoftware.mbin: + throw Exception('Feeds not available on mbin'); + + case ServerSoftware.lemmy: + throw Exception('Feeds not available on lemmy'); + + case ServerSoftware.piefed: + const path = '/feed'; + + final response = await client.post( + path, + body: { + 'name': title.toLowerCase(), + 'title': title, + 'description': ?description, + 'icon_url': ?iconUrl, + 'banner_url': ?bannerUrl, + 'nsfw': ?nsfw, + 'nsfl': ?nsfl, + 'public': ?public, + 'is_instance_feed': ?instanceFeed, + 'show_child_posts': ?showChildPosts, + 'parent_feed_id': ?parentId, + 'communities': ?communities?.join('\n'), + }, + ); + + return FeedModel.fromPiefed(response.bodyJson); + } + } + + Future delete({required int feedId}) async { + switch (client.software) { + case ServerSoftware.mbin: + throw Exception('Feeds not available on mbin'); + + case ServerSoftware.lemmy: + throw Exception('Feeds not available on lemmy'); + + case ServerSoftware.piefed: + const path = '/feed/delete'; + + final response = await client.post( + path, + body: {'feed_id': feedId, 'deleted': true}, + ); + } } } diff --git a/lib/src/controller/controller.dart b/lib/src/controller/controller.dart index fd186a98..acdc54f2 100644 --- a/lib/src/controller/controller.dart +++ b/lib/src/controller/controller.dart @@ -266,6 +266,8 @@ class AppController with ChangeNotifier { FeedInput(name: input.name, sourceType: input.source), ) .toSet(), + server: feed.server, + owner: feed.owner, ), ), ), @@ -810,7 +812,13 @@ class AppController with ChangeNotifier { await database .into(database.feeds) - .insertOnConflictUpdate(FeedsCompanion.insert(name: name)); + .insertOnConflictUpdate( + FeedsCompanion.insert( + name: name, + server: Value(value.server), + owner: Value(value.owner), + ), + ); await database.transaction(() async { for (final input in value.inputs) { @@ -1003,17 +1011,15 @@ class AppController with ChangeNotifier { .get()) .firstOrNull; - if (cachedValue != null) return cachedValue.sourceId; + if (cachedValue != null && cachedValue.sourceId != null) { + return cachedValue.sourceId; + } try { final newValue = switch (source) { FeedSource.community => (await api.community.getByName(name)).id, FeedSource.user => (await api.users.getByName(name)).id, - FeedSource.feed => - name.split(':').last != - instanceHost // tmp until proper getByName method can be made - ? throw Exception('Wrong instance') - : int.parse(name.split(':').first), + FeedSource.feed => (await api.feed.getByName(normalisedName)).id, FeedSource.topic => name.split(':').last != instanceHost // tmp until proper getByName method can be made diff --git a/lib/src/controller/database/database.dart b/lib/src/controller/database/database.dart index 6c623d83..f5771fa5 100644 --- a/lib/src/controller/database/database.dart +++ b/lib/src/controller/database/database.dart @@ -82,6 +82,8 @@ class Accounts extends Table { @DataClassName('RawFeed') class Feeds extends Table { TextColumn get name => text().withLength(min: 1)(); + BoolColumn get server => boolean().withDefault(const Constant(false))(); + TextColumn get owner => text().nullable()(); @override Set> get primaryKey => {name}; @@ -492,6 +494,9 @@ class InterstellarDatabase extends _$InterstellarDatabase { await m.dropColumn(schema.profiles, 'compact_mode'); await m.dropColumn(schema.profiles, 'show_posts_cards'); + await m.addColumn(schema.feeds, schema.feeds.server); + await m.addColumn(schema.feeds, schema.feeds.owner); + await m.addColumn(schema.accounts, schema.accounts.unifiedpushKey); await m.addColumn(schema.accounts, schema.accounts.unifiedpushToken); await m.create(schema.accountUnifiedpushToken); diff --git a/lib/src/controller/database/database.steps.dart b/lib/src/controller/database/database.steps.dart index 5c1dcb80..d7874ef3 100644 --- a/lib/src/controller/database/database.steps.dart +++ b/lib/src/controller/database/database.steps.dart @@ -1356,13 +1356,13 @@ final class Schema3 extends i0.VersionedSchema { ), alias: null, ); - late final Shape1 feeds = Shape1( + late final Shape14 feeds = Shape14( source: i0.VersionedTable( entityName: 'feeds', withoutRowId: false, isStrict: false, tableConstraints: ['PRIMARY KEY(name)'], - columns: [_column_4], + columns: [_column_4, _column_99, _column_100], attachedDatabase: database, ), alias: null, @@ -1422,7 +1422,7 @@ final class Schema3 extends i0.VersionedSchema { ), alias: null, ); - late final Shape14 profiles = Shape14( + late final Shape15 profiles = Shape15( source: i0.VersionedTable( entityName: 'profiles', withoutRowId: false, @@ -1445,12 +1445,12 @@ final class Schema3 extends i0.VersionedSchema { _column_31, _column_32, _column_33, - _column_99, + _column_101, _column_34, _column_35, _column_36, _column_37, - _column_100, + _column_102, _column_39, _column_40, _column_41, @@ -1502,7 +1502,7 @@ final class Schema3 extends i0.VersionedSchema { ), alias: null, ); - late final Shape15 miscCache = Shape15( + late final Shape16 miscCache = Shape16( source: i0.VersionedTable( entityName: 'misc_cache', withoutRowId: false, @@ -1522,8 +1522,8 @@ final class Schema3 extends i0.VersionedSchema { _column_86, _column_87, _column_88, - _column_101, - _column_102, + _column_103, + _column_104, ], attachedDatabase: database, ), @@ -1603,6 +1603,34 @@ i1.GeneratedColumn _column_98(String aliasedName) => class Shape14 extends i0.VersionedTable { Shape14({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get name => + columnsByName['name']! as i1.GeneratedColumn; + i1.GeneratedColumn get server => + columnsByName['server']! as i1.GeneratedColumn; + i1.GeneratedColumn get owner => + columnsByName['owner']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_99(String aliasedName) => + i1.GeneratedColumn( + 'server', + aliasedName, + false, + type: i1.DriftSqlType.int, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (server IN (0, 1))', + defaultValue: const i1.CustomExpression('0'), + ); +i1.GeneratedColumn _column_100(String aliasedName) => + i1.GeneratedColumn( + 'owner', + aliasedName, + true, + type: i1.DriftSqlType.string, + $customConstraints: 'NULL', + ); + +class Shape15 extends i0.VersionedTable { + Shape15({required super.source, required super.alias}) : super.aliased(); i1.GeneratedColumn get name => columnsByName['name']! as i1.GeneratedColumn; i1.GeneratedColumn get autoSwitchAccount => @@ -1723,7 +1751,7 @@ class Shape14 extends i0.VersionedTable { columnsByName['show_errors']! as i1.GeneratedColumn; } -i1.GeneratedColumn _column_99(String aliasedName) => +i1.GeneratedColumn _column_101(String aliasedName) => i1.GeneratedColumn( 'default_link_action', aliasedName, @@ -1731,7 +1759,7 @@ i1.GeneratedColumn _column_99(String aliasedName) => type: i1.DriftSqlType.string, $customConstraints: 'NULL', ); -i1.GeneratedColumn _column_100(String aliasedName) => +i1.GeneratedColumn _column_102(String aliasedName) => i1.GeneratedColumn( 'post_mode', aliasedName, @@ -1740,8 +1768,8 @@ i1.GeneratedColumn _column_100(String aliasedName) => $customConstraints: 'NULL', ); -class Shape15 extends i0.VersionedTable { - Shape15({required super.source, required super.alias}) : super.aliased(); +class Shape16 extends i0.VersionedTable { + Shape16({required super.source, required super.alias}) : super.aliased(); i1.GeneratedColumn get id => columnsByName['id']! as i1.GeneratedColumn; i1.GeneratedColumn get mainProfile => @@ -1775,7 +1803,7 @@ class Shape15 extends i0.VersionedTable { columnsByName['unifiedpush_distributor_ack']! as i1.GeneratedColumn; } -i1.GeneratedColumn _column_101(String aliasedName) => +i1.GeneratedColumn _column_103(String aliasedName) => i1.GeneratedColumn( 'unifiedpush_distributor_name', aliasedName, @@ -1783,7 +1811,7 @@ i1.GeneratedColumn _column_101(String aliasedName) => type: i1.DriftSqlType.string, $customConstraints: 'NULL', ); -i1.GeneratedColumn _column_102(String aliasedName) => +i1.GeneratedColumn _column_104(String aliasedName) => i1.GeneratedColumn( 'unifiedpush_distributor_ack', aliasedName, diff --git a/lib/src/controller/database/schemas/drift_schema_v3.json b/lib/src/controller/database/schemas/drift_schema_v3.json index 55769c48..4fd6e260 100644 --- a/lib/src/controller/database/schemas/drift_schema_v3.json +++ b/lib/src/controller/database/schemas/drift_schema_v3.json @@ -123,6 +123,30 @@ } } ] + }, + { + "name": "server", + "getter_name": "server", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"server\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"server\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "owner", + "getter_name": "owner", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] } ], "is_virtual": false, @@ -1887,7 +1911,7 @@ "sql": [ { "dialect": "sqlite", - "sql": "CREATE TABLE IF NOT EXISTS \"feeds\" (\"name\" TEXT NOT NULL, PRIMARY KEY (\"name\"));" + "sql": "CREATE TABLE IF NOT EXISTS \"feeds\" (\"name\" TEXT NOT NULL, \"server\" INTEGER NOT NULL DEFAULT 0 CHECK (\"server\" IN (0, 1)), \"owner\" TEXT NULL, PRIMARY KEY (\"name\"));" } ] }, diff --git a/lib/src/controller/feed.dart b/lib/src/controller/feed.dart index 40612863..d963e6d0 100644 --- a/lib/src/controller/feed.dart +++ b/lib/src/controller/feed.dart @@ -1,5 +1,6 @@ import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:interstellar/src/api/feed_source.dart'; +import 'package:interstellar/src/models/feed.dart'; import 'package:interstellar/src/utils/utils.dart'; part 'feed.freezed.dart'; @@ -21,20 +22,41 @@ abstract class FeedInput with _$FeedInput { @freezed abstract class Feed with _$Feed { @JsonSerializable(explicitToJson: true, includeIfNull: false) - const factory Feed({required Set inputs}) = _Feed; + const factory Feed({ + required Set inputs, + required bool server, + required String? owner, + }) = _Feed; const Feed._(); factory Feed.fromJson(JsonMap json) => _$FeedFromJson(json); + factory Feed.fromModel(FeedModel feed, String instanceHost) { + return Feed( + inputs: feed.communities + .map( + (community) => FeedInput( + name: normalizeName(community.name, instanceHost), + sourceType: FeedSource.community, + serverId: community.id, + ), + ) + .toSet(), + server: true, + owner: null, + ); + } + bool get clientFeed { return !serverFeed; } bool get serverFeed { - return inputs.every( - (input) => - input.sourceType == FeedSource.feed || - input.sourceType == FeedSource.topic, - ); + return server; + // return inputs.every( + // (input) => + // input.sourceType == FeedSource.feed || + // input.sourceType == FeedSource.topic, + // ); } } diff --git a/lib/src/controller/router.dart b/lib/src/controller/router.dart index acad3475..7955e867 100644 --- a/lib/src/controller/router.dart +++ b/lib/src/controller/router.dart @@ -19,7 +19,7 @@ class AppRouter extends RootStackRouter { page: AppHome.page, path: '', children: [ - AutoRoute(page: FeedRoute.page, path: 'home'), + AutoRoute(page: HomeRoute.page, path: 'home'), AutoRoute(page: ExploreTab.page, path: 'explore'), AutoRoute(page: SelfFeed.page, path: 'account'), AutoRoute(page: InboxRoute.page, path: 'inbox'), @@ -27,6 +27,8 @@ class AppRouter extends RootStackRouter { ], ), + AutoRoute(page: FeedRoute.page, path: 'f/:feedName'), + AutoRoute(page: ThreadRoute.page, path: 'c/:communityName/thread/:id'), AutoRoute( page: MicroblogRoute.page, diff --git a/lib/src/models/feed.dart b/lib/src/models/feed.dart index 01e94b15..ec32f1bc 100644 --- a/lib/src/models/feed.dart +++ b/lib/src/models/feed.dart @@ -1,4 +1,5 @@ import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:interstellar/src/models/community.dart'; import 'package:interstellar/src/models/image.dart'; import 'package:interstellar/src/utils/models.dart'; import 'package:interstellar/src/utils/utils.dart'; @@ -45,6 +46,7 @@ abstract class FeedModel with _$FeedModel { required bool? isNSFL, required int? subscriptionCount, required int communityCount, + required List communities, required bool? public, required int? parentId, required bool? isInstanceFeed, @@ -55,6 +57,7 @@ abstract class FeedModel with _$FeedModel { required DateTime? published, required DateTime? updated, required List children, + required String? apId, }) = _FeedModel; factory FeedModel.fromPiefed(JsonMap json) { @@ -68,6 +71,9 @@ abstract class FeedModel with _$FeedModel { isNSFL: json['nsfl'] as bool?, subscriptionCount: json['subscriptions_count'] as int?, communityCount: json['communities_count']! as int, + communities: (json['communities']! as List) + .map((community) => CommunityModel.fromPiefed(community)) + .toList(), public: json['public'] as bool?, parentId: (json['parent_feed_id'] as int?) ?? (json['parent_topic_id'] as int?), @@ -87,6 +93,7 @@ abstract class FeedModel with _$FeedModel { : (json['children']! as List) .map((child) => FeedModel.fromPiefed(child)) .toList(), + apId: json['actor_id'] as String?, ); } } diff --git a/lib/src/screens/app_home.dart b/lib/src/screens/app_home.dart index f90ce48c..350a4018 100644 --- a/lib/src/screens/app_home.dart +++ b/lib/src/screens/app_home.dart @@ -138,7 +138,7 @@ class _AppHomeState extends State { onPopInvokedWithResult: _handleExit, child: AutoTabsRouter( routes: [ - FeedRoute(key: _feedKey, scrollController: _feedScrollController), + HomeRoute(key: _feedKey, scrollController: _feedScrollController), ExploreTab(key: _exploreKey, focusNode: _exploreFocusNode), SelfFeed(key: _accountKey), InboxRoute(key: _inboxKey), diff --git a/lib/src/screens/explore/community_screen.dart b/lib/src/screens/explore/community_screen.dart index 6e322343..9ee0976a 100644 --- a/lib/src/screens/explore/community_screen.dart +++ b/lib/src/screens/explore/community_screen.dart @@ -78,6 +78,7 @@ class _CommunityScreenState extends State { : '!${_data!.name}@${ac.instanceHost}'; return FeedScreen( + feedName: _data?.name ?? '', feed: FeedAggregator.fromSingleSource( name: _data?.name ?? '', source: FeedSource.community, diff --git a/lib/src/screens/explore/domain_screen.dart b/lib/src/screens/explore/domain_screen.dart index cdbd1100..4732acc8 100644 --- a/lib/src/screens/explore/domain_screen.dart +++ b/lib/src/screens/explore/domain_screen.dart @@ -47,6 +47,7 @@ class _DomainScreenState extends State { @override Widget build(BuildContext context) { return FeedScreen( + feedName: _data?.name ?? '', feed: FeedAggregator.fromSingleSource( name: _data?.name ?? '', source: FeedSource.domain, diff --git a/lib/src/screens/explore/explore_screen.dart b/lib/src/screens/explore/explore_screen.dart index ff5aede6..40222ca8 100644 --- a/lib/src/screens/explore/explore_screen.dart +++ b/lib/src/screens/explore/explore_screen.dart @@ -112,6 +112,7 @@ class _ExploreScreenState extends State { case ExploreType.topics: final newPage = await ac.api.feed.list( topics: type == ExploreType.topics, + includeCommunities: true, ); return (newPage.items, null); diff --git a/lib/src/screens/explore/explore_screen_item.dart b/lib/src/screens/explore/explore_screen_item.dart index 944f6051..2580644c 100644 --- a/lib/src/screens/explore/explore_screen_item.dart +++ b/lib/src/screens/explore/explore_screen_item.dart @@ -10,12 +10,14 @@ import 'package:interstellar/src/models/feed.dart'; import 'package:interstellar/src/models/post.dart'; import 'package:interstellar/src/models/user.dart'; import 'package:interstellar/src/screens/feed/feed_agregator.dart'; +import 'package:interstellar/src/screens/feed/feed_screen.dart'; import 'package:interstellar/src/screens/feed/post_comment.dart'; import 'package:interstellar/src/screens/feed/post_item.dart'; import 'package:interstellar/src/screens/feed/post_page.dart'; import 'package:interstellar/src/utils/utils.dart'; import 'package:interstellar/src/widgets/avatar.dart'; import 'package:interstellar/src/widgets/menus/community_menu.dart'; +import 'package:interstellar/src/widgets/menus/feed_menu.dart'; import 'package:interstellar/src/widgets/menus/user_menu.dart'; import 'package:interstellar/src/widgets/subscription_button.dart'; import 'package:interstellar/src/widgets/user_status_icons.dart'; @@ -106,7 +108,15 @@ class ExploreScreenItem extends StatelessWidget { onUpdate(newValue); }, - FeedModel _ => null, + final FeedModel i => (bool selected) async { + final newValue = await context + .read() + .api + .feed + .subscribe(i.id, selected); + + onUpdate(newValue); + }, _ => throw UnreachableError(), }; final navigate = switch (item) { @@ -131,6 +141,7 @@ class ExploreScreenItem extends StatelessWidget { ), final FeedModel i => () => context.router.push( FeedRoute( + feedName: title, feed: FeedAggregator( name: title, inputs: [ @@ -141,6 +152,7 @@ class ExploreScreenItem extends StatelessWidget { ), ], ), + details: FeedDetails(feed: i), ), ), _ => throw UnreachableError(), @@ -170,12 +182,16 @@ class ExploreScreenItem extends StatelessWidget { navigateOption: true, ), DomainModel _ => {}, - FeedModel _ => {}, + final FeedModel i => showFeedMenu( + context, + feed: i, + navigateOption: true, + ), _ => throw UnreachableError(), }, subtitle: subtitle == null ? null : Text(subtitle), trailing: button == null - ? subscriptions != null && onSubscribe != null + ? subscriptions != null ? SubscriptionButton( isSubscribed: isSubscribed, subscriptionCount: subscriptions, diff --git a/lib/src/screens/feed/feed_screen.dart b/lib/src/screens/feed/feed_screen.dart index eabcff12..cda6d537 100644 --- a/lib/src/screens/feed/feed_screen.dart +++ b/lib/src/screens/feed/feed_screen.dart @@ -3,6 +3,8 @@ import 'package:collection/collection.dart'; import 'package:expandable/expandable.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:interstellar/src/widgets/markdown/markdown.dart'; import 'package:image_picker/image_picker.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:interstellar/src/api/feed_source.dart'; @@ -10,6 +12,7 @@ import 'package:interstellar/src/controller/controller.dart'; import 'package:interstellar/src/controller/router.gr.dart'; import 'package:interstellar/src/controller/server.dart'; import 'package:interstellar/src/models/community.dart'; +import 'package:interstellar/src/models/feed.dart'; import 'package:interstellar/src/models/post.dart'; import 'package:interstellar/src/screens/feed/feed_agregator.dart'; import 'package:interstellar/src/screens/feed/nav_drawer.dart'; @@ -19,21 +22,169 @@ import 'package:interstellar/src/utils/breakpoints.dart'; import 'package:interstellar/src/utils/debouncer.dart'; import 'package:interstellar/src/utils/utils.dart'; import 'package:interstellar/src/widgets/actions.dart'; +import 'package:interstellar/src/widgets/avatar.dart'; import 'package:interstellar/src/widgets/error_page.dart'; import 'package:interstellar/src/widgets/floating_menu.dart'; import 'package:interstellar/src/widgets/hide_on_scroll.dart'; +import 'package:interstellar/src/widgets/menus/feed_menu.dart'; import 'package:interstellar/src/widgets/paging.dart'; import 'package:interstellar/src/widgets/scaffold.dart'; import 'package:interstellar/src/widgets/selection_menu.dart'; import 'package:interstellar/src/widgets/subordinate_scroll.dart'; +import 'package:interstellar/src/widgets/subscription_button.dart'; import 'package:interstellar/src/widgets/wrapper.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import 'package:visibility_detector/visibility_detector.dart'; +class FeedDetails extends StatefulWidget { + const FeedDetails({required this.feed, super.key, this.onUpdate}); + + final FeedModel feed; + final void Function(FeedModel)? onUpdate; + + @override + State createState() => _FeedDetails(); +} + +class _FeedDetails extends State { + late FeedModel _data; + + @override + void initState() { + super.initState(); + + _data = widget.feed; + } + + @override + Widget build(BuildContext context) { + final ac = context.read(); + + final globalName = _data.name.contains('@') + ? '~${_data.name}' + : '~${_data.name}@${ac.instanceHost}'; + + return Padding( + padding: const EdgeInsets.all(12), + child: LayoutBuilder( + builder: (context, constraints) { + final actions = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + SubscriptionButton( + isSubscribed: _data.subscribed, + subscriptionCount: _data.subscriptionCount, + onSubscribe: (selected) async { + final newValue = await ac.api.feed.subscribe( + _data.id, + selected, + ); + + setState(() { + _data = newValue; + }); + widget.onUpdate?.call(newValue); + }, + followMode: false, + ), + IconButton( + onPressed: () => showFeedMenu( + context, + feed: _data, + update: (newFeed) { + setState(() { + _data = newFeed; + }); + widget.onUpdate?.call(newFeed); + }, + ), + icon: const Icon(Symbols.more_vert_rounded), + ), + ], + ), + ], + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + if (_data.icon != null) + Padding( + padding: const EdgeInsets.only(right: 12), + child: Avatar(_data.icon, radius: 32), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + _data.title, + style: Theme.of(context).textTheme.titleLarge, + ), + ], + ), + InkWell( + onTap: () async { + await Clipboard.setData( + ClipboardData( + text: _data.name.contains('@') + ? '!${_data.name}' + : '!${_data.name}@${ac.instanceHost}', + ), + ); + + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l(context).copied), + duration: const Duration(seconds: 2), + ), + ); + }, + child: Text(globalName), + ), + ], + ), + ), + if (constraints.maxWidth > 600) actions, + ], + ), + if (constraints.maxWidth <= 600) actions, + if (_data.description != null) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Markdown( + _data.description!, + getNameHost(context, _data.name), + ), + ), + ], + ); + }, + ), + ); + } +} + +@RoutePage() +class HomeScreen extends FeedScreen { + const HomeScreen({super.key, super.scrollController}) + : super(feedName: 'home'); +} + @RoutePage() class FeedScreen extends StatefulWidget { const FeedScreen({ + @PathParam('feedName') required this.feedName, super.key, this.feed, this.details, @@ -41,6 +192,7 @@ class FeedScreen extends StatefulWidget { this.scrollController, }); + final String feedName; final FeedAggregator? feed; final Widget? details; final DetailedCommunityModel? createPostCommunity; diff --git a/lib/src/screens/feed/nav_drawer.dart b/lib/src/screens/feed/nav_drawer.dart index 4f027aa3..31f50633 100644 --- a/lib/src/screens/feed/nav_drawer.dart +++ b/lib/src/screens/feed/nav_drawer.dart @@ -252,7 +252,9 @@ class _NavDrawerState extends State { feed.value, ); if (!context.mounted) return; - context.router.push(FeedRoute(feed: aggregator)); + context.router.push( + FeedRoute(feedName: aggregator.name, feed: aggregator), + ); }, ), ), diff --git a/lib/src/screens/settings/feed_settings_screen.dart b/lib/src/screens/settings/feed_settings_screen.dart index b3e3c52f..86ebdef9 100644 --- a/lib/src/screens/settings/feed_settings_screen.dart +++ b/lib/src/screens/settings/feed_settings_screen.dart @@ -12,10 +12,14 @@ import 'package:interstellar/src/models/feed.dart'; import 'package:interstellar/src/models/user.dart'; import 'package:interstellar/src/screens/explore/explore_screen.dart'; import 'package:interstellar/src/screens/feed/feed_agregator.dart'; +import 'package:interstellar/src/screens/feed/feed_screen.dart'; import 'package:interstellar/src/screens/settings/about_screen.dart'; import 'package:interstellar/src/utils/utils.dart'; import 'package:interstellar/src/widgets/context_menu.dart'; +import 'package:interstellar/src/widgets/list_tile_switch.dart'; import 'package:interstellar/src/widgets/loading_button.dart'; +import 'package:interstellar/src/widgets/markdown/drafts_controller.dart'; +import 'package:interstellar/src/widgets/markdown/markdown_editor.dart'; import 'package:interstellar/src/widgets/text_editor.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; @@ -54,25 +58,66 @@ class _FeedSettingsScreenState extends State { entry.value.inputs.first.name.split('@').last == ac.instanceHost), onTap: () async { + final serverFeed = entry.value.serverFeed + ? await ac.api.feed.getByName(entry.value.inputs.first.name) + : null; + final feed = await FeedAggregator.create( ac, entry.key, ac.feeds[entry.key]!, ); if (!context.mounted) return; - context.router.push(FeedRoute(feed: feed)); + context.router.push( + FeedRoute( + feedName: feed.name, + feed: feed, + details: serverFeed == null + ? null + : FeedDetails(feed: serverFeed), + ), + ); }, trailing: Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( - onPressed: () => context.router.push( - EditFeedRoute( - feed: entry.key, - feedData: ac.feeds[entry.key], + onPressed: + entry.value.serverFeed && + entry.value.owner != ac.selectedAccount + ? null + : () => context.router.push( + EditFeedRoute( + feed: entry.key, + feedData: ac.feeds[entry.key], + ), + ), + icon: const Icon(Symbols.edit_rounded), + ), + IconButton( + onPressed: () => showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(l(context).feeds_delete), + content: Text(entry.key), + actions: [ + OutlinedButton( + onPressed: () => context.router.pop(), + child: Text(l(context).cancel), + ), + FilledButton( + onPressed: () async { + await ac.removeFeed(entry.key); + + if (!context.mounted) return; + context.router.pop(); + }, + child: Text(l(context).delete), + ), + ], ), ), - icon: const Icon(Symbols.edit_rounded), + icon: const Icon(Symbols.delete_rounded), ), IconButton( onPressed: () async { @@ -146,67 +191,8 @@ void newFeed(BuildContext context) { ContextMenuItem( title: l(context).serverFeed, subtitle: l(context).serverFeedSubtitle, - onTap: () => context.router.push( - ExploreRoute( - mode: ExploreType.feeds, - onTap: (selected, item) async { - if (item is! FeedModel) return; - - final feed = Feed( - inputs: { - FeedInput( - name: normalizeName(item.name, ac.instanceHost), - sourceType: FeedSource.feed, - serverId: item.id, - ), // TODO(olorin99): tmp until proper getByName method can be made - }, - ); - - var title = item.title; - if (ac.feeds[title] != null) { - await showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: Text(l(context).feeds_exist), - actions: [ - OutlinedButton( - onPressed: () { - context.router.pop(); - context.router.pop(); - context.router.pop(); - }, - child: Text(l(context).cancel), - ), - LoadingFilledButton( - onPressed: () async { - var num = 0; - while (ac.feeds[title] != null) { - title = '${item.title} ${num++}'; - } - context.router.pop(); - }, - label: Text(l(context).rename), - ), - LoadingFilledButton( - onPressed: () async { - context.router.pop(); - }, - label: Text(l(context).replace), - ), - ], - ); - }, - ); - } - - if (!context.mounted) return; - ac.setFeed(title, feed); - context.router.pop(); - context.router.pop(); - }, - ), - ), + onTap: () => + context.router.push(EditFeedRoute(feed: 'new', server: true)), ), ContextMenuItem( title: l(context).serverTopic, @@ -225,6 +211,8 @@ void newFeed(BuildContext context) { serverId: item.id, ), // TODO(olorin99): tmp until proper getByName method can be made }, + server: true, + owner: null, ); var title = item.title; @@ -268,7 +256,6 @@ void newFeed(BuildContext context) { if (!context.mounted) return; ac.setFeed(title, feed); context.router.pop(); - context.router.pop(); }, ), ), @@ -283,11 +270,13 @@ class EditFeedScreen extends StatefulWidget { const EditFeedScreen({ @PathParam('feed') required this.feed, this.feedData, + this.server = false, super.key, }); final String? feed; final Feed? feedData; + final bool server; @override State createState() => _EditFeedScreenState(); @@ -296,6 +285,8 @@ class EditFeedScreen extends StatefulWidget { class _EditFeedScreenState extends State { late Feed feedData; final nameController = TextEditingController(); + final descriptionController = TextEditingController(); + FeedModel? _feedModel; @override void initState() { @@ -306,8 +297,64 @@ class _EditFeedScreenState extends State { } feedData = widget.feedData == null - ? const Feed(inputs: {}) + ? const Feed(inputs: {}, server: false, owner: null) : widget.feedData!; + + if (widget.feedData != null && widget.feedData!.serverFeed) { + context + .read() + .api + .feed + .getByName(widget.feedData!.inputs.first.name) + .then( + (feed) => setState(() { + _feedModel = feed; + feedData = Feed( + inputs: feed.communities + .map( + (community) => FeedInput( + name: normalizeName( + community.name, + context.read().instanceHost, + ), + sourceType: FeedSource.community, + ), + ) + .toSet(), + server: true, + owner: feed.owner ?? false + ? context.read().selectedAccount + : null, + ); + descriptionController.text = feed.description ?? ''; + }), + ); + } else if (widget.feedData == null && widget.server) { + // create new server feed + _feedModel = const FeedModel( + id: 0, + userId: null, + title: '', + name: '', + description: null, + isNSFW: null, + isNSFL: null, + subscriptionCount: null, + communityCount: 0, + communities: [], + public: null, + parentId: null, + isInstanceFeed: null, + icon: null, + banner: null, + subscribed: null, + owner: null, + published: null, + updated: null, + children: [], + apId: null, + ); + } } void addInput(FeedInput input) { @@ -324,6 +371,83 @@ class _EditFeedScreenState extends State { }); } + Future save() async { + final ac = context.read(); + final name = nameController.text; + final description = descriptionController.text; + + // create new serverfeed + if (widget.server && widget.feedData == null) { + final feed = await ac.api.feed.create( + title: name, + description: description, + nsfw: _feedModel?.isNSFW, + nsfl: _feedModel?.isNSFL, + public: _feedModel?.public, + communities: feedData.inputs.map((input) => input.name).toList(), + ); + + await ac.setFeed( + feed.title, + Feed( + server: true, + owner: ac.selectedAccount, + inputs: { + FeedInput( + name: normalizeName(feed.name, ac.instanceHost), + sourceType: FeedSource.feed, + ), + }, + ), + ); + + if (!mounted) return; + context.router.pop(); + return; + } + + // edit existing server feed + if (_feedModel != null && (_feedModel!.owner ?? false)) { + await ac.api.feed.edit( + feedId: _feedModel!.id, + title: name, + description: description, + nsfw: _feedModel!.isNSFW, + nsfl: _feedModel!.isNSFL, + public: _feedModel!.public, + communities: feedData.inputs.map((input) => input.name).toList(), + ); + + if (!mounted) return; + context.router.pop(); + return; + } + + if (widget.feed != null && name != widget.feed) { + await ac.renameFeed(widget.feed!, name); + } + + await ac.setFeed(name, feedData); + if (!mounted) return; + context.router.pop(); + } + + Future delete() async { + final ac = context.read(); + + if (_feedModel != null) { + await ac.api.feed.delete(feedId: _feedModel!.id); + await ac.removeFeed(widget.feed!); + if (!mounted) return; + context.router.pop(); + return; + } + + await ac.removeFeed(widget.feed!); + if (!mounted) return; + context.router.pop(); + } + @override Widget build(BuildContext context) { final ac = context.watch(); @@ -341,6 +465,48 @@ class _EditFeedScreenState extends State { onChanged: (_) => setState(() {}), ), ), + if (_feedModel != null) ...[ + Padding( + padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8), + child: MarkdownEditor( + descriptionController, + label: l(context).description, + onChanged: (_) => setState(() {}), + originInstance: ac.instanceHost, + draftController: context.watch().auto( + 'feed_description:${_feedModel!.name}:${ac.instanceHost}:${_feedModel!.id}', + ), + ), + ), + ListTileSwitch( + title: Text(l(context).isNSFW), + value: _feedModel!.isNSFW ?? false, + onChanged: (newValue) => setState(() { + _feedModel = _feedModel!.copyWith(isNSFW: newValue); + }), + ), + ListTileSwitch( + title: Text(l(context).isNSFL), + value: _feedModel!.isNSFL ?? false, + onChanged: (newValue) => setState(() { + _feedModel = _feedModel!.copyWith(isNSFL: newValue); + }), + ), + ListTileSwitch( + title: Text(l(context).public), + value: _feedModel!.public ?? false, + onChanged: (newValue) => setState(() { + _feedModel = _feedModel!.copyWith(public: newValue); + }), + ), + const SizedBox(height: 16), + ], + ListTile( + title: Text( + l(context).feeds_inputs, + style: Theme.of(context).textTheme.titleMedium, + ), + ), ...feedData.inputs.map((input) { return ListTile( title: Text(input.name), @@ -408,16 +574,7 @@ class _EditFeedScreenState extends State { (nameController.text != widget.feed && ac.filterLists.containsKey(nameController.text)) ? null - : () async { - final name = nameController.text; - if (widget.feed != null && name != widget.feed) { - await ac.renameFeed(widget.feed!, name); - } - - await ac.setFeed(name, feedData); - if (!context.mounted) return; - context.router.pop(); - }, + : save, label: Text(l(context).saveChanges), ), ), @@ -426,10 +583,10 @@ class _EditFeedScreenState extends State { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: OutlinedButton.icon( icon: const Icon(Symbols.delete_rounded), - onPressed: () { - showDialog( + onPressed: () async { + showDialog( context: context, - builder: (BuildContext context) => AlertDialog( + builder: (context) => AlertDialog( title: Text(l(context).feeds_delete), content: Text(widget.feed!), actions: [ @@ -439,11 +596,10 @@ class _EditFeedScreenState extends State { ), FilledButton( onPressed: () async { - await ac.removeFeed(widget.feed!); + await delete(); if (!context.mounted) return; - context.router.pop(); - context.router.pop(); + context.router.pop(true); }, child: Text(l(context).delete), ), diff --git a/lib/src/utils/ap_urls.dart b/lib/src/utils/ap_urls.dart index d80631f2..0120d4e4 100644 --- a/lib/src/utils/ap_urls.dart +++ b/lib/src/utils/ap_urls.dart @@ -3,6 +3,7 @@ import 'package:interstellar/src/controller/controller.dart'; import 'package:interstellar/src/controller/server.dart'; import 'package:interstellar/src/models/comment.dart'; import 'package:interstellar/src/models/community.dart'; +import 'package:interstellar/src/models/feed.dart'; import 'package:interstellar/src/models/post.dart'; import 'package:interstellar/src/models/user.dart'; import 'package:interstellar/src/utils/utils.dart'; @@ -90,3 +91,15 @@ List genUserUrls(BuildContext context, UserModel user) { ?apUrl, ]; } + +List genFeedUrls(BuildContext context, FeedModel feed) { + final ac = context.read(); + + final apUrl = feed.apId == null ? null : Uri.tryParse(feed.apId!); + + return [ + if (apUrl == null || apUrl.host != ac.instanceHost) + Uri.https(ac.instanceHost, '/f/${feed.name}'), + ?apUrl, + ]; +} diff --git a/lib/src/widgets/menus/feed_menu.dart b/lib/src/widgets/menus/feed_menu.dart new file mode 100644 index 00000000..7069acf5 --- /dev/null +++ b/lib/src/widgets/menus/feed_menu.dart @@ -0,0 +1,129 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:interstellar/src/api/feed_source.dart'; +import 'package:interstellar/src/controller/controller.dart'; +import 'package:interstellar/src/controller/feed.dart'; +import 'package:interstellar/src/controller/router.gr.dart'; +import 'package:interstellar/src/models/feed.dart'; +import 'package:interstellar/src/screens/feed/feed_agregator.dart'; +import 'package:interstellar/src/screens/feed/feed_screen.dart'; +import 'package:interstellar/src/screens/settings/feed_settings_screen.dart'; +import 'package:interstellar/src/utils/ap_urls.dart'; +import 'package:interstellar/src/utils/utils.dart'; +import 'package:interstellar/src/widgets/context_menu.dart'; +import 'package:interstellar/src/widgets/star_button.dart'; +import 'package:interstellar/src/widgets/subscription_button.dart'; +import 'package:provider/provider.dart'; + +Future showFeedMenu( + BuildContext context, { + FeedModel? feed, + void Function(FeedModel)? update, + bool navigateOption = false, +}) async { + final ac = context.read(); + + if (feed == null) return; + + final globalName = feed.name.contains('@') + ? '!${feed.name}' + : '!${feed.name}@${ac.instanceHost}'; + + final isOwner = feed.owner ?? false; + + return ContextMenu( + actions: [ + ContextMenuAction( + child: SubscriptionButton( + isSubscribed: feed.subscribed, + subscriptionCount: feed.subscriptionCount, + onSubscribe: (selected) async { + final newValue = await ac.api.feed.subscribe(feed.id, selected); + + if (update != null) { + update(newValue); + } + }, + followMode: false, + ), + ), + ContextMenuAction(child: StarButton(globalName)), + ], + links: genFeedUrls(context, feed), + items: [ + if (navigateOption) + ContextMenuItem( + title: l(context).openItem(feed.name), + onTap: () => context.router.push( + FeedRoute( + feedName: feed.title, + feed: FeedAggregator( + name: feed.title, + inputs: [ + FeedInputState( + title: feed.name, + source: feed.owner == null + ? FeedSource.topic + : FeedSource.feed, + sourceId: feed.id, + ), + ], + ), + details: FeedDetails(feed: feed), + ), + ), + ), + ContextMenuItem( + title: l(context).feeds_addTo, + onTap: () async => showAddToFeedMenu( + context, + normalizeName(feed.name, ac.instanceHost), + FeedSource.feed, + ), + ), + if (feed.owner ?? false) + ContextMenuItem( + title: l(context).edit, + onTap: () => context.router.push( + EditFeedRoute( + feed: feed.name, + feedData: Feed.fromModel(feed, ac.instanceHost), + ), + ), + ), + ContextMenuItem( + title: l(context).saveLocal, + onTap: () async { + await ac.setFeed( + feed.name, + Feed( + inputs: { + FeedInput( + name: normalizeName(feed.name, ac.instanceHost), + sourceType: FeedSource.feed, + ), + }, + server: true, + owner: feed.owner ?? false ? ac.selectedAccount : null, + ), + ); + if (!context.mounted) return; + context.router.pop(); + }, + ), + ContextMenuItem( + title: l(context).communities, + subItems: feed.communities + .map( + (community) => ContextMenuItem( + title: community.name, + onTap: () => context.router.push( + CommunityRoute(communityName: community.name), + ), + ), + ) + .toList(), + ), + ], + ).openMenu(context); +}