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>
387 lines
15 KiB
Dart
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,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|