mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00: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:
11
AGENTS.md
11
AGENTS.md
@@ -218,12 +218,17 @@ Desktop E2E: `cd desktop && pnpm exec playwright test`
|
|||||||
|
|
||||||
See [TESTING.md](TESTING.md) for the full multi-agent E2E guide.
|
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
|
> **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
|
> image host for PR screenshots.** Relay media URLs fail through GitHub's camo
|
||||||
> proxy. Always use `scripts/post-screenshots.sh` — see the `desktop-screenshot`
|
> proxy. Always use `scripts/post-screenshots.sh` for PNGs before linking them
|
||||||
> skill for the full workflow.
|
> 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
|
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,
|
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
|
**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.
|
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
|
## Step 1 — Capture Screenshots
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ int? _channelReadTimestamp({
|
|||||||
if (events != null && events.isNotEmpty) {
|
if (events != null && events.isNotEmpty) {
|
||||||
var latest = 0;
|
var latest = 0;
|
||||||
for (final event in events) {
|
for (final event in events) {
|
||||||
|
if (event.threadReference.parentId != null) continue;
|
||||||
if (event.createdAt > latest) {
|
if (event.createdAt > latest) {
|
||||||
latest = event.createdAt;
|
latest = event.createdAt;
|
||||||
}
|
}
|
||||||
@@ -132,6 +133,9 @@ class ChannelDetailPage extends HookConsumerWidget {
|
|||||||
ref
|
ref
|
||||||
.read(readStateProvider.notifier)
|
.read(readStateProvider.notifier)
|
||||||
.markContextRead(channel.id, readTimestamp);
|
.markContextRead(channel.id, readTimestamp);
|
||||||
|
ref
|
||||||
|
.read(channelsProvider.notifier)
|
||||||
|
.clearObservedUnreadCoveredByRead(channel.id, readTimestamp);
|
||||||
});
|
});
|
||||||
}, [channel.id, readState.isReady, readTimestamp]);
|
}, [channel.id, readState.isReady, readTimestamp]);
|
||||||
|
|
||||||
|
|||||||
@@ -488,6 +488,9 @@ class _SliverChannelsList extends HookConsumerWidget {
|
|||||||
ref
|
ref
|
||||||
.read(readStateProvider.notifier)
|
.read(readStateProvider.notifier)
|
||||||
.markContextRead(channel.id, ts);
|
.markContextRead(channel.id, ts);
|
||||||
|
ref
|
||||||
|
.read(channelsProvider.notifier)
|
||||||
|
.clearObservedUnreadCoveredByRead(channel.id, ts);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -1104,6 +1107,9 @@ class _ChannelTile extends ConsumerWidget {
|
|||||||
ref
|
ref
|
||||||
.read(readStateProvider.notifier)
|
.read(readStateProvider.notifier)
|
||||||
.markContextRead(channel.id, ts);
|
.markContextRead(channel.id, ts);
|
||||||
|
ref
|
||||||
|
.read(channelsProvider.notifier)
|
||||||
|
.clearObservedUnreadCoveredByRead(channel.id, ts);
|
||||||
} else {
|
} else {
|
||||||
ref
|
ref
|
||||||
.read(readStateProvider.notifier)
|
.read(readStateProvider.notifier)
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
import '../../shared/relay/relay.dart';
|
import '../../shared/relay/relay.dart';
|
||||||
|
import '../../shared/theme/theme_provider.dart';
|
||||||
import '../../shared/utils/string_utils.dart';
|
import '../../shared/utils/string_utils.dart';
|
||||||
import 'channel.dart';
|
import 'channel.dart';
|
||||||
import 'channel_management_provider.dart' show channelDetailsProvider;
|
import 'channel_management_provider.dart' show channelDetailsProvider;
|
||||||
import 'read_state/read_state_provider.dart';
|
import 'read_state/read_state_provider.dart';
|
||||||
import 'unread_badge/is_high_priority_event.dart';
|
import 'unread_badge/is_high_priority_event.dart';
|
||||||
|
import 'unread_badge/observed_unread_event.dart';
|
||||||
import 'unread_badge/should_notify_for_event.dart';
|
import 'unread_badge/should_notify_for_event.dart';
|
||||||
|
|
||||||
const _channelTypeOrder = {'stream': 0, 'forum': 1, 'dm': 2};
|
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.
|
/// 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 = [];
|
final List<void Function()> _unsubscribers = [];
|
||||||
int _subscriptionVersion = 0;
|
int _subscriptionVersion = 0;
|
||||||
Timer? _backstopTimer;
|
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<String, int> get latestObservedByChannel =>
|
||||||
Map.unmodifiable(_latestHighPriorityByChannel);
|
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
|
@override
|
||||||
Future<List<Channel>> build() {
|
Future<List<Channel>> build() {
|
||||||
@@ -50,14 +68,16 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
|||||||
|
|
||||||
ref.onDispose(() {
|
ref.onDispose(() {
|
||||||
_clearLiveSubscriptions();
|
_clearLiveSubscriptions();
|
||||||
_latestHighPriorityByChannel.clear();
|
_latestObservedByChannel.clear();
|
||||||
|
_observedUnreadEventsByChannel.clear();
|
||||||
_backstopTimer?.cancel();
|
_backstopTimer?.cancel();
|
||||||
_backstopTimer = null;
|
_backstopTimer = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (sessionState.status != SessionStatus.connected) {
|
if (sessionState.status != SessionStatus.connected) {
|
||||||
_clearLiveSubscriptions();
|
_clearLiveSubscriptions();
|
||||||
_latestHighPriorityByChannel.clear();
|
_latestObservedByChannel.clear();
|
||||||
|
_observedUnreadEventsByChannel.clear();
|
||||||
// Preserve the last successfully loaded channels while reconnecting
|
// Preserve the last successfully loaded channels while reconnecting
|
||||||
// instead of re-entering a loading/error state. The UI will show cached
|
// instead of re-entering a loading/error state. The UI will show cached
|
||||||
// channels with a "Reconnecting…" banner overlay, which is far better
|
// channels with a "Reconnecting…" banner overlay, which is far better
|
||||||
@@ -79,6 +99,7 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
|||||||
}) async {
|
}) async {
|
||||||
final myPk = ref.read(myPubkeyProvider);
|
final myPk = ref.read(myPubkeyProvider);
|
||||||
if (myPk == null) throw StateError('No signing identity available');
|
if (myPk == null) throw StateError('No signing identity available');
|
||||||
|
_loadThreadInterestStores(myPk);
|
||||||
|
|
||||||
final session = ref.read(relaySessionProvider.notifier);
|
final session = ref.read(relaySessionProvider.notifier);
|
||||||
|
|
||||||
@@ -399,9 +420,7 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
|||||||
|
|
||||||
_unsubscribers.addAll(subscriptions.whereType<void Function()>());
|
_unsubscribers.addAll(subscriptions.whereType<void Function()>());
|
||||||
|
|
||||||
// Backfill high-priority map from recent history so unread @mentions that
|
unawaited(_catchUpUnreadEvents(channels));
|
||||||
// arrived before app launch are correctly classified as high-priority tier.
|
|
||||||
unawaited(_backfillHighPriority(channels));
|
|
||||||
|
|
||||||
_backstopTimer?.cancel();
|
_backstopTimer?.cancel();
|
||||||
_backstopTimer = Timer.periodic(
|
_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);
|
final myPk = ref.read(myPubkeyProvider);
|
||||||
if (myPk == null) return;
|
if (myPk == null) return;
|
||||||
|
|
||||||
final session = ref.read(relaySessionProvider.notifier);
|
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>>[];
|
final futures = <Future<void>>[];
|
||||||
|
|
||||||
for (final channel in channels) {
|
for (final channel in channels) {
|
||||||
if (!channel.isMember || channel.isArchived) continue;
|
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);
|
final readAt = readState.effectiveTimestamp(channel.id);
|
||||||
futures.add(
|
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;
|
const batchSize = 5;
|
||||||
for (var i = 0; i < futures.length; i += batchSize) {
|
for (var i = 0; i < futures.length; i += batchSize) {
|
||||||
await Future.wait(futures.sublist(i, min(i + batchSize, futures.length)));
|
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));
|
state = state.whenData((channels) => List<Channel>.of(channels));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _backfillHighPriorityForChannel(
|
Future<void> _catchUpUnreadEventsForChannel(
|
||||||
RelaySessionNotifier session,
|
RelaySessionNotifier session,
|
||||||
Channel channel,
|
Channel channel,
|
||||||
String myPk,
|
String myPk,
|
||||||
int? readAt,
|
int? readAt,
|
||||||
) async {
|
) 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 {
|
try {
|
||||||
final events = await session.fetchHistory(
|
final events = await session.fetchHistory(
|
||||||
NostrFilter(
|
NostrFilter(
|
||||||
kinds: EventKind.channelEventKinds,
|
kinds: EventKind.channelMessageEventKinds,
|
||||||
tags: {
|
tags: {
|
||||||
'#h': [channel.id],
|
'#h': [channel.id],
|
||||||
},
|
},
|
||||||
since: readAt ?? 0,
|
since: readAt == null ? 0 : readAt + 1,
|
||||||
limit: 200,
|
limit: _unreadCatchUpLimit,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
var maxHighPriority = 0;
|
|
||||||
for (final event in events) {
|
for (final event in events) {
|
||||||
if (event.pubkey == myPk) continue;
|
if (event.pubkey.toLowerCase() == myPk.toLowerCase()) {
|
||||||
if (!EventKind.channelMessageEventKinds.contains(event.kind)) continue;
|
_recordSelfThreadInterest(event, myPk);
|
||||||
if (isHighPriorityEvent(event.tags, myPk) &&
|
|
||||||
event.createdAt > maxHighPriority) {
|
|
||||||
maxHighPriority = event.createdAt;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (maxHighPriority > 0) {
|
for (final event in events) {
|
||||||
final current = _latestHighPriorityByChannel[channel.id] ?? 0;
|
if (event.pubkey.toLowerCase() == myPk.toLowerCase()) continue;
|
||||||
if (maxHighPriority > current) {
|
if (readAt != null && event.createdAt <= readAt) continue;
|
||||||
_latestHighPriorityByChannel[channel.id] = maxHighPriority;
|
if (!shouldNotifyForEvent(
|
||||||
|
event,
|
||||||
|
myPk,
|
||||||
|
participatedRootIds: _participatedRootIds,
|
||||||
|
authoredRootIds: _authoredRootIds,
|
||||||
|
)) {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
_recordUnreadEvent(channel, event, myPk);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debugPrint(
|
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 updated = List<Channel>.of(channels);
|
||||||
final channel = updated[idx];
|
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(
|
final eventTime = DateTime.fromMillisecondsSinceEpoch(
|
||||||
event.createdAt * 1000,
|
event.createdAt * 1000,
|
||||||
isUtc: true,
|
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;
|
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.
|
/// Backstop refresh that preserves existing state on transient failure.
|
||||||
Future<void> _backstopRefresh() async {
|
Future<void> _backstopRefresh() async {
|
||||||
try {
|
try {
|
||||||
@@ -580,3 +675,26 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
|||||||
final channelsProvider = AsyncNotifierProvider<ChannelsNotifier, List<Channel>>(
|
final channelsProvider = AsyncNotifierProvider<ChannelsNotifier, List<Channel>>(
|
||||||
ChannelsNotifier.new,
|
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 readStateFetchLimit = 500;
|
||||||
const readStateHorizonSeconds = 7 * 24 * 60 * 60;
|
const readStateHorizonSeconds = 7 * 24 * 60 * 60;
|
||||||
const _maxContexts = 10000;
|
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);
|
typedef ReadStateDecrypt = String Function(String ciphertext);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:lucide_icons_flutter/lucide_icons.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_actions.dart';
|
||||||
import 'message_content.dart';
|
import 'message_content.dart';
|
||||||
import 'reaction_row.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 'send_message_provider.dart';
|
||||||
import 'small_avatar.dart';
|
import 'small_avatar.dart';
|
||||||
import 'timeline_message.dart';
|
import 'timeline_message.dart';
|
||||||
@@ -62,6 +65,22 @@ class ThreadDetailPage extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final replies = childrenByParent[threadHead.id] ?? const [];
|
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).
|
// Thread-scoped typing indicators (exclude self).
|
||||||
final allTyping = ref.watch(channelTypingProvider(channelId));
|
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';
|
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 (!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;
|
final ref = event.threadReference;
|
||||||
if (ref.parentId == null) return true;
|
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 '../channels_provider.dart';
|
||||||
import '../read_state/read_state_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 {
|
class UnreadBadgeState {
|
||||||
const UnreadBadgeState({
|
const UnreadBadgeState({
|
||||||
@@ -20,42 +21,58 @@ final unreadBadgeProvider = Provider<UnreadBadgeState>((ref) {
|
|||||||
|
|
||||||
return channelsAsync.when(
|
return channelsAsync.when(
|
||||||
data: (channels) {
|
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 notifier = ref.read(channelsProvider.notifier);
|
||||||
final highPriorityMap = notifier.latestHighPriorityByChannel;
|
final observedEventsByChannel = notifier.observedUnreadEventsByChannel;
|
||||||
|
final latestObservedByChannel = notifier.latestObservedByChannel;
|
||||||
|
|
||||||
int highPriority = 0;
|
var highPriority = 0;
|
||||||
int general = 0;
|
var general = 0;
|
||||||
|
|
||||||
for (final channel in channels) {
|
for (final channel in channels) {
|
||||||
if (!channel.isMember || channel.isArchived) continue;
|
if (!channel.isMember || channel.isArchived) continue;
|
||||||
|
|
||||||
final isLocallyForced = readState.locallyForcedChannelIds.contains(
|
if (readState.locallyForcedChannelIds.contains(channel.id)) {
|
||||||
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 (unreadCount == 0) continue;
|
||||||
if (lastMessageAt == null && !isLocallyForced) continue;
|
|
||||||
|
|
||||||
final readAt = readState.effectiveTimestamp(channel.id);
|
if (channel.isDm ||
|
||||||
final isUnread =
|
countUnreadHighPriorityObservedEvents(
|
||||||
isLocallyForced ||
|
observedEvents,
|
||||||
readAt == null ||
|
readAtForObservedEvent,
|
||||||
(lastMessageAt != null && lastMessageAt > readAt);
|
) >
|
||||||
if (!isUnread) continue;
|
0) {
|
||||||
|
final appBadgeCount = countUnreadAppBadgeObservedEvents(
|
||||||
if (channel.isDm) {
|
observedEvents,
|
||||||
highPriority++;
|
readAtForObservedEvent,
|
||||||
|
);
|
||||||
|
highPriority += appBadgeCount > 0 ? appBadgeCount : 1;
|
||||||
} else {
|
} else {
|
||||||
final highPriorityAt = highPriorityMap[channel.id];
|
final badgeCount = countUnreadBadgeObservedEvents(
|
||||||
if (highPriorityAt != null &&
|
observedEvents,
|
||||||
(readAt == null || highPriorityAt > readAt)) {
|
readAtForObservedEvent,
|
||||||
highPriority++;
|
);
|
||||||
} else {
|
general += badgeCount > 0 ? badgeCount : 1;
|
||||||
general++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,49 +1,276 @@
|
|||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
|
||||||
|
import '../../shared/theme/grid.dart';
|
||||||
import '../activity/activity_page.dart';
|
import '../activity/activity_page.dart';
|
||||||
import '../channels/channels_page.dart';
|
import '../channels/channels_page.dart';
|
||||||
import '../pulse/pulse_page.dart';
|
|
||||||
import '../search/search_page.dart';
|
import '../search/search_page.dart';
|
||||||
|
|
||||||
class HomePage extends HookConsumerWidget {
|
class HomePage extends HookConsumerWidget {
|
||||||
const HomePage({super.key});
|
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
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final tabIndex = useState(0);
|
final tabIndex = useState(0);
|
||||||
|
|
||||||
const pages = [ChannelsPage(), PulsePage(), ActivityPage(), SearchPage()];
|
const pages = [ChannelsPage(), ActivityPage(), SearchPage()];
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: IndexedStack(index: tabIndex.value, children: pages),
|
extendBody: true,
|
||||||
bottomNavigationBar: NavigationBar(
|
body: MediaQuery(
|
||||||
|
data: _mediaQueryWithFloatingTabBarClearance(
|
||||||
|
context,
|
||||||
|
HomePage._fabClearance,
|
||||||
|
),
|
||||||
|
child: IndexedStack(index: tabIndex.value, children: pages),
|
||||||
|
),
|
||||||
|
bottomNavigationBar: _FloatingTabBar(
|
||||||
selectedIndex: tabIndex.value,
|
selectedIndex: tabIndex.value,
|
||||||
onDestinationSelected: (i) => tabIndex.value = i,
|
onDestinationSelected: (i) => tabIndex.value = i,
|
||||||
destinations: const [
|
destinations: _destinations,
|
||||||
NavigationDestination(
|
),
|
||||||
icon: Icon(LucideIcons.house),
|
);
|
||||||
selectedIcon: Icon(LucideIcons.house),
|
}
|
||||||
label: 'Home',
|
}
|
||||||
),
|
|
||||||
NavigationDestination(
|
MediaQueryData _mediaQueryWithFloatingTabBarClearance(
|
||||||
icon: Icon(LucideIcons.activity),
|
BuildContext context,
|
||||||
selectedIcon: Icon(LucideIcons.activity),
|
double clearance,
|
||||||
label: 'Pulse',
|
) {
|
||||||
),
|
final mediaQuery = MediaQuery.of(context);
|
||||||
NavigationDestination(
|
return mediaQuery.copyWith(
|
||||||
icon: Icon(LucideIcons.bell),
|
padding: mediaQuery.padding.copyWith(
|
||||||
selectedIcon: Icon(LucideIcons.bell),
|
bottom: mediaQuery.padding.bottom + clearance,
|
||||||
label: 'Activity',
|
),
|
||||||
),
|
viewPadding: mediaQuery.viewPadding.copyWith(
|
||||||
NavigationDestination(
|
bottom: mediaQuery.viewPadding.bottom + clearance,
|
||||||
icon: Icon(LucideIcons.search),
|
),
|
||||||
selectedIcon: Icon(LucideIcons.search),
|
);
|
||||||
label: 'Search',
|
}
|
||||||
),
|
|
||||||
],
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:buzz/features/channels/channel.dart';
|
import 'package:buzz/features/channels/channel.dart';
|
||||||
import 'package:buzz/features/channels/channels_provider.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/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';
|
import 'package:buzz/features/channels/unread_badge/unread_badge_provider.dart';
|
||||||
|
|
||||||
/// Unit tests for [unreadBadgeProvider].
|
/// Unit tests for [unreadBadgeProvider].
|
||||||
@@ -57,10 +58,13 @@ void main() {
|
|||||||
Set<String> locallyForcedChannelIds = const {},
|
Set<String> locallyForcedChannelIds = const {},
|
||||||
bool readStateReady = true,
|
bool readStateReady = true,
|
||||||
Map<String, int> highPriorityMap = const {},
|
Map<String, int> highPriorityMap = const {},
|
||||||
|
Map<String, List<ObservedUnreadEvent>>? observedEventsByChannel,
|
||||||
}) {
|
}) {
|
||||||
final notifier = _StubbedChannelsNotifier(
|
final notifier = _StubbedChannelsNotifier(
|
||||||
channels: channels,
|
channels: channels,
|
||||||
highPriorityMap: highPriorityMap,
|
observedEventsByChannel:
|
||||||
|
observedEventsByChannel ??
|
||||||
|
_defaultObservedEvents(channels, highPriorityMap),
|
||||||
);
|
);
|
||||||
|
|
||||||
return ProviderContainer(
|
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)', () {
|
test('channelsProvider in loading state returns (0, 0)', () {
|
||||||
// The provider returns const UnreadBadgeState() while channels are loading.
|
// The provider returns const UnreadBadgeState() while channels are loading.
|
||||||
// We intentionally do NOT await the future here — the channels notifier
|
// We intentionally do NOT await the future here — the channels notifier
|
||||||
@@ -302,7 +366,12 @@ void main() {
|
|||||||
final container = buildContainer(
|
final container = buildContainer(
|
||||||
channels: [makeChannel(id: channelId, lastMessageAtSeconds: t30)],
|
channels: [makeChannel(id: channelId, lastMessageAtSeconds: t30)],
|
||||||
readContexts: {channelId: t20},
|
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);
|
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
|
/// 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
|
/// 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 {
|
class _StubbedChannelsNotifier extends ChannelsNotifier {
|
||||||
_StubbedChannelsNotifier({
|
_StubbedChannelsNotifier({
|
||||||
required List<Channel> channels,
|
required List<Channel> channels,
|
||||||
Map<String, int> highPriorityMap = const {},
|
Map<String, List<ObservedUnreadEvent>> observedEventsByChannel = const {},
|
||||||
}) : _channels = channels,
|
}) : _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 List<Channel> _channels;
|
||||||
final Map<String, int> _highPriorityMap;
|
final Map<String, Map<String, ObservedUnreadEvent>> _observedEventsByChannel;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<Channel>> build() async => _channels;
|
Future<List<Channel>> build() async => _channels;
|
||||||
|
|
||||||
@override
|
@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.
|
/// 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;
|
Future<List<Channel>> build() => Completer<List<Channel>>().future;
|
||||||
|
|
||||||
@override
|
@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].
|
/// A [ReadStateNotifier] that returns a fixed [ReadStateState].
|
||||||
|
|||||||
39
scripts/check-pr-image-urls.sh
Executable file
39
scripts/check-pr-image-urls.sh
Executable 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
|
||||||
@@ -66,6 +66,8 @@ for i in "${!PNGS[@]}"; do
|
|||||||
done
|
done
|
||||||
|
|
||||||
if [[ -n "$BODY_FILE" ]]; then
|
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")"
|
COMMENT_BODY="$(cat "$BODY_FILE")"
|
||||||
UNREFERENCED=()
|
UNREFERENCED=()
|
||||||
for NAME in "${!IMAGE_URL_MAP[@]}"; do
|
for NAME in "${!IMAGE_URL_MAP[@]}"; do
|
||||||
|
|||||||
Reference in New Issue
Block a user