Files
buzz/mobile/lib/features/activity/activity_page.dart
Tom Brow 0ac71eaf95 feat(mobile): start a Solo chat from a message or inbox row (#9251)
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>
2026-11-11 04:12:00 -08:00

621 lines
23 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
),
),
],
),
),
),
);
}
}