feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
@@ -0,0 +1,407 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../shared/mentions/agent_identity_provider.dart';
|
||||
import '../../shared/mentions/mention_tags.dart';
|
||||
import '../../shared/relay/relay.dart';
|
||||
import '../../shared/l10n/l10n.dart';
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/utils/string_utils.dart';
|
||||
import '../../shared/widgets/avatar_image.dart';
|
||||
import '../../shared/widgets/anchored_popover_menu.dart';
|
||||
import '../../shared/widgets/bee_refresh_indicator.dart';
|
||||
import '../../shared/widgets/buzz_loading_indicator.dart';
|
||||
import '../../shared/widgets/frosted_app_bar.dart';
|
||||
import '../../shared/widgets/frosted_scaffold.dart';
|
||||
import '../../shared/widgets/message_author_meta.dart';
|
||||
import '../../shared/widgets/modal_presentation.dart';
|
||||
import '../channels/channel.dart';
|
||||
import '../channels/channel_detail_page.dart';
|
||||
import '../channels/channels_provider.dart';
|
||||
import '../channels/dm_channel_labels.dart';
|
||||
import '../channels/message_content.dart';
|
||||
import '../../shared/read_state/read_state_format.dart';
|
||||
import '../../shared/read_state/read_state_provider.dart';
|
||||
import '../profile/user_cache_provider.dart';
|
||||
import '../profile/user_profile.dart';
|
||||
import 'activity_provider.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';
|
||||
|
||||
part 'activity_page/header_actions.dart';
|
||||
part 'activity_page/inbox_row.dart';
|
||||
part 'activity_page/lists.dart';
|
||||
part 'activity_page/status_views.dart';
|
||||
|
||||
EdgeInsets _activityScrollPadding(
|
||||
BuildContext context, {
|
||||
double horizontal = 0,
|
||||
double top = Grid.xxs,
|
||||
double bottom = Grid.xxs,
|
||||
}) => EdgeInsets.fromLTRB(
|
||||
horizontal,
|
||||
top,
|
||||
horizontal,
|
||||
MediaQuery.paddingOf(context).bottom + bottom,
|
||||
);
|
||||
|
||||
/// Conversation-oriented Activity inbox.
|
||||
///
|
||||
/// Matches desktop's Home inbox item design and semantics (see
|
||||
/// `desktop/src/features/home/ui/InboxListPane.tsx`): full sender avatar +
|
||||
/// name, contextual "Mentioned in #channel"-style label, unread dot + time,
|
||||
/// message preview — while keeping mobile's list → canonical destination
|
||||
/// navigation. Row taps deep-link to the represented message (oldest unread
|
||||
/// for grouped conversations) rather than just opening the channel.
|
||||
class ActivityPage extends HookConsumerWidget {
|
||||
const ActivityPage({this.tabReselection, super.key});
|
||||
|
||||
/// Notifies this page when its already-selected tab is tapped again.
|
||||
final ValueListenable<int>? tabReselection;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final feedAsync = ref.watch(activityProvider);
|
||||
final channelsAsync = ref.watch(channelsProvider);
|
||||
final filter = useState(InboxFilter.all);
|
||||
final unreadOnly = useState(false);
|
||||
final scrollController = useScrollController();
|
||||
final reducedMotion = MediaQuery.disableAnimationsOf(context);
|
||||
useEffect(() {
|
||||
final tabReselection = this.tabReselection;
|
||||
if (tabReselection == null) return null;
|
||||
|
||||
void scrollToTop() {
|
||||
if (!scrollController.hasClients) return;
|
||||
final position = scrollController.position;
|
||||
if (position.pixels <= position.minScrollExtent + 0.5) return;
|
||||
if (reducedMotion) {
|
||||
scrollController.jumpTo(position.minScrollExtent);
|
||||
return;
|
||||
}
|
||||
unawaited(
|
||||
scrollController.animateTo(
|
||||
position.minScrollExtent,
|
||||
duration: const Duration(milliseconds: 260),
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
tabReselection.addListener(scrollToTop);
|
||||
return () => tabReselection.removeListener(scrollToTop);
|
||||
}, [tabReselection, scrollController, reducedMotion]);
|
||||
final headerTitleStyle = context.textTheme.titleMedium?.copyWith(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: navigationPrimaryForeground(context),
|
||||
);
|
||||
final topSectionHeight = frostedAppBarHeight(
|
||||
context,
|
||||
titleStyle: headerTitleStyle,
|
||||
bottomHeight: Grid.xxs,
|
||||
);
|
||||
|
||||
final readState = ref.watch(readStateProvider);
|
||||
final localState = ref.watch(inboxLocalStateProvider);
|
||||
final drafts = ref.watch(composeDraftsProvider);
|
||||
final dueReminderCount = ref.watch(dueReminderCountProvider);
|
||||
final allItems = ref.watch(inboxItemsProvider);
|
||||
final myPk = ref.watch(myPubkeyProvider);
|
||||
|
||||
// Cache the last non-empty feed so the UI doesn't flash on rebuild.
|
||||
final hasLoadedOnce = useRef(false);
|
||||
if (feedAsync.hasValue) hasLoadedOnce.value = true;
|
||||
|
||||
final channels = channelsAsync.asData?.value ?? const <Channel>[];
|
||||
final channelById = {for (final c in channels) c.id: c};
|
||||
|
||||
int? markerOf(String contextId) => readState.effectiveTimestamp(contextId);
|
||||
bool isDone(InboxItem item) => isInboxItemDone(
|
||||
item,
|
||||
markerOf: markerOf,
|
||||
localUnreadOverrides: localState.unreadIds,
|
||||
localDoneSet: localState.doneIds,
|
||||
);
|
||||
|
||||
final visibleItems = [
|
||||
for (final item in allItems)
|
||||
if (matchesInboxFilter(item, filter.value) &&
|
||||
(!unreadOnly.value || !isDone(item)))
|
||||
item,
|
||||
];
|
||||
|
||||
// Preload sender profiles for visible rows.
|
||||
final preloadPubkeys = {
|
||||
for (final item in visibleItems) item.item.pubkey.toLowerCase(),
|
||||
for (final item in visibleItems)
|
||||
...mentionedPubkeysFromTags(item.item.tags),
|
||||
}.toList()..sort();
|
||||
final preloadPubkeysKey = preloadPubkeys.join('\u0000');
|
||||
useEffect(() {
|
||||
ref.read(userCacheProvider.notifier).preload(preloadPubkeys);
|
||||
return null;
|
||||
}, [preloadPubkeysKey]);
|
||||
|
||||
final unreadVisibleCount = visibleItems.where((i) => !isDone(i)).length;
|
||||
|
||||
void markItemRead(InboxItem item) {
|
||||
final notifier = ref.read(readStateProvider.notifier);
|
||||
ref
|
||||
.read(inboxLocalStateProvider.notifier)
|
||||
.clearUnread(groupedInboxItemIds(item));
|
||||
final threadRootId = item.threadRootId;
|
||||
if (threadRootId != null) {
|
||||
notifier.markContextRead(
|
||||
threadContextKey(threadRootId),
|
||||
item.latestActivityAt,
|
||||
);
|
||||
final channelRead = groupedChannelReadTimestamp(item);
|
||||
if (channelRead != null) {
|
||||
notifier.markContextRead(
|
||||
channelRead.channelId,
|
||||
channelRead.timestamp,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
final channelId = item.item.channelId;
|
||||
if (channelId != null) {
|
||||
notifier.markContextRead(channelId, item.latestActivityAt);
|
||||
ref
|
||||
.read(channelsProvider.notifier)
|
||||
.clearObservedUnreadCoveredByRead(channelId, item.latestActivityAt);
|
||||
return;
|
||||
}
|
||||
ref.read(inboxLocalStateProvider.notifier).markDone(item.id);
|
||||
}
|
||||
|
||||
void markItemUnread(InboxItem item) {
|
||||
ref
|
||||
.read(inboxLocalStateProvider.notifier)
|
||||
.markUnread(groupedInboxItemIds(item));
|
||||
}
|
||||
|
||||
void openItem(InboxItem item) {
|
||||
final channelId = item.item.channelId;
|
||||
if (channelId == null) {
|
||||
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.channelNotLinked)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final channel = channelById[channelId];
|
||||
if (channel == null) {
|
||||
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.channelNotFound)),
|
||||
);
|
||||
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.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(
|
||||
SnackBar(content: Text(context.l10n.draftChannelUnavailable)),
|
||||
);
|
||||
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(
|
||||
SnackBar(content: Text(context.l10n.reminderNotLinked)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final channel = channelById[target.channelId];
|
||||
if (channel == null) {
|
||||
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.reminderChannelUnavailable)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ChannelDetailPage(
|
||||
channel: channel,
|
||||
initialMessageId: target.eventId,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
await Future.wait([
|
||||
ref.read(activityProvider.notifier).refresh(),
|
||||
ref.read(remindersProvider.notifier).refresh(),
|
||||
]);
|
||||
}
|
||||
|
||||
late final Widget body;
|
||||
var bodyRidesOverTopSection = false;
|
||||
if (filter.value == InboxFilter.reminders) {
|
||||
body = _RemindersList(
|
||||
scrollController: scrollController,
|
||||
onOpen: openReminder,
|
||||
onRefresh: refresh,
|
||||
);
|
||||
} else if (filter.value == InboxFilter.drafts) {
|
||||
body = _DraftsList(
|
||||
drafts: drafts,
|
||||
scrollController: scrollController,
|
||||
channelById: channelById,
|
||||
myPubkey: myPk,
|
||||
onOpen: openDraft,
|
||||
onDelete: (draft) =>
|
||||
ref.read(composeDraftsProvider.notifier).remove(draft.key),
|
||||
);
|
||||
} else if (feedAsync.hasError && allItems.isEmpty) {
|
||||
body = _ErrorView(onRetry: refresh);
|
||||
} else if (!hasLoadedOnce.value && allItems.isEmpty) {
|
||||
body = _LoadingSkeleton(scrollController: scrollController);
|
||||
} else if (visibleItems.isEmpty) {
|
||||
body = _EmptyFilterState(
|
||||
filter: filter.value,
|
||||
unreadOnly: unreadOnly.value,
|
||||
);
|
||||
} else {
|
||||
// Compute the "New" boundary: index of the first unread row when the
|
||||
// rows above it are read (list is newest-first, so unread rows sit on
|
||||
// top; the divider marks where the unread block ends).
|
||||
final firstReadIndex = visibleItems.indexWhere(isDone);
|
||||
final newBoundaryIndex = !unreadOnly.value && firstReadIndex > 0
|
||||
? firstReadIndex
|
||||
: -1;
|
||||
|
||||
bodyRidesOverTopSection = true;
|
||||
body = BeeRefreshIndicator(
|
||||
edgeOffset: topSectionHeight,
|
||||
onRefresh: refresh,
|
||||
child: CustomScrollView(
|
||||
controller: scrollController,
|
||||
slivers: [
|
||||
SliverToBoxAdapter(child: SizedBox(height: topSectionHeight)),
|
||||
DecoratedSliver(
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surface,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(Radii.dialog),
|
||||
),
|
||||
),
|
||||
sliver: SliverPadding(
|
||||
padding: _activityScrollPadding(context),
|
||||
sliver: SliverList.builder(
|
||||
itemCount: visibleItems.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = visibleItems[index];
|
||||
final channel = item.item.channelId != null
|
||||
? channelById[item.item.channelId]
|
||||
: null;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (index == newBoundaryIndex)
|
||||
const _NewBoundaryDivider(),
|
||||
_InboxRow(
|
||||
key: ValueKey(item.id),
|
||||
item: item,
|
||||
channel: channel,
|
||||
currentPubkey: myPk,
|
||||
isDone: isDone(item),
|
||||
onTap: () => openItem(item),
|
||||
onMarkRead: () => markItemRead(item),
|
||||
onMarkUnread: () => markItemUnread(item),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return FrostedScaffold(
|
||||
backgroundColor: context.colors.surface,
|
||||
appBar: FrostedAppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
horizontalInset: Grid.gutter,
|
||||
showBottomDivider: true,
|
||||
bottomDividerOpacity: 0.06,
|
||||
title: Text(context.l10n.activityTitle, style: headerTitleStyle),
|
||||
titleStyle: headerTitleStyle,
|
||||
actions: [
|
||||
_ActivityActionsPill(
|
||||
filter: filter.value,
|
||||
dueReminderCount: dueReminderCount,
|
||||
draftCount: drafts.length,
|
||||
unreadOnly: unreadOnly.value,
|
||||
unreadCount: unreadVisibleCount,
|
||||
onFilterChanged: (f) => filter.value = f,
|
||||
onUnreadOnlyChanged: (v) => unreadOnly.value = v,
|
||||
onMarkAllRead: () {
|
||||
for (final item in visibleItems) {
|
||||
if (!isDone(item)) markItemRead(item);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
bottomHeight: Grid.xxs,
|
||||
bottom: const SizedBox.expand(),
|
||||
),
|
||||
body: SafeArea(
|
||||
key: const ValueKey('activity-content-safe-area'),
|
||||
top: false,
|
||||
bottom: false,
|
||||
child: bodyRidesOverTopSection
|
||||
? body
|
||||
: Padding(
|
||||
padding: EdgeInsets.only(top: topSectionHeight),
|
||||
child: body,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
part of '../activity_page.dart';
|
||||
|
||||
String _filterLabel(BuildContext context, InboxFilter filter) =>
|
||||
switch (filter) {
|
||||
InboxFilter.all => context.l10n.filterAll,
|
||||
InboxFilter.mention => context.l10n.filterMentions,
|
||||
InboxFilter.thread => context.l10n.filterThreads,
|
||||
InboxFilter.needsAction => context.l10n.filterNeedsAction,
|
||||
InboxFilter.activity => context.l10n.filterActivity,
|
||||
InboxFilter.agentActivity => context.l10n.filterAgents,
|
||||
InboxFilter.reminders => context.l10n.filterReminders,
|
||||
InboxFilter.drafts => context.l10n.filterDrafts,
|
||||
};
|
||||
|
||||
class _ActivityActionsPill extends StatelessWidget {
|
||||
final InboxFilter filter;
|
||||
final int dueReminderCount;
|
||||
final int draftCount;
|
||||
final bool unreadOnly;
|
||||
final int unreadCount;
|
||||
final ValueChanged<InboxFilter> onFilterChanged;
|
||||
final ValueChanged<bool> onUnreadOnlyChanged;
|
||||
final VoidCallback onMarkAllRead;
|
||||
|
||||
const _ActivityActionsPill({
|
||||
required this.filter,
|
||||
required this.dueReminderCount,
|
||||
required this.draftCount,
|
||||
required this.unreadOnly,
|
||||
required this.unreadCount,
|
||||
required this.onFilterChanged,
|
||||
required this.onUnreadOnlyChanged,
|
||||
required this.onMarkAllRead,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ClipRRect(
|
||||
borderRadius: BorderRadius.circular(Radii.full),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(Radii.full),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: Grid.quarter),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_FilterMenuButton(
|
||||
filter: filter,
|
||||
dueReminderCount: dueReminderCount,
|
||||
draftCount: draftCount,
|
||||
onChanged: onFilterChanged,
|
||||
),
|
||||
_InboxOptionsButton(
|
||||
unreadOnly: unreadOnly,
|
||||
unreadCount: unreadCount,
|
||||
onUnreadOnlyChanged: onUnreadOnlyChanged,
|
||||
onMarkAllRead: onMarkAllRead,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 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 Builder(
|
||||
builder: (buttonContext) => InkWell(
|
||||
key: const ValueKey('activity-filter-menu'),
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
onTap: () async {
|
||||
final selected = await showAnchoredPopover<InboxFilter>(
|
||||
context: buttonContext,
|
||||
width: 240,
|
||||
alignment: AnchoredPopoverAlignment.start,
|
||||
offset: const Offset(0, Grid.half),
|
||||
menuPadding: const EdgeInsets.symmetric(vertical: Grid.half),
|
||||
surfaceKey: const ValueKey('activity-filter-popover'),
|
||||
items: [
|
||||
for (final entry in InboxFilter.values)
|
||||
PopupMenuItem(
|
||||
value: entry,
|
||||
height: Grid.xl,
|
||||
padding: const EdgeInsets.symmetric(horizontal: Grid.twelve),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: Grid.sm,
|
||||
child: entry == filter
|
||||
? Icon(
|
||||
LucideIcons.check,
|
||||
size: 16,
|
||||
color: context.colors.primary,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_filterLabel(context, entry),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.labelLarge?.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (entry == InboxFilter.reminders &&
|
||||
dueReminderCount > 0)
|
||||
_CountBadge(count: dueReminderCount)
|
||||
else if (entry == InboxFilter.drafts &&
|
||||
draftCount > 0)
|
||||
_CountBadge(count: draftCount),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
if (buttonContext.mounted && selected != null) onChanged(selected);
|
||||
},
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: Grid.xl),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: Grid.xxs),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
_filterLabel(context, filter),
|
||||
style: context.textTheme.labelLarge?.copyWith(
|
||||
color: navigationPrimaryForeground(context),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.quarter),
|
||||
Icon(
|
||||
LucideIcons.chevronDown,
|
||||
size: 16,
|
||||
color: navigationPrimaryForeground(context),
|
||||
),
|
||||
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: navigationPrimaryForeground(context),
|
||||
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 Builder(
|
||||
builder: (buttonContext) => IconButton(
|
||||
key: const ValueKey('activity-options-menu'),
|
||||
tooltip: context.l10n.activityOptions,
|
||||
color: navigationPrimaryForeground(context),
|
||||
padding: const EdgeInsets.symmetric(horizontal: Grid.xxs),
|
||||
constraints: const BoxConstraints.tightFor(
|
||||
width: Grid.xl,
|
||||
height: Grid.xl,
|
||||
),
|
||||
icon: const Icon(LucideIcons.ellipsis, size: 20),
|
||||
onPressed: () async {
|
||||
final selected = await showAnchoredPopover<String>(
|
||||
context: buttonContext,
|
||||
width: 216,
|
||||
alignment: AnchoredPopoverAlignment.end,
|
||||
surfaceKey: const ValueKey('activity-options-popover'),
|
||||
items: [
|
||||
PopupMenuItem(
|
||||
value: 'unread-only',
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
unreadOnly
|
||||
? context.l10n.showAll
|
||||
: context.l10n.showUnread,
|
||||
),
|
||||
),
|
||||
if (unreadOnly)
|
||||
Icon(
|
||||
LucideIcons.check,
|
||||
size: 16,
|
||||
color: context.colors.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'mark-all-read',
|
||||
enabled: unreadCount > 0,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Text(context.l10n.markAllRead)),
|
||||
if (unreadCount > 0)
|
||||
Text(
|
||||
'$unreadCount',
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
if (!buttonContext.mounted || selected == null) return;
|
||||
if (selected == 'unread-only') onUnreadOnlyChanged(!unreadOnly);
|
||||
if (selected == 'mark-all-read') onMarkAllRead();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
part of '../activity_page.dart';
|
||||
|
||||
const _inboxSwipeActionInset = Grid.half;
|
||||
const _inboxSwipeActionLabelMinWidth = Grid.xxl;
|
||||
const _inboxSwipeLabelRevealWidth =
|
||||
_inboxSwipeActionLabelMinWidth + (_inboxSwipeActionInset * 2);
|
||||
|
||||
String _localizedInboxHeadline(BuildContext context, InboxItem item) {
|
||||
final l10n = context.l10n;
|
||||
return switch (item.item.kind) {
|
||||
45001 => l10n.forumPost,
|
||||
45003 => l10n.forumReply,
|
||||
46010 => l10n.approvalRequested,
|
||||
43001 => l10n.jobRequested,
|
||||
43002 => l10n.jobAccepted,
|
||||
43003 => l10n.progressUpdate,
|
||||
43004 => l10n.jobResult,
|
||||
43005 => l10n.jobCancelled,
|
||||
43006 => l10n.jobFailed,
|
||||
_ => switch (item.item.category) {
|
||||
'mention' => l10n.mention,
|
||||
'agent_activity' => l10n.agentUpdate,
|
||||
_ => l10n.channelUpdate,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
({String text, String? channelLabel}) _localizedInboxTypeLabel(
|
||||
BuildContext context,
|
||||
InboxItem item, {
|
||||
required String? channelName,
|
||||
required bool isDm,
|
||||
required String senderLabel,
|
||||
}) {
|
||||
final l10n = context.l10n;
|
||||
if (isDm) {
|
||||
return (
|
||||
text: senderLabel.isNotEmpty ? l10n.dmFrom(senderLabel) : l10n.directMessage,
|
||||
channelLabel: null,
|
||||
);
|
||||
}
|
||||
|
||||
final category = item.categories.firstOrNull ?? item.item.category;
|
||||
if (category == 'mention') {
|
||||
return (
|
||||
text: channelName != null ? l10n.mentionedIn : l10n.mentioned,
|
||||
channelLabel: channelName,
|
||||
);
|
||||
}
|
||||
if (category == 'needs_action') {
|
||||
return (
|
||||
text: channelName != null ? l10n.needsActionIn : l10n.needsAction,
|
||||
channelLabel: channelName,
|
||||
);
|
||||
}
|
||||
if (item.threadRootId != null) {
|
||||
return (
|
||||
text: channelName != null ? l10n.threadInLabel : l10n.threadTitle,
|
||||
channelLabel: channelName,
|
||||
);
|
||||
}
|
||||
|
||||
final headline = _localizedInboxHeadline(context, item);
|
||||
return (
|
||||
text: channelName != null ? l10n.headlineIn(headline) : headline,
|
||||
channelLabel: channelName,
|
||||
);
|
||||
}
|
||||
|
||||
String _localizedInboxPreview(BuildContext context, InboxItem item) {
|
||||
final content = item.item.content.trim();
|
||||
if (content.isNotEmpty) return content;
|
||||
return item.item.kind == 46010
|
||||
? context.l10n.workflowWaitingApproval
|
||||
: context.l10n.noAdditionalDetails;
|
||||
}
|
||||
|
||||
/// "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(
|
||||
context.l10n.newActivity,
|
||||
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.
|
||||
///
|
||||
/// Swiping left reveals the row's read-state action. Opening remains a tap or
|
||||
/// long-press action, so the swipe has one clear, easily recoverable outcome.
|
||||
class _InboxRow extends HookConsumerWidget {
|
||||
final InboxItem item;
|
||||
final Channel? channel;
|
||||
final String? currentPubkey;
|
||||
final bool isDone;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onMarkRead;
|
||||
final VoidCallback onMarkUnread;
|
||||
|
||||
const _InboxRow({
|
||||
super.key,
|
||||
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) {
|
||||
const restingRevealWidth = 132.0;
|
||||
const commitThreshold = 0.58;
|
||||
final revealAmount = useState(0.0);
|
||||
final isDragging = useState(false);
|
||||
final labelHapticFired = useRef(false);
|
||||
final userCache = ref.watch(userCacheProvider);
|
||||
final profile = userCache[item.item.pubkey.toLowerCase()];
|
||||
final senderLabel = profile?.displayName ?? shortPubkey(item.item.pubkey);
|
||||
final profileMentionNames = {
|
||||
for (final pubkey in mentionedPubkeysFromTags(item.item.tags))
|
||||
if (userCache[pubkey]?.displayName?.trim().isNotEmpty == true)
|
||||
pubkey: userCache[pubkey]!.displayName!.trim(),
|
||||
};
|
||||
final mentionPubkeys = mentionedPubkeysFromTags(item.item.tags);
|
||||
final knownAgentPubkeys = channel == null
|
||||
? ref.watch(knownAgentPubkeysProvider)
|
||||
: ref.watch(agentMentionPubkeysProvider(channel!.id));
|
||||
final agentMentionPubkeys = agentPubkeysWithProfileOwners(
|
||||
knownAgentPubkeys: knownAgentPubkeys,
|
||||
profileOwnedAgentPubkeys: [
|
||||
for (final profile in userCache.values)
|
||||
if (profile.ownerPubkey != null) profile.pubkey,
|
||||
],
|
||||
);
|
||||
final mentionNames = mentionNamesWithDirectoryLabels(
|
||||
mentionPubkeys: mentionPubkeys,
|
||||
profileMentionNames: profileMentionNames,
|
||||
directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider),
|
||||
agentMentionPubkeys: agentMentionPubkeys,
|
||||
);
|
||||
|
||||
final isDm = channel?.isDm ?? false;
|
||||
final channelName = channel != null && !isDm
|
||||
? (channel!.name.isNotEmpty ? channel!.name : null)
|
||||
: null;
|
||||
final label = _localizedInboxTypeLabel(
|
||||
context,
|
||||
item,
|
||||
channelName: channelName,
|
||||
isDm: isDm,
|
||||
senderLabel: senderLabel,
|
||||
);
|
||||
|
||||
final mutedColor = context.colors.onSurfaceVariant;
|
||||
final labelColor = item.isActionRequired && !isDone
|
||||
? context.colors.tertiary
|
||||
: mutedColor;
|
||||
|
||||
final reducedMotion = MediaQuery.of(context).disableAnimations;
|
||||
final swipeDirection = Directionality.of(context) == TextDirection.ltr
|
||||
? 1.0
|
||||
: -1.0;
|
||||
void closeActions() => revealAmount.value = 0;
|
||||
void toggleReadState() {
|
||||
closeActions();
|
||||
isDone ? onMarkUnread() : onMarkRead();
|
||||
}
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final actionExtent = constraints.maxWidth;
|
||||
final revealedWidth = revealAmount.value
|
||||
.clamp(0, actionExtent)
|
||||
.toDouble();
|
||||
final actionColor = isDone
|
||||
? context.colors.primary
|
||||
: context.appColors.success;
|
||||
return ClipRect(
|
||||
child: Stack(
|
||||
children: [
|
||||
if (revealedWidth > 0)
|
||||
PositionedDirectional(
|
||||
top: 0,
|
||||
end: 0,
|
||||
bottom: 0,
|
||||
width: revealedWidth,
|
||||
child: Padding(
|
||||
key: ValueKey('inbox-swipe-background-${item.id}'),
|
||||
padding: const EdgeInsets.all(_inboxSwipeActionInset),
|
||||
child: _InboxSwipeAction(
|
||||
key: ValueKey('inbox-swipe-read-${item.id}'),
|
||||
color: actionColor,
|
||||
foregroundColor: contrastForeground(actionColor),
|
||||
icon: isDone ? LucideIcons.mail : LucideIcons.mailOpen,
|
||||
label: isDone ? context.l10n.markUnread : context.l10n.markRead,
|
||||
onTap: toggleReadState,
|
||||
),
|
||||
),
|
||||
),
|
||||
AnimatedSlide(
|
||||
offset: Offset(
|
||||
-swipeDirection * revealAmount.value / constraints.maxWidth,
|
||||
0,
|
||||
),
|
||||
duration: reducedMotion || isDragging.value
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 160),
|
||||
curve: Curves.easeOutCubic,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onHorizontalDragStart: (_) {
|
||||
isDragging.value = true;
|
||||
labelHapticFired.value = false;
|
||||
},
|
||||
onHorizontalDragUpdate: (details) {
|
||||
isDragging.value = true;
|
||||
final previous = revealAmount.value;
|
||||
final next =
|
||||
(previous - (details.delta.dx * swipeDirection))
|
||||
.clamp(0, actionExtent)
|
||||
.toDouble();
|
||||
if (!labelHapticFired.value &&
|
||||
previous < _inboxSwipeLabelRevealWidth &&
|
||||
next >= _inboxSwipeLabelRevealWidth) {
|
||||
labelHapticFired.value = true;
|
||||
unawaited(HapticFeedback.selectionClick());
|
||||
}
|
||||
revealAmount.value = next;
|
||||
},
|
||||
onHorizontalDragEnd: (details) {
|
||||
isDragging.value = false;
|
||||
final velocity = details.primaryVelocity ?? 0;
|
||||
final shouldCommit =
|
||||
(velocity * swipeDirection) < -900 ||
|
||||
revealAmount.value >= actionExtent * commitThreshold;
|
||||
if (shouldCommit) {
|
||||
toggleReadState();
|
||||
} else {
|
||||
revealAmount.value =
|
||||
(velocity * swipeDirection) < -200 ||
|
||||
revealAmount.value >= restingRevealWidth / 2
|
||||
? restingRevealWidth
|
||||
: 0;
|
||||
}
|
||||
},
|
||||
child: Material(
|
||||
color: context.colors.surface,
|
||||
child: 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: messageAvatarContentGap),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Sender + unread dot + timestamp.
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: MessageAuthorMeta(
|
||||
displayName: senderLabel,
|
||||
username: messageUsernameLabel(
|
||||
profile,
|
||||
),
|
||||
timestamp: _inboxTimestamp(
|
||||
context,
|
||||
item.latestActivityAt,
|
||||
),
|
||||
nameColor: context.colors.onSurface,
|
||||
metadataColor: mutedColor,
|
||||
nameStyle: activityUsernameTextStyle,
|
||||
metadataStyle:
|
||||
activityTimestampTextStyle,
|
||||
displayNameKey: ValueKey(
|
||||
'activity-author-${item.id}',
|
||||
),
|
||||
usernameKey: ValueKey(
|
||||
'activity-username-${item.id}',
|
||||
),
|
||||
timestampKey: ValueKey(
|
||||
'activity-timestamp-${item.id}',
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!isDone) ...[
|
||||
const SizedBox(width: Grid.xxs),
|
||||
Container(
|
||||
key: ValueKey(
|
||||
'inbox-unread-dot-${item.id}',
|
||||
),
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: context.colors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: Grid.quarter),
|
||||
// Contextual label: "Mentioned in #channel" etc.
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
label.text,
|
||||
style: activityContextTextStyle
|
||||
.copyWith(color: labelColor),
|
||||
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: activityContextTextStyle
|
||||
.copyWith(color: mutedColor),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: Grid.half),
|
||||
// Message preview.
|
||||
MessageContent(
|
||||
content: _localizedInboxPreview(context, item),
|
||||
mentionNames: mentionNames,
|
||||
agentMentionPubkeys: agentMentionPubkeys,
|
||||
tags: item.item.tags,
|
||||
maxLines: 2,
|
||||
baseStyle: activityPreviewTextStyle
|
||||
.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showRowActions(BuildContext context) {
|
||||
showBuzzModalBottomSheet<void>(
|
||||
context: context,
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Grid.gutter,
|
||||
0,
|
||||
Grid.gutter,
|
||||
Grid.xs,
|
||||
),
|
||||
child: IconTheme.merge(
|
||||
data: const IconThemeData(size: 18),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
isDone ? LucideIcons.mail : LucideIcons.mailOpen,
|
||||
),
|
||||
title: Text(
|
||||
isDone ? context.l10n.markUnread : context.l10n.markRead,
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
isDone ? onMarkUnread() : onMarkRead();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.externalLink),
|
||||
title: Text(context.l10n.openConversation),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
onTap();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InboxSwipeAction extends StatelessWidget {
|
||||
final Color color;
|
||||
final Color foregroundColor;
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _InboxSwipeAction({
|
||||
super.key,
|
||||
required this.color,
|
||||
required this.foregroundColor,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Material(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(Radii.full),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(Radii.full),
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: label,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
if (constraints.maxWidth < Grid.lg) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final showLabel =
|
||||
constraints.maxWidth >= _inboxSwipeActionLabelMinWidth;
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 18, color: foregroundColor),
|
||||
if (showLabel) ...[
|
||||
const SizedBox(height: Grid.quarter),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: Grid.xxs),
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 2,
|
||||
softWrap: true,
|
||||
textAlign: TextAlign.center,
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: foregroundColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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: activityAvatarSize / 2,
|
||||
backgroundColor: context.colors.primaryContainer,
|
||||
fallback: Text(
|
||||
initial,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: context.colors.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
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 ScrollController scrollController;
|
||||
final void Function(Reminder reminder) onOpen;
|
||||
final Future<void> Function() onRefresh;
|
||||
|
||||
const _RemindersList({
|
||||
required this.scrollController,
|
||||
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 Center(
|
||||
child: BuzzLoadingIndicator(
|
||||
size: 44,
|
||||
semanticLabel: context.l10n.loadingReminders,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (reminders.isEmpty) {
|
||||
return _EmptySurface(
|
||||
icon: LucideIcons.clock,
|
||||
message: context.l10n.noReminders,
|
||||
detail: context.l10n.remindersHint,
|
||||
);
|
||||
}
|
||||
|
||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
return BeeRefreshIndicator(
|
||||
onRefresh: onRefresh,
|
||||
child: ListView.builder(
|
||||
controller: scrollController,
|
||||
padding: _activityScrollPadding(context),
|
||||
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 ?? context.l10n.reminderLabel;
|
||||
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
|
||||
? context.l10n.dueNow
|
||||
: context.l10n.dueAt(
|
||||
_inboxTimestamp(context, 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 ScrollController scrollController;
|
||||
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.scrollController,
|
||||
required this.channelById,
|
||||
required this.myPubkey,
|
||||
required this.onOpen,
|
||||
required this.onDelete,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (drafts.isEmpty) {
|
||||
return _EmptySurface(
|
||||
icon: LucideIcons.filePen,
|
||||
message: context.l10n.noDrafts,
|
||||
detail: context.l10n.draftsHint,
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
controller: scrollController,
|
||||
padding: _activityScrollPadding(context),
|
||||
itemCount: drafts.length,
|
||||
itemBuilder: (context, index) {
|
||||
final draft = drafts[index];
|
||||
final channel = channelById[draft.channelId];
|
||||
final destination = channel == null
|
||||
? context.l10n.unavailableChannel
|
||||
: 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
|
||||
? context.l10n.threadIn(destination)
|
||||
: destination,
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(LucideIcons.trash2, size: 18),
|
||||
tooltip: context.l10n.deleteDraft,
|
||||
onPressed: () => onDelete(draft),
|
||||
),
|
||||
onTap: () => onOpen(draft),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Inbox list timestamp — desktop's `formatInboxTimestamp`:
|
||||
/// time today, "Yesterday", short date this year, dated otherwise.
|
||||
String _inboxTimestamp(
|
||||
BuildContext context,
|
||||
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');
|
||||
final period = date.hour < 12 ? context.l10n.timeAm : context.l10n.timePm;
|
||||
return '$hour:$minute $period';
|
||||
}
|
||||
if (dayDiff == 1) return context.l10n.timeYesterday;
|
||||
|
||||
final months = [
|
||||
context.l10n.monthJan,
|
||||
context.l10n.monthFeb,
|
||||
context.l10n.monthMar,
|
||||
context.l10n.monthApr,
|
||||
context.l10n.monthMay,
|
||||
context.l10n.monthJun,
|
||||
context.l10n.monthJul,
|
||||
context.l10n.monthAug,
|
||||
context.l10n.monthSep,
|
||||
context.l10n.monthOct,
|
||||
context.l10n.monthNov,
|
||||
context.l10n.monthDec,
|
||||
];
|
||||
final month = months[date.month - 1];
|
||||
return date.year == current.year
|
||||
? context.l10n.shortDate(month, date.day)
|
||||
: context.l10n.shortDateYear(month, date.day, date.year);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
part of '../activity_page.dart';
|
||||
|
||||
class _LoadingSkeleton extends StatelessWidget {
|
||||
final ScrollController scrollController;
|
||||
|
||||
const _LoadingSkeleton({required this.scrollController});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView.separated(
|
||||
controller: scrollController,
|
||||
padding: _activityScrollPadding(
|
||||
context,
|
||||
horizontal: Grid.gutter,
|
||||
top: Grid.gutter,
|
||||
bottom: 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 _EmptySurface(
|
||||
icon: LucideIcons.mailOpen,
|
||||
message: context.l10n.activityNoUnread,
|
||||
detail: context.l10n.activityUnreadHint,
|
||||
);
|
||||
}
|
||||
final (icon, message) = switch (filter) {
|
||||
InboxFilter.mention => (
|
||||
LucideIcons.atSign,
|
||||
context.l10n.activityNoMentions,
|
||||
),
|
||||
InboxFilter.thread => (
|
||||
LucideIcons.messageSquare,
|
||||
context.l10n.activityNoThreadReplies,
|
||||
),
|
||||
InboxFilter.needsAction => (
|
||||
LucideIcons.circleAlert,
|
||||
context.l10n.activityNothingNeedsAction,
|
||||
),
|
||||
InboxFilter.activity => (
|
||||
LucideIcons.activity,
|
||||
context.l10n.activityNoRecent,
|
||||
),
|
||||
InboxFilter.agentActivity => (
|
||||
LucideIcons.bot,
|
||||
context.l10n.activityNoAgentUpdates,
|
||||
),
|
||||
_ => (LucideIcons.bell, context.l10n.activityNoActivity),
|
||||
};
|
||||
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(
|
||||
context.l10n.activityLoadFailed,
|
||||
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: Text(context.l10n.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../shared/relay/relay.dart';
|
||||
import '../channels/channel.dart';
|
||||
import '../channels/channels_provider.dart';
|
||||
import 'feed_item.dart';
|
||||
import 'inbox_item.dart';
|
||||
|
||||
/// Builds the Activity inbox feed over the relay websocket.
|
||||
///
|
||||
/// 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> {
|
||||
static const _addressedKinds = [
|
||||
1,
|
||||
9,
|
||||
40002,
|
||||
43001,
|
||||
43002,
|
||||
43003,
|
||||
43004,
|
||||
43005,
|
||||
43006,
|
||||
45001,
|
||||
45003,
|
||||
46010,
|
||||
46011,
|
||||
46012,
|
||||
];
|
||||
|
||||
void Function()? _unsubscribeAddressed;
|
||||
void Function()? _unsubscribeDms;
|
||||
Timer? _liveRefreshTimer;
|
||||
Future<void>? _refreshInFlight;
|
||||
int? _refreshGeneration;
|
||||
bool _refreshQueued = false;
|
||||
int _subscriptionGeneration = 0;
|
||||
|
||||
@override
|
||||
Future<HomeFeedResponse> build() async {
|
||||
ref.watch(relayConfigProvider);
|
||||
final sessionState = 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));
|
||||
|
||||
final generation = ++_subscriptionGeneration;
|
||||
_clearLiveSubscriptions();
|
||||
ref.onDispose(() {
|
||||
_subscriptionGeneration += 1;
|
||||
_clearLiveSubscriptions();
|
||||
});
|
||||
|
||||
final response = await _fetch();
|
||||
if (sessionState.status == SessionStatus.connected &&
|
||||
generation == _subscriptionGeneration) {
|
||||
unawaited(_subscribeLive(generation));
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
Future<void> _subscribeLive(int generation) async {
|
||||
final myPk = ref.read(myPubkeyProvider);
|
||||
if (myPk == null || generation != _subscriptionGeneration) return;
|
||||
|
||||
final session = ref.read(relaySessionProvider.notifier);
|
||||
final since = DateTime.now().millisecondsSinceEpoch ~/ 1000 - 5;
|
||||
try {
|
||||
final unsubscribeAddressed = await session.subscribe(
|
||||
NostrFilter(
|
||||
kinds: _addressedKinds,
|
||||
tags: {
|
||||
'#p': [myPk],
|
||||
},
|
||||
since: since,
|
||||
limit: 100,
|
||||
),
|
||||
(_) => _scheduleLiveRefresh(generation),
|
||||
);
|
||||
if (generation != _subscriptionGeneration) {
|
||||
unsubscribeAddressed();
|
||||
return;
|
||||
}
|
||||
_unsubscribeAddressed = unsubscribeAddressed;
|
||||
|
||||
final dmChannelIds = [
|
||||
for (final channel
|
||||
in ref.read(channelsProvider).asData?.value ?? const <Channel>[])
|
||||
if (channel.isDm && channel.isMember) channel.id,
|
||||
];
|
||||
if (dmChannelIds.isEmpty) return;
|
||||
|
||||
final unsubscribeDms = await session.subscribe(
|
||||
NostrFilter(
|
||||
kinds: const [9],
|
||||
tags: {'#h': dmChannelIds},
|
||||
since: since,
|
||||
limit: 100,
|
||||
),
|
||||
(_) => _scheduleLiveRefresh(generation),
|
||||
);
|
||||
if (generation != _subscriptionGeneration) {
|
||||
unsubscribeDms();
|
||||
return;
|
||||
}
|
||||
_unsubscribeDms = unsubscribeDms;
|
||||
} catch (error) {
|
||||
if (generation == _subscriptionGeneration) {
|
||||
debugPrint('[ActivityNotifier] live subscription failed: $error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _scheduleLiveRefresh(int generation) {
|
||||
if (generation != _subscriptionGeneration) return;
|
||||
_liveRefreshTimer?.cancel();
|
||||
_liveRefreshTimer = Timer(
|
||||
const Duration(milliseconds: 50),
|
||||
() => unawaited(_queueRefresh(generation)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _queueRefresh(int generation) {
|
||||
if (generation != _subscriptionGeneration) return Future.value();
|
||||
_refreshQueued = true;
|
||||
final inFlight = _refreshInFlight;
|
||||
if (_refreshGeneration == generation && inFlight != null) {
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
_refreshGeneration = generation;
|
||||
final future = _drainRefreshQueue(generation);
|
||||
_refreshInFlight = future;
|
||||
return future;
|
||||
}
|
||||
|
||||
Future<void> _drainRefreshQueue(int generation) async {
|
||||
try {
|
||||
do {
|
||||
_refreshQueued = false;
|
||||
try {
|
||||
final next = await _fetch();
|
||||
if (generation != _subscriptionGeneration) return;
|
||||
state = AsyncData(next);
|
||||
} catch (error) {
|
||||
if (generation != _subscriptionGeneration) return;
|
||||
debugPrint(
|
||||
'[ActivityNotifier] inbox refresh failed; retaining feed: $error',
|
||||
);
|
||||
}
|
||||
} while (_refreshQueued && generation == _subscriptionGeneration);
|
||||
} finally {
|
||||
if (_refreshGeneration == generation) {
|
||||
_refreshInFlight = null;
|
||||
_refreshGeneration = null;
|
||||
_refreshQueued = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _clearLiveSubscriptions() {
|
||||
_liveRefreshTimer?.cancel();
|
||||
_liveRefreshTimer = null;
|
||||
_refreshInFlight = null;
|
||||
_refreshGeneration = null;
|
||||
_refreshQueued = false;
|
||||
_unsubscribeAddressed?.call();
|
||||
_unsubscribeAddressed = null;
|
||||
_unsubscribeDms?.call();
|
||||
_unsubscribeDms = null;
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
final myPk = ref.read(myPubkeyProvider);
|
||||
if (myPk == null) {
|
||||
return HomeFeedResponse(
|
||||
mentions: const [],
|
||||
needsAction: const [],
|
||||
activity: const [],
|
||||
agentActivity: const [],
|
||||
);
|
||||
}
|
||||
|
||||
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([
|
||||
// Mentions of me on user-visible channel content.
|
||||
session.fetchHistory(
|
||||
NostrFilter(
|
||||
kinds: const [9, 40002, 1, 45001, 45003],
|
||||
tags: {
|
||||
'#p': [myPk],
|
||||
},
|
||||
limit: 50,
|
||||
),
|
||||
),
|
||||
// Workflow approvals addressed to me.
|
||||
session.fetchHistory(
|
||||
NostrFilter(
|
||||
kinds: const [46010, 46011, 46012],
|
||||
tags: {
|
||||
'#p': [myPk],
|
||||
},
|
||||
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),
|
||||
),
|
||||
]);
|
||||
|
||||
bool isFromOther(NostrEvent e) =>
|
||||
e.pubkey.toLowerCase() != myPk.toLowerCase();
|
||||
|
||||
// Dedupe across sources by event id, keeping the higher-priority
|
||||
// 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(
|
||||
mentions: [
|
||||
for (final i in items)
|
||||
if (i.category == 'mention') i,
|
||||
],
|
||||
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,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
FeedItem _feedItem(NostrEvent event, {required String category}) {
|
||||
return FeedItem(
|
||||
id: event.id,
|
||||
kind: event.kind,
|
||||
pubkey: event.pubkey,
|
||||
content: event.content,
|
||||
createdAt: event.createdAt,
|
||||
channelId: event.channelId,
|
||||
channelName: '',
|
||||
tags: event.tags,
|
||||
category: category,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
await _queueRefresh(_subscriptionGeneration);
|
||||
}
|
||||
}
|
||||
|
||||
final activityProvider =
|
||||
AsyncNotifierProvider<ActivityNotifier, HomeFeedResponse>(
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
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 = readMigratedPref<String>(
|
||||
prefs,
|
||||
canonicalKey: _prefsKey,
|
||||
legacyKey: '$_draftsPrefsKey:${config.storedOrigin}:$pubkey',
|
||||
read: prefs.getString,
|
||||
write: prefs.setString,
|
||||
);
|
||||
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,
|
||||
);
|
||||
@@ -0,0 +1,137 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
@immutable
|
||||
class FeedItem {
|
||||
final String id;
|
||||
final int kind;
|
||||
final String pubkey;
|
||||
final String content;
|
||||
final int createdAt;
|
||||
final String? channelId;
|
||||
final String channelName;
|
||||
final List<List<String>> tags;
|
||||
final String
|
||||
category; // "mention", "needs_action", "activity", "agent_activity"
|
||||
|
||||
const FeedItem({
|
||||
required this.id,
|
||||
required this.kind,
|
||||
required this.pubkey,
|
||||
required this.content,
|
||||
required this.createdAt,
|
||||
required this.channelId,
|
||||
required this.channelName,
|
||||
required this.tags,
|
||||
required this.category,
|
||||
});
|
||||
|
||||
factory FeedItem.fromJson(Map<String, dynamic> json) => FeedItem(
|
||||
id: json['id'] as String,
|
||||
kind: json['kind'] as int,
|
||||
pubkey: json['pubkey'] as String,
|
||||
content: json['content'] as String,
|
||||
createdAt: json['created_at'] as int,
|
||||
channelId: json['channel_id'] as String?,
|
||||
channelName: (json['channel_name'] as String?) ?? '',
|
||||
tags:
|
||||
(json['tags'] as List<dynamic>?)
|
||||
?.map((t) => (t as List<dynamic>).map((e) => e as String).toList())
|
||||
.toList() ??
|
||||
const [],
|
||||
category: json['category'] as String,
|
||||
);
|
||||
|
||||
/// The message whose thread directly contains this item, when this event is
|
||||
/// a reply. For nested replies this is the direct parent, not the outer root.
|
||||
String? get threadHeadId {
|
||||
for (final tag in tags) {
|
||||
if (tag.length >= 4 && tag[0] == 'e' && tag[3] == 'reply') {
|
||||
return tag[1];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Human-readable headline based on event kind and category.
|
||||
String get headline {
|
||||
switch (kind) {
|
||||
case 45001:
|
||||
return 'Forum post';
|
||||
case 45003:
|
||||
return 'Forum reply';
|
||||
case 46010:
|
||||
return 'Approval requested';
|
||||
case 43001:
|
||||
return 'Job requested';
|
||||
case 43002:
|
||||
return 'Job accepted';
|
||||
case 43003:
|
||||
return 'Progress update';
|
||||
case 43004:
|
||||
return 'Job result';
|
||||
case 43005:
|
||||
return 'Job cancelled';
|
||||
case 43006:
|
||||
return 'Job failed';
|
||||
default:
|
||||
if (category == 'mention') return 'Mention';
|
||||
if (category == 'agent_activity') return 'Agent update';
|
||||
return 'Channel update';
|
||||
}
|
||||
}
|
||||
|
||||
/// Trimmed content, with a fallback for empty events.
|
||||
String get displayContent {
|
||||
final trimmed = content.trim();
|
||||
if (trimmed.isNotEmpty) return trimmed;
|
||||
if (kind == 46010) return 'A workflow is waiting for approval.';
|
||||
return 'No additional details.';
|
||||
}
|
||||
}
|
||||
|
||||
/// Parsed response from GET /api/feed.
|
||||
@immutable
|
||||
class HomeFeedResponse {
|
||||
final List<FeedItem> mentions;
|
||||
final List<FeedItem> needsAction;
|
||||
final List<FeedItem> activity;
|
||||
final List<FeedItem> agentActivity;
|
||||
|
||||
HomeFeedResponse({
|
||||
required this.mentions,
|
||||
required this.needsAction,
|
||||
required this.activity,
|
||||
required this.agentActivity,
|
||||
});
|
||||
|
||||
factory HomeFeedResponse.fromJson(Map<String, dynamic> json) {
|
||||
final feed = json['feed'] as Map<String, dynamic>;
|
||||
return HomeFeedResponse(
|
||||
mentions: _parseItems(feed['mentions']),
|
||||
needsAction: _parseItems(feed['needs_action']),
|
||||
activity: _parseItems(feed['activity']),
|
||||
agentActivity: _parseItems(feed['agent_activity']),
|
||||
);
|
||||
}
|
||||
|
||||
/// All items merged into a single list, sorted newest-first.
|
||||
late final List<FeedItem> all = () {
|
||||
final items = [...mentions, ...needsAction, ...activity, ...agentActivity];
|
||||
items.sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
return List<FeedItem>.unmodifiable(items);
|
||||
}();
|
||||
|
||||
bool get isEmpty =>
|
||||
mentions.isEmpty &&
|
||||
needsAction.isEmpty &&
|
||||
activity.isEmpty &&
|
||||
agentActivity.isEmpty;
|
||||
}
|
||||
|
||||
List<FeedItem> _parseItems(dynamic json) {
|
||||
if (json == null) return const [];
|
||||
return (json as List<dynamic>)
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map(FeedItem.fromJson)
|
||||
.toList();
|
||||
}
|
||||
@@ -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}';
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
@@ -0,0 +1,71 @@
|
||||
import '../../shared/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);
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
Reference in New Issue
Block a user