Files
cls 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
feat: import Chinese-localized Buzz source snapshot
Signed-off-by: cls_宁波本机 <908705107@qq.com>
2026-08-13 18:34:25 +08:00

387 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 bool showConnectionSkeleton;
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.showConnectionSkeleton,
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 =
showConnectionSkeleton || (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,
// The transparent gap shows the top section and must not absorb
// taps meant for the community or profile controls beneath it.
hitTestBehavior: HitTestBehavior.deferToChild,
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(
loading: loading,
shimmerEnabled: sessionStatus != SessionStatus.disconnected,
skeleton: _ChannelsSkeleton(
channels: loadedChannels,
topInset: barHeight,
status: sessionStatus,
),
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 dmChannels = sortDmChannelsByDisplayLabel(
visibleChannels.where((channel) => channel.isDm),
currentPubkey: currentPubkey,
);
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 notifier = ref.read(readStateProvider.notifier);
for (final channel in visibleChannels) {
if (readState.effectiveTimestamp(channel.id) != null) {
continue;
}
final lastMessageAt = dateTimeToUnixSeconds(channel.lastMessageAt);
if (lastMessageAt != null) {
notifier.seedContextRead(channel.id, lastMessageAt);
}
}
initialSeedComplete.value = true;
});
}, [readState.isReady, readState.pubkey, visibleChannels]);
final unreadState = _computeUnreadChannelState(
channels: visibleChannels,
readState: readState,
channelsNotifier: ref.read(channelsProvider.notifier),
);
final unreadChannelIds = {
for (final channelId in unreadState.ids)
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: context.l10n.starred,
icon: LucideIcons.star,
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: context.l10n.renameSection,
confirmLabel: context.l10n.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(context.l10n.deleteSectionNamed(section.name)),
content: Text(context.l10n.deleteSectionConfirm),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(context.l10n.cancel),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text(
context.l10n.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: context.l10n.searchChannels,
icon: LucideIcons.hash,
showTopDivider:
starredStreamChannels.isNotEmpty || userSections.isNotEmpty,
expanded: channelsExpanded.value,
onToggle: () => channelsExpanded.value = !channelsExpanded.value,
channels: ungroupedStreamChannels,
unreadChannelIds: unreadChannelIds,
mutedChannelIds: mutedChannelIds,
currentPubkey: currentPubkey,
emptyLabel: context.l10n.noStreamChannels,
sortMode: sortState.sortModeFor('channels'),
onSortModeChange: (mode) => setSortMode('channels', mode),
onSelectChannel: onSelectChannel,
),
_ChannelSection(
title: context.l10n.directMessages,
icon: LucideIcons.messagesSquare,
showTopDivider: true,
expanded: dmsExpanded.value,
onToggle: () => dmsExpanded.value = !dmsExpanded.value,
channels: sortedDmChannels,
unreadChannelIds: unreadChannelIds,
mutedChannelIds: mutedChannelIds,
currentPubkey: currentPubkey,
emptyLabel: context.l10n.noDirectMessages,
sortMode: sortState.sortModeFor('dms'),
onSortModeChange: (mode) => setSortMode('dms', mode),
onSelectChannel: onSelectChannel,
),
],
],
),
);
}
}