mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
## Summary Opening an uncached community seeded each previously unseen channel's read marker separately. Every seed copied and serialized the full read-state snapshot, started three preference writes, and notified listeners. Home now collects those seeds and saves/emits once, using the existing monotonic marker admission rules. For a 400-channel fixture with one existing marker, initial seeding falls from 399 full snapshot saves to one. Existing unread frontiers, local-only seeds and explicit read publishing keep their behavior. No transport, cache, relay or unread eligibility changes. This is independent of #8580 and #8490. ## Validation - `just mobile-install mobile-check mobile-test`: clean analysis; 3,779 tests passed, 19 skipped, plus four checks for builds without push configuration. - Real Home/channel-provider/read-state journey covers 400 channels, preservation of an existing unread highlight, persisted marks, no seed publication, and no repeated seed save on refresh. It replaces the earlier fake-notifier seed test. - Restoring the old per-channel loop makes the new journey fail at the single-snapshot assertion. - Diff and file-size checks pass. - Merged current main (`209fcd217`) at `d34d29627`; retained both the cold read-seeding and cached-community landing test registrations. The production diff is byte-for-byte unchanged from the version reviewed by the Court at `6a232ed37`. The complete mobile gates above passed again on the merged tree. Device latency is unmeasured. The Court source review found no blockers at `6a232ed37`; app/device testing and human acceptance remain unconfirmed. To try: open a community without a saved channel list, confirm its channels load and existing unread highlights remain correct, then refresh and reopen it. The performance claim here is reduced save work, not a measured end-to-end speedup. Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Star Lord <b89298dbe87c6b3fd8a425b535d9d161f23c88d555a47925ae0b04dfe02b201e@buzz.block.builderlab.xyz>
408 lines
15 KiB
Dart
408 lines
15 KiB
Dart
part of '../channels_page.dart';
|
|
|
|
class _ChannelsBody extends StatelessWidget {
|
|
final List<Channel>? channels;
|
|
final AsyncValue<List<Channel>> channelsAsync;
|
|
final bool showError;
|
|
final SessionStatus sessionStatus;
|
|
final ValueChanged<bool> onReadyChanged;
|
|
final String? currentPubkey;
|
|
final double topSectionHeight;
|
|
final bool usesPinnedGradient;
|
|
final ScrollController scrollController;
|
|
final Future<void> Function() onRefresh;
|
|
final Future<void> Function(Channel channel) onSelectChannel;
|
|
|
|
const _ChannelsBody({
|
|
required this.channels,
|
|
required this.channelsAsync,
|
|
required this.showError,
|
|
required this.sessionStatus,
|
|
required this.onReadyChanged,
|
|
required this.currentPubkey,
|
|
required this.topSectionHeight,
|
|
required this.usesPinnedGradient,
|
|
required this.scrollController,
|
|
required this.onRefresh,
|
|
required this.onSelectChannel,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final barHeight = topSectionHeight;
|
|
final loadedChannels = channels;
|
|
final loading = loadedChannels == null && !showError;
|
|
final content = showError && channelsAsync.hasError
|
|
? Padding(
|
|
padding: EdgeInsets.only(top: barHeight),
|
|
child: _ErrorView(error: channelsAsync.error!, onRetry: onRefresh),
|
|
)
|
|
: loadedChannels == null
|
|
? const SizedBox.shrink()
|
|
: BeeRefreshIndicator(
|
|
edgeOffset: barHeight,
|
|
onRefresh: onRefresh,
|
|
child: CustomScrollView(
|
|
controller: scrollController,
|
|
// Transparent list gaps must remain hit-testable so a new drag
|
|
// can interrupt ballistic scrolling. The app bar is painted
|
|
// later and retains its community and profile controls.
|
|
hitTestBehavior: HitTestBehavior.translucent,
|
|
slivers: [
|
|
SliverToBoxAdapter(child: SizedBox(height: barHeight)),
|
|
if (usesPinnedGradient)
|
|
_SliverChannelsList(
|
|
channels: loadedChannels,
|
|
currentPubkey: currentPubkey,
|
|
onSelectChannel: onSelectChannel,
|
|
)
|
|
else
|
|
DecoratedSliver(
|
|
decoration: BoxDecoration(
|
|
color: context.colors.surface,
|
|
borderRadius: const BorderRadius.vertical(
|
|
top: Radius.circular(Radii.dialog),
|
|
),
|
|
),
|
|
sliver: _SliverChannelsList(
|
|
channels: loadedChannels,
|
|
currentPubkey: currentPubkey,
|
|
onSelectChannel: onSelectChannel,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
return SkeletonReveal(
|
|
onReadyChanged: onReadyChanged,
|
|
loading: loading,
|
|
loadingSemanticsKey: const Key('channels-connection-skeleton'),
|
|
loadingLabel: switch (sessionStatus) {
|
|
SessionStatus.connecting => 'Connecting',
|
|
SessionStatus.reconnecting => 'Reconnecting',
|
|
_ => 'Loading',
|
|
},
|
|
shimmerEnabled: sessionStatus != SessionStatus.disconnected,
|
|
skeleton: _ChannelsSkeleton(
|
|
channels: loadedChannels,
|
|
topInset: barHeight,
|
|
),
|
|
content: content,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SliverChannelsList extends HookConsumerWidget {
|
|
final List<Channel> channels;
|
|
final String? currentPubkey;
|
|
final Future<void> Function(Channel channel) onSelectChannel;
|
|
|
|
const _SliverChannelsList({
|
|
required this.channels,
|
|
required this.currentPubkey,
|
|
required this.onSelectChannel,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final readState = ref.watch(readStateProvider);
|
|
final sectionsState = ref.watch(channelSectionsProvider);
|
|
final mutesState = ref.watch(channelMutesProvider);
|
|
final mutedChannelIds = {
|
|
for (final entry in mutesState.store.channels.entries)
|
|
if (entry.value.muted) entry.key,
|
|
};
|
|
final starsState = ref.watch(channelStarsProvider);
|
|
final starredChannelIds = {
|
|
for (final entry in starsState.store.channels.entries)
|
|
if (entry.value.starred) entry.key,
|
|
};
|
|
final visibleChannels = channels
|
|
.where((channel) => channel.isMember && !channel.isArchived)
|
|
.toList();
|
|
final streamChannels = visibleChannels
|
|
.where((channel) => channel.isStream)
|
|
.toList();
|
|
final unsortedDms = visibleChannels.where((channel) => channel.isDm);
|
|
// Rebuild only when the DM order changes, not for every profile fetch.
|
|
final dmOrder = ref.watch(
|
|
identityNameSourcesProvider.select(
|
|
(names) => [
|
|
for (final channel in sortDmChannelsByDisplayLabel(
|
|
unsortedDms,
|
|
currentPubkey: currentPubkey,
|
|
names: names,
|
|
))
|
|
channel.id,
|
|
].join('\u0000'),
|
|
),
|
|
);
|
|
final dmRank = {
|
|
for (final (index, id) in dmOrder.split('\u0000').indexed) id: index,
|
|
};
|
|
final dmChannels = unsortedDms.toList()
|
|
..sort((a, b) => dmRank[a.id]!.compareTo(dmRank[b.id]!));
|
|
|
|
final starredExpanded = useState(true);
|
|
final channelsExpanded = useState(true);
|
|
final dmsExpanded = useState(true);
|
|
final sortState = ref.watch(channelSortProvider);
|
|
final initialSeedComplete = useState(false);
|
|
final seededPubkey = useRef<String?>(null);
|
|
final seedCompleteForPubkey =
|
|
seededPubkey.value == readState.pubkey && initialSeedComplete.value;
|
|
|
|
useEffect(() {
|
|
if (!readState.isReady) {
|
|
return null;
|
|
}
|
|
|
|
return deferReadStateUpdate(context, () {
|
|
if (seededPubkey.value != readState.pubkey) {
|
|
seededPubkey.value = readState.pubkey;
|
|
initialSeedComplete.value = false;
|
|
}
|
|
|
|
if (initialSeedComplete.value) {
|
|
return;
|
|
}
|
|
|
|
final seeds = <String, int>{};
|
|
for (final channel in visibleChannels) {
|
|
if (readState.effectiveTimestamp(channel.id) != null) {
|
|
continue;
|
|
}
|
|
|
|
final lastMessageAt = dateTimeToUnixSeconds(channel.lastMessageAt);
|
|
if (lastMessageAt != null) {
|
|
seeds[channel.id] = lastMessageAt;
|
|
}
|
|
}
|
|
ref.read(readStateProvider.notifier).seedContextsRead(seeds);
|
|
initialSeedComplete.value = true;
|
|
});
|
|
}, [readState.isReady, readState.pubkey, visibleChannels]);
|
|
|
|
final unreadState = computeUnreadChannelIds(
|
|
channels: visibleChannels,
|
|
readState: readState,
|
|
channelsNotifier: ref.read(channelsProvider.notifier),
|
|
);
|
|
final unreadChannelIds = {
|
|
for (final channelId in unreadState)
|
|
if (seedCompleteForPubkey ||
|
|
readState.effectiveTimestamp(channelId) != null)
|
|
channelId,
|
|
};
|
|
// Build sorted user-defined sections and compute which stream channels
|
|
// belong to each section. Channels not assigned to any valid section fall
|
|
// through to the built-in "Channels" list.
|
|
final userSections = sectionsState.store.sections.toList()
|
|
..sort((a, b) => a.order.compareTo(b.order));
|
|
final sectionAssignments = sectionsState.store.assignments;
|
|
final validSectionIds = {for (final s in userSections) s.id};
|
|
final assignedChannelIds = {
|
|
for (final entry in sectionAssignments.entries)
|
|
if (validSectionIds.contains(entry.value)) entry.key,
|
|
};
|
|
// Starred is exclusive: a starred channel lives only in the Starred section,
|
|
// not in its custom section or the default Channels list.
|
|
final starredStreamChannels = sortChannelsForList(
|
|
streamChannels.where((c) => starredChannelIds.contains(c.id)).toList(),
|
|
sortState.sortModeFor('starred'),
|
|
);
|
|
final ungroupedStreamChannels = sortChannelsForList(
|
|
streamChannels
|
|
.where(
|
|
(c) =>
|
|
!assignedChannelIds.contains(c.id) &&
|
|
!starredChannelIds.contains(c.id),
|
|
)
|
|
.toList(),
|
|
sortState.sortModeFor('channels'),
|
|
);
|
|
// DMs default to the display-label alphabetical order (labels can differ
|
|
// from channel names); Recent mode reorders by last message time.
|
|
final sortedDmChannels =
|
|
sortState.sortModeFor('dms') == ChannelSortMode.recent
|
|
? sortChannelsForList(dmChannels, ChannelSortMode.recent)
|
|
: dmChannels;
|
|
|
|
final liveSectionIds = [for (final s in userSections) s.id];
|
|
void setSortMode(String groupKey, ChannelSortMode mode) {
|
|
ref
|
|
.read(channelSortProvider.notifier)
|
|
.setSortModeFor(groupKey, mode, liveSectionIds: liveSectionIds);
|
|
}
|
|
|
|
final sectionExpandedStates = useState<Map<String, bool>>({});
|
|
|
|
bool sectionExpanded(String sectionId) =>
|
|
sectionExpandedStates.value[sectionId] ?? true;
|
|
|
|
void toggleSection(String sectionId) {
|
|
sectionExpandedStates.value = {
|
|
...sectionExpandedStates.value,
|
|
sectionId: !sectionExpanded(sectionId),
|
|
};
|
|
}
|
|
|
|
return SliverPadding(
|
|
padding: EdgeInsets.only(
|
|
top: Grid.xxs,
|
|
bottom: MediaQuery.paddingOf(context).bottom,
|
|
),
|
|
sliver: SliverList.list(
|
|
children: [
|
|
if (visibleChannels.isEmpty)
|
|
const _EmptyState()
|
|
else ...[
|
|
// Starred channels (exclusive — pinned above all sections).
|
|
if (starredStreamChannels.isNotEmpty)
|
|
_ChannelSection(
|
|
title: 'Starred',
|
|
showTopDivider: false,
|
|
expanded: starredExpanded.value,
|
|
onToggle: () => starredExpanded.value = !starredExpanded.value,
|
|
channels: starredStreamChannels,
|
|
unreadChannelIds: unreadChannelIds,
|
|
mutedChannelIds: mutedChannelIds,
|
|
currentPubkey: currentPubkey,
|
|
emptyLabel: '',
|
|
sortMode: sortState.sortModeFor('starred'),
|
|
onSortModeChange: (mode) => setSortMode('starred', mode),
|
|
onSelectChannel: onSelectChannel,
|
|
),
|
|
// User-defined sections for stream channels, in user-defined order.
|
|
for (final section in userSections)
|
|
_CustomChannelSection(
|
|
section: section,
|
|
channels: sortChannelsForList(
|
|
streamChannels
|
|
.where(
|
|
(c) =>
|
|
sectionAssignments[c.id] == section.id &&
|
|
!starredChannelIds.contains(c.id),
|
|
)
|
|
.toList(),
|
|
sortState.sortModeFor(sectionSortGroupKey(section.id)),
|
|
),
|
|
unreadChannelIds: unreadChannelIds,
|
|
mutedChannelIds: mutedChannelIds,
|
|
currentPubkey: currentPubkey,
|
|
expanded: sectionExpanded(section.id),
|
|
isFirst: userSections.first.id == section.id,
|
|
isLast: userSections.last.id == section.id,
|
|
showTopDivider:
|
|
starredStreamChannels.isNotEmpty ||
|
|
userSections.first.id != section.id,
|
|
onToggle: () => toggleSection(section.id),
|
|
onRename: () async {
|
|
final name = await showBuzzDialog<String>(
|
|
context: context,
|
|
builder: (_) => _SectionNameDialog(
|
|
title: 'Rename Section',
|
|
confirmLabel: 'Rename',
|
|
initialValue: section.name,
|
|
),
|
|
);
|
|
if (name != null && name.isNotEmpty) {
|
|
ref
|
|
.read(channelSectionsProvider.notifier)
|
|
.renameSection(section.id, name);
|
|
}
|
|
},
|
|
onDelete: () async {
|
|
final confirmed = await showBuzzDialog<bool>(
|
|
context: context,
|
|
builder: (_) => AlertDialog(
|
|
title: Text('Delete "${section.name}"?'),
|
|
content: const Text(
|
|
'Channels in this section will move back to the main list.',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context, false),
|
|
child: const Text('Cancel'),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context, true),
|
|
child: Text(
|
|
'Delete',
|
|
style: TextStyle(color: context.colors.error),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (confirmed == true) {
|
|
ref
|
|
.read(channelSectionsProvider.notifier)
|
|
.deleteSection(section.id);
|
|
}
|
|
},
|
|
onMoveUp: () => ref
|
|
.read(channelSectionsProvider.notifier)
|
|
.moveSectionUp(section.id),
|
|
onMoveDown: () => ref
|
|
.read(channelSectionsProvider.notifier)
|
|
.moveSectionDown(section.id),
|
|
sortMode: sortState.sortModeFor(
|
|
sectionSortGroupKey(section.id),
|
|
),
|
|
onSortModeChange: (mode) =>
|
|
setSortMode(sectionSortGroupKey(section.id), mode),
|
|
onSelectChannel: onSelectChannel,
|
|
onMarkChannelRead: (channel) {
|
|
final ts = dateTimeToUnixSeconds(channel.lastMessageAt);
|
|
if (ts != null) {
|
|
ref
|
|
.read(readStateProvider.notifier)
|
|
.markContextRead(
|
|
channel.id,
|
|
ts,
|
|
clearForcedMessages: true,
|
|
);
|
|
ref
|
|
.read(channelsProvider.notifier)
|
|
.clearObservedUnreadCoveredByRead(channel.id, ts);
|
|
}
|
|
},
|
|
),
|
|
_ChannelSection(
|
|
title: 'Channels',
|
|
showTopDivider:
|
|
starredStreamChannels.isNotEmpty || userSections.isNotEmpty,
|
|
expanded: channelsExpanded.value,
|
|
onToggle: () => channelsExpanded.value = !channelsExpanded.value,
|
|
channels: ungroupedStreamChannels,
|
|
unreadChannelIds: unreadChannelIds,
|
|
mutedChannelIds: mutedChannelIds,
|
|
currentPubkey: currentPubkey,
|
|
emptyLabel: 'No stream channels yet',
|
|
sortMode: sortState.sortModeFor('channels'),
|
|
onSortModeChange: (mode) => setSortMode('channels', mode),
|
|
onSelectChannel: onSelectChannel,
|
|
),
|
|
_ChannelSection(
|
|
title: 'DMs',
|
|
showTopDivider: true,
|
|
expanded: dmsExpanded.value,
|
|
onToggle: () => dmsExpanded.value = !dmsExpanded.value,
|
|
channels: sortedDmChannels,
|
|
unreadChannelIds: unreadChannelIds,
|
|
mutedChannelIds: mutedChannelIds,
|
|
currentPubkey: currentPubkey,
|
|
emptyLabel: 'No direct messages yet',
|
|
sortMode: sortState.sortModeFor('dms'),
|
|
onSortModeChange: (mode) => setSortMode('dms', mode),
|
|
onSelectChannel: onSelectChannel,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|