Bring mobile sidebar unread and DM parity (#1303)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-06-26 09:06:46 -07:00
committed by GitHub
co-authored by Pinky
parent 2ecdcce7bd
commit 1843a05747
11 changed files with 707 additions and 147 deletions
+1 -1
View File
@@ -71,7 +71,7 @@ class Channel {
: null,
);
bool get isEphemeral => ttlSeconds != null;
bool get isEphemeral => ttlSeconds != null || ttlDeadline != null;
bool get isStream => channelType == 'stream';
bool get isForum => channelType == 'forum';
@@ -23,6 +23,8 @@ import 'channels_provider.dart';
import 'compose_bar.dart';
import 'date_formatters.dart';
import 'day_divider.dart';
import 'dm_channel_labels.dart';
import 'ephemeral_channel_display.dart';
import 'manage_channel_sheet.dart';
import 'members_sheet.dart';
import 'message_actions.dart';
@@ -159,11 +161,23 @@ class ChannelDetailPage extends HookConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
resolvedChannel.displayLabel(
currentPubkey: currentPubkey,
),
overflow: TextOverflow.ellipsis,
Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
resolveDmChannelDisplayLabel(
resolvedChannel,
currentPubkey: currentPubkey,
),
overflow: TextOverflow.ellipsis,
),
),
if (resolvedChannel.isEphemeral) ...[
const SizedBox(width: Grid.quarter),
_HeaderEphemeralBadge(channel: resolvedChannel),
],
],
),
if (resolvedChannel.isStream)
Text(
@@ -977,6 +991,28 @@ class _ReadOnlyNotice extends StatelessWidget {
}
}
class _HeaderEphemeralBadge extends StatelessWidget {
final Channel channel;
const _HeaderEphemeralBadge({required this.channel});
@override
Widget build(BuildContext context) {
final display = ephemeralChannelDisplay(channel);
if (display == null) return const SizedBox.shrink();
return Tooltip(
message: display.tooltipLabel,
child: Icon(
LucideIcons.clockFading,
key: const Key('chat-ephemeral-badge'),
size: 16,
color: context.colors.onSurfaceVariant,
),
);
}
}
class _DetailConnectionBanner extends StatelessWidget {
final SessionStatus status;
@@ -1233,11 +1269,25 @@ class _DmAppBarTitle extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
channel.displayLabel(currentPubkey: currentPubkey),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.titleSmall,
Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
resolveDmChannelDisplayLabel(
channel,
currentPubkey: currentPubkey,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.titleSmall,
),
),
if (channel.isEphemeral) ...[
const SizedBox(width: Grid.quarter),
_HeaderEphemeralBadge(channel: channel),
],
],
),
Text(
presenceLabel,
+192 -121
View File
@@ -21,33 +21,83 @@ import '../pairing/pairing_provider.dart';
import 'channel.dart';
import 'channel_detail_page.dart';
import 'channel_management_provider.dart';
import 'dm_channel_labels.dart';
import 'ephemeral_channel_display.dart';
import 'channel_mutes/channel_mutes_provider.dart';
import 'channel_sections/channel_sections_provider.dart';
import 'channel_sections/channel_sections_storage.dart';
import 'channel_stars/channel_stars_provider.dart';
import 'channels_provider.dart';
import 'read_state/deferred_read_state_update.dart';
import 'read_state/read_state_format.dart';
import 'read_state/read_state_provider.dart';
import 'read_state/read_state_time.dart';
import 'unread_badge/observed_unread_event.dart';
enum _QuickAction { createChannel, createForum, newDm }
enum _QuickAction { createChannel, newDm }
/// Height of the [_ConnectionBanner]: vertical padding (Grid.quarter + 2) × 2
/// plus the ~16px row content (12px spinner / labelSmall text).
const double _kBannerHeight = 24.0;
bool _isUnread(Channel channel, ReadStateState readState) {
if (readState.locallyForcedChannelIds.contains(channel.id)) {
return true;
class _UnreadChannelState {
final Set<String> ids;
final Map<String, int> counts;
const _UnreadChannelState({required this.ids, required this.counts});
}
_UnreadChannelState _computeUnreadChannelState({
required Iterable<Channel> channels,
required ReadStateState readState,
required ChannelsNotifier channelsNotifier,
}) {
if (!readState.isReady) {
return const _UnreadChannelState(ids: {}, counts: {});
}
final lastMessageAt = dateTimeToUnixSeconds(channel.lastMessageAt);
if (lastMessageAt == null) {
return false;
final latestObservedByChannel = channelsNotifier.latestObservedByChannel;
final observedEventsByChannel =
channelsNotifier.observedUnreadEventsByChannel;
final ids = <String>{};
final counts = <String, int>{};
for (final channel in channels) {
if (readState.locallyForcedChannelIds.contains(channel.id)) {
ids.add(channel.id);
counts[channel.id] = 1;
continue;
}
final latestObserved = latestObservedByChannel[channel.id];
if (latestObserved == null) continue;
final channelReadAt = readState.effectiveTimestamp(channel.id);
if (channelReadAt != null && latestObserved <= channelReadAt) continue;
final observedEvents = observedEventsByChannel[channel.id];
int? readAtForObservedEvent(ObservedUnreadEvent event) =>
observedUnreadEventReadAt(
event,
channelReadAt,
(rootId) => readState.effectiveTimestamp(threadContextKey(rootId)),
(messageId) => readState.effectiveTimestamp(msgContextKey(messageId)),
);
final unreadCount = countUnreadObservedEvents(
observedEvents,
readAtForObservedEvent,
);
if (unreadCount == 0) continue;
ids.add(channel.id);
counts[channel.id] = countUnreadBadgeObservedEvents(
observedEvents,
readAtForObservedEvent,
);
}
final readAt = readState.effectiveTimestamp(channel.id);
return readAt == null || lastMessageAt > readAt;
return _UnreadChannelState(ids: ids, counts: counts);
}
class ChannelsPage extends HookConsumerWidget {
@@ -103,16 +153,11 @@ class ChannelsPage extends HookConsumerWidget {
switch (action) {
case _QuickAction.createChannel:
case _QuickAction.createForum:
final created = await showModalBottomSheet<Channel>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (_) => _CreateChannelSheet(
channelType: action == _QuickAction.createForum
? 'forum'
: 'stream',
),
builder: (_) => const _CreateChannelSheet(channelType: 'stream'),
);
if (created != null && context.mounted) {
await openChannel(created);
@@ -297,16 +342,13 @@ class _SliverChannelsList extends HookConsumerWidget {
final streamChannels = visibleChannels
.where((channel) => channel.isStream)
.toList();
final forumChannels = visibleChannels
.where((channel) => channel.isForum)
.toList();
final dmChannels = visibleChannels
.where((channel) => channel.isDm)
.toList();
final dmChannels = sortDmChannelsByDisplayLabel(
visibleChannels.where((channel) => channel.isDm),
currentPubkey: currentPubkey,
);
final starredExpanded = useState(true);
final channelsExpanded = useState(true);
final forumsExpanded = useState(true);
final dmsExpanded = useState(true);
final initialSeedComplete = useState(false);
final seededPubkey = useRef<String?>(null);
@@ -343,16 +385,21 @@ class _SliverChannelsList extends HookConsumerWidget {
});
}, [readState.isReady, readState.pubkey, visibleChannels]);
final unreadChannelIds = readState.isReady
? {
for (final channel in visibleChannels)
if ((seedCompleteForPubkey ||
readState.effectiveTimestamp(channel.id) != null) &&
_isUnread(channel, readState) &&
!mutedChannelIds.contains(channel.id))
channel.id,
}
: const <String>{};
final unreadState = _computeUnreadChannelState(
channels: visibleChannels,
readState: readState,
channelsNotifier: ref.read(channelsProvider.notifier),
);
final unreadChannelIds = {
for (final channelId in unreadState.ids)
if (seedCompleteForPubkey ||
readState.effectiveTimestamp(channelId) != null)
channelId,
};
final unreadChannelCounts = {
for (final entry in unreadState.counts.entries)
if (unreadChannelIds.contains(entry.key)) entry.key: entry.value,
};
// Build sorted user-defined sections and compute which stream channels
// belong to each section. Channels not assigned to any valid section fall
@@ -406,6 +453,7 @@ class _SliverChannelsList extends HookConsumerWidget {
onToggle: () => starredExpanded.value = !starredExpanded.value,
channels: starredStreamChannels,
unreadChannelIds: unreadChannelIds,
unreadChannelCounts: unreadChannelCounts,
mutedChannelIds: mutedChannelIds,
currentPubkey: currentPubkey,
emptyLabel: '',
@@ -423,6 +471,7 @@ class _SliverChannelsList extends HookConsumerWidget {
)
.toList(),
unreadChannelIds: unreadChannelIds,
unreadChannelCounts: unreadChannelCounts,
mutedChannelIds: mutedChannelIds,
currentPubkey: currentPubkey,
expanded: sectionExpanded(section.id),
@@ -501,23 +550,12 @@ class _SliverChannelsList extends HookConsumerWidget {
onToggle: () => channelsExpanded.value = !channelsExpanded.value,
channels: ungroupedStreamChannels,
unreadChannelIds: unreadChannelIds,
unreadChannelCounts: unreadChannelCounts,
mutedChannelIds: mutedChannelIds,
currentPubkey: currentPubkey,
emptyLabel: 'No stream channels yet',
onSelectChannel: onSelectChannel,
),
_ChannelSection(
title: 'Forums',
icon: LucideIcons.messageSquareText,
expanded: forumsExpanded.value,
onToggle: () => forumsExpanded.value = !forumsExpanded.value,
channels: forumChannels,
unreadChannelIds: unreadChannelIds,
mutedChannelIds: mutedChannelIds,
currentPubkey: currentPubkey,
emptyLabel: 'No forums yet',
onSelectChannel: onSelectChannel,
),
_ChannelSection(
title: 'DMs',
icon: LucideIcons.messagesSquare,
@@ -525,6 +563,7 @@ class _SliverChannelsList extends HookConsumerWidget {
onToggle: () => dmsExpanded.value = !dmsExpanded.value,
channels: dmChannels,
unreadChannelIds: unreadChannelIds,
unreadChannelCounts: unreadChannelCounts,
mutedChannelIds: mutedChannelIds,
currentPubkey: currentPubkey,
emptyLabel: 'No direct messages yet',
@@ -541,6 +580,7 @@ class _CustomChannelSection extends StatelessWidget {
final ChannelSection section;
final List<Channel> channels;
final Set<String> unreadChannelIds;
final Map<String, int> unreadChannelCounts;
final Set<String> mutedChannelIds;
final String? currentPubkey;
final bool expanded;
@@ -558,6 +598,7 @@ class _CustomChannelSection extends StatelessWidget {
required this.section,
required this.channels,
required this.unreadChannelIds,
required this.unreadChannelCounts,
required this.mutedChannelIds,
required this.currentPubkey,
required this.expanded,
@@ -592,6 +633,7 @@ class _CustomChannelSection extends StatelessWidget {
for (final channel in channels)
_ChannelTile(
channel: channel,
unreadCount: unreadChannelCounts[channel.id],
isUnread: unreadChannelIds.contains(channel.id),
isMuted: mutedChannelIds.contains(channel.id),
currentPubkey: currentPubkey,
@@ -759,6 +801,7 @@ class _ChannelSection extends StatelessWidget {
final VoidCallback onToggle;
final List<Channel> channels;
final Set<String> unreadChannelIds;
final Map<String, int> unreadChannelCounts;
final Set<String> mutedChannelIds;
final String? currentPubkey;
final String emptyLabel;
@@ -771,6 +814,7 @@ class _ChannelSection extends StatelessWidget {
required this.onToggle,
required this.channels,
required this.unreadChannelIds,
required this.unreadChannelCounts,
required this.mutedChannelIds,
required this.currentPubkey,
required this.emptyLabel,
@@ -808,6 +852,7 @@ class _ChannelSection extends StatelessWidget {
for (final channel in channels)
_ChannelTile(
channel: channel,
unreadCount: unreadChannelCounts[channel.id],
isUnread: unreadChannelIds.contains(channel.id),
isMuted: mutedChannelIds.contains(channel.id),
currentPubkey: currentPubkey,
@@ -903,6 +948,7 @@ class _SectionHeader extends StatelessWidget {
class _ChannelTile extends ConsumerWidget {
final Channel channel;
final int? unreadCount;
final bool isUnread;
final bool isMuted;
final String? currentPubkey;
@@ -917,6 +963,7 @@ class _ChannelTile extends ConsumerWidget {
const _ChannelTile({
required this.channel,
this.unreadCount,
required this.isUnread,
required this.currentPubkey,
required this.onTap,
@@ -934,7 +981,7 @@ class _ChannelTile extends ConsumerWidget {
onTap: onTap,
onLongPress: () => _showChannelActions(context, ref),
child: Opacity(
opacity: isMuted ? 0.5 : 1.0,
opacity: isMuted && !isUnread ? 0.5 : 1.0,
child: Padding(
padding: const EdgeInsets.only(
left: Grid.xs + Grid.xxs,
@@ -950,7 +997,7 @@ class _ChannelTile extends ConsumerWidget {
Icon(
channelIcon(channel),
size: 18,
color: hasActivity
color: isUnread || hasActivity
? context.colors.onSurface
: context.colors.onSurfaceVariant,
),
@@ -960,7 +1007,10 @@ class _ChannelTile extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
channel.displayLabel(currentPubkey: currentPubkey),
resolveDmChannelDisplayLabel(
channel,
currentPubkey: currentPubkey,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodyMedium?.copyWith(
@@ -969,14 +1019,16 @@ class _ChannelTile extends ConsumerWidget {
: hasActivity
? context.colors.onSurface
: context.colors.onSurfaceVariant,
fontWeight: isUnread && !isMuted
? FontWeight.w700
: null,
fontWeight: isUnread ? FontWeight.w700 : null,
),
),
],
),
),
if (channel.isEphemeral) ...[
const SizedBox(width: Grid.xxs),
_EphemeralBadge(channel: channel),
],
if (isMuted) ...[
const SizedBox(width: Grid.xxs),
Icon(
@@ -984,17 +1036,10 @@ class _ChannelTile extends ConsumerWidget {
size: 12,
color: context.colors.onSurfaceVariant,
),
] else if (isUnread) ...[
],
if (isUnread && !channel.isDm) ...[
const SizedBox(width: Grid.xxs),
Container(
key: Key('channel-unread-${channel.id}'),
width: 8,
height: 8,
decoration: BoxDecoration(
color: context.colors.primary,
shape: BoxShape.circle,
),
),
_UnreadBadge(channelId: channel.id, count: unreadCount ?? 0),
],
if (!channel.isMember && !channel.isDm)
Padding(
@@ -1017,10 +1062,6 @@ class _ChannelTile extends ConsumerWidget {
),
),
),
if (channel.isEphemeral) ...[
const SizedBox(width: Grid.xxs),
_EphemeralBadge(channel: channel),
],
],
),
),
@@ -1233,16 +1274,37 @@ class _DmAvatar extends ConsumerWidget {
final profiles = ref.watch(userCacheProvider);
final presenceMap = ref.watch(presenceCacheProvider);
final normalizedCurrent = currentPubkey?.toLowerCase();
final otherPubkeys = [
for (final pk in channel.participantPubkeys)
if (pk.toLowerCase() != normalizedCurrent) pk.toLowerCase(),
];
final visiblePubkeys = otherPubkeys.isNotEmpty
? otherPubkeys
: channel.participantPubkeys.map((pk) => pk.toLowerCase()).toList();
// Find the other participant's pubkey.
String? otherPubkey;
for (final pk in channel.participantPubkeys) {
if (pk.toLowerCase() != normalizedCurrent) {
otherPubkey = pk.toLowerCase();
break;
}
if (visiblePubkeys.length > 1) {
return Container(
width: 22,
height: 22,
alignment: Alignment.center,
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
shape: BoxShape.circle,
border: Border.all(color: context.colors.outlineVariant),
),
child: Text(
'${visiblePubkeys.length}',
style: context.textTheme.labelSmall?.copyWith(
fontSize: 10,
color: context.colors.onSurface,
fontWeight: FontWeight.w600,
height: 1,
),
),
);
}
final otherPubkey = visiblePubkeys.isNotEmpty ? visiblePubkeys.first : null;
final profile = otherPubkey != null ? profiles[otherPubkey] : null;
// Trigger fetches if not cached yet.
@@ -1333,12 +1395,6 @@ class _QuickActionsSheet extends StatelessWidget {
onTap: () =>
Navigator.of(context).pop(_QuickAction.createChannel),
),
ListTile(
leading: const Icon(LucideIcons.messageSquareText),
title: const Text('Create forum'),
subtitle: const Text('Set up a threaded discussion space'),
onTap: () => Navigator.of(context).pop(_QuickAction.createForum),
),
ListTile(
leading: const Icon(LucideIcons.messagesSquare),
title: const Text('New direct message'),
@@ -1670,6 +1726,57 @@ class _NewDirectMessageSheet extends HookConsumerWidget {
}
}
class _UnreadBadge extends StatelessWidget {
final String channelId;
final int count;
const _UnreadBadge({required this.channelId, required this.count});
@override
Widget build(BuildContext context) {
if (count <= 0) {
return SizedBox(
key: Key('channel-unread-dot-$channelId'),
width: 20,
height: 20,
child: Center(
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: context.colors.primary,
shape: BoxShape.circle,
),
child: Semantics(label: 'unread'),
),
),
);
}
return Container(
key: Key('channel-unread-$channelId'),
constraints: const BoxConstraints(minWidth: 20, minHeight: 20),
padding: const EdgeInsets.symmetric(horizontal: Grid.quarter),
alignment: Alignment.center,
decoration: BoxDecoration(
color: context.colors.primary,
borderRadius: BorderRadius.circular(999),
),
child: Text(
_formatUnreadCount(count),
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onPrimary,
fontSize: 10,
fontWeight: FontWeight.w700,
height: 1,
),
),
);
}
}
String _formatUnreadCount(int count) => count > 99 ? '99+' : count.toString();
class _EphemeralBadge extends StatelessWidget {
final Channel channel;
@@ -1677,55 +1784,19 @@ class _EphemeralBadge extends StatelessWidget {
@override
Widget build(BuildContext context) {
final label = _label();
final display = ephemeralChannelDisplay(channel);
if (display == null) return const SizedBox.shrink();
return Tooltip(
message: 'Ephemeral channel — cleans up after inactivity',
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFFF59E0B).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(Radii.sm),
border: Border.all(
color: const Color(0xFFF59E0B).withValues(alpha: 0.2),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(LucideIcons.clock, size: 10, color: _amberColor(context)),
if (label != null) ...[
const SizedBox(width: 3),
Text(
label,
style: context.textTheme.labelSmall?.copyWith(
fontSize: 10,
color: _amberColor(context),
fontWeight: FontWeight.w500,
),
),
],
],
),
message: display.tooltipLabel,
child: Icon(
LucideIcons.clockFading,
key: Key('channel-ephemeral-${channel.id}'),
size: 16,
color: context.colors.onSurfaceVariant,
),
);
}
Color _amberColor(BuildContext context) {
return context.colors.brightness == Brightness.light
? const Color(0xFFB45309)
: const Color(0xFFFCD34D);
}
String? _label() {
final deadline = channel.ttlDeadline;
if (deadline == null) return null;
final diff = deadline.difference(DateTime.now());
if (diff.isNegative) return 'due';
if (diff.inMinutes < 60) return '${diff.inMinutes}m';
if (diff.inHours < 24) return '${diff.inHours}h';
return '${diff.inDays}d';
}
}
class _ConnectionBanner extends StatelessWidget {
@@ -10,6 +10,7 @@ import '../../shared/theme/theme_provider.dart';
import '../../shared/utils/string_utils.dart';
import 'channel.dart';
import 'channel_management_provider.dart' show channelDetailsProvider;
import 'channel_mutes/channel_mutes_provider.dart';
import 'read_state/read_state_provider.dart';
import 'unread_badge/is_high_priority_event.dart';
import 'unread_badge/observed_unread_event.dart';
@@ -184,6 +185,8 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
}
}
final hiddenDmIds = await _fetchHiddenDmIds(session, myPk);
final channels = <Channel>[];
for (final event in dedupedMetas) {
final channel = _channelFromMeta(
@@ -191,6 +194,7 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
isMember: true,
displayNames: displayNames,
);
if (channel.isDm && hiddenDmIds.contains(channel.id)) continue;
// Ephemeral (TTL) channels are surfaced in the list with an
// `_EphemeralBadge` rendered in `channels_page.dart` — they shouldn't be
// hidden. Desktop shows them too. Previously dropped here unconditionally,
@@ -258,7 +262,12 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
),
);
for (final event in events) {
if (shouldNotifyForEvent(event, myPk)) {
if (shouldNotifyForEvent(
event,
myPk,
mutedChannelIds: _mutedChannelIds(),
channelId: channel.id,
)) {
return MapEntry(channel.id, event.createdAt);
}
}
@@ -323,6 +332,28 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
return channels;
}
Future<Set<String>> _fetchHiddenDmIds(
RelaySessionNotifier session,
String myPk,
) async {
try {
final events = await session.fetchHistory(NostrFilters.hiddenDms(myPk));
if (events.isEmpty) return const {};
NostrEvent latest = events.first;
for (final event in events.skip(1)) {
if (event.createdAt > latest.createdAt) {
latest = event;
}
}
return {
for (final tag in latest.tags)
if (tag.length >= 2 && tag[0] == 'h') tag[1],
};
} catch (_) {
return const {};
}
}
/// Build a [Channel] from a kind:39000 metadata event.
///
/// [displayNames] maps lowercase participant pubkey → resolved label and is
@@ -434,6 +465,7 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
if (myPk == null) return;
final session = ref.read(relaySessionProvider.notifier);
final mutedChannelIds = _mutedChannelIds();
final ReadStateState readState;
try {
readState = ref.read(readStateProvider);
@@ -447,7 +479,13 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
if (!channel.isMember || channel.isArchived) continue;
final readAt = readState.effectiveTimestamp(channel.id);
futures.add(
_catchUpUnreadEventsForChannel(session, channel, myPk, readAt),
_catchUpUnreadEventsForChannel(
session,
channel,
myPk,
readAt,
mutedChannelIds,
),
);
}
@@ -464,6 +502,7 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
Channel channel,
String myPk,
int? readAt,
Set<String> mutedChannelIds,
) async {
try {
final events = await session.fetchHistory(
@@ -491,6 +530,8 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
myPk,
participatedRootIds: _participatedRootIds,
authoredRootIds: _authoredRootIds,
mutedChannelIds: mutedChannelIds,
channelId: channel.id,
)) {
continue;
}
@@ -508,6 +549,7 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
if (channelId == null) return;
final myPk = ref.read(myPubkeyProvider);
final mutedChannelIds = _mutedChannelIds();
state = state.whenData((channels) {
final idx = channels.indexWhere((c) => c.id == channelId);
@@ -528,6 +570,8 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
myPk,
participatedRootIds: _participatedRootIds,
authoredRootIds: _authoredRootIds,
mutedChannelIds: mutedChannelIds,
channelId: channel.id,
)) {
_recordUnreadEvent(channel, event, myPk);
final eventTime = DateTime.fromMillisecondsSinceEpoch(
@@ -544,6 +588,11 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
});
}
Set<String> _mutedChannelIds() => {
for (final entry in ref.read(channelMutesProvider).store.channels.entries)
if (entry.value.muted) entry.key,
};
void _loadThreadInterestStores(String pubkey) {
final normalizedPubkey = pubkey.toLowerCase();
if (_threadInterestPubkey == normalizedPubkey) return;
@@ -0,0 +1,78 @@
import '../../shared/utils/string_utils.dart';
import 'channel.dart';
const int _dmParticipantPreviewLimit = 3;
bool isGenericDmChannelName(String name) {
final normalized = name.trim().toLowerCase();
if (normalized.isEmpty ||
normalized == 'dm' ||
normalized == 'direct message' ||
normalized == 'direct messages') {
return true;
}
return RegExp(r'^group dm\s*(\(\d+\))?$').hasMatch(normalized);
}
String formatDmParticipantDisplayName(List<String> displayNames) {
final visible = displayNames.take(_dmParticipantPreviewLimit).toList();
final hiddenCount = displayNames.length - visible.length;
return hiddenCount > 0
? [...visible, '+$hiddenCount more'].join(', ')
: visible.join(', ');
}
String resolveDmChannelDisplayLabel(Channel channel, {String? currentPubkey}) {
if (!channel.isDm || !isGenericDmChannelName(channel.name)) {
return channel.name;
}
final normalizedCurrent = currentPubkey?.toLowerCase();
final participants = <({String label, String? pubkey})>[
for (var index = 0; index < channel.participantPubkeys.length; index++)
(
label: index < channel.participants.length
? channel.participants[index]
: shortPubkey(channel.participantPubkeys[index]),
pubkey: channel.participantPubkeys[index].toLowerCase(),
),
];
final displayParticipants = normalizedCurrent == null
? participants
: participants
.where((participant) => participant.pubkey != normalizedCurrent)
.toList();
final labels = <String>{
for (final participant
in displayParticipants.isNotEmpty ? displayParticipants : participants)
participant.label,
}.toList();
return labels.isNotEmpty
? formatDmParticipantDisplayName(labels)
: channel.name;
}
List<Channel> sortDmChannelsByDisplayLabel(
Iterable<Channel> channels, {
String? currentPubkey,
}) {
final sorted = channels.toList();
sorted.sort((left, right) {
final leftLabel = resolveDmChannelDisplayLabel(
left,
currentPubkey: currentPubkey,
);
final rightLabel = resolveDmChannelDisplayLabel(
right,
currentPubkey: currentPubkey,
);
final labelCompare = leftLabel.toLowerCase().compareTo(
rightLabel.toLowerCase(),
);
if (labelCompare != 0) return labelCompare;
return left.id.compareTo(right.id);
});
return sorted;
}
@@ -0,0 +1,129 @@
import 'dart:math' as math;
import 'channel.dart';
class EphemeralChannelDisplay {
final String? detailLabel;
final String tooltipLabel;
const EphemeralChannelDisplay({
required this.detailLabel,
required this.tooltipLabel,
});
}
bool isEphemeralChannel(Channel channel) =>
channel.ttlSeconds != null || channel.ttlDeadline != null;
EphemeralChannelDisplay? ephemeralChannelDisplay(
Channel channel, {
DateTime? now,
}) {
if (!isEphemeralChannel(channel)) return null;
final deadline = channel.ttlDeadline;
final remainingSeconds = deadline == null
? null
: ((deadline.millisecondsSinceEpoch -
(now ?? DateTime.now()).millisecondsSinceEpoch) /
1000)
.ceil();
if (remainingSeconds == null) {
final ttlSeconds = channel.ttlSeconds;
return EphemeralChannelDisplay(
detailLabel: ttlSeconds == null ? null : formatCompactTtl(ttlSeconds),
tooltipLabel: ttlSeconds == null
? 'Ephemeral channel. Cleans up automatically after inactivity.'
: 'Ephemeral channel. Cleans up after ${formatVerboseTtl(ttlSeconds)} of inactivity.',
);
}
final compactRemaining = formatCompactRemaining(remainingSeconds);
final verboseRemaining = formatVerboseRemaining(remainingSeconds);
final absoluteDeadlineLabel = formatAbsoluteDeadline(deadline!);
return EphemeralChannelDisplay(
detailLabel: compactRemaining,
tooltipLabel: compactRemaining == 'Cleanup due'
? 'Ephemeral channel. Cleanup is due now.'
: 'Ephemeral channel. Cleans up $verboseRemaining. Scheduled for $absoluteDeadlineLabel.',
);
}
String formatCompactRemaining(int remainingSeconds) {
if (remainingSeconds <= 0) return 'Cleanup due';
if (remainingSeconds <= 60) return '1m left';
if (remainingSeconds < 60 * 60) {
return '${math.max(1, (remainingSeconds / 60).ceil())}m left';
}
if (remainingSeconds < 60 * 60 * 24) {
return '${math.max(1, (remainingSeconds / (60 * 60)).ceil())}h left';
}
return '${math.max(1, (remainingSeconds / (60 * 60 * 24)).ceil())}d left';
}
String formatVerboseRemaining(int remainingSeconds) {
if (remainingSeconds <= 0) return 'now';
if (remainingSeconds <= 60) return 'in 1 minute';
if (remainingSeconds < 60 * 60) {
final minutes = math.max(1, (remainingSeconds / 60).ceil());
return 'in $minutes minute${minutes == 1 ? '' : 's'}';
}
if (remainingSeconds < 60 * 60 * 24) {
final hours = math.max(1, (remainingSeconds / (60 * 60)).ceil());
return 'in $hours hour${hours == 1 ? '' : 's'}';
}
final days = math.max(1, (remainingSeconds / (60 * 60 * 24)).ceil());
return 'in $days day${days == 1 ? '' : 's'}';
}
String formatCompactTtl(int ttlSeconds) {
if (ttlSeconds < 60) return '${math.max(1, ttlSeconds)}s TTL';
if (ttlSeconds < 60 * 60) {
return '${math.max(1, (ttlSeconds / 60).ceil())}m TTL';
}
if (ttlSeconds < 60 * 60 * 24) {
return '${math.max(1, (ttlSeconds / (60 * 60)).ceil())}h TTL';
}
return '${math.max(1, (ttlSeconds / (60 * 60 * 24)).ceil())}d TTL';
}
String formatVerboseTtl(int ttlSeconds) {
if (ttlSeconds < 60) {
final seconds = math.max(1, ttlSeconds);
return '$seconds second${seconds == 1 ? '' : 's'}';
}
if (ttlSeconds < 60 * 60) {
final minutes = math.max(1, (ttlSeconds / 60).ceil());
return '$minutes minute${minutes == 1 ? '' : 's'}';
}
if (ttlSeconds < 60 * 60 * 24) {
final hours = math.max(1, (ttlSeconds / (60 * 60)).ceil());
return '$hours hour${hours == 1 ? '' : 's'}';
}
final days = math.max(1, (ttlSeconds / (60 * 60 * 24)).ceil());
return '$days day${days == 1 ? '' : 's'}';
}
String formatAbsoluteDeadline(DateTime deadline) {
const months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
final local = deadline.toLocal();
final hour12 = local.hour % 12 == 0 ? 12 : local.hour % 12;
final minute = local.minute.toString().padLeft(2, '0');
final period = local.hour < 12 ? 'AM' : 'PM';
return '${months[local.month - 1]} ${local.day}, $hour12:$minute $period';
}
@@ -4,14 +4,17 @@ bool shouldNotifyForEvent(
NostrEvent event,
String myPubkey, {
Set<String> participatedRootIds = const {},
Set<String> followedRootIds = const {},
Set<String> authoredRootIds = const {},
Set<String> mutedRootIds = const {},
Set<String> mutedChannelIds = const {},
String? channelId,
}) {
if (!EventKind.channelMessageEventKinds.contains(event.kind)) return false;
if (event.pubkey.toLowerCase() == myPubkey.toLowerCase()) return false;
final ref = event.threadReference;
if (ref.parentId == null) return true;
for (final tag in event.tags) {
if (tag.length >= 2 && tag[0] == 'broadcast' && tag[1] == '1') {
@@ -28,8 +31,20 @@ bool shouldNotifyForEvent(
}
}
final eventChannelId = channelId ?? event.channelId;
if (eventChannelId != null && mutedChannelIds.contains(eventChannelId)) {
return false;
}
if (ref.parentId == null) return true;
final rootId = ref.rootId;
if (rootId != null && mutedRootIds.contains(rootId)) {
return false;
}
return rootId != null &&
(participatedRootIds.contains(rootId) ||
followedRootIds.contains(rootId) ||
authoredRootIds.contains(rootId));
}
@@ -83,6 +83,15 @@ abstract final class NostrFilters {
},
);
/// Latest per-viewer hidden-DM snapshot (kind:30622, `#p` = my pubkey).
static NostrFilter hiddenDms(String myPk) => NostrFilter(
kinds: [EventKind.dmVisibility],
tags: {
'#p': [myPk],
},
limit: 1,
);
/// Forum posts (kind:45001) in a channel.
static NostrFilter forumPosts(
String channelId, {
@@ -18,6 +18,7 @@ abstract final class EventKind {
static const agentObserverFrame = 24200;
static const readState = 30078;
static const userStatus = 30315;
static const dmVisibility = 30622;
static const streamMessageV2 = 40002;
static const streamMessageEdit = 40003;
static const streamMessageDiff = 40008;
@@ -6,6 +6,7 @@ import 'package:buzz/features/channels/channel.dart';
import 'package:buzz/features/channels/channels_page.dart';
import 'package:buzz/features/channels/channels_provider.dart';
import 'package:buzz/features/channels/read_state/read_state_provider.dart';
import 'package:buzz/features/channels/unread_badge/observed_unread_event.dart';
import 'package:buzz/features/profile/profile_provider.dart';
import 'package:buzz/features/profile/user_profile.dart';
import 'package:buzz/shared/theme/theme.dart';
@@ -48,7 +49,7 @@ void main() {
),
Channel(
id: '3',
name: 'dm-alice',
name: 'DM',
channelType: 'dm',
visibility: 'open',
description: 'Direct message',
@@ -72,10 +73,10 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('general'), findsOneWidget);
expect(find.text('design-forum'), findsOneWidget);
expect(find.text('design-forum'), findsNothing);
expect(find.text('Alice'), findsOneWidget);
expect(find.text('CHANNELS'), findsOneWidget);
expect(find.text('FORUMS'), findsOneWidget);
expect(find.text('FORUMS'), findsNothing);
expect(find.text('DMS'), findsOneWidget);
expect(find.text('\u{1F331}'), findsOneWidget);
expect(find.byTooltip('Create or start conversation'), findsOneWidget);
@@ -155,7 +156,7 @@ void main() {
expect(find.text('Retry'), findsOneWidget);
});
testWidgets('renders and clears unread indicator', (tester) async {
testWidgets('renders and clears unread dot indicator', (tester) async {
final channels = [
Channel(
id: '1',
@@ -185,7 +186,81 @@ void main() {
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(channels)),
channelsProvider.overrideWith(
() => _FakeNotifier(
channels,
observedEventsByChannel: {
'1': [_observed(id: 'msg-1', createdAt: 20)],
},
),
),
readStateProvider.overrideWith(() => readState),
],
),
);
await tester.pumpAndSettle();
expect(find.byKey(const Key('channel-unread-dot-1')), findsOneWidget);
readState.markContextRead('1', 20);
await tester.pump();
expect(find.byKey(const Key('channel-unread-dot-1')), findsNothing);
});
testWidgets('renders numeric unread count for counted events', (
tester,
) async {
final channels = [
Channel(
id: '1',
name: 'general',
channelType: 'stream',
visibility: 'open',
description: 'General discussion',
createdBy: 'abc',
createdAt: DateTime(2025),
memberCount: 10,
lastMessageAt: DateTime.fromMillisecondsSinceEpoch(
30 * 1000,
isUtc: true,
),
isMember: true,
),
];
final readState = _FakeReadStateNotifier(
const ReadStateState(
isReady: true,
pubkey: 'pk',
contexts: {'1': 10},
version: 0,
),
);
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(
() => _FakeNotifier(
channels,
observedEventsByChannel: {
'1': [
_observed(
id: 'reply-1',
createdAt: 20,
rootId: 'root',
isThreadedReply: true,
),
_observed(
id: 'reply-2',
createdAt: 30,
rootId: 'root',
isThreadedReply: true,
),
],
},
),
),
readStateProvider.overrideWith(() => readState),
],
),
@@ -193,11 +268,8 @@ void main() {
await tester.pumpAndSettle();
expect(find.byKey(const Key('channel-unread-1')), findsOneWidget);
readState.markContextRead('1', 20);
await tester.pump();
expect(find.byKey(const Key('channel-unread-1')), findsNothing);
expect(find.text('2'), findsOneWidget);
expect(find.byKey(const Key('channel-unread-dot-1')), findsNothing);
});
testWidgets('seeds first loaded channels as read', (tester) async {
@@ -294,10 +366,31 @@ void main() {
class _FakeNotifier extends ChannelsNotifier {
final List<Channel> _channels;
_FakeNotifier(this._channels);
final Map<String, Map<String, ObservedUnreadEvent>> _observedEventsByChannel;
_FakeNotifier(
this._channels, {
Map<String, List<ObservedUnreadEvent>> observedEventsByChannel = const {},
}) : _observedEventsByChannel = {
for (final entry in observedEventsByChannel.entries)
entry.key: {for (final event in entry.value) event.id: event},
};
@override
Future<List<Channel>> build() async => _channels;
@override
Map<String, int> get latestObservedByChannel => {
for (final entry in _observedEventsByChannel.entries)
if (entry.value.isNotEmpty)
entry.key: entry.value.values
.map((event) => event.createdAt)
.reduce((left, right) => left > right ? left : right),
};
@override
Map<String, Map<String, ObservedUnreadEvent>>
get observedUnreadEventsByChannel => _observedEventsByChannel;
}
class _ErrorNotifier extends ChannelsNotifier {
@@ -348,3 +441,18 @@ class _FakeReadStateNotifier extends ReadStateNotifier {
state = state.copyWithContext(contextId, unixTimestamp);
}
}
ObservedUnreadEvent _observed({
required String id,
required int createdAt,
String? rootId,
bool highPriority = false,
bool isThreadedReply = false,
}) => makeObservedUnreadEvent(
id: id,
createdAt: createdAt,
rootId: rootId,
highPriority: highPriority,
channelType: 'stream',
isThreadedReply: isThreadedReply,
);
@@ -113,6 +113,33 @@ void main() {
expect(ephemeral.ttlSeconds, 86400);
});
test('hidden DMs are filtered from the channel list', () async {
final session = _FakeRelaySession(
memberships: [_membership(_channelA, myPk), _membership(_channelB, myPk)],
metadata: [
_meta(id: _channelA, name: 'Alice', channelType: 'dm'),
_meta(id: _channelB, name: 'Bob', channelType: 'dm'),
],
hiddenDmEvents: [
_hiddenDms([_channelA], pubkey: myPk),
],
);
final container = _buildContainer(session: session);
addTearDown(container.dispose);
final channels = await container.read(channelsProvider.future);
expect(channels.map((c) => c.id), [_channelB]);
expect(
session.historyFilters.any(
(filter) =>
filter.kinds.contains(EventKind.dmVisibility) &&
filter.tags['#p']?.single == myPk,
),
isTrue,
);
});
test(
'archived kind:39000 metadata sets Channel.isArchived (covers TTL auto-archive)',
() async {
@@ -256,6 +283,21 @@ NostrEvent _membership(String channelId, String pubkey) => NostrEvent(
sig: 'sig',
);
NostrEvent _hiddenDms(List<String> channelIds, {required String pubkey}) =>
NostrEvent(
id: 'hidden-${channelIds.join('-')}',
pubkey: 'relay',
createdAt: 2,
kind: EventKind.dmVisibility,
tags: [
['d', pubkey],
['p', pubkey],
for (final channelId in channelIds) ['h', channelId],
],
content: '',
sig: 'sig',
);
/// Build a kind:39000 channel metadata event.
NostrEvent _meta({
required String id,
@@ -294,10 +336,15 @@ ProviderContainer _buildContainer({required _FakeRelaySession session}) {
/// Fake [RelaySessionNotifier] that returns canned events from [fetchHistory]
/// and records subscribe calls.
class _FakeRelaySession extends RelaySessionNotifier {
_FakeRelaySession({required this.memberships, required this.metadata});
_FakeRelaySession({
required this.memberships,
required this.metadata,
this.hiddenDmEvents = const [],
});
final List<NostrEvent> memberships;
final List<NostrEvent> metadata;
final List<NostrEvent> hiddenDmEvents;
final List<NostrFilter> historyFilters = [];
final List<NostrFilter> subscribeFilters = [];
@@ -322,6 +369,9 @@ class _FakeRelaySession extends RelaySessionNotifier {
)
.toList();
}
if (filter.kinds.contains(EventKind.dmVisibility)) {
return hiddenDmEvents;
}
if (filter.kinds.contains(39000)) {
// Metadata query — return all metadata events whose `d` tag matches.
final ids = (filter.tags['#d'] ?? const <String>[]).toSet();