mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -18,6 +18,7 @@ const MOBILE_SCOPES: TokenScope[] = [
|
||||
"messages:read",
|
||||
"messages:write",
|
||||
"channels:read",
|
||||
"channels:write",
|
||||
"users:read",
|
||||
"files:read",
|
||||
];
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user