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:
Wes
2026-06-25 17:40:00 -07:00
committed by GitHub
co-authored by Pinky
parent 4fce5aab2e
commit 59b21592c9
14 changed files with 843 additions and 119 deletions
+8 -3
View File
@@ -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;
}
}
+251 -24
View File
@@ -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].
+39
View File
@@ -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
+2
View File
@@ -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