Files
buzz/mobile/lib/features/activity/activity_page.dart
kenny lopez 2ea7367d65 fix mobile switch and inbox selection
Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-08-03 17:09:40 +01:00

520 lines
17 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/mentions/agent_identity_provider.dart';
import '../../shared/mentions/mention_tags.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/anchored_popover_menu.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart';
import '../../shared/widgets/message_author_meta.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 'feed_item.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';
const _wideInboxBreakpoint = 1000.0;
const _wideInboxMinListWidth = 300.0;
const _wideInboxMaxListWidth = 400.0;
EdgeInsets _activityScrollPadding(
BuildContext context, {
double horizontal = 0,
double top = Grid.xxs,
double bottom = Grid.xxs,
}) => EdgeInsets.fromLTRB(
horizontal,
top,
horizontal,
MediaQuery.paddingOf(context).bottom + bottom,
);
/// 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 selectedItemId = useState<String?>(null);
// Retain the event selected before its row is marked read. A grouped row
// otherwise recomputes its deep link after the read marker changes and
// opens the latest event instead of the oldest unread one the user chose.
final selectedItemTarget = useState<FeedItem?>(null);
final selectedItemForDetail = useState<InboxItem?>(null);
final isWideInbox =
MediaQuery.sizeOf(context).width >= _wideInboxBreakpoint;
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,
];
final visibleItemIdsKey = visibleItems
.map((item) => item.id)
.join('\u0000');
useEffect(() {
if (!isWideInbox || visibleItems.isEmpty) return null;
final hasSelectedItem = visibleItems.any(
(item) => item.id == selectedItemId.value,
);
final retainsSelectedDetail =
selectedItemForDetail.value?.id == selectedItemId.value;
if (!hasSelectedItem && !retainsSelectedDetail) {
selectedItemId.value = visibleItems.first.id;
selectedItemTarget.value = null;
selectedItemForDetail.value = null;
}
return null;
}, [isWideInbox, visibleItemIdsKey, filter.value, unreadOnly.value]);
final selectedItem =
visibleItems.cast<InboxItem?>().firstWhere(
(item) => item?.id == selectedItemId.value,
orElse: () => null,
) ??
selectedItemForDetail.value;
// Preload sender profiles for visible rows.
final preloadPubkeys = {
for (final item in visibleItems) item.item.pubkey.toLowerCase(),
for (final item in visibleItems)
...mentionedPubkeysFromTags(item.item.tags),
}.toList()..sort();
final preloadPubkeysKey = preloadPubkeys.join('\u0000');
useEffect(() {
ref.read(userCacheProvider.notifier).preload(preloadPubkeys);
return null;
}, [preloadPubkeysKey]);
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;
if (isWideInbox) {
selectedItemId.value = item.id;
selectedItemTarget.value = target;
selectedItemForDetail.value = item;
markItemRead(item);
return;
}
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: _activityScrollPadding(context),
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),
selected: isWideInbox && item.id == selectedItemId.value,
onTap: () => openItem(item),
onMarkRead: () => markItemRead(item),
onMarkUnread: () => markItemUnread(item),
),
],
);
},
),
);
}
Widget inboxListPane(String title) => FrostedScaffold(
backgroundColor: Colors.transparent,
appBar: FrostedAppBar(
gradient: context.appColors.topSectionGradient,
automaticallyImplyLeading: false,
title: Text(title),
titleStyle: headerTitleStyle,
actions: [
_FilterMenuButton(
filter: filter.value,
dueReminderCount: dueReminderCount,
draftCount: drafts.length,
onChanged: (nextFilter) {
selectedItemId.value = null;
selectedItemTarget.value = null;
selectedItemForDetail.value = null;
filter.value = nextFilter;
},
),
_InboxOptionsButton(
unreadOnly: unreadOnly.value,
unreadCount: unreadVisibleCount,
onUnreadOnlyChanged: (value) => unreadOnly.value = value,
onMarkAllRead: () {
for (final item in visibleItems) {
if (!isDone(item)) markItemRead(item);
}
},
),
],
),
body: SafeArea(
key: const ValueKey('activity-content-safe-area'),
top: false,
bottom: false,
child: Padding(
padding: EdgeInsets.only(
top: frostedAppBarHeight(context, titleStyle: headerTitleStyle),
),
child: body,
),
),
);
if (isWideInbox) {
FeedItem? detailTarget(InboxItem? inboxItem) {
if (inboxItem == null) return null;
return inboxItem.deepLinkTarget(
resolveInboxItemReadAt(inboxItem, markerOf: markerOf),
);
}
final selectedTarget =
selectedItemTarget.value ?? detailTarget(selectedItem);
final selectedChannelId = selectedTarget?.channelId;
final selectedChannel = selectedChannelId == null
? null
: channelById[selectedChannelId];
final selectedThread = selectedTarget == null
? null
: isBroadcastReply(selectedTarget.tags)
? null
: threadReferenceOf(selectedTarget.tags).parentId;
return LayoutBuilder(
builder: (context, constraints) {
final listWidth = (constraints.maxWidth / 3)
.clamp(_wideInboxMinListWidth, _wideInboxMaxListWidth)
.toDouble();
return Row(
children: [
SizedBox(
key: const Key('wide-activity-inbox-list'),
width: listWidth,
child: inboxListPane('Inbox'),
),
VerticalDivider(width: 1, color: context.colors.outlineVariant),
Expanded(
child: _WideActivityDetail(
item: selectedItem,
channel: selectedChannel,
initialMessageId: selectedTarget?.id,
initialThreadRootId: selectedThread,
),
),
],
);
},
);
}
return inboxListPane('Activity');
}
}
class _WideActivityDetail extends HookWidget {
final InboxItem? item;
final Channel? channel;
final String? initialMessageId;
final String? initialThreadRootId;
const _WideActivityDetail({
required this.item,
required this.channel,
required this.initialMessageId,
required this.initialThreadRootId,
});
@override
Widget build(BuildContext context) {
if (item == null || channel == null || initialMessageId == null) {
return const _WideActivityEmptyDetail();
}
final navigatorKey = useMemoized(GlobalKey<NavigatorState>.new, [item!.id]);
return NavigatorPopHandler(
key: ValueKey('wide-activity-detail-${item!.id}'),
onPopWithResult: (_) => navigatorKey.currentState?.maybePop(),
child: Navigator(
key: navigatorKey,
onGenerateRoute: (_) => MaterialPageRoute<void>(
builder: (_) => ChannelDetailPage(
channel: channel!,
initialMessageId: initialMessageId,
initialThreadRootId: initialThreadRootId,
),
),
),
);
}
}
class _WideActivityEmptyDetail extends StatelessWidget {
const _WideActivityEmptyDetail();
@override
Widget build(BuildContext context) {
return ColoredBox(
color: context.colors.surface,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.inbox,
color: context.colors.onSurfaceVariant,
size: 32,
),
const SizedBox(height: Grid.sm),
Text(
'Select an inbox item',
style: context.textTheme.titleMedium?.copyWith(
color: context.colors.onSurface,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: Grid.xxs),
Text(
'Its conversation will open here.',
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
),
),
);
}
}