9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
846 lines
28 KiB
Dart
846 lines
28 KiB
Dart
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 'channel_mutes/channel_mutes_provider.dart';
|
|
import '../../shared/read_state/read_state_provider.dart';
|
|
import 'thread_follows/thread_follows_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.
|
|
///
|
|
/// Two-step query:
|
|
/// 1. Fetch kind:39002 membership events tagged `#p:<my-pubkey>` to find
|
|
/// the channel ids I'm a member of.
|
|
/// 2. Fetch the corresponding kind:39000 channel metadata events.
|
|
///
|
|
/// Live updates are layered on top via per-channel subscriptions on the
|
|
/// `#h` tag for any of the visible channel event kinds — incoming events
|
|
/// bump `lastMessageAt` for that channel.
|
|
class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
|
static const _backstopInterval = Duration(seconds: 60);
|
|
|
|
final Map<String, void Function()> _unsubscribersByChannel = {};
|
|
Future<void> _liveSubscriptionQueue = Future.value();
|
|
List<Channel> _desiredLiveChannels = const [];
|
|
Set<String> _desiredLiveChannelIds = const {};
|
|
int _subscriptionVersion = 0;
|
|
String? _subscriptionRelayBaseUrl;
|
|
Timer? _backstopTimer;
|
|
final Map<String, int> _latestObservedByChannel = {};
|
|
final Map<String, Map<String, ObservedUnreadEvent>>
|
|
_observedUnreadEventsByChannel = {};
|
|
Set<String> _participatedRootIds = {};
|
|
Set<String> _authoredRootIds = {};
|
|
String? _threadInterestPubkey;
|
|
bool _hasLoaded = false;
|
|
|
|
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() async {
|
|
ref.watch(relayConfigProvider);
|
|
final connected = Completer<void>();
|
|
final sessionState = ref.read(relaySessionProvider);
|
|
final waitingForInitialConnection =
|
|
sessionState.status != SessionStatus.connected;
|
|
ref.listen(relaySessionProvider, (previous, next) {
|
|
if (next.status != SessionStatus.connected) return;
|
|
if (waitingForInitialConnection &&
|
|
!_hasLoaded &&
|
|
!connected.isCompleted) {
|
|
connected.complete();
|
|
} else if (previous?.status != SessionStatus.connected) {
|
|
unawaited(_backstopRefresh());
|
|
}
|
|
});
|
|
|
|
// Re-fetch when the app returns to foreground so channels created on
|
|
// another device while mobile was backgrounded appear immediately.
|
|
ref.listen(appLifecycleProvider, (prev, next) {
|
|
if (next == AppLifecycleState.resumed) {
|
|
refresh();
|
|
}
|
|
});
|
|
|
|
ref.onDispose(() {
|
|
_clearLiveSubscriptions();
|
|
_latestObservedByChannel.clear();
|
|
_observedUnreadEventsByChannel.clear();
|
|
_backstopTimer?.cancel();
|
|
_backstopTimer = null;
|
|
});
|
|
|
|
if (sessionState.status != SessionStatus.connected) {
|
|
// Keep the prior community's cache visible until the new relay connects.
|
|
if (_hasLoaded) return state.value ?? const [];
|
|
await connected.future;
|
|
}
|
|
|
|
return _fetch(subscribeLive: true);
|
|
}
|
|
|
|
Future<List<Channel>> _fetch({
|
|
bool subscribeLive = false,
|
|
bool fetchLastMessage = true,
|
|
}) async {
|
|
final channels = await _fetchChannels(
|
|
subscribeLive: subscribeLive,
|
|
fetchLastMessage: fetchLastMessage,
|
|
);
|
|
_hasLoaded = true;
|
|
return channels;
|
|
}
|
|
|
|
Future<List<Channel>> _fetchChannels({
|
|
bool subscribeLive = false,
|
|
bool fetchLastMessage = true,
|
|
}) async {
|
|
final myPk = ref.read(myPubkeyProvider);
|
|
if (myPk == null) throw StateError('No signing identity available');
|
|
_loadThreadInterestStores(myPk);
|
|
|
|
final session = ref.read(relaySessionProvider.notifier);
|
|
|
|
// Step 1: find the channels I'm a member of via kind:39002.
|
|
final memberships = <NostrEvent>[];
|
|
{
|
|
int? until;
|
|
const pageSize = 500;
|
|
while (true) {
|
|
final page = await session.fetchHistory(
|
|
NostrFilter(
|
|
kinds: const [39002],
|
|
tags: {
|
|
'#p': [myPk],
|
|
},
|
|
limit: pageSize,
|
|
until: until,
|
|
),
|
|
);
|
|
memberships.addAll(page);
|
|
if (page.length < pageSize) break;
|
|
until = page.map((e) => e.createdAt).reduce(min) - 1;
|
|
}
|
|
}
|
|
final channelIds = memberships
|
|
.map((e) => e.getTagValue('d'))
|
|
.whereType<String>()
|
|
.toSet()
|
|
.toList();
|
|
if (channelIds.isEmpty) {
|
|
if (subscribeLive) await _subscribeLive(const []);
|
|
return const [];
|
|
}
|
|
|
|
// Step 2: pull channel metadata in one batched filter.
|
|
final metas = await session.fetchHistory(
|
|
NostrFilters.channelMetadata(channelIds),
|
|
);
|
|
|
|
// Dedupe by `d` tag (channel id) — kind:39000 is parameterized-replaceable,
|
|
// so logically there's exactly one current event per id, but stale revisions
|
|
// from before the relay's d_tag backfill can linger. Keep the highest
|
|
// `created_at` per id so the latest channel_type / name wins.
|
|
final latestMetaPerId = <String, NostrEvent>{};
|
|
for (final event in metas) {
|
|
if (event.kind != 39000) continue;
|
|
final id = event.getTagValue('d');
|
|
if (id == null) continue;
|
|
final existing = latestMetaPerId[id];
|
|
if (existing == null || event.createdAt > existing.createdAt) {
|
|
latestMetaPerId[id] = event;
|
|
}
|
|
}
|
|
final dedupedMetas = latestMetaPerId.values;
|
|
|
|
// Resolve DM participant display names. Relay stores DM channels with
|
|
// literal name="DM"; pure-Nostr architecture pushes name resolution to
|
|
// the client, so collect non-self participant pubkeys across all DM
|
|
// metas and batch-fetch their kind:0 profiles in one round-trip.
|
|
final dmParticipants = <String>{};
|
|
final myPkLower = myPk.toLowerCase();
|
|
for (final event in dedupedMetas) {
|
|
final data = ChannelData.fromEvent(event);
|
|
if (data.channelType != 'dm') continue;
|
|
for (final pk in data.participantPubkeys) {
|
|
final lower = pk.toLowerCase();
|
|
if (lower != myPkLower) dmParticipants.add(lower);
|
|
}
|
|
}
|
|
|
|
final displayNames = <String, String>{};
|
|
if (dmParticipants.isNotEmpty) {
|
|
final profileEvents = await session.fetchHistory(
|
|
NostrFilters.profilesBatch(dmParticipants.toList()),
|
|
);
|
|
for (final event in profileEvents) {
|
|
if (event.kind != 0) continue;
|
|
final profile = ProfileData.fromEvent(event);
|
|
final label = profile.displayName?.trim().isNotEmpty == true
|
|
? profile.displayName!.trim()
|
|
: profile.nip05?.trim().isNotEmpty == true
|
|
? profile.nip05!.trim()
|
|
: shortPubkey(profile.pubkey);
|
|
displayNames[profile.pubkey.toLowerCase()] = label;
|
|
}
|
|
}
|
|
|
|
final hiddenDmIds = await _fetchHiddenDmIds(session, myPk);
|
|
|
|
final channels = <Channel>[];
|
|
for (final event in dedupedMetas) {
|
|
final channel = _channelFromMeta(
|
|
event,
|
|
isMember: true,
|
|
displayNames: displayNames,
|
|
);
|
|
if (channel.isDm && hiddenDmIds.contains(channel.id)) continue;
|
|
// Ephemeral (TTL) channels are surfaced in the list with an
|
|
// `_EphemeralBadge` rendered in `channels_page.dart` — they shouldn't be
|
|
// hidden. Desktop shows them too. Previously dropped here unconditionally,
|
|
// which made TTL channels invisible on iOS even when the user was a member.
|
|
channels.add(channel);
|
|
}
|
|
|
|
// Batch-fetch member counts via kind:39002 membership events.
|
|
final memberEvents = await session.fetchHistory(
|
|
NostrFilter(
|
|
kinds: const [39002],
|
|
tags: {'#d': channelIds},
|
|
limit: channelIds.length,
|
|
),
|
|
);
|
|
final memberCounts = <String, int>{};
|
|
for (final event in memberEvents) {
|
|
final chId = event.getTagValue('d');
|
|
if (chId == null) continue;
|
|
final pTags = <String>{};
|
|
for (final tag in event.tags) {
|
|
if (tag.isNotEmpty && tag[0] == 'p' && tag.length > 1) {
|
|
pTags.add(tag[1].toLowerCase());
|
|
}
|
|
}
|
|
memberCounts[chId] = pTags.length;
|
|
}
|
|
for (var i = 0; i < channels.length; i++) {
|
|
final count = memberCounts[channels[i].id];
|
|
if (count != null) {
|
|
channels[i] = channels[i].copyWith(memberCount: count);
|
|
}
|
|
}
|
|
|
|
// Step 3: fetch the most recent message per channel to populate lastMessageAt.
|
|
// kind:39000 metadata doesn't carry message timestamps, so channels load with
|
|
// lastMessageAt: null. Without this, unread detection and badge computation
|
|
// see every channel as having no messages. Skipped on backstop refreshes since
|
|
// live subscriptions keep lastMessageAt current after the initial load.
|
|
if (fetchLastMessage) {
|
|
final lastMessageResults = await Future.wait(
|
|
channels.map((channel) async {
|
|
if (!channel.isMember || channel.isArchived) return null;
|
|
try {
|
|
if (channel.isDm) {
|
|
final events = await session.fetchHistory(
|
|
NostrFilter(
|
|
kinds: EventKind.channelMessageEventKinds,
|
|
tags: {
|
|
'#h': [channel.id],
|
|
},
|
|
limit: 1,
|
|
),
|
|
);
|
|
if (events.isEmpty) return null;
|
|
return MapEntry(channel.id, events.first.createdAt);
|
|
}
|
|
final events = await session.fetchHistory(
|
|
NostrFilter(
|
|
kinds: EventKind.channelMessageEventKinds,
|
|
tags: {
|
|
'#h': [channel.id],
|
|
},
|
|
limit: 20,
|
|
),
|
|
);
|
|
for (final event in events) {
|
|
if (shouldNotifyForEvent(
|
|
event,
|
|
myPk,
|
|
mutedChannelIds: _mutedChannelIds(),
|
|
channelId: channel.id,
|
|
)) {
|
|
return MapEntry(channel.id, event.createdAt);
|
|
}
|
|
}
|
|
return null;
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}),
|
|
);
|
|
|
|
final lastMessageMap = <String, int>{};
|
|
for (final entry
|
|
in lastMessageResults.whereType<MapEntry<String, int>>()) {
|
|
lastMessageMap[entry.key] = entry.value;
|
|
}
|
|
|
|
for (var i = 0; i < channels.length; i++) {
|
|
final ts = lastMessageMap[channels[i].id];
|
|
if (ts != null) {
|
|
channels[i] = channels[i].copyWith(
|
|
lastMessageAt: DateTime.fromMillisecondsSinceEpoch(
|
|
ts * 1000,
|
|
isUtc: true,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
channels.sort((left, right) {
|
|
final typeOrder =
|
|
(_channelTypeOrder[left.channelType] ?? 99) -
|
|
(_channelTypeOrder[right.channelType] ?? 99);
|
|
if (typeOrder != 0) return typeOrder;
|
|
// Case-insensitive to match desktop's `localeCompare` ordering.
|
|
return left.name.toLowerCase().compareTo(right.name.toLowerCase());
|
|
});
|
|
|
|
// Invalidate `channelDetailsProvider` entries whose archived state flipped
|
|
// since the last fetch. Required because `channelDetailsProvider` is a
|
|
// separate Riverpod cache and `Channel.mergeDetails(details)` overwrites
|
|
// archivedAt from the cached details — so an active-then-archived channel
|
|
// (e.g. TTL auto-archive by the relay reaper) could keep showing compose
|
|
// and manage actions in the detail view until the cache expired naturally.
|
|
//
|
|
// Scoped narrowly to the archived flip — broader metadata staleness
|
|
// (renames, topic changes, etc.) is a separate, pre-existing concern that
|
|
// already affects this provider for other reasons.
|
|
final prevById = <String, Channel>{
|
|
for (final c in state.value ?? const <Channel>[]) c.id: c,
|
|
};
|
|
for (final channel in channels) {
|
|
final prev = prevById[channel.id];
|
|
if (prev != null && prev.isArchived != channel.isArchived) {
|
|
ref.invalidate(channelDetailsProvider(channel.id));
|
|
}
|
|
}
|
|
|
|
if (subscribeLive) {
|
|
await _subscribeLive(channels);
|
|
}
|
|
return channels;
|
|
}
|
|
|
|
Future<Set<String>> _fetchHiddenDmIds(
|
|
RelaySessionNotifier session,
|
|
String myPk,
|
|
) async {
|
|
try {
|
|
final events = await session.fetchHistory(NostrFilters.hiddenDms(myPk));
|
|
if (events.isEmpty) return const {};
|
|
NostrEvent latest = events.first;
|
|
for (final event in events.skip(1)) {
|
|
if (event.createdAt > latest.createdAt) {
|
|
latest = event;
|
|
}
|
|
}
|
|
return {
|
|
for (final tag in latest.tags)
|
|
if (tag.length >= 2 && tag[0] == 'h') tag[1],
|
|
};
|
|
} catch (_) {
|
|
return const {};
|
|
}
|
|
}
|
|
|
|
/// Build a [Channel] from a kind:39000 metadata event.
|
|
///
|
|
/// [displayNames] maps lowercase participant pubkey → resolved label and is
|
|
/// used to populate [Channel.participants] for DMs so [Channel.displayLabel]
|
|
/// can render real names instead of the relay-canonical "DM" name.
|
|
Channel _channelFromMeta(
|
|
NostrEvent event, {
|
|
required bool isMember,
|
|
Map<String, String> displayNames = const {},
|
|
}) {
|
|
final data = ChannelData.fromEvent(event);
|
|
final participants = data.channelType == 'dm'
|
|
? [
|
|
for (final pk in data.participantPubkeys)
|
|
displayNames[pk.toLowerCase()] ?? shortPubkey(pk),
|
|
]
|
|
: const <String>[];
|
|
return Channel(
|
|
id: data.id,
|
|
name: data.name,
|
|
channelType: data.channelType,
|
|
visibility: data.visibility,
|
|
description: data.description,
|
|
topic: data.topic,
|
|
createdBy: event.pubkey,
|
|
createdAt: DateTime.fromMillisecondsSinceEpoch(
|
|
event.createdAt * 1000,
|
|
isUtc: true,
|
|
),
|
|
memberCount: 0,
|
|
lastMessageAt: null,
|
|
// `archivedAt` doubles as both the archived-state flag and the timestamp.
|
|
// The kind:39000 metadata only carries `["archived", "true"]`, not the
|
|
// moment of archival, so we stamp the event's `createdAt` — that's when
|
|
// the relay republished the metadata, which is the closest signal we have.
|
|
archivedAt: data.isArchived
|
|
? DateTime.fromMillisecondsSinceEpoch(
|
|
event.createdAt * 1000,
|
|
isUtc: true,
|
|
)
|
|
: null,
|
|
participants: participants,
|
|
participantPubkeys: data.participantPubkeys,
|
|
isMember: isMember,
|
|
ttlSeconds: data.ttlSeconds,
|
|
ttlDeadline: data.ttlDeadline,
|
|
);
|
|
}
|
|
|
|
/// Subscribe per-channel to live events (requires `#h` tag for relay
|
|
/// channel-scoped fan-out). Also starts a 60s WS backstop poll to detect
|
|
/// newly created channels we don't yet have subscriptions for.
|
|
Future<void> _subscribeLive(List<Channel> channels) {
|
|
final channelIds = {
|
|
for (final channel in channels)
|
|
if (channel.isMember && !channel.isArchived) channel.id,
|
|
};
|
|
final relayBaseUrl = ref.read(relayConfigProvider).baseUrl;
|
|
_desiredLiveChannels = channels;
|
|
_desiredLiveChannelIds = channelIds;
|
|
final subscriptionVersion = ++_subscriptionVersion;
|
|
|
|
final sync = _liveSubscriptionQueue.then(
|
|
(_) =>
|
|
_syncLiveSubscriptions(relayBaseUrl, subscriptionVersion, channels),
|
|
);
|
|
_liveSubscriptionQueue = sync.catchError((Object error, StackTrace stack) {
|
|
debugPrint(
|
|
'[ChannelsNotifier] live subscription sync failed: $error\n$stack',
|
|
);
|
|
});
|
|
return sync;
|
|
}
|
|
|
|
Future<void> _syncLiveSubscriptions(
|
|
String relayBaseUrl,
|
|
int subscriptionVersion,
|
|
List<Channel> channels,
|
|
) async {
|
|
if (ref.read(relaySessionProvider).status != SessionStatus.connected) {
|
|
return;
|
|
}
|
|
|
|
if (subscriptionVersion != _subscriptionVersion) {
|
|
await _syncLiveSubscriptions(
|
|
ref.read(relayConfigProvider).baseUrl,
|
|
_subscriptionVersion,
|
|
_desiredLiveChannels,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (_subscriptionRelayBaseUrl != relayBaseUrl) {
|
|
for (final unsubscribe in _unsubscribersByChannel.values) {
|
|
unsubscribe();
|
|
}
|
|
_unsubscribersByChannel.clear();
|
|
_subscriptionRelayBaseUrl = relayBaseUrl;
|
|
}
|
|
if (ref.read(relayConfigProvider).baseUrl != relayBaseUrl) {
|
|
return;
|
|
}
|
|
final session = ref.read(relaySessionProvider.notifier);
|
|
final channelIds = _desiredLiveChannelIds;
|
|
|
|
for (final entry in _unsubscribersByChannel.entries.toList()) {
|
|
if (channelIds.contains(entry.key)) continue;
|
|
_unsubscribersByChannel.remove(entry.key);
|
|
entry.value();
|
|
}
|
|
|
|
for (final channelId in channelIds) {
|
|
if (ref.read(relaySessionProvider).status != SessionStatus.connected) {
|
|
return;
|
|
}
|
|
if (_unsubscribersByChannel.containsKey(channelId)) continue;
|
|
try {
|
|
final unsubscribe = await session.subscribe(
|
|
NostrFilter(
|
|
kinds: EventKind.channelEventKinds,
|
|
tags: {
|
|
'#h': [channelId],
|
|
},
|
|
limit: 0,
|
|
),
|
|
_handleLiveEvent,
|
|
);
|
|
if (ref.read(relaySessionProvider).status != SessionStatus.connected ||
|
|
!_desiredLiveChannelIds.contains(channelId) ||
|
|
ref.read(relayConfigProvider).baseUrl != relayBaseUrl ||
|
|
_subscriptionRelayBaseUrl != relayBaseUrl) {
|
|
unsubscribe();
|
|
return;
|
|
}
|
|
final replaced = _unsubscribersByChannel[channelId];
|
|
if (replaced != null) {
|
|
unsubscribe();
|
|
continue;
|
|
}
|
|
_unsubscribersByChannel[channelId] = unsubscribe;
|
|
} catch (error) {
|
|
debugPrint(
|
|
'[ChannelsNotifier] live subscription failed for $channelId: $error',
|
|
);
|
|
}
|
|
}
|
|
|
|
if (ref.read(relaySessionProvider).status != SessionStatus.connected) {
|
|
return;
|
|
}
|
|
|
|
if (subscriptionVersion != _subscriptionVersion) {
|
|
final desiredChannelIds = _desiredLiveChannelIds;
|
|
for (final entry in _unsubscribersByChannel.entries.toList()) {
|
|
if (desiredChannelIds.contains(entry.key)) continue;
|
|
_unsubscribersByChannel.remove(entry.key);
|
|
entry.value();
|
|
}
|
|
return;
|
|
}
|
|
|
|
unawaited(_catchUpUnreadEvents(channels));
|
|
|
|
_backstopTimer?.cancel();
|
|
_backstopTimer = Timer.periodic(
|
|
_backstopInterval,
|
|
(_) => _backstopRefresh(),
|
|
);
|
|
}
|
|
|
|
Future<void> _catchUpUnreadEvents(List<Channel> channels) async {
|
|
final myPk = ref.read(myPubkeyProvider);
|
|
if (myPk == null) return;
|
|
|
|
final session = ref.read(relaySessionProvider.notifier);
|
|
final mutedChannelIds = _mutedChannelIds();
|
|
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;
|
|
final readAt = readState.effectiveTimestamp(channel.id);
|
|
futures.add(
|
|
_catchUpUnreadEventsForChannel(
|
|
session,
|
|
channel,
|
|
myPk,
|
|
readAt,
|
|
mutedChannelIds,
|
|
),
|
|
);
|
|
}
|
|
|
|
const batchSize = 5;
|
|
for (var i = 0; i < futures.length; i += batchSize) {
|
|
await Future.wait(futures.sublist(i, min(i + batchSize, futures.length)));
|
|
}
|
|
|
|
state = state.whenData((channels) => List<Channel>.of(channels));
|
|
}
|
|
|
|
Future<void> _catchUpUnreadEventsForChannel(
|
|
RelaySessionNotifier session,
|
|
Channel channel,
|
|
String myPk,
|
|
int? readAt,
|
|
Set<String> mutedChannelIds,
|
|
) async {
|
|
try {
|
|
final events = await session.fetchHistory(
|
|
NostrFilter(
|
|
kinds: EventKind.channelMessageEventKinds,
|
|
tags: {
|
|
'#h': [channel.id],
|
|
},
|
|
since: readAt == null ? 0 : readAt + 1,
|
|
limit: _unreadCatchUpLimit,
|
|
),
|
|
);
|
|
|
|
for (final event in events) {
|
|
if (event.pubkey.toLowerCase() == myPk.toLowerCase()) {
|
|
_recordSelfThreadInterest(event, myPk);
|
|
}
|
|
}
|
|
|
|
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,
|
|
followedRootIds: _followedRootIds(),
|
|
authoredRootIds: _authoredRootIds,
|
|
mutedChannelIds: mutedChannelIds,
|
|
channelId: channel.id,
|
|
)) {
|
|
continue;
|
|
}
|
|
_recordUnreadEvent(channel, event, myPk);
|
|
}
|
|
} catch (error) {
|
|
debugPrint(
|
|
'[ChannelsNotifier] unread catch-up failed for ${channel.id}: $error',
|
|
);
|
|
}
|
|
}
|
|
|
|
void _handleLiveEvent(NostrEvent event) {
|
|
final channelId = event.channelId;
|
|
if (channelId == null) return;
|
|
|
|
final myPk = ref.read(myPubkeyProvider);
|
|
final mutedChannelIds = _mutedChannelIds();
|
|
|
|
state = state.whenData((channels) {
|
|
final idx = channels.indexWhere((c) => c.id == channelId);
|
|
if (idx == -1) {
|
|
refresh();
|
|
return channels;
|
|
}
|
|
final updated = List<Channel>.of(channels);
|
|
final channel = updated[idx];
|
|
|
|
if (myPk != null && event.pubkey.toLowerCase() == myPk.toLowerCase()) {
|
|
_recordSelfThreadInterest(event, myPk);
|
|
}
|
|
|
|
if (myPk != null &&
|
|
shouldNotifyForEvent(
|
|
event,
|
|
myPk,
|
|
participatedRootIds: _participatedRootIds,
|
|
followedRootIds: _followedRootIds(),
|
|
authoredRootIds: _authoredRootIds,
|
|
mutedChannelIds: mutedChannelIds,
|
|
channelId: channel.id,
|
|
)) {
|
|
_recordUnreadEvent(channel, event, myPk);
|
|
final eventTime = DateTime.fromMillisecondsSinceEpoch(
|
|
event.createdAt * 1000,
|
|
isUtc: true,
|
|
);
|
|
if (channel.lastMessageAt == null ||
|
|
eventTime.isAfter(channel.lastMessageAt!)) {
|
|
updated[idx] = channel.copyWith(lastMessageAt: eventTime);
|
|
}
|
|
}
|
|
|
|
return updated;
|
|
});
|
|
}
|
|
|
|
Set<String> _mutedChannelIds() => {
|
|
for (final entry in ref.read(channelMutesProvider).store.channels.entries)
|
|
if (entry.value.muted) entry.key,
|
|
};
|
|
|
|
Set<String> _followedRootIds() =>
|
|
ref.read(threadFollowsProvider).followedRootIds;
|
|
|
|
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 {
|
|
final sessionState = ref.read(relaySessionProvider);
|
|
final prevChannels = state.value ?? const [];
|
|
final prevLastMessage = {
|
|
for (final c in prevChannels)
|
|
if (c.lastMessageAt != null) c.id: c.lastMessageAt,
|
|
};
|
|
final channels = await _fetch(
|
|
subscribeLive: sessionState.status == SessionStatus.connected,
|
|
fetchLastMessage: false,
|
|
);
|
|
for (var i = 0; i < channels.length; i++) {
|
|
final prev = prevLastMessage[channels[i].id];
|
|
if (channels[i].lastMessageAt == null && prev != null) {
|
|
channels[i] = channels[i].copyWith(lastMessageAt: prev);
|
|
}
|
|
}
|
|
state = AsyncData(channels);
|
|
} catch (error) {
|
|
debugPrint('[ChannelsNotifier] backstop refresh failed: $error');
|
|
}
|
|
}
|
|
|
|
Future<void> refresh() async {
|
|
final sessionState = ref.read(relaySessionProvider);
|
|
// Don't attempt to fetch when the session isn't connected — fetchHistory
|
|
// would send REQs over an unauthenticated socket that either time out
|
|
// (returning empty results) or get cancelled on disconnect, replacing the
|
|
// cached channel list with [] or an error. Wait for `build()` to re-run
|
|
// when the session transitions to connected.
|
|
if (sessionState.status != SessionStatus.connected) return;
|
|
state = await AsyncValue.guard(() => _fetch(subscribeLive: true));
|
|
}
|
|
|
|
void _clearLiveSubscriptions() {
|
|
_subscriptionVersion++;
|
|
_desiredLiveChannels = const [];
|
|
_desiredLiveChannelIds = const {};
|
|
for (final unsubscribe in _unsubscribersByChannel.values) {
|
|
unsubscribe();
|
|
}
|
|
_unsubscribersByChannel.clear();
|
|
_subscriptionRelayBaseUrl = null;
|
|
_backstopTimer?.cancel();
|
|
_backstopTimer = null;
|
|
}
|
|
}
|
|
|
|
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());
|