mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(mobile): harden unread badges and float tabs (#1298)
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:
@@ -218,12 +218,17 @@ Desktop E2E: `cd desktop && pnpm exec playwright test`
|
||||
|
||||
See [TESTING.md](TESTING.md) for the full multi-agent E2E guide.
|
||||
|
||||
### Desktop Screenshots (Playwright)
|
||||
### PR Screenshots
|
||||
|
||||
> **Do NOT use `buzz upload`, the relay media endpoint, or any third-party
|
||||
> image host for PR screenshots.** Relay media URLs fail through GitHub's camo
|
||||
> proxy. Always use `scripts/post-screenshots.sh` — see the `desktop-screenshot`
|
||||
> skill for the full workflow.
|
||||
> proxy. Always use `scripts/post-screenshots.sh` for PNGs before linking them
|
||||
> from a PR body/comment. If you hand-edit PR markdown, run
|
||||
> `scripts/check-pr-image-urls.sh <markdown-file>` first to catch relay URLs.
|
||||
|
||||
For mobile simulator screenshots, save the PNGs in a local directory and run
|
||||
`./scripts/post-screenshots.sh <PR-number> <png-dir>` or use the third argument
|
||||
with a markdown template containing `{{filename}}` placeholders.
|
||||
|
||||
The desktop app requires the E2E mock bridge to render — it cannot run in a plain
|
||||
browser. Use `just desktop-screenshot` to capture screenshots (builds frontend,
|
||||
|
||||
@@ -16,6 +16,12 @@ unreliable and may expose content.
|
||||
|
||||
**ALWAYS use `scripts/post-screenshots.sh`** — it hosts PNGs on a per-developer
|
||||
git branch with immutable commit-SHA URLs that render correctly on GitHub.
|
||||
If you manually compose or edit PR markdown, run
|
||||
`scripts/check-pr-image-urls.sh <markdown-file>` before posting. The checker
|
||||
fails on Buzz/relay media URLs so broken images are caught locally.
|
||||
|
||||
This hosting rule applies to any PNG you want in a PR, including mobile
|
||||
simulator screenshots captured outside the desktop Playwright helper.
|
||||
|
||||
## Step 1 — Capture Screenshots
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ int? _channelReadTimestamp({
|
||||
if (events != null && events.isNotEmpty) {
|
||||
var latest = 0;
|
||||
for (final event in events) {
|
||||
if (event.threadReference.parentId != null) continue;
|
||||
if (event.createdAt > latest) {
|
||||
latest = event.createdAt;
|
||||
}
|
||||
@@ -132,6 +133,9 @@ class ChannelDetailPage extends HookConsumerWidget {
|
||||
ref
|
||||
.read(readStateProvider.notifier)
|
||||
.markContextRead(channel.id, readTimestamp);
|
||||
ref
|
||||
.read(channelsProvider.notifier)
|
||||
.clearObservedUnreadCoveredByRead(channel.id, readTimestamp);
|
||||
});
|
||||
}, [channel.id, readState.isReady, readTimestamp]);
|
||||
|
||||
|
||||
@@ -488,6 +488,9 @@ class _SliverChannelsList extends HookConsumerWidget {
|
||||
ref
|
||||
.read(readStateProvider.notifier)
|
||||
.markContextRead(channel.id, ts);
|
||||
ref
|
||||
.read(channelsProvider.notifier)
|
||||
.clearObservedUnreadCoveredByRead(channel.id, ts);
|
||||
}
|
||||
},
|
||||
),
|
||||
@@ -1104,6 +1107,9 @@ class _ChannelTile extends ConsumerWidget {
|
||||
ref
|
||||
.read(readStateProvider.notifier)
|
||||
.markContextRead(channel.id, ts);
|
||||
ref
|
||||
.read(channelsProvider.notifier)
|
||||
.clearObservedUnreadCoveredByRead(channel.id, ts);
|
||||
} else {
|
||||
ref
|
||||
.read(readStateProvider.notifier)
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../shared/relay/relay.dart';
|
||||
import '../../shared/theme/theme_provider.dart';
|
||||
import '../../shared/utils/string_utils.dart';
|
||||
import 'channel.dart';
|
||||
import 'channel_management_provider.dart' show channelDetailsProvider;
|
||||
import 'read_state/read_state_provider.dart';
|
||||
import 'unread_badge/is_high_priority_event.dart';
|
||||
import 'unread_badge/observed_unread_event.dart';
|
||||
import 'unread_badge/should_notify_for_event.dart';
|
||||
|
||||
const _channelTypeOrder = {'stream': 0, 'forum': 1, 'dm': 2};
|
||||
const _unreadCatchUpLimit = 1000;
|
||||
const _participatedRootIdsPrefix = 'buzz-thread-participation.v1';
|
||||
const _authoredRootIdsPrefix = 'buzz-thread-authored.v1';
|
||||
|
||||
/// Loads the user's channel list from the relay over WebSocket.
|
||||
///
|
||||
@@ -30,10 +36,22 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
final List<void Function()> _unsubscribers = [];
|
||||
int _subscriptionVersion = 0;
|
||||
Timer? _backstopTimer;
|
||||
final Map<String, int> _latestHighPriorityByChannel = {};
|
||||
final Map<String, int> _latestObservedByChannel = {};
|
||||
final Map<String, Map<String, ObservedUnreadEvent>>
|
||||
_observedUnreadEventsByChannel = {};
|
||||
Set<String> _participatedRootIds = {};
|
||||
Set<String> _authoredRootIds = {};
|
||||
String? _threadInterestPubkey;
|
||||
|
||||
Map<String, int> get latestHighPriorityByChannel =>
|
||||
Map.unmodifiable(_latestHighPriorityByChannel);
|
||||
Map<String, int> get latestObservedByChannel =>
|
||||
Map.unmodifiable(_latestObservedByChannel);
|
||||
|
||||
Map<String, Map<String, ObservedUnreadEvent>>
|
||||
get observedUnreadEventsByChannel =>
|
||||
Map<String, Map<String, ObservedUnreadEvent>>.unmodifiable({
|
||||
for (final entry in _observedUnreadEventsByChannel.entries)
|
||||
entry.key: Map<String, ObservedUnreadEvent>.unmodifiable(entry.value),
|
||||
});
|
||||
|
||||
@override
|
||||
Future<List<Channel>> build() {
|
||||
@@ -50,14 +68,16 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
|
||||
ref.onDispose(() {
|
||||
_clearLiveSubscriptions();
|
||||
_latestHighPriorityByChannel.clear();
|
||||
_latestObservedByChannel.clear();
|
||||
_observedUnreadEventsByChannel.clear();
|
||||
_backstopTimer?.cancel();
|
||||
_backstopTimer = null;
|
||||
});
|
||||
|
||||
if (sessionState.status != SessionStatus.connected) {
|
||||
_clearLiveSubscriptions();
|
||||
_latestHighPriorityByChannel.clear();
|
||||
_latestObservedByChannel.clear();
|
||||
_observedUnreadEventsByChannel.clear();
|
||||
// Preserve the last successfully loaded channels while reconnecting
|
||||
// instead of re-entering a loading/error state. The UI will show cached
|
||||
// channels with a "Reconnecting…" banner overlay, which is far better
|
||||
@@ -79,6 +99,7 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
}) async {
|
||||
final myPk = ref.read(myPubkeyProvider);
|
||||
if (myPk == null) throw StateError('No signing identity available');
|
||||
_loadThreadInterestStores(myPk);
|
||||
|
||||
final session = ref.read(relaySessionProvider.notifier);
|
||||
|
||||
@@ -399,9 +420,7 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
|
||||
_unsubscribers.addAll(subscriptions.whereType<void Function()>());
|
||||
|
||||
// Backfill high-priority map from recent history so unread @mentions that
|
||||
// arrived before app launch are correctly classified as high-priority tier.
|
||||
unawaited(_backfillHighPriority(channels));
|
||||
unawaited(_catchUpUnreadEvents(channels));
|
||||
|
||||
_backstopTimer?.cancel();
|
||||
_backstopTimer = Timer.periodic(
|
||||
@@ -410,83 +429,76 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _backfillHighPriority(List<Channel> channels) async {
|
||||
Future<void> _catchUpUnreadEvents(List<Channel> channels) async {
|
||||
final myPk = ref.read(myPubkeyProvider);
|
||||
if (myPk == null) return;
|
||||
|
||||
final session = ref.read(relaySessionProvider.notifier);
|
||||
final readState = ref.read(readStateProvider);
|
||||
final ReadStateState readState;
|
||||
try {
|
||||
readState = ref.read(readStateProvider);
|
||||
} catch (error) {
|
||||
debugPrint('[ChannelsNotifier] unread catch-up skipped: $error');
|
||||
return;
|
||||
}
|
||||
final futures = <Future<void>>[];
|
||||
|
||||
for (final channel in channels) {
|
||||
if (!channel.isMember || channel.isArchived) continue;
|
||||
|
||||
// All DM messages are high-priority — no need to scan event history.
|
||||
if (channel.isDm) {
|
||||
final lastMsg = channel.lastMessageAt;
|
||||
if (lastMsg != null) {
|
||||
_latestHighPriorityByChannel[channel.id] =
|
||||
lastMsg.millisecondsSinceEpoch ~/ 1000;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
final readAt = readState.effectiveTimestamp(channel.id);
|
||||
futures.add(
|
||||
_backfillHighPriorityForChannel(session, channel, myPk, readAt),
|
||||
_catchUpUnreadEventsForChannel(session, channel, myPk, readAt),
|
||||
);
|
||||
}
|
||||
|
||||
// Batch into groups of 5 to avoid saturating the relay.
|
||||
const batchSize = 5;
|
||||
for (var i = 0; i < futures.length; i += batchSize) {
|
||||
await Future.wait(futures.sublist(i, min(i + batchSize, futures.length)));
|
||||
}
|
||||
|
||||
// Trigger unreadBadgeProvider to re-evaluate now that the map is populated.
|
||||
state = state.whenData((channels) => List<Channel>.of(channels));
|
||||
}
|
||||
|
||||
Future<void> _backfillHighPriorityForChannel(
|
||||
Future<void> _catchUpUnreadEventsForChannel(
|
||||
RelaySessionNotifier session,
|
||||
Channel channel,
|
||||
String myPk,
|
||||
int? readAt,
|
||||
) async {
|
||||
// For non-DM channels, fetch events since the last read timestamp and scan
|
||||
// for high-priority ones. Using `since` avoids fetching messages the user
|
||||
// has already seen, and `limit: 200` covers deep mention backfills.
|
||||
try {
|
||||
final events = await session.fetchHistory(
|
||||
NostrFilter(
|
||||
kinds: EventKind.channelEventKinds,
|
||||
kinds: EventKind.channelMessageEventKinds,
|
||||
tags: {
|
||||
'#h': [channel.id],
|
||||
},
|
||||
since: readAt ?? 0,
|
||||
limit: 200,
|
||||
since: readAt == null ? 0 : readAt + 1,
|
||||
limit: _unreadCatchUpLimit,
|
||||
),
|
||||
);
|
||||
|
||||
var maxHighPriority = 0;
|
||||
for (final event in events) {
|
||||
if (event.pubkey == myPk) continue;
|
||||
if (!EventKind.channelMessageEventKinds.contains(event.kind)) continue;
|
||||
if (isHighPriorityEvent(event.tags, myPk) &&
|
||||
event.createdAt > maxHighPriority) {
|
||||
maxHighPriority = event.createdAt;
|
||||
if (event.pubkey.toLowerCase() == myPk.toLowerCase()) {
|
||||
_recordSelfThreadInterest(event, myPk);
|
||||
}
|
||||
}
|
||||
|
||||
if (maxHighPriority > 0) {
|
||||
final current = _latestHighPriorityByChannel[channel.id] ?? 0;
|
||||
if (maxHighPriority > current) {
|
||||
_latestHighPriorityByChannel[channel.id] = maxHighPriority;
|
||||
for (final event in events) {
|
||||
if (event.pubkey.toLowerCase() == myPk.toLowerCase()) continue;
|
||||
if (readAt != null && event.createdAt <= readAt) continue;
|
||||
if (!shouldNotifyForEvent(
|
||||
event,
|
||||
myPk,
|
||||
participatedRootIds: _participatedRootIds,
|
||||
authoredRootIds: _authoredRootIds,
|
||||
)) {
|
||||
continue;
|
||||
}
|
||||
_recordUnreadEvent(channel, event, myPk);
|
||||
}
|
||||
} catch (error) {
|
||||
debugPrint(
|
||||
'[ChannelsNotifier] backfill failed for ${channel.id}: $error',
|
||||
'[ChannelsNotifier] unread catch-up failed for ${channel.id}: $error',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -506,7 +518,18 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
final updated = List<Channel>.of(channels);
|
||||
final channel = updated[idx];
|
||||
|
||||
if (myPk != null && shouldNotifyForEvent(event, myPk)) {
|
||||
if (myPk != null && event.pubkey.toLowerCase() == myPk.toLowerCase()) {
|
||||
_recordSelfThreadInterest(event, myPk);
|
||||
}
|
||||
|
||||
if (myPk != null &&
|
||||
shouldNotifyForEvent(
|
||||
event,
|
||||
myPk,
|
||||
participatedRootIds: _participatedRootIds,
|
||||
authoredRootIds: _authoredRootIds,
|
||||
)) {
|
||||
_recordUnreadEvent(channel, event, myPk);
|
||||
final eventTime = DateTime.fromMillisecondsSinceEpoch(
|
||||
event.createdAt * 1000,
|
||||
isUtc: true,
|
||||
@@ -517,19 +540,91 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
}
|
||||
}
|
||||
|
||||
if (myPk != null &&
|
||||
event.pubkey != myPk &&
|
||||
(channel.isDm || isHighPriorityEvent(event.tags, myPk))) {
|
||||
final current = _latestHighPriorityByChannel[channelId] ?? 0;
|
||||
if (event.createdAt > current) {
|
||||
_latestHighPriorityByChannel[channelId] = event.createdAt;
|
||||
}
|
||||
}
|
||||
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
void _loadThreadInterestStores(String pubkey) {
|
||||
final normalizedPubkey = pubkey.toLowerCase();
|
||||
if (_threadInterestPubkey == normalizedPubkey) return;
|
||||
_threadInterestPubkey = normalizedPubkey;
|
||||
try {
|
||||
final prefs = ref.read(savedPrefsProvider);
|
||||
_participatedRootIds = _readRootIdSet(
|
||||
prefs.getString('$_participatedRootIdsPrefix:$normalizedPubkey'),
|
||||
);
|
||||
_authoredRootIds = _readRootIdSet(
|
||||
prefs.getString('$_authoredRootIdsPrefix:$normalizedPubkey'),
|
||||
);
|
||||
} catch (_) {
|
||||
_participatedRootIds = {};
|
||||
_authoredRootIds = {};
|
||||
}
|
||||
}
|
||||
|
||||
void _recordSelfThreadInterest(NostrEvent event, String pubkey) {
|
||||
final ref = event.threadReference;
|
||||
final target = ref.rootId != null ? _participatedRootIds : _authoredRootIds;
|
||||
final id = ref.rootId ?? event.id;
|
||||
if (!target.add(id)) return;
|
||||
_writeThreadInterestStores(pubkey);
|
||||
}
|
||||
|
||||
void _writeThreadInterestStores(String pubkey) {
|
||||
final normalizedPubkey = pubkey.toLowerCase();
|
||||
try {
|
||||
final prefs = ref.read(savedPrefsProvider);
|
||||
prefs.setString(
|
||||
'$_participatedRootIdsPrefix:$normalizedPubkey',
|
||||
_encodeRootIdSet(_participatedRootIds),
|
||||
);
|
||||
prefs.setString(
|
||||
'$_authoredRootIdsPrefix:$normalizedPubkey',
|
||||
_encodeRootIdSet(_authoredRootIds),
|
||||
);
|
||||
} catch (_) {
|
||||
// Ignore storage failures; in-memory interest still works this session.
|
||||
}
|
||||
}
|
||||
|
||||
void _recordUnreadEvent(Channel channel, NostrEvent event, String myPk) {
|
||||
final isThreadedReply =
|
||||
event.threadReference.parentId != null && !_isBroadcastReply(event);
|
||||
final isHighPriority =
|
||||
channel.isDm || isHighPriorityEvent(event.tags, myPk);
|
||||
recordObservedUnreadEvent(
|
||||
_observedUnreadEventsByChannel,
|
||||
channel.id,
|
||||
makeObservedUnreadEvent(
|
||||
id: event.id,
|
||||
createdAt: event.createdAt,
|
||||
rootId: _observedUnreadRootId(event),
|
||||
highPriority: isHighPriority,
|
||||
channelType: channel.channelType,
|
||||
isThreadedReply: isThreadedReply,
|
||||
),
|
||||
_unreadCatchUpLimit,
|
||||
);
|
||||
|
||||
final current = _latestObservedByChannel[channel.id] ?? 0;
|
||||
if (event.createdAt > current) {
|
||||
_latestObservedByChannel[channel.id] = event.createdAt;
|
||||
}
|
||||
}
|
||||
|
||||
void clearObservedUnreadForChannel(String channelId) {
|
||||
_latestObservedByChannel.remove(channelId);
|
||||
_observedUnreadEventsByChannel.remove(channelId);
|
||||
state = state.whenData((channels) => List<Channel>.of(channels));
|
||||
}
|
||||
|
||||
void clearObservedUnreadCoveredByRead(String channelId, int readAt) {
|
||||
final latest = _latestObservedByChannel[channelId];
|
||||
if (latest != null && latest <= readAt) {
|
||||
clearObservedUnreadForChannel(channelId);
|
||||
}
|
||||
}
|
||||
|
||||
/// Backstop refresh that preserves existing state on transient failure.
|
||||
Future<void> _backstopRefresh() async {
|
||||
try {
|
||||
@@ -580,3 +675,26 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
final channelsProvider = AsyncNotifierProvider<ChannelsNotifier, List<Channel>>(
|
||||
ChannelsNotifier.new,
|
||||
);
|
||||
|
||||
String? _observedUnreadRootId(NostrEvent event) =>
|
||||
_isBroadcastReply(event) ? null : event.threadReference.rootId;
|
||||
|
||||
bool _isBroadcastReply(NostrEvent event) => event.tags.any(
|
||||
(tag) => tag.length >= 2 && tag[0] == 'broadcast' && tag[1] == '1',
|
||||
);
|
||||
|
||||
Set<String> _readRootIdSet(String? raw) {
|
||||
if (raw == null || raw.isEmpty) return {};
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! List) return {};
|
||||
return {
|
||||
for (final value in decoded)
|
||||
if (value is String) value,
|
||||
};
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
String _encodeRootIdSet(Set<String> values) => jsonEncode(values.toList());
|
||||
|
||||
@@ -8,6 +8,22 @@ const readStateDTagPrefix = 'read-state:';
|
||||
const readStateFetchLimit = 500;
|
||||
const readStateHorizonSeconds = 7 * 24 * 60 * 60;
|
||||
const _maxContexts = 10000;
|
||||
const msgContextPrefix = 'msg:';
|
||||
const threadContextPrefix = 'thread:';
|
||||
|
||||
String msgContextKey(String messageId) => '$msgContextPrefix$messageId';
|
||||
String threadContextKey(String rootId) => '$threadContextPrefix$rootId';
|
||||
|
||||
int? maxReadAt(Iterable<int?> markers) {
|
||||
int? latest;
|
||||
for (final marker in markers) {
|
||||
if (marker == null) continue;
|
||||
if (latest == null || marker > latest) {
|
||||
latest = marker;
|
||||
}
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
typedef ReadStateDecrypt = String Function(String ciphertext);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
@@ -16,6 +17,8 @@ import '../profile/user_profile_sheet.dart';
|
||||
import 'message_actions.dart';
|
||||
import 'message_content.dart';
|
||||
import 'reaction_row.dart';
|
||||
import 'read_state/read_state_format.dart';
|
||||
import 'read_state/read_state_provider.dart';
|
||||
import 'send_message_provider.dart';
|
||||
import 'small_avatar.dart';
|
||||
import 'timeline_message.dart';
|
||||
@@ -62,6 +65,22 @@ class ThreadDetailPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
final replies = childrenByParent[threadHead.id] ?? const [];
|
||||
final readState = ref.watch(readStateProvider);
|
||||
final visibleReplyReadKey = replies
|
||||
.map((reply) => '${reply.id}:${reply.createdAt}')
|
||||
.join(',');
|
||||
|
||||
useEffect(() {
|
||||
if (!readState.isReady || replies.isEmpty) return null;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
for (final reply in replies) {
|
||||
ref
|
||||
.read(readStateProvider.notifier)
|
||||
.markContextRead(msgContextKey(reply.id), reply.createdAt);
|
||||
}
|
||||
});
|
||||
return null;
|
||||
}, [threadHead.id, readState.isReady, visibleReplyReadKey]);
|
||||
|
||||
// Thread-scoped typing indicators (exclude self).
|
||||
final allTyping = ref.watch(channelTypingProvider(channelId));
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import '../read_state/read_state_format.dart';
|
||||
|
||||
class ObservedUnreadEvent {
|
||||
final String id;
|
||||
final int createdAt;
|
||||
final String? rootId;
|
||||
final bool highPriority;
|
||||
final bool countsTowardBadge;
|
||||
final bool countsTowardAppBadge;
|
||||
|
||||
const ObservedUnreadEvent({
|
||||
required this.id,
|
||||
required this.createdAt,
|
||||
required this.rootId,
|
||||
required this.highPriority,
|
||||
required this.countsTowardBadge,
|
||||
required this.countsTowardAppBadge,
|
||||
});
|
||||
}
|
||||
|
||||
ObservedUnreadEvent makeObservedUnreadEvent({
|
||||
required String id,
|
||||
required int createdAt,
|
||||
required String? rootId,
|
||||
required bool highPriority,
|
||||
required String? channelType,
|
||||
required bool isThreadedReply,
|
||||
}) {
|
||||
final isDm = channelType == 'dm';
|
||||
return ObservedUnreadEvent(
|
||||
id: id,
|
||||
createdAt: createdAt,
|
||||
rootId: rootId,
|
||||
highPriority: highPriority,
|
||||
countsTowardBadge: isDm || isThreadedReply || highPriority,
|
||||
countsTowardAppBadge: isDm || (!isThreadedReply && highPriority),
|
||||
);
|
||||
}
|
||||
|
||||
bool recordObservedUnreadEvent(
|
||||
Map<String, Map<String, ObservedUnreadEvent>> eventsByChannel,
|
||||
String channelId,
|
||||
ObservedUnreadEvent event,
|
||||
int limit,
|
||||
) {
|
||||
final eventsById = eventsByChannel.putIfAbsent(channelId, () => {});
|
||||
if (eventsById.containsKey(event.id)) return false;
|
||||
|
||||
eventsById[event.id] = event;
|
||||
if (eventsById.length <= limit) return true;
|
||||
|
||||
String? oldestId;
|
||||
int? oldestCreatedAt;
|
||||
for (final event in eventsById.values) {
|
||||
if (oldestCreatedAt == null || event.createdAt < oldestCreatedAt) {
|
||||
oldestCreatedAt = event.createdAt;
|
||||
oldestId = event.id;
|
||||
}
|
||||
}
|
||||
if (oldestId != null) {
|
||||
eventsById.remove(oldestId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
int countUnreadObservedEvents(
|
||||
Map<String, ObservedUnreadEvent>? eventsById,
|
||||
int? Function(ObservedUnreadEvent event) getReadAt,
|
||||
) {
|
||||
if (eventsById == null) return 0;
|
||||
var count = 0;
|
||||
for (final event in eventsById.values) {
|
||||
final readAt = getReadAt(event);
|
||||
if (readAt == null || event.createdAt > readAt) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
int countUnreadBadgeObservedEvents(
|
||||
Map<String, ObservedUnreadEvent>? eventsById,
|
||||
int? Function(ObservedUnreadEvent event) getReadAt,
|
||||
) {
|
||||
if (eventsById == null) return 0;
|
||||
var count = 0;
|
||||
for (final event in eventsById.values) {
|
||||
if (!event.countsTowardBadge) continue;
|
||||
final readAt = getReadAt(event);
|
||||
if (readAt == null || event.createdAt > readAt) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
int countUnreadAppBadgeObservedEvents(
|
||||
Map<String, ObservedUnreadEvent>? eventsById,
|
||||
int? Function(ObservedUnreadEvent event) getReadAt,
|
||||
) {
|
||||
if (eventsById == null) return 0;
|
||||
var count = 0;
|
||||
for (final event in eventsById.values) {
|
||||
if (!event.countsTowardAppBadge) continue;
|
||||
final readAt = getReadAt(event);
|
||||
if (readAt == null || event.createdAt > readAt) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
int countUnreadHighPriorityObservedEvents(
|
||||
Map<String, ObservedUnreadEvent>? eventsById,
|
||||
int? Function(ObservedUnreadEvent event) getReadAt,
|
||||
) {
|
||||
if (eventsById == null) return 0;
|
||||
var count = 0;
|
||||
for (final event in eventsById.values) {
|
||||
if (!event.highPriority) continue;
|
||||
final readAt = getReadAt(event);
|
||||
if (readAt == null || event.createdAt > readAt) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
int? observedUnreadEventReadAt(
|
||||
ObservedUnreadEvent event,
|
||||
int? channelReadAt,
|
||||
int? Function(String rootId) getThreadOwnMarker,
|
||||
int? Function(String messageId) getMessageOwnMarker,
|
||||
) {
|
||||
final markers = <int?>[channelReadAt, getMessageOwnMarker(event.id)];
|
||||
final rootId = event.rootId;
|
||||
if (rootId != null) {
|
||||
markers.add(getThreadOwnMarker(rootId));
|
||||
}
|
||||
return maxReadAt(markers);
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
import '../../../shared/relay/nostr_models.dart';
|
||||
|
||||
bool shouldNotifyForEvent(NostrEvent event, String myPubkey) {
|
||||
bool shouldNotifyForEvent(
|
||||
NostrEvent event,
|
||||
String myPubkey, {
|
||||
Set<String> participatedRootIds = const {},
|
||||
Set<String> authoredRootIds = const {},
|
||||
}) {
|
||||
if (!EventKind.channelMessageEventKinds.contains(event.kind)) return false;
|
||||
|
||||
if (event.pubkey == myPubkey) return false;
|
||||
if (event.pubkey.toLowerCase() == myPubkey.toLowerCase()) return false;
|
||||
|
||||
final ref = event.threadReference;
|
||||
if (ref.parentId == null) return true;
|
||||
@@ -23,5 +28,8 @@ bool shouldNotifyForEvent(NostrEvent event, String myPubkey) {
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
final rootId = ref.rootId;
|
||||
return rootId != null &&
|
||||
(participatedRootIds.contains(rootId) ||
|
||||
authoredRootIds.contains(rootId));
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../channels_provider.dart';
|
||||
import '../read_state/read_state_provider.dart';
|
||||
import '../read_state/read_state_time.dart';
|
||||
import '../read_state/read_state_format.dart';
|
||||
import 'observed_unread_event.dart';
|
||||
|
||||
class UnreadBadgeState {
|
||||
const UnreadBadgeState({
|
||||
@@ -20,42 +21,58 @@ final unreadBadgeProvider = Provider<UnreadBadgeState>((ref) {
|
||||
|
||||
return channelsAsync.when(
|
||||
data: (channels) {
|
||||
// Safe to ref.read the notifier here: _latestHighPriorityByChannel is
|
||||
// only mutated inside _handleLiveEvent's state.whenData block, which
|
||||
// always emits a new channelsProvider state — so the ref.watch above
|
||||
// guarantees we re-run whenever the map changes.
|
||||
final notifier = ref.read(channelsProvider.notifier);
|
||||
final highPriorityMap = notifier.latestHighPriorityByChannel;
|
||||
final observedEventsByChannel = notifier.observedUnreadEventsByChannel;
|
||||
final latestObservedByChannel = notifier.latestObservedByChannel;
|
||||
|
||||
int highPriority = 0;
|
||||
int general = 0;
|
||||
var highPriority = 0;
|
||||
var general = 0;
|
||||
|
||||
for (final channel in channels) {
|
||||
if (!channel.isMember || channel.isArchived) continue;
|
||||
|
||||
final isLocallyForced = readState.locallyForcedChannelIds.contains(
|
||||
channel.id,
|
||||
if (readState.locallyForcedChannelIds.contains(channel.id)) {
|
||||
general++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!latestObservedByChannel.containsKey(channel.id)) continue;
|
||||
|
||||
final observedEvents = observedEventsByChannel[channel.id];
|
||||
final channelReadAt = readState.effectiveTimestamp(channel.id);
|
||||
int? readAtForObservedEvent(ObservedUnreadEvent event) =>
|
||||
observedUnreadEventReadAt(
|
||||
event,
|
||||
channelReadAt,
|
||||
(rootId) =>
|
||||
readState.effectiveTimestamp(threadContextKey(rootId)),
|
||||
(messageId) =>
|
||||
readState.effectiveTimestamp(msgContextKey(messageId)),
|
||||
);
|
||||
|
||||
final unreadCount = countUnreadObservedEvents(
|
||||
observedEvents,
|
||||
readAtForObservedEvent,
|
||||
);
|
||||
final lastMessageAt = dateTimeToUnixSeconds(channel.lastMessageAt);
|
||||
if (lastMessageAt == null && !isLocallyForced) continue;
|
||||
if (unreadCount == 0) continue;
|
||||
|
||||
final readAt = readState.effectiveTimestamp(channel.id);
|
||||
final isUnread =
|
||||
isLocallyForced ||
|
||||
readAt == null ||
|
||||
(lastMessageAt != null && lastMessageAt > readAt);
|
||||
if (!isUnread) continue;
|
||||
|
||||
if (channel.isDm) {
|
||||
highPriority++;
|
||||
if (channel.isDm ||
|
||||
countUnreadHighPriorityObservedEvents(
|
||||
observedEvents,
|
||||
readAtForObservedEvent,
|
||||
) >
|
||||
0) {
|
||||
final appBadgeCount = countUnreadAppBadgeObservedEvents(
|
||||
observedEvents,
|
||||
readAtForObservedEvent,
|
||||
);
|
||||
highPriority += appBadgeCount > 0 ? appBadgeCount : 1;
|
||||
} else {
|
||||
final highPriorityAt = highPriorityMap[channel.id];
|
||||
if (highPriorityAt != null &&
|
||||
(readAt == null || highPriorityAt > readAt)) {
|
||||
highPriority++;
|
||||
} else {
|
||||
general++;
|
||||
}
|
||||
final badgeCount = countUnreadBadgeObservedEvents(
|
||||
observedEvents,
|
||||
readAtForObservedEvent,
|
||||
);
|
||||
general += badgeCount > 0 ? badgeCount : 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,49 +1,276 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../shared/theme/grid.dart';
|
||||
import '../activity/activity_page.dart';
|
||||
import '../channels/channels_page.dart';
|
||||
import '../pulse/pulse_page.dart';
|
||||
import '../search/search_page.dart';
|
||||
|
||||
class HomePage extends HookConsumerWidget {
|
||||
const HomePage({super.key});
|
||||
|
||||
static const double _tabBarHeight = 60;
|
||||
static const double _tabBarRadius = _tabBarHeight / 2;
|
||||
static const double _tabBarInnerInset = 5;
|
||||
static const double _selectedTabRadius =
|
||||
(_tabBarHeight - (_tabBarInnerInset * 2)) / 2;
|
||||
static const double _tabBarBottomGap = Grid.twelve;
|
||||
static const double _tabBarHorizontalMargin = Grid.sm;
|
||||
static const double _fabClearance = _tabBarHeight + _tabBarBottomGap;
|
||||
|
||||
static const _destinations = [
|
||||
_HomeDestination(
|
||||
icon: LucideIcons.house,
|
||||
selectedIcon: LucideIcons.house,
|
||||
label: 'Home',
|
||||
),
|
||||
_HomeDestination(
|
||||
icon: LucideIcons.bell,
|
||||
selectedIcon: LucideIcons.bell,
|
||||
label: 'Activity',
|
||||
),
|
||||
_HomeDestination(
|
||||
icon: LucideIcons.search,
|
||||
selectedIcon: LucideIcons.search,
|
||||
label: 'Search',
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final tabIndex = useState(0);
|
||||
|
||||
const pages = [ChannelsPage(), PulsePage(), ActivityPage(), SearchPage()];
|
||||
const pages = [ChannelsPage(), ActivityPage(), SearchPage()];
|
||||
|
||||
return Scaffold(
|
||||
body: IndexedStack(index: tabIndex.value, children: pages),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
extendBody: true,
|
||||
body: MediaQuery(
|
||||
data: _mediaQueryWithFloatingTabBarClearance(
|
||||
context,
|
||||
HomePage._fabClearance,
|
||||
),
|
||||
child: IndexedStack(index: tabIndex.value, children: pages),
|
||||
),
|
||||
bottomNavigationBar: _FloatingTabBar(
|
||||
selectedIndex: tabIndex.value,
|
||||
onDestinationSelected: (i) => tabIndex.value = i,
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(LucideIcons.house),
|
||||
selectedIcon: Icon(LucideIcons.house),
|
||||
label: 'Home',
|
||||
destinations: _destinations,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
MediaQueryData _mediaQueryWithFloatingTabBarClearance(
|
||||
BuildContext context,
|
||||
double clearance,
|
||||
) {
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
return mediaQuery.copyWith(
|
||||
padding: mediaQuery.padding.copyWith(
|
||||
bottom: mediaQuery.padding.bottom + clearance,
|
||||
),
|
||||
viewPadding: mediaQuery.viewPadding.copyWith(
|
||||
bottom: mediaQuery.viewPadding.bottom + clearance,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _HomeDestination {
|
||||
final IconData icon;
|
||||
final IconData selectedIcon;
|
||||
final String label;
|
||||
|
||||
const _HomeDestination({
|
||||
required this.icon,
|
||||
required this.selectedIcon,
|
||||
required this.label,
|
||||
});
|
||||
}
|
||||
|
||||
class _FloatingTabBar extends StatelessWidget {
|
||||
final int selectedIndex;
|
||||
final ValueChanged<int> onDestinationSelected;
|
||||
final List<_HomeDestination> destinations;
|
||||
|
||||
const _FloatingTabBar({
|
||||
required this.selectedIndex,
|
||||
required this.onDestinationSelected,
|
||||
required this.destinations,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
return SafeArea(
|
||||
minimum: const EdgeInsets.fromLTRB(
|
||||
HomePage._tabBarHorizontalMargin,
|
||||
0,
|
||||
HomePage._tabBarHorizontalMargin,
|
||||
HomePage._tabBarBottomGap,
|
||||
),
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 336),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(HomePage._tabBarRadius),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: colorScheme.shadow.withValues(alpha: 0.18),
|
||||
blurRadius: 28,
|
||||
offset: const Offset(0, 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(HomePage._tabBarRadius),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(HomePage._tabBarRadius),
|
||||
color: isDark
|
||||
? colorScheme.surfaceContainerHighest.withValues(
|
||||
alpha: 0.72,
|
||||
)
|
||||
: null,
|
||||
border: Border.all(
|
||||
color: colorScheme.outlineVariant.withValues(
|
||||
alpha: isDark ? 0.20 : 0.38,
|
||||
),
|
||||
),
|
||||
gradient: isDark
|
||||
? null
|
||||
: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
colorScheme.surface.withValues(alpha: 0.90),
|
||||
colorScheme.surfaceContainerHighest.withValues(
|
||||
alpha: 0.78,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
if (!isDark)
|
||||
Positioned.fill(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.center,
|
||||
colors: [
|
||||
Colors.white.withValues(alpha: 0.22),
|
||||
Colors.white.withValues(alpha: 0.02),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(
|
||||
HomePage._tabBarInnerInset,
|
||||
),
|
||||
child: SizedBox(
|
||||
height:
|
||||
HomePage._tabBarHeight -
|
||||
(HomePage._tabBarInnerInset * 2),
|
||||
child: Row(
|
||||
children: [
|
||||
for (var i = 0; i < destinations.length; i++)
|
||||
Expanded(
|
||||
child: _FloatingTabDestination(
|
||||
destination: destinations[i],
|
||||
selected: i == selectedIndex,
|
||||
onTap: () => onDestinationSelected(i),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(LucideIcons.activity),
|
||||
selectedIcon: Icon(LucideIcons.activity),
|
||||
label: 'Pulse',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FloatingTabDestination extends StatelessWidget {
|
||||
final _HomeDestination destination;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _FloatingTabDestination({
|
||||
required this.destination,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textStyle = Theme.of(context).textTheme.labelSmall;
|
||||
final foregroundColor = selected
|
||||
? colorScheme.onPrimary
|
||||
: colorScheme.onSurfaceVariant;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: Grid.quarter),
|
||||
child: Material(
|
||||
color: selected
|
||||
? colorScheme.primary.withValues(alpha: 0.94)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(HomePage._selectedTabRadius),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(HomePage._selectedTabRadius),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOutCubic,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.xxs,
|
||||
vertical: Grid.xxs,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
selected ? destination.selectedIcon : destination.icon,
|
||||
color: foregroundColor,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(height: 1),
|
||||
Text(
|
||||
destination.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textStyle?.copyWith(
|
||||
color: foregroundColor,
|
||||
fontSize: 10.5,
|
||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w600,
|
||||
letterSpacing: 0.05,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(LucideIcons.bell),
|
||||
selectedIcon: Icon(LucideIcons.bell),
|
||||
label: 'Activity',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(LucideIcons.search),
|
||||
selectedIcon: Icon(LucideIcons.search),
|
||||
label: 'Search',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:buzz/features/channels/channel.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/channels/unread_badge/unread_badge_provider.dart';
|
||||
|
||||
/// Unit tests for [unreadBadgeProvider].
|
||||
@@ -57,10 +58,13 @@ void main() {
|
||||
Set<String> locallyForcedChannelIds = const {},
|
||||
bool readStateReady = true,
|
||||
Map<String, int> highPriorityMap = const {},
|
||||
Map<String, List<ObservedUnreadEvent>>? observedEventsByChannel,
|
||||
}) {
|
||||
final notifier = _StubbedChannelsNotifier(
|
||||
channels: channels,
|
||||
highPriorityMap: highPriorityMap,
|
||||
observedEventsByChannel:
|
||||
observedEventsByChannel ??
|
||||
_defaultObservedEvents(channels, highPriorityMap),
|
||||
);
|
||||
|
||||
return ProviderContainer(
|
||||
@@ -266,6 +270,66 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test('thread marker clears only replies in that thread context', () async {
|
||||
const channelId = 'ch-a';
|
||||
final container = buildContainer(
|
||||
channels: [makeChannel(id: channelId, lastMessageAtSeconds: t30)],
|
||||
readContexts: {'thread:root-1': t30},
|
||||
observedEventsByChannel: {
|
||||
channelId: [
|
||||
_observed(
|
||||
id: 'reply-1',
|
||||
createdAt: t20,
|
||||
rootId: 'root-1',
|
||||
isThreadedReply: true,
|
||||
),
|
||||
_observed(
|
||||
id: 'reply-2',
|
||||
createdAt: t20,
|
||||
rootId: 'root-2',
|
||||
isThreadedReply: true,
|
||||
),
|
||||
],
|
||||
},
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(channelsProvider.future);
|
||||
final badge = container.read(unreadBadgeProvider);
|
||||
expect(badge.highPriorityCount, 0);
|
||||
expect(badge.generalUnreadCount, 1);
|
||||
});
|
||||
|
||||
test('message marker clears only that observed message', () async {
|
||||
const channelId = 'ch-a';
|
||||
final container = buildContainer(
|
||||
channels: [makeChannel(id: channelId, lastMessageAtSeconds: t30)],
|
||||
readContexts: {'msg:reply-1': t30},
|
||||
observedEventsByChannel: {
|
||||
channelId: [
|
||||
_observed(
|
||||
id: 'reply-1',
|
||||
createdAt: t20,
|
||||
rootId: 'root-1',
|
||||
isThreadedReply: true,
|
||||
),
|
||||
_observed(
|
||||
id: 'reply-2',
|
||||
createdAt: t20,
|
||||
rootId: 'root-1',
|
||||
isThreadedReply: true,
|
||||
),
|
||||
],
|
||||
},
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(channelsProvider.future);
|
||||
final badge = container.read(unreadBadgeProvider);
|
||||
expect(badge.highPriorityCount, 0);
|
||||
expect(badge.generalUnreadCount, 1);
|
||||
});
|
||||
|
||||
test('channelsProvider in loading state returns (0, 0)', () {
|
||||
// The provider returns const UnreadBadgeState() while channels are loading.
|
||||
// We intentionally do NOT await the future here — the channels notifier
|
||||
@@ -302,7 +366,12 @@ void main() {
|
||||
final container = buildContainer(
|
||||
channels: [makeChannel(id: channelId, lastMessageAtSeconds: t30)],
|
||||
readContexts: {channelId: t20},
|
||||
highPriorityMap: {channelId: t10}, // mention is older than read marker
|
||||
observedEventsByChannel: {
|
||||
channelId: [
|
||||
_observed(id: 'mention', createdAt: t10, highPriority: true),
|
||||
_observed(id: 'general', createdAt: t30),
|
||||
],
|
||||
},
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
@@ -314,26 +383,77 @@ void main() {
|
||||
);
|
||||
}
|
||||
|
||||
ObservedUnreadEvent _observed({
|
||||
required String id,
|
||||
required int createdAt,
|
||||
String? rootId,
|
||||
bool highPriority = false,
|
||||
bool isThreadedReply = false,
|
||||
String channelType = 'stream',
|
||||
}) => makeObservedUnreadEvent(
|
||||
id: id,
|
||||
createdAt: createdAt,
|
||||
rootId: rootId,
|
||||
highPriority: highPriority,
|
||||
channelType: channelType,
|
||||
isThreadedReply: isThreadedReply,
|
||||
);
|
||||
|
||||
Map<String, List<ObservedUnreadEvent>> _defaultObservedEvents(
|
||||
List<Channel> channels,
|
||||
Map<String, int> highPriorityMap,
|
||||
) {
|
||||
return {
|
||||
for (final channel in channels)
|
||||
if (channel.lastMessageAt != null)
|
||||
channel.id: [
|
||||
_observed(
|
||||
id: '${channel.id}-latest',
|
||||
createdAt: channel.lastMessageAt!.millisecondsSinceEpoch ~/ 1000,
|
||||
highPriority:
|
||||
channel.isDm || highPriorityMap.containsKey(channel.id),
|
||||
channelType: channel.channelType,
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/// A [ChannelsNotifier] that immediately resolves to a canned [channels] list
|
||||
/// and exposes a pre-seeded [latestHighPriorityByChannel] map.
|
||||
/// and exposes pre-seeded observed unread events.
|
||||
///
|
||||
/// Extends [ChannelsNotifier] so [ref.read(channelsProvider.notifier)] returns
|
||||
/// an instance whose [latestHighPriorityByChannel] getter works correctly.
|
||||
/// an instance whose observed-event getters work correctly.
|
||||
class _StubbedChannelsNotifier extends ChannelsNotifier {
|
||||
_StubbedChannelsNotifier({
|
||||
required List<Channel> channels,
|
||||
Map<String, int> highPriorityMap = const {},
|
||||
Map<String, List<ObservedUnreadEvent>> observedEventsByChannel = const {},
|
||||
}) : _channels = channels,
|
||||
_highPriorityMap = Map<String, int>.unmodifiable(highPriorityMap);
|
||||
_observedEventsByChannel =
|
||||
Map<String, Map<String, ObservedUnreadEvent>>.unmodifiable({
|
||||
for (final entry in observedEventsByChannel.entries)
|
||||
entry.key: Map<String, ObservedUnreadEvent>.unmodifiable({
|
||||
for (final event in entry.value) event.id: event,
|
||||
}),
|
||||
});
|
||||
|
||||
final List<Channel> _channels;
|
||||
final Map<String, int> _highPriorityMap;
|
||||
final Map<String, Map<String, ObservedUnreadEvent>> _observedEventsByChannel;
|
||||
|
||||
@override
|
||||
Future<List<Channel>> build() async => _channels;
|
||||
|
||||
@override
|
||||
Map<String, int> get latestHighPriorityByChannel => _highPriorityMap;
|
||||
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;
|
||||
}
|
||||
|
||||
/// A [ChannelsNotifier] that stays in the loading state indefinitely.
|
||||
@@ -342,7 +462,11 @@ class _LoadingChannelsNotifier extends ChannelsNotifier {
|
||||
Future<List<Channel>> build() => Completer<List<Channel>>().future;
|
||||
|
||||
@override
|
||||
Map<String, int> get latestHighPriorityByChannel => const {};
|
||||
Map<String, int> get latestObservedByChannel => const {};
|
||||
|
||||
@override
|
||||
Map<String, Map<String, ObservedUnreadEvent>>
|
||||
get observedUnreadEventsByChannel => const {};
|
||||
}
|
||||
|
||||
/// A [ReadStateNotifier] that returns a fixed [ReadStateState].
|
||||
|
||||
Executable
+39
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -ne 1 ]]; then
|
||||
echo "Usage: $0 <markdown-file>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MARKDOWN_FILE="$1"
|
||||
|
||||
if [[ ! -f "$MARKDOWN_FILE" ]]; then
|
||||
echo "error: markdown file not found: $MARKDOWN_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Buzz/relay media URLs often render in Buzz but fail in GitHub PR markdown
|
||||
# because GitHub's Camo proxy fetches them anonymously. PR screenshots should
|
||||
# be hosted through scripts/post-screenshots.sh or another GitHub-safe host.
|
||||
relay_media_pattern='https?://[^][()<>[:space:]"'"'"']*/media/[0-9a-fA-F]{64}\.(png|jpe?g|webp|gif)'
|
||||
sprout_media_pattern='https?://sprout-oss[^][()<>[:space:]"'"'"']*/media/'
|
||||
|
||||
tmp_matches=$(mktemp)
|
||||
trap 'rm -f "$tmp_matches"' EXIT
|
||||
|
||||
if grep -nE "$relay_media_pattern" "$MARKDOWN_FILE" >>"$tmp_matches"; then
|
||||
:
|
||||
fi
|
||||
if grep -nE "$sprout_media_pattern" "$MARKDOWN_FILE" >>"$tmp_matches"; then
|
||||
:
|
||||
fi
|
||||
|
||||
if [[ -s "$tmp_matches" ]]; then
|
||||
matches=$(sort -u "$tmp_matches")
|
||||
echo "error: PR markdown contains Buzz/relay media URLs that may not render on GitHub:" >&2
|
||||
printf '%s\n' "$matches" >&2
|
||||
echo >&2
|
||||
echo "Upload screenshots with scripts/post-screenshots.sh, then use its GitHub-safe image URLs in the PR body/comment." >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -66,6 +66,8 @@ for i in "${!PNGS[@]}"; do
|
||||
done
|
||||
|
||||
if [[ -n "$BODY_FILE" ]]; then
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
"$SCRIPT_DIR/check-pr-image-urls.sh" "$BODY_FILE"
|
||||
COMMENT_BODY="$(cat "$BODY_FILE")"
|
||||
UNREFERENCED=()
|
||||
for NAME in "${!IMAGE_URL_MAP[@]}"; do
|
||||
|
||||
Reference in New Issue
Block a user