diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 10adac881d..8271529297 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -15,6 +15,7 @@ import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; import '../forum/forum_posts_view.dart'; import 'channel.dart'; +import 'channel_link_navigation.dart'; import 'agent_activity/working_bots_provider.dart'; import 'channel_management_provider.dart'; import 'channel_messages_provider.dart'; diff --git a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart index 0226316f71..340210b14e 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart @@ -115,24 +115,11 @@ class _MessageBubble extends ConsumerWidget { channelNames: channelNames, tags: message.tags, onChannelTap: (channelId) { - if (channelId == currentChannelId) return; - final channelsAsync = ref.read(channelsProvider); - final channels = channelsAsync.hasValue - ? channelsAsync.value - : null; - Channel? targetChannel; - for (final channel in channels ?? const []) { - if (channel.id == channelId) { - targetChannel = channel; - break; - } - } - if (targetChannel == null) return; - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => - ChannelDetailPage(channel: targetChannel!), - ), + openChannelLink( + context: context, + ref: ref, + channelId: channelId, + currentChannelId: currentChannelId, ); }, ), diff --git a/mobile/lib/features/channels/channel_link_navigation.dart b/mobile/lib/features/channels/channel_link_navigation.dart new file mode 100644 index 0000000000..7aa32c4df4 --- /dev/null +++ b/mobile/lib/features/channels/channel_link_navigation.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import 'channel.dart'; +import 'channel_detail_page.dart'; +import 'channels_provider.dart'; + +void openChannelLink({ + required BuildContext context, + required WidgetRef ref, + required String channelId, + required String currentChannelId, +}) { + if (channelId == currentChannelId) return; + + final channelsAsync = ref.read(channelsProvider); + final channels = channelsAsync.hasValue ? channelsAsync.value : null; + Channel? targetChannel; + for (final channel in channels ?? const []) { + if (channel.id == channelId) { + targetChannel = channel; + break; + } + } + + if (targetChannel == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Channel could not be opened')), + ); + return; + } + + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ChannelDetailPage(channel: targetChannel!), + ), + ); +} diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 711508d734..fb69d9feaf 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -8,6 +8,7 @@ import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; +import 'channel_link_navigation.dart'; import 'channel_typing_provider.dart'; import 'thread_replies_provider.dart'; import 'channels_provider.dart'; @@ -468,7 +469,14 @@ class _ThreadMessage extends ConsumerWidget { mentionNames: mentionNames, channelNames: channelNames, tags: message.tags, - onChannelTap: (_) {}, + onChannelTap: (targetChannelId) { + openChannelLink( + context: context, + ref: ref, + channelId: targetChannelId, + currentChannelId: channelId, + ); + }, ), if (message.reactions.isNotEmpty) ReactionRow( diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 07ed3bca21..0d504afc0f 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -10,6 +10,8 @@ import 'package:buzz/features/channels/channel_detail_page.dart'; import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/features/channels/channel_messages_provider.dart'; import 'package:buzz/features/channels/channel_typing_provider.dart'; +import 'package:buzz/features/channels/thread_detail_page.dart'; +import 'package:buzz/features/channels/timeline_message.dart'; import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/features/channels/read_state/read_state_provider.dart'; import 'package:buzz/features/profile/profile_provider.dart'; @@ -1099,17 +1101,7 @@ void main() { group('Channel links', () { testWidgets('tapping a channel link opens that channel', (tester) async { - final randomChannel = Channel( - id: 'random-channel', - name: 'random', - channelType: 'stream', - visibility: 'open', - description: 'Random discussion', - createdBy: 'abc123', - createdAt: DateTime(2025), - memberCount: 3, - isMember: true, - ); + final randomChannel = _channel(id: 'random-channel', name: 'random'); final observer = _TestNavigatorObserver(); await tester.pumpWidget( @@ -1137,9 +1129,107 @@ void main() { expect(observer.pushCount, initialPushCount + 1); }); + + testWidgets('missing channel link shows an error', (tester) async { + final randomChannel = _channel(id: 'random-channel', name: 'random'); + final channelsNotifier = _FakeChannelsNotifier([ + _testChannel, + randomChannel, + ]); + + await tester.pumpWidget( + _buildTestable( + messages: [ + _textMsg( + id: 'msg1', + pubkey: 'alice', + content: 'Take this to #random', + createdAt: 1000, + ), + ], + users: { + 'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + channelsNotifier: channelsNotifier, + ), + ); + await tester.pumpAndSettle(); + + channelsNotifier.setChannels([_testChannel]); + await tester.tap(find.text('#random')); + await tester.pump(); + + expect(find.text('Channel could not be opened'), findsOneWidget); + }); + + testWidgets('tapping a channel link inside a thread opens that channel', ( + tester, + ) async { + final randomChannel = _channel(id: 'random-channel', name: 'random'); + final observer = _TestNavigatorObserver(); + + await tester.pumpWidget( + _buildTestable( + messages: [ + _textMsg( + id: 'msg1', + pubkey: 'alice', + content: 'Thread root #random', + createdAt: 1000, + ), + ], + users: { + 'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + channels: [_testChannel, randomChannel], + navigatorObservers: [observer], + ), + ); + await tester.pumpAndSettle(); + + final threadMessages = formatTimeline([ + _textMsg( + id: 'msg1', + pubkey: 'alice', + content: 'Thread root #random', + createdAt: 1000, + ), + ]); + Navigator.of(tester.element(find.byType(ChannelDetailPage))).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: threadMessages.single, + allMessages: threadMessages, + channelId: _channelId, + currentPubkey: 'self', + isMember: true, + isArchived: false, + ), + ), + ); + await tester.pumpAndSettle(); + final initialPushCount = observer.pushCount; + + await tester.tap(find.text('#random').last); + await tester.pumpAndSettle(); + + expect(observer.pushCount, initialPushCount + 1); + }); }); } +Channel _channel({required String id, required String name}) => Channel( + id: id, + name: name, + channelType: 'stream', + visibility: 'open', + description: '$name discussion', + createdBy: 'abc123', + createdAt: DateTime(2025), + memberCount: 3, + isMember: true, +); + class _FakeMessagesNotifier extends ChannelMessagesNotifier { List _messages; _FakeMessagesNotifier(this._messages) : super(_channelId);