fix(mobile): handle buzz:// links (#1826)

Signed-off-by: Tom Brow <tomb@block.xyz>
Signed-off-by: npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu <85d1fb36f341a371cee9aa12765c2068f79ff1b85e7176b02e6c4a738850f4f0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Goose <opensource@block.xyz>
Co-authored-by: npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu <85d1fb36f341a371cee9aa12765c2068f79ff1b85e7176b02e6c4a738850f4f0@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Tom Brow
2026-07-14 16:00:13 -07:00
committed by GitHub
co-authored by Goose npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu
parent 1950eb114f
commit 37f65c08dc
16 changed files with 799 additions and 137 deletions
@@ -26,6 +26,18 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<!-- Handle buzz:// deep links (e.g. buzz://message?channel=…&id=…). -->
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="buzz"/>
</intent-filter>
<!-- Disable Flutter's built-in deeplink handler; app_links owns
incoming URLs so they can be routed with app state in Dart. -->
<meta-data
android:name="flutter_deeplinking_enabled"
android:value="false" />
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
+15
View File
@@ -22,8 +22,23 @@
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>com.buzz.deeplink</string>
<key>CFBundleURLSchemes</key>
<array>
<string>buzz</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>FlutterDeepLinkingEnabled</key>
<false/>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>
+9 -1
View File
@@ -8,8 +8,10 @@ import 'features/channels/unread_badge/unread_badge_provider.dart';
import 'features/home/home_page.dart';
import 'features/pairing/pairing_page.dart';
import 'features/channels/agent_activity/observer_subscription.dart';
import 'features/channels/deep_link_dispatcher.dart';
import 'features/profile/user_status_cache_provider.dart';
import 'shared/auth/auth.dart';
import 'shared/deeplink/pending_deep_link_provider.dart';
import 'shared/relay/relay.dart';
import 'shared/theme/theme.dart';
@@ -39,6 +41,10 @@ class App extends HookConsumerWidget {
ref.watch(userStatusCacheProvider);
}
// Start listening for buzz:// links immediately (even pre-auth) so a
// cold-start link survives until the authenticated UI can dispatch it.
ref.watch(pendingDeepLinkProvider);
void applyBadge(UnreadBadgeState state) {
if (state.highPriorityCount > 0) {
AppBadgePlus.updateBadge(state.highPriorityCount);
@@ -66,7 +72,9 @@ class App extends HookConsumerWidget {
loading: () => const _SplashScreen(),
error: (_, _) => const PairingPage(),
data: (state) => switch (state.status) {
AuthStatus.authenticated => const HomePage(),
AuthStatus.authenticated => const DeepLinkDispatcher(
child: HomePage(),
),
_ => const PairingPage(),
},
),
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
@@ -47,6 +48,21 @@ part 'channel_detail_page/message_bubble.dart';
part 'channel_detail_page/banners.dart';
part 'channel_detail_page/app_bar.dart';
/// Fetch deep-link targets that may be outside the loaded channel window.
Future<void> _loadDeepLinkEvents(
WidgetRef ref,
String channelId,
Set<String> eventIds,
) async {
try {
await ref
.read(channelMessagesProvider(channelId).notifier)
.loadEventsById(eventIds);
} catch (error) {
debugPrint('deep-link: failed to load target messages: $error');
}
}
/// Fetch channel members and preload their profiles into the user cache.
Future<void> _preloadMembers(WidgetRef ref, String channelId) async {
// Capture references before async gap to avoid using disposed ref.
@@ -89,8 +105,15 @@ int? _channelReadTimestamp({
class ChannelDetailPage extends HookConsumerWidget {
final Channel channel;
final String? initialMessageId;
final String? initialThreadRootId;
const ChannelDetailPage({super.key, required this.channel});
const ChannelDetailPage({
super.key,
required this.channel,
this.initialMessageId,
this.initialThreadRootId,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -135,6 +158,15 @@ class ChannelDetailPage extends HookConsumerWidget {
return null;
}, [channel.id]);
useEffect(() {
final messageId = initialMessageId;
if (messageId == null || channel.isForum) return null;
final eventIds = {messageId, ?initialThreadRootId};
final notifier = ref.read(channelMessagesProvider(channel.id).notifier);
unawaited(_loadDeepLinkEvents(ref, channel.id, eventIds));
return () => notifier.releaseDeepLinkEvents(eventIds);
}, [channel.id, initialMessageId, initialThreadRootId]);
useEffect(() {
if (!readState.isReady || readTimestamp == null) {
return null;
@@ -274,6 +306,8 @@ class ChannelDetailPage extends HookConsumerWidget {
return _MessageList(
entries: entries,
allMessages: messages,
initialMessageId: initialMessageId,
initialThreadRootId: initialThreadRootId,
channelId: channel.id,
currentPubkey: currentPubkey,
isMember: resolvedChannel.isMember,
@@ -3,6 +3,8 @@ part of '../channel_detail_page.dart';
class _MessageList extends HookConsumerWidget {
final List<MainTimelineEntry> entries;
final List<TimelineMessage> allMessages;
final String? initialMessageId;
final String? initialThreadRootId;
final String channelId;
final String? currentPubkey;
final bool isMember;
@@ -11,75 +13,119 @@ class _MessageList extends HookConsumerWidget {
const _MessageList({
required this.entries,
required this.allMessages,
required this.initialMessageId,
required this.initialThreadRootId,
required this.channelId,
required this.currentPubkey,
required this.isMember,
required this.isArchived,
});
static const _fetchOlderThreshold = 200.0;
static const _latestThreshold = 48.0;
@override
Widget build(BuildContext context, WidgetRef ref) {
// Pagination: fetch older messages when scrolling near the top.
final scrollController = useScrollController();
final itemScrollController = useMemoized(ItemScrollController.new);
final itemPositionsListener = useMemoized(ItemPositionsListener.create);
final isLoadingOlder = useState(false);
final isAtLatest = useState(true);
final hasUserScrolled = useState(false);
final latestEntryId = entries.isEmpty ? null : entries.last.message.id;
final previousLatestEntryId = useRef<String?>(null);
final didOpenInitialThread = useRef(false);
final didJumpToInitialMessage = useRef(false);
bool nearLatest() {
if (!scrollController.hasClients) return true;
return scrollController.position.pixels <= _latestThreshold;
}
void updateLatestState() {
final next = nearLatest();
if (isAtLatest.value != next) {
isAtLatest.value = next;
}
int? reversedIndexOf(String? messageId) {
if (messageId == null) return null;
final chronologicalIndex = entries.indexWhere(
(entry) => entry.message.id == messageId,
);
return chronologicalIndex < 0
? null
: entries.length - 1 - chronologicalIndex;
}
Future<void> scrollToLatest() async {
if (!scrollController.hasClients) return;
await scrollController.animateTo(
0,
if (!itemScrollController.isAttached) return;
await itemScrollController.scrollTo(
index: 0,
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
);
if (context.mounted) {
isAtLatest.value = true;
}
if (context.mounted) isAtLatest.value = true;
}
useEffect(() {
void onScroll() {
updateLatestState();
if (isLoadingOlder.value) return;
void onPositionsChanged() {
final positions = itemPositionsListener.itemPositions.value;
if (positions.isEmpty) return;
final nextIsAtLatest = positions.any(
(position) => position.index == 0 && position.itemLeadingEdge < 1,
);
if (isAtLatest.value != nextIsAtLatest) {
isAtLatest.value = nextIsAtLatest;
}
final oldestVisible = positions
.map((position) => position.index)
.reduce((a, b) => a > b ? a : b);
if (!hasUserScrolled.value ||
oldestVisible < entries.length - 3 ||
isLoadingOlder.value) {
return;
}
final notifier = ref.read(channelMessagesProvider(channelId).notifier);
if (notifier.reachedOldest) return;
// In a reversed ListView, maxScrollExtent is the oldest messages.
final pos = scrollController.position;
if (pos.pixels >= pos.maxScrollExtent - _fetchOlderThreshold) {
isLoadingOlder.value = true;
notifier.fetchOlder().whenComplete(
() => isLoadingOlder.value = false,
);
}
isLoadingOlder.value = true;
notifier.fetchOlder().whenComplete(() => isLoadingOlder.value = false);
}
scrollController.addListener(onScroll);
return () => scrollController.removeListener(onScroll);
}, [channelId, scrollController]);
itemPositionsListener.itemPositions.addListener(onPositionsChanged);
return () => itemPositionsListener.itemPositions.removeListener(
onPositionsChanged,
);
}, [channelId, entries.length, itemPositionsListener]);
useEffect(() {
if (initialThreadRootId == null || didOpenInitialThread.value) {
return null;
}
final threadHead = allMessages
.where((message) => message.id == initialThreadRootId)
.firstOrNull;
if (threadHead == null) return null;
didOpenInitialThread.value = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted) return;
updateLatestState();
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ThreadDetailPage(
threadHead: threadHead,
allMessages: allMessages,
channelId: channelId,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
initialMessageId: initialMessageId,
),
),
);
});
return null;
}, [entries.length, scrollController]);
}, [initialThreadRootId, allMessages]);
useEffect(() {
final targetIndex = reversedIndexOf(initialMessageId);
if (initialThreadRootId != null ||
targetIndex == null ||
didJumpToInitialMessage.value) {
return null;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted || !itemScrollController.isAttached) return;
itemScrollController.jumpTo(index: targetIndex, alignment: 0.35);
didJumpToInitialMessage.value = true;
});
return null;
}, [initialMessageId, initialThreadRootId, entries.length]);
useEffect(() {
final previous = previousLatestEntryId.value;
@@ -90,17 +136,11 @@ class _MessageList extends HookConsumerWidget {
!isAtLatest.value) {
return null;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted || !scrollController.hasClients) return;
scrollController.animateTo(
0,
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
);
if (context.mounted) scrollToLatest();
});
return null;
}, [latestEntryId, scrollController]);
}, [latestEntryId]);
if (entries.isEmpty) {
return Center(
@@ -142,92 +182,102 @@ class _MessageList extends HookConsumerWidget {
return Stack(
children: [
ListView.builder(
key: const ValueKey('channel-message-list'),
controller: scrollController,
reverse: true,
padding: EdgeInsets.only(
left: Grid.gutter,
right: Grid.gutter,
top: frostedAppBarHeight(context),
bottom: Grid.xxs,
),
itemCount: entries.length + (isLoadingOlder.value ? 1 : 0),
itemBuilder: (context, index) {
// Loading indicator at the top (last index in reversed list).
if (index >= entries.length) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: Grid.xs),
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (notification is ScrollStartNotification &&
notification.dragDetails != null) {
hasUserScrolled.value = true;
}
// Reversed list: index 0 = newest (bottom of screen).
final chronIdx = entries.length - 1 - index;
final entry = entries[chronIdx];
final message = entry.message;
// Day boundary check — applies to all messages including system.
final prevEntry = chronIdx > 0 ? entries[chronIdx - 1] : null;
final prevMessage = prevEntry?.message;
final showDayDivider =
prevMessage == null ||
!isSameDay(prevMessage.createdAt, message.createdAt);
final showAuthor =
!message.isSystem &&
(prevMessage == null ||
prevMessage.isSystem ||
showDayDivider ||
prevMessage.pubkey.toLowerCase() !=
message.pubkey.toLowerCase() ||
(message.createdAt - prevMessage.createdAt) > 300);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showDayDivider)
DayDivider(label: formatDayHeading(message.createdAt)),
if (message.isSystem)
_SystemMessageRow(
message: message,
channelId: channelId,
currentPubkey: currentPubkey,
allMessages: null,
isMember: isMember,
isArchived: isArchived,
)
else ...[
_MessageBubble(
message: message,
showAuthor: showAuthor,
channelNames: channelNamesMap,
currentChannelId: channelId,
currentPubkey: currentPubkey,
allMessages: allMessages,
isMember: isMember,
isArchived: isArchived,
return false;
},
child: ScrollablePositionedList.builder(
key: const ValueKey('channel-message-list'),
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener,
reverse: true,
padding: EdgeInsets.only(
left: Grid.gutter,
right: Grid.gutter,
top: frostedAppBarHeight(context),
bottom: Grid.xxs,
),
itemCount: entries.length + (isLoadingOlder.value ? 1 : 0),
itemBuilder: (context, index) {
// Loading indicator at the top (last index in reversed list).
if (index >= entries.length) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: Grid.xs),
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
if (entry.summary != null)
_ThreadSummaryRow(
summary: entry.summary!,
);
}
// Reversed list: index 0 = newest (bottom of screen).
final chronIdx = entries.length - 1 - index;
final entry = entries[chronIdx];
final message = entry.message;
// Day boundary check — applies to all messages including system.
final prevEntry = chronIdx > 0 ? entries[chronIdx - 1] : null;
final prevMessage = prevEntry?.message;
final showDayDivider =
prevMessage == null ||
!isSameDay(prevMessage.createdAt, message.createdAt);
final showAuthor =
!message.isSystem &&
(prevMessage == null ||
prevMessage.isSystem ||
showDayDivider ||
prevMessage.pubkey.toLowerCase() !=
message.pubkey.toLowerCase() ||
(message.createdAt - prevMessage.createdAt) > 300);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showDayDivider)
DayDivider(label: formatDayHeading(message.createdAt)),
if (message.isSystem)
_SystemMessageRow(
message: message,
allMessages: allMessages,
channelId: channelId,
currentPubkey: currentPubkey,
allMessages: null,
isMember: isMember,
isArchived: isArchived,
)
else ...[
_MessageBubble(
message: message,
showAuthor: showAuthor,
channelNames: channelNamesMap,
currentChannelId: channelId,
currentPubkey: currentPubkey,
allMessages: allMessages,
isMember: isMember,
isArchived: isArchived,
),
if (entry.summary != null)
_ThreadSummaryRow(
summary: entry.summary!,
message: message,
allMessages: allMessages,
channelId: channelId,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
),
],
],
],
);
},
);
},
),
),
if (!isAtLatest.value)
Positioned(
@@ -18,6 +18,8 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
bool _usingChannelWindow = false;
int _initVersion = 0;
ChannelWindowStore _windowStore = const ChannelWindowStore.empty();
final Map<String, NostrEvent> _deepLinkEvents = {};
final Set<String> _retainedDeepLinkEventIds = {};
ChannelMessagesNotifier(this.channelId);
@@ -88,10 +90,10 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
final existing = state.value ?? const <NostrEvent>[];
final existingIds = existing.map((event) => event.id).toSet();
final merged = [
final merged = _withDeepLinkEvents([
...existing,
...history.where((event) => !existingIds.contains(event.id)),
]..sort((a, b) => a.createdAt.compareTo(b.createdAt));
...history.where((event) => existingIds.add(event.id)),
]);
_lastKnownMessages = merged;
state = AsyncData(merged);
} catch (e, st) {
@@ -175,7 +177,9 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
void _handleWindowLiveEvent(NostrEvent event) {
if (!_mergeWindowEventIntoStore(event)) return;
final flattened = flattenChannelWindowEvents(_windowStore);
final flattened = _withDeepLinkEvents(
flattenChannelWindowEvents(_windowStore),
);
_lastKnownMessages = flattened;
state = AsyncData(flattened);
}
@@ -244,6 +248,62 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
bool get reachedOldest => _reachedOldest;
/// Loads specific deep-link targets that may fall outside the newest window.
Future<void> loadEventsById(Iterable<String> eventIds) async {
final ids = eventIds.where((id) => id.isNotEmpty).toSet();
if (ids.isEmpty) return;
_retainedDeepLinkEventIds.addAll(ids);
final existing = state.value ?? const <NostrEvent>[];
for (final event in existing) {
if (ids.contains(event.id)) _deepLinkEvents[event.id] = event;
}
ids.removeAll(_deepLinkEvents.keys);
if (ids.isEmpty) return;
final events = await ref
.read(relaySessionProvider.notifier)
.fetchHistory(
NostrFilter(
kinds: EventKind.channelTimelineContentKinds,
ids: ids.toList(),
limit: ids.length,
),
);
for (final event in events) {
if (event.channelId == channelId &&
_retainedDeepLinkEventIds.contains(event.id)) {
_deepLinkEvents[event.id] = event;
}
}
// Let the initial history load publish the complete timeline once it
// finishes. Publishing a target-only list here would make the UI consume
// its one-shot jump against a provisional ordering.
if (_initInFlight) return;
final merged = _withDeepLinkEvents(
state.value ?? _lastKnownMessages ?? const [],
);
_lastKnownMessages = merged;
state = AsyncData(merged);
}
/// Stops pinning deep-link-only events into subsequent window rebuilds.
void releaseDeepLinkEvents(Iterable<String> eventIds) {
for (final id in eventIds) {
_retainedDeepLinkEventIds.remove(id);
_deepLinkEvents.remove(id);
}
}
List<NostrEvent> _withDeepLinkEvents(List<NostrEvent> events) {
final ids = events.map((event) => event.id).toSet();
return [
...events,
..._deepLinkEvents.values.where((event) => ids.add(event.id)),
]..sort((a, b) => a.createdAt.compareTo(b.createdAt));
}
Future<bool> fetchOlder() async {
if (_reachedOldest || _initInFlight) return false;
@@ -258,7 +318,9 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
final page = await _fetchWindowPage(session, cursor);
_windowStore = appendOlderChannelWindow(_windowStore, page);
_reachedOldest = !channelWindowHasMore(_windowStore);
final flattened = flattenChannelWindowEvents(_windowStore);
final flattened = _withDeepLinkEvents(
flattenChannelWindowEvents(_windowStore),
);
_lastKnownMessages = flattened;
state = AsyncData(flattened);
return page.rows.isNotEmpty || page.aux.isNotEmpty;
@@ -0,0 +1,95 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/deeplink/deep_link.dart';
import '../../shared/deeplink/pending_deep_link_provider.dart';
import 'channel.dart';
import 'channel_detail_page.dart';
import 'channels_provider.dart';
/// Routes pending `buzz://message` deep links into the channel view.
///
/// Wraps the authenticated home subtree. Whenever a parsed link is parked in
/// [pendingDeepLinkProvider] and the channel list is available, this pushes
/// the target [ChannelDetailPage] on the enclosing [Navigator]. Links are
/// held (not dropped) while channels are still loading, so cold-start links
/// dispatch as soon as the first channel fetch completes.
typedef DeepLinkDestinationBuilder =
Widget Function(Channel channel, MessageDeepLink link);
class DeepLinkDispatcher extends ConsumerStatefulWidget {
final Widget child;
final DeepLinkDestinationBuilder? destinationBuilder;
const DeepLinkDispatcher({
super.key,
required this.child,
this.destinationBuilder,
});
@override
ConsumerState<DeepLinkDispatcher> createState() => _DeepLinkDispatcherState();
}
class _DeepLinkDispatcherState extends ConsumerState<DeepLinkDispatcher> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_maybeDispatch(ref.read(pendingDeepLinkProvider));
});
}
@override
Widget build(BuildContext context) {
// Re-evaluate dispatch when either a new link arrives or channels load.
ref.listen<MessageDeepLink?>(pendingDeepLinkProvider, (_, link) {
_maybeDispatch(link);
});
ref.listen<AsyncValue<List<Channel>>>(channelsProvider, (_, _) {
_maybeDispatch(ref.read(pendingDeepLinkProvider));
});
return widget.child;
}
void _maybeDispatch(MessageDeepLink? link) {
if (link == null) return;
final channels = ref.read(channelsProvider).asData?.value;
// Channels not loaded yet — keep the link parked; the channelsProvider
// listener re-attempts once data arrives.
if (channels == null) return;
ref.read(pendingDeepLinkProvider.notifier).consume();
final channel = channels
.where((c) => c.id == link.channelId)
.cast<Channel?>()
.firstOrNull;
if (channel == null) {
debugPrint(
'deep-link: channel ${link.channelId} not found in workspace; '
'dropping link',
);
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
const SnackBar(content: Text('Channel not found in this workspace')),
);
return;
}
if (!context.mounted) return;
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) =>
widget.destinationBuilder?.call(channel, link) ??
ChannelDetailPage(
channel: channel,
initialMessageId: link.messageId,
initialThreadRootId: link.threadRootId,
),
),
);
}
}
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
@@ -36,6 +37,7 @@ class ThreadDetailPage extends HookConsumerWidget {
final String? currentPubkey;
final bool isMember;
final bool isArchived;
final String? initialMessageId;
const ThreadDetailPage({
super.key,
@@ -45,6 +47,7 @@ class ThreadDetailPage extends HookConsumerWidget {
required this.currentPubkey,
required this.isMember,
required this.isArchived,
this.initialMessageId,
});
@override
@@ -73,6 +76,29 @@ class ThreadDetailPage extends HookConsumerWidget {
}
final replies = childrenByParent[threadHead.id] ?? const [];
final itemScrollController = useMemoized(ItemScrollController.new);
final didJumpToInitialMessage = useRef(false);
useEffect(() {
final messageId = initialMessageId;
// Wait for the authoritative thread query before consuming the one-shot
// jump; the fallback main-timeline list can contain only the linked reply.
if (messageId == null || fetchedReplies == null) return null;
final chronologicalIndex = replies.indexWhere(
(reply) => reply.id == messageId,
);
final targetIndex = messageId == threadHead.id
? replies.length
: chronologicalIndex < 0
? null
: replies.length - 1 - chronologicalIndex;
if (targetIndex == null || didJumpToInitialMessage.value) return null;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted || !itemScrollController.isAttached) return;
itemScrollController.jumpTo(index: targetIndex, alignment: 0.35);
didJumpToInitialMessage.value = true;
});
return null;
}, [initialMessageId, fetchedReplies, replies.length]);
final readState = ref.watch(readStateProvider);
final visibleReplyReadKey = replies
.map((reply) => '${reply.id}:${reply.createdAt}')
@@ -123,7 +149,8 @@ class ThreadDetailPage extends HookConsumerWidget {
body: Column(
children: [
Expanded(
child: ListView.builder(
child: ScrollablePositionedList.builder(
itemScrollController: itemScrollController,
// Reversed so the list opens pinned to the newest reply,
// matching the channel message list.
reverse: true,
+63
View File
@@ -0,0 +1,63 @@
/// Parsing for `buzz://` deep links.
///
/// Mirrors the desktop handler in `desktop/src-tauri/src/deep_link.rs`:
/// `buzz://message?channel=<uuid>&id=<hex>[&thread=<hex>]` references a
/// message (optionally inside a thread) in a channel. Required params that
/// are missing or empty make the link invalid — the caller never sees a
/// half-formed target.
library;
/// A parsed `buzz://message` deep link.
class MessageDeepLink {
/// Channel UUID from the `channel` query param.
final String channelId;
/// Event ID (hex) from the `id` query param.
final String messageId;
/// Optional thread root event ID from the `thread` query param.
final String? threadRootId;
const MessageDeepLink({
required this.channelId,
required this.messageId,
this.threadRootId,
});
@override
bool operator ==(Object other) =>
other is MessageDeepLink &&
other.channelId == channelId &&
other.messageId == messageId &&
other.threadRootId == threadRootId;
@override
int get hashCode => Object.hash(channelId, messageId, threadRootId);
@override
String toString() =>
'MessageDeepLink(channel: $channelId, id: $messageId, '
'thread: $threadRootId)';
}
/// Parse a `buzz://message?…` URI into a [MessageDeepLink].
///
/// Returns `null` for non-`buzz` schemes, non-`message` hosts (e.g.
/// `buzz://connect` which is desktop-only), or links missing a non-empty
/// `channel` or `id` param.
MessageDeepLink? parseMessageDeepLink(Uri uri) {
if (uri.scheme != 'buzz' || uri.host != 'message') return null;
final channel = uri.queryParameters['channel'];
final id = uri.queryParameters['id'];
if (channel == null || channel.isEmpty || id == null || id.isEmpty) {
return null;
}
final thread = uri.queryParameters['thread'];
return MessageDeepLink(
channelId: channel,
messageId: id,
threadRootId: (thread == null || thread.isEmpty) ? null : thread,
);
}
@@ -0,0 +1,52 @@
import 'dart:async';
import 'package:app_links/app_links.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'deep_link.dart';
/// Holds the most recent `buzz://message` deep link that has not been
/// dispatched yet.
///
/// Listens to [AppLinks.uriLinkStream], which delivers both the cold-start
/// link (the URL that launched the app) and links received while running.
/// Navigation cannot always happen the moment a link arrives — the user may
/// not be authenticated yet, or channels may still be loading — so the parsed
/// link is parked here and consumed by the dispatcher once the app is ready.
class PendingDeepLinkNotifier extends Notifier<MessageDeepLink?> {
@visibleForTesting
static Stream<Uri>? debugUriStreamOverride;
StreamSubscription<Uri>? _subscription;
@override
MessageDeepLink? build() {
final stream = debugUriStreamOverride ?? AppLinks().uriLinkStream;
_subscription = stream.listen(handleUri);
ref.onDispose(() {
_subscription?.cancel();
_subscription = null;
});
return null;
}
/// Parse and park an incoming URI. Unsupported links are ignored loudly.
@visibleForTesting
void handleUri(Uri uri) {
final link = parseMessageDeepLink(uri);
if (link == null) {
debugPrint('deep-link: ignoring unsupported link: $uri');
return;
}
state = link;
}
/// Clear the pending link after it has been dispatched (or dropped).
void consume() => state = null;
}
final pendingDeepLinkProvider =
NotifierProvider<PendingDeepLinkNotifier, MessageDeepLink?>(
PendingDeepLinkNotifier.new,
);
+48
View File
@@ -49,6 +49,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.2.10"
app_links:
dependency: "direct main"
description:
name: app_links
sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8"
url: "https://pub.dev"
source: hosted
version: "6.4.1"
app_links_linux:
dependency: transitive
description:
name: app_links_linux
sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81
url: "https://pub.dev"
source: hosted
version: "1.0.3"
app_links_platform_interface:
dependency: transitive
description:
name: app_links_platform_interface
sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
app_links_web:
dependency: transitive
description:
name: app_links_web
sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555
url: "https://pub.dev"
source: hosted
version: "1.0.4"
args:
dependency: transitive
description:
@@ -480,6 +512,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.6"
gtk:
dependency: transitive
description:
name: gtk
sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
highlight:
dependency: "direct main"
description:
@@ -968,6 +1008,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.28.0"
scrollable_positioned_list:
dependency: "direct main"
description:
name: scrollable_positioned_list
sha256: "1b54d5f1329a1e263269abc9e2543d90806131aa14fe7c6062a8054d57249287"
url: "https://pub.dev"
source: hosted
version: "0.3.8"
shared_preferences:
dependency: "direct main"
description:
+2
View File
@@ -30,6 +30,8 @@ dependencies:
video_player: ^2.10.1
package_info_plus: ^10.0.0
app_badge_plus: ^1.2.10
app_links: ^6.4.0
scrollable_positioned_list: ^0.3.8
dev_dependencies:
flutter_test:
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import 'package:buzz/features/channels/channel.dart';
import 'package:buzz/features/channels/channel_detail_page.dart';
import 'package:buzz/features/channels/channel_management_provider.dart';
@@ -476,14 +477,11 @@ void main() {
);
await tester.pumpAndSettle();
final listView = tester.widget<ListView>(
final listView = tester.widget<ScrollablePositionedList>(
find.byKey(const ValueKey('channel-message-list')),
);
final controller = listView.controller!;
expect(controller.position.maxScrollExtent, greaterThan(0));
controller.jumpTo(controller.position.maxScrollExtent);
await tester.pump();
listView.itemScrollController!.jumpTo(index: 39);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('channel-jump-to-latest')),
findsOneWidget,
@@ -504,7 +502,6 @@ void main() {
await tester.tap(find.byKey(const ValueKey('channel-jump-to-latest')));
await tester.pumpAndSettle();
expect(controller.position.pixels, lessThanOrEqualTo(1));
expect(findRichText('Newest live update'), findsOneWidget);
});
@@ -95,6 +95,41 @@ void main() {
},
);
test(
'waits for initial history before publishing and preserves deep-link target',
() async {
final relaySession = _RecordingRelaySessionNotifier();
final container = _buildContainer(relaySession);
addTearDown(container.dispose);
container.read(channelMessagesProvider(_channelId));
await relaySession.subscribed;
final notifier = container.read(
channelMessagesProvider(_channelId).notifier,
);
final targetLoad = notifier.loadEventsById(const ['target']);
relaySession.completeTargetHistory([_event(id: 'target', createdAt: 5)]);
await targetLoad;
expect(
container.read(channelMessagesProvider(_channelId)).isLoading,
isTrue,
);
relaySession.completeHistory([_event(id: 'history', createdAt: 10)]);
await _pumpEventQueue();
expect(
container
.read(channelMessagesProvider(_channelId))
.value
?.map((event) => event.id),
['target', 'history'],
);
},
);
test('window pagination failures return false without exhausting', () async {
final relaySession = _RecordingRelaySessionNotifier(
queryResults: [
@@ -196,6 +231,7 @@ class _RecordingRelaySessionNotifier extends RelaySessionNotifier {
final List<void Function(NostrEvent)> _listeners = [];
final Completer<void> _subscribed = Completer<void>();
final Completer<List<NostrEvent>> _history = Completer<List<NostrEvent>>();
final Queue<Completer<List<NostrEvent>>> _targetHistories = Queue();
_RecordingRelaySessionNotifier({
this.failSubscribe = false,
@@ -227,6 +263,11 @@ class _RecordingRelaySessionNotifier extends RelaySessionNotifier {
}) {
operations.add('fetch');
historyFilters.add(filter);
if (filter.ids != null) {
final completer = Completer<List<NostrEvent>>();
_targetHistories.add(completer);
return completer.future;
}
return _history.future;
}
@@ -256,6 +297,10 @@ class _RecordingRelaySessionNotifier extends RelaySessionNotifier {
}
}
void completeTargetHistory(List<NostrEvent> events) {
_targetHistories.removeFirst().complete(events);
}
void completeHistory(List<NostrEvent> events) {
if (!_history.isCompleted) {
_history.complete(events);
@@ -0,0 +1,89 @@
import 'package:buzz/features/channels/channel.dart';
import 'package:buzz/features/channels/channels_provider.dart';
import 'package:buzz/features/channels/deep_link_dispatcher.dart';
import 'package:buzz/shared/deeplink/deep_link.dart';
import 'package:buzz/shared/deeplink/pending_deep_link_provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
void main() {
testWidgets('dispatches a link that is already ready on mount', (
tester,
) async {
const link = MessageDeepLink(
channelId: 'channel-1',
messageId: 'message-2',
threadRootId: 'message-1',
);
await tester.pumpWidget(
ProviderScope(
overrides: [
pendingDeepLinkProvider.overrideWith(
() => _FakePendingDeepLinkNotifier(link),
),
channelsProvider.overrideWith(
() => _FakeChannelsNotifier(Future.value([_channel])),
),
],
child: MaterialApp(
home: DeepLinkDispatcher(
destinationBuilder: (channel, link) =>
_CapturedDestination(channel: channel, link: link),
child: const Scaffold(body: SizedBox()),
),
),
),
);
await tester.pumpAndSettle();
final destination = tester.widget<_CapturedDestination>(
find.byType(_CapturedDestination),
);
expect(destination.channel.id, 'channel-1');
expect(destination.link.messageId, 'message-2');
expect(destination.link.threadRootId, 'message-1');
});
}
final _channel = Channel(
id: 'channel-1',
name: 'general',
channelType: 'stream',
visibility: 'open',
description: 'General discussion',
createdBy: 'creator',
createdAt: DateTime(2026),
memberCount: 2,
isMember: true,
);
class _FakePendingDeepLinkNotifier extends PendingDeepLinkNotifier {
_FakePendingDeepLinkNotifier(this.link);
final MessageDeepLink link;
@override
MessageDeepLink? build() => link;
}
class _FakeChannelsNotifier extends ChannelsNotifier {
_FakeChannelsNotifier(this.channels);
final Future<List<Channel>> channels;
@override
Future<List<Channel>> build() => channels;
}
class _CapturedDestination extends StatelessWidget {
const _CapturedDestination({required this.channel, required this.link});
final Channel channel;
final MessageDeepLink link;
@override
Widget build(BuildContext context) => const SizedBox();
}
@@ -0,0 +1,63 @@
import 'package:buzz/shared/deeplink/deep_link.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('parseMessageDeepLink', () {
test('parses channel and id', () {
final link = parseMessageDeepLink(
Uri.parse('buzz://message?channel=d14cd131&id=abc123'),
);
expect(
link,
const MessageDeepLink(channelId: 'd14cd131', messageId: 'abc123'),
);
});
test('parses optional thread param', () {
final link = parseMessageDeepLink(
Uri.parse('buzz://message?channel=d14cd131&id=abc123&thread=root99'),
);
expect(link?.threadRootId, 'root99');
});
test('treats empty thread as absent', () {
final link = parseMessageDeepLink(
Uri.parse('buzz://message?channel=d14cd131&id=abc123&thread='),
);
expect(link, isNotNull);
expect(link?.threadRootId, isNull);
});
test('rejects missing channel', () {
expect(parseMessageDeepLink(Uri.parse('buzz://message?id=abc')), isNull);
});
test('rejects empty channel', () {
expect(
parseMessageDeepLink(Uri.parse('buzz://message?channel=&id=abc')),
isNull,
);
});
test('rejects missing id', () {
expect(
parseMessageDeepLink(Uri.parse('buzz://message?channel=d14cd131')),
isNull,
);
});
test('rejects non-buzz scheme', () {
expect(
parseMessageDeepLink(Uri.parse('https://message?channel=a&id=b')),
isNull,
);
});
test('rejects non-message host (connect is desktop-only)', () {
expect(
parseMessageDeepLink(Uri.parse('buzz://connect?relay=wss://x')),
isNull,
);
});
});
}