mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Goose
npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu
parent
1950eb114f
commit
37f65c08dc
@@ -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 -->
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user