mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user