Files
buzz/mobile/lib/features/activity/activity_page.dart
klopez4212 3a45e87b25 fix(mobile): polish Inbox controls and system notices (#8423)
Use Tabler `filter-2` in Inbox and keep the iOS filter button rounded
when its menu closes. Remove shaded containers from chat system notices
while preserving spacing, profile links, and reactions.

Validation: mobile formatting, analysis, full test suite, and iOS
Release build; independent review found no blockers. Kenny verified the
icon and menu dismissal on his iPhone.

---------

Signed-off-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-18 10:31:21 -04:00

509 lines
18 KiB
Dart

import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:buzz/shared/theme/buzz_icons.dart';
import '../../shared/identity_names/identity_names_provider.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/sheet_action_section.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/anchored_popover_menu.dart';
import '../../shared/widgets/bee_refresh_indicator.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 '../../shared/widgets/modal_presentation.dart';
import '../channels/channel.dart';
import '../channels/channel_detail_page.dart';
import '../channels/channel_identity_names_provider.dart';
import '../channels/channel_management_provider.dart';
import '../channels/channels_provider.dart';
import '../channels/dm_channel_labels.dart';
import '../channels/message_content.dart';
import '../../shared/read_state/read_state_format.dart';
import '../../shared/read_state/read_state_provider.dart';
import '../../shared/profile/user_cache_provider.dart';
import '../../shared/profile/user_profile.dart';
import 'activity_provider.dart';
import 'compose_drafts_provider.dart';
import 'dm_resurface.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';
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({this.tabReselection, super.key});
/// Notifies this page when its already-selected tab is tapped again.
final ValueListenable<int>? tabReselection;
@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 scrollController = useScrollController();
final reducedMotion = MediaQuery.disableAnimationsOf(context);
useEffect(() {
final tabReselection = this.tabReselection;
if (tabReselection == null) return null;
void scrollToTop() {
if (!scrollController.hasClients) return;
final position = scrollController.position;
if (position.pixels <= position.minScrollExtent + 0.5) return;
if (reducedMotion) {
scrollController.jumpTo(position.minScrollExtent);
return;
}
unawaited(
scrollController.animateTo(
position.minScrollExtent,
duration: const Duration(milliseconds: 260),
curve: Curves.easeOutCubic,
),
);
}
tabReselection.addListener(scrollToTop);
return () => tabReselection.removeListener(scrollToTop);
}, [tabReselection, scrollController, reducedMotion]);
final headerTitleStyle = context.textTheme.titleMedium?.copyWith(
fontSize: 22,
fontWeight: FontWeight.w600,
color: navigationPrimaryForeground(context),
);
final topSectionHeight = frostedAppBarHeight(
context,
nativeLargeTitle: true,
titleStyle: headerTitleStyle,
bottomHeight: Grid.xxs,
);
final readState = ref.watch(readStateProvider);
final localState = ref.watch(inboxLocalStateProvider);
final drafts = ref.watch(composeDraftsProvider);
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 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));
}
Future<void> openItem(InboxItem item) async {
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;
}
var channel = channelById[channelId];
if (channel == null &&
myPk != null &&
ref.read(channelsProvider.notifier).hiddenDmIds.contains(channelId)) {
final expectedPubkey = myPk.toLowerCase();
final expectedRelayUrl = ref.read(relayConfigProvider).baseUrl;
bool isCurrentScope() =>
context.mounted &&
ref.read(myPubkeyProvider)?.toLowerCase() == expectedPubkey &&
ref.read(relayConfigProvider).baseUrl == expectedRelayUrl;
try {
final members = await ref.read(
channelMembersProvider(channelId).future,
);
if (!isCurrentScope()) return;
final peers = dmPeerPubkeysFromMembers(
members.map((member) => member.pubkey),
expectedPubkey,
);
if (peers.isEmpty) {
throw StateError('Could not determine the DM membership.');
}
final reopened = await ref
.read(channelActionsProvider)
.openDm(pubkeys: peers.toList());
if (!isCurrentScope()) return;
if (reopened.id != channelId) {
throw StateError('Relay reopened a different DM conversation.');
}
channel = reopened;
} catch (error) {
if (!isCurrentScope()) return;
if (!context.mounted) return;
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
SnackBar(content: Text('Could not reopen conversation: $error')),
);
return;
}
}
if (channel == null) {
if (!context.mounted) return;
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
const SnackBar(content: Text('Channel not found in this workspace.')),
);
return;
}
final resolvedChannel = channel;
// Deep-link to the represented message: oldest unread in the group,
// falling back to the latest event.
final target = item.deepLinkTarget(
(event) => isInboxEventRead(event, markerOf: markerOf),
);
final thread = threadReferenceOf(target.tags);
final threadRootId = isBroadcastReply(target.tags)
? null
: thread.parentId;
if (!context.mounted) return;
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ChannelDetailPage(
channel: resolvedChannel,
initialMessageId: target.id,
initialThreadRootId: threadRootId,
initialThreadRouteBehavior:
InitialThreadRouteBehavior.replaceCurrentRoute,
),
),
);
}
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,
initialThreadRouteBehavior:
InitialThreadRouteBehavior.replaceCurrentRoute,
),
),
);
}
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(),
]);
}
late final Widget body;
var bodyRidesOverTopSection = false;
if (filter.value == InboxFilter.reminders) {
body = _RemindersList(
scrollController: scrollController,
onOpen: openReminder,
onRefresh: refresh,
);
} else if (filter.value == InboxFilter.drafts) {
body = _DraftsList(
drafts: drafts,
scrollController: scrollController,
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 = _LoadingSkeleton(scrollController: scrollController);
} 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;
bodyRidesOverTopSection = true;
body = BeeRefreshIndicator(
edgeOffset: topSectionHeight,
onRefresh: refresh,
child: CustomScrollView(
controller: scrollController,
slivers: [
SliverToBoxAdapter(child: SizedBox(height: topSectionHeight)),
DecoratedSliver(
decoration: BoxDecoration(
color: context.colors.surface,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(Radii.dialog),
),
),
sliver: SliverPadding(
padding: _activityScrollPadding(context),
sliver: SliverList.builder(
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(
key: ValueKey(item.id),
item: item,
channel: channel,
currentPubkey: myPk,
isDone: isDone(item),
onTap: () => unawaited(openItem(item)),
onMarkRead: () => markItemRead(item),
onMarkUnread: () => markItemUnread(item),
),
],
);
},
),
),
),
],
),
);
}
return FrostedScaffold(
backgroundColor: context.colors.surface,
appBar: FrostedAppBar(
nativeTitle: 'Inbox',
nativeLeading: IosNavigationAction(
label: 'Close inbox',
symbol: 'xmark',
onPressed: () => Navigator.of(context).pop(),
),
leading: IconButton(
tooltip: 'Close inbox',
icon: const Icon(BuzzIcons.x),
onPressed: () => Navigator.of(context).pop(),
),
nativeLargeTitle: true,
nativeActions: [
IosNavigationAction(
label: 'Filter inbox',
icon: BuzzIcons.filter2,
children: [
for (final entry in _filterLabels.entries)
IosNavigationAction(
label: entry.value,
selected: filter.value == entry.key,
onPressed: () => filter.value = entry.key,
),
],
),
IosNavigationAction(
label: 'Inbox options',
symbol: 'ellipsis',
children: [
IosNavigationAction(
label: 'Unread only',
selected: unreadOnly.value,
onPressed: () => unreadOnly.value = !unreadOnly.value,
),
IosNavigationAction(
label: 'Mark all as read',
onPressed: unreadVisibleCount == 0
? null
: () {
for (final item in visibleItems) {
if (!isDone(item)) markItemRead(item);
}
},
),
],
),
],
automaticallyImplyLeading: false,
horizontalInset: Grid.gutter,
showBottomDivider: true,
bottomDividerOpacity: 0.07,
title: Text('Inbox', style: headerTitleStyle),
titleStyle: headerTitleStyle,
actions: [
_ActivityActionsPill(
filter: filter.value,
unreadOnly: unreadOnly.value,
unreadCount: unreadVisibleCount,
onFilterChanged: (f) => filter.value = f,
onUnreadOnlyChanged: (v) => unreadOnly.value = v,
onMarkAllRead: () {
for (final item in visibleItems) {
if (!isDone(item)) markItemRead(item);
}
},
),
],
bottomHeight: Grid.xxs,
bottom: const SizedBox.expand(),
),
body: SafeArea(
key: const ValueKey('activity-content-safe-area'),
top: false,
bottom: false,
child: bodyRidesOverTopSection
? body
: Padding(
padding: EdgeInsets.only(top: topSectionHeight),
child: body,
),
),
);
}
}