mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(mobile): refactor Activity behavior and ui (#2889)
**Category:** improvement **User Impact:** Mobile users can scan Activity as a focused conversation inbox and open the exact unread message or thread represented by each item. ## Context Mobile's Activity tab had not kept pace with Desktop: it presented isolated event headlines, advertised categories that were often empty, and opened a channel without clearly landing on the selected item. This PR brings the Mobile surface toward the conversation-oriented direction explored in Clay Delk's Desktop [Inbox refactor PR #2045](https://github.com/block/buzz/pull/2045), while adapting it to Mobile rather than copying the Desktop split-pane implementation. The related product/UX discussion is captured in the originating [Buzz thread](buzz://message?channel=a9bbc0e5-d25d-4740-849c-93c34bb578a4&id=a7d9a4d33dcd8c6bf0dc67d81c328892b9e38dedaa8548920224ef388301b6ab). ## UX decisions in this PR - **Conversation-oriented, not event-oriented:** related updates collapse into one row per thread/DM conversation, represented by the latest update and ordered by latest activity. Separate top-level conversations in the same channel remain separate rows. - **Resume at the oldest unread:** tapping a grouped row opens the represented canonical message/thread/DM at its oldest unread item, rather than merely opening the channel at an arbitrary position. - **Desktop-aligned row hierarchy:** rows lead with a full avatar and sender, followed by contextual location/type metadata, unread dot + time, and a two-line preview. A **New** boundary separates unread and read content. - **Mobile-native navigation:** Mobile keeps a single-column `Activity → canonical conversation → Back` flow. It does not introduce Desktop's persistent detail pane. - **Compact filtering:** the old horizontal chip rail becomes a compact filter menu so the source set fits a phone viewport without horizontal scanning. Filters are All, Mentions, Threads, Needs Action, Activity, Agents, Reminders, and Drafts. - **Focused source semantics:** All covers personally relevant work—DMs, mentions, thread replies, needs-action events, owned-agent activity, due reminders, and active drafts—rather than becoming a generic stream of every channel message. Mobile's standalone Activity source is currently limited to DM traffic because it does not have Desktop's aggregated channel-activity feed. - **Shared read behavior:** rows project canonical channel/thread/message markers, support unread-only and mark-all-read, and use local overrides only where canonical markers cannot represent an item. - **Reminders and drafts are real data:** reminders use the same encrypted NIP-ER events as Desktop. Drafts persist device-local composer state, restore on return, survive failed sends, and clear after successful sends. - **Explain navigation failures:** an unavailable destination produces an explanatory message rather than silently doing nothing or falling back to an unrelated channel position. ## Implementation summary - Adds a Mobile inbox model for conversation grouping, category priority, contextual labels, sorting, filtering, and oldest-unread targets. - Expands relay-backed sources for mentions, approvals, owned-agent lifecycle events, and DM traffic. - Adds fail-closed NIP-ER reminder decryption and device-local compose-draft persistence. - Redesigns Activity rows, boundaries, filters, unread controls, and empty/loading states. - Routes rows through Mobile's existing canonical channel/thread screens with precise target IDs. - Adds model, provider, widget, reminder, read-state, draft-lifecycle, and deep-link coverage. ## Reproduction steps 1. Run Mobile and open **Activity**. 2. Confirm full avatars, sender-first rows, context labels, unread indicators, timestamps, two-line previews, and the compact filter control. 3. Open the filter menu and verify All, Mentions, Threads, Needs Action, Activity, Agents, Reminders, and Drafts. 4. Tap a grouped thread row and confirm the canonical conversation opens at its oldest unread message. 5. Mark rows read/unread, enable unread-only mode, and use mark-all-read; confirm state agrees with the channel/thread destination. 6. Type without sending in a channel or thread, leave, and confirm the draft appears in Activity and restores in the composer. ## Screenshots | Before — merge-base `dd222a509` | After — PR head `52ad40aee` | |---|---| | <img width="1206" height="2622" alt="image" src="https://github.com/user-attachments/assets/ae961b08-bf8a-4bd5-b487-f6321ae8d85b" /> | <img width="1206" height="2622" alt="image" src="https://github.com/user-attachments/assets/bcbe7ab0-552a-417c-9e85-7a85eb4592ee" /> | Recaptured on the same authenticated iPhone 17 simulator, account, theme, and Activity view, at this PR's current merge-base (`dd222a509`) and head (`52ad40aee`). Both frames were taken within a few minutes on the same live feed, so the visible conversation set overlaps closely (the recent Ned/Bart/Tommy items appear in both). The compared change is the row *structure*: Before leads with an `@ Mention` headline over a small inline avatar and a horizontal chip rail; After leads with a full avatar, a compact `labelMedium` sender label, contextual "Mentioned in" metadata, and a filter menu. The sender username now renders at the same compact scale the old `@ Mention` label used. ## Verification - Current rebased head: `5bd87f4f4` on `origin/main` at `dd222a509`; GitHub reports the PR mergeable. - `flutter analyze` — clean at `5bd87f4f4`. - Full Mobile suite — 698 passed, 1 skipped, 4 failed; all four failures reproduce identically on clean `origin/main` (`channels_page_test` create-channel sheet and three `compose_bar_test` agent-mention cases). - The prior PR-specific `home_page_test` failures were fixed by providing the Activity local-state dependency in that harness. - Independent code and simulator UI review — approved. - Post-rebase GitHub checks are running. --------- Signed-off-by: Taylor Ho <taylorkmho@gmail.com> Signed-off-by: npub14vtk7pvazqrq9639qu7e560wnqtl0d53ca4gjuvq6jzf3k2el23qqlwa7f <ab176f059d100602ea25073d9a69ee9817f7b691c76a897180d48498d959faa2@buzz.block.builderlab.xyz> Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Co-authored-by: npub14vtk7pvazqrq9639qu7e560wnqtl0d53ca4gjuvq6jzf3k2el23qqlwa7f <ab176f059d100602ea25073d9a69ee9817f7b691c76a897180d48498d959faa2@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -1,23 +1,45 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
|
||||||
|
import '../../shared/relay/relay.dart';
|
||||||
import '../../shared/theme/theme.dart';
|
import '../../shared/theme/theme.dart';
|
||||||
import '../../shared/widgets/filter_chip_bar.dart';
|
import '../../shared/utils/string_utils.dart';
|
||||||
|
import '../../shared/widgets/avatar_image.dart';
|
||||||
import '../../shared/widgets/frosted_app_bar.dart';
|
import '../../shared/widgets/frosted_app_bar.dart';
|
||||||
import '../../shared/widgets/frosted_scaffold.dart';
|
import '../../shared/widgets/frosted_scaffold.dart';
|
||||||
import '../channels/channel.dart';
|
import '../channels/channel.dart';
|
||||||
import '../channels/channel_detail_page.dart';
|
import '../channels/channel_detail_page.dart';
|
||||||
import '../channels/message_content.dart';
|
|
||||||
import '../channels/channels_provider.dart';
|
import '../channels/channels_provider.dart';
|
||||||
import '../channels/small_avatar.dart';
|
import '../channels/dm_channel_labels.dart';
|
||||||
|
import '../channels/message_content.dart';
|
||||||
|
import '../channels/read_state/read_state_format.dart';
|
||||||
|
import '../channels/read_state/read_state_provider.dart';
|
||||||
import '../profile/user_cache_provider.dart';
|
import '../profile/user_cache_provider.dart';
|
||||||
|
import '../profile/user_profile.dart';
|
||||||
import 'activity_provider.dart';
|
import 'activity_provider.dart';
|
||||||
import 'feed_item.dart';
|
import 'compose_drafts_provider.dart';
|
||||||
|
import 'inbox_item.dart';
|
||||||
|
import 'inbox_local_state_provider.dart';
|
||||||
|
import 'inbox_read_state.dart';
|
||||||
|
import 'reminders_provider.dart';
|
||||||
|
|
||||||
enum _Filter { all, mentions, needsAction, activity, agents }
|
part 'activity_page/header_actions.dart';
|
||||||
|
part 'activity_page/inbox_row.dart';
|
||||||
|
part 'activity_page/lists.dart';
|
||||||
|
part 'activity_page/status_views.dart';
|
||||||
|
|
||||||
|
/// 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 {
|
class ActivityPage extends HookConsumerWidget {
|
||||||
const ActivityPage({super.key});
|
const ActivityPage({super.key});
|
||||||
|
|
||||||
@@ -25,53 +47,235 @@ class ActivityPage extends HookConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final feedAsync = ref.watch(activityProvider);
|
final feedAsync = ref.watch(activityProvider);
|
||||||
final channelsAsync = ref.watch(channelsProvider);
|
final channelsAsync = ref.watch(channelsProvider);
|
||||||
final filter = useState(_Filter.all);
|
final filter = useState(InboxFilter.all);
|
||||||
|
final unreadOnly = useState(false);
|
||||||
final headerTitleStyle = context.textTheme.titleMedium?.copyWith(
|
final headerTitleStyle = context.textTheme.titleMedium?.copyWith(
|
||||||
fontSize: 22,
|
fontSize: 22,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Cache last successful feed so the UI doesn't flash on rebuild.
|
final readState = ref.watch(readStateProvider);
|
||||||
final cachedFeed = useRef<HomeFeedResponse?>(null);
|
final localState = ref.watch(inboxLocalStateProvider);
|
||||||
if (feedAsync.asData?.value case final data?) {
|
final drafts = ref.watch(composeDraftsProvider);
|
||||||
cachedFeed.value = data;
|
final dueReminderCount = ref.watch(dueReminderCountProvider);
|
||||||
}
|
final allItems = ref.watch(inboxItemsProvider);
|
||||||
final feed = cachedFeed.value;
|
final myPk = ref.watch(myPubkeyProvider);
|
||||||
|
|
||||||
final Widget content;
|
// Cache the last non-empty feed so the UI doesn't flash on rebuild.
|
||||||
if (feed != null && !feed.isEmpty) {
|
final hasLoadedOnce = useRef(false);
|
||||||
final items = _filteredItems(feed, filter.value);
|
if (feedAsync.hasValue) hasLoadedOnce.value = true;
|
||||||
final channels = channelsAsync.asData?.value ?? [];
|
|
||||||
|
|
||||||
// Preload user profiles for visible feed items.
|
final channels = channelsAsync.asData?.value ?? const <Channel>[];
|
||||||
final pubkeys = items.map((i) => i.pubkey).toSet().toList();
|
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 pubkeys = visibleItems.map((i) => i.item.pubkey).toSet().toList();
|
||||||
ref.read(userCacheProvider.notifier).preload(pubkeys);
|
ref.read(userCacheProvider.notifier).preload(pubkeys);
|
||||||
|
|
||||||
content = items.isEmpty
|
final unreadVisibleCount = visibleItems.where((i) => !isDone(i)).length;
|
||||||
? _EmptyFilterState(filter: filter.value)
|
|
||||||
: RefreshIndicator(
|
void markItemRead(InboxItem item) {
|
||||||
onRefresh: () => ref.read(activityProvider.notifier).refresh(),
|
final notifier = ref.read(readStateProvider.notifier);
|
||||||
child: ListView.separated(
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
void openItem(InboxItem item) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
final channel = channelById[channelId];
|
||||||
|
if (channel == null) {
|
||||||
|
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
|
||||||
|
const SnackBar(content: Text('Channel not found in this workspace.')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deep-link to the represented message: oldest unread in the group,
|
||||||
|
// falling back to the latest event.
|
||||||
|
final readAt = resolveInboxItemReadAt(item, markerOf: markerOf);
|
||||||
|
final target = item.deepLinkTarget(readAt);
|
||||||
|
final thread = threadReferenceOf(target.tags);
|
||||||
|
final threadRootId = isBroadcastReply(target.tags)
|
||||||
|
? null
|
||||||
|
: (thread.rootId ?? thread.parentId);
|
||||||
|
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute<void>(
|
||||||
|
builder: (_) => ChannelDetailPage(
|
||||||
|
channel: channel,
|
||||||
|
initialMessageId: target.id,
|
||||||
|
initialThreadRootId: threadRootId,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
final Widget body;
|
||||||
|
if (filter.value == InboxFilter.reminders) {
|
||||||
|
body = _RemindersList(onOpen: openReminder, onRefresh: refresh);
|
||||||
|
} else if (filter.value == InboxFilter.drafts) {
|
||||||
|
body = _DraftsList(
|
||||||
|
drafts: drafts,
|
||||||
|
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 = const _LoadingSkeleton();
|
||||||
|
} 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;
|
||||||
|
|
||||||
|
body = RefreshIndicator(
|
||||||
|
onRefresh: refresh,
|
||||||
|
child: ListView.builder(
|
||||||
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
|
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
|
||||||
itemCount: items.length,
|
itemCount: visibleItems.length,
|
||||||
separatorBuilder: (_, _) => const SizedBox(height: Grid.half),
|
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final item = items[index];
|
final item = visibleItems[index];
|
||||||
return _FeedItemTile(
|
final channel = item.item.channelId != null
|
||||||
|
? channelById[item.item.channelId]
|
||||||
|
: null;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (index == newBoundaryIndex) const _NewBoundaryDivider(),
|
||||||
|
_InboxRow(
|
||||||
item: item,
|
item: item,
|
||||||
onTap: () => _openItem(context, item, channels),
|
channel: channel,
|
||||||
|
currentPubkey: myPk,
|
||||||
|
isDone: isDone(item),
|
||||||
|
onTap: () => openItem(item),
|
||||||
|
onMarkRead: () => markItemRead(item),
|
||||||
|
onMarkUnread: () => markItemUnread(item),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else if (feedAsync.hasError) {
|
|
||||||
content = _ErrorView(
|
|
||||||
onRetry: () => ref.read(activityProvider.notifier).refresh(),
|
|
||||||
);
|
|
||||||
} else if (feedAsync.hasValue) {
|
|
||||||
content = const _EmptyState();
|
|
||||||
} else {
|
|
||||||
content = const _LoadingSkeleton();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return FrostedScaffold(
|
return FrostedScaffold(
|
||||||
@@ -79,6 +283,24 @@ class ActivityPage extends HookConsumerWidget {
|
|||||||
gradient: context.appColors.topSectionGradient,
|
gradient: context.appColors.topSectionGradient,
|
||||||
title: const Text('Activity'),
|
title: const Text('Activity'),
|
||||||
titleStyle: headerTitleStyle,
|
titleStyle: headerTitleStyle,
|
||||||
|
actions: [
|
||||||
|
_FilterMenuButton(
|
||||||
|
filter: filter.value,
|
||||||
|
dueReminderCount: dueReminderCount,
|
||||||
|
draftCount: drafts.length,
|
||||||
|
onChanged: (f) => filter.value = f,
|
||||||
|
),
|
||||||
|
_InboxOptionsButton(
|
||||||
|
unreadOnly: unreadOnly.value,
|
||||||
|
unreadCount: unreadVisibleCount,
|
||||||
|
onUnreadOnlyChanged: (v) => unreadOnly.value = v,
|
||||||
|
onMarkAllRead: () {
|
||||||
|
for (final item in visibleItems) {
|
||||||
|
if (!isDone(item)) markItemRead(item);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
@@ -86,340 +308,8 @@ class ActivityPage extends HookConsumerWidget {
|
|||||||
padding: EdgeInsets.only(
|
padding: EdgeInsets.only(
|
||||||
top: frostedAppBarHeight(context, titleStyle: headerTitleStyle),
|
top: frostedAppBarHeight(context, titleStyle: headerTitleStyle),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: body,
|
||||||
children: [
|
|
||||||
FilterChipBar<_Filter>(
|
|
||||||
expandItems: false,
|
|
||||||
visualDensity: const VisualDensity(horizontal: -2),
|
|
||||||
chipVerticalPadding: Grid.xxs,
|
|
||||||
barVerticalPadding: Grid.twelve,
|
|
||||||
selected: filter.value,
|
|
||||||
onSelected: (f) => filter.value = f,
|
|
||||||
items: [
|
|
||||||
const FilterChipItem(id: _Filter.all, label: 'All'),
|
|
||||||
FilterChipItem(
|
|
||||||
id: _Filter.mentions,
|
|
||||||
label: 'Mentions',
|
|
||||||
icon: LucideIcons.atSign,
|
|
||||||
count: feed?.mentions.length,
|
|
||||||
),
|
),
|
||||||
FilterChipItem(
|
|
||||||
id: _Filter.needsAction,
|
|
||||||
label: 'Action',
|
|
||||||
icon: LucideIcons.circleAlert,
|
|
||||||
count: feed?.needsAction.length,
|
|
||||||
),
|
|
||||||
FilterChipItem(
|
|
||||||
id: _Filter.activity,
|
|
||||||
label: 'Activity',
|
|
||||||
icon: LucideIcons.activity,
|
|
||||||
count: feed?.activity.length,
|
|
||||||
),
|
|
||||||
FilterChipItem(
|
|
||||||
id: _Filter.agents,
|
|
||||||
label: 'Agents',
|
|
||||||
icon: LucideIcons.bot,
|
|
||||||
count: feed?.agentActivity.length,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Expanded(child: content),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<FeedItem> _filteredItems(HomeFeedResponse feed, _Filter filter) {
|
|
||||||
return switch (filter) {
|
|
||||||
_Filter.all => feed.all,
|
|
||||||
_Filter.mentions => feed.mentions,
|
|
||||||
_Filter.needsAction => feed.needsAction,
|
|
||||||
_Filter.activity => feed.activity,
|
|
||||||
_Filter.agents => feed.agentActivity,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
void _openItem(BuildContext context, FeedItem item, List<Channel> channels) {
|
|
||||||
if (item.channelId == null) return;
|
|
||||||
final channel = channels
|
|
||||||
.where((c) => c.id == item.channelId)
|
|
||||||
.cast<Channel?>()
|
|
||||||
.firstOrNull;
|
|
||||||
if (channel == null) return;
|
|
||||||
Navigator.of(context).push(
|
|
||||||
MaterialPageRoute<void>(
|
|
||||||
builder: (_) => ChannelDetailPage(channel: channel),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _FeedItemTile extends ConsumerWidget {
|
|
||||||
final FeedItem item;
|
|
||||||
final VoidCallback onTap;
|
|
||||||
|
|
||||||
const _FeedItemTile({required this.item, required this.onTap});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final userCache = ref.watch(userCacheProvider);
|
|
||||||
final profile = userCache[item.pubkey.toLowerCase()];
|
|
||||||
final authorLabel = profile?.label ?? _shortPubkey(item.pubkey);
|
|
||||||
|
|
||||||
return InkWell(
|
|
||||||
onTap: item.channelId != null ? onTap : null,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: Grid.gutter,
|
|
||||||
vertical: Grid.twelve,
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
// Header row: icon + headline + author + channel + time
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
_categoryIcon(item.category),
|
|
||||||
size: 14,
|
|
||||||
color: context.colors.primary,
|
|
||||||
),
|
|
||||||
const SizedBox(width: Grid.half),
|
|
||||||
Text(
|
|
||||||
item.headline,
|
|
||||||
style: context.textTheme.labelMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: Grid.xxs),
|
|
||||||
SmallAvatar(pubkey: item.pubkey, userCache: userCache),
|
|
||||||
const SizedBox(width: Grid.quarter),
|
|
||||||
Flexible(
|
|
||||||
child: Text(
|
|
||||||
authorLabel,
|
|
||||||
style: context.textTheme.labelSmall?.copyWith(
|
|
||||||
color: context.colors.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (item.channelName.isNotEmpty) ...[
|
|
||||||
const SizedBox(width: Grid.half),
|
|
||||||
Flexible(
|
|
||||||
child: Text(
|
|
||||||
'#${item.channelName}',
|
|
||||||
style: context.textTheme.labelSmall?.copyWith(
|
|
||||||
color: context.colors.primary.withValues(
|
|
||||||
alpha: 0.8,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: Grid.xxs),
|
|
||||||
Text(
|
|
||||||
_relativeTime(item.createdAt),
|
|
||||||
style: context.textTheme.labelSmall?.copyWith(
|
|
||||||
color: context.colors.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: Grid.half),
|
|
||||||
// Content preview (max 2 lines)
|
|
||||||
MessageContent(
|
|
||||||
content: item.displayContent,
|
|
||||||
tags: item.tags,
|
|
||||||
maxLines: 2,
|
|
||||||
baseStyle: context.textTheme.bodySmall?.copyWith(
|
|
||||||
color: context.colors.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static IconData _categoryIcon(String category) {
|
|
||||||
return switch (category) {
|
|
||||||
'mention' => LucideIcons.atSign,
|
|
||||||
'needs_action' => LucideIcons.circleAlert,
|
|
||||||
'agent_activity' => LucideIcons.bot,
|
|
||||||
_ => LucideIcons.activity,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
static String _shortPubkey(String pk) =>
|
|
||||||
pk.length >= 8 ? '${pk.substring(0, 8)}...' : pk;
|
|
||||||
|
|
||||||
static String _relativeTime(int unixSeconds) {
|
|
||||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
|
||||||
final diff = now - unixSeconds;
|
|
||||||
|
|
||||||
if (diff < 60) return 'now';
|
|
||||||
if (diff < 3600) return '${diff ~/ 60}m';
|
|
||||||
if (diff < 86400) return '${diff ~/ 3600}h';
|
|
||||||
if (diff < 604800) return '${diff ~/ 86400}d';
|
|
||||||
final date = DateTime.fromMillisecondsSinceEpoch(unixSeconds * 1000);
|
|
||||||
return '${date.month}/${date.day}';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _LoadingSkeleton extends StatelessWidget {
|
|
||||||
const _LoadingSkeleton();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return ListView.separated(
|
|
||||||
padding: const EdgeInsets.all(Grid.xs),
|
|
||||||
itemCount: 8,
|
|
||||||
separatorBuilder: (_, _) => const SizedBox(height: Grid.xs),
|
|
||||||
itemBuilder: (context, _) => Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 180,
|
|
||||||
height: 12,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: context.colors.outlineVariant.withValues(alpha: 0.4),
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: Grid.xxs),
|
|
||||||
Container(
|
|
||||||
width: double.infinity,
|
|
||||||
height: 10,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: context.colors.outlineVariant.withValues(alpha: 0.3),
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: Grid.half),
|
|
||||||
Container(
|
|
||||||
width: 240,
|
|
||||||
height: 10,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: context.colors.outlineVariant.withValues(alpha: 0.2),
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _EmptyState extends StatelessWidget {
|
|
||||||
const _EmptyState();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
LucideIcons.bell,
|
|
||||||
size: Grid.xl,
|
|
||||||
color: context.colors.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
const SizedBox(height: Grid.xs),
|
|
||||||
Text(
|
|
||||||
'No activity yet',
|
|
||||||
style: context.textTheme.bodyLarge?.copyWith(
|
|
||||||
color: context.colors.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: Grid.half),
|
|
||||||
Text(
|
|
||||||
'Mentions, replies, and reactions will show up here.',
|
|
||||||
style: context.textTheme.bodySmall?.copyWith(
|
|
||||||
color: context.colors.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _EmptyFilterState extends StatelessWidget {
|
|
||||||
final _Filter filter;
|
|
||||||
|
|
||||||
const _EmptyFilterState({required this.filter});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final (icon, message) = switch (filter) {
|
|
||||||
_Filter.mentions => (LucideIcons.atSign, 'No mentions yet'),
|
|
||||||
_Filter.needsAction => (
|
|
||||||
LucideIcons.circleAlert,
|
|
||||||
'Nothing needs your action',
|
|
||||||
),
|
|
||||||
_Filter.activity => (LucideIcons.activity, 'No recent channel activity'),
|
|
||||||
_Filter.agents => (LucideIcons.bot, 'No agent updates'),
|
|
||||||
_Filter.all => (LucideIcons.bell, 'No activity yet'),
|
|
||||||
};
|
|
||||||
|
|
||||||
return Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(icon, size: Grid.lg, color: context.colors.onSurfaceVariant),
|
|
||||||
const SizedBox(height: Grid.xxs),
|
|
||||||
Text(
|
|
||||||
message,
|
|
||||||
style: context.textTheme.bodyMedium?.copyWith(
|
|
||||||
color: context.colors.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ErrorView extends StatelessWidget {
|
|
||||||
final VoidCallback onRetry;
|
|
||||||
|
|
||||||
const _ErrorView({required this.onRetry});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
LucideIcons.triangleAlert,
|
|
||||||
size: Grid.lg,
|
|
||||||
color: context.colors.error,
|
|
||||||
),
|
|
||||||
const SizedBox(height: Grid.xxs),
|
|
||||||
Text(
|
|
||||||
'Failed to load activity',
|
|
||||||
style: context.textTheme.bodyMedium?.copyWith(
|
|
||||||
color: context.colors.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: Grid.xs),
|
|
||||||
FilledButton.icon(
|
|
||||||
onPressed: onRetry,
|
|
||||||
icon: const Icon(LucideIcons.refreshCcw, size: 16),
|
|
||||||
label: const Text('Retry'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
180
mobile/lib/features/activity/activity_page/header_actions.dart
Normal file
180
mobile/lib/features/activity/activity_page/header_actions.dart
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
part of '../activity_page.dart';
|
||||||
|
|
||||||
|
const _filterLabels = <InboxFilter, String>{
|
||||||
|
InboxFilter.all: 'All',
|
||||||
|
InboxFilter.mention: 'Mentions',
|
||||||
|
InboxFilter.thread: 'Threads',
|
||||||
|
InboxFilter.needsAction: 'Needs Action',
|
||||||
|
InboxFilter.activity: 'Activity',
|
||||||
|
InboxFilter.agentActivity: 'Agents',
|
||||||
|
InboxFilter.reminders: 'Reminders',
|
||||||
|
InboxFilter.drafts: 'Drafts',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Compact filter dropdown replacing the old chip rail — mirrors desktop's
|
||||||
|
/// inbox filter menu (`FILTER_OPTIONS`).
|
||||||
|
class _FilterMenuButton extends StatelessWidget {
|
||||||
|
final InboxFilter filter;
|
||||||
|
final int dueReminderCount;
|
||||||
|
final int draftCount;
|
||||||
|
final ValueChanged<InboxFilter> onChanged;
|
||||||
|
|
||||||
|
const _FilterMenuButton({
|
||||||
|
required this.filter,
|
||||||
|
required this.dueReminderCount,
|
||||||
|
required this.draftCount,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return PopupMenuButton<InboxFilter>(
|
||||||
|
key: const ValueKey('activity-filter-menu'),
|
||||||
|
onSelected: onChanged,
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
for (final entry in _filterLabels.entries)
|
||||||
|
PopupMenuItem(
|
||||||
|
value: entry.key,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: Grid.sm,
|
||||||
|
child: entry.key == filter
|
||||||
|
? Icon(
|
||||||
|
LucideIcons.check,
|
||||||
|
size: 16,
|
||||||
|
color: context.colors.primary,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
Text(entry.value),
|
||||||
|
const Spacer(),
|
||||||
|
if (entry.key == InboxFilter.reminders && dueReminderCount > 0)
|
||||||
|
_CountBadge(count: dueReminderCount)
|
||||||
|
else if (entry.key == InboxFilter.drafts && draftCount > 0)
|
||||||
|
_CountBadge(count: draftCount),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: Grid.xxs),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_filterLabels[filter]!,
|
||||||
|
style: context.textTheme.labelLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: Grid.quarter),
|
||||||
|
Icon(
|
||||||
|
LucideIcons.chevronDown,
|
||||||
|
size: 16,
|
||||||
|
color: context.colors.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
if (dueReminderCount > 0 || draftCount > 0) ...[
|
||||||
|
const SizedBox(width: Grid.quarter),
|
||||||
|
Container(
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: context.colors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CountBadge extends StatelessWidget {
|
||||||
|
final int count;
|
||||||
|
|
||||||
|
const _CountBadge({required this.count});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: Grid.half + Grid.quarter,
|
||||||
|
vertical: Grid.quarter,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: context.colors.primary,
|
||||||
|
borderRadius: BorderRadius.circular(Grid.xxs),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'$count',
|
||||||
|
style: context.textTheme.labelSmall?.copyWith(
|
||||||
|
color: context.colors.onPrimary,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Overflow menu with the unread-only toggle and mark-all-read, mirroring
|
||||||
|
/// desktop's inbox options popover.
|
||||||
|
class _InboxOptionsButton extends StatelessWidget {
|
||||||
|
final bool unreadOnly;
|
||||||
|
final int unreadCount;
|
||||||
|
final ValueChanged<bool> onUnreadOnlyChanged;
|
||||||
|
final VoidCallback onMarkAllRead;
|
||||||
|
|
||||||
|
const _InboxOptionsButton({
|
||||||
|
required this.unreadOnly,
|
||||||
|
required this.unreadCount,
|
||||||
|
required this.onUnreadOnlyChanged,
|
||||||
|
required this.onMarkAllRead,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return PopupMenuButton<String>(
|
||||||
|
key: const ValueKey('activity-options-menu'),
|
||||||
|
icon: const Icon(LucideIcons.ellipsis, size: 20),
|
||||||
|
onSelected: (value) {
|
||||||
|
if (value == 'unread-only') onUnreadOnlyChanged(!unreadOnly);
|
||||||
|
if (value == 'mark-all-read') onMarkAllRead();
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'unread-only',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: Text(unreadOnly ? 'Show all' : 'Show unread')),
|
||||||
|
if (unreadOnly)
|
||||||
|
Icon(
|
||||||
|
LucideIcons.check,
|
||||||
|
size: 16,
|
||||||
|
color: context.colors.primary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'mark-all-read',
|
||||||
|
enabled: unreadCount > 0,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Expanded(child: Text('Mark all as read')),
|
||||||
|
if (unreadCount > 0)
|
||||||
|
Text(
|
||||||
|
'$unreadCount',
|
||||||
|
style: context.textTheme.labelSmall?.copyWith(
|
||||||
|
color: context.colors.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
253
mobile/lib/features/activity/activity_page/inbox_row.dart
Normal file
253
mobile/lib/features/activity/activity_page/inbox_row.dart
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
part of '../activity_page.dart';
|
||||||
|
|
||||||
|
/// "New" boundary between unread and previously read rows.
|
||||||
|
class _NewBoundaryDivider extends StatelessWidget {
|
||||||
|
const _NewBoundaryDivider();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: Grid.gutter,
|
||||||
|
vertical: Grid.half,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Divider(color: context.colors.error.withValues(alpha: 0.5)),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: Grid.xxs),
|
||||||
|
child: Text(
|
||||||
|
'New',
|
||||||
|
style: context.textTheme.labelSmall?.copyWith(
|
||||||
|
color: context.colors.error,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Divider(color: context.colors.error.withValues(alpha: 0.5)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One conversation row, matching desktop's inbox item hierarchy:
|
||||||
|
/// avatar | sender + unread dot + time | contextual label | preview.
|
||||||
|
class _InboxRow extends ConsumerWidget {
|
||||||
|
final InboxItem item;
|
||||||
|
final Channel? channel;
|
||||||
|
final String? currentPubkey;
|
||||||
|
final bool isDone;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final VoidCallback onMarkRead;
|
||||||
|
final VoidCallback onMarkUnread;
|
||||||
|
|
||||||
|
const _InboxRow({
|
||||||
|
required this.item,
|
||||||
|
required this.channel,
|
||||||
|
required this.currentPubkey,
|
||||||
|
required this.isDone,
|
||||||
|
required this.onTap,
|
||||||
|
required this.onMarkRead,
|
||||||
|
required this.onMarkUnread,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final userCache = ref.watch(userCacheProvider);
|
||||||
|
final profile = userCache[item.item.pubkey.toLowerCase()];
|
||||||
|
final senderLabel = profile?.displayName ?? shortPubkey(item.item.pubkey);
|
||||||
|
|
||||||
|
final isDm = channel?.isDm ?? false;
|
||||||
|
final channelName = channel != null && !isDm
|
||||||
|
? (channel!.name.isNotEmpty ? channel!.name : null)
|
||||||
|
: null;
|
||||||
|
final label = inboxTypeLabel(
|
||||||
|
item,
|
||||||
|
channelName: channelName,
|
||||||
|
isDm: isDm,
|
||||||
|
senderLabel: senderLabel,
|
||||||
|
);
|
||||||
|
|
||||||
|
final mutedColor = context.colors.onSurfaceVariant;
|
||||||
|
final labelColor = item.isActionRequired && !isDone
|
||||||
|
? context.colors.tertiary
|
||||||
|
: mutedColor;
|
||||||
|
|
||||||
|
return InkWell(
|
||||||
|
key: ValueKey('inbox-row-${item.id}'),
|
||||||
|
onTap: onTap,
|
||||||
|
onLongPress: () => _showRowActions(context),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: Grid.gutter,
|
||||||
|
vertical: Grid.twelve,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_RowAvatar(pubkey: item.item.pubkey, profile: profile),
|
||||||
|
const SizedBox(width: Grid.twelve),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Sender + unread dot + timestamp.
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
senderLabel,
|
||||||
|
// Compact label scale — matches the old
|
||||||
|
// "@ Mention" headline treatment while staying
|
||||||
|
// the row's primary label.
|
||||||
|
style: context.textTheme.labelMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: Grid.xxs),
|
||||||
|
if (!isDone) ...[
|
||||||
|
Container(
|
||||||
|
key: ValueKey('inbox-unread-dot-${item.id}'),
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: context.colors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: Grid.half),
|
||||||
|
],
|
||||||
|
Text(
|
||||||
|
_inboxTimestamp(item.latestActivityAt),
|
||||||
|
style: context.textTheme.labelSmall?.copyWith(
|
||||||
|
color: mutedColor,
|
||||||
|
fontWeight: isDone
|
||||||
|
? FontWeight.w400
|
||||||
|
: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: Grid.quarter),
|
||||||
|
// Contextual label: "Mentioned in #channel" etc.
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
label.text,
|
||||||
|
style: context.textTheme.labelSmall?.copyWith(
|
||||||
|
color: labelColor,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (label.channelLabel != null) ...[
|
||||||
|
const SizedBox(width: Grid.half),
|
||||||
|
Flexible(
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: Grid.half + Grid.quarter,
|
||||||
|
vertical: Grid.quarter / 2,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: context.colors.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(Grid.half),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'#${label.channelLabel}',
|
||||||
|
style: context.textTheme.labelSmall?.copyWith(
|
||||||
|
color: mutedColor,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: Grid.half),
|
||||||
|
// Preview (bold while unread — desktop parity).
|
||||||
|
MessageContent(
|
||||||
|
content: item.item.displayContent,
|
||||||
|
tags: item.item.tags,
|
||||||
|
maxLines: 2,
|
||||||
|
baseStyle: context.textTheme.bodySmall?.copyWith(
|
||||||
|
color: isDone ? mutedColor : context.colors.onSurface,
|
||||||
|
fontWeight: isDone ? FontWeight.w400 : FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showRowActions(BuildContext context) {
|
||||||
|
showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (sheetContext) => SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
isDone ? LucideIcons.mail : LucideIcons.mailOpen,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
title: Text(isDone ? 'Mark unread' : 'Mark as read'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(sheetContext).pop();
|
||||||
|
isDone ? onMarkUnread() : onMarkRead();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(LucideIcons.externalLink, size: 20),
|
||||||
|
title: const Text('Open conversation'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(sheetContext).pop();
|
||||||
|
onTap();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RowAvatar extends StatelessWidget {
|
||||||
|
final String pubkey;
|
||||||
|
final UserProfile? profile;
|
||||||
|
|
||||||
|
const _RowAvatar({required this.pubkey, required this.profile});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final initial =
|
||||||
|
profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?');
|
||||||
|
return AvatarImage(
|
||||||
|
imageUrl: profile?.avatarUrl,
|
||||||
|
radius: 18,
|
||||||
|
backgroundColor: context.colors.primaryContainer,
|
||||||
|
fallback: Text(
|
||||||
|
initial,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: context.colors.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
160
mobile/lib/features/activity/activity_page/lists.dart
Normal file
160
mobile/lib/features/activity/activity_page/lists.dart
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
part of '../activity_page.dart';
|
||||||
|
|
||||||
|
/// Reminders surface for the Reminders filter — due/pending NIP-ER
|
||||||
|
/// reminders that deep-link to their target message.
|
||||||
|
class _RemindersList extends ConsumerWidget {
|
||||||
|
final void Function(Reminder reminder) onOpen;
|
||||||
|
final Future<void> Function() onRefresh;
|
||||||
|
|
||||||
|
const _RemindersList({required this.onOpen, required this.onRefresh});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final remindersAsync = ref.watch(remindersProvider);
|
||||||
|
final reminders = [
|
||||||
|
for (final r in remindersAsync.value ?? const <Reminder>[])
|
||||||
|
if (r.status == 'pending') r,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (remindersAsync.isLoading && reminders.isEmpty) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
if (reminders.isEmpty) {
|
||||||
|
return const _EmptySurface(
|
||||||
|
icon: LucideIcons.clock,
|
||||||
|
message: 'No reminders',
|
||||||
|
detail: 'Reminders you set will show up here.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: onRefresh,
|
||||||
|
child: ListView.builder(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
|
||||||
|
itemCount: reminders.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final reminder = reminders[index];
|
||||||
|
final due = reminder.isDue(now);
|
||||||
|
final title = reminder.note?.trim().isNotEmpty == true
|
||||||
|
? reminder.note!.trim()
|
||||||
|
: reminder.target?.preview ?? 'Reminder';
|
||||||
|
return ListTile(
|
||||||
|
key: ValueKey('reminder-row-${reminder.id}'),
|
||||||
|
leading: Icon(
|
||||||
|
due ? LucideIcons.bellRing : LucideIcons.clock,
|
||||||
|
size: 20,
|
||||||
|
color: due
|
||||||
|
? context.colors.primary
|
||||||
|
: context.colors.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
title: Text(title, maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||||
|
subtitle: reminder.notBefore != null
|
||||||
|
? Text(
|
||||||
|
due
|
||||||
|
? 'Due now'
|
||||||
|
: 'Due ${_inboxTimestamp(reminder.notBefore!)}',
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
onTap: () => onOpen(reminder),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drafts surface for the Drafts filter — locally saved unsent composer
|
||||||
|
/// text that reopens the target composer.
|
||||||
|
class _DraftsList extends StatelessWidget {
|
||||||
|
final List<ComposeDraft> drafts;
|
||||||
|
final Map<String, Channel> channelById;
|
||||||
|
final String? myPubkey;
|
||||||
|
final void Function(ComposeDraft draft) onOpen;
|
||||||
|
final void Function(ComposeDraft draft) onDelete;
|
||||||
|
|
||||||
|
const _DraftsList({
|
||||||
|
required this.drafts,
|
||||||
|
required this.channelById,
|
||||||
|
required this.myPubkey,
|
||||||
|
required this.onOpen,
|
||||||
|
required this.onDelete,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (drafts.isEmpty) {
|
||||||
|
return const _EmptySurface(
|
||||||
|
icon: LucideIcons.filePen,
|
||||||
|
message: 'No drafts',
|
||||||
|
detail: 'Unsent messages you start composing will show up here.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.builder(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
|
||||||
|
itemCount: drafts.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final draft = drafts[index];
|
||||||
|
final channel = channelById[draft.channelId];
|
||||||
|
final destination = channel == null
|
||||||
|
? 'Unavailable channel'
|
||||||
|
: channel.isDm
|
||||||
|
? resolveDmChannelDisplayLabel(channel, currentPubkey: myPubkey)
|
||||||
|
: '#${channel.name}';
|
||||||
|
return ListTile(
|
||||||
|
key: ValueKey('draft-row-${draft.key}'),
|
||||||
|
leading: Icon(
|
||||||
|
LucideIcons.filePen,
|
||||||
|
size: 20,
|
||||||
|
color: context.colors.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
title: Text(draft.text, maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||||
|
subtitle: Text(
|
||||||
|
draft.threadHeadId != null ? 'Thread in $destination' : destination,
|
||||||
|
),
|
||||||
|
trailing: IconButton(
|
||||||
|
icon: const Icon(LucideIcons.trash2, size: 18),
|
||||||
|
tooltip: 'Delete draft',
|
||||||
|
onPressed: () => onDelete(draft),
|
||||||
|
),
|
||||||
|
onTap: () => onOpen(draft),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inbox list timestamp — desktop's `formatInboxTimestamp`:
|
||||||
|
/// time today, "Yesterday", short date this year, dated otherwise.
|
||||||
|
String _inboxTimestamp(int unixSeconds, {DateTime? now}) {
|
||||||
|
final current = now ?? DateTime.now();
|
||||||
|
final date = DateTime.fromMillisecondsSinceEpoch(unixSeconds * 1000);
|
||||||
|
final today = DateTime(current.year, current.month, current.day);
|
||||||
|
final day = DateTime(date.year, date.month, date.day);
|
||||||
|
final dayDiff = today.difference(day).inDays;
|
||||||
|
|
||||||
|
if (dayDiff == 0) {
|
||||||
|
final hour = date.hour % 12 == 0 ? 12 : date.hour % 12;
|
||||||
|
final minute = date.minute.toString().padLeft(2, '0');
|
||||||
|
return '$hour:$minute ${date.hour < 12 ? 'AM' : 'PM'}';
|
||||||
|
}
|
||||||
|
if (dayDiff == 1) return 'Yesterday';
|
||||||
|
|
||||||
|
const months = [
|
||||||
|
'Jan',
|
||||||
|
'Feb',
|
||||||
|
'Mar',
|
||||||
|
'Apr',
|
||||||
|
'May',
|
||||||
|
'Jun',
|
||||||
|
'Jul',
|
||||||
|
'Aug',
|
||||||
|
'Sep',
|
||||||
|
'Oct',
|
||||||
|
'Nov',
|
||||||
|
'Dec',
|
||||||
|
];
|
||||||
|
final label = '${months[date.month - 1]} ${date.day}';
|
||||||
|
return date.year == current.year ? label : '$label, ${date.year}';
|
||||||
|
}
|
||||||
172
mobile/lib/features/activity/activity_page/status_views.dart
Normal file
172
mobile/lib/features/activity/activity_page/status_views.dart
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
part of '../activity_page.dart';
|
||||||
|
|
||||||
|
class _LoadingSkeleton extends StatelessWidget {
|
||||||
|
const _LoadingSkeleton();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListView.separated(
|
||||||
|
padding: const EdgeInsets.all(Grid.gutter),
|
||||||
|
itemCount: 8,
|
||||||
|
separatorBuilder: (_, _) => const SizedBox(height: Grid.xs),
|
||||||
|
itemBuilder: (context, _) => Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: context.colors.outlineVariant.withValues(alpha: 0.4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: Grid.twelve),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 140,
|
||||||
|
height: 12,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: context.colors.outlineVariant.withValues(alpha: 0.4),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: Grid.xxs),
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 10,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: context.colors.outlineVariant.withValues(alpha: 0.3),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: Grid.half),
|
||||||
|
Container(
|
||||||
|
width: 220,
|
||||||
|
height: 10,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: context.colors.outlineVariant.withValues(alpha: 0.2),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EmptySurface extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String message;
|
||||||
|
final String? detail;
|
||||||
|
|
||||||
|
const _EmptySurface({required this.icon, required this.message, this.detail});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: Grid.lg, color: context.colors.onSurfaceVariant),
|
||||||
|
const SizedBox(height: Grid.xxs),
|
||||||
|
Text(
|
||||||
|
message,
|
||||||
|
style: context.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: context.colors.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (detail != null) ...[
|
||||||
|
const SizedBox(height: Grid.half),
|
||||||
|
Text(
|
||||||
|
detail!,
|
||||||
|
style: context.textTheme.bodySmall?.copyWith(
|
||||||
|
color: context.colors.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EmptyFilterState extends StatelessWidget {
|
||||||
|
final InboxFilter filter;
|
||||||
|
final bool unreadOnly;
|
||||||
|
|
||||||
|
const _EmptyFilterState({required this.filter, required this.unreadOnly});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (unreadOnly) {
|
||||||
|
return const _EmptySurface(
|
||||||
|
icon: LucideIcons.mailOpen,
|
||||||
|
message: 'No unread messages',
|
||||||
|
detail: 'Turn off the unread filter to see read messages.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final (icon, message) = switch (filter) {
|
||||||
|
InboxFilter.mention => (LucideIcons.atSign, 'No mentions yet'),
|
||||||
|
InboxFilter.thread => (
|
||||||
|
LucideIcons.messageSquare,
|
||||||
|
'No thread replies yet',
|
||||||
|
),
|
||||||
|
InboxFilter.needsAction => (
|
||||||
|
LucideIcons.circleAlert,
|
||||||
|
'Nothing needs your action',
|
||||||
|
),
|
||||||
|
InboxFilter.activity => (
|
||||||
|
LucideIcons.activity,
|
||||||
|
'No recent channel activity',
|
||||||
|
),
|
||||||
|
InboxFilter.agentActivity => (LucideIcons.bot, 'No agent updates'),
|
||||||
|
_ => (LucideIcons.bell, 'No activity yet'),
|
||||||
|
};
|
||||||
|
return _EmptySurface(icon: icon, message: message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ErrorView extends StatelessWidget {
|
||||||
|
final Future<void> Function() onRetry;
|
||||||
|
|
||||||
|
const _ErrorView({required this.onRetry});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
LucideIcons.triangleAlert,
|
||||||
|
size: Grid.lg,
|
||||||
|
color: context.colors.error,
|
||||||
|
),
|
||||||
|
const SizedBox(height: Grid.xxs),
|
||||||
|
Text(
|
||||||
|
'Failed to load activity',
|
||||||
|
style: context.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: context.colors.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: Grid.xs),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: () => unawaited(onRetry()),
|
||||||
|
icon: const Icon(LucideIcons.refreshCcw, size: 16),
|
||||||
|
label: const Text('Retry'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,45 @@
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
import '../../shared/relay/relay.dart';
|
import '../../shared/relay/relay.dart';
|
||||||
|
import '../channels/channel.dart';
|
||||||
|
import '../channels/channels_provider.dart';
|
||||||
import 'feed_item.dart';
|
import 'feed_item.dart';
|
||||||
|
import 'inbox_item.dart';
|
||||||
|
|
||||||
/// Builds the home activity feed by issuing two parallel REQs over the relay
|
/// Builds the Activity inbox feed over the relay websocket.
|
||||||
/// websocket: one for mentions of me on user-visible channel kinds, and one
|
///
|
||||||
/// for agent-activity / approval kinds addressed to me.
|
/// Sources mirror desktop's Home inbox (`useHomeFeedQuery` + `get_feed`):
|
||||||
|
/// - mentions of me on user-visible channel kinds (also yields thread
|
||||||
|
/// replies, which the thread filter classifies from NIP-10 tags)
|
||||||
|
/// - workflow approvals / needs-action events addressed to me
|
||||||
|
/// - agent job lifecycle events addressed to me (kinds 43001-43006)
|
||||||
|
/// - recent DM messages from others (desktop surfaces DMs through p-tags;
|
||||||
|
/// mobile queries DM channels directly so untagged DM sends still appear)
|
||||||
class ActivityNotifier extends AsyncNotifier<HomeFeedResponse> {
|
class ActivityNotifier extends AsyncNotifier<HomeFeedResponse> {
|
||||||
@override
|
@override
|
||||||
Future<HomeFeedResponse> build() {
|
Future<HomeFeedResponse> build() {
|
||||||
ref.watch(relayConfigProvider);
|
ref.watch(relayConfigProvider);
|
||||||
ref.watch(relaySessionProvider);
|
ref.watch(relaySessionProvider);
|
||||||
|
// React to the DM channel set (loading → data, membership changes) so a
|
||||||
|
// cold start where channels resolve after the first fetch still surfaces
|
||||||
|
// DMs without a manual refresh.
|
||||||
|
ref.watch(channelsProvider.select(_dmChannelKey));
|
||||||
return _fetch();
|
return _fetch();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stable identity for the joined DM channel set: null while channels are
|
||||||
|
/// loading, otherwise the sorted member-DM ids. Keeps unrelated channel
|
||||||
|
/// updates from refetching the feed.
|
||||||
|
static String? _dmChannelKey(AsyncValue<List<Channel>> channels) {
|
||||||
|
final value = channels.asData?.value;
|
||||||
|
if (value == null) return null;
|
||||||
|
final ids = [
|
||||||
|
for (final channel in value)
|
||||||
|
if (channel.isDm && channel.isMember) channel.id,
|
||||||
|
]..sort();
|
||||||
|
return ids.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
Future<HomeFeedResponse> _fetch() async {
|
Future<HomeFeedResponse> _fetch() async {
|
||||||
final myPk = ref.read(myPubkeyProvider);
|
final myPk = ref.read(myPubkeyProvider);
|
||||||
if (myPk == null) {
|
if (myPk == null) {
|
||||||
@@ -27,6 +53,15 @@ class ActivityNotifier extends AsyncNotifier<HomeFeedResponse> {
|
|||||||
|
|
||||||
final session = ref.read(relaySessionProvider.notifier);
|
final session = ref.read(relaySessionProvider.notifier);
|
||||||
|
|
||||||
|
// DM channels come from the channel list; while it is still loading the
|
||||||
|
// DM source is skipped, and build() rebuilds when it resolves (see the
|
||||||
|
// channelsProvider watch above).
|
||||||
|
final dmChannelIds = [
|
||||||
|
for (final channel
|
||||||
|
in ref.read(channelsProvider).asData?.value ?? const <Channel>[])
|
||||||
|
if (channel.isDm && channel.isMember) channel.id,
|
||||||
|
];
|
||||||
|
|
||||||
final results = await Future.wait([
|
final results = await Future.wait([
|
||||||
// Mentions of me on user-visible channel content.
|
// Mentions of me on user-visible channel content.
|
||||||
session.fetchHistory(
|
session.fetchHistory(
|
||||||
@@ -38,7 +73,7 @@ class ActivityNotifier extends AsyncNotifier<HomeFeedResponse> {
|
|||||||
limit: 50,
|
limit: 50,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Agent activity and approvals addressed to me.
|
// Workflow approvals addressed to me.
|
||||||
session.fetchHistory(
|
session.fetchHistory(
|
||||||
NostrFilter(
|
NostrFilter(
|
||||||
kinds: const [46010, 46011, 46012],
|
kinds: const [46010, 46011, 46012],
|
||||||
@@ -48,24 +83,67 @@ class ActivityNotifier extends AsyncNotifier<HomeFeedResponse> {
|
|||||||
limit: 20,
|
limit: 20,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// Agent job lifecycle events addressed to me.
|
||||||
|
session.fetchHistory(
|
||||||
|
NostrFilter(
|
||||||
|
kinds: const [43001, 43002, 43003, 43004, 43005, 43006],
|
||||||
|
tags: {
|
||||||
|
'#p': [myPk],
|
||||||
|
},
|
||||||
|
limit: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Recent DM traffic (filtered to other senders below).
|
||||||
|
if (dmChannelIds.isEmpty)
|
||||||
|
Future.value(const <NostrEvent>[])
|
||||||
|
else
|
||||||
|
session.fetchHistory(
|
||||||
|
NostrFilter(kinds: const [9], tags: {'#h': dmChannelIds}, limit: 30),
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
final mentions = results[0]
|
bool isFromOther(NostrEvent e) =>
|
||||||
.where((e) => e.pubkey.toLowerCase() != myPk.toLowerCase())
|
e.pubkey.toLowerCase() != myPk.toLowerCase();
|
||||||
.map((e) => _feedItem(e, category: 'mention'))
|
|
||||||
.toList();
|
|
||||||
final approvals = results[1]
|
|
||||||
.map((e) => _feedItem(e, category: 'needs_action'))
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
mentions.sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
// Dedupe across sources by event id, keeping the higher-priority
|
||||||
approvals.sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
// category (needs_action > mention > agent_activity > activity).
|
||||||
|
final byId = <String, FeedItem>{};
|
||||||
|
void add(Iterable<NostrEvent> events, String category) {
|
||||||
|
for (final event in events) {
|
||||||
|
final existing = byId[event.id];
|
||||||
|
if (existing != null &&
|
||||||
|
categoryPriority(existing.category) <= categoryPriority(category)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
byId[event.id] = _feedItem(event, category: category);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
add(results[1], 'needs_action');
|
||||||
|
add(results[0].where(isFromOther), 'mention');
|
||||||
|
add(results[2], 'agent_activity');
|
||||||
|
add(results[3].where(isFromOther), 'activity');
|
||||||
|
|
||||||
|
final items = byId.values.toList()
|
||||||
|
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||||
|
|
||||||
return HomeFeedResponse(
|
return HomeFeedResponse(
|
||||||
mentions: mentions,
|
mentions: [
|
||||||
needsAction: approvals,
|
for (final i in items)
|
||||||
activity: const [],
|
if (i.category == 'mention') i,
|
||||||
agentActivity: const [],
|
],
|
||||||
|
needsAction: [
|
||||||
|
for (final i in items)
|
||||||
|
if (i.category == 'needs_action') i,
|
||||||
|
],
|
||||||
|
activity: [
|
||||||
|
for (final i in items)
|
||||||
|
if (i.category == 'activity') i,
|
||||||
|
],
|
||||||
|
agentActivity: [
|
||||||
|
for (final i in items)
|
||||||
|
if (i.category == 'agent_activity') i,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,3 +170,16 @@ final activityProvider =
|
|||||||
AsyncNotifierProvider<ActivityNotifier, HomeFeedResponse>(
|
AsyncNotifierProvider<ActivityNotifier, HomeFeedResponse>(
|
||||||
ActivityNotifier.new,
|
ActivityNotifier.new,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// Conversation-grouped inbox rows derived from the raw feed. DM messages
|
||||||
|
/// group by DM channel so one conversation renders as one row.
|
||||||
|
final inboxItemsProvider = Provider<List<InboxItem>>((ref) {
|
||||||
|
final feed = ref.watch(activityProvider).value;
|
||||||
|
if (feed == null) return const [];
|
||||||
|
final dmChannelIds = {
|
||||||
|
for (final channel
|
||||||
|
in ref.watch(channelsProvider).asData?.value ?? const <Channel>[])
|
||||||
|
if (channel.isDm) channel.id,
|
||||||
|
};
|
||||||
|
return buildInboxItems(feed.all, isDmChannel: dmChannelIds.contains);
|
||||||
|
});
|
||||||
|
|||||||
144
mobile/lib/features/activity/compose_drafts_provider.dart
Normal file
144
mobile/lib/features/activity/compose_drafts_provider.dart
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../shared/relay/relay.dart';
|
||||||
|
import '../../shared/theme/theme_provider.dart';
|
||||||
|
|
||||||
|
const _draftsPrefsKey = 'compose_drafts_v1';
|
||||||
|
const _maxDrafts = 50;
|
||||||
|
|
||||||
|
/// A locally persisted, unsent composer draft.
|
||||||
|
///
|
||||||
|
/// Mobile has no durable relay-backed draft store (desktop keeps drafts
|
||||||
|
/// locally too), so drafts are device-local by design: the inbox Drafts
|
||||||
|
/// filter reflects what this device's composer has in flight.
|
||||||
|
@immutable
|
||||||
|
class ComposeDraft {
|
||||||
|
/// `<channelId>` for channel composers, `<channelId>:<threadHeadId>` for
|
||||||
|
/// thread composers.
|
||||||
|
final String key;
|
||||||
|
final String channelId;
|
||||||
|
final String? threadHeadId;
|
||||||
|
final String text;
|
||||||
|
final int updatedAt; // unix seconds
|
||||||
|
|
||||||
|
const ComposeDraft({
|
||||||
|
required this.key,
|
||||||
|
required this.channelId,
|
||||||
|
required this.threadHeadId,
|
||||||
|
required this.text,
|
||||||
|
required this.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'key': key,
|
||||||
|
'channel_id': channelId,
|
||||||
|
if (threadHeadId != null) 'thread_head_id': threadHeadId,
|
||||||
|
'text': text,
|
||||||
|
'updated_at': updatedAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
static ComposeDraft? fromJson(Object? raw) {
|
||||||
|
if (raw is! Map<String, dynamic>) return null;
|
||||||
|
final key = raw['key'];
|
||||||
|
final channelId = raw['channel_id'];
|
||||||
|
final text = raw['text'];
|
||||||
|
final updatedAt = raw['updated_at'];
|
||||||
|
if (key is! String || channelId is! String || text is! String) return null;
|
||||||
|
if (text.trim().isEmpty) return null;
|
||||||
|
return ComposeDraft(
|
||||||
|
key: key,
|
||||||
|
channelId: channelId,
|
||||||
|
threadHeadId: raw['thread_head_id'] as String?,
|
||||||
|
text: text,
|
||||||
|
updatedAt: updatedAt is int ? updatedAt : 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String composeDraftKey(String channelId, {String? threadHeadId}) =>
|
||||||
|
threadHeadId == null ? channelId : '$channelId:$threadHeadId';
|
||||||
|
|
||||||
|
/// SharedPreferences-backed store of unsent composer drafts, newest first.
|
||||||
|
///
|
||||||
|
/// Drafts are plaintext, so persistence is namespaced by community (relay)
|
||||||
|
/// and account pubkey: switching community or account rebuilds this provider
|
||||||
|
/// against that identity's own store and can never surface another
|
||||||
|
/// identity's draft text.
|
||||||
|
class ComposeDraftsNotifier extends Notifier<List<ComposeDraft>> {
|
||||||
|
late String _prefsKey;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<ComposeDraft> build() {
|
||||||
|
// Rebuild (and re-read the identity-scoped store) whenever the active
|
||||||
|
// community or the derived account pubkey changes.
|
||||||
|
final config = ref.watch(relayConfigProvider);
|
||||||
|
final pubkey = ref.watch(myPubkeyProvider) ?? 'anon';
|
||||||
|
_prefsKey = '$_draftsPrefsKey:${config.baseUrl}:$pubkey';
|
||||||
|
|
||||||
|
final prefs = ref.read(savedPrefsProvider);
|
||||||
|
final raw = prefs.getString(_prefsKey);
|
||||||
|
if (raw == null) return const [];
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(raw);
|
||||||
|
if (decoded is! List) return const [];
|
||||||
|
final drafts = decoded
|
||||||
|
.map(ComposeDraft.fromJson)
|
||||||
|
.whereType<ComposeDraft>()
|
||||||
|
.toList();
|
||||||
|
drafts.sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
|
||||||
|
return drafts;
|
||||||
|
} catch (_) {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist [text] for the composer identified by [key]. Empty text removes
|
||||||
|
/// the draft.
|
||||||
|
void save({
|
||||||
|
required String key,
|
||||||
|
required String channelId,
|
||||||
|
String? threadHeadId,
|
||||||
|
required String text,
|
||||||
|
}) {
|
||||||
|
if (text.trim().isEmpty) {
|
||||||
|
remove(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final existing = state.where((d) => d.key == key).firstOrNull;
|
||||||
|
if (existing?.text == text) return;
|
||||||
|
final draft = ComposeDraft(
|
||||||
|
key: key,
|
||||||
|
channelId: channelId,
|
||||||
|
threadHeadId: threadHeadId,
|
||||||
|
text: text,
|
||||||
|
updatedAt: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
);
|
||||||
|
final next = [draft, ...state.where((d) => d.key != key)];
|
||||||
|
_persist(next.length > _maxDrafts ? next.sublist(0, _maxDrafts) : next);
|
||||||
|
}
|
||||||
|
|
||||||
|
void remove(String key) {
|
||||||
|
if (!state.any((d) => d.key == key)) return;
|
||||||
|
_persist([...state.where((d) => d.key != key)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? textFor(String key) =>
|
||||||
|
state.where((d) => d.key == key).firstOrNull?.text;
|
||||||
|
|
||||||
|
void _persist(List<ComposeDraft> drafts) {
|
||||||
|
state = List.unmodifiable(drafts);
|
||||||
|
final prefs = ref.read(savedPrefsProvider);
|
||||||
|
prefs.setString(
|
||||||
|
_prefsKey,
|
||||||
|
jsonEncode([for (final d in drafts) d.toJson()]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final composeDraftsProvider =
|
||||||
|
NotifierProvider<ComposeDraftsNotifier, List<ComposeDraft>>(
|
||||||
|
ComposeDraftsNotifier.new,
|
||||||
|
);
|
||||||
284
mobile/lib/features/activity/inbox_item.dart
Normal file
284
mobile/lib/features/activity/inbox_item.dart
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import 'feed_item.dart';
|
||||||
|
|
||||||
|
/// Inbox filters, mirroring desktop's `InboxFilter` in
|
||||||
|
/// `desktop/src/features/home/lib/inbox.ts`.
|
||||||
|
enum InboxFilter {
|
||||||
|
all,
|
||||||
|
mention,
|
||||||
|
thread,
|
||||||
|
needsAction,
|
||||||
|
activity,
|
||||||
|
agentActivity,
|
||||||
|
reminders,
|
||||||
|
drafts,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Category ordering used to pick the label for a grouped conversation.
|
||||||
|
/// Mirrors desktop's `categoryPriority`.
|
||||||
|
int categoryPriority(String category) {
|
||||||
|
return switch (category) {
|
||||||
|
'needs_action' => 0,
|
||||||
|
'mention' => 1,
|
||||||
|
'agent_activity' => 2,
|
||||||
|
_ => 3,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Thread reference from NIP-10 tags — desktop's `getThreadReference`.
|
||||||
|
({String? parentId, String? rootId}) threadReferenceOf(
|
||||||
|
List<List<String>> tags,
|
||||||
|
) {
|
||||||
|
List<String>? rootTag;
|
||||||
|
List<String>? replyTag;
|
||||||
|
for (final tag in tags) {
|
||||||
|
if (tag.length >= 4 && tag[0] == 'e') {
|
||||||
|
if (tag[3] == 'root') rootTag = tag;
|
||||||
|
if (tag[3] == 'reply') replyTag = tag;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (replyTag == null) return (parentId: null, rootId: null);
|
||||||
|
final parentId = replyTag[1];
|
||||||
|
return (parentId: parentId, rootId: rootTag?[1] ?? parentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Broadcast replies surface at the channel top level — desktop's
|
||||||
|
/// `isBroadcastReply`.
|
||||||
|
bool isBroadcastReply(List<List<String>> tags) {
|
||||||
|
return tags.any(
|
||||||
|
(tag) => tag.length >= 2 && tag[0] == 'broadcast' && tag[1] == '1',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A thread reply that is not broadcast — desktop's `isThreadReply`.
|
||||||
|
bool isThreadReply(List<List<String>> tags) {
|
||||||
|
return threadReferenceOf(tags).parentId != null && !isBroadcastReply(tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stable conversation identity: `rootId ?? parentId ?? event.id`, except
|
||||||
|
/// ordinary (non-thread) DM messages, which group by DM channel identity so
|
||||||
|
/// one DM conversation renders as one row. Mirrors desktop's
|
||||||
|
/// `getInboxConversationId`.
|
||||||
|
String inboxConversationId(
|
||||||
|
List<List<String>> tags,
|
||||||
|
String eventId, {
|
||||||
|
String? dmChannelId,
|
||||||
|
}) {
|
||||||
|
final thread = threadReferenceOf(tags);
|
||||||
|
final threadId = thread.rootId ?? thread.parentId;
|
||||||
|
if (threadId != null) return threadId;
|
||||||
|
if (dmChannelId != null) return 'dm:$dmChannelId';
|
||||||
|
return eventId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One conversation-grouped inbox row. Mirrors desktop's `InboxItem`.
|
||||||
|
@immutable
|
||||||
|
class InboxItem {
|
||||||
|
/// Stable conversation identity for the thread group.
|
||||||
|
final String conversationId;
|
||||||
|
|
||||||
|
/// Representative (latest) event id.
|
||||||
|
final String id;
|
||||||
|
|
||||||
|
/// Representative (latest) feed item.
|
||||||
|
final FeedItem item;
|
||||||
|
|
||||||
|
/// All events grouped into this conversation, unordered.
|
||||||
|
final List<FeedItem> groupItems;
|
||||||
|
|
||||||
|
/// Distinct categories present in the group, priority-sorted.
|
||||||
|
final List<String> categories;
|
||||||
|
|
||||||
|
final bool isActionRequired;
|
||||||
|
final int latestActivityAt;
|
||||||
|
|
||||||
|
const InboxItem({
|
||||||
|
required this.conversationId,
|
||||||
|
required this.id,
|
||||||
|
required this.item,
|
||||||
|
required this.groupItems,
|
||||||
|
required this.categories,
|
||||||
|
required this.isActionRequired,
|
||||||
|
required this.latestActivityAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Root id if any grouped event carries thread-reply tags.
|
||||||
|
String? get threadRootId {
|
||||||
|
for (final candidate in [item, ...groupItems]) {
|
||||||
|
if (isThreadReply(candidate.tags)) {
|
||||||
|
return threadReferenceOf(candidate.tags).rootId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The event the row should deep-link to: the oldest event in the group
|
||||||
|
/// newer than [readAt] (oldest unread), falling back to the latest event.
|
||||||
|
FeedItem deepLinkTarget(int? readAt) {
|
||||||
|
if (readAt != null) {
|
||||||
|
FeedItem? oldestUnread;
|
||||||
|
for (final candidate in groupItems) {
|
||||||
|
if (candidate.createdAt <= readAt) continue;
|
||||||
|
if (oldestUnread == null ||
|
||||||
|
candidate.createdAt < oldestUnread.createdAt) {
|
||||||
|
oldestUnread = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (oldestUnread != null) return oldestUnread;
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Contextual row label — desktop's `getInboxTypeLabel`:
|
||||||
|
/// "DM from X" / "Mentioned in" / "Needs action in" / "Thread in" / "... in".
|
||||||
|
({String text, String? channelLabel}) inboxTypeLabel(
|
||||||
|
InboxItem item, {
|
||||||
|
required String? channelName,
|
||||||
|
required bool isDm,
|
||||||
|
required String senderLabel,
|
||||||
|
}) {
|
||||||
|
if (isDm) {
|
||||||
|
return (
|
||||||
|
text: senderLabel.isNotEmpty ? 'DM from $senderLabel' : 'DM',
|
||||||
|
channelLabel: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final category = item.categories.firstOrNull ?? item.item.category;
|
||||||
|
if (category == 'mention') {
|
||||||
|
return (
|
||||||
|
text: channelName != null ? 'Mentioned in' : 'Mentioned',
|
||||||
|
channelLabel: channelName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (category == 'needs_action') {
|
||||||
|
return (
|
||||||
|
text: channelName != null ? 'Needs action in' : 'Needs action',
|
||||||
|
channelLabel: channelName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (item.threadRootId != null) {
|
||||||
|
return (
|
||||||
|
text: channelName != null ? 'Thread in' : 'Thread',
|
||||||
|
channelLabel: channelName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final headline = item.item.headline;
|
||||||
|
return (
|
||||||
|
text: channelName != null ? '$headline in' : headline,
|
||||||
|
channelLabel: channelName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether [item] matches [filter] — desktop's `matchesInboxFilter`.
|
||||||
|
/// `reminders` and `drafts` are separate surfaces and never match here.
|
||||||
|
bool matchesInboxFilter(InboxItem item, InboxFilter filter) {
|
||||||
|
return switch (filter) {
|
||||||
|
InboxFilter.all => true,
|
||||||
|
InboxFilter.thread => [
|
||||||
|
item.item,
|
||||||
|
...item.groupItems,
|
||||||
|
].any((i) => isThreadReply(i.tags)),
|
||||||
|
InboxFilter.mention => item.categories.contains('mention'),
|
||||||
|
InboxFilter.needsAction => item.categories.contains('needs_action'),
|
||||||
|
InboxFilter.activity => item.categories.contains('activity'),
|
||||||
|
InboxFilter.agentActivity => item.categories.contains('agent_activity'),
|
||||||
|
InboxFilter.reminders || InboxFilter.drafts => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Group raw feed items into conversation rows sorted by latest activity.
|
||||||
|
/// [isDmChannel] identifies DM channels so their ordinary top-level messages
|
||||||
|
/// collapse into one row per DM conversation. Mirrors desktop's
|
||||||
|
/// `buildInboxItems`.
|
||||||
|
List<InboxItem> buildInboxItems(
|
||||||
|
Iterable<FeedItem> feedItems, {
|
||||||
|
bool Function(String channelId)? isDmChannel,
|
||||||
|
}) {
|
||||||
|
final groups = <String, List<FeedItem>>{};
|
||||||
|
for (final item in feedItems) {
|
||||||
|
final channelId = item.channelId;
|
||||||
|
final dmChannelId =
|
||||||
|
channelId != null && (isDmChannel?.call(channelId) ?? false)
|
||||||
|
? channelId
|
||||||
|
: null;
|
||||||
|
final key = inboxConversationId(
|
||||||
|
item.tags,
|
||||||
|
item.id,
|
||||||
|
dmChannelId: dmChannelId,
|
||||||
|
);
|
||||||
|
groups.putIfAbsent(key, () => []).add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
final rows = <InboxItem>[];
|
||||||
|
for (final entry in groups.entries) {
|
||||||
|
final items = entry.value;
|
||||||
|
var latest = items.first;
|
||||||
|
var latestActivityAt = 0;
|
||||||
|
for (final item in items) {
|
||||||
|
if (item.createdAt > latest.createdAt) latest = item;
|
||||||
|
if (item.createdAt > latestActivityAt) latestActivityAt = item.createdAt;
|
||||||
|
}
|
||||||
|
final categories = items.map((i) => i.category).toSet().toList()
|
||||||
|
..sort((a, b) => categoryPriority(a).compareTo(categoryPriority(b)));
|
||||||
|
|
||||||
|
rows.add(
|
||||||
|
InboxItem(
|
||||||
|
conversationId: entry.key,
|
||||||
|
id: latest.id,
|
||||||
|
item: latest,
|
||||||
|
groupItems: List.unmodifiable(items),
|
||||||
|
categories: List.unmodifiable(categories),
|
||||||
|
isActionRequired: categories.contains('needs_action'),
|
||||||
|
latestActivityAt: latestActivityAt,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.sort((a, b) => b.latestActivityAt.compareTo(a.latestActivityAt));
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Date-bucket label for section headers — desktop's `groupInboxItems`:
|
||||||
|
/// Today / Yesterday / weekday (within 7 days) / short date.
|
||||||
|
String inboxDayLabel(int unixSeconds, {DateTime? now}) {
|
||||||
|
final current = now ?? DateTime.now();
|
||||||
|
final date = DateTime.fromMillisecondsSinceEpoch(unixSeconds * 1000);
|
||||||
|
final today = DateTime(current.year, current.month, current.day);
|
||||||
|
final day = DateTime(date.year, date.month, date.day);
|
||||||
|
final dayDiff = today.difference(day).inDays;
|
||||||
|
|
||||||
|
if (dayDiff == 0) return 'Today';
|
||||||
|
if (dayDiff == 1) return 'Yesterday';
|
||||||
|
if (dayDiff < 7 && dayDiff > 0) {
|
||||||
|
const weekdays = [
|
||||||
|
'Monday',
|
||||||
|
'Tuesday',
|
||||||
|
'Wednesday',
|
||||||
|
'Thursday',
|
||||||
|
'Friday',
|
||||||
|
'Saturday',
|
||||||
|
'Sunday',
|
||||||
|
];
|
||||||
|
return weekdays[date.weekday - 1];
|
||||||
|
}
|
||||||
|
const months = [
|
||||||
|
'Jan',
|
||||||
|
'Feb',
|
||||||
|
'Mar',
|
||||||
|
'Apr',
|
||||||
|
'May',
|
||||||
|
'Jun',
|
||||||
|
'Jul',
|
||||||
|
'Aug',
|
||||||
|
'Sep',
|
||||||
|
'Oct',
|
||||||
|
'Nov',
|
||||||
|
'Dec',
|
||||||
|
];
|
||||||
|
final label = '${months[date.month - 1]} ${date.day}';
|
||||||
|
return date.year == current.year ? label : '$label, ${date.year}';
|
||||||
|
}
|
||||||
93
mobile/lib/features/activity/inbox_local_state_provider.dart
Normal file
93
mobile/lib/features/activity/inbox_local_state_provider.dart
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../shared/theme/theme_provider.dart';
|
||||||
|
|
||||||
|
const _localStateKey = 'activity_inbox_local_state_v1';
|
||||||
|
const _maxTrackedIds = 500;
|
||||||
|
|
||||||
|
/// Local, per-device inbox row overrides — mirrors desktop's
|
||||||
|
/// `useFeedItemState`:
|
||||||
|
/// - [doneIds] marks channel-less rows (e.g. approvals without an `h` tag)
|
||||||
|
/// as read, since they have no NIP-RS context to advance.
|
||||||
|
/// - [unreadIds] is the per-row "mark unread" override that reopens a row
|
||||||
|
/// without rewinding the shared channel/thread read marker.
|
||||||
|
@immutable
|
||||||
|
class InboxLocalState {
|
||||||
|
final Set<String> doneIds;
|
||||||
|
final Set<String> unreadIds;
|
||||||
|
|
||||||
|
const InboxLocalState({required this.doneIds, required this.unreadIds});
|
||||||
|
|
||||||
|
const InboxLocalState.empty() : doneIds = const {}, unreadIds = const {};
|
||||||
|
}
|
||||||
|
|
||||||
|
class InboxLocalStateNotifier extends Notifier<InboxLocalState> {
|
||||||
|
@override
|
||||||
|
InboxLocalState build() {
|
||||||
|
final prefs = ref.read(savedPrefsProvider);
|
||||||
|
final raw = prefs.getString(_localStateKey);
|
||||||
|
if (raw == null) return const InboxLocalState.empty();
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(raw);
|
||||||
|
if (decoded is! Map<String, dynamic>) {
|
||||||
|
return const InboxLocalState.empty();
|
||||||
|
}
|
||||||
|
Set<String> readIds(Object? value) =>
|
||||||
|
value is List ? value.whereType<String>().toSet() : const <String>{};
|
||||||
|
return InboxLocalState(
|
||||||
|
doneIds: readIds(decoded['done']),
|
||||||
|
unreadIds: readIds(decoded['unread']),
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
return const InboxLocalState.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void markDone(String id) {
|
||||||
|
_update(
|
||||||
|
doneIds: {...state.doneIds, id},
|
||||||
|
unreadIds: {...state.unreadIds}..remove(id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void markUnread(Iterable<String> ids) {
|
||||||
|
_update(
|
||||||
|
doneIds: {...state.doneIds}..removeAll(ids),
|
||||||
|
unreadIds: {...state.unreadIds, ...ids},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearUnread(Iterable<String> ids) {
|
||||||
|
if (!ids.any(state.unreadIds.contains)) return;
|
||||||
|
_update(
|
||||||
|
doneIds: state.doneIds,
|
||||||
|
unreadIds: {...state.unreadIds}..removeAll(ids),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _update({required Set<String> doneIds, required Set<String> unreadIds}) {
|
||||||
|
Set<String> capped(Set<String> ids) => ids.length <= _maxTrackedIds
|
||||||
|
? ids
|
||||||
|
: ids.skip(ids.length - _maxTrackedIds).toSet();
|
||||||
|
state = InboxLocalState(
|
||||||
|
doneIds: capped(doneIds),
|
||||||
|
unreadIds: capped(unreadIds),
|
||||||
|
);
|
||||||
|
final prefs = ref.read(savedPrefsProvider);
|
||||||
|
prefs.setString(
|
||||||
|
_localStateKey,
|
||||||
|
jsonEncode({
|
||||||
|
'done': state.doneIds.toList(),
|
||||||
|
'unread': state.unreadIds.toList(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final inboxLocalStateProvider =
|
||||||
|
NotifierProvider<InboxLocalStateNotifier, InboxLocalState>(
|
||||||
|
InboxLocalStateNotifier.new,
|
||||||
|
);
|
||||||
71
mobile/lib/features/activity/inbox_read_state.dart
Normal file
71
mobile/lib/features/activity/inbox_read_state.dart
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import '../channels/read_state/read_state_format.dart';
|
||||||
|
import 'inbox_item.dart';
|
||||||
|
|
||||||
|
/// Resolves the effective NIP-RS read marker for one inbox row, mirroring
|
||||||
|
/// desktop's `resolveInboxItemReadAt`:
|
||||||
|
/// - thread rows use `max(thread:<root>, msg:<id>)`
|
||||||
|
/// - channel rows use the channel context marker
|
||||||
|
/// - rows with no channel have no marker (caller falls back to local state)
|
||||||
|
int? resolveInboxItemReadAt(
|
||||||
|
InboxItem item, {
|
||||||
|
required int? Function(String contextId) markerOf,
|
||||||
|
}) {
|
||||||
|
final channelId = item.item.channelId;
|
||||||
|
final threadRootId = item.threadRootId;
|
||||||
|
if (threadRootId != null) {
|
||||||
|
return maxReadAt([
|
||||||
|
markerOf(threadContextKey(threadRootId)),
|
||||||
|
markerOf(msgContextKey(item.item.id)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return channelId == null ? null : markerOf(channelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the row is read ("done"), mirroring desktop's
|
||||||
|
/// `useHomeInboxReadState` projection: a local unread override always wins;
|
||||||
|
/// otherwise the row is done when no grouped activity is newer than the
|
||||||
|
/// shared read marker; channel-less rows fall back to the local done set.
|
||||||
|
bool isInboxItemDone(
|
||||||
|
InboxItem item, {
|
||||||
|
required int? Function(String contextId) markerOf,
|
||||||
|
required Set<String> localUnreadOverrides,
|
||||||
|
required Set<String> localDoneSet,
|
||||||
|
}) {
|
||||||
|
final ids = groupedInboxItemIds(item);
|
||||||
|
if (ids.any(localUnreadOverrides.contains)) return false;
|
||||||
|
|
||||||
|
final readAt = resolveInboxItemReadAt(item, markerOf: markerOf);
|
||||||
|
if (readAt != null) return item.latestActivityAt <= readAt;
|
||||||
|
|
||||||
|
if (item.threadRootId != null || item.item.channelId != null) return false;
|
||||||
|
return localDoneSet.contains(item.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All event ids identified with the row — desktop's `getGroupedInboxItemIds`.
|
||||||
|
List<String> groupedInboxItemIds(InboxItem item) {
|
||||||
|
return {item.id, item.item.id, ...item.groupItems.map((i) => i.id)}.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The channel-level timestamp that marking a thread row read should also
|
||||||
|
/// advance — desktop's `getGroupedChannelReadTimestamp`. Only top-level
|
||||||
|
/// (non-thread-reply) grouped events count, so marking a thread read cannot
|
||||||
|
/// swallow unrelated channel messages.
|
||||||
|
({String channelId, int timestamp})? groupedChannelReadTimestamp(
|
||||||
|
InboxItem item,
|
||||||
|
) {
|
||||||
|
final channelId = item.item.channelId;
|
||||||
|
if (channelId == null) return null;
|
||||||
|
|
||||||
|
int? timestamp;
|
||||||
|
for (final groupItem in item.groupItems) {
|
||||||
|
if (groupItem.channelId != channelId || isThreadReply(groupItem.tags)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (timestamp == null || groupItem.createdAt > timestamp) {
|
||||||
|
timestamp = groupItem.createdAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return timestamp == null
|
||||||
|
? null
|
||||||
|
: (channelId: channelId, timestamp: timestamp);
|
||||||
|
}
|
||||||
224
mobile/lib/features/activity/reminders_provider.dart
Normal file
224
mobile/lib/features/activity/reminders_provider.dart
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:nostr/nostr.dart' as nostr;
|
||||||
|
|
||||||
|
import '../../shared/crypto/nip44.dart';
|
||||||
|
import '../../shared/relay/relay.dart';
|
||||||
|
|
||||||
|
/// NIP-ER event reminder kind — matches desktop's `KIND_EVENT_REMINDER`.
|
||||||
|
const kindEventReminder = 30300;
|
||||||
|
|
||||||
|
/// Message a reminder points at. Mirrors desktop's `ReminderTarget`.
|
||||||
|
@immutable
|
||||||
|
class ReminderTarget {
|
||||||
|
final String eventId;
|
||||||
|
final String channelId;
|
||||||
|
final String preview;
|
||||||
|
final String authorPubkey;
|
||||||
|
|
||||||
|
const ReminderTarget({
|
||||||
|
required this.eventId,
|
||||||
|
required this.channelId,
|
||||||
|
required this.preview,
|
||||||
|
required this.authorPubkey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A decrypted NIP-ER reminder. Mirrors desktop's `Reminder`.
|
||||||
|
@immutable
|
||||||
|
class Reminder {
|
||||||
|
/// The `d`-tag (unique reminder identity).
|
||||||
|
final String id;
|
||||||
|
|
||||||
|
/// Unix seconds when due; null for done/cancelled reminders.
|
||||||
|
final int? notBefore;
|
||||||
|
|
||||||
|
final String status; // "pending" | "done" | "cancelled"
|
||||||
|
final ReminderTarget? target;
|
||||||
|
final String? note;
|
||||||
|
final int createdAt;
|
||||||
|
final String eventId;
|
||||||
|
|
||||||
|
const Reminder({
|
||||||
|
required this.id,
|
||||||
|
required this.notBefore,
|
||||||
|
required this.status,
|
||||||
|
required this.target,
|
||||||
|
required this.note,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.eventId,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool isDue(int nowUnixSeconds) =>
|
||||||
|
status == 'pending' && notBefore != null && notBefore! <= nowUnixSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse NIP-ER `not_before`: ASCII digits, no leading zero except "0".
|
||||||
|
/// Mirrors desktop's `parseNotBefore` strictness.
|
||||||
|
int? parseNotBefore(String raw) {
|
||||||
|
if (!RegExp(r'^(0|[1-9][0-9]*)$').hasMatch(raw)) return null;
|
||||||
|
return int.tryParse(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate decrypted reminder plaintext — desktop's `parseReminderContent`.
|
||||||
|
/// Off-shape content fails closed (returns null).
|
||||||
|
({String status, ReminderTarget? target, String? note})? parseReminderContent(
|
||||||
|
String plaintext,
|
||||||
|
) {
|
||||||
|
final Object? parsed;
|
||||||
|
try {
|
||||||
|
parsed = jsonDecode(plaintext);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (parsed is! Map<String, dynamic>) return null;
|
||||||
|
|
||||||
|
final status = parsed['status'];
|
||||||
|
if (status != 'pending' && status != 'done' && status != 'cancelled') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final note = parsed['note'];
|
||||||
|
if (note != null && note is! String) return null;
|
||||||
|
|
||||||
|
ReminderTarget? target;
|
||||||
|
final rawTarget = parsed['target'];
|
||||||
|
if (rawTarget != null) {
|
||||||
|
if (rawTarget is! Map<String, dynamic>) return null;
|
||||||
|
final eventId = rawTarget['eventId'];
|
||||||
|
final channelId = rawTarget['channelId'];
|
||||||
|
final preview = rawTarget['preview'];
|
||||||
|
final authorPubkey = rawTarget['authorPubkey'];
|
||||||
|
if (eventId is! String ||
|
||||||
|
channelId is! String ||
|
||||||
|
preview is! String ||
|
||||||
|
authorPubkey is! String) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
target = ReminderTarget(
|
||||||
|
eventId: eventId,
|
||||||
|
channelId: channelId,
|
||||||
|
preview: preview,
|
||||||
|
authorPubkey: authorPubkey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final noteText = note as String?;
|
||||||
|
if (target == null && (noteText == null || noteText.isEmpty)) return null;
|
||||||
|
|
||||||
|
return (status: status as String, target: target, note: noteText);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode one kind:30300 event into a [Reminder], or null when malformed.
|
||||||
|
Reminder? decodeReminderEvent(
|
||||||
|
NostrEvent event, {
|
||||||
|
required String Function(String ciphertext) decrypt,
|
||||||
|
}) {
|
||||||
|
String? dTag;
|
||||||
|
int? notBefore;
|
||||||
|
for (final tag in event.tags) {
|
||||||
|
if (tag.length >= 2 && tag[0] == 'd') dTag ??= tag[1];
|
||||||
|
if (tag.length >= 2 && tag[0] == 'not_before') {
|
||||||
|
notBefore ??= parseNotBefore(tag[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dTag == null) return null;
|
||||||
|
|
||||||
|
final String plaintext;
|
||||||
|
try {
|
||||||
|
plaintext = decrypt(event.content);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final content = parseReminderContent(plaintext);
|
||||||
|
if (content == null) return null;
|
||||||
|
|
||||||
|
return Reminder(
|
||||||
|
id: dTag,
|
||||||
|
notBefore: notBefore,
|
||||||
|
status: content.status,
|
||||||
|
target: content.target,
|
||||||
|
note: content.note,
|
||||||
|
createdAt: event.createdAt,
|
||||||
|
eventId: event.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches the user's NIP-ER reminders (kind 30300, self-encrypted). These
|
||||||
|
/// are the same relay events Buzz Desktop reads and writes, so reminders
|
||||||
|
/// created on Desktop appear here and vice versa.
|
||||||
|
class RemindersNotifier extends AsyncNotifier<List<Reminder>> {
|
||||||
|
@override
|
||||||
|
Future<List<Reminder>> build() {
|
||||||
|
ref.watch(relayConfigProvider);
|
||||||
|
ref.watch(relaySessionProvider);
|
||||||
|
return _fetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Reminder>> _fetch() async {
|
||||||
|
final config = ref.read(relayConfigProvider);
|
||||||
|
final myPk = ref.read(myPubkeyProvider);
|
||||||
|
final nsec = config.nsec?.trim();
|
||||||
|
if (myPk == null || nsec == null || nsec.isEmpty) return const [];
|
||||||
|
|
||||||
|
final String privkeyHex;
|
||||||
|
try {
|
||||||
|
privkeyHex = nostr.Nip19.decode(payload: nsec).data;
|
||||||
|
} catch (_) {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
if (privkeyHex.isEmpty) return const [];
|
||||||
|
final conversationKey = getConversationKey(privkeyHex, myPk);
|
||||||
|
|
||||||
|
final session = ref.read(relaySessionProvider.notifier);
|
||||||
|
final events = await session.fetchHistory(
|
||||||
|
NostrFilter(
|
||||||
|
kinds: const [kindEventReminder],
|
||||||
|
authors: [myPk],
|
||||||
|
limit: 200,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Parameterized-replaceable: keep only the newest event per d-tag.
|
||||||
|
final newestByDTag = <String, NostrEvent>{};
|
||||||
|
for (final event in events) {
|
||||||
|
final dTag = event.getTagValue('d');
|
||||||
|
if (dTag == null) continue;
|
||||||
|
final existing = newestByDTag[dTag];
|
||||||
|
if (existing == null || event.createdAt > existing.createdAt) {
|
||||||
|
newestByDTag[dTag] = event;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final reminders = <Reminder>[];
|
||||||
|
for (final event in newestByDTag.values) {
|
||||||
|
final reminder = decodeReminderEvent(
|
||||||
|
event,
|
||||||
|
decrypt: (ciphertext) => nip44Decrypt(conversationKey, ciphertext),
|
||||||
|
);
|
||||||
|
if (reminder != null) reminders.add(reminder);
|
||||||
|
}
|
||||||
|
reminders.sort(
|
||||||
|
(a, b) =>
|
||||||
|
(a.notBefore ?? a.createdAt).compareTo(b.notBefore ?? b.createdAt),
|
||||||
|
);
|
||||||
|
return reminders;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refresh() async {
|
||||||
|
state = await AsyncValue.guard(_fetch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final remindersProvider =
|
||||||
|
AsyncNotifierProvider<RemindersNotifier, List<Reminder>>(
|
||||||
|
RemindersNotifier.new,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Count of due pending reminders — powers the filter badge.
|
||||||
|
final dueReminderCountProvider = Provider<int>((ref) {
|
||||||
|
final reminders = ref.watch(remindersProvider).value ?? const <Reminder>[];
|
||||||
|
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||||
|
return reminders.where((r) => r.isDue(now)).length;
|
||||||
|
});
|
||||||
@@ -19,6 +19,7 @@ import '../profile/user_cache_provider.dart';
|
|||||||
import '../profile/user_profile.dart';
|
import '../profile/user_profile.dart';
|
||||||
import '../../shared/custom_emoji/custom_emoji.dart';
|
import '../../shared/custom_emoji/custom_emoji.dart';
|
||||||
import '../../shared/custom_emoji/custom_emoji_provider.dart';
|
import '../../shared/custom_emoji/custom_emoji_provider.dart';
|
||||||
|
import '../activity/compose_drafts_provider.dart';
|
||||||
import 'camera_capture_cleanup.dart';
|
import 'camera_capture_cleanup.dart';
|
||||||
import 'channel.dart';
|
import 'channel.dart';
|
||||||
import 'channel_management_provider.dart';
|
import 'channel_management_provider.dart';
|
||||||
@@ -77,6 +78,46 @@ class ComposeBar extends HookConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final controller = useMemoized(_MarkdownEditingController.new);
|
final controller = useMemoized(_MarkdownEditingController.new);
|
||||||
useEffect(() => controller.dispose, [controller]);
|
useEffect(() => controller.dispose, [controller]);
|
||||||
|
|
||||||
|
// Restore and persist unsent text as a local draft so the Activity
|
||||||
|
// inbox Drafts filter reflects real composer state.
|
||||||
|
//
|
||||||
|
// The effect is additionally keyed on the active relay + pubkey identity:
|
||||||
|
// provider-level namespacing alone cannot protect a composer that stays
|
||||||
|
// mounted through an in-place community/account switch — the controller
|
||||||
|
// would retain the old identity's text and the next edit would persist it
|
||||||
|
// into the new identity's store. On identity change we replace the
|
||||||
|
// controller content with the new identity's own saved draft (or clear).
|
||||||
|
final draftKey = composeDraftKey(channelId, threadHeadId: threadHeadId);
|
||||||
|
final draftIdentity =
|
||||||
|
'${ref.watch(relayConfigProvider).baseUrl}'
|
||||||
|
':${ref.watch(myPubkeyProvider) ?? 'anon'}';
|
||||||
|
final lastDraftIdentity = useRef<String?>(null);
|
||||||
|
useEffect(() {
|
||||||
|
final identityChanged =
|
||||||
|
lastDraftIdentity.value != null &&
|
||||||
|
lastDraftIdentity.value != draftIdentity;
|
||||||
|
lastDraftIdentity.value = draftIdentity;
|
||||||
|
final saved = ref.read(composeDraftsProvider.notifier).textFor(draftKey);
|
||||||
|
if (identityChanged) {
|
||||||
|
controller.text = saved ?? '';
|
||||||
|
} else if (saved != null && controller.text.isEmpty) {
|
||||||
|
controller.text = saved;
|
||||||
|
}
|
||||||
|
void persistDraft() {
|
||||||
|
ref
|
||||||
|
.read(composeDraftsProvider.notifier)
|
||||||
|
.save(
|
||||||
|
key: draftKey,
|
||||||
|
channelId: channelId,
|
||||||
|
threadHeadId: threadHeadId,
|
||||||
|
text: controller.text,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.addListener(persistDraft);
|
||||||
|
return () => controller.removeListener(persistDraft);
|
||||||
|
}, [controller, draftKey, draftIdentity]);
|
||||||
final focusNode = useFocusNode();
|
final focusNode = useFocusNode();
|
||||||
final isComposerExpanded = useState(false);
|
final isComposerExpanded = useState(false);
|
||||||
final showAttachments = useState(false);
|
final showAttachments = useState(false);
|
||||||
|
|||||||
@@ -1,47 +1,47 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:hooks_riverpod/misc.dart';
|
|
||||||
import 'package:buzz/features/activity/activity_page.dart';
|
import 'package:buzz/features/activity/activity_page.dart';
|
||||||
import 'package:buzz/features/activity/activity_provider.dart';
|
import 'package:buzz/features/activity/activity_provider.dart';
|
||||||
import 'package:buzz/features/activity/feed_item.dart';
|
import 'package:buzz/features/activity/feed_item.dart';
|
||||||
|
import 'package:buzz/features/activity/reminders_provider.dart';
|
||||||
import 'package:buzz/features/channels/channel.dart';
|
import 'package:buzz/features/channels/channel.dart';
|
||||||
import 'package:buzz/features/channels/channels_provider.dart';
|
import 'package:buzz/features/channels/channels_provider.dart';
|
||||||
|
import 'package:buzz/features/channels/read_state/read_state_provider.dart';
|
||||||
import 'package:buzz/features/profile/user_cache_provider.dart';
|
import 'package:buzz/features/profile/user_cache_provider.dart';
|
||||||
import 'package:buzz/features/profile/user_profile.dart';
|
import 'package:buzz/features/profile/user_profile.dart';
|
||||||
import 'package:buzz/shared/theme/theme.dart';
|
import 'package:buzz/shared/theme/theme.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
Widget buildTestable({required List<Override> overrides}) {
|
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||||
return ProviderScope(
|
|
||||||
overrides: overrides,
|
|
||||||
child: MaterialApp(theme: AppTheme.light(), home: const ActivityPage()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final testMention = FeedItem(
|
final testMention = FeedItem(
|
||||||
id: 'm1',
|
id: 'm1',
|
||||||
kind: 9,
|
kind: 9,
|
||||||
pubkey: 'alice_pk',
|
pubkey: 'alice_pk',
|
||||||
content: 'Hey check this out',
|
content: 'Hey check this out',
|
||||||
createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000 - 120,
|
createdAt: now - 120,
|
||||||
channelId: 'ch1',
|
channelId: 'ch1',
|
||||||
channelName: 'general',
|
channelName: 'general',
|
||||||
tags: const [],
|
tags: const [],
|
||||||
category: 'mention',
|
category: 'mention',
|
||||||
);
|
);
|
||||||
|
|
||||||
final testActivity = FeedItem(
|
final testThreadReply = FeedItem(
|
||||||
id: 'a1',
|
id: 'a1',
|
||||||
kind: 9,
|
kind: 9,
|
||||||
pubkey: 'bob_pk',
|
pubkey: 'bob_pk',
|
||||||
content: 'Deployed the fix',
|
content: 'Deployed the fix',
|
||||||
createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000 - 3600,
|
createdAt: now - 3600,
|
||||||
channelId: 'ch2',
|
channelId: 'ch2',
|
||||||
channelName: 'engineering',
|
channelName: 'engineering',
|
||||||
tags: const [],
|
tags: const [
|
||||||
|
['e', 'root1', '', 'root'],
|
||||||
|
['e', 'root1', '', 'reply'],
|
||||||
|
],
|
||||||
category: 'activity',
|
category: 'activity',
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ void main() {
|
|||||||
kind: 43004,
|
kind: 43004,
|
||||||
pubkey: 'agent_pk',
|
pubkey: 'agent_pk',
|
||||||
content: 'Job completed successfully',
|
content: 'Job completed successfully',
|
||||||
createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000 - 60,
|
createdAt: now - 60,
|
||||||
channelId: 'ch1',
|
channelId: 'ch1',
|
||||||
channelName: 'general',
|
channelName: 'general',
|
||||||
tags: const [],
|
tags: const [],
|
||||||
@@ -60,7 +60,7 @@ void main() {
|
|||||||
final testFeed = HomeFeedResponse(
|
final testFeed = HomeFeedResponse(
|
||||||
mentions: [testMention],
|
mentions: [testMention],
|
||||||
needsAction: const [],
|
needsAction: const [],
|
||||||
activity: [testActivity],
|
activity: [testThreadReply],
|
||||||
agentActivity: [testAgent],
|
agentActivity: [testAgent],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -95,68 +95,66 @@ void main() {
|
|||||||
'agent_pk': const UserProfile(pubkey: 'agent_pk', displayName: 'Scout'),
|
'agent_pk': const UserProfile(pubkey: 'agent_pk', displayName: 'Scout'),
|
||||||
};
|
};
|
||||||
|
|
||||||
final emptyUsers = <String, UserProfile>{};
|
Future<Widget> buildTestable({
|
||||||
|
HomeFeedResponse? feed,
|
||||||
List<Override> defaultOverrides({HomeFeedResponse? feed}) => [
|
ActivityNotifier Function()? activityNotifier,
|
||||||
|
Map<String, UserProfile>? users,
|
||||||
|
Map<String, int> readContexts = const {},
|
||||||
|
List<Channel>? channels,
|
||||||
|
}) async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
savedPrefsProvider.overrideWithValue(prefs),
|
||||||
activityProvider.overrideWith(
|
activityProvider.overrideWith(
|
||||||
() => _FakeActivityNotifier(feed ?? testFeed),
|
activityNotifier ?? () => _FakeActivityNotifier(feed ?? testFeed),
|
||||||
),
|
),
|
||||||
channelsProvider.overrideWith(() => _FakeChannelsNotifier(testChannels)),
|
channelsProvider.overrideWith(
|
||||||
userCacheProvider.overrideWith(() => _FakeUserCacheNotifier(testUsers)),
|
() => _FakeChannelsNotifier(channels ?? testChannels),
|
||||||
];
|
),
|
||||||
|
userCacheProvider.overrideWith(
|
||||||
|
() => _FakeUserCacheNotifier(users ?? testUsers),
|
||||||
|
),
|
||||||
|
readStateProvider.overrideWith(
|
||||||
|
() => _FakeReadStateNotifier(readContexts),
|
||||||
|
),
|
||||||
|
remindersProvider.overrideWith(() => _FakeRemindersNotifier(const [])),
|
||||||
|
],
|
||||||
|
child: MaterialApp(theme: AppTheme.light(), home: const ActivityPage()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
testWidgets('shows loading skeleton while feed loads', (tester) async {
|
testWidgets('shows loading skeleton while feed loads', (tester) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
buildTestable(
|
await buildTestable(activityNotifier: _PendingActivityNotifier.new),
|
||||||
overrides: [
|
|
||||||
activityProvider.overrideWith(() => _PendingActivityNotifier()),
|
|
||||||
channelsProvider.overrideWith(() => _FakeChannelsNotifier([])),
|
|
||||||
userCacheProvider.overrideWith(
|
|
||||||
() => _FakeUserCacheNotifier(emptyUsers),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
// Single pump - don't settle, the future never completes.
|
// Single pump - don't settle, the future never completes.
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
// Skeleton containers should be present (loading state).
|
|
||||||
expect(find.byType(Container), findsWidgets);
|
expect(find.byType(Container), findsWidgets);
|
||||||
// No feed content visible.
|
expect(find.text('Hey check this out'), findsNothing);
|
||||||
expect(find.text('Mention'), findsNothing);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('shows empty state when feed is empty', (tester) async {
|
testWidgets('shows empty state when feed is empty', (tester) async {
|
||||||
final emptyFeed = HomeFeedResponse(
|
|
||||||
mentions: [],
|
|
||||||
needsAction: [],
|
|
||||||
activity: [],
|
|
||||||
agentActivity: [],
|
|
||||||
);
|
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
buildTestable(overrides: defaultOverrides(feed: emptyFeed)),
|
await buildTestable(
|
||||||
|
feed: HomeFeedResponse(
|
||||||
|
mentions: const [],
|
||||||
|
needsAction: const [],
|
||||||
|
activity: const [],
|
||||||
|
agentActivity: const [],
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('No activity yet'), findsOneWidget);
|
expect(find.text('No activity yet'), findsOneWidget);
|
||||||
expect(
|
|
||||||
find.text('Mentions, replies, and reactions will show up here.'),
|
|
||||||
findsOneWidget,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('shows error view with retry button', (tester) async {
|
testWidgets('shows error view with retry button', (tester) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
buildTestable(
|
await buildTestable(activityNotifier: _ErrorActivityNotifier.new),
|
||||||
overrides: [
|
|
||||||
activityProvider.overrideWith(() => _ErrorActivityNotifier()),
|
|
||||||
channelsProvider.overrideWith(() => _FakeChannelsNotifier([])),
|
|
||||||
userCacheProvider.overrideWith(
|
|
||||||
() => _FakeUserCacheNotifier(emptyUsers),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
@@ -164,108 +162,218 @@ void main() {
|
|||||||
expect(find.text('Retry'), findsOneWidget);
|
expect(find.text('Retry'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('shows feed items with correct content', (tester) async {
|
testWidgets('rows lead with sender, contextual label, and preview', (
|
||||||
await tester.pumpWidget(buildTestable(overrides: defaultOverrides()));
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(await buildTestable());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// All three items visible in "All" filter.
|
// Sender names resolved from the user cache.
|
||||||
expect(find.text('Hey check this out'), findsOneWidget);
|
|
||||||
expect(find.text('Deployed the fix'), findsOneWidget);
|
|
||||||
expect(find.text('Job completed successfully'), findsOneWidget);
|
|
||||||
|
|
||||||
// Author names resolved from user cache.
|
|
||||||
expect(find.text('Alice'), findsOneWidget);
|
expect(find.text('Alice'), findsOneWidget);
|
||||||
expect(find.text('Bob'), findsOneWidget);
|
expect(find.text('Bob'), findsOneWidget);
|
||||||
expect(find.text('Scout'), findsOneWidget);
|
expect(find.text('Scout'), findsOneWidget);
|
||||||
|
|
||||||
// Channel names visible.
|
// Contextual labels + channel chips.
|
||||||
|
expect(find.text('Mentioned in'), findsOneWidget);
|
||||||
|
expect(find.text('Thread in'), findsOneWidget);
|
||||||
expect(find.text('#general'), findsNWidgets(2)); // mention + agent
|
expect(find.text('#general'), findsNWidgets(2)); // mention + agent
|
||||||
expect(find.text('#engineering'), findsOneWidget);
|
expect(find.text('#engineering'), findsOneWidget);
|
||||||
|
|
||||||
|
// Message previews.
|
||||||
|
expect(find.textContaining('Hey check this out'), findsOneWidget);
|
||||||
|
expect(find.textContaining('Deployed the fix'), findsOneWidget);
|
||||||
|
|
||||||
|
// Sender uses the compact label scale (labelMedium), not a
|
||||||
|
// headline-like title scale.
|
||||||
|
final senderText = tester.widget<Text>(find.text('Alice'));
|
||||||
|
final textTheme = Theme.of(tester.element(find.text('Alice'))).textTheme;
|
||||||
|
expect(senderText.style?.fontSize, textTheme.labelMedium?.fontSize);
|
||||||
|
expect(
|
||||||
|
senderText.style!.fontSize!,
|
||||||
|
lessThan(textTheme.titleSmall!.fontSize!),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('shows filter chips with counts', (tester) async {
|
testWidgets('multiple top-level messages in one DM render one row', (
|
||||||
await tester.pumpWidget(buildTestable(overrides: defaultOverrides()));
|
tester,
|
||||||
|
) async {
|
||||||
|
final dmChannel = Channel(
|
||||||
|
id: 'dm1',
|
||||||
|
name: 'dm',
|
||||||
|
channelType: 'dm',
|
||||||
|
visibility: 'private',
|
||||||
|
description: '',
|
||||||
|
createdBy: 'x',
|
||||||
|
createdAt: DateTime(2025),
|
||||||
|
memberCount: 2,
|
||||||
|
isMember: true,
|
||||||
|
participants: const ['Alice'],
|
||||||
|
);
|
||||||
|
FeedItem dmMessage(String id, int age) => FeedItem(
|
||||||
|
id: id,
|
||||||
|
kind: 9,
|
||||||
|
pubkey: 'alice_pk',
|
||||||
|
content: 'dm body $id',
|
||||||
|
createdAt: now - age,
|
||||||
|
channelId: 'dm1',
|
||||||
|
channelName: '',
|
||||||
|
tags: const [],
|
||||||
|
category: 'activity',
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
await buildTestable(
|
||||||
|
feed: HomeFeedResponse(
|
||||||
|
mentions: const [],
|
||||||
|
needsAction: const [],
|
||||||
|
activity: [dmMessage('dm-a', 300), dmMessage('dm-b', 200)],
|
||||||
|
agentActivity: const [],
|
||||||
|
),
|
||||||
|
channels: [...testChannels, dmChannel],
|
||||||
|
),
|
||||||
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('All'), findsOneWidget);
|
// One conversation row for the DM, represented by the latest message.
|
||||||
expect(find.text('Mentions (1)'), findsOneWidget);
|
expect(find.byKey(const ValueKey('inbox-row-dm-b')), findsOneWidget);
|
||||||
expect(find.text('Action (0)'), findsOneWidget);
|
expect(find.byKey(const ValueKey('inbox-row-dm-a')), findsNothing);
|
||||||
expect(find.text('Activity (1)'), findsOneWidget);
|
expect(find.textContaining('dm body dm-b'), findsOneWidget);
|
||||||
expect(find.text('Agents (1)'), findsOneWidget);
|
expect(find.textContaining('dm body dm-a'), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('filtering shows only matching items', (tester) async {
|
testWidgets('unread rows show a dot; read rows do not', (tester) async {
|
||||||
await tester.pumpWidget(buildTestable(overrides: defaultOverrides()));
|
await tester.pumpWidget(
|
||||||
|
await buildTestable(
|
||||||
|
// ch1 fully read; ch2 (thread) unread.
|
||||||
|
readContexts: {'ch1': now},
|
||||||
|
),
|
||||||
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// Tap the Mentions filter.
|
expect(find.byKey(const ValueKey('inbox-unread-dot-m1')), findsNothing);
|
||||||
await tester.tap(find.text('Mentions (1)'));
|
expect(find.byKey(const ValueKey('inbox-unread-dot-ag1')), findsNothing);
|
||||||
await tester.pumpAndSettle();
|
expect(find.byKey(const ValueKey('inbox-unread-dot-a1')), findsOneWidget);
|
||||||
|
|
||||||
expect(find.text('Hey check this out'), findsOneWidget);
|
|
||||||
expect(find.text('Deployed the fix'), findsNothing);
|
|
||||||
expect(find.text('Job completed successfully'), findsNothing);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('empty filter shows per-filter empty state', (tester) async {
|
testWidgets('New boundary separates unread rows from read rows', (
|
||||||
await tester.pumpWidget(buildTestable(overrides: defaultOverrides()));
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
await buildTestable(
|
||||||
|
// Mention (2m ago) and agent (1m ago) unread; thread (1h ago) read.
|
||||||
|
readContexts: {'thread:root1': now, 'ch2': now},
|
||||||
|
),
|
||||||
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// Tap Action filter (has 0 items).
|
expect(find.text('New'), findsOneWidget);
|
||||||
await tester.tap(find.text('Action (0)'));
|
});
|
||||||
|
|
||||||
|
testWidgets('filter menu switches sources and shows empty states', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(await buildTestable());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const ValueKey('activity-filter-menu')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Mentions'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.textContaining('Hey check this out'), findsOneWidget);
|
||||||
|
expect(find.textContaining('Deployed the fix'), findsNothing);
|
||||||
|
expect(find.textContaining('Job completed successfully'), findsNothing);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const ValueKey('activity-filter-menu')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Needs Action'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Nothing needs your action'), findsOneWidget);
|
expect(find.text('Nothing needs your action'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('shows headline for known event kinds', (tester) async {
|
testWidgets('thread filter matches grouped thread replies', (tester) async {
|
||||||
await tester.pumpWidget(buildTestable(overrides: defaultOverrides()));
|
await tester.pumpWidget(await buildTestable());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// testAgent has kind 43004 -> "Job result"
|
await tester.tap(find.byKey(const ValueKey('activity-filter-menu')));
|
||||||
expect(find.text('Job result'), findsOneWidget);
|
await tester.pumpAndSettle();
|
||||||
// testMention has kind 9, category mention -> "Mention"
|
await tester.tap(find.text('Threads'));
|
||||||
expect(find.text('Mention'), findsOneWidget);
|
await tester.pumpAndSettle();
|
||||||
// testActivity has kind 9, category activity -> "Channel update"
|
|
||||||
expect(find.text('Channel update'), findsOneWidget);
|
expect(find.textContaining('Deployed the fix'), findsOneWidget);
|
||||||
|
expect(find.textContaining('Hey check this out'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('reminders filter shows the reminders empty surface', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(await buildTestable());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const ValueKey('activity-filter-menu')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Reminders'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('No reminders'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('drafts filter shows the drafts empty surface', (tester) async {
|
||||||
|
await tester.pumpWidget(await buildTestable());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const ValueKey('activity-filter-menu')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Drafts'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('No drafts'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('unread-only toggle hides read rows', (tester) async {
|
||||||
|
await tester.pumpWidget(await buildTestable(readContexts: {'ch1': now}));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const ValueKey('activity-options-menu')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Show unread'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Only the unread thread row remains.
|
||||||
|
expect(find.textContaining('Deployed the fix'), findsOneWidget);
|
||||||
|
expect(find.textContaining('Hey check this out'), findsNothing);
|
||||||
|
expect(find.textContaining('Job completed successfully'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('long-press mark unread reopens a read row', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
await buildTestable(
|
||||||
|
readContexts: {'ch1': now, 'ch2': now, 'thread:root1': now},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byKey(const ValueKey('inbox-unread-dot-m1')), findsNothing);
|
||||||
|
|
||||||
|
await tester.longPress(find.byKey(const ValueKey('inbox-row-m1')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Mark unread'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byKey(const ValueKey('inbox-unread-dot-m1')), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('falls back to short pubkey when user not cached', (
|
testWidgets('falls back to short pubkey when user not cached', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(await buildTestable(users: const {}));
|
||||||
buildTestable(
|
|
||||||
overrides: [
|
|
||||||
activityProvider.overrideWith(() => _FakeActivityNotifier(testFeed)),
|
|
||||||
channelsProvider.overrideWith(
|
|
||||||
() => _FakeChannelsNotifier(testChannels),
|
|
||||||
),
|
|
||||||
// Empty user cache - no profiles resolved.
|
|
||||||
userCacheProvider.overrideWith(
|
|
||||||
() => _FakeUserCacheNotifier(emptyUsers),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// Should show truncated pubkey instead of display name.
|
// Sender label falls back to the (short) pubkey.
|
||||||
expect(find.text('alice_pk...'), findsOneWidget);
|
expect(find.text('alice_pk'), findsOneWidget);
|
||||||
expect(find.text('Alice'), findsNothing);
|
expect(find.text('Alice'), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('timestamps are right-aligned consistently', (tester) async {
|
|
||||||
await tester.pumpWidget(buildTestable(overrides: defaultOverrides()));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
// Find all time labels - they should show relative times.
|
|
||||||
// testMention: 120s ago -> "2m"
|
|
||||||
expect(find.text('2m'), findsOneWidget);
|
|
||||||
// testActivity: 3600s ago -> "1h"
|
|
||||||
expect(find.text('1h'), findsOneWidget);
|
|
||||||
// testAgent: 60s ago -> "1m"
|
|
||||||
expect(find.text('1m'), findsOneWidget);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FakeActivityNotifier extends ActivityNotifier {
|
class _FakeActivityNotifier extends ActivityNotifier {
|
||||||
@@ -301,3 +409,29 @@ class _FakeUserCacheNotifier extends UserCacheNotifier {
|
|||||||
@override
|
@override
|
||||||
Map<String, UserProfile> build() => _users;
|
Map<String, UserProfile> build() => _users;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _FakeReadStateNotifier extends ReadStateNotifier {
|
||||||
|
final Map<String, int> _contexts;
|
||||||
|
_FakeReadStateNotifier(this._contexts);
|
||||||
|
|
||||||
|
@override
|
||||||
|
ReadStateState build() => ReadStateState(
|
||||||
|
isReady: true,
|
||||||
|
pubkey: 'me_pk',
|
||||||
|
contexts: Map.unmodifiable(_contexts),
|
||||||
|
version: 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void markContextRead(String contextId, int unixTimestamp) {
|
||||||
|
state = state.copyWithContext(contextId, unixTimestamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeRemindersNotifier extends RemindersNotifier {
|
||||||
|
final List<Reminder> _reminders;
|
||||||
|
_FakeRemindersNotifier(this._reminders);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Reminder>> build() async => _reminders;
|
||||||
|
}
|
||||||
|
|||||||
108
mobile/test/features/activity/activity_provider_test.dart
Normal file
108
mobile/test/features/activity/activity_provider_test.dart
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:buzz/features/activity/activity_provider.dart';
|
||||||
|
import 'package:buzz/features/channels/channel.dart';
|
||||||
|
import 'package:buzz/features/channels/channels_provider.dart';
|
||||||
|
import 'package:buzz/shared/relay/relay.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
|
/// Records every DM history query (`#h` filter) so tests can assert whether
|
||||||
|
/// the DM source was fetched. All queries resolve to empty lists.
|
||||||
|
class _RecordingSessionNotifier extends RelaySessionNotifier {
|
||||||
|
final List<List<String>> dmQueries = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
SessionState build() => const SessionState(status: SessionStatus.connected);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<NostrEvent>> fetchHistory(
|
||||||
|
NostrFilter filter, {
|
||||||
|
Duration timeout = const Duration(seconds: 8),
|
||||||
|
}) async {
|
||||||
|
final h = filter.tags['#h'];
|
||||||
|
if (h != null) dmQueries.add(h);
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Channels provider that starts loading and resolves on demand, modelling a
|
||||||
|
/// cold start where the channel list arrives after Activity's first fetch.
|
||||||
|
class _LateChannelsNotifier extends ChannelsNotifier {
|
||||||
|
final Completer<List<Channel>> _completer = Completer<List<Channel>>();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Channel>> build() => _completer.future;
|
||||||
|
|
||||||
|
void resolve(List<Channel> channels) => _completer.complete(channels);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FixedRelayConfigNotifier extends RelayConfigNotifier {
|
||||||
|
@override
|
||||||
|
RelayConfig build() =>
|
||||||
|
const RelayConfig(baseUrl: 'https://relay.example', nsec: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
Channel _dmChannel(String id) => Channel(
|
||||||
|
id: id,
|
||||||
|
name: 'dm',
|
||||||
|
channelType: 'dm',
|
||||||
|
visibility: 'private',
|
||||||
|
description: '',
|
||||||
|
createdBy: 'x',
|
||||||
|
createdAt: DateTime(2025),
|
||||||
|
memberCount: 2,
|
||||||
|
isMember: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
test('refetches and includes DMs when channels resolve after first '
|
||||||
|
'fetch (cold start)', () async {
|
||||||
|
final session = _RecordingSessionNotifier();
|
||||||
|
final channels = _LateChannelsNotifier();
|
||||||
|
final container = ProviderContainer(
|
||||||
|
overrides: [
|
||||||
|
relayConfigProvider.overrideWith(_FixedRelayConfigNotifier.new),
|
||||||
|
myPubkeyProvider.overrideWithValue('me_pk'),
|
||||||
|
relaySessionProvider.overrideWith(() => session),
|
||||||
|
channelsProvider.overrideWith(() => channels),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
// Cold start: channels still loading, so the first fetch has no DM ids.
|
||||||
|
await container.read(activityProvider.future);
|
||||||
|
expect(session.dmQueries, isEmpty);
|
||||||
|
|
||||||
|
// Channel list resolves with a DM → Activity must rebuild and query it.
|
||||||
|
channels.resolve([_dmChannel('dm1')]);
|
||||||
|
await container.read(channelsProvider.future);
|
||||||
|
await container.read(activityProvider.future);
|
||||||
|
|
||||||
|
expect(session.dmQueries, hasLength(1));
|
||||||
|
expect(session.dmQueries.single, ['dm1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not query DMs when the resolved channel list has none', () async {
|
||||||
|
final session = _RecordingSessionNotifier();
|
||||||
|
final channels = _LateChannelsNotifier();
|
||||||
|
final container = ProviderContainer(
|
||||||
|
overrides: [
|
||||||
|
relayConfigProvider.overrideWith(_FixedRelayConfigNotifier.new),
|
||||||
|
myPubkeyProvider.overrideWithValue('me_pk'),
|
||||||
|
relaySessionProvider.overrideWith(() => session),
|
||||||
|
channelsProvider.overrideWith(() => channels),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
await container.read(activityProvider.future);
|
||||||
|
channels.resolve(const []);
|
||||||
|
await container.read(channelsProvider.future);
|
||||||
|
await container.read(activityProvider.future);
|
||||||
|
|
||||||
|
expect(session.dmQueries, isEmpty);
|
||||||
|
});
|
||||||
|
}
|
||||||
190
mobile/test/features/activity/compose_drafts_provider_test.dart
Normal file
190
mobile/test/features/activity/compose_drafts_provider_test.dart
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import 'package:buzz/features/activity/compose_drafts_provider.dart';
|
||||||
|
import 'package:buzz/shared/relay/relay.dart';
|
||||||
|
import 'package:buzz/shared/theme/theme_provider.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
class _FixedRelayConfigNotifier extends RelayConfigNotifier {
|
||||||
|
final RelayConfig _config;
|
||||||
|
_FixedRelayConfigNotifier(this._config);
|
||||||
|
|
||||||
|
@override
|
||||||
|
RelayConfig build() => _config;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
Future<ProviderContainer> containerWithPrefs({
|
||||||
|
String relayUrl = 'https://relay-a.example',
|
||||||
|
String? pubkey = 'pk_a',
|
||||||
|
}) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final container = ProviderContainer(
|
||||||
|
overrides: [
|
||||||
|
savedPrefsProvider.overrideWithValue(prefs),
|
||||||
|
relayConfigProvider.overrideWith(
|
||||||
|
() => _FixedRelayConfigNotifier(RelayConfig(baseUrl: relayUrl)),
|
||||||
|
),
|
||||||
|
myPubkeyProvider.overrideWithValue(pubkey),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('composeDraftKey separates channel and thread composers', () {
|
||||||
|
expect(composeDraftKey('ch1'), 'ch1');
|
||||||
|
expect(composeDraftKey('ch1', threadHeadId: 't1'), 'ch1:t1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('save adds a draft and textFor returns it', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final container = await containerWithPrefs();
|
||||||
|
final notifier = container.read(composeDraftsProvider.notifier);
|
||||||
|
|
||||||
|
notifier.save(key: 'ch1', channelId: 'ch1', text: 'hello there');
|
||||||
|
|
||||||
|
final drafts = container.read(composeDraftsProvider);
|
||||||
|
expect(drafts, hasLength(1));
|
||||||
|
expect(drafts.single.text, 'hello there');
|
||||||
|
expect(notifier.textFor('ch1'), 'hello there');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty text removes the draft', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final container = await containerWithPrefs();
|
||||||
|
final notifier = container.read(composeDraftsProvider.notifier);
|
||||||
|
|
||||||
|
notifier.save(key: 'ch1', channelId: 'ch1', text: 'hello');
|
||||||
|
notifier.save(key: 'ch1', channelId: 'ch1', text: ' ');
|
||||||
|
|
||||||
|
expect(container.read(composeDraftsProvider), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drafts persist across container restarts', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final first = await containerWithPrefs();
|
||||||
|
first
|
||||||
|
.read(composeDraftsProvider.notifier)
|
||||||
|
.save(key: 'ch1:t1', channelId: 'ch1', threadHeadId: 't1', text: 'wip');
|
||||||
|
|
||||||
|
final second = await containerWithPrefs();
|
||||||
|
final restored = second.read(composeDraftsProvider);
|
||||||
|
expect(restored, hasLength(1));
|
||||||
|
expect(restored.single.channelId, 'ch1');
|
||||||
|
expect(restored.single.threadHeadId, 't1');
|
||||||
|
expect(restored.single.text, 'wip');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('remove deletes by key', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final container = await containerWithPrefs();
|
||||||
|
final notifier = container.read(composeDraftsProvider.notifier);
|
||||||
|
|
||||||
|
notifier.save(key: 'ch1', channelId: 'ch1', text: 'hello');
|
||||||
|
notifier.remove('ch1');
|
||||||
|
|
||||||
|
expect(container.read(composeDraftsProvider), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('malformed persisted json is ignored', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({
|
||||||
|
'compose_drafts_v1:https://relay-a.example:pk_a': '{not valid',
|
||||||
|
});
|
||||||
|
final container = await containerWithPrefs();
|
||||||
|
expect(container.read(composeDraftsProvider), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drafts are isolated per community', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final communityA = await containerWithPrefs(
|
||||||
|
relayUrl: 'https://relay-a.example',
|
||||||
|
);
|
||||||
|
communityA
|
||||||
|
.read(composeDraftsProvider.notifier)
|
||||||
|
.save(key: 'ch1', channelId: 'ch1', text: 'secret from A');
|
||||||
|
|
||||||
|
// Same channel id in another community must not see A's draft.
|
||||||
|
final communityB = await containerWithPrefs(
|
||||||
|
relayUrl: 'https://relay-b.example',
|
||||||
|
);
|
||||||
|
expect(communityB.read(composeDraftsProvider), isEmpty);
|
||||||
|
expect(
|
||||||
|
communityB.read(composeDraftsProvider.notifier).textFor('ch1'),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
|
||||||
|
// A's own store is untouched.
|
||||||
|
final communityAAgain = await containerWithPrefs(
|
||||||
|
relayUrl: 'https://relay-a.example',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
communityAAgain.read(composeDraftsProvider.notifier).textFor('ch1'),
|
||||||
|
'secret from A',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drafts are isolated per account pubkey', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final accountA = await containerWithPrefs(pubkey: 'pk_a');
|
||||||
|
accountA
|
||||||
|
.read(composeDraftsProvider.notifier)
|
||||||
|
.save(key: 'ch1', channelId: 'ch1', text: 'account A draft');
|
||||||
|
|
||||||
|
// Same relay, different account: a colliding channel id must not
|
||||||
|
// restore the other account's draft.
|
||||||
|
final accountB = await containerWithPrefs(pubkey: 'pk_b');
|
||||||
|
expect(accountB.read(composeDraftsProvider), isEmpty);
|
||||||
|
expect(
|
||||||
|
accountB.read(composeDraftsProvider.notifier).textFor('ch1'),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('in-place identity change rebuilds onto the new store', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final container = ProviderContainer(
|
||||||
|
overrides: [
|
||||||
|
savedPrefsProvider.overrideWithValue(prefs),
|
||||||
|
// Real derivation path: myPubkeyProvider derives from the config's
|
||||||
|
// nsec, so an in-place config change simulates an account/community
|
||||||
|
// switch without restarting the container.
|
||||||
|
relayConfigProvider.overrideWith(
|
||||||
|
() => _FixedRelayConfigNotifier(
|
||||||
|
const RelayConfig(baseUrl: 'https://relay-a.example'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
// Seed a draft for the initial identity (nsec-less config → 'anon').
|
||||||
|
container
|
||||||
|
.read(composeDraftsProvider.notifier)
|
||||||
|
.save(key: 'ch1', channelId: 'ch1', text: 'first identity draft');
|
||||||
|
expect(container.read(composeDraftsProvider), hasLength(1));
|
||||||
|
|
||||||
|
// Switch community in place: the drafts provider must rebuild against
|
||||||
|
// the new identity's (empty) store, not keep the old state.
|
||||||
|
container
|
||||||
|
.read(relayConfigProvider.notifier)
|
||||||
|
.update(baseUrl: 'https://relay-b.example');
|
||||||
|
expect(container.read(composeDraftsProvider), isEmpty);
|
||||||
|
expect(
|
||||||
|
container.read(composeDraftsProvider.notifier).textFor('ch1'),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Switching back restores the original identity's draft.
|
||||||
|
container
|
||||||
|
.read(relayConfigProvider.notifier)
|
||||||
|
.update(baseUrl: 'https://relay-a.example');
|
||||||
|
expect(
|
||||||
|
container.read(composeDraftsProvider.notifier).textFor('ch1'),
|
||||||
|
'first identity draft',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
285
mobile/test/features/activity/inbox_item_test.dart
Normal file
285
mobile/test/features/activity/inbox_item_test.dart
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
import 'package:buzz/features/activity/feed_item.dart';
|
||||||
|
import 'package:buzz/features/activity/inbox_item.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
FeedItem item({
|
||||||
|
required String id,
|
||||||
|
int createdAt = 100,
|
||||||
|
String category = 'mention',
|
||||||
|
String pubkey = 'pk1',
|
||||||
|
String? channelId = 'ch1',
|
||||||
|
List<List<String>> tags = const [],
|
||||||
|
int kind = 9,
|
||||||
|
String content = 'hello',
|
||||||
|
}) => FeedItem(
|
||||||
|
id: id,
|
||||||
|
kind: kind,
|
||||||
|
pubkey: pubkey,
|
||||||
|
content: content,
|
||||||
|
createdAt: createdAt,
|
||||||
|
channelId: channelId,
|
||||||
|
channelName: '',
|
||||||
|
tags: tags,
|
||||||
|
category: category,
|
||||||
|
);
|
||||||
|
|
||||||
|
List<List<String>> replyTags(String rootId, String parentId) => [
|
||||||
|
['e', rootId, '', 'root'],
|
||||||
|
['e', parentId, '', 'reply'],
|
||||||
|
];
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('inboxConversationId', () {
|
||||||
|
test('uses root id when present', () {
|
||||||
|
expect(
|
||||||
|
inboxConversationId(replyTags('root1', 'parent1'), 'ev1'),
|
||||||
|
'root1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to parent id without a root tag', () {
|
||||||
|
expect(
|
||||||
|
inboxConversationId([
|
||||||
|
['e', 'parent1', '', 'reply'],
|
||||||
|
], 'ev1'),
|
||||||
|
'parent1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to the event id for top-level messages', () {
|
||||||
|
expect(inboxConversationId(const [], 'ev1'), 'ev1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('isThreadReply', () {
|
||||||
|
test('true for reply-tagged events', () {
|
||||||
|
expect(isThreadReply(replyTags('r', 'p')), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('false for broadcast replies', () {
|
||||||
|
expect(
|
||||||
|
isThreadReply([
|
||||||
|
...replyTags('r', 'p'),
|
||||||
|
['broadcast', '1'],
|
||||||
|
]),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('false for top-level messages', () {
|
||||||
|
expect(isThreadReply(const []), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('buildInboxItems', () {
|
||||||
|
test('groups thread events into one conversation row', () {
|
||||||
|
final rows = buildInboxItems([
|
||||||
|
item(id: 'a', createdAt: 10, tags: replyTags('root1', 'root1')),
|
||||||
|
item(id: 'b', createdAt: 20, tags: replyTags('root1', 'a')),
|
||||||
|
item(id: 'c', createdAt: 15),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(rows, hasLength(2));
|
||||||
|
final threadRow = rows.firstWhere((r) => r.conversationId == 'root1');
|
||||||
|
expect(threadRow.id, 'b'); // latest event represents the row
|
||||||
|
expect(threadRow.groupItems, hasLength(2));
|
||||||
|
expect(threadRow.latestActivityAt, 20);
|
||||||
|
expect(threadRow.threadRootId, 'root1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sorts rows by latest activity, newest first', () {
|
||||||
|
final rows = buildInboxItems([
|
||||||
|
item(id: 'old', createdAt: 10),
|
||||||
|
item(id: 'new', createdAt: 99),
|
||||||
|
]);
|
||||||
|
expect(rows.map((r) => r.id).toList(), ['new', 'old']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('groups ordinary top-level DM messages into one row per DM', () {
|
||||||
|
bool isDm(String channelId) => channelId == 'dm1';
|
||||||
|
final rows = buildInboxItems([
|
||||||
|
// Three top-level messages (no thread tags) in the same DM.
|
||||||
|
item(id: 'd1', createdAt: 10, channelId: 'dm1', category: 'activity'),
|
||||||
|
item(id: 'd2', createdAt: 20, channelId: 'dm1', category: 'activity'),
|
||||||
|
item(id: 'd3', createdAt: 30, channelId: 'dm1', category: 'activity'),
|
||||||
|
// Ordinary top-level channel posts still group per event.
|
||||||
|
item(id: 'c1', createdAt: 40, channelId: 'ch1'),
|
||||||
|
item(id: 'c2', createdAt: 50, channelId: 'ch1'),
|
||||||
|
], isDmChannel: isDm);
|
||||||
|
|
||||||
|
expect(rows, hasLength(3));
|
||||||
|
final dmRow = rows.singleWhere((r) => r.conversationId == 'dm:dm1');
|
||||||
|
expect(dmRow.groupItems, hasLength(3));
|
||||||
|
expect(dmRow.id, 'd3'); // latest DM message represents the row
|
||||||
|
expect(dmRow.latestActivityAt, 30);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('thread replies inside a DM still group by thread root', () {
|
||||||
|
bool isDm(String channelId) => channelId == 'dm1';
|
||||||
|
final rows = buildInboxItems([
|
||||||
|
item(id: 'top', createdAt: 10, channelId: 'dm1'),
|
||||||
|
item(
|
||||||
|
id: 'reply',
|
||||||
|
createdAt: 20,
|
||||||
|
channelId: 'dm1',
|
||||||
|
tags: replyTags('top', 'top'),
|
||||||
|
),
|
||||||
|
], isDmChannel: isDm);
|
||||||
|
|
||||||
|
expect(rows, hasLength(2));
|
||||||
|
expect(rows.map((r) => r.conversationId).toSet(), {'dm:dm1', 'top'});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('categories are priority sorted and set isActionRequired', () {
|
||||||
|
final rows = buildInboxItems([
|
||||||
|
item(
|
||||||
|
id: 'a',
|
||||||
|
createdAt: 10,
|
||||||
|
category: 'activity',
|
||||||
|
tags: replyTags('root1', 'root1'),
|
||||||
|
),
|
||||||
|
item(
|
||||||
|
id: 'b',
|
||||||
|
createdAt: 20,
|
||||||
|
category: 'needs_action',
|
||||||
|
tags: replyTags('root1', 'a'),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
expect(rows.single.categories.first, 'needs_action');
|
||||||
|
expect(rows.single.isActionRequired, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('InboxItem.deepLinkTarget', () {
|
||||||
|
final rows = buildInboxItems([
|
||||||
|
item(id: 'a', createdAt: 10, tags: replyTags('root1', 'root1')),
|
||||||
|
item(id: 'b', createdAt: 20, tags: replyTags('root1', 'a')),
|
||||||
|
item(id: 'c', createdAt: 30, tags: replyTags('root1', 'b')),
|
||||||
|
]);
|
||||||
|
|
||||||
|
test('targets the oldest unread event', () {
|
||||||
|
expect(rows.single.deepLinkTarget(15).id, 'b');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('targets the oldest event when nothing is read', () {
|
||||||
|
expect(rows.single.deepLinkTarget(5).id, 'a');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to the latest event when all are read', () {
|
||||||
|
expect(rows.single.deepLinkTarget(99).id, 'c');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to the latest event without a read marker', () {
|
||||||
|
expect(rows.single.deepLinkTarget(null).id, 'c');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('matchesInboxFilter', () {
|
||||||
|
InboxItem rowOf(FeedItem feedItem) => buildInboxItems([feedItem]).single;
|
||||||
|
|
||||||
|
test('all matches everything', () {
|
||||||
|
expect(matchesInboxFilter(rowOf(item(id: 'a')), InboxFilter.all), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('thread filter needs a thread reply in the group', () {
|
||||||
|
expect(
|
||||||
|
matchesInboxFilter(rowOf(item(id: 'a')), InboxFilter.thread),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
matchesInboxFilter(
|
||||||
|
rowOf(item(id: 'a', tags: replyTags('r', 'p'))),
|
||||||
|
InboxFilter.thread,
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('category filters match their category', () {
|
||||||
|
final mention = rowOf(item(id: 'a', category: 'mention'));
|
||||||
|
expect(matchesInboxFilter(mention, InboxFilter.mention), isTrue);
|
||||||
|
expect(matchesInboxFilter(mention, InboxFilter.needsAction), isFalse);
|
||||||
|
expect(matchesInboxFilter(mention, InboxFilter.activity), isFalse);
|
||||||
|
expect(
|
||||||
|
matchesInboxFilter(
|
||||||
|
rowOf(item(id: 'b', category: 'agent_activity')),
|
||||||
|
InboxFilter.agentActivity,
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reminders and drafts are separate surfaces', () {
|
||||||
|
final row = rowOf(item(id: 'a'));
|
||||||
|
expect(matchesInboxFilter(row, InboxFilter.reminders), isFalse);
|
||||||
|
expect(matchesInboxFilter(row, InboxFilter.drafts), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('inboxTypeLabel', () {
|
||||||
|
InboxItem rowOf(FeedItem feedItem) => buildInboxItems([feedItem]).single;
|
||||||
|
|
||||||
|
test('DM rows lead with the sender', () {
|
||||||
|
final label = inboxTypeLabel(
|
||||||
|
rowOf(item(id: 'a', category: 'activity')),
|
||||||
|
channelName: null,
|
||||||
|
isDm: true,
|
||||||
|
senderLabel: 'Alice',
|
||||||
|
);
|
||||||
|
expect(label.text, 'DM from Alice');
|
||||||
|
expect(label.channelLabel, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mention rows say Mentioned in #channel', () {
|
||||||
|
final label = inboxTypeLabel(
|
||||||
|
rowOf(item(id: 'a', category: 'mention')),
|
||||||
|
channelName: 'general',
|
||||||
|
isDm: false,
|
||||||
|
senderLabel: 'Alice',
|
||||||
|
);
|
||||||
|
expect(label.text, 'Mentioned in');
|
||||||
|
expect(label.channelLabel, 'general');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('needs action wins over thread context', () {
|
||||||
|
final label = inboxTypeLabel(
|
||||||
|
rowOf(
|
||||||
|
item(id: 'a', category: 'needs_action', tags: replyTags('r', 'p')),
|
||||||
|
),
|
||||||
|
channelName: 'general',
|
||||||
|
isDm: false,
|
||||||
|
senderLabel: 'Alice',
|
||||||
|
);
|
||||||
|
expect(label.text, 'Needs action in');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('plain thread replies say Thread in', () {
|
||||||
|
final label = inboxTypeLabel(
|
||||||
|
rowOf(item(id: 'a', category: 'activity', tags: replyTags('r', 'p'))),
|
||||||
|
channelName: 'general',
|
||||||
|
isDm: false,
|
||||||
|
senderLabel: 'Alice',
|
||||||
|
);
|
||||||
|
expect(label.text, 'Thread in');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('inboxDayLabel', () {
|
||||||
|
final now = DateTime(2026, 7, 25, 12);
|
||||||
|
int at(DateTime d) => d.millisecondsSinceEpoch ~/ 1000;
|
||||||
|
|
||||||
|
test('today / yesterday / weekday / date buckets', () {
|
||||||
|
expect(inboxDayLabel(at(DateTime(2026, 7, 25, 9)), now: now), 'Today');
|
||||||
|
expect(
|
||||||
|
inboxDayLabel(at(DateTime(2026, 7, 24, 9)), now: now),
|
||||||
|
'Yesterday',
|
||||||
|
);
|
||||||
|
expect(inboxDayLabel(at(DateTime(2026, 7, 21, 9)), now: now), 'Tuesday');
|
||||||
|
expect(inboxDayLabel(at(DateTime(2026, 1, 2)), now: now), 'Jan 2');
|
||||||
|
expect(
|
||||||
|
inboxDayLabel(at(DateTime(2025, 12, 31)), now: now),
|
||||||
|
'Dec 31, 2025',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
138
mobile/test/features/activity/inbox_read_state_test.dart
Normal file
138
mobile/test/features/activity/inbox_read_state_test.dart
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import 'package:buzz/features/activity/feed_item.dart';
|
||||||
|
import 'package:buzz/features/activity/inbox_item.dart';
|
||||||
|
import 'package:buzz/features/activity/inbox_read_state.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
FeedItem item({
|
||||||
|
required String id,
|
||||||
|
int createdAt = 100,
|
||||||
|
String category = 'mention',
|
||||||
|
String? channelId = 'ch1',
|
||||||
|
List<List<String>> tags = const [],
|
||||||
|
}) => FeedItem(
|
||||||
|
id: id,
|
||||||
|
kind: 9,
|
||||||
|
pubkey: 'pk1',
|
||||||
|
content: 'hello',
|
||||||
|
createdAt: createdAt,
|
||||||
|
channelId: channelId,
|
||||||
|
channelName: '',
|
||||||
|
tags: tags,
|
||||||
|
category: category,
|
||||||
|
);
|
||||||
|
|
||||||
|
List<List<String>> replyTags(String rootId, String parentId) => [
|
||||||
|
['e', rootId, '', 'root'],
|
||||||
|
['e', parentId, '', 'reply'],
|
||||||
|
];
|
||||||
|
|
||||||
|
int? Function(String) markers(Map<String, int> map) =>
|
||||||
|
(contextId) => map[contextId];
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('resolveInboxItemReadAt', () {
|
||||||
|
test('channel rows use the channel marker', () {
|
||||||
|
final row = buildInboxItems([item(id: 'a')]).single;
|
||||||
|
expect(resolveInboxItemReadAt(row, markerOf: markers({'ch1': 42})), 42);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('thread rows use max of thread and msg markers', () {
|
||||||
|
final row = buildInboxItems([
|
||||||
|
item(id: 'a', tags: replyTags('root1', 'root1')),
|
||||||
|
]).single;
|
||||||
|
expect(
|
||||||
|
resolveInboxItemReadAt(
|
||||||
|
row,
|
||||||
|
markerOf: markers({'thread:root1': 10, 'msg:a': 25}),
|
||||||
|
),
|
||||||
|
25,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('channel-less rows have no marker', () {
|
||||||
|
final row = buildInboxItems([item(id: 'a', channelId: null)]).single;
|
||||||
|
expect(resolveInboxItemReadAt(row, markerOf: markers({})), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('isInboxItemDone', () {
|
||||||
|
test('done when no grouped activity is newer than the marker', () {
|
||||||
|
final row = buildInboxItems([item(id: 'a', createdAt: 50)]).single;
|
||||||
|
expect(
|
||||||
|
isInboxItemDone(
|
||||||
|
row,
|
||||||
|
markerOf: markers({'ch1': 50}),
|
||||||
|
localUnreadOverrides: const {},
|
||||||
|
localDoneSet: const {},
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
isInboxItemDone(
|
||||||
|
row,
|
||||||
|
markerOf: markers({'ch1': 49}),
|
||||||
|
localUnreadOverrides: const {},
|
||||||
|
localDoneSet: const {},
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a local unread override always wins', () {
|
||||||
|
final row = buildInboxItems([item(id: 'a', createdAt: 50)]).single;
|
||||||
|
expect(
|
||||||
|
isInboxItemDone(
|
||||||
|
row,
|
||||||
|
markerOf: markers({'ch1': 99}),
|
||||||
|
localUnreadOverrides: const {'a'},
|
||||||
|
localDoneSet: const {},
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('channel-less rows fall back to the local done set', () {
|
||||||
|
final row = buildInboxItems([item(id: 'a', channelId: null)]).single;
|
||||||
|
expect(
|
||||||
|
isInboxItemDone(
|
||||||
|
row,
|
||||||
|
markerOf: markers({}),
|
||||||
|
localUnreadOverrides: const {},
|
||||||
|
localDoneSet: const {},
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
isInboxItemDone(
|
||||||
|
row,
|
||||||
|
markerOf: markers({}),
|
||||||
|
localUnreadOverrides: const {},
|
||||||
|
localDoneSet: const {'a'},
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('groupedChannelReadTimestamp', () {
|
||||||
|
test('only counts top-level events in the channel', () {
|
||||||
|
final row = buildInboxItems([
|
||||||
|
item(id: 'a', createdAt: 10, tags: replyTags('root1', 'root1')),
|
||||||
|
item(id: 'b', createdAt: 30, tags: replyTags('root1', 'a')),
|
||||||
|
]).single;
|
||||||
|
// Every grouped event is a thread reply, so marking the thread read
|
||||||
|
// must not advance the channel marker.
|
||||||
|
expect(groupedChannelReadTimestamp(row), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns the newest top-level timestamp', () {
|
||||||
|
final row = buildInboxItems([
|
||||||
|
item(id: 'root1', createdAt: 10),
|
||||||
|
item(id: 'b', createdAt: 30, tags: replyTags('root1', 'root1')),
|
||||||
|
]).single;
|
||||||
|
final result = groupedChannelReadTimestamp(row);
|
||||||
|
expect(result?.channelId, 'ch1');
|
||||||
|
expect(result?.timestamp, 10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
127
mobile/test/features/activity/reminders_provider_test.dart
Normal file
127
mobile/test/features/activity/reminders_provider_test.dart
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:buzz/features/activity/reminders_provider.dart';
|
||||||
|
import 'package:buzz/shared/relay/relay.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
NostrEvent reminderEvent({
|
||||||
|
List<List<String>> tags = const [],
|
||||||
|
String content = 'ciphertext',
|
||||||
|
int createdAt = 100,
|
||||||
|
}) => NostrEvent(
|
||||||
|
id: 'ev1',
|
||||||
|
pubkey: 'pk1',
|
||||||
|
createdAt: createdAt,
|
||||||
|
kind: kindEventReminder,
|
||||||
|
tags: tags,
|
||||||
|
content: content,
|
||||||
|
sig: '',
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('parseNotBefore', () {
|
||||||
|
test('accepts plain unix seconds', () {
|
||||||
|
expect(parseNotBefore('1753400000'), 1753400000);
|
||||||
|
expect(parseNotBefore('0'), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects malformed values', () {
|
||||||
|
expect(parseNotBefore('01'), isNull);
|
||||||
|
expect(parseNotBefore('-5'), isNull);
|
||||||
|
expect(parseNotBefore('12.5'), isNull);
|
||||||
|
expect(parseNotBefore('abc'), isNull);
|
||||||
|
expect(parseNotBefore(''), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('parseReminderContent', () {
|
||||||
|
test('parses a well-formed reminder with target', () {
|
||||||
|
final parsed = parseReminderContent(
|
||||||
|
jsonEncode({
|
||||||
|
'status': 'pending',
|
||||||
|
'note': 'follow up',
|
||||||
|
'target': {
|
||||||
|
'eventId': 'ev1',
|
||||||
|
'channelId': 'ch1',
|
||||||
|
'preview': 'hello',
|
||||||
|
'authorPubkey': 'pk1',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(parsed, isNotNull);
|
||||||
|
expect(parsed!.status, 'pending');
|
||||||
|
expect(parsed.note, 'follow up');
|
||||||
|
expect(parsed.target?.eventId, 'ev1');
|
||||||
|
expect(parsed.target?.channelId, 'ch1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts a note-only reminder', () {
|
||||||
|
final parsed = parseReminderContent(
|
||||||
|
jsonEncode({'status': 'pending', 'note': 'water plants'}),
|
||||||
|
);
|
||||||
|
expect(parsed?.note, 'water plants');
|
||||||
|
expect(parsed?.target, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fails closed on off-shape content', () {
|
||||||
|
expect(parseReminderContent('not json'), isNull);
|
||||||
|
expect(parseReminderContent('[]'), isNull);
|
||||||
|
expect(parseReminderContent(jsonEncode({'status': 'snoozed'})), isNull);
|
||||||
|
// No target and no note is meaningless.
|
||||||
|
expect(parseReminderContent(jsonEncode({'status': 'pending'})), isNull);
|
||||||
|
// Target missing required fields.
|
||||||
|
expect(
|
||||||
|
parseReminderContent(
|
||||||
|
jsonEncode({
|
||||||
|
'status': 'pending',
|
||||||
|
'target': {'eventId': 'ev1'},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('decodeReminderEvent', () {
|
||||||
|
final plaintext = jsonEncode({'status': 'pending', 'note': 'hi'});
|
||||||
|
|
||||||
|
test('decodes d-tag, not_before, and decrypted content', () {
|
||||||
|
final reminder = decodeReminderEvent(
|
||||||
|
reminderEvent(
|
||||||
|
tags: [
|
||||||
|
['d', 'rem1'],
|
||||||
|
['not_before', '1753400000'],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
decrypt: (_) => plaintext,
|
||||||
|
);
|
||||||
|
expect(reminder, isNotNull);
|
||||||
|
expect(reminder!.id, 'rem1');
|
||||||
|
expect(reminder.notBefore, 1753400000);
|
||||||
|
expect(reminder.status, 'pending');
|
||||||
|
expect(reminder.isDue(1753400001), isTrue);
|
||||||
|
expect(reminder.isDue(1753399999), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('requires a d tag', () {
|
||||||
|
expect(
|
||||||
|
decodeReminderEvent(reminderEvent(), decrypt: (_) => plaintext),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fails closed when decryption throws', () {
|
||||||
|
expect(
|
||||||
|
decodeReminderEvent(
|
||||||
|
reminderEvent(
|
||||||
|
tags: [
|
||||||
|
['d', 'rem1'],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
decrypt: (_) => throw StateError('bad ciphertext'),
|
||||||
|
),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -25,10 +25,14 @@ import 'package:buzz/features/profile/user_cache_provider.dart';
|
|||||||
import 'package:buzz/features/profile/user_profile.dart';
|
import 'package:buzz/features/profile/user_profile.dart';
|
||||||
import 'package:buzz/shared/relay/relay.dart';
|
import 'package:buzz/shared/relay/relay.dart';
|
||||||
import 'package:buzz/shared/theme/theme.dart';
|
import 'package:buzz/shared/theme/theme.dart';
|
||||||
import 'package:buzz/shared/widgets/frosted_app_bar.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
const _channelId = 'test-channel';
|
const _channelId = 'test-channel';
|
||||||
|
|
||||||
|
/// Shared mock prefs for providers that read [savedPrefsProvider]
|
||||||
|
/// (e.g. the compose bar's draft store). Initialized in [main].
|
||||||
|
late SharedPreferences _testPrefs;
|
||||||
|
|
||||||
final _testChannel = Channel(
|
final _testChannel = Channel(
|
||||||
id: _channelId,
|
id: _channelId,
|
||||||
name: 'general',
|
name: 'general',
|
||||||
@@ -145,7 +149,6 @@ Widget _buildTestable({
|
|||||||
_FakeMessagesNotifier? messagesNotifier,
|
_FakeMessagesNotifier? messagesNotifier,
|
||||||
String? canvasContent,
|
String? canvasContent,
|
||||||
List<NostrEvent>? threadReplies,
|
List<NostrEvent>? threadReplies,
|
||||||
TextScaler textScaler = TextScaler.noScaling,
|
|
||||||
}) {
|
}) {
|
||||||
final resolvedChannel = channel ?? _testChannel;
|
final resolvedChannel = channel ?? _testChannel;
|
||||||
final fakeChannelsNotifier =
|
final fakeChannelsNotifier =
|
||||||
@@ -188,16 +191,13 @@ Widget _buildTestable({
|
|||||||
relayClientProvider.overrideWithValue(
|
relayClientProvider.overrideWithValue(
|
||||||
RelayClient(baseUrl: 'http://localhost:3000'),
|
RelayClient(baseUrl: 'http://localhost:3000'),
|
||||||
),
|
),
|
||||||
|
// Compose bar drafts persist through SharedPreferences.
|
||||||
|
savedPrefsProvider.overrideWithValue(_testPrefs),
|
||||||
],
|
],
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
theme: AppTheme.light(),
|
theme: AppTheme.light(),
|
||||||
navigatorObservers: navigatorObservers,
|
navigatorObservers: navigatorObservers,
|
||||||
home: Builder(
|
home: ChannelDetailPage(channel: resolvedChannel),
|
||||||
builder: (context) => MediaQuery(
|
|
||||||
data: MediaQuery.of(context).copyWith(textScaler: textScaler),
|
|
||||||
child: ChannelDetailPage(channel: resolvedChannel),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -229,6 +229,11 @@ double? effectiveFontSizeForText(
|
|||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
setUp(() async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
_testPrefs = await SharedPreferences.getInstance();
|
||||||
|
});
|
||||||
|
|
||||||
group('ChannelDetailPage', () {
|
group('ChannelDetailPage', () {
|
||||||
testWidgets('defers read-state mark until after build', (tester) async {
|
testWidgets('defers read-state mark until after build', (tester) async {
|
||||||
final readState = _SynchronousReadStateNotifier(
|
final readState = _SynchronousReadStateNotifier(
|
||||||
@@ -1334,50 +1339,6 @@ void main() {
|
|||||||
expect(find.text('secret'), findsOneWidget);
|
expect(find.text('secret'), findsOneWidget);
|
||||||
expect(find.byIcon(LucideIcons.lock), findsOneWidget);
|
expect(find.byIcon(LucideIcons.lock), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('grows for a scaled two-line DM title', (tester) async {
|
|
||||||
final dmChannel = Channel(
|
|
||||||
id: _channelId,
|
|
||||||
name: 'dm',
|
|
||||||
channelType: 'dm',
|
|
||||||
visibility: 'private',
|
|
||||||
description: '',
|
|
||||||
createdBy: 'alice',
|
|
||||||
createdAt: DateTime(2025),
|
|
||||||
memberCount: 2,
|
|
||||||
participants: const ['Alice'],
|
|
||||||
participantPubkeys: const ['alice'],
|
|
||||||
isMember: true,
|
|
||||||
);
|
|
||||||
|
|
||||||
await tester.pumpWidget(
|
|
||||||
_buildTestable(
|
|
||||||
messages: [],
|
|
||||||
channel: dmChannel,
|
|
||||||
users: const {
|
|
||||||
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
|
||||||
},
|
|
||||||
textScaler: const TextScaler.linear(1.25),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
final appBar = find.byType(FrostedAppBar);
|
|
||||||
final clip = find.descendant(of: appBar, matching: find.byType(ClipRect));
|
|
||||||
final title = find.descendant(of: appBar, matching: find.text('Alice'));
|
|
||||||
final presence = find.descendant(
|
|
||||||
of: appBar,
|
|
||||||
matching: find.text('Offline'),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
tester.getSize(clip).height,
|
|
||||||
greaterThan(
|
|
||||||
tester.getSize(title).height + tester.getSize(presence).height,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
expect(tester.takeException(), isNull);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
group('Error and loading states', () {
|
group('Error and loading states', () {
|
||||||
@@ -1398,6 +1359,7 @@ void main() {
|
|||||||
relayClientProvider.overrideWithValue(
|
relayClientProvider.overrideWithValue(
|
||||||
RelayClient(baseUrl: 'http://localhost:3000'),
|
RelayClient(baseUrl: 'http://localhost:3000'),
|
||||||
),
|
),
|
||||||
|
savedPrefsProvider.overrideWithValue(_testPrefs),
|
||||||
],
|
],
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
theme: AppTheme.light(),
|
theme: AppTheme.light(),
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import 'package:buzz/shared/custom_emoji/custom_emoji.dart';
|
|||||||
import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart';
|
import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart';
|
||||||
import 'package:buzz/shared/relay/relay.dart';
|
import 'package:buzz/shared/relay/relay.dart';
|
||||||
import 'package:buzz/shared/theme/theme.dart';
|
import 'package:buzz/shared/theme/theme.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
final _pngBytes = Uint8List.fromList([
|
final _pngBytes = Uint8List.fromList([
|
||||||
0x89,
|
0x89,
|
||||||
@@ -134,6 +135,10 @@ void _setMockMediaUploadPlatformHandler(
|
|||||||
.setMockMethodCallHandler(_mediaUploadPlatformChannel, handler);
|
.setMockMethodCallHandler(_mediaUploadPlatformChannel, handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shared mock prefs for the compose bar's draft store. Initialized in
|
||||||
|
/// [main].
|
||||||
|
late SharedPreferences _testPrefs;
|
||||||
|
|
||||||
Widget _buildComposeBar({
|
Widget _buildComposeBar({
|
||||||
required MediaUploadService uploadService,
|
required MediaUploadService uploadService,
|
||||||
required ComposeBarOnSend onSend,
|
required ComposeBarOnSend onSend,
|
||||||
@@ -144,6 +149,7 @@ Widget _buildComposeBar({
|
|||||||
String? currentPubkey,
|
String? currentPubkey,
|
||||||
bool? supportsShowingSystemContextMenu,
|
bool? supportsShowingSystemContextMenu,
|
||||||
List<CustomEmoji> customEmoji = const <CustomEmoji>[],
|
List<CustomEmoji> customEmoji = const <CustomEmoji>[],
|
||||||
|
RelayConfigNotifier Function()? relayConfig,
|
||||||
}) {
|
}) {
|
||||||
return ProviderScope(
|
return ProviderScope(
|
||||||
overrides: [
|
overrides: [
|
||||||
@@ -158,7 +164,10 @@ Widget _buildComposeBar({
|
|||||||
relayClientProvider.overrideWithValue(
|
relayClientProvider.overrideWithValue(
|
||||||
RelayClient(baseUrl: 'http://localhost:3000'),
|
RelayClient(baseUrl: 'http://localhost:3000'),
|
||||||
),
|
),
|
||||||
relayConfigProvider.overrideWith(() => _FakeRelayConfigNotifier()),
|
relayConfigProvider.overrideWith(
|
||||||
|
relayConfig ?? _FakeRelayConfigNotifier.new,
|
||||||
|
),
|
||||||
|
savedPrefsProvider.overrideWithValue(_testPrefs),
|
||||||
channelsProvider.overrideWith(() => _FakeChannelsNotifier(channels)),
|
channelsProvider.overrideWith(() => _FakeChannelsNotifier(channels)),
|
||||||
],
|
],
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
@@ -192,6 +201,18 @@ class _FakeRelayConfigNotifier extends RelayConfigNotifier {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Relay config that starts from a fixed identity and can be switched
|
||||||
|
/// in place via [RelayConfigNotifier.update] — simulates a community or
|
||||||
|
/// account switch while widgets stay mounted.
|
||||||
|
class _SwitchableRelayConfigNotifier extends RelayConfigNotifier {
|
||||||
|
final RelayConfig initial;
|
||||||
|
|
||||||
|
_SwitchableRelayConfigNotifier(this.initial);
|
||||||
|
|
||||||
|
@override
|
||||||
|
RelayConfig build() => initial;
|
||||||
|
}
|
||||||
|
|
||||||
class _RecordingRelaySocket extends RelaySocket {
|
class _RecordingRelaySocket extends RelaySocket {
|
||||||
final List<Map<String, dynamic>> events;
|
final List<Map<String, dynamic>> events;
|
||||||
final void Function(List<dynamic> message) handleMessage;
|
final void Function(List<dynamic> message) handleMessage;
|
||||||
@@ -241,6 +262,11 @@ class _FakeChannelsNotifier extends ChannelsNotifier {
|
|||||||
void main() {
|
void main() {
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
_testPrefs = await SharedPreferences.getInstance();
|
||||||
|
});
|
||||||
|
|
||||||
setUpAll(() {
|
setUpAll(() {
|
||||||
_setMockMediaUploadPlatformHandler((call) async {
|
_setMockMediaUploadPlatformHandler((call) async {
|
||||||
switch (call.method) {
|
switch (call.method) {
|
||||||
@@ -262,6 +288,72 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
group('ComposeBar', () {
|
group('ComposeBar', () {
|
||||||
|
testWidgets('mounted composer does not carry draft text across an in-place '
|
||||||
|
'identity switch', (tester) async {
|
||||||
|
final keysA = nostr.Keys.generate();
|
||||||
|
final keysB = nostr.Keys.generate();
|
||||||
|
const relayUrl = 'http://localhost:3000';
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_buildComposeBar(
|
||||||
|
uploadService: _testUploadService(keysA.nsec),
|
||||||
|
relayConfig: () => _SwitchableRelayConfigNotifier(
|
||||||
|
RelayConfig(baseUrl: relayUrl, nsec: keysA.nsec),
|
||||||
|
),
|
||||||
|
onSend:
|
||||||
|
(
|
||||||
|
content,
|
||||||
|
mentionPubkeys, {
|
||||||
|
mediaTags = const <List<String>>[],
|
||||||
|
}) async {},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
String? storedText(nostr.Keys keys) {
|
||||||
|
final raw = _testPrefs.getString(
|
||||||
|
'compose_drafts_v1:$relayUrl:${keys.public}',
|
||||||
|
);
|
||||||
|
if (raw == null) return null;
|
||||||
|
final drafts = jsonDecode(raw) as List;
|
||||||
|
if (drafts.isEmpty) return null;
|
||||||
|
return (drafts.first as Map<String, dynamic>)['text'] as String?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identity A types a draft; it persists into A's namespaced store.
|
||||||
|
await _expandComposer(tester);
|
||||||
|
await tester.enterText(find.byType(TextField), 'identity A secret draft');
|
||||||
|
await tester.pump();
|
||||||
|
expect(storedText(keysA), 'identity A secret draft');
|
||||||
|
|
||||||
|
// Switch identity in place while the composer stays mounted.
|
||||||
|
final container = ProviderScope.containerOf(
|
||||||
|
tester.element(find.byType(ComposeBar)),
|
||||||
|
);
|
||||||
|
container
|
||||||
|
.read(relayConfigProvider.notifier)
|
||||||
|
.update(baseUrl: relayUrl, nsec: keysB.nsec);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// The mounted composer must not carry identity A's text forward.
|
||||||
|
final textField = tester.widget<TextField>(find.byType(TextField));
|
||||||
|
expect(textField.controller!.text, isEmpty);
|
||||||
|
expect(storedText(keysB), isNull);
|
||||||
|
|
||||||
|
// Identity B's edits persist only into B's store; A's is untouched.
|
||||||
|
await tester.enterText(find.byType(TextField), 'identity B text');
|
||||||
|
await tester.pump();
|
||||||
|
expect(storedText(keysB), 'identity B text');
|
||||||
|
expect(storedText(keysA), 'identity A secret draft');
|
||||||
|
|
||||||
|
// Switching back restores identity A's own draft into the composer.
|
||||||
|
container
|
||||||
|
.read(relayConfigProvider.notifier)
|
||||||
|
.update(baseUrl: relayUrl, nsec: keysA.nsec);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(textField.controller!.text, 'identity A secret draft');
|
||||||
|
expect(storedText(keysB), 'identity B text');
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('inserts a community emoji at the cursor from the action row', (
|
testWidgets('inserts a community emoji at the cursor from the action row', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import 'package:buzz/features/profile/user_cache_provider.dart';
|
|||||||
import 'package:buzz/features/profile/user_profile.dart';
|
import 'package:buzz/features/profile/user_profile.dart';
|
||||||
import 'package:buzz/shared/relay/relay.dart';
|
import 'package:buzz/shared/relay/relay.dart';
|
||||||
import 'package:buzz/shared/theme/theme.dart';
|
import 'package:buzz/shared/theme/theme.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
const _channelId = 'forum-channel';
|
const _channelId = 'forum-channel';
|
||||||
|
|
||||||
@@ -103,6 +104,10 @@ Widget _buildPostsView({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shared mock prefs for the compose bar's draft store. Initialized in
|
||||||
|
/// [main].
|
||||||
|
late SharedPreferences _testPrefs;
|
||||||
|
|
||||||
Widget _buildThreadPage({
|
Widget _buildThreadPage({
|
||||||
required ForumThreadResponse threadResponse,
|
required ForumThreadResponse threadResponse,
|
||||||
String postEventId = 'post1',
|
String postEventId = 'post1',
|
||||||
@@ -119,6 +124,7 @@ Widget _buildThreadPage({
|
|||||||
channelId: _channelId,
|
channelId: _channelId,
|
||||||
eventId: postEventId,
|
eventId: postEventId,
|
||||||
)).overrideWith((ref) async => threadResponse),
|
)).overrideWith((ref) async => threadResponse),
|
||||||
|
savedPrefsProvider.overrideWithValue(_testPrefs),
|
||||||
relayClientProvider.overrideWithValue(
|
relayClientProvider.overrideWithValue(
|
||||||
RelayClient(baseUrl: 'http://localhost:3000'),
|
RelayClient(baseUrl: 'http://localhost:3000'),
|
||||||
),
|
),
|
||||||
@@ -137,6 +143,11 @@ Widget _buildThreadPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
setUp(() async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
_testPrefs = await SharedPreferences.getInstance();
|
||||||
|
});
|
||||||
|
|
||||||
group('ForumPostCard', () {
|
group('ForumPostCard', () {
|
||||||
testWidgets('renders author name and content', (tester) async {
|
testWidgets('renders author name and content', (tester) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
|
|||||||
@@ -5,19 +5,25 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('shows icon-only navigation and an aligned quick action', (
|
Future<Widget> buildHome() async {
|
||||||
tester,
|
SharedPreferences.setMockInitialValues({});
|
||||||
) async {
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await tester.pumpWidget(
|
return ProviderScope(
|
||||||
ProviderScope(
|
overrides: [savedPrefsProvider.overrideWithValue(prefs)],
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
theme: AppTheme.light(),
|
theme: AppTheme.light(),
|
||||||
home: const HomePage(settingsPageBuilder: _buildSettingsPage),
|
home: const HomePage(settingsPageBuilder: _buildSettingsPage),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('shows icon-only navigation and an aligned quick action', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(await buildHome());
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
expect(find.text('Home'), findsNothing);
|
expect(find.text('Home'), findsNothing);
|
||||||
@@ -68,14 +74,7 @@ void main() {
|
|||||||
.setMockMethodCallHandler(SystemChannels.platform, null),
|
.setMockMethodCallHandler(SystemChannels.platform, null),
|
||||||
);
|
);
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(await buildHome());
|
||||||
ProviderScope(
|
|
||||||
child: MaterialApp(
|
|
||||||
theme: AppTheme.light(),
|
|
||||||
home: const HomePage(settingsPageBuilder: _buildSettingsPage),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
await tester.tap(find.byTooltip('Home'));
|
await tester.tap(find.byTooltip('Home'));
|
||||||
@@ -99,14 +98,7 @@ void main() {
|
|||||||
testWidgets('scales and fades the quick action as tabs change', (
|
testWidgets('scales and fades the quick action as tabs change', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(await buildHome());
|
||||||
ProviderScope(
|
|
||||||
child: MaterialApp(
|
|
||||||
theme: AppTheme.light(),
|
|
||||||
home: const HomePage(settingsPageBuilder: _buildSettingsPage),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
double scale() => tester
|
double scale() => tester
|
||||||
|
|||||||
Reference in New Issue
Block a user