feat(mobile): channel management — create, browse, join/leave, DMs, canvas (#331)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wes
2026-04-15 21:28:46 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 711e8213ac
commit affeee60c2
13 changed files with 2493 additions and 154 deletions
@@ -18,6 +18,7 @@ const MOBILE_SCOPES: TokenScope[] = [
"messages:read",
"messages:write",
"channels:read",
"channels:write",
"users:read",
"files:read",
];
+146 -4
View File
@@ -1,5 +1,7 @@
import 'package:flutter/foundation.dart';
const Object _sentinel = Object();
@immutable
class Channel {
final String id;
@@ -13,6 +15,9 @@ class Channel {
final DateTime createdAt;
final int memberCount;
final DateTime? lastMessageAt;
final DateTime? archivedAt;
final List<String> participants;
final List<String> participantPubkeys;
final bool isMember;
final int? ttlSeconds;
final DateTime? ttlDeadline;
@@ -29,6 +34,9 @@ class Channel {
this.topic,
this.purpose,
this.lastMessageAt,
this.archivedAt,
this.participants = const [],
this.participantPubkeys = const [],
this.isMember = false,
this.ttlSeconds,
this.ttlDeadline,
@@ -48,6 +56,14 @@ class Channel {
lastMessageAt: json['last_message_at'] != null
? DateTime.parse(json['last_message_at'] as String)
: null,
archivedAt: json['archived_at'] != null
? DateTime.parse(json['archived_at'] as String)
: null,
participants: (json['participants'] as List<dynamic>? ?? const [])
.cast<String>(),
participantPubkeys:
(json['participant_pubkeys'] as List<dynamic>? ?? const [])
.cast<String>(),
isMember: json['is_member'] as bool? ?? false,
ttlSeconds: json['ttl_seconds'] as int?,
ttlDeadline: json['ttl_deadline'] != null
@@ -61,8 +77,58 @@ class Channel {
bool get isForum => channelType == 'forum';
bool get isDm => channelType == 'dm';
bool get isPrivate => visibility == 'private';
bool get isArchived => archivedAt != null;
Channel copyWith({DateTime? lastMessageAt}) => Channel(
String displayLabel({String? currentPubkey}) {
if (!isDm || participants.isEmpty) {
return name;
}
final normalizedCurrent = currentPubkey?.toLowerCase();
final labels = <String>[];
for (var index = 0; index < participants.length; index++) {
final participantPubkey = index < participantPubkeys.length
? participantPubkeys[index].toLowerCase()
: null;
if (participantPubkey != null && participantPubkey == normalizedCurrent) {
continue;
}
labels.add(participants[index]);
}
if (labels.isEmpty) {
labels.addAll(participants);
}
return labels.join(', ');
}
Channel mergeDetails(ChannelDetails details) => Channel(
id: id,
name: details.name,
channelType: details.channelType,
visibility: details.visibility,
description: details.description,
topic: details.topic,
purpose: details.purpose,
createdBy: details.createdBy,
createdAt: details.createdAt,
memberCount: details.memberCount,
lastMessageAt: lastMessageAt,
archivedAt: details.archivedAt,
participants: participants,
participantPubkeys: participantPubkeys,
isMember: isMember,
ttlSeconds: details.ttlSeconds,
ttlDeadline: details.ttlDeadline,
);
Channel copyWith({
Object? lastMessageAt = _sentinel,
Object? archivedAt = _sentinel,
int? memberCount,
bool? isMember,
}) => Channel(
id: id,
name: name,
channelType: channelType,
@@ -72,10 +138,86 @@ class Channel {
purpose: purpose,
createdBy: createdBy,
createdAt: createdAt,
memberCount: memberCount,
lastMessageAt: lastMessageAt ?? this.lastMessageAt,
isMember: isMember,
memberCount: memberCount ?? this.memberCount,
lastMessageAt: identical(lastMessageAt, _sentinel)
? this.lastMessageAt
: lastMessageAt as DateTime?,
archivedAt: identical(archivedAt, _sentinel)
? this.archivedAt
: archivedAt as DateTime?,
participants: participants,
participantPubkeys: participantPubkeys,
isMember: isMember ?? this.isMember,
ttlSeconds: ttlSeconds,
ttlDeadline: ttlDeadline,
);
}
@immutable
class ChannelDetails {
final String id;
final String name;
final String channelType;
final String visibility;
final String description;
final String? topic;
final String? purpose;
final String createdBy;
final DateTime createdAt;
final int memberCount;
final DateTime? archivedAt;
final int? ttlSeconds;
final DateTime? ttlDeadline;
const ChannelDetails({
required this.id,
required this.name,
required this.channelType,
required this.visibility,
required this.description,
required this.createdBy,
required this.createdAt,
required this.memberCount,
this.topic,
this.purpose,
this.archivedAt,
this.ttlSeconds,
this.ttlDeadline,
});
factory ChannelDetails.fromJson(Map<String, dynamic> json) => ChannelDetails(
id: json['id'] as String,
name: json['name'] as String,
channelType: json['channel_type'] as String,
visibility: json['visibility'] as String,
description: (json['description'] as String?) ?? '',
topic: json['topic'] as String?,
purpose: json['purpose'] as String?,
createdBy: json['created_by'] as String,
createdAt: DateTime.parse(json['created_at'] as String),
memberCount: json['member_count'] as int,
archivedAt: json['archived_at'] != null
? DateTime.parse(json['archived_at'] as String)
: null,
ttlSeconds: json['ttl_seconds'] as int?,
ttlDeadline: json['ttl_deadline'] != null
? DateTime.parse(json['ttl_deadline'] as String)
: null,
);
factory ChannelDetails.fromChannel(Channel channel) => ChannelDetails(
id: channel.id,
name: channel.name,
channelType: channel.channelType,
visibility: channel.visibility,
description: channel.description,
topic: channel.topic,
purpose: channel.purpose,
createdBy: channel.createdBy,
createdAt: channel.createdAt,
memberCount: channel.memberCount,
archivedAt: channel.archivedAt,
ttlSeconds: channel.ttlSeconds,
ttlDeadline: channel.ttlDeadline,
);
}
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -5,9 +7,11 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
import '../profile/profile_provider.dart';
import '../profile/user_cache_provider.dart';
import '../profile/user_profile.dart';
import 'channel.dart';
import 'channel_management_provider.dart';
import 'channel_messages_provider.dart';
import 'channel_typing_provider.dart';
import 'channels_provider.dart';
@@ -43,8 +47,26 @@ class ChannelDetailPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final detailsAsync = ref.watch(channelDetailsProvider(channel.id));
final channelsAsync = ref.watch(channelsProvider);
final messagesState = ref.watch(channelMessagesProvider(channel.id));
final typingEntries = ref.watch(channelTypingProvider(channel.id));
final currentPubkey = ref
.watch(profileProvider)
.whenData((value) => value?.pubkey)
.value;
final baseChannel =
channelsAsync
.whenData(
(channels) => channels.firstWhere(
(candidate) => candidate.id == channel.id,
orElse: () => channel,
),
)
.value ??
channel;
final resolvedChannel =
detailsAsync.whenData(baseChannel.mergeDetails).value ?? baseChannel;
// Preload channel member profiles so @mentions resolve correctly.
useEffect(() {
@@ -57,39 +79,88 @@ class ChannelDetailPage extends HookConsumerWidget {
title: Row(
children: [
Icon(
channel.isPrivate ? LucideIcons.lock : LucideIcons.hash,
channelIcon(resolvedChannel),
size: 18,
color: context.colors.onSurfaceVariant,
),
const SizedBox(width: Grid.half),
Expanded(
child: Text(channel.name, overflow: TextOverflow.ellipsis),
child: Text(
resolvedChannel.displayLabel(currentPubkey: currentPubkey),
overflow: TextOverflow.ellipsis,
),
),
],
),
actions: [
IconButton(
onPressed: () {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (_) => _MembersSheet(
channel: resolvedChannel,
currentPubkey: currentPubkey,
),
);
},
tooltip: 'View members',
icon: const Icon(LucideIcons.users),
),
if (!resolvedChannel.isDm)
IconButton(
onPressed: () async {
final shouldClose = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (_) => _ManageChannelSheet(channel: resolvedChannel),
);
if (shouldClose == true && context.mounted) {
Navigator.of(context).pop();
}
},
tooltip: 'Manage channel',
icon: const Icon(LucideIcons.ellipsis),
),
],
),
body: Column(
children: [
Expanded(
child: messagesState.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(
child: Text(
'Failed to load messages',
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.error,
child: resolvedChannel.isForum
? _ForumPlaceholder(channel: resolvedChannel)
: messagesState.when(
loading: () =>
const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(
child: Text(
'Failed to load messages',
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.error,
),
),
),
data: (events) {
final messages = formatTimeline(events);
return _MessageList(
messages: messages,
channelId: channel.id,
);
},
),
),
),
data: (events) {
final messages = formatTimeline(events);
return _MessageList(messages: messages, channelId: channel.id);
},
),
),
if (typingEntries.isNotEmpty)
if (!resolvedChannel.isForum && typingEntries.isNotEmpty)
_TypingIndicator(entries: typingEntries),
_ComposeBar(channelId: channel.id),
if (!resolvedChannel.isForum &&
resolvedChannel.isMember &&
!resolvedChannel.isArchived)
_ComposeBar(channelId: channel.id)
else if (!resolvedChannel.isForum &&
!resolvedChannel.isDm &&
(!resolvedChannel.isMember || resolvedChannel.isArchived))
_ReadOnlyNotice(channel: resolvedChannel),
],
),
);
@@ -389,6 +460,491 @@ class _UserAvatar extends StatelessWidget {
}
}
// ---------------------------------------------------------------------------
// Channel management
// ---------------------------------------------------------------------------
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;
}
class _ForumPlaceholder extends StatelessWidget {
final Channel channel;
const _ForumPlaceholder({required this.channel});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.sm),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.messageSquareText,
size: Grid.xl,
color: context.colors.outline,
),
const SizedBox(height: Grid.xs),
Text(
'Forum threads are not on mobile yet',
style: context.textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: Grid.xxs),
Text(
'You can still view channel context, canvas, and members from the actions above.',
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
if (channel.description.trim().isNotEmpty) ...[
const SizedBox(height: Grid.xs),
Text(
channel.description,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.outline,
),
textAlign: TextAlign.center,
),
],
],
),
),
);
}
}
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.xs,
right: Grid.xs,
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 _MembersSheet extends HookConsumerWidget {
final Channel channel;
final String? currentPubkey;
const _MembersSheet({required this.channel, required this.currentPubkey});
@override
Widget build(BuildContext context, WidgetRef ref) {
final membersAsync = ref.watch(channelMembersProvider(channel.id));
final allMembers = membersAsync.asData?.value ?? const <ChannelMember>[];
final people = allMembers.where((member) => !member.isBot).toList();
return Padding(
padding: EdgeInsets.fromLTRB(
Grid.xs,
0,
Grid.xs,
MediaQuery.viewInsetsOf(context).bottom + Grid.xs,
),
child: SafeArea(
top: false,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Members', style: context.textTheme.titleMedium),
const SizedBox(height: Grid.xxs),
Text(
'People in ${channel.displayLabel(currentPubkey: currentPubkey)}.',
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
if (!channel.isDm) ...[
const SizedBox(height: Grid.xxs),
Text(
channel.isArchived
? 'Archived channels are read-only on mobile. Member and bot management stay on desktop.'
: 'Member and bot management stay on desktop.',
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.outline,
),
),
],
if (!channel.isDm) ...[const Divider(height: Grid.sm)],
SizedBox(
height: 280,
child: membersAsync.when(
data: (_) => people.isEmpty
? Center(
child: Text(
'No people found.',
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.outline,
),
),
)
: ListView(
shrinkWrap: true,
children: [
for (final member in people)
ListTile(
contentPadding: EdgeInsets.zero,
leading: CircleAvatar(
child: Text(
member
.labelFor(currentPubkey)
.substring(0, 1)
.toUpperCase(),
),
),
title: Text(member.labelFor(currentPubkey)),
subtitle: Text(member.role),
),
],
),
loading: () =>
const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(
child: Text(
error.toString(),
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.error,
),
),
),
),
),
],
),
),
),
);
}
}
class _ManageChannelSheet extends HookConsumerWidget {
final Channel channel;
const _ManageChannelSheet({required this.channel});
@override
Widget build(BuildContext context, WidgetRef ref) {
final canvasAsync = ref.watch(channelCanvasProvider(channel.id));
final isEditingCanvas = useState(false);
final isSavingCanvas = useState(false);
final isBusy = useState(false);
final actionError = useState<String?>(null);
final canvasController = useTextEditingController();
useEffect(() {
final canvas = canvasAsync.asData?.value;
if (!isEditingCanvas.value) {
canvasController.text = canvas?.content ?? '';
}
return null;
}, [canvasAsync.asData?.value.content, isEditingCanvas.value]);
final canJoin =
channel.visibility == 'open' &&
!channel.isArchived &&
!channel.isMember &&
!channel.isDm;
final canLeave = channel.isMember && !channel.isArchived && !channel.isDm;
final canEditCanvas = channel.isMember && !channel.isArchived;
Future<void> joinChannel() async {
if (isBusy.value) return;
isBusy.value = true;
actionError.value = null;
try {
await ref.read(channelActionsProvider).joinChannel(channel.id);
if (context.mounted) {
Navigator.of(context).pop(false);
}
} catch (error) {
actionError.value = error.toString();
} finally {
isBusy.value = false;
}
}
Future<void> leaveChannel() async {
if (isBusy.value) return;
isBusy.value = true;
actionError.value = null;
try {
await ref.read(channelActionsProvider).leaveChannel(channel.id);
if (context.mounted) {
Navigator.of(context).pop(true);
}
} catch (error) {
actionError.value = error.toString();
} finally {
isBusy.value = false;
}
}
Future<void> saveCanvas() async {
if (isSavingCanvas.value) {
return;
}
isSavingCanvas.value = true;
actionError.value = null;
try {
await ref
.read(channelActionsProvider)
.setCanvas(
channelId: channel.id,
content: canvasController.text.trim(),
);
if (context.mounted) {
isEditingCanvas.value = false;
}
} catch (error) {
actionError.value = error.toString();
} finally {
isSavingCanvas.value = false;
}
}
return Padding(
padding: EdgeInsets.fromLTRB(
Grid.xs,
0,
Grid.xs,
MediaQuery.viewInsetsOf(context).bottom + Grid.xs,
),
child: SafeArea(
top: false,
child: ListView(
shrinkWrap: true,
children: [
Text('Manage channel', style: context.textTheme.titleMedium),
const SizedBox(height: Grid.xxs),
Text(
'Basic management for ${channel.name}.',
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
if (actionError.value case final error?) ...[
const SizedBox(height: Grid.xs),
Text(
error,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.error,
),
),
],
if (canJoin || canLeave) ...[
const SizedBox(height: Grid.xs),
Wrap(
spacing: Grid.xxs,
children: [
if (canJoin)
FilledButton.tonal(
onPressed: isBusy.value ? null : joinChannel,
child: Text(isBusy.value ? 'Joining…' : 'Join channel'),
),
if (canLeave)
OutlinedButton(
onPressed: isBusy.value ? null : leaveChannel,
child: Text(isBusy.value ? 'Leaving…' : 'Leave channel'),
),
],
),
],
const SizedBox(height: Grid.sm),
Text('Context', style: context.textTheme.labelLarge),
const SizedBox(height: Grid.xxs),
_ContextCard(
label: 'Description',
value: channel.description,
emptyLabel: 'No description set',
),
const SizedBox(height: Grid.xxs),
_ContextCard(
label: 'Topic',
value: channel.topic,
emptyLabel: 'No topic set',
),
const SizedBox(height: Grid.xxs),
_ContextCard(
label: 'Purpose',
value: channel.purpose,
emptyLabel: 'No purpose set',
),
if (!channel.isDm) ...[
const SizedBox(height: Grid.sm),
Text('Canvas', style: context.textTheme.labelLarge),
const SizedBox(height: Grid.xxs),
canvasAsync.when(
data: (canvas) {
if (isEditingCanvas.value) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: canvasController,
maxLines: 8,
minLines: 6,
decoration: const InputDecoration(
hintText: 'Write your canvas content in Markdown…',
),
),
const SizedBox(height: Grid.xxs),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: isSavingCanvas.value
? null
: () {
isEditingCanvas.value = false;
canvasController.text =
canvas.content ?? '';
},
child: const Text('Cancel'),
),
const SizedBox(width: Grid.half),
FilledButton(
onPressed: isSavingCanvas.value
? null
: saveCanvas,
child: Text(
isSavingCanvas.value
? 'Saving…'
: 'Save canvas',
),
),
],
),
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.all(Grid.xs),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.md),
),
child: Text(
canvas.content?.trim().isNotEmpty == true
? canvas.content!
: 'No canvas set for this channel.',
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
const SizedBox(height: Grid.xxs),
Align(
alignment: Alignment.centerRight,
child: FilledButton.tonal(
onPressed: canEditCanvas
? () => isEditingCanvas.value = true
: null,
child: Text(
canvas.content?.trim().isNotEmpty == true
? 'Edit canvas'
: 'Create canvas',
),
),
),
],
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Text(
error.toString(),
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.error,
),
),
),
],
],
),
),
);
}
}
class _ContextCard extends StatelessWidget {
final String label;
final String? value;
final String emptyLabel;
const _ContextCard({
required this.label,
required this.value,
required this.emptyLabel,
});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(Grid.xs),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.md),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.outline,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: Grid.half),
Text(
value?.trim().isNotEmpty == true ? value!.trim() : emptyLabel,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Typing indicator
// ---------------------------------------------------------------------------
@@ -0,0 +1,314 @@
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/auth/auth.dart';
import '../../shared/relay/relay.dart';
import '../profile/profile_provider.dart';
import 'channel.dart';
import 'channels_provider.dart';
@immutable
class ChannelMember {
final String pubkey;
final String role;
final DateTime joinedAt;
final String? displayName;
const ChannelMember({
required this.pubkey,
required this.role,
required this.joinedAt,
this.displayName,
});
factory ChannelMember.fromJson(Map<String, dynamic> json) => ChannelMember(
pubkey: json['pubkey'] as String,
role: json['role'] as String? ?? 'member',
joinedAt: DateTime.parse(json['joined_at'] as String),
displayName: json['display_name'] as String?,
);
bool get isBot => role == 'bot';
String labelFor(String? currentPubkey) {
if (currentPubkey != null &&
currentPubkey.toLowerCase() == pubkey.toLowerCase()) {
return 'You';
}
if (displayName case final name? when name.trim().isNotEmpty) {
return name.trim();
}
return pubkey.length > 8 ? '${pubkey.substring(0, 8)}' : pubkey;
}
}
@immutable
class ChannelCanvas {
final String? content;
final DateTime? updatedAt;
final String? authorPubkey;
const ChannelCanvas({
required this.content,
required this.updatedAt,
required this.authorPubkey,
});
factory ChannelCanvas.fromJson(Map<String, dynamic> json) => ChannelCanvas(
content: json['content'] as String?,
updatedAt: json['updated_at'] != null
? DateTime.fromMillisecondsSinceEpoch(
(json['updated_at'] as int) * 1000,
isUtc: true,
)
: null,
authorPubkey: json['author'] as String?,
);
}
@immutable
class DirectoryUser {
final String pubkey;
final String? displayName;
final String? avatarUrl;
final String? nip05Handle;
const DirectoryUser({
required this.pubkey,
this.displayName,
this.avatarUrl,
this.nip05Handle,
});
factory DirectoryUser.fromJson(Map<String, dynamic> json) => DirectoryUser(
pubkey: json['pubkey'] as String,
displayName: json['display_name'] as String?,
avatarUrl: json['avatar_url'] as String?,
nip05Handle: json['nip05_handle'] as String?,
);
String get label {
final display = displayName?.trim();
if (display != null && display.isNotEmpty) {
return display;
}
final nip05 = nip05Handle?.trim();
if (nip05 != null && nip05.isNotEmpty) {
return nip05;
}
return pubkey.length > 8 ? '${pubkey.substring(0, 8)}' : pubkey;
}
String get secondaryLabel {
final nip05 = nip05Handle?.trim();
if (nip05 != null && nip05.isNotEmpty && nip05 != label) {
return nip05;
}
return pubkey.length > 16 ? '${pubkey.substring(0, 16)}' : pubkey;
}
}
final currentPubkeyProvider = Provider<String?>((ref) {
final profile = ref.watch(profileProvider).whenData((value) => value).value;
final profilePubkey = profile?.pubkey.trim();
if (profilePubkey != null && profilePubkey.isNotEmpty) {
return profilePubkey.toLowerCase();
}
final authState = ref.watch(authProvider).whenData((value) => value).value;
final credentialPubkey = authState?.credentials?.pubkey?.trim();
if (credentialPubkey != null && credentialPubkey.isNotEmpty) {
return credentialPubkey.toLowerCase();
}
return null;
});
final channelDetailsProvider = FutureProvider.family<ChannelDetails, String>((
ref,
channelId,
) async {
final client = ref.watch(relayClientProvider);
final json =
await client.get('/api/channels/$channelId') as Map<String, dynamic>;
return ChannelDetails.fromJson(json);
});
final channelMembersProvider =
FutureProvider.family<List<ChannelMember>, String>((ref, channelId) async {
final client = ref.watch(relayClientProvider);
final json =
await client.get('/api/channels/$channelId/members')
as Map<String, dynamic>;
final members = json['members'] as List<dynamic>? ?? const [];
return members
.cast<Map<String, dynamic>>()
.map(ChannelMember.fromJson)
.toList();
});
final channelCanvasProvider = FutureProvider.family<ChannelCanvas, String>((
ref,
channelId,
) async {
final client = ref.watch(relayClientProvider);
final json =
await client.get('/api/channels/$channelId/canvas')
as Map<String, dynamic>;
return ChannelCanvas.fromJson(json);
});
class ChannelActions {
final Ref _ref;
final RelayClient _client;
final SignedEventRelay _signedEventRelay;
final String? _currentPubkey;
ChannelActions({
required Ref ref,
required RelayClient client,
required SignedEventRelay signedEventRelay,
required String? currentPubkey,
}) : _ref = ref,
_client = client,
_signedEventRelay = signedEventRelay,
_currentPubkey = currentPubkey;
Future<Channel> createChannel({
required String name,
required String channelType,
required String visibility,
String? description,
}) async {
final channelId = _newUuidV4();
final tags = <List<String>>[
['h', channelId],
['name', name],
['visibility', visibility],
['channel_type', channelType],
if (description case final about? when about.trim().isNotEmpty)
['about', about.trim()],
];
await _signedEventRelay.submit(kind: 9007, content: '', tags: tags);
return _refreshChannelsAndRead(channelId);
}
Future<Channel> openDm({required List<String> pubkeys}) async {
final json =
await _client.post('/api/dms', body: {'pubkeys': pubkeys})
as Map<String, dynamic>;
final channelId = json['channel_id'] as String?;
if (channelId == null || channelId.isEmpty) {
throw Exception('Relay did not return a DM channel id');
}
return _refreshChannelsAndRead(channelId);
}
Future<void> joinChannel(String channelId) async {
await _signedEventRelay.submit(
kind: 9021,
content: '',
tags: [
['h', channelId],
],
);
await _refreshChannelState(channelId);
}
Future<void> leaveChannel(String channelId) async {
await _signedEventRelay.submit(
kind: 9022,
content: '',
tags: [
['h', channelId],
],
);
await _refreshChannelState(channelId);
}
Future<void> setCanvas({
required String channelId,
required String content,
}) async {
await _signedEventRelay.submit(
kind: 40100,
content: content,
tags: [
['h', channelId],
],
);
_ref.invalidate(channelCanvasProvider(channelId));
}
Future<List<DirectoryUser>> searchUsers(String query, {int limit = 8}) async {
final trimmed = query.trim();
if (trimmed.isEmpty) {
return const [];
}
final json =
await _client.get(
'/api/users/search',
queryParams: {'q': trimmed, 'limit': '$limit'},
)
as Map<String, dynamic>;
final users = json['users'] as List<dynamic>? ?? const [];
return users
.cast<Map<String, dynamic>>()
.map(DirectoryUser.fromJson)
.where(
(user) =>
_currentPubkey == null ||
user.pubkey.toLowerCase() != _currentPubkey,
)
.toList();
}
Future<Channel> _refreshChannelsAndRead(String channelId) async {
await _ref.read(channelsProvider.notifier).refresh();
final channels = await _ref.read(channelsProvider.future);
return channels.firstWhere(
(channel) => channel.id == channelId,
orElse: () =>
throw Exception('Channel was created but is not visible yet'),
);
}
Future<void> _refreshChannelState(String channelId) async {
await _ref.read(channelsProvider.notifier).refresh();
_ref.invalidate(channelDetailsProvider(channelId));
_ref.invalidate(channelMembersProvider(channelId));
_ref.invalidate(channelCanvasProvider(channelId));
}
String _newUuidV4() {
final bytes = List<int>.generate(16, (_) => _random.nextInt(256));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
final hex = bytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join();
return '${hex.substring(0, 8)}-'
'${hex.substring(8, 12)}-'
'${hex.substring(12, 16)}-'
'${hex.substring(16, 20)}-'
'${hex.substring(20, 32)}';
}
static final Random _random = Random.secure();
}
final channelActionsProvider = Provider<ChannelActions>((ref) {
final client = ref.watch(relayClientProvider);
final relayConfig = ref.watch(relayConfigProvider);
final currentPubkey = ref.watch(currentPubkeyProvider);
return ChannelActions(
ref: ref,
client: client,
signedEventRelay: SignedEventRelay(client: client, nsec: relayConfig.nsec),
currentPubkey: currentPubkey,
);
});
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/relay/relay.dart';
import 'channel.dart';
const _channelTypeOrder = {'stream': 0, 'forum': 1, 'dm': 2};
class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
void Function()? _unsubscribe;
@@ -34,9 +36,16 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
final channels = json
.cast<Map<String, dynamic>>()
.map(Channel.fromJson)
.where((c) => !c.isDm) // exclude DMs from channel list
.toList();
channels.sort((a, b) => a.name.compareTo(b.name));
channels.sort((left, right) {
final typeOrder =
(_channelTypeOrder[left.channelType] ?? 99) -
(_channelTypeOrder[right.channelType] ?? 99);
if (typeOrder != 0) {
return typeOrder;
}
return left.name.compareTo(right.name);
});
return channels;
}
@@ -1,7 +1,4 @@
import 'dart:convert';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import '../../shared/relay/relay.dart';
import '../profile/user_cache_provider.dart';
@@ -11,16 +8,13 @@ import '../profile/user_profile.dart';
/// with the user's nsec, then POSTed as a full signed Nostr event — matching
/// what the desktop does via `submit_event`.
class SendMessage {
final RelayClient _client;
final String? _nsec;
final SignedEventRelay _signedEventRelay;
final Map<String, UserProfile> Function() _readUserCache;
SendMessage({
required RelayClient client,
required String? nsec,
required SignedEventRelay signedEventRelay,
required Map<String, UserProfile> Function() readUserCache,
}) : _client = client,
_nsec = nsec,
}) : _signedEventRelay = signedEventRelay,
_readUserCache = readUserCache;
/// Send a text message to a channel.
@@ -30,17 +24,6 @@ class SendMessage {
String? parentEventId,
List<String>? mentionPubkeys,
}) async {
final nsec = _nsec;
if (nsec == null || nsec.isEmpty) {
throw Exception('Cannot send messages: no signing key available');
}
// Decode bech32 nsec to hex private key.
final privkeyHex = nostr.Nip19.decodePrivkey(nsec);
if (privkeyHex.isEmpty) {
throw Exception('Invalid nsec');
}
// Resolve @mentions in the message content to pubkeys.
final resolvedMentions = mentionPubkeys ?? _resolveMentions(content);
@@ -50,25 +33,11 @@ class SendMessage {
for (final pk in resolvedMentions) ['p', pk],
];
// Create and sign the event using the nostr package.
final event = nostr.Event.from(
await _signedEventRelay.submit(
kind: EventKind.streamMessage,
content: content,
tags: tags,
privkey: privkeyHex,
verify: false,
);
// POST the full signed event JSON to the relay.
final response = await _client.postRaw(
'/api/events',
body: jsonEncode(event.toJson()),
);
final result = jsonDecode(response) as Map<String, dynamic>;
if (result['accepted'] != true) {
throw Exception(result['message'] ?? 'Event rejected by relay');
}
}
/// Parse @mentions from message content and resolve to pubkeys using
@@ -104,11 +73,12 @@ class SendMessage {
}
final sendMessageProvider = Provider<SendMessage>((ref) {
final client = ref.watch(relayClientProvider);
final config = ref.watch(relayConfigProvider);
return SendMessage(
client: client,
nsec: config.nsec,
signedEventRelay: SignedEventRelay(
client: ref.watch(relayClientProvider),
nsec: config.nsec,
),
readUserCache: () => ref.read(userCacheProvider),
);
});
+1
View File
@@ -4,3 +4,4 @@ export 'relay_client.dart';
export 'relay_provider.dart';
export 'relay_session.dart';
export 'relay_socket.dart';
export 'signed_event_relay.dart';
@@ -0,0 +1,48 @@
import 'dart:convert';
import 'package:nostr/nostr.dart' as nostr;
import 'relay_client.dart';
/// Signs and submits Nostr events through the relay HTTP API.
class SignedEventRelay {
final RelayClient _client;
final String? _nsec;
SignedEventRelay({required RelayClient client, required String? nsec})
: _client = client,
_nsec = nsec;
Future<void> submit({
required int kind,
required String content,
required List<List<String>> tags,
}) async {
final nsec = _nsec;
if (nsec == null || nsec.isEmpty) {
throw Exception('Cannot submit event: no signing key available');
}
final privkeyHex = nostr.Nip19.decodePrivkey(nsec);
if (privkeyHex.isEmpty) {
throw Exception('Invalid nsec');
}
final event = nostr.Event.from(
kind: kind,
content: content,
tags: tags,
privkey: privkeyHex,
verify: false,
);
final response = await _client.postRaw(
'/api/events',
body: jsonEncode(event.toJson()),
);
final payload = jsonDecode(response) as Map<String, dynamic>;
if (payload['accepted'] != true) {
throw Exception(payload['message'] ?? 'Event rejected by relay');
}
}
}
@@ -7,9 +7,11 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:sprout_mobile/features/channels/channel.dart';
import 'package:sprout_mobile/features/channels/channel_detail_page.dart';
import 'package:sprout_mobile/features/channels/channel_management_provider.dart';
import 'package:sprout_mobile/features/channels/channel_messages_provider.dart';
import 'package:sprout_mobile/features/channels/channel_typing_provider.dart';
import 'package:sprout_mobile/features/channels/channels_provider.dart';
import 'package:sprout_mobile/features/profile/profile_provider.dart';
import 'package:sprout_mobile/features/profile/user_cache_provider.dart';
import 'package:sprout_mobile/features/profile/user_profile.dart';
import 'package:sprout_mobile/shared/relay/relay.dart';
@@ -109,10 +111,17 @@ Widget _buildTestable({
required List<NostrEvent> messages,
List<TypingEntry> typing = const [],
Map<String, UserProfile> users = const {},
List<ChannelMember> members = const [],
Channel? channel,
List<Channel>? channels,
_FakeChannelsNotifier? channelsNotifier,
List<NavigatorObserver> navigatorObservers = const [],
Future<List<ChannelMember>> Function()? loadMembers,
ChannelActions Function(Ref ref)? createChannelActions,
}) {
final resolvedChannel = channel ?? _testChannel;
final fakeChannelsNotifier =
channelsNotifier ?? _FakeChannelsNotifier(channels ?? [resolvedChannel]);
return ProviderScope(
overrides: [
channelMessagesProvider(
@@ -122,9 +131,23 @@ Widget _buildTestable({
_channelId,
).overrideWith(() => _FakeTypingNotifier(typing)),
userCacheProvider.overrideWith(() => _FakeUserCacheNotifier(users)),
channelsProvider.overrideWith(
() => _FakeChannelsNotifier(channels ?? [channel ?? _testChannel]),
profileProvider.overrideWith(() => _FakeProfileNotifier()),
channelsProvider.overrideWith(() => fakeChannelsNotifier),
channelDetailsProvider(_channelId).overrideWith(
(ref) async => ChannelDetails.fromChannel(resolvedChannel),
),
channelCanvasProvider(_channelId).overrideWith(
(ref) async => const ChannelCanvas(
content: null,
updatedAt: null,
authorPubkey: null,
),
),
channelMembersProvider(_channelId).overrideWith(
(ref) async => loadMembers != null ? loadMembers() : members,
),
if (createChannelActions != null)
channelActionsProvider.overrideWith(createChannelActions),
// Stub the relay client provider so preloadMembers doesn't crash.
relayClientProvider.overrideWithValue(
RelayClient(baseUrl: 'http://localhost:3000'),
@@ -133,7 +156,7 @@ Widget _buildTestable({
child: MaterialApp(
theme: AppTheme.lightTheme,
navigatorObservers: navigatorObservers,
home: ChannelDetailPage(channel: channel ?? _testChannel),
home: ChannelDetailPage(channel: resolvedChannel),
),
);
}
@@ -155,6 +178,121 @@ Finder findRichText(String text) {
void main() {
group('ChannelDetailPage', () {
testWidgets('shows forum placeholder for forum channels', (tester) async {
final forumChannel = Channel(
id: _channelId,
name: 'design-forum',
channelType: 'forum',
visibility: 'open',
description: 'Talk through design changes',
createdBy: 'abc123',
createdAt: DateTime(2025),
memberCount: 5,
isMember: true,
);
await tester.pumpWidget(
_buildTestable(messages: const [], channel: forumChannel),
);
await tester.pumpAndSettle();
expect(find.text('Forum threads are not on mobile yet'), findsOneWidget);
expect(find.text('Talk through design changes'), findsOneWidget);
expect(find.text('Message…'), findsNothing);
});
testWidgets('members sheet stays read-only on mobile', (tester) async {
await tester.pumpWidget(
_buildTestable(
messages: const [],
members: [
ChannelMember(
pubkey: 'self',
role: 'owner',
joinedAt: DateTime(2025),
displayName: 'Self',
),
ChannelMember(
pubkey: 'alice',
role: 'member',
joinedAt: DateTime(2025),
displayName: 'Alice',
),
],
),
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('View members'));
await tester.pumpAndSettle();
expect(
find.text('Member and bot management stay on desktop.'),
findsOneWidget,
);
expect(find.text('Alice'), findsOneWidget);
expect(find.byKey(const Key('members-search-field')), findsNothing);
});
testWidgets('hides composer for archived channels', (tester) async {
final archivedChannel = _testChannel.copyWith(
archivedAt: DateTime.utc(2025, 1, 2),
);
await tester.pumpWidget(
_buildTestable(messages: const [], channel: archivedChannel),
);
await tester.pumpAndSettle();
expect(find.text('Message…'), findsNothing);
expect(
find.text('This channel is archived and read-only on mobile.'),
findsOneWidget,
);
});
testWidgets('updates detail page state after joining a channel', (
tester,
) async {
final openChannel = _testChannel.copyWith(isMember: false);
final channelsNotifier = _FakeChannelsNotifier([openChannel]);
await tester.pumpWidget(
_buildTestable(
messages: const [],
channel: openChannel,
channelsNotifier: channelsNotifier,
createChannelActions: (ref) => _FakeChannelActions(
ref,
onJoinChannel: (_) async {
channelsNotifier.setChannels([
openChannel.copyWith(isMember: true, memberCount: 6),
]);
},
),
),
);
await tester.pumpAndSettle();
expect(
find.text('Join this channel from Manage to participate.'),
findsOneWidget,
);
expect(find.text('Message…'), findsNothing);
await tester.tap(find.byTooltip('Manage channel'));
await tester.pumpAndSettle();
await tester.tap(find.text('Join channel'));
await tester.pumpAndSettle();
expect(find.text('Join channel'), findsNothing);
expect(
find.text('Join this channel from Manage to participate.'),
findsNothing,
);
expect(find.text('Message…'), findsOneWidget);
});
testWidgets('shows empty state when no messages', (tester) async {
await tester.pumpWidget(_buildTestable(messages: []));
await tester.pumpAndSettle();
@@ -896,6 +1034,12 @@ class _FakeTypingNotifier extends ChannelTypingNotifier {
List<TypingEntry> build() => _entries;
}
class _FakeProfileNotifier extends ProfileNotifier {
@override
Future<UserProfile?> build() async =>
const UserProfile(pubkey: 'self', displayName: 'Self');
}
class _FakeUserCacheNotifier extends UserCacheNotifier {
final Map<String, UserProfile> _users;
_FakeUserCacheNotifier(this._users);
@@ -908,11 +1052,41 @@ class _FakeUserCacheNotifier extends UserCacheNotifier {
}
class _FakeChannelsNotifier extends ChannelsNotifier {
final List<Channel> _channels;
List<Channel> _channels;
_FakeChannelsNotifier(this._channels);
@override
Future<List<Channel>> build() => SynchronousFuture(_channels);
void setChannels(List<Channel> channels) {
_channels = channels;
state = AsyncData(channels);
}
}
class _FakeChannelActions extends ChannelActions {
final Future<void> Function(String channelId)? onJoinChannel;
_FakeChannelActions(Ref ref, {this.onJoinChannel})
: super(
ref: ref,
client: RelayClient(baseUrl: 'http://localhost:3000'),
signedEventRelay: SignedEventRelay(
client: RelayClient(baseUrl: 'http://localhost:3000'),
nsec: null,
),
currentPubkey: 'self',
);
@override
Future<void> joinChannel(String channelId) async {
await onJoinChannel?.call(channelId);
}
@override
Future<void> leaveChannel(String channelId) async {
return;
}
}
class _TestNavigatorObserver extends NavigatorObserver {
@@ -16,6 +16,9 @@ void main() {
'created_at': '2025-01-01T00:00:00+00:00',
'member_count': 42,
'last_message_at': '2025-06-01T12:00:00+00:00',
'archived_at': null,
'participants': ['Alice', 'Bob'],
'participant_pubkeys': ['alice', 'bob'],
'is_member': true,
};
@@ -29,6 +32,8 @@ void main() {
expect(channel.topic, 'Welcome!');
expect(channel.purpose, 'Team chat');
expect(channel.memberCount, 42);
expect(channel.participants, ['Alice', 'Bob']);
expect(channel.participantPubkeys, ['alice', 'bob']);
expect(channel.isMember, isTrue);
expect(channel.isStream, isTrue);
expect(channel.isForum, isFalse);
@@ -49,6 +54,7 @@ void main() {
'created_at': '2025-01-01T00:00:00+00:00',
'member_count': 2,
'last_message_at': null,
'archived_at': '2025-01-02T00:00:00+00:00',
'is_member': false,
};
@@ -57,6 +63,7 @@ void main() {
expect(channel.description, '');
expect(channel.topic, isNull);
expect(channel.lastMessageAt, isNull);
expect(channel.isArchived, isTrue);
expect(channel.isMember, isFalse);
expect(channel.isPrivate, isTrue);
});
@@ -78,4 +85,114 @@ void main() {
expect(channel.isForum, isTrue);
});
});
group('Channel.displayLabel', () {
Channel makeDm({
List<String> participants = const [],
List<String> participantPubkeys = const [],
}) => Channel(
id: '1',
name: 'dm-name',
channelType: 'dm',
visibility: 'open',
description: '',
createdBy: 'x',
createdAt: DateTime(2025),
memberCount: 2,
participants: participants,
participantPubkeys: participantPubkeys,
);
test('returns name for non-DM channels', () {
final channel = Channel(
id: '1',
name: 'general',
channelType: 'stream',
visibility: 'open',
description: '',
createdBy: 'x',
createdAt: DateTime(2025),
memberCount: 1,
);
expect(channel.displayLabel(), 'general');
expect(channel.displayLabel(currentPubkey: 'abc'), 'general');
});
test('returns name for DM with empty participants', () {
final channel = makeDm();
expect(channel.displayLabel(), 'dm-name');
});
test('returns all participants when no currentPubkey', () {
final channel = makeDm(
participants: ['Alice', 'Bob'],
participantPubkeys: ['aaa', 'bbb'],
);
expect(channel.displayLabel(), 'Alice, Bob');
});
test('filters out current user by pubkey', () {
final channel = makeDm(
participants: ['You', 'Alice'],
participantPubkeys: ['self', 'alice'],
);
expect(channel.displayLabel(currentPubkey: 'SELF'), 'Alice');
});
test('falls back to all participants when self is only participant', () {
final channel = makeDm(
participants: ['You'],
participantPubkeys: ['self'],
);
expect(channel.displayLabel(currentPubkey: 'self'), 'You');
});
test('handles mismatched participants and pubkeys lengths', () {
final channel = makeDm(
participants: ['Alice', 'Bob', 'Carol'],
participantPubkeys: ['alice'],
);
// Only index 0 has a pubkey; indexes 1-2 have no pubkey to match,
// so they always appear. Filtering out 'alice' leaves Bob and Carol.
expect(channel.displayLabel(currentPubkey: 'alice'), 'Bob, Carol');
});
});
group('Channel.copyWith', () {
final base = Channel(
id: '1',
name: 'test',
channelType: 'stream',
visibility: 'open',
description: '',
createdBy: 'x',
createdAt: DateTime(2025),
memberCount: 5,
archivedAt: DateTime(2025, 1, 2),
lastMessageAt: DateTime(2025, 6, 1),
isMember: true,
);
test('can explicitly null out archivedAt', () {
final updated = base.copyWith(archivedAt: null);
expect(updated.archivedAt, isNull);
});
test('can explicitly null out lastMessageAt', () {
final updated = base.copyWith(lastMessageAt: null);
expect(updated.lastMessageAt, isNull);
});
test('preserves archivedAt when not specified', () {
final updated = base.copyWith(memberCount: 10);
expect(updated.archivedAt, base.archivedAt);
expect(updated.memberCount, 10);
});
test('can set new archivedAt value', () {
final newDate = DateTime(2026);
final updated = base.copyWith(archivedAt: newDate);
expect(updated.archivedAt, newDate);
});
});
}
@@ -39,18 +39,31 @@ void main() {
),
Channel(
id: '2',
name: 'secret',
channelType: 'stream',
visibility: 'private',
description: 'Private channel',
name: 'design-forum',
channelType: 'forum',
visibility: 'open',
description: 'Discuss designs',
createdBy: 'abc',
createdAt: DateTime(2025),
memberCount: 3,
isMember: false,
isMember: true,
),
Channel(
id: '3',
name: 'dm-alice',
channelType: 'dm',
visibility: 'open',
description: 'Direct message',
createdBy: 'abc',
createdAt: DateTime(2025),
memberCount: 2,
participants: const ['Test', 'Alice'],
participantPubkeys: const ['aabb', 'alice'],
isMember: true,
),
];
testWidgets('shows channel list when data loads', (tester) async {
testWidgets('shows grouped channel list when data loads', (tester) async {
await tester.pumpWidget(
buildTestable(
overrides: [
@@ -61,9 +74,64 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('general'), findsOneWidget);
expect(find.text('secret'), findsOneWidget);
// Section header shows channel count
expect(find.text('2'), findsOneWidget);
expect(find.text('design-forum'), findsOneWidget);
expect(find.text('Alice'), findsOneWidget);
expect(find.text('Channels'), findsOneWidget);
expect(find.text('Forums'), findsOneWidget);
expect(find.text('DMs'), findsOneWidget);
expect(find.text('1'), findsNWidgets(3));
expect(find.byTooltip('Browse channels'), findsOneWidget);
expect(find.byTooltip('Create or start conversation'), findsOneWidget);
});
testWidgets('hides unjoined and archived channels from the main list', (
tester,
) async {
final channels = [
...testChannels,
Channel(
id: '4',
name: 'open-stream',
channelType: 'stream',
visibility: 'open',
description: 'Available to join',
createdBy: 'abc',
createdAt: DateTime(2025),
memberCount: 8,
isMember: false,
),
Channel(
id: '5',
name: 'archived-stream',
channelType: 'stream',
visibility: 'open',
description: 'Archived channel',
createdBy: 'abc',
createdAt: DateTime(2025),
memberCount: 4,
isMember: true,
archivedAt: DateTime(2025, 1, 2),
),
];
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(channels)),
],
),
);
await tester.pumpAndSettle();
expect(find.text('general'), findsOneWidget);
expect(find.text('open-stream'), findsNothing);
expect(find.text('archived-stream'), findsNothing);
await tester.tap(find.byTooltip('Browse channels'));
await tester.pumpAndSettle();
expect(find.text('open-stream'), findsOneWidget);
expect(find.text('archived-stream'), findsOneWidget);
});
testWidgets('shows empty state when no channels', (tester) async {
@@ -74,7 +142,7 @@ void main() {
);
await tester.pumpAndSettle();
expect(find.text('No channels yet'), findsOneWidget);
expect(find.text('No conversations yet'), findsOneWidget);
});
testWidgets('shows error view with retry button', (tester) async {
@@ -0,0 +1,135 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
import 'package:nostr/nostr.dart' as nostr;
import 'package:sprout_mobile/shared/relay/relay_client.dart';
import 'package:sprout_mobile/shared/relay/signed_event_relay.dart';
void main() {
group('SignedEventRelay', () {
test('throws when nsec is null', () {
final relay = SignedEventRelay(
client: RelayClient(baseUrl: 'http://localhost'),
nsec: null,
);
expect(
() => relay.submit(kind: 1, content: 'hi', tags: []),
throwsA(
isA<Exception>().having(
(e) => e.toString(),
'message',
contains('no signing key'),
),
),
);
});
test('throws when nsec is empty', () {
final relay = SignedEventRelay(
client: RelayClient(baseUrl: 'http://localhost'),
nsec: '',
);
expect(
() => relay.submit(kind: 1, content: 'hi', tags: []),
throwsA(
isA<Exception>().having(
(e) => e.toString(),
'message',
contains('no signing key'),
),
),
);
});
test('posts signed event and succeeds when accepted', () async {
final keychain = nostr.Keychain.generate();
final nsec = nostr.Nip19.encodePrivkey(keychain.private);
Map<String, dynamic>? postedBody;
final mockHttp = http_testing.MockClient((request) async {
expect(request.url.path, '/api/events');
postedBody = jsonDecode(request.body) as Map<String, dynamic>;
return http.Response(jsonEncode({'accepted': true}), 200);
});
final client = RelayClient(
baseUrl: 'http://localhost',
httpClient: mockHttp,
);
final relay = SignedEventRelay(client: client, nsec: nsec);
await relay.submit(
kind: 9007,
content: 'test message',
tags: [
['h', 'channel-1'],
],
);
expect(postedBody, isNotNull);
expect(postedBody!['kind'], 9007);
expect(postedBody!['content'], 'test message');
expect(postedBody!['sig'], isNotEmpty);
expect(postedBody!['pubkey'], keychain.public);
});
test('throws when relay rejects event', () async {
final keychain = nostr.Keychain.generate();
final nsec = nostr.Nip19.encodePrivkey(keychain.private);
final mockHttp = http_testing.MockClient((request) async {
return http.Response(
jsonEncode({'accepted': false, 'message': 'invalid event'}),
200,
);
});
final client = RelayClient(
baseUrl: 'http://localhost',
httpClient: mockHttp,
);
final relay = SignedEventRelay(client: client, nsec: nsec);
expect(
() => relay.submit(kind: 1, content: '', tags: []),
throwsA(
isA<Exception>().having(
(e) => e.toString(),
'message',
contains('invalid event'),
),
),
);
});
test('throws generic message when relay rejects without message', () async {
final keychain = nostr.Keychain.generate();
final nsec = nostr.Nip19.encodePrivkey(keychain.private);
final mockHttp = http_testing.MockClient((request) async {
return http.Response(jsonEncode({'accepted': false}), 200);
});
final client = RelayClient(
baseUrl: 'http://localhost',
httpClient: mockHttp,
);
final relay = SignedEventRelay(client: client, nsec: nsec);
expect(
() => relay.submit(kind: 1, content: '', tags: []),
throwsA(
isA<Exception>().having(
(e) => e.toString(),
'message',
contains('Event rejected by relay'),
),
),
);
});
});
}