mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
Refactor oversized mobile widgets (#1401)
Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
12
AGENTS.md
12
AGENTS.md
@@ -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.
|
||||
|
||||
2
Justfile
2
Justfile
@@ -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
248
mobile/lib/features/channels/channel_detail_page/app_bar.dart
Normal file
248
mobile/lib/features/channels/channel_detail_page/app_bar.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
183
mobile/lib/features/channels/channels_page/badges.dart
Normal file
183
mobile/lib/features/channels/channels_page/badges.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
349
mobile/lib/features/channels/channels_page/body.dart
Normal file
349
mobile/lib/features/channels/channels_page/body.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
436
mobile/lib/features/channels/channels_page/channel_tile.dart
Normal file
436
mobile/lib/features/channels/channels_page/channel_tile.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
532
mobile/lib/features/channels/channels_page/sections.dart
Normal file
532
mobile/lib/features/channels/channels_page/sections.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
356
mobile/lib/features/channels/channels_page/sheets.dart
Normal file
356
mobile/lib/features/channels/channels_page/sheets.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
320
mobile/lib/features/channels/channels_page/workspace.dart
Normal file
320
mobile/lib/features/channels/channels_page/workspace.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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: ', '');
|
||||
}
|
||||
|
||||
145
mobile/lib/features/channels/compose_bar/attachments.dart
Normal file
145
mobile/lib/features/channels/compose_bar/attachments.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
102
mobile/lib/features/channels/compose_bar/formatting_toolbar.dart
Normal file
102
mobile/lib/features/channels/compose_bar/formatting_toolbar.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
126
mobile/lib/features/channels/compose_bar/helpers.dart
Normal file
126
mobile/lib/features/channels/compose_bar/helpers.dart
Normal 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.
|
||||
}
|
||||
}
|
||||
52
mobile/lib/features/channels/compose_bar/send_button.dart
Normal file
52
mobile/lib/features/channels/compose_bar/send_button.dart
Normal 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: ', '');
|
||||
}
|
||||
186
mobile/lib/features/channels/compose_bar/suggestions.dart
Normal file
186
mobile/lib/features/channels/compose_bar/suggestions.dart
Normal 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),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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())),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
|
||||
30
mobile/scripts/check-file-sizes.mjs
Normal file
30
mobile/scripts/check-file-sizes.mjs
Normal 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",
|
||||
});
|
||||
Reference in New Issue
Block a user