mirror of
https://github.com/block/buzz.git
synced 2026-11-12 20:31:12 +00:00
Adds **Start Solo chat** to the message long-press sheet and the inbox row menu, analogous to buzz-app desktop's action. ## Behavior - Opens an unsent New chat draft (no channel is created until the first send), with the composer expanded and focused. - **Open channel messages** (public stream or forum) are seeded with the message's `buzz://` link, as desktop does, so the agent can open it. - **DMs, private channels, Solo chats and unknown channels** are seeded with the message excerpted as a Markdown block quote, because the agent usually can't read them. A long quote is cut at 6 KB so the first message stays under the 8 KB limit. - Starting again from the same message returns to the same draft (keyed `new-solo:<messageId>`). - Unlike desktop, it's available on your own messages too. - In the sheet it's a list row under **Copy text**; in the inbox menu it's under **Open conversation**, for message items with content. - The draft title skips a leading block quote and uses the prompt after it (or the quote text when there's no prompt). ## Notes - A seeded draft left unsent stays in the inbox Drafts list, as an unsent New chat draft already does. - Conflicts with #9142 (inbox row menu follow and remind actions), which reindents `_showRowActions`. Whichever lands second needs a small rebase. ## Demo Public channel message (linked), private channel message (quoted), then a public channel inbox item (linked) and a DM inbox item (quoted). Test data only. https://github.com/user-attachments/assets/91d2de7f-d35d-4907-bcf6-ec904327f06c ## Before / after | | Before | After | |---|---|---| | Message long-press sheet | <img src="https://github.com/user-attachments/assets/da79ae08-68a0-4396-9a63-f01af9ceb3a3" width="260"> | <img src="https://github.com/user-attachments/assets/88ee6ff1-66d6-42fe-8b78-ab7f4e6cbcfa" width="260"> | | Inbox row menu | <img src="https://github.com/user-attachments/assets/5c6f551c-63f5-40ab-b0a3-08778472d964" width="260"> | <img src="https://github.com/user-attachments/assets/25f9fa30-ac52-4b78-85fb-5679ca336495" width="260"> | Where it lands (new, unsent New chat draft): | Public message | Private message | Public inbox item | DM inbox item | |---|---|---|---| | <img src="https://github.com/user-attachments/assets/245204d3-0a56-4029-a2a6-e9d054ee6e0e" width="190"> | <img src="https://github.com/user-attachments/assets/3d5a100b-5ae5-470e-b945-8c9b24ac1db0" width="190"> | <img src="https://github.com/user-attachments/assets/8deea67e-07f4-4fc8-93d8-981fbe848c1d" width="190"> | <img src="https://github.com/user-attachments/assets/0f4b62c9-81ba-4d08-a77c-8298f3b46718" width="190"> | ## Validation - Automated simulator run (iOS 27 sim, Remote Agents Pilot): sheet on own message in a public channel seeds the link chip; sheet on a private channel message seeds a quote; inbox public channel item seeds the link; inbox DM item seeds a quote. Recorded as the demo above. - New `solo_chat_seed_test.dart` table test (link, thread link, quote trimming, multi-line quote) and two new title cases in `me_chat_creation_test.dart`. - `flutter test`: all pass except the 4 `right drag starting on/beside a link replies` cases in `channel_detail_page_test.dart`, which fail identically on clean `main`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
621 lines
23 KiB
Dart
621 lines
23 KiB
Dart
import 'package:buzz/shared/widgets/feedback_snackbar.dart';
|
||
|
||
import 'dart:async';
|
||
import 'dart:math' show max;
|
||
|
||
import '../../shared/widgets/ios_message_create_button.dart';
|
||
import '../../shared/widgets/ios_glass_navigation_button.dart';
|
||
import '../../shared/widgets/ios_scroll_to_top.dart';
|
||
import '../../shared/widgets/top_edge_fade.dart';
|
||
|
||
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/relay/thread_reference.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/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/me/me_page.dart';
|
||
import '../channels/dm_channel_labels.dart';
|
||
import '../channels/message_content.dart';
|
||
import '../../shared/read_state/read_state_format.dart';
|
||
import '../../shared/reminders/remind_me_later_sheet.dart';
|
||
import '../../shared/reminders/reminder_service.dart'
|
||
show ReminderTarget, reminderServiceProvider;
|
||
import '../channels/thread_follows/thread_follows_provider.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 'feed_item.dart';
|
||
import 'inbox_item.dart';
|
||
import 'inbox_local_state_provider.dart';
|
||
import 'inbox_read_state.dart';
|
||
import 'reminders_provider.dart' hide ReminderTarget;
|
||
|
||
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.
|
||
const _activityTypeOptions = {
|
||
InboxFilter.all: 'All',
|
||
InboxFilter.thread: 'Threads',
|
||
InboxFilter.mention: 'Mentions',
|
||
InboxFilter.dm: 'DMs',
|
||
InboxFilter.reminders: 'Reminders',
|
||
InboxFilter.drafts: 'Drafts',
|
||
};
|
||
|
||
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 sender = useState('Anyone');
|
||
final agents = ref.watch(knownAgentPubkeysProvider);
|
||
final profiles = ref.watch(userCacheProvider);
|
||
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 filterHeight =
|
||
max(48.0, MediaQuery.textScalerOf(context).scale(16) * 1.4 + 16) +
|
||
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,
|
||
);
|
||
|
||
// Rows mark channel bot members as agents, so the sender filter does too.
|
||
final channelAgents = <String, Set<String>>{};
|
||
bool includesSender(FeedItem e) {
|
||
if (sender.value == 'Anyone') return true;
|
||
final pk = e.pubkey.toLowerCase();
|
||
final channelId = e.channelId;
|
||
final known = channelId == null
|
||
? agents
|
||
: channelAgents.putIfAbsent(
|
||
channelId,
|
||
() => ref.watch(agentMentionPubkeysProvider(channelId)),
|
||
);
|
||
final isAgent = known.contains(pk) || profiles[pk]?.ownerPubkey != null;
|
||
return sender.value == 'Agents' ? isAgent : !isAgent;
|
||
}
|
||
|
||
// Mentions, Threads and sender filters apply to each event before
|
||
// regrouping, so a row shows the matching activity rather than a later
|
||
// unrelated message from the same conversation.
|
||
final candidates = buildInboxItems(
|
||
allItems
|
||
.expand((i) => i.groupItems)
|
||
.where(
|
||
(e) =>
|
||
(filter.value != InboxFilter.mention ||
|
||
e.category == 'mention') &&
|
||
(filter.value != InboxFilter.thread || isThreadReply(e.tags)) &&
|
||
includesSender(e),
|
||
),
|
||
isDmChannel: (id) =>
|
||
(channelById[id]?.isDm ?? false) ||
|
||
ref.read(channelsProvider.notifier).hiddenDmIds.contains(id),
|
||
);
|
||
final rowsArePartial =
|
||
filter.value == InboxFilter.thread ||
|
||
filter.value == InboxFilter.mention ||
|
||
sender.value != 'Anyone';
|
||
final visibleItems = [
|
||
for (final item in candidates)
|
||
if (matchesInboxFilter(item, filter.value) &&
|
||
(!unreadOnly.value || !isDone(item)))
|
||
item,
|
||
];
|
||
|
||
// Preload sender profiles for visible rows, and for every author while a
|
||
// sender filter needs their agent ownership.
|
||
final preloadPubkeys = {
|
||
if (sender.value != 'Anyone')
|
||
for (final item in allItems)
|
||
for (final e in item.groupItems) e.pubkey.toLowerCase(),
|
||
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]);
|
||
|
||
// A filtered row holds only part of its conversation. Its synced thread
|
||
// or channel marker is one watermark, so advance it through every event
|
||
// that is now read (shown here, or already read) and stop just before the
|
||
// first unread event the filter hides. Shown events past that point get
|
||
// local per-message marks until the hidden one is read too.
|
||
void markPartialRowRead(
|
||
InboxItem item,
|
||
String channelId, {
|
||
required bool isDmRow,
|
||
}) {
|
||
final notifier = ref.read(readStateProvider.notifier);
|
||
final shown = {
|
||
for (final e in [item.item, ...item.groupItems]) e.id: e,
|
||
};
|
||
final rootId = isDmRow ? null : item.threadRootId;
|
||
bool inContext(FeedItem e) =>
|
||
e.channelId == channelId &&
|
||
(rootId == null ||
|
||
(isThreadReply(e.tags) &&
|
||
threadReferenceOf(e.tags).rootId == rootId));
|
||
final events = {
|
||
for (final e in [
|
||
...allItems.expand((i) => i.groupItems),
|
||
...shown.values,
|
||
])
|
||
if (inContext(e)) e.id: e,
|
||
}.values.toList()..sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
||
int? cap;
|
||
for (final e in events) {
|
||
if (!shown.containsKey(e.id) &&
|
||
!isInboxEventRead(e, markerOf: markerOf)) {
|
||
cap = e.createdAt - 1;
|
||
break;
|
||
}
|
||
cap = e.createdAt;
|
||
}
|
||
if (cap != null) {
|
||
notifier.markContextRead(
|
||
rootId == null ? channelId : threadContextKey(rootId),
|
||
cap,
|
||
);
|
||
}
|
||
for (final e in shown.values) {
|
||
if (inContext(e) && cap != null && e.createdAt <= cap) continue;
|
||
notifier.markContextRead(msgContextKey(e.id), e.createdAt);
|
||
}
|
||
}
|
||
|
||
void markItemRead(InboxItem item) {
|
||
final notifier = ref.read(readStateProvider.notifier);
|
||
ref
|
||
.read(inboxLocalStateProvider.notifier)
|
||
.clearUnread(groupedInboxItemIds(item));
|
||
final isDmRow = item.conversationId.startsWith('dm:');
|
||
final partialChannelId = rowsArePartial ? item.item.channelId : null;
|
||
if (partialChannelId != null) {
|
||
markPartialRowRead(item, partialChannelId, isDmRow: isDmRow);
|
||
return;
|
||
}
|
||
// A DM row can group replies under several thread roots; its channel
|
||
// marker reads all of them.
|
||
final threadRootId = isDmRow ? null : 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)?.showFeedbackSnackBar(
|
||
const SnackBar(content: Text('No linked channel')),
|
||
kind: ToastKind.error,
|
||
);
|
||
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)?.showFeedbackSnackBar(
|
||
SnackBar(content: Text('Could not reopen conversation: $error')),
|
||
kind: ToastKind.error,
|
||
);
|
||
return;
|
||
}
|
||
}
|
||
if (channel == null) {
|
||
if (!context.mounted) return;
|
||
ScaffoldMessenger.maybeOf(context)?.showFeedbackSnackBar(
|
||
const SnackBar(content: Text('Channel not found here')),
|
||
kind: ToastKind.error,
|
||
);
|
||
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)?.showFeedbackSnackBar(
|
||
const SnackBar(content: Text('Draft’s channel unavailable')),
|
||
kind: ToastKind.error,
|
||
);
|
||
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)?.showFeedbackSnackBar(
|
||
const SnackBar(content: Text('Reminder has no linked message')),
|
||
kind: ToastKind.error,
|
||
);
|
||
return;
|
||
}
|
||
final channel = channelById[target.channelId];
|
||
if (channel == null) {
|
||
ScaffoldMessenger.maybeOf(context)?.showFeedbackSnackBar(
|
||
const SnackBar(content: Text('Reminder’s channel unavailable')),
|
||
kind: ToastKind.error,
|
||
);
|
||
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;
|
||
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 {
|
||
body = BeeRefreshIndicator(
|
||
onRefresh: refresh,
|
||
child: CustomScrollView(
|
||
controller: scrollController,
|
||
slivers: [
|
||
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: [
|
||
_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 IosScrollToTop(
|
||
child: Scaffold(
|
||
backgroundColor: context.colors.surface,
|
||
body: SafeArea(
|
||
bottom: false,
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: Grid.gutter,
|
||
vertical: Grid.xxs,
|
||
),
|
||
child: Row(
|
||
children: [
|
||
defaultTargetPlatform == TargetPlatform.iOS
|
||
? Padding(
|
||
padding: const EdgeInsets.only(right: Grid.twelve),
|
||
child: IosGlassNavigationButton(
|
||
icon: IosGlassNavigationIcon.close,
|
||
semanticLabel: 'Close activity',
|
||
width: 44,
|
||
height: 44,
|
||
controlSize: 44,
|
||
foregroundColor: navigationPrimaryForeground(
|
||
context,
|
||
),
|
||
onPressed: () => Navigator.of(context).pop(),
|
||
),
|
||
)
|
||
: IconButton(
|
||
tooltip: 'Close activity',
|
||
icon: const Icon(BuzzIcons.x),
|
||
onPressed: () => Navigator.of(context).pop(),
|
||
),
|
||
Expanded(child: Text('Activity', style: headerTitleStyle)),
|
||
],
|
||
),
|
||
),
|
||
SizedBox(
|
||
height: filterHeight,
|
||
child: SingleChildScrollView(
|
||
key: const ValueKey('activity-filters'),
|
||
scrollDirection: Axis.horizontal,
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: Grid.gutter,
|
||
vertical: Grid.half,
|
||
),
|
||
child: Row(
|
||
children: [
|
||
_ActivityMenuButton<InboxFilter>(
|
||
controlKey: 'activity-type-menu',
|
||
popoverKey: 'activity-type-popover',
|
||
semanticLabel:
|
||
'Activity type, ${_activityTypeOptions[filter.value]}',
|
||
value: filter.value,
|
||
active: filter.value != InboxFilter.all,
|
||
options: _activityTypeOptions,
|
||
onChanged: (value) => filter.value = value,
|
||
),
|
||
const SizedBox(width: Grid.xxs),
|
||
_ActivityMenuButton<String>(
|
||
controlKey: 'activity-sender-menu',
|
||
popoverKey: 'activity-sender-popover',
|
||
semanticLabel: 'From ${sender.value}',
|
||
value: sender.value,
|
||
active: sender.value != 'Anyone',
|
||
options: const {
|
||
'Anyone': 'Anyone',
|
||
'People': 'People',
|
||
'Agents': 'Agents',
|
||
},
|
||
onChanged: (value) => sender.value = value,
|
||
),
|
||
const SizedBox(width: Grid.xxs),
|
||
_UnreadFilterButton(
|
||
selected: unreadOnly.value,
|
||
onPressed: () => unreadOnly.value = !unreadOnly.value,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
Expanded(
|
||
child: TopEdgeFade(
|
||
key: const ValueKey('activity-content-viewport'),
|
||
child: body,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|