mirror of
https://github.com/block/buzz.git
synced 2026-10-27 20:41:57 +00:00
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>
509 lines
18 KiB
Dart
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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|