mirror of
https://github.com/block/buzz.git
synced 2026-10-29 13:31:44 +00:00
## Summary Polish mobile Home and Solo: group Search/Inbox beside a separate profile avatar, move Inbox and Settings to bottom-up pages with Close controls, and place tabs opposite the plus/compose action. Morph plus to compose clockwise when entering Solo and reverse counterclockwise when returning Home, with a directional quarter-turn of the icon inside a level button and a subtle scale pulse, respecting reduced motion. Align Search’s Close control with Inbox, simplify its placeholder, and initialize selected filter contrast correctly. Collapse the plus menu when leaving Home. Center Solo’s native-glass New chat empty state and keep it stationary behind the keyboard. Standardize Tabler icons and neutral backgrounds, retain SF Back/Close on iOS, use native Liquid Glass headers, tab container, and actions (solid grey selected tab, slightly darker on iOS) on iOS and black actions on Android, and smooth the expanded menu corners. Repair native header safe-area/return layout and compose movement after cancellation. ### Related issue None found for this UI refinement; follows #8337. ### Testing - Formatting, analysis, full mobile suite: 3,487 passed, 4 skipped; 3 push-configuration tests passed. File-size checks passed. - Base UI built and installed on Pixel and iPhone in place. Pixel navigation/New chat cancellation checked. Subsequent glass and rotation refinements are previewed on iPhone; motion feel awaits human confirmation. - Draft pending human confirmation of the final Settings entrance/return behavior on iPhone. ### Pixel snapshots | Solo | Expanded actions | | --- | --- | | <img src="https://raw.githubusercontent.com/block/buzz/16dc0f8af971e63d788fee20974997fab925dec9/docs/screenshots/mobile-home-solo/pixel-solo.png" width="280" alt="Solo empty state with New chat" /> | <img src="https://raw.githubusercontent.com/block/buzz/16dc0f8af971e63d788fee20974997fab925dec9/docs/screenshots/mobile-home-solo/pixel-actions.png" width="280" alt="Expanded channel actions with smooth corners" /> | --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> 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',
|
|
symbol: 'line.3.horizontal.decrease',
|
|
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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|