feat: import Chinese-localized Buzz source snapshot
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>
This commit is contained in:
2026-08-13 18:34:25 +08:00
parent 61c3fa1df9
commit 9dfa06ffee
3785 changed files with 1085458 additions and 2 deletions
@@ -0,0 +1,87 @@
part of '../channels_page.dart';
class _EphemeralBadge extends StatelessWidget {
final Channel channel;
const _EphemeralBadge({required this.channel});
@override
Widget build(BuildContext context) {
final display = ephemeralChannelDisplay(channel, l10n: context.l10n);
if (display == null) return const SizedBox.shrink();
return Tooltip(
message: display.tooltipLabel,
child: Icon(
LucideIcons.clockFading,
key: Key('channel-ephemeral-${channel.id}'),
size: 16,
color: context.colors.onSurfaceVariant,
),
);
}
}
class _ErrorView extends StatelessWidget {
final Object error;
final VoidCallback onRetry;
const _ErrorView({required this.error, required this.onRetry});
static String _userMessage(BuildContext context, Object error) {
final l10n = context.l10n;
if (error is RelayException) {
if (error.statusCode == 401) {
return l10n.notAuthorizedApi;
}
if (error.statusCode == 403) {
return l10n.accessDenied;
}
return l10n.serverErrorRetry('${error.statusCode}');
}
if (error is SocketException) {
return l10n.relayUnreachable;
}
return l10n.connectionSomethingWrong;
}
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(Grid.sm),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.wifiOff,
size: Grid.xl,
color: context.colors.error,
),
const SizedBox(height: Grid.xs),
Text(
context.l10n.channelsLoadFailed,
style: context.textTheme.titleMedium,
),
const SizedBox(height: Grid.xxs),
Text(
_userMessage(context, error),
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: Grid.xs),
FilledButton.icon(
onPressed: onRetry,
icon: const Icon(LucideIcons.refreshCw),
label: Text(context.l10n.retry),
),
],
),
),
);
}
}
@@ -0,0 +1,386 @@
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,
),
],
],
),
);
}
}
@@ -0,0 +1,241 @@
part of '../channels_page.dart';
class _ChannelTile extends ConsumerWidget {
final Channel channel;
final bool isUnread;
final bool isMuted;
final String? currentPubkey;
final VoidCallback onTap;
/// Called when the user requests to mark this channel read (from long-press
/// actions menu). Null for channels in built-in sections.
final VoidCallback? onMarkRead;
/// The user-defined section this channel currently belongs to, or null.
final String? sectionId;
const _ChannelTile({
required this.channel,
required this.isUnread,
required this.currentPubkey,
required this.onTap,
this.isMuted = false,
this.onMarkRead,
this.sectionId,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final contentColor = isMuted
? navigationSecondaryForeground(context)
: navigationPrimaryForeground(
context,
).withValues(alpha: isUnread ? 1 : 0.8);
return InkWell(
borderRadius: BorderRadius.circular(Radii.md),
onTap: onTap,
onLongPress: () => _showChannelActions(context, ref),
child: Padding(
padding: const EdgeInsets.only(
left: _kChannelSectionInset,
right: _kChannelSectionInset,
top: _kChannelRowVerticalPadding,
bottom: _kChannelRowVerticalPadding,
),
child: Row(
children: [
SizedBox(
width: _kChannelLeadingWidth,
child: Align(
alignment: Alignment.centerLeft,
child: channel.isDm
? _DmAvatar(channel: channel, currentPubkey: currentPubkey)
: Icon(
channelIcon(channel),
key: ValueKey('channel-icon-${channel.id}'),
size: _kChannelIconSize,
color: contentColor,
),
),
),
const SizedBox(width: _kChannelLabelGap),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
resolveDmChannelDisplayLabel(
channel,
currentPubkey: currentPubkey,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: contentListTitleTextStyle.copyWith(
color: contentColor,
fontWeight: isUnread ? FontWeight.w700 : FontWeight.w400,
),
),
],
),
),
if (channel.isEphemeral) ...[
const SizedBox(width: Grid.xxs),
_EphemeralBadge(channel: channel),
],
if (isMuted) ...[
const SizedBox(width: Grid.xxs),
Icon(
LucideIcons.bellOff,
size: 12,
color: context.colors.onSurface.withValues(alpha: 0.4),
),
],
if (!channel.isMember && !channel.isDm)
Padding(
padding: const EdgeInsets.only(right: Grid.xxs),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: Grid.half + 2,
vertical: 3,
),
decoration: BoxDecoration(
color: context.colors.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(Radii.sm),
),
child: Text(
context.l10n.openChannel,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.primary,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
),
);
}
void _showChannelActions(BuildContext context, WidgetRef ref) {
showChannelActionsSheet(
context: context,
channel: channel,
isUnread: isUnread,
onMarkRead: onMarkRead,
sectionId: sectionId,
);
}
}
class _DmAvatar extends ConsumerWidget {
final Channel channel;
final String? currentPubkey;
const _DmAvatar({required this.channel, required this.currentPubkey});
@override
Widget build(BuildContext context, WidgetRef ref) {
final profiles = ref.watch(userCacheProvider);
final presenceMap = ref.watch(presenceCacheProvider);
final normalizedCurrent = currentPubkey?.toLowerCase();
final otherPubkeys = [
for (final pk in channel.participantPubkeys)
if (pk.toLowerCase() != normalizedCurrent) pk.toLowerCase(),
];
final visiblePubkeys = otherPubkeys.isNotEmpty
? otherPubkeys
: channel.participantPubkeys.map((pk) => pk.toLowerCase()).toList();
if (visiblePubkeys.length > 1) {
return Container(
width: _kDmAvatarSize,
height: _kDmAvatarSize,
alignment: Alignment.center,
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
shape: BoxShape.circle,
border: Border.all(color: context.colors.outlineVariant),
),
child: Text(
'${visiblePubkeys.length}',
style: context.textTheme.labelSmall?.copyWith(
fontSize: 9,
color: context.colors.onSurface,
fontWeight: FontWeight.w600,
height: 1,
),
),
);
}
final otherPubkey = visiblePubkeys.isNotEmpty ? visiblePubkeys.first : null;
final profile = otherPubkey != null ? profiles[otherPubkey] : null;
// Trigger fetches if not cached yet.
if (otherPubkey != null) {
if (profile == null) {
ref.read(userCacheProvider.notifier).preload([otherPubkey]);
}
ref.read(presenceCacheProvider.notifier).track([otherPubkey]);
}
final avatarUrl = profile?.avatarUrl;
final initial =
profile?.initial ??
(channel.participants.isNotEmpty
? channel.participants.first[0].toUpperCase()
: '?');
final presence = otherPubkey != null
? (presenceMap[otherPubkey] ?? 'offline')
: 'offline';
return SizedBox(
width: _kDmAvatarSize,
height: _kDmAvatarSize,
child: Stack(
clipBehavior: Clip.none,
children: [
AvatarImage(
imageUrl: avatarUrl,
radius: _kDmAvatarSize / 2,
backgroundColor: context.colors.primaryContainer,
fallback: Text(
initial,
style: context.textTheme.labelSmall?.copyWith(
fontSize: 9,
color: context.colors.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
),
Positioned(
right: -1,
bottom: -1,
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: _presenceColor(context, presence),
shape: BoxShape.circle,
border: Border.all(
color: context.theme.scaffoldBackgroundColor,
width: 1.5,
),
),
),
),
],
),
);
}
Color _presenceColor(BuildContext context, String presence) {
return switch (presence) {
'online' => context.appColors.success,
'away' => context.appColors.warning,
_ => context.colors.outline,
};
}
}
@@ -0,0 +1,552 @@
part of '../channels_page.dart';
class _CommunitySwitcherSheet extends HookConsumerWidget {
const _CommunitySwitcherSheet();
@override
Widget build(BuildContext context, WidgetRef ref) {
final communitiesAsync = ref.watch(communityListProvider);
final activeAsync = ref.watch(activeCommunityProvider);
final isEditing = useState(false);
return SafeArea(
child: Column(
key: const Key('community-switcher-sheet'),
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
Grid.xxs,
Grid.gutter,
Grid.xxs,
),
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 32),
child: Row(
children: [
Expanded(
child: Text(
context.l10n.switchCommunity,
key: const Key('community-switcher-title'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
Padding(
padding: const EdgeInsets.only(right: Grid.half),
child: ConstrainedBox(
key: const Key('community-switcher-edit'),
constraints: const BoxConstraints(
minWidth: 48,
minHeight: 32,
),
child: TextButton(
onPressed: () => isEditing.value = !isEditing.value,
style: TextButton.styleFrom(
minimumSize: Size.zero,
padding: const EdgeInsets.symmetric(
horizontal: Grid.half,
),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Text(isEditing.value ? context.l10n.done : context.l10n.edit),
),
),
),
],
),
),
),
Flexible(
child: communitiesAsync.when(
loading: () => SizedBox(
height: 120,
child: Center(
child: BuzzLoadingIndicator(
size: 40,
semanticLabel: context.l10n.loadingCommunities,
),
),
),
error: (e, _) => Padding(
padding: const EdgeInsets.all(Grid.xs),
child: Text(context.l10n.communityLoadFailed(e.toString())),
),
data: (communities) {
final activeId = activeAsync.value?.id;
return SingleChildScrollView(
padding: const EdgeInsets.only(bottom: Grid.xs),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.gutter,
),
child: Material(
key: const Key('community-switcher-options'),
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.card),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
for (
var index = 0;
index < communities.length;
index++
) ...[
if (index > 0) const _CommunitySwitcherDivider(),
_CommunitySwitcherTile(
community: communities[index],
isActive: communities[index].id == activeId,
isEditing: isEditing.value,
onTap: isEditing.value
? null
: () async {
final community = communities[index];
if (community.id != activeId) {
await ref
.read(
communityListProvider.notifier,
)
.switchCommunity(community.id);
}
if (context.mounted) {
Navigator.of(context).pop();
}
},
onRemove: () => _confirmRemoveCommunity(
context,
ref,
communities[index],
closeSheetAfterRemoval:
communities[index].id == activeId,
),
),
],
if (communities.isNotEmpty)
const _CommunitySwitcherDivider(),
_AddCommunityTile(
onTap: () {
final nav = Navigator.of(
context,
rootNavigator: true,
);
ref.read(pairingProvider.notifier).reset();
Navigator.of(context).pop();
nav.push(
MaterialPageRoute<void>(
builder: (_) =>
const PairingPage(addingCommunity: true),
),
);
},
),
],
),
),
),
);
},
),
),
],
),
);
}
}
const double _communitySwitcherAvatarSize = 36;
class _CommunitySwitcherDivider extends StatelessWidget {
const _CommunitySwitcherDivider();
@override
Widget build(BuildContext context) {
return Divider(
height: 1,
thickness: 1,
indent: Grid.xs + _communitySwitcherAvatarSize + Grid.xs,
endIndent: 0,
color: context.colors.onSurface.withValues(alpha: 0.12),
);
}
}
class _CommunitySwitcherTile extends StatelessWidget {
final Community community;
final bool isActive;
final bool isEditing;
final VoidCallback? onTap;
final VoidCallback onRemove;
const _CommunitySwitcherTile({
required this.community,
required this.isActive,
required this.isEditing,
required this.onTap,
required this.onRemove,
});
@override
Widget build(BuildContext context) {
final host = Uri.tryParse(community.relayUrl)?.host ?? community.relayUrl;
return InkWell(
key: Key('community-switcher-row-${community.id}'),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.xs,
Grid.twelve,
Grid.xxs,
Grid.twelve,
),
child: Row(
children: [
_CommunityAvatar(
key: Key('community-switcher-avatar-${community.id}'),
name: community.name,
relayUrl: community.relayUrl,
size: _communitySwitcherAvatarSize,
),
const SizedBox(width: Grid.xs),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
community.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: isActive
? FontWeight.w600
: FontWeight.normal,
),
),
const SizedBox(height: Grid.quarter),
Text(
host,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
),
),
const SizedBox(width: Grid.xxs),
_CommunityActionSwap(
key: Key('community-switcher-action-${community.id}'),
communityId: community.id,
communityName: community.name,
isActive: isActive,
isEditing: isEditing,
onRemove: onRemove,
),
],
),
),
);
}
}
const _communityActionSwapDuration = Duration(milliseconds: 250);
const double _communityActionSwapBlur = 2;
const double _communityActionSwapStartScale = 0.25;
class _CommunityActionSwap extends StatelessWidget {
const _CommunityActionSwap({
super.key,
required this.communityId,
required this.communityName,
required this.isActive,
required this.isEditing,
required this.onRemove,
});
final String communityId;
final String communityName;
final bool isActive;
final bool isEditing;
final VoidCallback onRemove;
@override
Widget build(BuildContext context) {
final reduceMotion =
MediaQuery.maybeOf(context)?.disableAnimations ?? false;
return SizedBox.square(
dimension: 40,
child: AnimatedSwitcher(
duration: reduceMotion ? Duration.zero : _communityActionSwapDuration,
reverseDuration: reduceMotion
? Duration.zero
: _communityActionSwapDuration,
transitionBuilder: (child, animation) {
final eased = CurvedAnimation(
parent: animation,
curve: Curves.easeInOut,
);
return AnimatedBuilder(
animation: eased,
child: child,
builder: (context, child) {
final progress = eased.value;
final blur = _communityActionSwapBlur * (1 - progress);
final scale =
_communityActionSwapStartScale +
((1 - _communityActionSwapStartScale) * progress);
return Opacity(
opacity: progress,
child: ImageFiltered(
enabled: blur > 0.01,
imageFilter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
child: Transform.scale(scale: scale, child: child),
),
);
},
);
},
child: isEditing
? IconButton(
key: ValueKey('community-switcher-remove-$communityId'),
tooltip: context.l10n.removeNamedCommunity(communityName),
visualDensity: VisualDensity.compact,
onPressed: onRemove,
icon: Icon(
LucideIcons.trash2,
size: 18,
color: context.colors.error,
),
)
: Center(
key: ValueKey('community-switcher-selection-$communityId'),
child: _CommunitySelectionIndicator(
key: ValueKey('community-switcher-circle-$communityId'),
communityName: communityName,
isActive: isActive,
),
),
),
);
}
}
class _CommunitySelectionIndicator extends StatelessWidget {
const _CommunitySelectionIndicator({
super.key,
required this.communityName,
required this.isActive,
});
final String communityName;
final bool isActive;
@override
Widget build(BuildContext context) {
return Semantics(
label: isActive
? context.l10n.currentCommunity(communityName)
: context.l10n.switchToCommunity(communityName),
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: isActive ? context.appColors.success : Colors.transparent,
border: isActive
? null
: Border.all(color: context.colors.outlineVariant, width: 2),
shape: BoxShape.circle,
),
child: isActive
? const Icon(LucideIcons.check, color: Colors.white, size: 16)
: null,
),
);
}
}
class _AddCommunityTile extends StatelessWidget {
const _AddCommunityTile({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
key: const Key('community-switcher-add'),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.xs,
vertical: Grid.twelve,
),
child: Row(
children: [
Container(
width: _communitySwitcherAvatarSize,
height: _communitySwitcherAvatarSize,
decoration: BoxDecoration(
color: context.colors.primaryContainer,
shape: BoxShape.circle,
),
child: Icon(
LucideIcons.plus,
size: 18,
color: context.colors.onPrimaryContainer,
),
),
const SizedBox(width: Grid.xs),
Text(
context.l10n.addCommunity,
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
),
],
),
),
);
}
}
Future<void> _confirmRemoveCommunity(
BuildContext context,
WidgetRef ref,
Community community, {
required bool closeSheetAfterRemoval,
}) async {
final confirmed = await showBuzzDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog.adaptive(
title: Text(context.l10n.removeCommunityQuestion),
content: Text(
context.l10n.removeCommunityConfirm(community.name),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(context.l10n.cancel),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
style: FilledButton.styleFrom(backgroundColor: context.colors.error),
child: Text(context.l10n.remove),
),
],
),
);
if (confirmed != true || !context.mounted) return;
final messenger = ScaffoldMessenger.of(context);
try {
await ref
.read(communityListProvider.notifier)
.removeCommunity(community.id);
if (closeSheetAfterRemoval && context.mounted) {
Navigator.of(context).pop();
}
} catch (e) {
messenger.showSnackBar(
SnackBar(content: Text(context.l10n.failedRemoveCommunity(e.toString()))),
);
}
}
class _CommunityIndicator extends ConsumerWidget {
final VoidCallback onTap;
const _CommunityIndicator({required this.onTap});
@override
Widget build(BuildContext context, WidgetRef ref) {
final activeAsync = ref.watch(activeCommunityProvider);
final activeCommunity = activeAsync.value;
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: _CommunityAvatar(
name: activeCommunity?.name,
relayUrl: activeCommunity?.relayUrl,
),
);
}
}
class _CommunityHeaderTitle extends ConsumerWidget {
final TextStyle? style;
final VoidCallback onTap;
const _CommunityHeaderTitle({required this.onTap, this.style});
@override
Widget build(BuildContext context, WidgetRef ref) {
final name = ref.watch(activeCommunityProvider).value?.name;
final title = name?.trim();
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: SizedBox.expand(
child: Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.only(left: Grid.xxs),
child: Text(
title == null || title.isEmpty ? context.l10n.community : title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: style,
),
),
),
),
);
}
}
class _CommunityAvatar extends ConsumerWidget {
final String? name;
final String? relayUrl;
final double size;
const _CommunityAvatar({
super.key,
required this.name,
this.relayUrl,
this.size = _kTopSectionAvatarSize,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final trimmedName = name?.trim();
final initial = trimmedName != null && trimmedName.isNotEmpty
? trimmedName.substring(0, 1).toUpperCase()
: '?';
final relay = relayUrl;
final iconUrl = relay == null
? null
: ref.watch(communityIconProvider(relay)).value;
return AvatarImage(
imageUrl: iconUrl,
radius: size / 2,
backgroundColor: context.colors.primaryContainer,
fallback: Text(
initial,
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
);
}
}
@@ -0,0 +1,342 @@
part of '../channels_page.dart';
const _kMorphOpenDuration = Duration(milliseconds: 280);
const _kMorphCloseDuration = Duration(milliseconds: 240);
const _kMorphFadeDuration = Duration(milliseconds: 200);
const _kMorphCloseCurve = Cubic(0.22, 1, 0.36, 1);
const double _kMorphOpenBounce = 0.14;
const double _kMorphCloseBounce = 0.06;
const double _kMorphClosedSize = 56;
const double _kMorphOpenHeight = 160;
const double _kMorphOpenRadius = 20;
const double _kMorphSlide = 40;
const double _kMorphScale = 0.97;
const double _kMorphBlur = 2;
const double _kQuickActionCardOverlayOpacity = 0.1;
const double _kQuickActionCardRadius = _kMorphOpenRadius - Grid.xxs;
class _MorphingQuickActionsButton extends HookWidget {
final bool open;
final double openEdgeOffset;
final VoidCallback onToggle;
final ValueChanged<_QuickAction> onSelected;
const _MorphingQuickActionsButton({
required this.open,
required this.openEdgeOffset,
required this.onToggle,
required this.onSelected,
});
@override
Widget build(BuildContext context) {
final reducedMotion = MediaQuery.of(context).disableAnimations;
final mediaQuery = MediaQuery.of(context);
final openWidth = (mediaQuery.size.width - (Grid.gutter * 2))
.clamp(_kMorphClosedSize, double.infinity)
.toDouble();
final surfaceController = useAnimationController(
initialValue: open ? 1 : 0,
upperBound: 1.02,
);
final fadeController = useAnimationController(
duration: reducedMotion ? Duration.zero : _kMorphFadeDuration,
reverseDuration: reducedMotion ? Duration.zero : _kMorphFadeDuration,
initialValue: open ? 1 : 0,
);
final fadeAnimation = useMemoized(
() => CurvedAnimation(
parent: fadeController,
curve: _kMorphCloseCurve,
reverseCurve: _kMorphCloseCurve,
),
[fadeController],
);
final animation = useMemoized(
() => Listenable.merge([surfaceController, fadeAnimation]),
[surfaceController, fadeAnimation],
);
useEffect(() => fadeAnimation.dispose, [fadeAnimation]);
useEffect(() {
fadeController.duration = reducedMotion
? Duration.zero
: _kMorphFadeDuration;
fadeController.reverseDuration = reducedMotion
? Duration.zero
: _kMorphFadeDuration;
if (reducedMotion) {
surfaceController.value = open ? 1 : 0;
fadeController.value = open ? 1 : 0;
} else {
final target = open ? 1.0 : 0.0;
if ((surfaceController.value - target).abs() > 0.001) {
unawaited(
surfaceController.animateWith(
SpringSimulation(
SpringDescription.withDurationAndBounce(
duration: open ? _kMorphOpenDuration : _kMorphCloseDuration,
bounce: open ? _kMorphOpenBounce : _kMorphCloseBounce,
),
surfaceController.value,
target,
surfaceController.velocity,
snapToEnd: true,
),
),
);
}
if (open) {
unawaited(fadeController.forward());
} else {
unawaited(fadeController.reverse());
}
}
return null;
}, [fadeController, open, reducedMotion, surfaceController]);
return AnimatedBuilder(
animation: animation,
builder: (context, _) {
final surfaceValue = surfaceController.value;
final surfaceProgress = surfaceValue.clamp(0.0, 1.0);
final fadeValue = fadeAnimation.value.clamp(0.0, 1.0);
final width = lerpDouble(_kMorphClosedSize, openWidth, surfaceValue)!;
final height = lerpDouble(
_kMorphClosedSize,
_kMorphOpenHeight,
surfaceValue,
)!;
final radius = lerpDouble(
_kMorphClosedSize / 2,
_kMorphOpenRadius,
surfaceValue,
)!;
final borderRadius = BorderRadius.circular(radius);
return Transform.translate(
offset: Offset(openEdgeOffset * surfaceProgress, 0),
child: SizedBox(
key: const Key('quick-actions-surface'),
width: width,
height: height,
child: DecoratedBox(
decoration: BoxDecoration(
color: context.colors.primary,
borderRadius: borderRadius,
boxShadow: [
BoxShadow(
color: context.colors.shadow.withValues(alpha: 0.24),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: ClipRRect(
borderRadius: borderRadius,
clipBehavior: Clip.antiAlias,
child: Material(
type: MaterialType.transparency,
child: Stack(
children: [
Positioned.fill(
child: OverflowBox(
alignment: Alignment.bottomRight,
minWidth: openWidth,
maxWidth: openWidth,
minHeight: _kMorphOpenHeight,
maxHeight: _kMorphOpenHeight,
child: IgnorePointer(
ignoring: !open || fadeValue < 0.9,
child: ExcludeSemantics(
excluding: !open,
child: Opacity(
opacity: fadeValue,
child: ImageFiltered(
imageFilter: ImageFilter.blur(
sigmaX: _kMorphBlur * (1 - fadeValue),
sigmaY: _kMorphBlur * (1 - fadeValue),
),
child: Transform.translate(
offset: Offset(
_kMorphSlide * (1 - surfaceProgress),
0,
),
child: Transform.scale(
alignment: Alignment.bottomRight,
scale:
_kMorphScale +
((1 - _kMorphScale) *
surfaceProgress),
child: _QuickActionsMenu(
onSelected: onSelected,
),
),
),
),
),
),
),
),
),
Positioned(
right: 0,
bottom: 0,
width: _kMorphClosedSize,
height: _kMorphClosedSize,
child: IgnorePointer(
ignoring: open,
child: ExcludeSemantics(
excluding: open,
child: Opacity(
opacity: 1 - fadeValue,
child: ImageFiltered(
imageFilter: ImageFilter.blur(
sigmaX: _kMorphBlur * fadeValue,
sigmaY: _kMorphBlur * fadeValue,
),
child: Transform.translate(
offset: Offset(
-_kMorphSlide * surfaceProgress,
0,
),
child: Transform.rotate(
angle: (pi / 4) * surfaceProgress,
child: Transform.scale(
scale:
1 -
((1 - _kMorphScale) *
surfaceProgress),
child: Tooltip(
message:
context.l10n.createOrStartConversation,
child: Semantics(
button: true,
label:
context.l10n.createOrStartConversation,
expanded: open,
child: InkWell(
customBorder: const CircleBorder(),
onTap: onToggle,
child: Center(
child: Icon(
LucideIcons.plus,
color: context.colors.onPrimary,
),
),
),
),
),
),
),
),
),
),
),
),
),
],
),
),
),
),
),
);
},
);
}
}
class _QuickActionsMenu extends StatelessWidget {
final ValueChanged<_QuickAction> onSelected;
const _QuickActionsMenu({required this.onSelected});
@override
Widget build(BuildContext context) {
return Padding(
key: const Key('quick-actions-menu'),
padding: const EdgeInsets.all(Grid.xxs),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_QuickActionItem(
icon: LucideIcons.hash,
title: context.l10n.createChannel,
key: const Key('quick-action-create-channel-card'),
onTap: () => onSelected(_QuickAction.createChannel),
),
const SizedBox(height: Grid.xxs),
_QuickActionItem(
icon: LucideIcons.messagesSquare,
title: context.l10n.newDirectMessage,
key: const Key('quick-action-new-dm-card'),
onTap: () => onSelected(_QuickAction.newDm),
),
],
),
);
}
}
class _QuickActionItem extends StatelessWidget {
final IconData icon;
final String title;
final VoidCallback onTap;
const _QuickActionItem({
super.key,
required this.icon,
required this.title,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final foreground = context.colors.onPrimary;
final background = Color.alphaBlend(
foreground.withValues(alpha: _kQuickActionCardOverlayOpacity),
context.colors.primary,
);
return Expanded(
child: Material(
color: background,
borderRadius: BorderRadius.circular(_kQuickActionCardRadius),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.xs),
child: Row(
children: [
Icon(icon, size: 24, color: foreground),
const SizedBox(width: Grid.xs),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodyLarge?.copyWith(
color: foreground,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
),
),
),
),
);
}
}
@@ -0,0 +1,184 @@
part of '../channels_page.dart';
const _kQuickActionsTabMotionDuration = Duration(milliseconds: 220);
const _kQuickActionsTabMotionCurve = Cubic(0.77, 0, 0.175, 1);
const _kQuickActionsHiddenOverlap = Grid.half;
const _kQuickActionsHiddenScale = 0.8;
/// Places the channel quick-actions button beside mobile navigation.
///
/// The button remains available only on Home and moves behind the navigation
/// bar when another destination is selected.
class ChannelQuickActionsLauncher extends HookConsumerWidget {
/// Whether the launcher should be visible beside the navigation bar.
final bool visible;
/// Height of the navigation bar used to vertically center the closed button.
final double navigationBarHeight;
/// Space between the navigation bar and the bottom safe area.
final double navigationBarBottomGap;
/// Full width of the navigation bar, including its inner edge padding.
final double navigationBarWidth;
/// Bottom system inset used by the navigation bar's [SafeArea].
final double systemBottomInset;
/// Distance between the launcher and the right edge of the screen.
final double rightInset;
/// Creates a launcher aligned with the supplied navigation geometry.
const ChannelQuickActionsLauncher({
super.key,
required this.visible,
required this.navigationBarHeight,
required this.navigationBarBottomGap,
required this.navigationBarWidth,
required this.systemBottomInset,
required this.rightInset,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentPubkey = ref.watch(currentPubkeyProvider);
final quickActionsOpen = useState(false);
final reducedMotion = MediaQuery.of(context).disableAnimations;
final navigationBottomInset = systemBottomInset > navigationBarBottomGap
? systemBottomInset
: navigationBarBottomGap;
final closedBottomInset =
navigationBottomInset + ((navigationBarHeight - _kMorphClosedSize) / 2);
final openLift =
navigationBarHeight +
Grid.xxs -
((navigationBarHeight - _kMorphClosedSize) / 2);
final effectiveOpen = visible && quickActionsOpen.value;
final screenWidth = MediaQuery.sizeOf(context).width;
final navigationBarRight = (screenWidth + navigationBarWidth) / 2;
final launcherRight = screenWidth - rightInset;
final hiddenHorizontalOffset =
navigationBarRight - launcherRight - _kQuickActionsHiddenOverlap;
useEffect(() {
if (!visible && quickActionsOpen.value) {
quickActionsOpen.value = false;
}
return null;
}, [visible]);
Future<void> openChannel(Channel channel) async {
if (!context.mounted) return;
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ChannelDetailPage(channel: channel),
),
);
}
Future<void> selectQuickAction(_QuickAction action) async {
quickActionsOpen.value = false;
if (!reducedMotion) {
await Future<void>.delayed(_kMorphCloseDuration);
}
if (!context.mounted) return;
switch (action) {
case _QuickAction.createChannel:
final created = await showBuzzModalBottomSheet<Channel>(
context: context,
constraints: _quickActionSheetConstraints(context),
isScrollControlled: true,
showDragHandle: true,
builder: (_) => const _CreateChannelSheet(channelType: 'stream'),
);
if (created != null && context.mounted) {
await openChannel(created);
}
case _QuickAction.newDm:
final opened = await showBuzzModalBottomSheet<Channel>(
context: context,
constraints: _quickActionSheetConstraints(context),
isScrollControlled: true,
showDragHandle: true,
builder: (_) =>
_NewDirectMessageSheet(currentPubkey: currentPubkey),
);
if (opened != null && context.mounted) {
await openChannel(opened);
}
}
}
return Stack(
fit: StackFit.expand,
clipBehavior: Clip.none,
children: [
if (quickActionsOpen.value)
Positioned.fill(
child: Semantics(
button: true,
label: context.l10n.closeQuickActions,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => quickActionsOpen.value = false,
),
),
),
AnimatedPositioned(
duration: reducedMotion
? Duration.zero
: _kQuickActionsTabMotionDuration,
curve: _kQuickActionsTabMotionCurve,
right: rightInset,
bottom: closedBottomInset + (effectiveOpen ? openLift : 0),
child: IgnorePointer(
ignoring: !visible,
child: ExcludeSemantics(
excluding: !visible,
child: TweenAnimationBuilder<double>(
key: const Key('channel-quick-actions-motion'),
tween: Tween(end: visible ? 0 : 1),
duration: reducedMotion
? Duration.zero
: _kQuickActionsTabMotionDuration,
curve: _kQuickActionsTabMotionCurve,
builder: (context, hiddenProgress, child) => Opacity(
key: const Key('channel-quick-actions-opacity'),
opacity: 1 - hiddenProgress,
child: Transform.translate(
key: const Key('channel-quick-actions-transform'),
offset: Offset(hiddenHorizontalOffset * hiddenProgress, 0),
child: Transform.scale(
key: const Key('channel-quick-actions-scale'),
scale:
1 -
((1 - _kQuickActionsHiddenScale) * hiddenProgress),
child: child,
),
),
),
child: _MorphingQuickActionsButton(
open: effectiveOpen,
openEdgeOffset: rightInset - Grid.gutter,
onToggle: () {
unawaited(HapticFeedback.lightImpact());
quickActionsOpen.value = !quickActionsOpen.value;
},
onSelected: (action) => unawaited(selectQuickAction(action)),
),
),
),
),
),
],
);
}
}
BoxConstraints _quickActionSheetConstraints(BuildContext context) {
final mediaQuery = MediaQuery.of(context);
return BoxConstraints(
maxHeight: mediaQuery.size.height - mediaQuery.viewPadding.top - Grid.sm,
);
}
@@ -0,0 +1,722 @@
part of '../channels_page.dart';
const _sectionMenuItemPadding = EdgeInsets.fromLTRB(Grid.xs, 0, Grid.twelve, 0);
class _CustomChannelSection extends StatelessWidget {
final ChannelSection section;
final List<Channel> channels;
final Set<String> unreadChannelIds;
final Set<String> mutedChannelIds;
final String? currentPubkey;
final bool expanded;
final bool isFirst;
final bool isLast;
final bool showTopDivider;
final VoidCallback onToggle;
final VoidCallback onRename;
final VoidCallback onDelete;
final VoidCallback onMoveUp;
final VoidCallback onMoveDown;
final ChannelSortMode sortMode;
final ValueChanged<ChannelSortMode> onSortModeChange;
final Future<void> Function(Channel channel) onSelectChannel;
final void Function(Channel channel) onMarkChannelRead;
const _CustomChannelSection({
required this.section,
required this.channels,
required this.unreadChannelIds,
required this.mutedChannelIds,
required this.currentPubkey,
required this.expanded,
required this.isFirst,
required this.isLast,
required this.showTopDivider,
required this.onToggle,
required this.onRename,
required this.onDelete,
required this.onMoveUp,
required this.onMoveDown,
required this.sortMode,
required this.onSortModeChange,
required this.onSelectChannel,
required this.onMarkChannelRead,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showTopDivider) const _SectionDivider(),
_CustomSectionHeader(
section: section,
expanded: expanded,
isFirst: isFirst,
isLast: isLast,
onToggle: onToggle,
onRename: onRename,
onDelete: onDelete,
onMoveUp: onMoveUp,
onMoveDown: onMoveDown,
sortMode: sortMode,
onSortModeChange: onSortModeChange,
),
_AnimatedSectionBody(
expanded: expanded,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final channel in channels)
_ChannelTile(
channel: channel,
isUnread: unreadChannelIds.contains(channel.id),
isMuted: mutedChannelIds.contains(channel.id),
currentPubkey: currentPubkey,
onTap: () => onSelectChannel(channel),
onMarkRead: () => onMarkChannelRead(channel),
sectionId: section.id,
),
const SizedBox(height: _kExpandedSectionTrailingPadding),
],
),
),
],
);
}
}
class _CustomSectionHeader extends ConsumerWidget {
final ChannelSection section;
final bool expanded;
final bool isFirst;
final bool isLast;
final VoidCallback onToggle;
final VoidCallback onRename;
final VoidCallback onDelete;
final VoidCallback onMoveUp;
final VoidCallback onMoveDown;
final ChannelSortMode sortMode;
final ValueChanged<ChannelSortMode> onSortModeChange;
const _CustomSectionHeader({
required this.section,
required this.expanded,
required this.isFirst,
required this.isLast,
required this.onToggle,
required this.onRename,
required this.onDelete,
required this.onMoveUp,
required this.onMoveDown,
required this.sortMode,
required this.onSortModeChange,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final sectionColor = navigationSectionForeground(context);
final icon = section.icon;
final customEmoji = icon == null
? null
: _resolveCustomEmoji(icon, ref.watch(customEmojiListProvider));
return GestureDetector(
onTap: onToggle,
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
_kSectionHeaderVerticalPadding,
Grid.gutter,
_kSectionHeaderVerticalPadding,
),
child: Row(
children: [
SizedBox(
width: _kChannelLeadingWidth,
child: Align(
alignment: Alignment.centerLeft,
child: icon == null || icon.isEmpty
? Icon(
LucideIcons.folder,
size: _kChannelIconSize,
color: sectionColor,
)
: customEmoji != null
? CustomEmojiImage(
shortcode: customEmoji.shortcode,
url: customEmoji.url,
size: _kChannelIconSize,
)
: Text(
icon,
maxLines: 1,
overflow: TextOverflow.visible,
style: TextStyle(
fontSize: _kChannelIconSize,
height: 1,
color: sectionColor,
),
),
),
),
const SizedBox(width: _kChannelLabelGap),
Expanded(
child: Text(
section.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: contentListTitleTextStyle.copyWith(
color: sectionColor,
fontWeight: FontWeight.w600,
),
),
),
Builder(
builder: (buttonContext) => IconButton(
key: ValueKey('section-menu-${section.id}'),
tooltip: context.l10n.optionsFor(section.name),
visualDensity: VisualDensity.compact,
icon: Icon(
LucideIcons.ellipsisVertical,
size: _kChannelIconSize,
color: sectionColor,
),
onPressed: () async {
final value = await showAnchoredPopover<String>(
context: buttonContext,
width: 216,
alignment: AnchoredPopoverAlignment.end,
surfaceKey: ValueKey('section-popover-${section.id}'),
items: [
PopupMenuItem(
value: 'rename',
padding: _sectionMenuItemPadding,
child: _SectionMenuItemContent(
icon: LucideIcons.pencil,
label: context.l10n.renameSection,
),
),
PopupMenuItem(
value: 'move_up',
enabled: !isFirst,
padding: _sectionMenuItemPadding,
child: _SectionMenuItemContent(
icon: LucideIcons.arrowUp,
label: context.l10n.moveUp,
),
),
PopupMenuItem(
value: 'move_down',
enabled: !isLast,
padding: _sectionMenuItemPadding,
child: _SectionMenuItemContent(
icon: LucideIcons.arrowDown,
label: context.l10n.moveDown,
),
),
..._sortMenuItems(sortMode, l10n: context.l10n),
PopupMenuItem(
value: 'delete',
padding: _sectionMenuItemPadding,
child: _SectionMenuItemContent(
icon: LucideIcons.trash2,
label: context.l10n.deleteSection,
color: context.colors.error,
),
),
],
);
switch (value) {
case 'rename':
onRename();
case 'move_up':
onMoveUp();
case 'move_down':
onMoveDown();
case _kSortRecentMenuValue:
onSortModeChange(ChannelSortMode.recent);
case _kSortAlphaMenuValue:
onSortModeChange(ChannelSortMode.alpha);
case 'delete':
onDelete();
}
},
),
),
const SizedBox(width: Grid.quarter),
_SectionChevron(expanded: expanded, color: sectionColor),
],
),
),
);
}
}
class _SectionMenuItemContent extends StatelessWidget {
final IconData icon;
final String label;
final Color? color;
const _SectionMenuItemContent({
required this.icon,
required this.label,
this.color,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
Icon(icon, size: 16, color: color),
const SizedBox(width: Grid.xxs),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: color == null ? null : TextStyle(color: color),
),
),
],
);
}
}
CustomEmoji? _resolveCustomEmoji(String icon, List<CustomEmoji> palette) {
if (!icon.startsWith(':') || !icon.endsWith(':')) return null;
final shortcode = normalizeShortcode(icon);
if (shortcode == null) return null;
for (final emoji in palette) {
if (emoji.shortcode == shortcode) return emoji;
}
return null;
}
class _SectionNameDialog extends HookWidget {
final String title;
final String confirmLabel;
final String initialValue;
const _SectionNameDialog({
required this.title,
required this.confirmLabel,
this.initialValue = '',
});
@override
Widget build(BuildContext context) {
final controller = useTextEditingController(text: initialValue);
void confirm() {
final name = controller.text.trim();
if (name.isNotEmpty) Navigator.of(context).pop(name);
}
return AlertDialog(
title: Text(title),
content: TextField(
controller: controller,
autofocus: true,
decoration: InputDecoration(labelText: context.l10n.sectionName),
onSubmitted: (_) => confirm(),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(context.l10n.cancel),
),
TextButton(onPressed: confirm, child: Text(confirmLabel)),
],
);
}
}
const _kSortRecentMenuValue = 'sort_recent';
const _kSortAlphaMenuValue = 'sort_alpha';
PopupMenuItem<String> _sortMenuItem({
required String value,
required String label,
required bool selected,
}) => PopupMenuItem(
value: value,
child: Row(
children: [
Expanded(child: Text(label)),
if (selected)
const Icon(LucideIcons.check, key: ValueKey('sort-selected-check'))
else
const SizedBox(width: 24),
],
),
);
List<PopupMenuEntry<String>> _sortMenuItems(
ChannelSortMode current, {
required AppLocalizations l10n,
bool showDivider = true,
}) => [
if (showDivider) const PopupMenuDivider(),
_sortMenuItem(
value: _kSortRecentMenuValue,
label: l10n.sortRecent,
selected: current == ChannelSortMode.recent,
),
_sortMenuItem(
value: _kSortAlphaMenuValue,
label: l10n.sortAlpha,
selected: current == ChannelSortMode.alpha,
),
];
class _ChannelSection extends StatelessWidget {
final String title;
final IconData icon;
final bool expanded;
final VoidCallback onToggle;
final List<Channel> channels;
final bool showTopDivider;
final Set<String> unreadChannelIds;
final Set<String> mutedChannelIds;
final String? currentPubkey;
final String emptyLabel;
final ChannelSortMode? sortMode;
final ValueChanged<ChannelSortMode>? onSortModeChange;
final Future<void> Function(Channel channel) onSelectChannel;
const _ChannelSection({
required this.title,
required this.icon,
required this.expanded,
required this.onToggle,
required this.channels,
required this.showTopDivider,
required this.unreadChannelIds,
required this.mutedChannelIds,
required this.currentPubkey,
required this.emptyLabel,
this.sortMode,
this.onSortModeChange,
required this.onSelectChannel,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showTopDivider) const _SectionDivider(),
_SectionHeader(
label: title,
icon: icon,
expanded: expanded,
onToggle: onToggle,
sortMode: sortMode,
onSortModeChange: onSortModeChange,
),
_AnimatedSectionBody(
expanded: expanded,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (channels.isEmpty)
Padding(
padding: const EdgeInsets.only(
left: _kChannelLabelInset,
right: _kChannelSectionInset,
top: Grid.half,
bottom: Grid.half,
),
child: Text(
emptyLabel,
style: contentListBodyTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
),
),
)
else
for (final channel in channels)
_ChannelTile(
channel: channel,
isUnread: unreadChannelIds.contains(channel.id),
isMuted: mutedChannelIds.contains(channel.id),
currentPubkey: currentPubkey,
onTap: () => onSelectChannel(channel),
onMarkRead: null,
sectionId: null,
),
const SizedBox(height: _kExpandedSectionTrailingPadding),
],
),
),
],
);
}
}
class _EmptyState extends StatelessWidget {
const _EmptyState();
@override
Widget build(BuildContext context) {
return SizedBox(
height: MediaQuery.sizeOf(context).height * 0.55,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.messagesSquare,
size: Grid.xl,
color: context.colors.onSurfaceVariant,
),
const SizedBox(height: Grid.xs),
Text(
context.l10n.noConversations,
style: context.textTheme.bodyLarge?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
),
),
);
}
}
class _SectionDivider extends StatelessWidget {
const _SectionDivider();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
child: Divider(
height: 1,
thickness: 1,
indent: _kChannelSectionInset,
endIndent: _kChannelSectionInset,
color: context.colors.primary.withValues(alpha: 0.15),
),
);
}
}
class _SectionHeader extends StatelessWidget {
final String label;
final IconData icon;
final bool expanded;
final VoidCallback onToggle;
final ChannelSortMode? sortMode;
final ValueChanged<ChannelSortMode>? onSortModeChange;
const _SectionHeader({
required this.label,
required this.icon,
required this.expanded,
required this.onToggle,
this.sortMode,
this.onSortModeChange,
});
@override
Widget build(BuildContext context) {
final sectionColor = navigationSectionForeground(context);
return GestureDetector(
onTap: onToggle,
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
_kSectionHeaderVerticalPadding,
Grid.gutter,
_kSectionHeaderVerticalPadding,
),
child: Row(
children: [
SizedBox(
width: _kChannelLeadingWidth,
child: Align(
alignment: Alignment.centerLeft,
child: Icon(icon, size: _kChannelIconSize, color: sectionColor),
),
),
const SizedBox(width: _kChannelLabelGap),
Text(
label,
style: contentListTitleTextStyle.copyWith(
color: sectionColor,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
if (sortMode case final mode?) ...[
Builder(
builder: (buttonContext) => IconButton(
key: ValueKey('sort-menu-$label'),
tooltip: context.l10n.optionsFor(label),
visualDensity: VisualDensity.compact,
icon: Icon(
LucideIcons.ellipsisVertical,
size: _kChannelIconSize,
color: sectionColor,
),
onPressed: () async {
final value = await showAnchoredPopover<String>(
context: buttonContext,
width: 216,
alignment: AnchoredPopoverAlignment.end,
color: context.colors.surface,
elevation: 4,
shadowColor: context.colors.shadow.withValues(
alpha: 0.18,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.md),
side: BorderSide(color: context.colors.outline),
),
surfaceKey: ValueKey('sort-popover-$label'),
items: _sortMenuItems(
mode,
l10n: context.l10n,
showDivider: false,
),
);
if (value == _kSortRecentMenuValue) {
onSortModeChange?.call(ChannelSortMode.recent);
} else if (value == _kSortAlphaMenuValue) {
onSortModeChange?.call(ChannelSortMode.alpha);
}
},
),
),
const SizedBox(width: Grid.quarter),
],
_SectionChevron(expanded: expanded, color: sectionColor),
],
),
),
);
}
}
class _SectionChevron extends StatelessWidget {
final bool expanded;
final Color color;
const _SectionChevron({required this.expanded, required this.color});
@override
Widget build(BuildContext context) {
final reducedMotion = MediaQuery.of(context).disableAnimations;
return AnimatedRotation(
turns: expanded ? 0 : -0.25,
duration: reducedMotion ? Duration.zero : _kSectionExpandDuration,
curve: _kSectionExpandCurve,
child: Icon(
LucideIcons.chevronDown,
size: _kChannelIconSize,
color: color,
),
);
}
}
class _AnimatedSectionBody extends HookWidget {
final bool expanded;
final Widget child;
const _AnimatedSectionBody({required this.expanded, required this.child});
@override
Widget build(BuildContext context) {
final reducedMotion = MediaQuery.of(context).disableAnimations;
final controller = useAnimationController(
duration: reducedMotion ? Duration.zero : _kSectionExpandDuration,
reverseDuration: reducedMotion
? Duration.zero
: _kSectionCollapseDuration,
initialValue: expanded ? 1 : 0,
);
final curvedAnimation = useMemoized(
() => CurvedAnimation(
parent: controller,
curve: _kSectionExpandCurve,
reverseCurve: _kSectionCollapseCurve,
),
[controller],
);
final shouldRender = useState(expanded);
useEffect(() => curvedAnimation.dispose, [curvedAnimation]);
useEffect(() {
void handleStatus(AnimationStatus status) {
if (status == AnimationStatus.dismissed && !expanded) {
shouldRender.value = false;
}
}
controller.addStatusListener(handleStatus);
return () => controller.removeStatusListener(handleStatus);
}, [controller, expanded]);
useEffect(() {
controller.duration = reducedMotion
? Duration.zero
: _kSectionExpandDuration;
controller.reverseDuration = reducedMotion
? Duration.zero
: _kSectionCollapseDuration;
if (expanded) {
shouldRender.value = true;
if (reducedMotion) {
controller.value = 1;
} else {
unawaited(controller.forward());
}
} else if (reducedMotion) {
controller.value = 0;
shouldRender.value = false;
} else {
unawaited(controller.reverse());
}
return null;
}, [controller, expanded, reducedMotion]);
return ClipRect(
child: AnimatedBuilder(
animation: curvedAnimation,
child: shouldRender.value ? child : const SizedBox.shrink(),
builder: (context, child) {
final value = curvedAnimation.value.clamp(0.0, 1.0);
final scaleY =
_kSectionCollapsedScaleY +
((1 - _kSectionCollapsedScaleY) * value);
return Align(
alignment: Alignment.topCenter,
heightFactor: value,
child: Opacity(
opacity: value,
child: Transform.scale(
alignment: Alignment.topCenter,
scaleY: scaleY,
child: ExcludeSemantics(
excluding: !expanded,
child: IgnorePointer(ignoring: !expanded, child: child),
),
),
),
);
},
),
);
}
}
@@ -0,0 +1,909 @@
part of '../channels_page.dart';
const int _defaultCreateChannelTtlSeconds = 7 * 24 * 60 * 60;
class _CreateChannelMenuOption<T> {
final Key? key;
final String label;
final T value;
const _CreateChannelMenuOption({
this.key,
required this.label,
required this.value,
});
}
const _createChannelTtlOptions = [
_CreateChannelMenuOption(label: '30 minutes', value: 30 * 60),
_CreateChannelMenuOption(label: '1 hour', value: 60 * 60),
_CreateChannelMenuOption(label: '6 hours', value: 6 * 60 * 60),
_CreateChannelMenuOption(label: '12 hours', value: 12 * 60 * 60),
_CreateChannelMenuOption(label: '1 day', value: 24 * 60 * 60),
_CreateChannelMenuOption(label: '3 days', value: 3 * 24 * 60 * 60),
_CreateChannelMenuOption(
label: '7 days',
value: _defaultCreateChannelTtlSeconds,
),
_CreateChannelMenuOption(label: '14 days', value: 14 * 24 * 60 * 60),
_CreateChannelMenuOption(label: '30 days', value: 30 * 24 * 60 * 60),
];
String _localizedTtlLabel(BuildContext context, int seconds) {
final l10n = context.l10n;
if (seconds % (24 * 60 * 60) == 0) {
return l10n.durationDays(seconds ~/ (24 * 60 * 60));
}
if (seconds % (60 * 60) == 0) {
return l10n.durationHours(seconds ~/ (60 * 60));
}
return l10n.durationMinutes(seconds ~/ 60);
}
class _CreateChannelSheet extends HookConsumerWidget {
final String channelType;
const _CreateChannelSheet({required this.channelType});
@override
Widget build(BuildContext context, WidgetRef ref) {
final nameController = useTextEditingController();
final descriptionController = useTextEditingController();
final visibility = useState('open');
final temporary = useState(false);
final ttlSeconds = useState(_defaultCreateChannelTtlSeconds);
final isSubmitting = useState(false);
final errorMessage = useState<String?>(null);
useListenable(nameController);
final kindLabel = channelType == 'forum'
? context.l10n.forum
: context.l10n.channel;
final ttlOptions = [
for (final option in _createChannelTtlOptions)
_CreateChannelMenuOption(
key: option.key,
label: _localizedTtlLabel(context, option.value),
value: option.value,
),
];
final canSubmit =
nameController.text.trim().isNotEmpty && !isSubmitting.value;
Future<void> submit() async {
final name = nameController.text.trim();
if (name.isEmpty || isSubmitting.value) {
return;
}
isSubmitting.value = true;
errorMessage.value = null;
try {
final created = await ref
.read(channelActionsProvider)
.createChannel(
name: name,
channelType: channelType,
visibility: visibility.value,
description: descriptionController.text.trim(),
ttlSeconds: temporary.value ? ttlSeconds.value : null,
);
if (context.mounted) {
Navigator.of(context).pop(created);
}
} catch (error) {
errorMessage.value = error.toString();
} finally {
isSubmitting.value = false;
}
}
return Padding(
padding: EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
MediaQuery.viewInsetsOf(context).bottom + Grid.xs,
),
child: SafeArea(
top: false,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.createNewKind(kindLabel),
style: context.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
letterSpacing: -0.3,
),
),
const SizedBox(height: Grid.sm),
_CreateChannelFieldLabel(label: context.l10n.sectionName),
const SizedBox(height: Grid.xxs),
_CreateChannelFieldShell(
child: TextField(
key: const Key('create-channel-name'),
controller: nameController,
enabled: !isSubmitting.value,
autofocus: true,
autocorrect: false,
enableSuggestions: false,
textCapitalization: TextCapitalization.none,
decoration: InputDecoration(
hintText: channelType == 'forum'
? context.l10n.forumNameHint
: context.l10n.channelNameHint,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(
horizontal: Grid.twelve,
vertical: Grid.xs,
),
),
textInputAction: TextInputAction.done,
onSubmitted: (_) {
if (canSubmit) {
unawaited(submit());
}
},
),
),
const SizedBox(height: Grid.xs),
_CreateChannelFieldLabel(
label: context.l10n.description,
optional: true,
),
const SizedBox(height: Grid.xxs),
_CreateChannelFieldShell(
child: TextField(
key: const Key('create-channel-description'),
controller: descriptionController,
enabled: !isSubmitting.value,
decoration: InputDecoration(
hintText: context.l10n.channelPurposeHint(kindLabel),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(
horizontal: Grid.twelve,
vertical: Grid.xs,
),
),
minLines: 2,
maxLines: 3,
textInputAction: TextInputAction.done,
onSubmitted: (_) {
if (canSubmit) {
unawaited(submit());
}
},
),
),
const SizedBox(height: Grid.sm),
_CreateChannelRadioGroup<bool>(
enabled: !isSubmitting.value,
label: context.l10n.channelType,
onSelected: (value) => temporary.value = value,
options: [
_CreateChannelMenuOption(
key: Key('create-channel-type-ongoing'),
label: context.l10n.ongoing,
value: false,
),
_CreateChannelMenuOption(
key: Key('create-channel-type-temporary'),
label: context.l10n.temporary,
value: true,
),
],
value: temporary.value,
),
if (temporary.value) ...[
const SizedBox(height: Grid.xs),
_CreateChannelSettingMenu<int>(
controlKey: const Key('create-channel-ttl'),
enabled: !isSubmitting.value,
label: context.l10n.expiresAfter,
onSelected: (value) => ttlSeconds.value = value,
options: ttlOptions,
value: ttlSeconds.value,
valueLabel: ttlOptions
.firstWhere((option) => option.value == ttlSeconds.value)
.label,
),
],
const SizedBox(height: Grid.sm),
_CreateChannelRadioGroup<String>(
enabled: !isSubmitting.value,
label: context.l10n.visibility,
onSelected: (value) => visibility.value = value,
options: [
_CreateChannelMenuOption(
key: Key('create-channel-visibility-public'),
label: context.l10n.publicVisibility,
value: 'open',
),
_CreateChannelMenuOption(
key: Key('create-channel-visibility-private'),
label: context.l10n.privateVisibility,
value: 'private',
),
],
value: visibility.value,
),
if (errorMessage.value case final error?) ...[
const SizedBox(height: Grid.xs),
Text(
error,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.error,
),
),
],
],
),
),
),
);
}
}
class _CreateChannelFieldLabel extends StatelessWidget {
final String label;
final bool optional;
const _CreateChannelFieldLabel({required this.label, this.optional = false});
@override
Widget build(BuildContext context) {
return Text.rich(
TextSpan(
children: [
TextSpan(text: label),
if (optional)
TextSpan(
text: ' ${context.l10n.optional}',
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant.withValues(alpha: 0.6),
fontWeight: FontWeight.w400,
),
),
],
),
style: context.textTheme.labelLarge?.copyWith(
color: context.colors.onSurface,
fontWeight: FontWeight.w600,
),
);
}
}
class _CreateChannelFieldShell extends StatelessWidget {
final Widget child;
const _CreateChannelFieldShell({required this.child});
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
color: context.colors.primaryContainer.withValues(alpha: 0.55),
border: Border.all(color: context.colors.outlineVariant),
borderRadius: BorderRadius.circular(Radii.lg),
),
child: child,
);
}
}
class _CreateChannelRadioGroup<T> extends StatelessWidget {
final bool enabled;
final String label;
final ValueChanged<T> onSelected;
final List<_CreateChannelMenuOption<T>> options;
final T value;
const _CreateChannelRadioGroup({
required this.enabled,
required this.label,
required this.onSelected,
required this.options,
required this.value,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_CreateChannelFieldLabel(label: label),
const SizedBox(height: Grid.xxs),
DecoratedBox(
decoration: BoxDecoration(
color: context.colors.surface,
border: Border.all(color: context.colors.outlineVariant),
borderRadius: BorderRadius.circular(Radii.lg),
),
child: RadioGroup<T>(
groupValue: value,
onChanged: (nextValue) {
if (enabled && nextValue != null) {
onSelected(nextValue);
}
},
child: Column(
children: [
for (final (index, option) in options.indexed) ...[
RadioListTile<T>(
key: option.key,
value: option.value,
enabled: enabled,
selected: option.value == value,
controlAffinity: ListTileControlAffinity.leading,
contentPadding: const EdgeInsets.symmetric(
horizontal: Grid.xs,
vertical: Grid.half,
),
visualDensity: VisualDensity.standard,
activeColor: context.colors.primary,
title: Text(
option.label,
style: context.textTheme.bodyMedium?.copyWith(
color: enabled
? context.colors.onSurface
: context.colors.onSurface.withValues(alpha: 0.38),
fontWeight: option.value == value
? FontWeight.w600
: FontWeight.w400,
),
),
),
if (index < options.length - 1)
Divider(
height: 1,
indent: Grid.xs,
endIndent: Grid.xs,
color: context.colors.outlineVariant,
),
],
],
),
),
),
],
);
}
}
class _CreateChannelSettingMenu<T> extends StatelessWidget {
final Key controlKey;
final bool enabled;
final String label;
final ValueChanged<T> onSelected;
final List<_CreateChannelMenuOption<T>> options;
final T value;
final IconData? valueIcon;
final String valueLabel;
const _CreateChannelSettingMenu({
required this.controlKey,
required this.enabled,
required this.label,
required this.onSelected,
required this.options,
required this.value,
this.valueIcon,
required this.valueLabel,
});
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
color: context.colors.surface,
border: Border.all(color: context.colors.outlineVariant),
borderRadius: BorderRadius.circular(Radii.lg),
),
child: PopupMenuButton<T>(
enabled: enabled,
initialValue: value,
onSelected: onSelected,
tooltip: '$label: $valueLabel',
itemBuilder: (context) => [
for (final option in options)
CheckedPopupMenuItem<T>(
checked: option.value == value,
value: option.value,
child: Text(option.label),
),
],
child: Padding(
key: controlKey,
padding: const EdgeInsets.symmetric(
horizontal: Grid.twelve,
vertical: Grid.xs,
),
child: Row(
children: [
Expanded(
child: Text(
label,
style: context.textTheme.labelLarge?.copyWith(
color: context.colors.onSurface,
fontWeight: FontWeight.w600,
),
),
),
if (valueIcon case final icon?) ...[
Icon(icon, size: 16, color: context.colors.onSurfaceVariant),
const SizedBox(width: Grid.half),
],
Text(
valueLabel,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurface,
fontWeight: FontWeight.w500,
),
),
const SizedBox(width: Grid.half),
Icon(
LucideIcons.chevronDown,
size: 16,
color: context.colors.onSurfaceVariant,
),
],
),
),
),
);
}
}
class _NewDirectMessageSheet extends HookConsumerWidget {
final String? currentPubkey;
const _NewDirectMessageSheet({required this.currentPubkey});
@override
Widget build(BuildContext context, WidgetRef ref) {
final queryController = useTextEditingController();
final queryFocusNode = useFocusNode();
final query = useState('');
final debouncedQuery = useState('');
final selectedUsers = useState<List<DirectoryUser>>([]);
final isSubmitting = useState(false);
final submitError = useState<String?>(null);
final previewDirectoryActive = ref.watch(dmDirectoryPreviewEnabledProvider);
useEffect(() {
final timer = Timer(const Duration(milliseconds: 250), () {
debouncedQuery.value = query.value.trim().toLowerCase();
});
return timer.cancel;
}, [query.value]);
final normalizedQuery = previewDirectoryActive
? query.value.trim().toLowerCase()
: debouncedQuery.value;
final isSearchTransitionPending =
!previewDirectoryActive &&
query.value.trim().toLowerCase() != normalizedQuery;
final directoryAsync = previewDirectoryActive
? AsyncValue.data(dmDirectoryPreviewUsers)
: normalizedQuery.isEmpty
? ref.watch(relayDirectoryUsersProvider)
: ref.watch(relayDirectorySearchProvider(normalizedQuery));
final selectedPubkeys = selectedUsers.value
.map((user) => user.pubkey.toLowerCase())
.toSet();
final availableResults =
directoryAsync.asData?.value.where((user) {
final normalizedPubkey = user.pubkey.toLowerCase();
if (selectedPubkeys.contains(normalizedPubkey) ||
normalizedPubkey == currentPubkey?.toLowerCase()) {
return false;
}
if (normalizedQuery.isEmpty) {
return true;
}
return user.label.toLowerCase().contains(normalizedQuery) ||
user.secondaryLabel.toLowerCase().contains(normalizedQuery) ||
normalizedPubkey.contains(normalizedQuery);
}).toList() ??
const <DirectoryUser>[];
final canSubmit = !isSubmitting.value && selectedUsers.value.isNotEmpty;
Future<void> submit() async {
if (selectedUsers.value.isEmpty || isSubmitting.value) {
return;
}
if (previewDirectoryActive) {
submitError.value = context.l10n.previewDmUnavailable;
return;
}
isSubmitting.value = true;
submitError.value = null;
try {
final channel = await ref
.read(channelActionsProvider)
.openDm(
pubkeys: selectedUsers.value.map((user) => user.pubkey).toList(),
);
if (context.mounted) {
Navigator.of(context).pop(channel);
}
} catch (error) {
submitError.value = error.toString();
} finally {
isSubmitting.value = false;
}
}
return Padding(
padding: EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
MediaQuery.viewInsetsOf(context).bottom + Grid.xs,
),
child: SafeArea(
top: false,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.newMessage,
style: context.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
letterSpacing: -0.3,
),
),
const SizedBox(height: Grid.xs),
GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: isSubmitting.value ? null : queryFocusNode.requestFocus,
child: SizedBox(
width: double.infinity,
child: ConstrainedBox(
key: const Key('new-dm-recipient-field'),
constraints: const BoxConstraints(minHeight: Grid.xl),
child: DecoratedBox(
decoration: BoxDecoration(
color: context.colors.surface,
border: Border.all(
color: context.colors.outlineVariant,
),
borderRadius: BorderRadius.circular(Radii.lg),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.twelve,
vertical: Grid.xxs,
),
child: LayoutBuilder(
builder: (context, constraints) {
final hasSelectedUsers =
selectedUsers.value.isNotEmpty;
final searchFieldWidth = hasSelectedUsers
? 96.0
: (constraints.maxWidth - 32).clamp(
96.0,
constraints.maxWidth,
);
return Wrap(
key: const Key('new-dm-recipient-wrap'),
spacing: 6,
runSpacing: 6,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Padding(
padding: const EdgeInsets.symmetric(
vertical: Grid.half,
),
child: Text(
context.l10n.toLabel,
style: context.textTheme.bodyLarge
?.copyWith(fontWeight: FontWeight.w600),
),
),
for (final user in selectedUsers.value)
_SelectedDmRecipientChip(
user: user,
enabled: !isSubmitting.value,
onDeleted: () {
selectedUsers.value = [
for (final candidate
in selectedUsers.value)
if (candidate.pubkey != user.pubkey)
candidate,
];
queryFocusNode.requestFocus();
},
),
SizedBox(
width: searchFieldWidth,
child: TextField(
key: const Key('new-dm-search'),
controller: queryController,
focusNode: queryFocusNode,
autofocus: true,
autocorrect: false,
enableSuggestions: false,
enabled: !isSubmitting.value,
onChanged: (value) => query.value = value,
onSubmitted: (_) {
if (canSubmit) {
unawaited(submit());
}
},
decoration: InputDecoration(
hintText: hasSelectedUsers
? null
: context.l10n.searchPerson,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
isDense: true,
contentPadding:
const EdgeInsets.symmetric(
vertical: Grid.half,
),
suffixIcon: isSubmitting.value
? Padding(
padding: EdgeInsets.all(
Grid.half,
),
child: SizedBox.square(
dimension: 16,
child: BuzzLoadingIndicator(
size: 16,
semanticLabel:
context
.l10n
.creatingConversation,
),
),
)
: null,
suffixIconConstraints:
const BoxConstraints(
minHeight: 32,
minWidth: 32,
),
),
textInputAction: TextInputAction.done,
),
),
],
);
},
),
),
),
),
),
),
const SizedBox(height: Grid.xs),
Builder(
key: const Key('new-dm-results'),
builder: (context) {
if (selectedUsers.value.length >= 8) {
return SizedBox(
height: 96,
child: Center(
child: Text(
context.l10n.dmPeopleLimit,
),
),
);
}
if (directoryAsync.isLoading &&
directoryAsync.asData == null ||
isSearchTransitionPending) {
return SizedBox(
height: 280,
child: Center(
child: BuzzLoadingIndicator(
size: 44,
semanticLabel: context.l10n.loadingPeople,
),
),
);
}
if (directoryAsync.hasError) {
return SizedBox(
height: 280,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(context.l10n.peopleLoadFailed),
const SizedBox(height: Grid.half),
TextButton(
onPressed: () {
if (normalizedQuery.isEmpty) {
ref.invalidate(relayDirectoryUsersProvider);
} else {
ref.invalidate(
relayDirectorySearchProvider(
normalizedQuery,
),
);
}
},
child: Text(context.l10n.retry),
),
],
),
),
);
}
if (availableResults.isEmpty) {
return SizedBox(
height: 280,
child: Center(
child: Text(
normalizedQuery.isEmpty
? context.l10n.noPeopleAvailable
: context.l10n.noMatchingUsers,
),
),
);
}
return ListView.separated(
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
itemCount: availableResults.length,
separatorBuilder: (_, _) =>
const Divider(height: 1, indent: 56),
itemBuilder: (context, index) {
final user = availableResults[index];
return ListTile(
key: Key('new-dm-person-${user.pubkey}'),
contentPadding: const EdgeInsets.symmetric(
horizontal: Grid.half,
),
leading: AvatarImage(
imageUrl: user.avatarUrl,
radius: 20,
backgroundColor: context.colors.primaryContainer,
fallback: Text(
user.initial,
style: context.textTheme.labelLarge?.copyWith(
color: context.colors.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
),
title: Text(
user.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
user.secondaryLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Icon(
LucideIcons.plus,
size: 18,
color: context.colors.onSurfaceVariant,
),
onTap: isSubmitting.value
? null
: () {
selectedUsers.value = [
...selectedUsers.value,
user,
];
queryController.clear();
query.value = '';
debouncedQuery.value = '';
submitError.value = null;
queryFocusNode.requestFocus();
},
);
},
);
},
),
if (submitError.value case final error?) ...[
const SizedBox(height: Grid.xxs),
Text(
error,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.error,
),
),
],
],
),
),
),
);
}
}
class _SelectedDmRecipientChip extends StatelessWidget {
final DirectoryUser user;
final bool enabled;
final VoidCallback onDeleted;
const _SelectedDmRecipientChip({
required this.user,
required this.enabled,
required this.onDeleted,
});
@override
Widget build(BuildContext context) {
final foreground = context.colors.onSurfaceVariant;
return ConstrainedBox(
key: Key('new-dm-selected-${user.pubkey}'),
constraints: const BoxConstraints(maxWidth: 224),
child: SizedBox(
height: 40,
child: Material(
color: context.colors.surfaceContainerHighest,
shape: const StadiumBorder(),
clipBehavior: Clip.antiAlias,
child: Semantics(
button: true,
enabled: enabled,
excludeSemantics: true,
label: context.l10n.removePerson(user.label),
child: InkWell(
customBorder: const StadiumBorder(),
onTap: enabled ? onDeleted : null,
child: Padding(
padding: const EdgeInsets.only(
left: Grid.half,
right: Grid.twelve,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
AvatarImage(
imageUrl: user.avatarUrl,
radius: 16,
backgroundColor: context.colors.primaryContainer,
fallback: Text(
user.initial,
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: Grid.xxs),
Flexible(
child: Text(
user.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodyLarge?.copyWith(
color: foreground,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
),
),
),
),
);
}
}
@@ -0,0 +1,137 @@
part of '../channels_page.dart';
class _ChannelsSkeleton extends StatelessWidget {
final List<Channel>? channels;
final double topInset;
final SessionStatus status;
const _ChannelsSkeleton({
required this.channels,
required this.topInset,
required this.status,
});
@override
Widget build(BuildContext context) {
final visibleChannels =
channels
?.where((channel) => channel.isMember && !channel.isArchived)
.take(8)
.toList() ??
const <Channel>[];
final widths = visibleChannels
.map(
(channel) => min(
240,
max(
88,
24 +
(resolveDmChannelDisplayLabel(
channel,
currentPubkey: null,
).length *
8),
),
).toDouble(),
)
.toList();
const fallbackWidths = <double>[136, 184, 112, 160, 208, 128];
while (widths.length < 6) {
widths.add(fallbackWidths[widths.length % fallbackWidths.length]);
}
final splitAt = min(4, widths.length);
final firstSection = widths.take(splitAt).toList();
final secondSection = widths.skip(splitAt).toList();
final semanticsLabel = switch (status) {
SessionStatus.connecting => context.l10n.connecting,
SessionStatus.reconnecting => context.l10n.reconnecting,
SessionStatus.connected || SessionStatus.disconnected =>
context.l10n.loading,
};
return Semantics(
key: const Key('channels-connection-skeleton'),
liveRegion: true,
label: semanticsLabel,
child: ExcludeSemantics(
child: ListView(
physics: const NeverScrollableScrollPhysics(),
padding: EdgeInsets.fromLTRB(
Grid.gutter,
topInset + Grid.twelve,
Grid.gutter,
80,
),
children: [
_ChannelSkeletonSection(widths: firstSection),
const SizedBox(height: Grid.xs),
_ChannelSkeletonSection(widths: secondSection),
],
),
),
);
}
}
class _ChannelSkeletonSection extends StatelessWidget {
final List<double> widths;
const _ChannelSkeletonSection({required this.widths});
@override
Widget build(BuildContext context) {
return Column(
children: [
const Padding(
padding: EdgeInsets.symmetric(vertical: Grid.half),
child: Row(
children: [
SizedBox(
width: _kChannelLeadingWidth,
child: Align(
alignment: Alignment.centerLeft,
child: SkeletonBar(width: 18, height: 18),
),
),
SizedBox(width: Grid.xxs),
SkeletonBar(
key: Key('channels-skeleton-section-label'),
width: 88,
height: 16,
),
],
),
),
for (var index = 0; index < widths.length; index++)
Padding(
padding: const EdgeInsets.symmetric(
vertical: _kChannelRowVerticalPadding,
),
child: Row(
children: [
SizedBox(
width: _kChannelLeadingWidth,
child: Align(
alignment: Alignment.centerLeft,
child: SkeletonBar(
width: _kChannelIconSize,
height: _kChannelIconSize,
borderRadius: BorderRadius.circular(
index.isEven ? Radii.xs : Radii.full,
),
),
),
),
const SizedBox(width: _kChannelLabelGap),
SkeletonBar(
key: Key('channels-skeleton-row-label-$index'),
width: widths[index],
height: 16,
),
],
),
),
],
);
}
}