mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
## The bug When you tap an item in the Activity inbox that refers to a message inside a thread (for example, someone replied to you or mentioned you in a thread reply), the app opened the channel at its top level. It did not open the thread, and it did not show you the message the notification was about. You had to hunt for the reply manually. ## The fix Activity items now keep track of two things: the root message of the thread and the specific message that triggered the notification. Tapping the item now: 1. Opens the thread detail view for that thread (instead of the channel's top level). 2. Scrolls to the specific message that triggered the notification. 3. Briefly highlights that message so it is easy to spot. This works for both direct replies and replies nested deeper in a thread, and it fetches the thread from the relay if it is not already loaded (for example, right after app launch). ## Testing - Flutter analyzer - 64 focused mobile tests covering direct and nested thread markers plus Activity navigation - Full pre-push suite (mobile, desktop, Rust, and Tauri tests) ## Manual verification Open Activity, tap a mention for a reply inside a thread, and confirm Buzz opens that thread, scrolls to the reply, and highlights it. --------- Signed-off-by: npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu <85d1fb36f341a371cee9aa12765c2068f79ff1b85e7176b02e6c4a738850f4f0@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub15w828kxsxu2684ynste0uah2jwkgatd99flt7ds4523hzm8ju6cshdr8hh <a38ea3d8d03715a3d49382f2fe76ea93ac8eada52a7ebf3615a2a3716cf2e6b1@buzz.block.builderlab.xyz> Co-authored-by: npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu <85d1fb36f341a371cee9aa12765c2068f79ff1b85e7176b02e6c4a738850f4f0@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz> Co-authored-by: npub15w828kxsxu2684ynste0uah2jwkgatd99flt7ds4523hzm8ju6cshdr8hh <a38ea3d8d03715a3d49382f2fe76ea93ac8eada52a7ebf3615a2a3716cf2e6b1@buzz.block.builderlab.xyz>
317 lines
11 KiB
Dart
317 lines
11 KiB
Dart
import 'dart:async';
|
|
|
|
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 '../../shared/relay/relay.dart';
|
|
import '../../shared/theme/theme.dart';
|
|
import '../../shared/utils/string_utils.dart';
|
|
import '../../shared/widgets/avatar_image.dart';
|
|
import '../../shared/widgets/frosted_app_bar.dart';
|
|
import '../../shared/widgets/frosted_scaffold.dart';
|
|
import '../channels/channel.dart';
|
|
import '../channels/channel_detail_page.dart';
|
|
import '../channels/channels_provider.dart';
|
|
import '../channels/dm_channel_labels.dart';
|
|
import '../channels/message_content.dart';
|
|
import '../channels/read_state/read_state_format.dart';
|
|
import '../channels/read_state/read_state_provider.dart';
|
|
import '../profile/user_cache_provider.dart';
|
|
import '../profile/user_profile.dart';
|
|
import 'activity_provider.dart';
|
|
import 'compose_drafts_provider.dart';
|
|
import 'inbox_item.dart';
|
|
import 'inbox_local_state_provider.dart';
|
|
import 'inbox_read_state.dart';
|
|
import 'reminders_provider.dart';
|
|
|
|
part 'activity_page/header_actions.dart';
|
|
part 'activity_page/inbox_row.dart';
|
|
part 'activity_page/lists.dart';
|
|
part 'activity_page/status_views.dart';
|
|
|
|
/// Conversation-oriented Activity inbox.
|
|
///
|
|
/// Matches desktop's Home inbox item design and semantics (see
|
|
/// `desktop/src/features/home/ui/InboxListPane.tsx`): full sender avatar +
|
|
/// name, contextual "Mentioned in #channel"-style label, unread dot + time,
|
|
/// message preview — while keeping mobile's list → canonical destination
|
|
/// navigation. Row taps deep-link to the represented message (oldest unread
|
|
/// for grouped conversations) rather than just opening the channel.
|
|
class ActivityPage extends HookConsumerWidget {
|
|
const ActivityPage({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final feedAsync = ref.watch(activityProvider);
|
|
final channelsAsync = ref.watch(channelsProvider);
|
|
final filter = useState(InboxFilter.all);
|
|
final unreadOnly = useState(false);
|
|
final headerTitleStyle = context.textTheme.titleMedium?.copyWith(
|
|
fontSize: 22,
|
|
fontWeight: FontWeight.w600,
|
|
);
|
|
|
|
final readState = ref.watch(readStateProvider);
|
|
final localState = ref.watch(inboxLocalStateProvider);
|
|
final drafts = ref.watch(composeDraftsProvider);
|
|
final dueReminderCount = ref.watch(dueReminderCountProvider);
|
|
final allItems = ref.watch(inboxItemsProvider);
|
|
final myPk = ref.watch(myPubkeyProvider);
|
|
|
|
// Cache the last non-empty feed so the UI doesn't flash on rebuild.
|
|
final hasLoadedOnce = useRef(false);
|
|
if (feedAsync.hasValue) hasLoadedOnce.value = true;
|
|
|
|
final channels = channelsAsync.asData?.value ?? const <Channel>[];
|
|
final channelById = {for (final c in channels) c.id: c};
|
|
|
|
int? markerOf(String contextId) => readState.effectiveTimestamp(contextId);
|
|
bool isDone(InboxItem item) => isInboxItemDone(
|
|
item,
|
|
markerOf: markerOf,
|
|
localUnreadOverrides: localState.unreadIds,
|
|
localDoneSet: localState.doneIds,
|
|
);
|
|
|
|
final visibleItems = [
|
|
for (final item in allItems)
|
|
if (matchesInboxFilter(item, filter.value) &&
|
|
(!unreadOnly.value || !isDone(item)))
|
|
item,
|
|
];
|
|
|
|
// Preload sender profiles for visible rows.
|
|
final pubkeys = visibleItems.map((i) => i.item.pubkey).toSet().toList();
|
|
ref.read(userCacheProvider.notifier).preload(pubkeys);
|
|
|
|
final unreadVisibleCount = visibleItems.where((i) => !isDone(i)).length;
|
|
|
|
void markItemRead(InboxItem item) {
|
|
final notifier = ref.read(readStateProvider.notifier);
|
|
ref
|
|
.read(inboxLocalStateProvider.notifier)
|
|
.clearUnread(groupedInboxItemIds(item));
|
|
final threadRootId = item.threadRootId;
|
|
if (threadRootId != null) {
|
|
notifier.markContextRead(
|
|
threadContextKey(threadRootId),
|
|
item.latestActivityAt,
|
|
);
|
|
final channelRead = groupedChannelReadTimestamp(item);
|
|
if (channelRead != null) {
|
|
notifier.markContextRead(
|
|
channelRead.channelId,
|
|
channelRead.timestamp,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
final channelId = item.item.channelId;
|
|
if (channelId != null) {
|
|
notifier.markContextRead(channelId, item.latestActivityAt);
|
|
ref
|
|
.read(channelsProvider.notifier)
|
|
.clearObservedUnreadCoveredByRead(channelId, item.latestActivityAt);
|
|
return;
|
|
}
|
|
ref.read(inboxLocalStateProvider.notifier).markDone(item.id);
|
|
}
|
|
|
|
void markItemUnread(InboxItem item) {
|
|
ref
|
|
.read(inboxLocalStateProvider.notifier)
|
|
.markUnread(groupedInboxItemIds(item));
|
|
}
|
|
|
|
void openItem(InboxItem item) {
|
|
final channelId = item.item.channelId;
|
|
if (channelId == null) {
|
|
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
|
|
const SnackBar(content: Text("This item isn't linked to a channel.")),
|
|
);
|
|
return;
|
|
}
|
|
final channel = channelById[channelId];
|
|
if (channel == null) {
|
|
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
|
|
const SnackBar(content: Text('Channel not found in this workspace.')),
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Deep-link to the represented message: oldest unread in the group,
|
|
// falling back to the latest event.
|
|
final readAt = resolveInboxItemReadAt(item, markerOf: markerOf);
|
|
final target = item.deepLinkTarget(readAt);
|
|
final thread = threadReferenceOf(target.tags);
|
|
final threadRootId = isBroadcastReply(target.tags)
|
|
? null
|
|
: thread.parentId;
|
|
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute<void>(
|
|
builder: (_) => ChannelDetailPage(
|
|
channel: channel,
|
|
initialMessageId: target.id,
|
|
initialThreadRootId: threadRootId,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void openDraft(ComposeDraft draft) {
|
|
final channel = channelById[draft.channelId];
|
|
if (channel == null) {
|
|
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Channel for this draft is no longer available.'),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute<void>(
|
|
builder: (_) => ChannelDetailPage(
|
|
channel: channel,
|
|
initialThreadRootId: draft.threadHeadId,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void openReminder(Reminder reminder) {
|
|
final target = reminder.target;
|
|
if (target == null) {
|
|
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
|
|
const SnackBar(
|
|
content: Text("This reminder isn't linked to a message."),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
final channel = channelById[target.channelId];
|
|
if (channel == null) {
|
|
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Channel for this reminder is no longer available.'),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute<void>(
|
|
builder: (_) => ChannelDetailPage(
|
|
channel: channel,
|
|
initialMessageId: target.eventId,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> refresh() async {
|
|
await Future.wait([
|
|
ref.read(activityProvider.notifier).refresh(),
|
|
ref.read(remindersProvider.notifier).refresh(),
|
|
]);
|
|
}
|
|
|
|
final Widget body;
|
|
if (filter.value == InboxFilter.reminders) {
|
|
body = _RemindersList(onOpen: openReminder, onRefresh: refresh);
|
|
} else if (filter.value == InboxFilter.drafts) {
|
|
body = _DraftsList(
|
|
drafts: drafts,
|
|
channelById: channelById,
|
|
myPubkey: myPk,
|
|
onOpen: openDraft,
|
|
onDelete: (draft) =>
|
|
ref.read(composeDraftsProvider.notifier).remove(draft.key),
|
|
);
|
|
} else if (feedAsync.hasError && allItems.isEmpty) {
|
|
body = _ErrorView(onRetry: refresh);
|
|
} else if (!hasLoadedOnce.value && allItems.isEmpty) {
|
|
body = const _LoadingSkeleton();
|
|
} else if (visibleItems.isEmpty) {
|
|
body = _EmptyFilterState(
|
|
filter: filter.value,
|
|
unreadOnly: unreadOnly.value,
|
|
);
|
|
} else {
|
|
// Compute the "New" boundary: index of the first unread row when the
|
|
// rows above it are read (list is newest-first, so unread rows sit on
|
|
// top; the divider marks where the unread block ends).
|
|
final firstReadIndex = visibleItems.indexWhere(isDone);
|
|
final newBoundaryIndex = !unreadOnly.value && firstReadIndex > 0
|
|
? firstReadIndex
|
|
: -1;
|
|
|
|
body = RefreshIndicator(
|
|
onRefresh: refresh,
|
|
child: ListView.builder(
|
|
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
|
|
itemCount: visibleItems.length,
|
|
itemBuilder: (context, index) {
|
|
final item = visibleItems[index];
|
|
final channel = item.item.channelId != null
|
|
? channelById[item.item.channelId]
|
|
: null;
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
if (index == newBoundaryIndex) const _NewBoundaryDivider(),
|
|
_InboxRow(
|
|
item: item,
|
|
channel: channel,
|
|
currentPubkey: myPk,
|
|
isDone: isDone(item),
|
|
onTap: () => openItem(item),
|
|
onMarkRead: () => markItemRead(item),
|
|
onMarkUnread: () => markItemUnread(item),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
return FrostedScaffold(
|
|
appBar: FrostedAppBar(
|
|
gradient: context.appColors.topSectionGradient,
|
|
title: const Text('Activity'),
|
|
titleStyle: headerTitleStyle,
|
|
actions: [
|
|
_FilterMenuButton(
|
|
filter: filter.value,
|
|
dueReminderCount: dueReminderCount,
|
|
draftCount: drafts.length,
|
|
onChanged: (f) => filter.value = f,
|
|
),
|
|
_InboxOptionsButton(
|
|
unreadOnly: unreadOnly.value,
|
|
unreadCount: unreadVisibleCount,
|
|
onUnreadOnlyChanged: (v) => unreadOnly.value = v,
|
|
onMarkAllRead: () {
|
|
for (final item in visibleItems) {
|
|
if (!isDone(item)) markItemRead(item);
|
|
}
|
|
},
|
|
),
|
|
],
|
|
),
|
|
body: SafeArea(
|
|
top: false,
|
|
child: Padding(
|
|
padding: EdgeInsets.only(
|
|
top: frostedAppBarHeight(context, titleStyle: headerTitleStyle),
|
|
),
|
|
child: body,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|