Wes
2026-06-30 13:27:37 -06:00
committed by GitHub
parent 34d61acc95
commit 71b2c41e92
31 changed files with 3904 additions and 3886 deletions

View File

@@ -505,15 +505,21 @@ The mobile app lives in `mobile/` — a Flutter app using Riverpod + Hooks.
### Rules
- **NEVER use `StatefulWidget`** — always use `HookConsumerWidget` or
`ConsumerWidget` with `flutter_hooks` for local state.
- **NEVER use `StatefulWidget`** — favor Riverpod for state and always use
`HookConsumerWidget` or `ConsumerWidget` with `flutter_hooks` for local state.
- **NEVER run `flutter run`, `flutter build`, `flutter clean`, or
`flutter upgrade`** — only `flutter test`, `flutter analyze`, and
`dart format` are safe for agents to run.
- **Do NOT use `print()`** — use `debugPrint()` or structured logging.
- Prefer `context.colors` and `context.textTheme` (via theme extensions)
over raw `Theme.of(context)` calls.
- Keep widgets small and composable.
- **Keep widgets small and composable.** One public widget per file; push
private sub-widgets (`_Foo`) into sibling `part` files under a
`<page>/` folder rather than growing the page file. Hard ceiling:
**1000 lines/file**, enforced by `mobile/scripts/check-file-sizes.mjs` via
`just mobile-check` (runs in `just check` + pre-push, mirroring desktop/web).
If the guard trips, **split the file — never bump the limit or add an
override to slip under it.**
- Feature modules must not import from other feature modules — only from
`shared/`.
- Use `Grid` tokens for spacing, `Radii` for border radius.

View File

@@ -429,7 +429,7 @@ mobile-fix:
# Run mobile lint and format checks
mobile-check:
unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && dart format --output=none --set-exit-if-changed . && flutter analyze
unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && dart format --output=none --set-exit-if-changed . && flutter analyze && node ./scripts/check-file-sizes.mjs
# Run mobile tests
mobile-test:

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,248 @@
part of '../channel_detail_page.dart';
class _TypingIndicator extends ConsumerWidget {
final List<TypingEntry> entries;
const _TypingIndicator({required this.entries});
@override
Widget build(BuildContext context, WidgetRef ref) {
final userCache = ref.watch(userCacheProvider);
final names = entries.map((e) {
final profile =
userCache[e.pubkey.toLowerCase()] ??
ref.read(userCacheProvider.notifier).get(e.pubkey.toLowerCase());
return profile?.label ?? shortPubkey(e.pubkey);
}).toList();
final text = switch (names.length) {
1 => '${names[0]} is typing…',
2 => '${names[0]} and ${names[1]} are typing…',
_ => '${names[0]} and ${names.length - 1} others are typing…',
};
final visibleEntries = entries.take(3).toList();
final avatarCount = visibleEntries.length;
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: Grid.gutter,
vertical: Grid.quarter + 2,
),
child: Row(
children: [
SizedBox(
width: 20.0 + (avatarCount - 1) * 12.0,
height: 20,
child: Stack(
children: [
for (var i = 0; i < avatarCount; i++)
Positioned(
left: i * 12.0,
child: SmallAvatar(
pubkey: visibleEntries[i].pubkey,
userCache: userCache,
),
),
],
),
),
const SizedBox(width: Grid.xxs),
Flexible(
child: Text(
text,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.outline,
fontStyle: FontStyle.italic,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
}
class _MembersButton extends ConsumerWidget {
final String channelId;
final Channel channel;
final String? currentPubkey;
const _MembersButton({
required this.channelId,
required this.channel,
required this.currentPubkey,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final hasWorkingBot = ref
.watch(workingBotPubkeysProvider(channelId))
.isNotEmpty;
return IconButton(
onPressed: () {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (_) =>
MembersSheet(channel: channel, currentPubkey: currentPubkey),
);
},
tooltip: 'View members',
icon: Stack(
clipBehavior: Clip.none,
children: [
const Icon(LucideIcons.users),
if (hasWorkingBot)
Positioned(
top: -2,
right: -2,
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: context.appColors.success,
shape: BoxShape.circle,
border: Border.all(color: context.colors.surface, width: 1.5),
),
),
),
],
),
);
}
}
class _DmAppBarTitle extends ConsumerWidget {
final Channel channel;
final String? currentPubkey;
const _DmAppBarTitle({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();
String? otherPubkey;
for (final pk in channel.participantPubkeys) {
if (pk.toLowerCase() != normalizedCurrent) {
otherPubkey = pk.toLowerCase();
break;
}
}
final profile = otherPubkey != null ? profiles[otherPubkey] : null;
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';
final presenceLabel = switch (presence) {
'online' => 'Online',
'away' => 'Away',
_ => 'Offline',
};
return Row(
children: [
SizedBox(
width: 30,
height: 30,
child: Stack(
clipBehavior: Clip.none,
children: [
CircleAvatar(
radius: 14,
backgroundColor: context.colors.primaryContainer,
backgroundImage: avatarUrl != null
? NetworkImage(avatarUrl)
: null,
child: avatarUrl == null
? Text(
initial,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
)
: null,
),
Positioned(
right: -1,
bottom: -1,
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: switch (presence) {
'online' => context.appColors.success,
'away' => context.appColors.warning,
_ => context.colors.outline,
},
shape: BoxShape.circle,
border: Border.all(
color: context.colors.surface,
width: 1.5,
),
),
),
),
],
),
),
const SizedBox(width: Grid.xxs),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
resolveDmChannelDisplayLabel(
channel,
currentPubkey: currentPubkey,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.titleSmall,
),
),
if (channel.isEphemeral) ...[
const SizedBox(width: Grid.quarter),
_HeaderEphemeralBadge(channel: channel),
],
],
),
Text(
presenceLabel,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
),
),
],
);
}
}

View File

@@ -0,0 +1,98 @@
part of '../channel_detail_page.dart';
class _ReadOnlyNotice extends StatelessWidget {
final Channel channel;
const _ReadOnlyNotice({required this.channel});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: EdgeInsets.only(
left: Grid.gutter,
right: Grid.gutter,
top: Grid.xxs,
bottom: MediaQuery.viewPaddingOf(context).bottom + Grid.xxs,
),
decoration: BoxDecoration(
border: Border(top: BorderSide(color: context.colors.outlineVariant)),
color: context.colors.surface,
),
child: Text(
channel.isArchived
? 'This ${channel.isForum ? 'forum' : 'channel'} is archived and read-only on mobile.'
: 'Join this ${channel.isForum ? 'forum' : 'channel'} from Manage to participate.',
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
);
}
}
class _HeaderEphemeralBadge extends StatelessWidget {
final Channel channel;
const _HeaderEphemeralBadge({required this.channel});
@override
Widget build(BuildContext context) {
final display = ephemeralChannelDisplay(channel);
if (display == null) return const SizedBox.shrink();
return Tooltip(
message: display.tooltipLabel,
child: Icon(
LucideIcons.clockFading,
key: const Key('chat-ephemeral-badge'),
size: 16,
color: context.colors.onSurfaceVariant,
),
);
}
}
class _DetailConnectionBanner extends StatelessWidget {
final SessionStatus status;
const _DetailConnectionBanner({required this.status});
@override
Widget build(BuildContext context) {
if (status == SessionStatus.connected ||
status == SessionStatus.disconnected) {
return const SizedBox.shrink();
}
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: Grid.gutter,
vertical: Grid.quarter + 2,
),
color: context.colors.surfaceContainerHighest,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
strokeWidth: 2,
color: context.colors.onSurfaceVariant,
),
),
const SizedBox(width: Grid.xxs),
Text(
'Reconnecting…',
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,186 @@
part of '../channel_detail_page.dart';
class _MessageBubble extends ConsumerWidget {
final TimelineMessage message;
final bool showAuthor;
final Map<String, String> channelNames;
final String currentChannelId;
final String? currentPubkey;
final List<TimelineMessage>? allMessages;
final bool isMember;
final bool isArchived;
const _MessageBubble({
required this.message,
required this.showAuthor,
required this.channelNames,
required this.currentChannelId,
required this.currentPubkey,
this.allMessages,
this.isMember = false,
this.isArchived = false,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Watch only this user's profile to avoid rebuilding on unrelated cache changes.
final pk = message.pubkey.toLowerCase();
final profile =
ref.watch(userCacheProvider.select((cache) => cache[pk])) ??
ref.read(userCacheProvider.notifier).get(pk);
final displayName = profile?.label ?? shortPubkey(message.pubkey);
// Build mention names map from event p-tags.
final userCache = ref.watch(userCacheProvider);
final mentionNames = <String, String>{};
for (final mpk in message.mentionPubkeys) {
final p = userCache[mpk.toLowerCase()];
if (p?.displayName != null) {
mentionNames[mpk.toLowerCase()] = p!.displayName!;
}
}
return GestureDetector(
behavior: HitTestBehavior.opaque,
onLongPress: () => showMessageActions(
context: context,
ref: ref,
message: message,
channelId: currentChannelId,
isOwnMessage:
currentPubkey?.toLowerCase() == message.pubkey.toLowerCase(),
allMessages: allMessages,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
),
child: Padding(
padding: EdgeInsets.only(top: showAuthor ? Grid.xs : Grid.half),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showAuthor)
GestureDetector(
onTap: () => showUserProfileSheet(context, message.pubkey),
child: _UserAvatar(profile: profile, pubkey: message.pubkey),
)
else
const SizedBox(width: 28),
const SizedBox(width: Grid.xxs),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showAuthor)
Padding(
padding: const EdgeInsets.only(bottom: Grid.quarter),
child: Row(
children: [
GestureDetector(
onTap: () =>
showUserProfileSheet(context, message.pubkey),
child: Text(
displayName,
style: context.textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w600,
color: context.colors.onSurface,
),
),
),
const SizedBox(width: Grid.xxs),
Text(
formatMessageTime(message.createdAt),
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
if (message.edited) ...[
const SizedBox(width: Grid.half),
Text(
'(edited)',
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
],
],
),
),
MessageContent(
content: message.content,
mentionNames: mentionNames,
channelNames: channelNames,
tags: message.tags,
onChannelTap: (channelId) {
if (channelId == currentChannelId) return;
final channelsAsync = ref.read(channelsProvider);
final channels = channelsAsync.hasValue
? channelsAsync.value
: null;
Channel? targetChannel;
for (final channel in channels ?? const <Channel>[]) {
if (channel.id == channelId) {
targetChannel = channel;
break;
}
}
if (targetChannel == null) return;
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) =>
ChannelDetailPage(channel: targetChannel!),
),
);
},
),
if (message.reactions.isNotEmpty)
ReactionRow(
reactions: message.reactions,
onToggle: (emoji) => toggleReaction(ref, message, emoji),
),
],
),
),
],
),
),
);
}
}
class _UserAvatar extends StatelessWidget {
final UserProfile? profile;
final String pubkey;
const _UserAvatar({required this.profile, required this.pubkey});
@override
Widget build(BuildContext context) {
final initial =
profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?');
final avatarUrl = profile?.avatarUrl;
return CircleAvatar(
radius: 14,
backgroundColor: context.colors.primaryContainer,
backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl) : null,
child: avatarUrl == null
? Text(
initial,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
)
: null,
);
}
}
IconData channelIcon(Channel channel) {
if (channel.isDm) return LucideIcons.messagesSquare;
if (channel.isPrivate) return LucideIcons.lock;
if (channel.isForum) return LucideIcons.messageSquareText;
return LucideIcons.hash;
}

View File

@@ -0,0 +1,257 @@
part of '../channel_detail_page.dart';
class _MessageList extends HookConsumerWidget {
final List<MainTimelineEntry> entries;
final List<TimelineMessage> allMessages;
final String channelId;
final String? currentPubkey;
final bool isMember;
final bool isArchived;
const _MessageList({
required this.entries,
required this.allMessages,
required this.channelId,
required this.currentPubkey,
required this.isMember,
required this.isArchived,
});
static const _fetchOlderThreshold = 200.0;
static const _latestThreshold = 48.0;
@override
Widget build(BuildContext context, WidgetRef ref) {
// Pagination: fetch older messages when scrolling near the top.
final scrollController = useScrollController();
final isLoadingOlder = useState(false);
final isAtLatest = useState(true);
final latestEntryId = entries.isEmpty ? null : entries.last.message.id;
final previousLatestEntryId = useRef<String?>(null);
bool nearLatest() {
if (!scrollController.hasClients) return true;
return scrollController.position.pixels <= _latestThreshold;
}
void updateLatestState() {
final next = nearLatest();
if (isAtLatest.value != next) {
isAtLatest.value = next;
}
}
Future<void> scrollToLatest() async {
if (!scrollController.hasClients) return;
await scrollController.animateTo(
0,
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
);
if (context.mounted) {
isAtLatest.value = true;
}
}
useEffect(() {
void onScroll() {
updateLatestState();
if (isLoadingOlder.value) return;
final notifier = ref.read(channelMessagesProvider(channelId).notifier);
if (notifier.reachedOldest) return;
// In a reversed ListView, maxScrollExtent is the oldest messages.
final pos = scrollController.position;
if (pos.pixels >= pos.maxScrollExtent - _fetchOlderThreshold) {
isLoadingOlder.value = true;
notifier.fetchOlder().whenComplete(
() => isLoadingOlder.value = false,
);
}
}
scrollController.addListener(onScroll);
return () => scrollController.removeListener(onScroll);
}, [channelId, scrollController]);
useEffect(() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted) return;
updateLatestState();
});
return null;
}, [entries.length, scrollController]);
useEffect(() {
final previous = previousLatestEntryId.value;
previousLatestEntryId.value = latestEntryId;
if (previous == null ||
latestEntryId == null ||
previous == latestEntryId ||
!isAtLatest.value) {
return null;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted || !scrollController.hasClients) return;
scrollController.animateTo(
0,
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
);
});
return null;
}, [latestEntryId, scrollController]);
if (entries.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.messageSquare,
size: Grid.xl,
color: context.colors.onSurfaceVariant,
),
const SizedBox(height: Grid.xxs),
Text(
'No messages yet',
style: context.textTheme.bodyLarge?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
const SizedBox(height: Grid.half),
Text(
'Be the first to say something!',
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
),
);
}
// Build channel names map once for all message bubbles.
final channelsAsync = ref.watch(channelsProvider);
final channelNamesMap = <String, String>{};
channelsAsync.whenData((channels) {
for (final ch in channels) {
channelNamesMap[ch.name.toLowerCase()] = ch.id;
}
});
return Stack(
children: [
ListView.builder(
key: const ValueKey('channel-message-list'),
controller: scrollController,
reverse: true,
padding: EdgeInsets.only(
left: Grid.gutter,
right: Grid.gutter,
top: frostedAppBarHeight(context),
bottom: Grid.xxs,
),
itemCount: entries.length + (isLoadingOlder.value ? 1 : 0),
itemBuilder: (context, index) {
// Loading indicator at the top (last index in reversed list).
if (index >= entries.length) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: Grid.xs),
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
}
// Reversed list: index 0 = newest (bottom of screen).
final chronIdx = entries.length - 1 - index;
final entry = entries[chronIdx];
final message = entry.message;
// Day boundary check — applies to all messages including system.
final prevEntry = chronIdx > 0 ? entries[chronIdx - 1] : null;
final prevMessage = prevEntry?.message;
final showDayDivider =
prevMessage == null ||
!isSameDay(prevMessage.createdAt, message.createdAt);
final showAuthor =
!message.isSystem &&
(prevMessage == null ||
prevMessage.isSystem ||
showDayDivider ||
prevMessage.pubkey.toLowerCase() !=
message.pubkey.toLowerCase() ||
(message.createdAt - prevMessage.createdAt) > 300);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showDayDivider)
DayDivider(label: formatDayHeading(message.createdAt)),
if (message.isSystem)
_SystemMessageRow(
message: message,
channelId: channelId,
currentPubkey: currentPubkey,
allMessages: null,
isMember: isMember,
isArchived: isArchived,
)
else ...[
_MessageBubble(
message: message,
showAuthor: showAuthor,
channelNames: channelNamesMap,
currentChannelId: channelId,
currentPubkey: currentPubkey,
allMessages: allMessages,
isMember: isMember,
isArchived: isArchived,
),
if (entry.summary != null)
_ThreadSummaryRow(
summary: entry.summary!,
message: message,
allMessages: allMessages,
channelId: channelId,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
),
],
],
);
},
),
if (!isAtLatest.value)
Positioned(
left: 0,
right: 0,
bottom: Grid.xs,
child: Center(
child: FilledButton.icon(
key: const ValueKey('channel-jump-to-latest'),
onPressed: scrollToLatest,
style: FilledButton.styleFrom(
backgroundColor: context.colors.primaryContainer,
foregroundColor: context.colors.onPrimaryContainer,
padding: const EdgeInsets.symmetric(
horizontal: Grid.gutter,
vertical: Grid.xxs,
),
),
icon: const Icon(LucideIcons.arrowDown, size: 16),
label: const Text('Latest'),
),
),
),
],
);
}
}

View File

@@ -0,0 +1,221 @@
part of '../channel_detail_page.dart';
class _SystemMessageRow extends ConsumerWidget {
final TimelineMessage message;
final String channelId;
final String? currentPubkey;
final List<TimelineMessage>? allMessages;
final bool isMember;
final bool isArchived;
const _SystemMessageRow({
required this.message,
required this.channelId,
this.currentPubkey,
this.allMessages,
this.isMember = false,
this.isArchived = false,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final systemEvent = message.systemEvent;
if (systemEvent == null) return const SizedBox.shrink();
final userCache = ref.watch(userCacheProvider);
String resolveLabel(String? pubkey) {
if (pubkey == null) return 'Someone';
final profile =
userCache[pubkey.toLowerCase()] ??
ref.read(userCacheProvider.notifier).get(pubkey.toLowerCase());
return profile?.label ?? shortPubkey(pubkey);
}
final description = systemEvent.describe(resolveLabel);
return GestureDetector(
behavior: HitTestBehavior.opaque,
onLongPress: () => showMessageActions(
context: context,
ref: ref,
message: message,
channelId: channelId,
isOwnMessage: false,
allMessages: null,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
_systemEventAvatar(context, systemEvent, userCache),
const SizedBox(width: Grid.xxs),
Expanded(
child: Text(
description,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
Text(
formatMessageTime(message.createdAt),
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
),
if (message.reactions.isNotEmpty)
Padding(
padding: const EdgeInsets.only(left: 28),
child: ReactionRow(
reactions: message.reactions,
onToggle: (emoji) => toggleReaction(ref, message, emoji),
),
),
],
),
),
);
}
}
Widget _systemEventAvatar(
BuildContext context,
SystemEvent event,
Map<String, UserProfile> userCache,
) {
final hasTarget =
event.targetPubkey != null && event.targetPubkey != event.actorPubkey;
if (event.actorPubkey != null && hasTarget) {
// Two-avatar stack: actor + target (e.g. "Alice added Bob").
return SizedBox(
width: 32,
height: 20,
child: Stack(
children: [
SmallAvatar(pubkey: event.actorPubkey!, userCache: userCache),
Positioned(
left: 12,
child: SmallAvatar(
pubkey: event.targetPubkey!,
userCache: userCache,
),
),
],
),
);
}
if (event.actorPubkey != null) {
return SmallAvatar(pubkey: event.actorPubkey!, userCache: userCache);
}
// Fallback: generic icon when no actor is available.
return Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: Icon(
LucideIcons.arrowLeftRight,
size: 12,
color: context.colors.onSurfaceVariant,
),
);
}
class _ThreadSummaryRow extends ConsumerWidget {
final ThreadSummary summary;
final TimelineMessage message;
final List<TimelineMessage> allMessages;
final String channelId;
final String? currentPubkey;
final bool isMember;
final bool isArchived;
const _ThreadSummaryRow({
required this.summary,
required this.message,
required this.allMessages,
required this.channelId,
required this.currentPubkey,
required this.isMember,
required this.isArchived,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final userCache = ref.watch(userCacheProvider);
return GestureDetector(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ThreadDetailPage(
threadHead: message,
allMessages: allMessages,
channelId: channelId,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
),
),
);
},
child: Padding(
padding: const EdgeInsets.only(
left: 36,
top: Grid.half,
bottom: Grid.half,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Stacked participant avatars.
SizedBox(
width: 20.0 + (summary.participantPubkeys.length - 1) * 12.0,
height: 20,
child: Stack(
children: [
for (var i = 0; i < summary.participantPubkeys.length; i++)
Positioned(
left: i * 12.0,
child: SmallAvatar(
pubkey: summary.participantPubkeys[i],
userCache: userCache,
),
),
],
),
),
const SizedBox(width: Grid.xxs),
Text(
'${summary.replyCount} ${summary.replyCount == 1 ? 'reply' : 'replies'}',
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.primary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: Grid.half),
Icon(
LucideIcons.chevronRight,
size: 14,
color: context.colors.primary,
),
],
),
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,183 @@
part of '../channels_page.dart';
class _UnreadBadge extends StatelessWidget {
final String channelId;
final int count;
const _UnreadBadge({required this.channelId, required this.count});
@override
Widget build(BuildContext context) {
if (count <= 0) {
return SizedBox(
key: Key('channel-unread-dot-$channelId'),
width: 20,
height: 20,
child: Center(
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: context.colors.primary,
shape: BoxShape.circle,
),
child: Semantics(label: 'unread'),
),
),
);
}
return Container(
key: Key('channel-unread-$channelId'),
constraints: const BoxConstraints(minWidth: 20, minHeight: 20),
padding: const EdgeInsets.symmetric(horizontal: Grid.quarter),
alignment: Alignment.center,
decoration: BoxDecoration(
color: context.colors.primary,
borderRadius: BorderRadius.circular(999),
),
child: Text(
_formatUnreadCount(count),
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onPrimary,
fontSize: 10,
fontWeight: FontWeight.w700,
height: 1,
),
),
);
}
}
String _formatUnreadCount(int count) => count > 99 ? '99+' : count.toString();
class _EphemeralBadge extends StatelessWidget {
final Channel channel;
const _EphemeralBadge({required this.channel});
@override
Widget build(BuildContext context) {
final display = ephemeralChannelDisplay(channel);
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 _ConnectionBanner extends StatelessWidget {
final SessionStatus status;
const _ConnectionBanner({required this.status});
@override
Widget build(BuildContext context) {
if (status == SessionStatus.connected ||
status == SessionStatus.disconnected) {
return const SizedBox.shrink();
}
final isConnecting = status == SessionStatus.connecting;
final message = isConnecting ? 'Connecting…' : 'Reconnecting…';
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: Grid.gutter,
vertical: Grid.quarter + 2,
),
color: context.colors.surfaceContainerHighest,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
strokeWidth: 2,
color: context.colors.onSurfaceVariant,
),
),
const SizedBox(width: Grid.xxs),
Text(
message,
style: context.textTheme.labelSmall?.copyWith(
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(Object error) {
if (error is RelayException) {
if (error.statusCode == 401) {
return 'Not authorized. Check your API token.';
}
if (error.statusCode == 403) {
return 'Access denied.';
}
return 'Server error (${error.statusCode}). Try again later.';
}
if (error is SocketException) {
return 'Could not reach the relay server.';
}
return 'Something went wrong. Check your connection.';
}
@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(
'Could not load channels',
style: context.textTheme.titleMedium,
),
const SizedBox(height: Grid.xxs),
Text(
_userMessage(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: const Text('Retry'),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,349 @@
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 String? currentPubkey;
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.currentPubkey,
required this.onRefresh,
required this.onSelectChannel,
});
@override
Widget build(BuildContext context) {
final barHeight = frostedAppBarHeight(context);
if (channels != null) {
return Stack(
children: [
RefreshIndicator(
edgeOffset: barHeight,
onRefresh: onRefresh,
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(child: SizedBox(height: barHeight)),
// Extra space for the connection banner when visible.
if (sessionStatus != SessionStatus.connected &&
sessionStatus != SessionStatus.disconnected)
const SliverToBoxAdapter(
child: SizedBox(height: _kBannerHeight),
),
_SliverChannelsList(
channels: channels!,
currentPubkey: currentPubkey,
onSelectChannel: onSelectChannel,
),
],
),
),
Positioned(
top: barHeight,
left: 0,
right: 0,
child: _ConnectionBanner(status: sessionStatus),
),
],
);
}
// The error view is gated on a grace timer in the parent — see the
// useEffect in ChannelsPage. While the grace window is in flight we fall
// through to the connection banner so transient relay-cancellation errors
// don't flash the error UI.
if (showError && channelsAsync.hasError) {
return Padding(
padding: EdgeInsets.only(top: barHeight),
child: _ErrorView(error: channelsAsync.error!, onRetry: onRefresh),
);
}
return Padding(
padding: EdgeInsets.only(top: barHeight),
child: _ConnectionBanner(
status: sessionStatus == SessionStatus.connected
? SessionStatus.connecting
: sessionStatus,
),
);
}
}
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 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,
};
final unreadChannelCounts = {
for (final entry in unreadState.counts.entries)
if (unreadChannelIds.contains(entry.key)) entry.key: entry.value,
};
// 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 = streamChannels
.where((c) => starredChannelIds.contains(c.id))
.toList();
final ungroupedStreamChannels = streamChannels
.where(
(c) =>
!assignedChannelIds.contains(c.id) &&
!starredChannelIds.contains(c.id),
)
.toList();
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: const EdgeInsets.only(top: Grid.xxs, bottom: 80),
sliver: SliverList.list(
children: [
if (visibleChannels.isEmpty)
const _EmptyState()
else ...[
// Starred channels (exclusive — pinned above all sections).
if (starredStreamChannels.isNotEmpty)
_ChannelSection(
title: 'Starred',
icon: LucideIcons.star,
showTopDivider: false,
expanded: starredExpanded.value,
onToggle: () => starredExpanded.value = !starredExpanded.value,
channels: starredStreamChannels,
unreadChannelIds: unreadChannelIds,
unreadChannelCounts: unreadChannelCounts,
mutedChannelIds: mutedChannelIds,
currentPubkey: currentPubkey,
emptyLabel: '',
onSelectChannel: onSelectChannel,
),
// User-defined sections for stream channels, in user-defined order.
for (final section in userSections)
_CustomChannelSection(
section: section,
channels: streamChannels
.where(
(c) =>
sectionAssignments[c.id] == section.id &&
!starredChannelIds.contains(c.id),
)
.toList(),
unreadChannelIds: unreadChannelIds,
unreadChannelCounts: unreadChannelCounts,
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 showDialog<String>(
context: context,
builder: (_) => _SectionNameDialog(
title: 'Rename Section',
confirmLabel: 'Rename',
initialValue: section.name,
),
);
if (name != null && name.isNotEmpty) {
ref
.read(channelSectionsProvider.notifier)
.renameSection(section.id, name);
}
},
onDelete: () async {
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: Text('Delete "${section.name}"?'),
content: const Text(
'Channels in this section will move back to the main list.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text(
'Delete',
style: TextStyle(color: context.colors.error),
),
),
],
),
);
if (confirmed == true) {
ref
.read(channelSectionsProvider.notifier)
.deleteSection(section.id);
}
},
onMoveUp: () => ref
.read(channelSectionsProvider.notifier)
.moveSectionUp(section.id),
onMoveDown: () => ref
.read(channelSectionsProvider.notifier)
.moveSectionDown(section.id),
onSelectChannel: onSelectChannel,
onMarkChannelRead: (channel) {
final ts = dateTimeToUnixSeconds(channel.lastMessageAt);
if (ts != null) {
ref
.read(readStateProvider.notifier)
.markContextRead(channel.id, ts);
ref
.read(channelsProvider.notifier)
.clearObservedUnreadCoveredByRead(channel.id, ts);
}
},
),
_ChannelSection(
title: 'Channels',
icon: LucideIcons.hash,
showTopDivider:
starredStreamChannels.isNotEmpty || userSections.isNotEmpty,
expanded: channelsExpanded.value,
onToggle: () => channelsExpanded.value = !channelsExpanded.value,
channels: ungroupedStreamChannels,
unreadChannelIds: unreadChannelIds,
unreadChannelCounts: unreadChannelCounts,
mutedChannelIds: mutedChannelIds,
currentPubkey: currentPubkey,
emptyLabel: 'No stream channels yet',
onSelectChannel: onSelectChannel,
),
_ChannelSection(
title: 'DMs',
icon: LucideIcons.messagesSquare,
showTopDivider: true,
expanded: dmsExpanded.value,
onToggle: () => dmsExpanded.value = !dmsExpanded.value,
channels: dmChannels,
unreadChannelIds: unreadChannelIds,
unreadChannelCounts: unreadChannelCounts,
mutedChannelIds: mutedChannelIds,
currentPubkey: currentPubkey,
emptyLabel: 'No direct messages yet',
onSelectChannel: onSelectChannel,
),
],
],
),
);
}
}

View File

@@ -0,0 +1,436 @@
part of '../channels_page.dart';
class _ChannelTile extends ConsumerWidget {
final Channel channel;
final int? unreadCount;
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,
this.unreadCount,
required this.isUnread,
required this.currentPubkey,
required this.onTap,
this.isMuted = false,
this.onMarkRead,
this.sectionId,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
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: [
if (channel.isDm)
_DmAvatar(channel: channel, currentPubkey: currentPubkey)
else
SizedBox(
width: _kChannelLeadingWidth,
child: Align(
alignment: Alignment.centerLeft,
child: Icon(
channelIcon(channel),
size: _kChannelIconSize,
color: context.colors.onSurface,
),
),
),
const SizedBox(width: _kChannelLabelGap),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
resolveDmChannelDisplayLabel(
channel,
currentPubkey: currentPubkey,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurface,
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.onSurfaceVariant,
),
],
if (isUnread && !channel.isDm) ...[
const SizedBox(width: Grid.xxs),
_UnreadBadge(channelId: channel.id, count: unreadCount ?? 0),
],
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(
'Open',
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.primary,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
),
);
}
void _showChannelActions(BuildContext context, WidgetRef ref) {
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (sheetContext) {
final sections = ref.read(channelSectionsProvider).store.sections
..sort((a, b) => a.order.compareTo(b.order));
final isStarred =
ref
.read(channelStarsProvider)
.store
.channels[channel.id]
?.starred ==
true;
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xs,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: Icon(
isStarred ? LucideIcons.starOff : LucideIcons.star,
),
title: Text(isStarred ? 'Unstar channel' : 'Star channel'),
onTap: () {
Navigator.of(sheetContext).pop();
if (isStarred) {
ref
.read(channelStarsProvider.notifier)
.unstarChannel(channel.id);
} else {
ref
.read(channelStarsProvider.notifier)
.starChannel(channel.id);
}
},
),
ListTile(
leading: const Icon(LucideIcons.folderInput),
title: const Text('Move to section'),
onTap: () async {
Navigator.of(sheetContext).pop();
await _showMoveSectionSheet(context, ref, sections);
},
),
ListTile(
leading: Icon(
isMuted ? LucideIcons.bell : LucideIcons.bellOff,
),
title: Text(isMuted ? 'Unmute channel' : 'Mute channel'),
onTap: () {
Navigator.of(sheetContext).pop();
if (isMuted) {
ref
.read(channelMutesProvider.notifier)
.unmuteChannel(channel.id);
} else {
ref
.read(channelMutesProvider.notifier)
.muteChannel(channel.id);
}
},
),
ListTile(
leading: Icon(
isUnread ? LucideIcons.checkCheck : LucideIcons.circleDot,
),
title: Text(isUnread ? 'Mark as read' : 'Mark as unread'),
onTap: () {
Navigator.of(sheetContext).pop();
final ts = dateTimeToUnixSeconds(channel.lastMessageAt);
if (ts != null) {
if (isUnread) {
onMarkRead?.call();
ref
.read(readStateProvider.notifier)
.markContextRead(channel.id, ts);
ref
.read(channelsProvider.notifier)
.clearObservedUnreadCoveredByRead(channel.id, ts);
} else {
ref
.read(readStateProvider.notifier)
.markContextUnread(channel.id);
}
}
},
),
],
),
),
);
},
);
}
Future<void> _showMoveSectionSheet(
BuildContext context,
WidgetRef ref,
List<ChannelSection> sections,
) async {
await showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (sheetContext) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xs,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final section in sections)
ListTile(
leading: Icon(
LucideIcons.folder,
color: sectionId == section.id
? sheetContext.colors.primary
: null,
),
title: Text(section.name),
trailing: sectionId == section.id
? Icon(
LucideIcons.check,
color: sheetContext.colors.primary,
)
: null,
onTap: () {
Navigator.of(sheetContext).pop();
ref
.read(channelSectionsProvider.notifier)
.assignChannel(channel.id, section.id);
},
),
ListTile(
leading: const Icon(LucideIcons.folderPlus),
title: const Text('New section…'),
onTap: () async {
Navigator.of(sheetContext).pop();
if (!context.mounted) return;
final name = await showDialog<String>(
context: context,
builder: (_) => const _SectionNameDialog(
title: 'New Section',
confirmLabel: 'Create',
),
);
if (name != null && name.isNotEmpty) {
ref
.read(channelSectionsProvider.notifier)
.createSection(name);
// Assign after create — sections list has been mutated,
// re-read to find the new section by name.
final newSection = ref
.read(channelSectionsProvider)
.store
.sections
.lastWhere(
(s) => s.name == name.trim(),
orElse: () => const ChannelSection(
id: '',
name: '',
order: -1,
),
);
if (newSection.id.isNotEmpty) {
ref
.read(channelSectionsProvider.notifier)
.assignChannel(channel.id, newSection.id);
}
}
},
),
if (sectionId != null)
ListTile(
leading: const Icon(LucideIcons.folderMinus),
title: const Text('Remove from section'),
onTap: () {
Navigator.of(sheetContext).pop();
ref
.read(channelSectionsProvider.notifier)
.unassignChannel(channel.id);
},
),
],
),
),
);
},
);
}
}
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: 22,
height: 22,
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: 10,
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: 22,
height: 22,
child: Stack(
clipBehavior: Clip.none,
children: [
CircleAvatar(
radius: 10,
backgroundColor: context.colors.primaryContainer,
backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl) : null,
child: avatarUrl == null
? Text(
initial,
style: context.textTheme.labelSmall?.copyWith(
fontSize: 9,
color: context.colors.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
)
: null,
),
Positioned(
right: -1,
bottom: -1,
child: Container(
width: 9,
height: 9,
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,
};
}
}

View File

@@ -0,0 +1,532 @@
part of '../channels_page.dart';
class _CustomChannelSection extends StatelessWidget {
final ChannelSection section;
final List<Channel> channels;
final Set<String> unreadChannelIds;
final Map<String, int> unreadChannelCounts;
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 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.unreadChannelCounts,
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.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,
),
_AnimatedSectionBody(
expanded: expanded,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final channel in channels)
_ChannelTile(
channel: channel,
unreadCount: unreadChannelCounts[channel.id],
isUnread: unreadChannelIds.contains(channel.id),
isMuted: mutedChannelIds.contains(channel.id),
currentPubkey: currentPubkey,
onTap: () => onSelectChannel(channel),
onMarkRead: () => onMarkChannelRead(channel),
sectionId: section.id,
),
],
),
),
],
);
}
}
class _CustomSectionHeader extends StatelessWidget {
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;
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,
});
@override
Widget build(BuildContext context) {
final sectionColor = context.colors.primary;
return GestureDetector(
onTap: onToggle,
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
Grid.twelve,
Grid.gutter,
_kChannelRowVerticalPadding,
),
child: Row(
children: [
SizedBox(
width: _kChannelLeadingWidth,
child: Align(
alignment: Alignment.centerLeft,
child: Icon(
LucideIcons.folder,
size: _kChannelIconSize,
color: sectionColor,
),
),
),
const SizedBox(width: _kChannelLabelGap),
Text(
section.name,
style: context.textTheme.bodyMedium?.copyWith(
color: sectionColor,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
GestureDetector(
onTapUp: (details) async {
final overlay =
Overlay.of(context).context.findRenderObject()!
as RenderBox;
final position = RelativeRect.fromRect(
details.globalPosition & Size.zero,
Offset.zero & overlay.size,
);
final value = await showMenu<String>(
context: context,
position: position,
items: [
const PopupMenuItem(value: 'rename', child: Text('Rename')),
PopupMenuItem(
value: 'move_up',
enabled: !isFirst,
child: const Text('Move Up'),
),
PopupMenuItem(
value: 'move_down',
enabled: !isLast,
child: const Text('Move Down'),
),
const PopupMenuItem(value: 'delete', child: Text('Delete')),
],
);
switch (value) {
case 'rename':
onRename();
case 'move_up':
onMoveUp();
case 'move_down':
onMoveDown();
case 'delete':
onDelete();
}
},
child: Icon(
LucideIcons.ellipsisVertical,
size: _kChannelIconSize,
color: sectionColor,
),
),
const SizedBox(width: Grid.quarter),
_SectionChevron(expanded: expanded, color: sectionColor),
],
),
),
);
}
}
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: const InputDecoration(labelText: 'Name'),
onSubmitted: (_) => confirm(),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
TextButton(onPressed: confirm, child: Text(confirmLabel)),
],
);
}
}
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 Map<String, int> unreadChannelCounts;
final Set<String> mutedChannelIds;
final String? currentPubkey;
final String emptyLabel;
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.unreadChannelCounts,
required this.mutedChannelIds,
required this.currentPubkey,
required this.emptyLabel,
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,
),
_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: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
)
else
for (final channel in channels)
_ChannelTile(
channel: channel,
unreadCount: unreadChannelCounts[channel.id],
isUnread: unreadChannelIds.contains(channel.id),
isMuted: mutedChannelIds.contains(channel.id),
currentPubkey: currentPubkey,
onTap: () => onSelectChannel(channel),
onMarkRead: null,
sectionId: null,
),
],
),
),
],
);
}
}
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(
'No conversations yet',
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.outlineVariant.withValues(alpha: 0.72),
),
);
}
}
class _SectionHeader extends StatelessWidget {
final String label;
final IconData icon;
final bool expanded;
final VoidCallback onToggle;
const _SectionHeader({
required this.label,
required this.icon,
required this.expanded,
required this.onToggle,
});
@override
Widget build(BuildContext context) {
final sectionColor = context.colors.primary;
return GestureDetector(
onTap: onToggle,
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
Grid.twelve,
Grid.gutter,
_kChannelRowVerticalPadding,
),
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: context.textTheme.bodyMedium?.copyWith(
color: sectionColor,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
_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),
),
),
),
);
},
),
);
}
}

View File

@@ -0,0 +1,356 @@
part of '../channels_page.dart';
class _QuickActionsSheet extends StatelessWidget {
const _QuickActionsSheet();
@override
Widget build(BuildContext context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xs,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
leading: const Icon(LucideIcons.hash),
title: const Text('Create channel'),
subtitle: const Text('Start a new stream channel'),
onTap: () =>
Navigator.of(context).pop(_QuickAction.createChannel),
),
ListTile(
leading: const Icon(LucideIcons.messagesSquare),
title: const Text('New direct message'),
subtitle: const Text('Open a DM with one or more people'),
onTap: () => Navigator.of(context).pop(_QuickAction.newDm),
),
],
),
),
);
}
}
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 isSubmitting = useState(false);
final errorMessage = useState<String?>(null);
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(),
);
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: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: nameController,
enabled: !isSubmitting.value,
decoration: InputDecoration(
labelText: 'Name',
hintText: channelType == 'forum'
? 'design-discussions'
: 'release-notes',
),
textInputAction: TextInputAction.next,
),
const SizedBox(height: Grid.xxs),
TextField(
controller: descriptionController,
enabled: !isSubmitting.value,
decoration: const InputDecoration(
labelText: 'Description',
hintText: 'What this space is for',
),
minLines: 2,
maxLines: 3,
),
const SizedBox(height: Grid.xxs),
SwitchListTile(
title: const Text('Private'),
contentPadding: EdgeInsets.zero,
value: visibility.value == 'private',
onChanged: isSubmitting.value
? null
: (on) => visibility.value = on ? 'private' : 'open',
),
if (errorMessage.value case final error?) ...[
const SizedBox(height: Grid.xxs),
Text(
error,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.error,
),
),
],
const SizedBox(height: Grid.xs),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: isSubmitting.value
? null
: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
const SizedBox(width: Grid.half),
FilledButton(
onPressed: isSubmitting.value ? null : submit,
child: Text(isSubmitting.value ? 'Creating…' : 'Create'),
),
],
),
],
),
),
);
}
}
class _NewDirectMessageSheet extends HookConsumerWidget {
final String? currentPubkey;
const _NewDirectMessageSheet({required this.currentPubkey});
@override
Widget build(BuildContext context, WidgetRef ref) {
final queryController = useTextEditingController();
final query = useState('');
final debouncedQuery = useState('');
final selectedUsers = useState<List<DirectoryUser>>([]);
final isSubmitting = useState(false);
final submitError = useState<String?>(null);
useEffect(() {
final timer = Timer(const Duration(milliseconds: 250), () {
debouncedQuery.value = query.value.trim();
});
return timer.cancel;
}, [query.value]);
final searchFuture = useMemoized(() {
if (debouncedQuery.value.isEmpty || selectedUsers.value.length >= 8) {
return Future.value(const <DirectoryUser>[]);
}
return ref
.read(channelActionsProvider)
.searchUsers(debouncedQuery.value, limit: 8);
}, [debouncedQuery.value, selectedUsers.value.length]);
final searchResults = useFuture(searchFuture);
final selectedPubkeys = selectedUsers.value
.map((user) => user.pubkey.toLowerCase())
.toSet();
final availableResults =
searchResults.data
?.where(
(user) =>
!selectedPubkeys.contains(user.pubkey.toLowerCase()) &&
user.pubkey.toLowerCase() != currentPubkey?.toLowerCase(),
)
.toList() ??
const <DirectoryUser>[];
final canSubmit = !isSubmitting.value && selectedUsers.value.isNotEmpty;
Future<void> submit() async {
if (selectedUsers.value.isEmpty || isSubmitting.value) {
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: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: queryController,
decoration: const InputDecoration(
prefixIcon: Icon(LucideIcons.search),
hintText: 'Search by name, NIP-05, or pubkey',
),
enabled: !isSubmitting.value,
onChanged: (value) => query.value = value,
),
if (selectedUsers.value.isNotEmpty) ...[
const SizedBox(height: Grid.xxs),
Wrap(
spacing: Grid.half,
runSpacing: Grid.half,
children: [
for (final user in selectedUsers.value)
InputChip(
label: Text(user.label),
onDeleted: isSubmitting.value
? null
: () {
selectedUsers.value = [
for (final candidate in selectedUsers.value)
if (candidate.pubkey != user.pubkey)
candidate,
];
},
),
],
),
],
const SizedBox(height: Grid.xs),
SizedBox(
height: 280,
child: Builder(
builder: (context) {
if (selectedUsers.value.length >= 8) {
return const Center(
child: Text(
'Direct messages support up to 9 people including you.',
),
);
}
if (debouncedQuery.value.isEmpty) {
return const Center(
child: Text(
'Search for someone to start a conversation.',
),
);
}
if (searchResults.connectionState ==
ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (availableResults.isEmpty) {
return const Center(child: Text('No matching users.'));
}
return ListView(
shrinkWrap: true,
children: [
for (final user in availableResults)
ListTile(
leading: CircleAvatar(
backgroundImage: user.avatarUrl != null
? NetworkImage(user.avatarUrl!)
: null,
child: user.avatarUrl == null
? Text(user.label.substring(0, 1).toUpperCase())
: null,
),
title: Text(user.label),
subtitle: Text(user.secondaryLabel),
onTap: () {
selectedUsers.value = [
...selectedUsers.value,
user,
];
queryController.clear();
query.value = '';
debouncedQuery.value = '';
},
),
],
);
},
),
),
if (submitError.value case final error?) ...[
const SizedBox(height: Grid.xxs),
Text(
error,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.error,
),
),
],
const SizedBox(height: Grid.xs),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: isSubmitting.value
? null
: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
const SizedBox(width: Grid.half),
FilledButton(
onPressed: canSubmit ? submit : null,
child: Text(isSubmitting.value ? 'Opening…' : 'Open DM'),
),
],
),
],
),
),
);
}
}

View File

@@ -0,0 +1,320 @@
part of '../channels_page.dart';
class _WorkspaceSwitcherSheet extends ConsumerWidget {
const _WorkspaceSwitcherSheet();
@override
Widget build(BuildContext context, WidgetRef ref) {
final workspacesAsync = ref.watch(workspaceListProvider);
final activeAsync = ref.watch(activeWorkspaceProvider);
final sessionState = ref.watch(relaySessionProvider);
return SafeArea(
child: workspacesAsync.when(
loading: () => const SizedBox(
height: 120,
child: Center(child: CircularProgressIndicator()),
),
error: (e, _) => Padding(
padding: const EdgeInsets.all(Grid.xs),
child: Text('Error loading workspaces: $e'),
),
data: (workspaces) {
final activeId = activeAsync.value?.id;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final workspace in workspaces)
_WorkspaceSwitcherTile(
workspace: workspace,
isActive: workspace.id == activeId,
sessionStatus: workspace.id == activeId
? sessionState.status
: null,
onTap: () async {
if (workspace.id != activeId) {
await ref
.read(workspaceListProvider.notifier)
.switchWorkspace(workspace.id);
}
if (context.mounted) Navigator.of(context).pop();
},
onRename: () async {
final nav = Navigator.of(context, rootNavigator: true);
final notifier = ref.read(workspaceListProvider.notifier);
Navigator.of(context).pop();
final name = await showDialog<String>(
context: nav.context,
useRootNavigator: true,
builder: (_) =>
_RenameWorkspaceDialog(currentName: workspace.name),
);
if (name != null && name.isNotEmpty) {
await notifier.renameWorkspace(workspace.id, name);
}
},
onRemove: () async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Remove Workspace'),
content: Text(
'Remove "${workspace.name}"? You can re-pair later.',
),
actions: [
TextButton(
onPressed: () =>
Navigator.of(dialogContext).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () =>
Navigator.of(dialogContext).pop(true),
child: const Text('Remove'),
),
],
),
);
if (confirmed == true && context.mounted) {
final messenger = ScaffoldMessenger.of(context);
try {
await ref
.read(workspaceListProvider.notifier)
.removeWorkspace(workspace.id);
if (context.mounted) Navigator.of(context).pop();
} catch (e) {
messenger.showSnackBar(
SnackBar(
content: Text('Failed to remove workspace: $e'),
),
);
}
}
},
),
const Divider(height: 1),
ListTile(
leading: const Icon(LucideIcons.plus),
title: const Text('Add Workspace'),
onTap: () {
final nav = Navigator.of(context, rootNavigator: true);
ref.read(pairingProvider.notifier).reset();
Navigator.of(context).pop();
nav.push(
MaterialPageRoute<void>(
builder: (_) => const PairingPage(addingWorkspace: true),
),
);
},
),
],
);
},
),
);
}
}
class _WorkspaceSwitcherTile extends StatelessWidget {
final Workspace workspace;
final bool isActive;
final SessionStatus? sessionStatus;
final VoidCallback onTap;
final VoidCallback onRename;
final VoidCallback onRemove;
const _WorkspaceSwitcherTile({
required this.workspace,
required this.isActive,
required this.sessionStatus,
required this.onTap,
required this.onRename,
required this.onRemove,
});
@override
Widget build(BuildContext context) {
final host = Uri.tryParse(workspace.relayUrl)?.host ?? workspace.relayUrl;
return ListTile(
leading: _StatusDot(isActive: isActive, sessionStatus: sessionStatus),
title: Text(
workspace.name,
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
),
),
subtitle: Text(
host,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
trailing: PopupMenuButton<String>(
icon: Icon(
LucideIcons.ellipsisVertical,
size: 18,
color: context.colors.onSurfaceVariant,
),
onSelected: (value) {
switch (value) {
case 'rename':
onRename();
case 'remove':
onRemove();
}
},
itemBuilder: (_) => [
const PopupMenuItem(value: 'rename', child: Text('Rename')),
const PopupMenuItem(value: 'remove', child: Text('Remove')),
],
),
onTap: onTap,
);
}
}
class _StatusDot extends StatelessWidget {
final bool isActive;
final SessionStatus? sessionStatus;
const _StatusDot({required this.isActive, required this.sessionStatus});
@override
Widget build(BuildContext context) {
if (!isActive) {
return Container(
width: 10,
height: 10,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: context.colors.outline.withValues(alpha: 0.3),
),
);
}
final color = switch (sessionStatus) {
SessionStatus.connected => context.appColors.success,
SessionStatus.connecting ||
SessionStatus.reconnecting => context.appColors.warning,
_ => context.colors.outline,
};
return Container(
width: 10,
height: 10,
decoration: BoxDecoration(shape: BoxShape.circle, color: color),
);
}
}
class _RenameWorkspaceDialog extends HookWidget {
final String currentName;
const _RenameWorkspaceDialog({required this.currentName});
@override
Widget build(BuildContext context) {
final controller = useTextEditingController(text: currentName);
return AlertDialog(
title: const Text('Rename Workspace'),
content: TextField(
controller: controller,
autofocus: true,
decoration: const InputDecoration(labelText: 'Name'),
onSubmitted: (value) {
final name = value.trim();
if (name.isNotEmpty) Navigator.of(context).pop(name);
},
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
final name = controller.text.trim();
if (name.isNotEmpty) Navigator.of(context).pop(name);
},
child: const Text('Rename'),
),
],
);
}
}
class _WorkspaceIndicator extends ConsumerWidget {
final VoidCallback onTap;
const _WorkspaceIndicator({required this.onTap});
@override
Widget build(BuildContext context, WidgetRef ref) {
final activeAsync = ref.watch(activeWorkspaceProvider);
final name = activeAsync.value?.name;
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.only(left: _kWorkspaceAvatarInset),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_WorkspaceAvatar(name: name),
const SizedBox(width: Grid.xxs),
if (name != null)
Flexible(
child: Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
)
else
Text(
'Workspace',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
),
);
}
}
class _WorkspaceAvatar extends StatelessWidget {
final String? name;
const _WorkspaceAvatar({required this.name});
@override
Widget build(BuildContext context) {
final trimmedName = name?.trim();
final initial = trimmedName != null && trimmedName.isNotEmpty
? trimmedName.substring(0, 1).toUpperCase()
: '?';
return CircleAvatar(
radius: 16,
backgroundColor: context.colors.primaryContainer,
child: Text(
initial,
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
);
}
}

View File

@@ -16,6 +16,12 @@ import 'channel_management_provider.dart';
import 'channels_provider.dart';
import 'emoji_picker.dart';
part 'compose_bar/helpers.dart';
part 'compose_bar/suggestions.dart';
part 'compose_bar/formatting_toolbar.dart';
part 'compose_bar/attachments.dart';
part 'compose_bar/send_button.dart';
/// Rich compose bar with @mention autocomplete, emoji picker, and a markdown
/// formatting toolbar. Used in both channel and thread views — the caller
/// provides an [onSend] callback that handles actual message submission.
@@ -502,609 +508,3 @@ class ComposeBar extends HookConsumerWidget {
);
}
}
const _typingThrottleMs = 3000;
/// Walk backward from [cursor] looking for [trigger] (e.g. `@` or `#`) at a
/// word boundary. Returns the index of the trigger character, or `null` if none
/// is found.
///
/// When [stopAtSpace] is `true` the walk stops at both spaces and newlines —
/// appropriate for `#channel` names which are kebab-case slugs without spaces.
/// When `false`, only newlines stop the walk, allowing multi-word queries like
/// `@Alice Smith` to match members with multi-word display names.
@visibleForTesting
int? findTrigger(
String text,
int cursor,
String trigger, {
bool stopAtSpace = true,
}) {
for (var i = cursor - 1; i >= 0; i--) {
final ch = text[i];
if (ch == '\n') break;
if (stopAtSpace && ch == ' ') break;
if (ch == trigger) {
if (i == 0 || text[i - 1] == ' ' || text[i - 1] == '\n') {
return i;
}
break;
}
}
return null;
}
/// Replace the range `[start, cursor)` with [replacement] and move the cursor
/// to the end of the replacement. Used by both mention and channel insertion.
@visibleForTesting
void spliceAndMoveCursor(
TextEditingController controller,
FocusNode focusNode, {
required int start,
required String replacement,
}) {
final text = controller.text;
final cursor =
(controller.selection.isValid
? controller.selection.baseOffset
: text.length)
.clamp(start, text.length);
final before = text.substring(0, start);
final after = text.substring(cursor);
controller.text = '$before$replacement$after';
controller.selection = TextSelection.collapsed(
offset: start + replacement.length,
);
focusNode.requestFocus();
}
/// Insert [trigger] (e.g. `@` or `#`) at the cursor position, prefixed with
/// a space if needed for word separation. Used by `triggerMention` and
/// `triggerChannel`.
void _insertTriggerAtCursor(
TextEditingController controller,
FocusNode focusNode,
String trigger,
) {
final text = controller.text;
final cursor = controller.selection.isValid
? controller.selection.baseOffset
: text.length;
final needsSpace =
cursor > 0 && text[cursor - 1] != ' ' && text[cursor - 1] != '\n';
final insert = needsSpace ? ' $trigger' : trigger;
final before = text.substring(0, cursor);
final after = text.substring(cursor);
controller.text = '$before$insert$after';
controller.selection = TextSelection.collapsed(
offset: cursor + insert.length,
);
focusNode.requestFocus();
}
/// Send a typing indicator over the WebSocket (fire-and-forget).
///
/// Desktop sends these as `["EVENT", signedEvent]` over the WebSocket — not
/// via HTTP. Ephemeral events like typing indicators are broadcast-only and
/// the relay doesn't persist them, so the HTTP `/api/events` endpoint may
/// silently discard them.
void _sendTypingIndicator(
WidgetRef ref, {
required String channelId,
String? threadHeadId,
String? rootId,
}) {
try {
final config = ref.read(relayConfigProvider);
final nsec = config.nsec;
if (nsec == null || nsec.isEmpty) return;
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
if (privkeyHex.isEmpty) return;
final tags = <List<String>>[
['h', channelId],
if (threadHeadId != null && rootId != null && rootId != threadHeadId) ...[
['e', rootId, '', 'root'],
['e', threadHeadId, '', 'reply'],
] else if (threadHeadId != null)
['e', threadHeadId, '', 'reply'],
];
final event = nostr.Event.from(
kind: EventKind.typingIndicator,
content: '',
tags: tags,
secretKey: privkeyHex,
verify: false,
);
// Send directly over WebSocket — fire-and-forget, matching desktop.
final session = ref.read(relaySessionProvider.notifier);
session.sendRaw(['EVENT', event.toMap()]);
} catch (_) {
// Fire-and-forget — typing indicator failure is non-fatal.
}
}
List<ChannelMember> _filterMembers(
List<ChannelMember> members,
String? query,
String? currentPubkey,
Map<String, UserProfile> userCache,
) {
if (query == null) return const [];
final q = query.toLowerCase();
return members
.where(
(m) =>
currentPubkey == null ||
m.pubkey.toLowerCase() != currentPubkey.toLowerCase(),
)
.where((m) {
if (q.isEmpty) return true;
final profile = userCache[m.pubkey.toLowerCase()];
final name = (profile?.displayName ?? m.displayName ?? '')
.toLowerCase();
final firstName = name.split(RegExp(r'\s+')).first;
return name.startsWith(q) ||
firstName.startsWith(q) ||
name.contains(q);
})
.take(6)
.toList();
}
class _MentionSuggestions extends StatelessWidget {
final List<ChannelMember> suggestions;
final Map<String, UserProfile> userCache;
final String? currentPubkey;
final void Function(ChannelMember) onSelect;
const _MentionSuggestions({
required this.suggestions,
required this.userCache,
required this.currentPubkey,
required this.onSelect,
});
@override
Widget build(BuildContext context) {
return Container(
constraints: const BoxConstraints(maxHeight: 240),
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(Radii.dialog),
),
boxShadow: [
BoxShadow(
color: context.colors.shadow.withValues(alpha: 0.08),
blurRadius: 8,
offset: const Offset(0, -2),
),
],
),
child: ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
itemCount: suggestions.length,
separatorBuilder: (_, _) => const SizedBox.shrink(),
itemBuilder: (context, index) {
final member = suggestions[index];
final profile = userCache[member.pubkey.toLowerCase()];
final name = profile?.displayName?.trim().isNotEmpty == true
? profile!.displayName!.trim()
: member.labelFor(currentPubkey);
final avatarUrl = profile?.avatarUrl;
final initial =
(profile?.displayName?.trim().isNotEmpty == true
? profile!.displayName!.trim()
: member.pubkey)[0]
.toUpperCase();
return ListTile(
dense: true,
visualDensity: VisualDensity.compact,
leading: CircleAvatar(
radius: 14,
backgroundColor: context.colors.primaryContainer,
backgroundImage: avatarUrl != null
? NetworkImage(avatarUrl)
: null,
child: avatarUrl == null
? Text(
initial,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onPrimaryContainer,
),
)
: null,
),
title: Text(name, style: context.textTheme.bodyMedium),
trailing: member.isBot
? Icon(
LucideIcons.bot,
size: 14,
color: context.colors.onSurfaceVariant,
)
: null,
onTap: () => onSelect(member),
);
},
),
);
}
}
@visibleForTesting
List<Channel> filterChannels(List<Channel> channels, String? query) {
if (query == null) return const [];
final q = query.toLowerCase();
return channels
.where((c) => c.channelType != 'dm')
.where((c) {
if (q.isEmpty) return true;
return c.name.toLowerCase().contains(q);
})
.take(8)
.toList();
}
class _ChannelSuggestions extends StatelessWidget {
final List<Channel> suggestions;
final void Function(Channel) onSelect;
const _ChannelSuggestions({
required this.suggestions,
required this.onSelect,
});
@override
Widget build(BuildContext context) {
return Container(
constraints: const BoxConstraints(maxHeight: 240),
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(Radii.dialog),
),
boxShadow: [
BoxShadow(
color: context.colors.shadow.withValues(alpha: 0.08),
blurRadius: 8,
offset: const Offset(0, -2),
),
],
),
child: ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
itemCount: suggestions.length,
separatorBuilder: (_, _) => const SizedBox.shrink(),
itemBuilder: (context, index) {
final channel = suggestions[index];
return ListTile(
dense: true,
visualDensity: VisualDensity.compact,
leading: Icon(
channel.isForum ? LucideIcons.messageSquare : LucideIcons.hash,
size: 18,
color: context.colors.onSurfaceVariant,
),
title: Text(
'#${channel.name}',
style: context.textTheme.bodyMedium,
),
trailing: Text(
channel.channelType,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
onTap: () => onSelect(channel),
);
},
),
);
}
}
class _FormattingToolbar extends StatelessWidget {
final void Function(String prefix, [String? suffix]) onFormat;
const _FormattingToolbar({required this.onFormat});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: Grid.half),
child: Row(
children: [
_FormatButton(
icon: LucideIcons.bold,
tooltip: 'Bold',
onTap: () => onFormat('**'),
),
_FormatButton(
icon: LucideIcons.italic,
tooltip: 'Italic',
onTap: () => onFormat('_'),
),
_FormatButton(
icon: LucideIcons.strikethrough,
tooltip: 'Strikethrough',
onTap: () => onFormat('~~'),
),
_FormatButton(
icon: LucideIcons.code,
tooltip: 'Code',
onTap: () => onFormat('`'),
),
_FormatButton(
icon: LucideIcons.squareCode,
tooltip: 'Code block',
onTap: () => onFormat('```\n', '\n```'),
),
],
),
);
}
}
class _FormatButton extends StatelessWidget {
final IconData icon;
final String tooltip;
final VoidCallback onTap;
const _FormatButton({
required this.icon,
required this.tooltip,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Tooltip(
message: tooltip,
child: InkWell(
borderRadius: BorderRadius.circular(Radii.sm),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(Grid.xxs),
child: Icon(icon, size: 18, color: context.colors.onSurfaceVariant),
),
),
);
}
}
class _ComposeAction extends StatelessWidget {
final IconData icon;
final bool active;
final VoidCallback onTap;
const _ComposeAction({
required this.icon,
this.active = false,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 36,
height: 36,
child: IconButton(
onPressed: onTap,
icon: Icon(
icon,
size: 20,
color: active
? context.colors.primary
: context.colors.onSurfaceVariant,
),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
),
);
}
}
@immutable
class _ComposeDraftPayload {
final String content;
final List<List<String>> mediaTags;
const _ComposeDraftPayload({required this.content, required this.mediaTags});
factory _ComposeDraftPayload.fromDraft({
required String text,
required List<BlobDescriptor> attachments,
required List<CustomEmoji> customEmoji,
}) {
var content = text;
final mediaTags = <List<String>>[];
for (final attachment in attachments) {
mediaTags.add(attachment.toImetaTag());
content += '\n${attachment.toMarkdownImage()}';
}
mediaTags.addAll(buildCustomEmojiTags(content, customEmoji));
return _ComposeDraftPayload(content: content, mediaTags: mediaTags);
}
}
List<BlobDescriptor> _withoutAttachment(
List<BlobDescriptor> attachments,
String url,
) {
return [
for (final attachment in attachments)
if (attachment.url != url) attachment,
];
}
class _AttachmentStrip extends StatelessWidget {
final List<BlobDescriptor> attachments;
final int uploadingCount;
final void Function(String url) onRemove;
const _AttachmentStrip({
required this.attachments,
required this.uploadingCount,
required this.onRemove,
});
@override
Widget build(BuildContext context) {
final thumbWidth = 72.0;
final thumbHeight = 72.0;
return SizedBox(
height: thumbHeight,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: attachments.length + uploadingCount,
separatorBuilder: (_, _) => const SizedBox(width: Grid.half),
itemBuilder: (context, index) {
if (index >= attachments.length) {
return Container(
width: thumbWidth,
decoration: BoxDecoration(
color: context.colors.surface,
borderRadius: BorderRadius.circular(Radii.md),
border: Border.all(color: context.colors.outlineVariant),
),
child: const Center(
child: CircularProgressIndicator(strokeWidth: 2),
),
);
}
final attachment = attachments[index];
final isVideo = attachment.type.startsWith('video/');
final previewUrl = attachment.thumb ?? attachment.url;
return Container(
key: ValueKey('compose-attachment:${attachment.url}'),
width: thumbWidth,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(Radii.md),
border: Border.all(color: context.colors.outlineVariant),
),
child: Stack(
fit: StackFit.expand,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(Radii.md),
child: isVideo
? ColoredBox(
color: Colors.black,
child: Center(
child: Icon(
LucideIcons.video,
color: Colors.white,
size: 24,
),
),
)
: Image.network(
previewUrl,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => ColoredBox(
color: context.colors.surface,
child: Icon(
LucideIcons.image,
color: context.colors.onSurfaceVariant,
),
),
),
),
Positioned(
top: Grid.quarter,
right: Grid.quarter,
child: SizedBox(
width: 24,
height: 24,
child: IconButton(
onPressed: () => onRemove(attachment.url),
tooltip: 'Remove attachment',
visualDensity: VisualDensity.compact,
style: IconButton.styleFrom(
backgroundColor: context.colors.surface.withValues(
alpha: 0.92,
),
minimumSize: const Size(24, 24),
maximumSize: const Size(24, 24),
padding: EdgeInsets.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
icon: Icon(
LucideIcons.x,
size: 14,
color: context.colors.onSurface,
),
),
),
),
],
),
);
},
),
);
}
}
class _SendButton extends StatelessWidget {
final bool isSending;
final bool isDisabled;
final VoidCallback onTap;
const _SendButton({
required this.isSending,
required this.onTap,
this.isDisabled = false,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 36,
height: 36,
child: IconButton(
onPressed: (isSending || isDisabled) ? null : onTap,
style: IconButton.styleFrom(
backgroundColor: context.colors.primary,
disabledBackgroundColor: context.colors.primary.withValues(
alpha: 0.5,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.md),
),
),
padding: EdgeInsets.zero,
icon: isSending
? SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: context.colors.onPrimary,
),
)
: Icon(
LucideIcons.sendHorizontal,
size: 18,
color: context.colors.onPrimary,
),
),
);
}
}
String _formatUploadError(Object error) {
return error.toString().replaceFirst('Exception: ', '');
}

View File

@@ -0,0 +1,145 @@
part of '../compose_bar.dart';
@immutable
class _ComposeDraftPayload {
final String content;
final List<List<String>> mediaTags;
const _ComposeDraftPayload({required this.content, required this.mediaTags});
factory _ComposeDraftPayload.fromDraft({
required String text,
required List<BlobDescriptor> attachments,
required List<CustomEmoji> customEmoji,
}) {
var content = text;
final mediaTags = <List<String>>[];
for (final attachment in attachments) {
mediaTags.add(attachment.toImetaTag());
content += '\n${attachment.toMarkdownImage()}';
}
mediaTags.addAll(buildCustomEmojiTags(content, customEmoji));
return _ComposeDraftPayload(content: content, mediaTags: mediaTags);
}
}
List<BlobDescriptor> _withoutAttachment(
List<BlobDescriptor> attachments,
String url,
) {
return [
for (final attachment in attachments)
if (attachment.url != url) attachment,
];
}
class _AttachmentStrip extends StatelessWidget {
final List<BlobDescriptor> attachments;
final int uploadingCount;
final void Function(String url) onRemove;
const _AttachmentStrip({
required this.attachments,
required this.uploadingCount,
required this.onRemove,
});
@override
Widget build(BuildContext context) {
final thumbWidth = 72.0;
final thumbHeight = 72.0;
return SizedBox(
height: thumbHeight,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: attachments.length + uploadingCount,
separatorBuilder: (_, _) => const SizedBox(width: Grid.half),
itemBuilder: (context, index) {
if (index >= attachments.length) {
return Container(
width: thumbWidth,
decoration: BoxDecoration(
color: context.colors.surface,
borderRadius: BorderRadius.circular(Radii.md),
border: Border.all(color: context.colors.outlineVariant),
),
child: const Center(
child: CircularProgressIndicator(strokeWidth: 2),
),
);
}
final attachment = attachments[index];
final isVideo = attachment.type.startsWith('video/');
final previewUrl = attachment.thumb ?? attachment.url;
return Container(
key: ValueKey('compose-attachment:${attachment.url}'),
width: thumbWidth,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(Radii.md),
border: Border.all(color: context.colors.outlineVariant),
),
child: Stack(
fit: StackFit.expand,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(Radii.md),
child: isVideo
? ColoredBox(
color: Colors.black,
child: Center(
child: Icon(
LucideIcons.video,
color: Colors.white,
size: 24,
),
),
)
: Image.network(
previewUrl,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => ColoredBox(
color: context.colors.surface,
child: Icon(
LucideIcons.image,
color: context.colors.onSurfaceVariant,
),
),
),
),
Positioned(
top: Grid.quarter,
right: Grid.quarter,
child: SizedBox(
width: 24,
height: 24,
child: IconButton(
onPressed: () => onRemove(attachment.url),
tooltip: 'Remove attachment',
visualDensity: VisualDensity.compact,
style: IconButton.styleFrom(
backgroundColor: context.colors.surface.withValues(
alpha: 0.92,
),
minimumSize: const Size(24, 24),
maximumSize: const Size(24, 24),
padding: EdgeInsets.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
icon: Icon(
LucideIcons.x,
size: 14,
color: context.colors.onSurface,
),
),
),
),
],
),
);
},
),
);
}
}

View File

@@ -0,0 +1,102 @@
part of '../compose_bar.dart';
class _FormattingToolbar extends StatelessWidget {
final void Function(String prefix, [String? suffix]) onFormat;
const _FormattingToolbar({required this.onFormat});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: Grid.half),
child: Row(
children: [
_FormatButton(
icon: LucideIcons.bold,
tooltip: 'Bold',
onTap: () => onFormat('**'),
),
_FormatButton(
icon: LucideIcons.italic,
tooltip: 'Italic',
onTap: () => onFormat('_'),
),
_FormatButton(
icon: LucideIcons.strikethrough,
tooltip: 'Strikethrough',
onTap: () => onFormat('~~'),
),
_FormatButton(
icon: LucideIcons.code,
tooltip: 'Code',
onTap: () => onFormat('`'),
),
_FormatButton(
icon: LucideIcons.squareCode,
tooltip: 'Code block',
onTap: () => onFormat('```\n', '\n```'),
),
],
),
);
}
}
class _FormatButton extends StatelessWidget {
final IconData icon;
final String tooltip;
final VoidCallback onTap;
const _FormatButton({
required this.icon,
required this.tooltip,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Tooltip(
message: tooltip,
child: InkWell(
borderRadius: BorderRadius.circular(Radii.sm),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(Grid.xxs),
child: Icon(icon, size: 18, color: context.colors.onSurfaceVariant),
),
),
);
}
}
class _ComposeAction extends StatelessWidget {
final IconData icon;
final bool active;
final VoidCallback onTap;
const _ComposeAction({
required this.icon,
this.active = false,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 36,
height: 36,
child: IconButton(
onPressed: onTap,
icon: Icon(
icon,
size: 20,
color: active
? context.colors.primary
: context.colors.onSurfaceVariant,
),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
),
);
}
}

View File

@@ -0,0 +1,126 @@
part of '../compose_bar.dart';
const _typingThrottleMs = 3000;
/// Walk backward from [cursor] looking for [trigger] (e.g. `@` or `#`) at a
/// word boundary. Returns the index of the trigger character, or `null` if none
/// is found.
///
/// When [stopAtSpace] is `true` the walk stops at both spaces and newlines —
/// appropriate for `#channel` names which are kebab-case slugs without spaces.
/// When `false`, only newlines stop the walk, allowing multi-word queries like
/// `@Alice Smith` to match members with multi-word display names.
@visibleForTesting
int? findTrigger(
String text,
int cursor,
String trigger, {
bool stopAtSpace = true,
}) {
for (var i = cursor - 1; i >= 0; i--) {
final ch = text[i];
if (ch == '\n') break;
if (stopAtSpace && ch == ' ') break;
if (ch == trigger) {
if (i == 0 || text[i - 1] == ' ' || text[i - 1] == '\n') {
return i;
}
break;
}
}
return null;
}
/// Replace the range `[start, cursor)` with [replacement] and move the cursor
/// to the end of the replacement. Used by both mention and channel insertion.
@visibleForTesting
void spliceAndMoveCursor(
TextEditingController controller,
FocusNode focusNode, {
required int start,
required String replacement,
}) {
final text = controller.text;
final cursor =
(controller.selection.isValid
? controller.selection.baseOffset
: text.length)
.clamp(start, text.length);
final before = text.substring(0, start);
final after = text.substring(cursor);
controller.text = '$before$replacement$after';
controller.selection = TextSelection.collapsed(
offset: start + replacement.length,
);
focusNode.requestFocus();
}
/// Insert [trigger] (e.g. `@` or `#`) at the cursor position, prefixed with
/// a space if needed for word separation. Used by `triggerMention` and
/// `triggerChannel`.
void _insertTriggerAtCursor(
TextEditingController controller,
FocusNode focusNode,
String trigger,
) {
final text = controller.text;
final cursor = controller.selection.isValid
? controller.selection.baseOffset
: text.length;
final needsSpace =
cursor > 0 && text[cursor - 1] != ' ' && text[cursor - 1] != '\n';
final insert = needsSpace ? ' $trigger' : trigger;
final before = text.substring(0, cursor);
final after = text.substring(cursor);
controller.text = '$before$insert$after';
controller.selection = TextSelection.collapsed(
offset: cursor + insert.length,
);
focusNode.requestFocus();
}
/// Send a typing indicator over the WebSocket (fire-and-forget).
///
/// Desktop sends these as `["EVENT", signedEvent]` over the WebSocket — not
/// via HTTP. Ephemeral events like typing indicators are broadcast-only and
/// the relay doesn't persist them, so the HTTP `/api/events` endpoint may
/// silently discard them.
void _sendTypingIndicator(
WidgetRef ref, {
required String channelId,
String? threadHeadId,
String? rootId,
}) {
try {
final config = ref.read(relayConfigProvider);
final nsec = config.nsec;
if (nsec == null || nsec.isEmpty) return;
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
if (privkeyHex.isEmpty) return;
final tags = <List<String>>[
['h', channelId],
if (threadHeadId != null && rootId != null && rootId != threadHeadId) ...[
['e', rootId, '', 'root'],
['e', threadHeadId, '', 'reply'],
] else if (threadHeadId != null)
['e', threadHeadId, '', 'reply'],
];
final event = nostr.Event.from(
kind: EventKind.typingIndicator,
content: '',
tags: tags,
secretKey: privkeyHex,
verify: false,
);
// Send directly over WebSocket — fire-and-forget, matching desktop.
final session = ref.read(relaySessionProvider.notifier);
session.sendRaw(['EVENT', event.toMap()]);
} catch (_) {
// Fire-and-forget — typing indicator failure is non-fatal.
}
}

View File

@@ -0,0 +1,52 @@
part of '../compose_bar.dart';
class _SendButton extends StatelessWidget {
final bool isSending;
final bool isDisabled;
final VoidCallback onTap;
const _SendButton({
required this.isSending,
required this.onTap,
this.isDisabled = false,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 36,
height: 36,
child: IconButton(
onPressed: (isSending || isDisabled) ? null : onTap,
style: IconButton.styleFrom(
backgroundColor: context.colors.primary,
disabledBackgroundColor: context.colors.primary.withValues(
alpha: 0.5,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.md),
),
),
padding: EdgeInsets.zero,
icon: isSending
? SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: context.colors.onPrimary,
),
)
: Icon(
LucideIcons.sendHorizontal,
size: 18,
color: context.colors.onPrimary,
),
),
);
}
}
String _formatUploadError(Object error) {
return error.toString().replaceFirst('Exception: ', '');
}

View File

@@ -0,0 +1,186 @@
part of '../compose_bar.dart';
List<ChannelMember> _filterMembers(
List<ChannelMember> members,
String? query,
String? currentPubkey,
Map<String, UserProfile> userCache,
) {
if (query == null) return const [];
final q = query.toLowerCase();
return members
.where(
(m) =>
currentPubkey == null ||
m.pubkey.toLowerCase() != currentPubkey.toLowerCase(),
)
.where((m) {
if (q.isEmpty) return true;
final profile = userCache[m.pubkey.toLowerCase()];
final name = (profile?.displayName ?? m.displayName ?? '')
.toLowerCase();
final firstName = name.split(RegExp(r'\s+')).first;
return name.startsWith(q) ||
firstName.startsWith(q) ||
name.contains(q);
})
.take(6)
.toList();
}
class _MentionSuggestions extends StatelessWidget {
final List<ChannelMember> suggestions;
final Map<String, UserProfile> userCache;
final String? currentPubkey;
final void Function(ChannelMember) onSelect;
const _MentionSuggestions({
required this.suggestions,
required this.userCache,
required this.currentPubkey,
required this.onSelect,
});
@override
Widget build(BuildContext context) {
return Container(
constraints: const BoxConstraints(maxHeight: 240),
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(Radii.dialog),
),
boxShadow: [
BoxShadow(
color: context.colors.shadow.withValues(alpha: 0.08),
blurRadius: 8,
offset: const Offset(0, -2),
),
],
),
child: ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
itemCount: suggestions.length,
separatorBuilder: (_, _) => const SizedBox.shrink(),
itemBuilder: (context, index) {
final member = suggestions[index];
final profile = userCache[member.pubkey.toLowerCase()];
final name = profile?.displayName?.trim().isNotEmpty == true
? profile!.displayName!.trim()
: member.labelFor(currentPubkey);
final avatarUrl = profile?.avatarUrl;
final initial =
(profile?.displayName?.trim().isNotEmpty == true
? profile!.displayName!.trim()
: member.pubkey)[0]
.toUpperCase();
return ListTile(
dense: true,
visualDensity: VisualDensity.compact,
leading: CircleAvatar(
radius: 14,
backgroundColor: context.colors.primaryContainer,
backgroundImage: avatarUrl != null
? NetworkImage(avatarUrl)
: null,
child: avatarUrl == null
? Text(
initial,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onPrimaryContainer,
),
)
: null,
),
title: Text(name, style: context.textTheme.bodyMedium),
trailing: member.isBot
? Icon(
LucideIcons.bot,
size: 14,
color: context.colors.onSurfaceVariant,
)
: null,
onTap: () => onSelect(member),
);
},
),
);
}
}
@visibleForTesting
List<Channel> filterChannels(List<Channel> channels, String? query) {
if (query == null) return const [];
final q = query.toLowerCase();
return channels
.where((c) => c.channelType != 'dm')
.where((c) {
if (q.isEmpty) return true;
return c.name.toLowerCase().contains(q);
})
.take(8)
.toList();
}
class _ChannelSuggestions extends StatelessWidget {
final List<Channel> suggestions;
final void Function(Channel) onSelect;
const _ChannelSuggestions({
required this.suggestions,
required this.onSelect,
});
@override
Widget build(BuildContext context) {
return Container(
constraints: const BoxConstraints(maxHeight: 240),
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(Radii.dialog),
),
boxShadow: [
BoxShadow(
color: context.colors.shadow.withValues(alpha: 0.08),
blurRadius: 8,
offset: const Offset(0, -2),
),
],
),
child: ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
itemCount: suggestions.length,
separatorBuilder: (_, _) => const SizedBox.shrink(),
itemBuilder: (context, index) {
final channel = suggestions[index];
return ListTile(
dense: true,
visualDensity: VisualDensity.compact,
leading: Icon(
channel.isForum ? LucideIcons.messageSquare : LucideIcons.hash,
size: 18,
color: context.colors.onSurfaceVariant,
),
title: Text(
'#${channel.name}',
style: context.textTheme.bodyMedium,
),
trailing: Text(
channel.channelType,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
onTap: () => onSelect(channel),
);
},
),
);
}
}

View File

@@ -16,7 +16,7 @@ void showEmojiPicker({
context: context,
isScrollControlled: true,
showDragHandle: true,
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
backgroundColor: context.colors.surfaceContainerHighest,
builder: (sheetContext) => EmojiPickerSheet(
onSelect: (emoji) {
Navigator.of(sheetContext).pop();
@@ -213,7 +213,7 @@ class _EmojiPickerSheetState extends ConsumerState<EmojiPickerSheet> {
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final colors = context.colors;
final customEmoji = ref.watch(customEmojiListProvider);
final isCustomCategory =
customEmoji.isNotEmpty && _selectedCategory == emojiCategories.length;
@@ -341,7 +341,7 @@ class CategoryIcon extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final colors = context.colors;
return SizedBox(
width: 40,
height: 40,

View File

@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:video_player/video_player.dart';
@@ -100,6 +101,8 @@ class _MediaViewerRouteTransition extends StatelessWidget {
}
}
// StatefulWidget retained: imperative gesture/animation controllers with
// listener lifecycle don't map cleanly to hooks (allowed exception).
class MediaImageViewerPage extends StatefulWidget {
final String imageUrl;
final Object heroTag;
@@ -321,6 +324,8 @@ bool _hasImageTransform(Matrix4 transform) {
return false;
}
// StatefulWidget retained: owns a VideoPlayerController with async init and
// disposal — kept imperative deliberately (allowed exception).
class MediaVideoViewerPage extends StatefulWidget {
final String videoUrl;
final String? posterUrl;
@@ -470,45 +475,15 @@ class _VideoLoadingPoster extends StatelessWidget {
}
}
class _VideoTransportBar extends StatefulWidget {
class _VideoTransportBar extends HookWidget {
final VideoPlayerController controller;
const _VideoTransportBar({required this.controller});
@override
State<_VideoTransportBar> createState() => _VideoTransportBarState();
}
class _VideoTransportBarState extends State<_VideoTransportBar> {
@override
void initState() {
super.initState();
widget.controller.addListener(_handleTick);
}
@override
void didUpdateWidget(covariant _VideoTransportBar oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.controller == widget.controller) return;
oldWidget.controller.removeListener(_handleTick);
widget.controller.addListener(_handleTick);
}
@override
void dispose() {
widget.controller.removeListener(_handleTick);
super.dispose();
}
void _handleTick() {
if (mounted) {
setState(() {});
}
}
@override
Widget build(BuildContext context) {
final value = widget.controller.value;
useListenable(controller);
final value = controller.value;
final durationMs = value.duration.inMilliseconds;
final positionMs = value.position.inMilliseconds.clamp(0, durationMs);
@@ -518,9 +493,9 @@ class _VideoTransportBarState extends State<_VideoTransportBar> {
IconButton(
onPressed: () {
if (value.isPlaying) {
widget.controller.pause();
controller.pause();
} else {
widget.controller.play();
controller.play();
}
},
tooltip: value.isPlaying ? 'Pause video' : 'Play video',
@@ -537,9 +512,8 @@ class _VideoTransportBarState extends State<_VideoTransportBar> {
max: durationMs == 0 ? 1 : durationMs.toDouble(),
onChanged: durationMs == 0
? null
: (next) => widget.controller.seekTo(
Duration(milliseconds: next.round()),
),
: (next) =>
controller.seekTo(Duration(milliseconds: next.round())),
),
),
],

View File

@@ -62,9 +62,7 @@ void showMessageActions({
height: 44,
alignment: Alignment.center,
decoration: BoxDecoration(
color: Theme.of(
sheetContext,
).colorScheme.surfaceContainerHighest,
color: sheetContext.colors.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: Text(emoji, style: const TextStyle(fontSize: 20)),
@@ -87,17 +85,13 @@ void showMessageActions({
height: 44,
alignment: Alignment.center,
decoration: BoxDecoration(
color: Theme.of(
sheetContext,
).colorScheme.surfaceContainerHighest,
color: sheetContext.colors.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: Icon(
LucideIcons.plus,
size: 20,
color: Theme.of(
sheetContext,
).colorScheme.onSurfaceVariant,
color: sheetContext.colors.onSurfaceVariant,
),
),
),
@@ -152,13 +146,11 @@ void showMessageActions({
ListTile(
leading: Icon(
LucideIcons.trash2,
color: Theme.of(sheetContext).colorScheme.error,
color: sheetContext.colors.error,
),
title: Text(
'Delete message',
style: TextStyle(
color: Theme.of(sheetContext).colorScheme.error,
),
style: TextStyle(color: sheetContext.colors.error),
),
onTap: () {
Navigator.of(sheetContext).pop();
@@ -269,7 +261,7 @@ void _confirmDelete({
.deleteMessage(channelId: channelId, eventId: messageId);
},
style: FilledButton.styleFrom(
backgroundColor: Theme.of(dialogContext).colorScheme.error,
backgroundColor: dialogContext.colors.error,
),
child: const Text('Delete'),
),

View File

@@ -215,7 +215,7 @@ List<CustomEmoji> _mergeCustomEmoji(
return merged;
}
class _MessageImagePreview extends StatefulWidget {
class _MessageImagePreview extends HookWidget {
final String url;
final ImetaEntry? imeta;
final String semanticLabel;
@@ -226,41 +226,32 @@ class _MessageImagePreview extends StatefulWidget {
required this.semanticLabel,
});
@override
State<_MessageImagePreview> createState() => _MessageImagePreviewState();
}
class _MessageImagePreviewState extends State<_MessageImagePreview> {
late final Object _heroTag = Object();
@override
Widget build(BuildContext context) {
final layout = _resolveImagePreviewLayout(
context,
widget.imeta?.aspectRatio,
);
final heroTag = useMemoized(() => Object());
final layout = _resolveImagePreviewLayout(context, imeta?.aspectRatio);
return Padding(
padding: const EdgeInsets.only(top: Grid.half),
child: GestureDetector(
onTap: () => openImageViewer(
context,
imageUrl: widget.url,
heroTag: _heroTag,
semanticLabel: widget.semanticLabel,
imageUrl: url,
heroTag: heroTag,
semanticLabel: semanticLabel,
),
child: _MessageMediaPreviewFrame(
previewKey: ValueKey('message-media-image-preview:${widget.url}'),
previewKey: ValueKey('message-media-image-preview:$url'),
backgroundColor: context.colors.surfaceContainerHighest,
width: layout.width,
height: layout.height,
constraints: layout.constraints,
child: Hero(
tag: _heroTag,
tag: heroTag,
child: Image.network(
widget.url,
url,
fit: layout.fit,
semanticLabel: widget.semanticLabel,
semanticLabel: semanticLabel,
errorBuilder: (_, _, _) => _MediaPreviewFallback(
icon: LucideIcons.imageOff,
label: 'Image unavailable',

View File

@@ -118,7 +118,7 @@ void showReactionDetailSheet({
context: context,
isScrollControlled: true,
showDragHandle: true,
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
backgroundColor: context.colors.surfaceContainerHighest,
builder: (sheetContext) =>
_ReactionDetailSheet(reactions: reactions, initialEmoji: initialEmoji),
);

View File

@@ -203,13 +203,11 @@ class ForumPostCard extends ConsumerWidget {
ListTile(
leading: Icon(
LucideIcons.trash2,
color: Theme.of(sheetContext).colorScheme.error,
color: sheetContext.colors.error,
),
title: Text(
'Delete post',
style: TextStyle(
color: Theme.of(sheetContext).colorScheme.error,
),
style: TextStyle(color: sheetContext.colors.error),
),
onTap: () {
Navigator.of(sheetContext).pop();
@@ -240,7 +238,7 @@ class ForumPostCard extends ConsumerWidget {
onDelete?.call(post.eventId);
},
style: FilledButton.styleFrom(
backgroundColor: Theme.of(dialogContext).colorScheme.error,
backgroundColor: dialogContext.colors.error,
),
child: const Text('Delete'),
),

View File

@@ -130,13 +130,11 @@ class ForumThreadPage extends HookConsumerWidget {
ListTile(
leading: Icon(
LucideIcons.trash2,
color: Theme.of(sheetContext).colorScheme.error,
color: sheetContext.colors.error,
),
title: Text(
'Delete post',
style: TextStyle(
color: Theme.of(sheetContext).colorScheme.error,
),
style: TextStyle(color: sheetContext.colors.error),
),
onTap: () {
Navigator.of(sheetContext).pop();
@@ -174,7 +172,7 @@ class ForumThreadPage extends HookConsumerWidget {
}
},
style: FilledButton.styleFrom(
backgroundColor: Theme.of(dialogContext).colorScheme.error,
backgroundColor: dialogContext.colors.error,
),
child: const Text('Delete'),
),
@@ -496,13 +494,11 @@ class _ReplyRow extends ConsumerWidget {
ListTile(
leading: Icon(
LucideIcons.trash2,
color: Theme.of(sheetContext).colorScheme.error,
color: sheetContext.colors.error,
),
title: Text(
'Delete reply',
style: TextStyle(
color: Theme.of(sheetContext).colorScheme.error,
),
style: TextStyle(color: sheetContext.colors.error),
),
onTap: () {
Navigator.of(sheetContext).pop();
@@ -538,7 +534,7 @@ class _ReplyRow extends ConsumerWidget {
);
},
style: FilledButton.styleFrom(
backgroundColor: Theme.of(dialogContext).colorScheme.error,
backgroundColor: dialogContext.colors.error,
),
child: const Text('Delete'),
),

View File

@@ -5,7 +5,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/theme/grid.dart';
import '../../shared/theme/theme.dart';
import '../activity/activity_page.dart';
import '../channels/channels_page.dart';
import '../search/search_page.dart';
@@ -104,8 +104,8 @@ class _FloatingTabBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
final colorScheme = context.colors;
final isDark = context.theme.brightness == Brightness.dark;
return SafeArea(
minimum: const EdgeInsets.fromLTRB(
HomePage._tabBarHorizontalMargin,
@@ -222,8 +222,8 @@ class _FloatingTabDestination extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textStyle = Theme.of(context).textTheme.labelSmall;
final colorScheme = context.colors;
final textStyle = context.textTheme.labelSmall;
final foregroundColor = selected
? colorScheme.onPrimary
: colorScheme.onSurfaceVariant;

View File

@@ -26,7 +26,7 @@ class SettingsPage extends HookConsumerWidget {
final config = ref.watch(relayConfigProvider);
final selectedAccent = ref.watch(accentProvider);
final selectedScheme = ref.watch(schemeProvider);
final colorScheme = Theme.of(context).colorScheme;
final colorScheme = context.colors;
final packageInfoFuture = useMemoized(() => PackageInfo.fromPlatform());
final packageInfo = useFuture(packageInfoFuture);
@@ -210,9 +210,7 @@ class SettingsPage extends HookConsumerWidget {
Navigator.of(context).popUntil((route) => route.isFirst);
ref.read(authProvider.notifier).signOut();
},
style: FilledButton.styleFrom(
backgroundColor: Theme.of(ctx).colorScheme.error,
),
style: FilledButton.styleFrom(backgroundColor: ctx.colors.error),
child: const Text('Remove'),
),
],

View File

@@ -0,0 +1,30 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { runFileSizeCheck } from "../../scripts/check-file-sizes-core.mjs";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, "..");
const MAX_LINES = 1000;
const rules = [
{
root: "lib",
extensions: new Set([".dart"]),
maxLines: MAX_LINES,
},
];
// TEMP — these files exceed the 1000-line limit and are queued to be split.
// Do not add to this list; split the file instead. Remove each entry as its
// file is broken up.
const overrides = new Map([
]);
await runFileSizeCheck({
projectRoot,
rules,
overrides,
label: "Mobile",
scriptPath: "mobile/scripts/check-file-sizes.mjs",
});