diff --git a/mobile/lib/features/channels/channel.dart b/mobile/lib/features/channels/channel.dart index 2ff02b327b..3104e90335 100644 --- a/mobile/lib/features/channels/channel.dart +++ b/mobile/lib/features/channels/channel.dart @@ -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'; diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 7803eb2b4e..d6e4f3e312 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -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, diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index 238c5c1d25..51e59baab2 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -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 ids; + final Map counts; + + const _UnreadChannelState({required this.ids, required this.counts}); +} + +_UnreadChannelState _computeUnreadChannelState({ + required Iterable 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 = {}; + final counts = {}; + + 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( 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(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 {}; + 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 channels; final Set unreadChannelIds; + final Map unreadChannelCounts; final Set 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 channels; final Set unreadChannelIds; + final Map unreadChannelCounts; final Set 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(); - - // Find the other participant's pubkey. - String? otherPubkey; - for (final pk in channel.participantPubkeys) { - if (pk.toLowerCase() != normalizedCurrent) { - otherPubkey = pk.toLowerCase(); - break; - } + 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(); + + 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,61 +1726,76 @@ class _NewDirectMessageSheet extends HookConsumerWidget { } } -class _EphemeralBadge extends StatelessWidget { - final Channel channel; +class _UnreadBadge extends StatelessWidget { + final String channelId; + final int count; - const _EphemeralBadge({required this.channel}); + const _UnreadBadge({required this.channelId, required this.count}); @override Widget build(BuildContext context) { - final label = _label(); - - 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), + 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'), ), ), - 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, - ), - ), - ], - ], + ); + } + + 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, ), ), ); } +} - Color _amberColor(BuildContext context) { - return context.colors.brightness == Brightness.light - ? const Color(0xFFB45309) - : const Color(0xFFFCD34D); - } +String _formatUnreadCount(int count) => count > 99 ? '99+' : count.toString(); + +class _EphemeralBadge extends StatelessWidget { + final Channel channel; + + const _EphemeralBadge({required this.channel}); + + @override + Widget build(BuildContext context) { + final display = ephemeralChannelDisplay(channel); + if (display == null) return const SizedBox.shrink(); - 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'; + return Tooltip( + message: display.tooltipLabel, + child: Icon( + LucideIcons.clockFading, + key: Key('channel-ephemeral-${channel.id}'), + size: 16, + color: context.colors.onSurfaceVariant, + ), + ); } } diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index 88adf65b99..1cf00db784 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -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> { } } + final hiddenDmIds = await _fetchHiddenDmIds(session, myPk); + final channels = []; for (final event in dedupedMetas) { final channel = _channelFromMeta( @@ -191,6 +194,7 @@ class ChannelsNotifier extends AsyncNotifier> { 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> { ), ); 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> { return channels; } + Future> _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> { 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> { 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> { Channel channel, String myPk, int? readAt, + Set mutedChannelIds, ) async { try { final events = await session.fetchHistory( @@ -491,6 +530,8 @@ class ChannelsNotifier extends AsyncNotifier> { myPk, participatedRootIds: _participatedRootIds, authoredRootIds: _authoredRootIds, + mutedChannelIds: mutedChannelIds, + channelId: channel.id, )) { continue; } @@ -508,6 +549,7 @@ class ChannelsNotifier extends AsyncNotifier> { 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> { 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> { }); } + Set _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; diff --git a/mobile/lib/features/channels/dm_channel_labels.dart b/mobile/lib/features/channels/dm_channel_labels.dart new file mode 100644 index 0000000000..809b00876f --- /dev/null +++ b/mobile/lib/features/channels/dm_channel_labels.dart @@ -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 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 = { + for (final participant + in displayParticipants.isNotEmpty ? displayParticipants : participants) + participant.label, + }.toList(); + + return labels.isNotEmpty + ? formatDmParticipantDisplayName(labels) + : channel.name; +} + +List sortDmChannelsByDisplayLabel( + Iterable 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; +} diff --git a/mobile/lib/features/channels/ephemeral_channel_display.dart b/mobile/lib/features/channels/ephemeral_channel_display.dart new file mode 100644 index 0000000000..748a825650 --- /dev/null +++ b/mobile/lib/features/channels/ephemeral_channel_display.dart @@ -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'; +} diff --git a/mobile/lib/features/channels/unread_badge/should_notify_for_event.dart b/mobile/lib/features/channels/unread_badge/should_notify_for_event.dart index 9c1a803130..f2f3c1e3b6 100644 --- a/mobile/lib/features/channels/unread_badge/should_notify_for_event.dart +++ b/mobile/lib/features/channels/unread_badge/should_notify_for_event.dart @@ -4,14 +4,17 @@ bool shouldNotifyForEvent( NostrEvent event, String myPubkey, { Set participatedRootIds = const {}, + Set followedRootIds = const {}, Set authoredRootIds = const {}, + Set mutedRootIds = const {}, + Set 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)); } diff --git a/mobile/lib/shared/relay/nostr_filters.dart b/mobile/lib/shared/relay/nostr_filters.dart index 493f328af3..37c787f2aa 100644 --- a/mobile/lib/shared/relay/nostr_filters.dart +++ b/mobile/lib/shared/relay/nostr_filters.dart @@ -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, { diff --git a/mobile/lib/shared/relay/nostr_models.dart b/mobile/lib/shared/relay/nostr_models.dart index d950ecdcef..bf3e12b25b 100644 --- a/mobile/lib/shared/relay/nostr_models.dart +++ b/mobile/lib/shared/relay/nostr_models.dart @@ -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; diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 742e5d5688..1aceb976f5 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -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,19 +186,90 @@ 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-1')), findsOneWidget); + expect(find.byKey(const Key('channel-unread-dot-1')), findsOneWidget); readState.markContextRead('1', 20); await tester.pump(); - expect(find.byKey(const Key('channel-unread-1')), findsNothing); + 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), + ], + ), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('channel-unread-1')), findsOneWidget); + 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 _channels; - _FakeNotifier(this._channels); + final Map> _observedEventsByChannel; + + _FakeNotifier( + this._channels, { + Map> observedEventsByChannel = const {}, + }) : _observedEventsByChannel = { + for (final entry in observedEventsByChannel.entries) + entry.key: {for (final event in entry.value) event.id: event}, + }; @override Future> build() async => _channels; + + @override + Map 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> + 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, +); diff --git a/mobile/test/features/channels/channels_provider_test.dart b/mobile/test/features/channels/channels_provider_test.dart index 7b300ca7f1..f586ab79aa 100644 --- a/mobile/test/features/channels/channels_provider_test.dart +++ b/mobile/test/features/channels/channels_provider_test.dart @@ -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 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 memberships; final List metadata; + final List hiddenDmEvents; final List historyFilters = []; final List 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 []).toSet();