mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
[codex] Add mobile chat timeline rendering and hardening (#327)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
49
AGENTS.md
49
AGENTS.md
@@ -129,6 +129,55 @@ just desktop-app # full Tauri app with native shell
|
||||
|
||||
---
|
||||
|
||||
## Mobile App (Flutter)
|
||||
|
||||
The mobile app lives in `mobile/` — a Flutter app using Riverpod + Hooks.
|
||||
|
||||
### Architecture
|
||||
|
||||
- **State management:** Riverpod + `flutter_hooks` (`HookConsumerWidget`)
|
||||
- **Theme:** Catppuccin Latte (light) / Macchiato (dark) — matches desktop
|
||||
- **Features:** Isolated under `lib/features/`, shared code in `lib/shared/`
|
||||
- **Nostr models:** `lib/shared/relay/nostr_models.dart` — event kinds must
|
||||
stay in sync with `desktop/src/shared/constants/kinds.ts`
|
||||
|
||||
### Rules
|
||||
|
||||
- **NEVER use `StatefulWidget`** — always use `HookConsumerWidget` or
|
||||
`ConsumerWidget` with `flutter_hooks` for local state.
|
||||
- **NEVER run `flutter run`, `flutter build`, `flutter clean`, or
|
||||
`flutter upgrade`** — only `flutter test`, `flutter analyze`, and
|
||||
`dart format` are safe for agents to run.
|
||||
- **Do NOT use `print()`** — use `debugPrint()` or structured logging.
|
||||
- Prefer `context.colors` and `context.textTheme` (via theme extensions)
|
||||
over raw `Theme.of(context)` calls.
|
||||
- Keep widgets small and composable.
|
||||
- Feature modules must not import from other feature modules — only from
|
||||
`shared/`.
|
||||
- Use `Grid` tokens for spacing, `Radii` for border radius.
|
||||
|
||||
### Quality Checks
|
||||
|
||||
```bash
|
||||
cd mobile
|
||||
dart format --output=none --set-exit-if-changed .
|
||||
flutter analyze
|
||||
flutter test
|
||||
```
|
||||
|
||||
Or from repo root: `just mobile-check` and `just mobile-test`.
|
||||
|
||||
### Testing Conventions
|
||||
|
||||
- Prefer **widget tests** over unit tests for UI components — test the
|
||||
whole widget tree, not individual methods.
|
||||
- Use `ProviderScope(overrides: [...])` to inject fake notifiers.
|
||||
- Fake notifiers should extend the real notifier class and override `build()`.
|
||||
- Use the `WidgetHelpers.testable()` wrapper for simple widget tests or
|
||||
build a custom `ProviderScope` + `MaterialApp` when you need specific overrides.
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- [CONTRIBUTING.md](CONTRIBUTING.md) — setup, code style, PR process, how to add event kinds / MCP tools / API endpoints
|
||||
|
||||
4
Cargo.lock
generated
4
Cargo.lock
generated
@@ -3196,9 +3196,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.10"
|
||||
version = "0.103.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
|
||||
checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
|
||||
@@ -3,13 +3,6 @@ ignore = [
|
||||
# instant 0.1.13 — unmaintained crate. Transitive dep: nostr → instant.
|
||||
# Will be resolved when nostr crate updates its dependencies.
|
||||
{ id = "RUSTSEC-2024-0384", reason = "transitive dep via nostr; no upstream fix available" },
|
||||
# rustls-webpki 0.101.7 — CRL distribution point matching bug. Transitive dep pinned by
|
||||
# old rustls 0.21 stack (via rust-s3). 0.103 branch updated; 0.101 has no fix available.
|
||||
{ id = "RUSTSEC-2026-0049", reason = "transitive dep via rust-s3 → old rustls stack; 0.101 branch has no fix" },
|
||||
# rustls-pemfile 1.0.4 — unmaintained crate. Transitive dep: rust-s3 → rustls-native-certs 0.6 → rustls-pemfile.
|
||||
# The tokio-rustls-tls feature of rust-s3 pins an old rustls 0.21 stack internally.
|
||||
# Not a security vulnerability — just an unmaintained notice. No fix until rust-s3 updates.
|
||||
{ id = "RUSTSEC-2025-0134", reason = "transitive dep via rust-s3; no upstream fix available" },
|
||||
]
|
||||
|
||||
[licenses]
|
||||
|
||||
@@ -10,12 +10,17 @@ import '../profile/user_profile.dart';
|
||||
import 'channel.dart';
|
||||
import 'channel_messages_provider.dart';
|
||||
import 'channel_typing_provider.dart';
|
||||
import 'channels_provider.dart';
|
||||
import 'message_content.dart';
|
||||
import 'send_message_provider.dart';
|
||||
import 'timeline_message.dart';
|
||||
|
||||
/// Fetch channel members and preload their profiles into the user cache.
|
||||
Future<void> _preloadMembers(WidgetRef ref, String channelId) async {
|
||||
// Capture references before async gap to avoid using disposed ref.
|
||||
final client = ref.read(relayClientProvider);
|
||||
final notifier = ref.read(userCacheProvider.notifier);
|
||||
try {
|
||||
final client = ref.read(relayClientProvider);
|
||||
final json =
|
||||
await client.get('/api/channels/$channelId/members')
|
||||
as Map<String, dynamic>;
|
||||
@@ -24,7 +29,7 @@ Future<void> _preloadMembers(WidgetRef ref, String channelId) async {
|
||||
.map((m) => (m as Map<String, dynamic>)['pubkey'] as String)
|
||||
.toList();
|
||||
if (pubkeys.isNotEmpty) {
|
||||
ref.read(userCacheProvider.notifier).preload(pubkeys);
|
||||
notifier.preload(pubkeys);
|
||||
}
|
||||
} catch (_) {
|
||||
// Non-fatal — mentions will just fall back to cache from messages.
|
||||
@@ -76,8 +81,10 @@ class ChannelDetailPage extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
data: (messages) =>
|
||||
_MessageList(messages: messages, channelId: channel.id),
|
||||
data: (events) {
|
||||
final messages = formatTimeline(events);
|
||||
return _MessageList(messages: messages, channelId: channel.id);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (typingEntries.isNotEmpty)
|
||||
@@ -90,7 +97,7 @@ class ChannelDetailPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
class _MessageList extends ConsumerWidget {
|
||||
final List<NostrEvent> messages;
|
||||
final List<TimelineMessage> messages;
|
||||
final String channelId;
|
||||
|
||||
const _MessageList({required this.messages, required this.channelId});
|
||||
@@ -126,14 +133,14 @@ class _MessageList extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
// Filter to only renderable message events for display and grouping.
|
||||
final displayMessages = messages
|
||||
.where(
|
||||
(e) =>
|
||||
e.kind == EventKind.streamMessage ||
|
||||
e.kind == EventKind.streamMessageV2,
|
||||
)
|
||||
.toList();
|
||||
// Build channel names map once for all message bubbles.
|
||||
final channelsAsync = ref.watch(channelsProvider);
|
||||
final channelNamesMap = <String, String>{};
|
||||
channelsAsync.whenData((channels) {
|
||||
for (final ch in channels) {
|
||||
channelNamesMap[ch.name.toLowerCase()] = ch.id;
|
||||
}
|
||||
});
|
||||
|
||||
return ListView.builder(
|
||||
reverse: true,
|
||||
@@ -141,39 +148,134 @@ class _MessageList extends ConsumerWidget {
|
||||
horizontal: Grid.xs,
|
||||
vertical: Grid.xxs,
|
||||
),
|
||||
itemCount: displayMessages.length,
|
||||
itemCount: messages.length,
|
||||
itemBuilder: (context, index) {
|
||||
// Reversed list: index 0 = newest (bottom of screen).
|
||||
final chronIdx = displayMessages.length - 1 - index;
|
||||
final message = displayMessages[chronIdx];
|
||||
// The message visually above is the one earlier in time.
|
||||
final prevMessage = chronIdx > 0 ? displayMessages[chronIdx - 1] : null;
|
||||
final chronIdx = messages.length - 1 - index;
|
||||
final message = messages[chronIdx];
|
||||
|
||||
if (message.isSystem) {
|
||||
return _SystemMessageRow(message: message);
|
||||
}
|
||||
|
||||
// The message visually above is the one earlier in time.
|
||||
final prevMessage = chronIdx > 0 ? messages[chronIdx - 1] : null;
|
||||
final showAuthor =
|
||||
prevMessage == null ||
|
||||
prevMessage.isSystem ||
|
||||
prevMessage.pubkey.toLowerCase() != message.pubkey.toLowerCase() ||
|
||||
(message.createdAt - prevMessage.createdAt) > 300;
|
||||
|
||||
return _MessageBubble(event: message, showAuthor: showAuthor);
|
||||
return _MessageBubble(
|
||||
message: message,
|
||||
showAuthor: showAuthor,
|
||||
channelNames: channelNamesMap,
|
||||
currentChannelId: channelId,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageBubble extends ConsumerWidget {
|
||||
final NostrEvent event;
|
||||
final bool showAuthor;
|
||||
// ---------------------------------------------------------------------------
|
||||
// System message row
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const _MessageBubble({required this.event, required this.showAuthor});
|
||||
class _SystemMessageRow extends ConsumerWidget {
|
||||
final TimelineMessage message;
|
||||
|
||||
const _SystemMessageRow({required this.message});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Look up the user profile for display name.
|
||||
final systemEvent = message.systemEvent;
|
||||
if (systemEvent == null) return const SizedBox.shrink();
|
||||
|
||||
final userCache = ref.watch(userCacheProvider);
|
||||
|
||||
String resolveLabel(String? pubkey) {
|
||||
if (pubkey == null) return 'Someone';
|
||||
final profile =
|
||||
userCache[pubkey.toLowerCase()] ??
|
||||
ref.read(userCacheProvider.notifier).get(pubkey.toLowerCase());
|
||||
return profile?.label ?? _shortPubkey(pubkey);
|
||||
}
|
||||
|
||||
final description = systemEvent.describe(resolveLabel);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: Grid.half),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surfaceContainerHighest,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
LucideIcons.arrowLeftRight,
|
||||
size: 12,
|
||||
color: context.colors.outline,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
Expanded(
|
||||
child: Text(
|
||||
description,
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.outline,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_formatTime(message.createdAt),
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: context.colors.outline.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User message bubble
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _MessageBubble extends ConsumerWidget {
|
||||
final TimelineMessage message;
|
||||
final bool showAuthor;
|
||||
final Map<String, String> channelNames;
|
||||
final String currentChannelId;
|
||||
|
||||
const _MessageBubble({
|
||||
required this.message,
|
||||
required this.showAuthor,
|
||||
required this.channelNames,
|
||||
required this.currentChannelId,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Watch only this user's profile to avoid rebuilding on unrelated cache changes.
|
||||
final pk = message.pubkey.toLowerCase();
|
||||
final profile =
|
||||
userCache[event.pubkey.toLowerCase()] ??
|
||||
ref.read(userCacheProvider.notifier).get(event.pubkey.toLowerCase());
|
||||
final displayName = profile?.label ?? _shortPubkey(event.pubkey);
|
||||
ref.watch(userCacheProvider.select((cache) => cache[pk])) ??
|
||||
ref.read(userCacheProvider.notifier).get(pk);
|
||||
final displayName = profile?.label ?? _shortPubkey(message.pubkey);
|
||||
|
||||
// Build mention names map from event p-tags.
|
||||
final userCache = ref.watch(userCacheProvider);
|
||||
final mentionNames = <String, String>{};
|
||||
for (final mpk in message.mentionPubkeys) {
|
||||
final p = userCache[mpk.toLowerCase()];
|
||||
if (p?.displayName != null) {
|
||||
mentionNames[mpk.toLowerCase()] = p!.displayName!;
|
||||
}
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: showAuthor ? Grid.xxs : Grid.quarter),
|
||||
@@ -181,7 +283,7 @@ class _MessageBubble extends ConsumerWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showAuthor)
|
||||
_UserAvatar(profile: profile, pubkey: event.pubkey)
|
||||
_UserAvatar(profile: profile, pubkey: message.pubkey)
|
||||
else
|
||||
const SizedBox(width: 28),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
@@ -203,19 +305,51 @@ class _MessageBubble extends ConsumerWidget {
|
||||
),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
Text(
|
||||
_formatTime(event.createdAt),
|
||||
_formatTime(message.createdAt),
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: context.colors.outline,
|
||||
),
|
||||
),
|
||||
if (message.edited) ...[
|
||||
const SizedBox(width: Grid.half),
|
||||
Text(
|
||||
'(edited)',
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: context.colors.outline,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
event.content,
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
MessageContent(
|
||||
content: message.content,
|
||||
mentionNames: mentionNames,
|
||||
channelNames: channelNames,
|
||||
onChannelTap: (channelId) {
|
||||
if (channelId == currentChannelId) {
|
||||
return;
|
||||
}
|
||||
final channelsAsync = ref.read(channelsProvider);
|
||||
final channels = channelsAsync.hasValue
|
||||
? channelsAsync.value
|
||||
: null;
|
||||
Channel? targetChannel;
|
||||
for (final channel in channels ?? const <Channel>[]) {
|
||||
if (channel.id == channelId) {
|
||||
targetChannel = channel;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (targetChannel == null) return;
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) =>
|
||||
ChannelDetailPage(channel: targetChannel!),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -224,29 +358,6 @@ class _MessageBubble extends ConsumerWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _shortPubkey(String pubkey) {
|
||||
if (pubkey.length > 12) {
|
||||
return '${pubkey.substring(0, 8)}…';
|
||||
}
|
||||
return pubkey;
|
||||
}
|
||||
|
||||
static String _formatTime(int createdAt) {
|
||||
final dt = DateTime.fromMillisecondsSinceEpoch(
|
||||
createdAt * 1000,
|
||||
isUtc: true,
|
||||
).toLocal();
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(dt);
|
||||
|
||||
if (diff.inDays > 0) {
|
||||
return '${dt.month}/${dt.day} ${_pad(dt.hour)}:${_pad(dt.minute)}';
|
||||
}
|
||||
return '${_pad(dt.hour)}:${_pad(dt.minute)}';
|
||||
}
|
||||
|
||||
static String _pad(int n) => n.toString().padLeft(2, '0');
|
||||
}
|
||||
|
||||
class _UserAvatar extends StatelessWidget {
|
||||
@@ -278,6 +389,10 @@ class _UserAvatar extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Typing indicator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _TypingIndicator extends ConsumerWidget {
|
||||
final List<TypingEntry> entries;
|
||||
|
||||
@@ -313,13 +428,12 @@ class _TypingIndicator extends ConsumerWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _shortPubkey(String pubkey) {
|
||||
if (pubkey.length > 12) return '${pubkey.substring(0, 8)}…';
|
||||
return pubkey;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compose bar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _ComposeBar extends HookConsumerWidget {
|
||||
final String channelId;
|
||||
|
||||
@@ -339,9 +453,9 @@ class _ComposeBar extends HookConsumerWidget {
|
||||
await ref
|
||||
.read(sendMessageProvider)
|
||||
.call(channelId: channelId, content: text);
|
||||
controller.clear();
|
||||
if (context.mounted) controller.clear();
|
||||
} finally {
|
||||
isSending.value = false;
|
||||
if (context.mounted) isSending.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,3 +523,28 @@ class _ComposeBar extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
String _shortPubkey(String pubkey) {
|
||||
if (pubkey.length > 12) return '${pubkey.substring(0, 8)}…';
|
||||
return pubkey;
|
||||
}
|
||||
|
||||
String _formatTime(int createdAt) {
|
||||
final dt = DateTime.fromMillisecondsSinceEpoch(
|
||||
createdAt * 1000,
|
||||
isUtc: true,
|
||||
).toLocal();
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(dt);
|
||||
|
||||
if (diff.inDays > 0) {
|
||||
return '${dt.month}/${dt.day} ${_pad(dt.hour)}:${_pad(dt.minute)}';
|
||||
}
|
||||
return '${_pad(dt.hour)}:${_pad(dt.minute)}';
|
||||
}
|
||||
|
||||
String _pad(int n) => n.toString().padLeft(2, '0');
|
||||
|
||||
252
mobile/lib/features/channels/message_content.dart
Normal file
252
mobile/lib/features/channels/message_content.dart
Normal file
@@ -0,0 +1,252 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gpt_markdown/gpt_markdown.dart';
|
||||
import 'package:gpt_markdown/custom_widgets/markdown_config.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../shared/theme/theme.dart';
|
||||
|
||||
/// Renders message content with markdown formatting, @mentions, and
|
||||
/// #channel links using [GptMarkdown] plus custom inline components for
|
||||
/// Sprout-specific tokens.
|
||||
class MessageContent extends StatelessWidget {
|
||||
final String content;
|
||||
|
||||
/// Display names for mentioned pubkeys, extracted from event p-tags.
|
||||
/// Keys are lowercase pubkeys, values are display names.
|
||||
final Map<String, String> mentionNames;
|
||||
|
||||
/// Known channel names for #channel links. Keys are lowercase channel
|
||||
/// names, values are channel IDs.
|
||||
final Map<String, String> channelNames;
|
||||
|
||||
/// Called when a #channel link is tapped.
|
||||
final void Function(String channelId)? onChannelTap;
|
||||
|
||||
final TextStyle? baseStyle;
|
||||
|
||||
const MessageContent({
|
||||
super.key,
|
||||
required this.content,
|
||||
this.mentionNames = const {},
|
||||
this.channelNames = const {},
|
||||
this.onChannelTap,
|
||||
this.baseStyle,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final style =
|
||||
baseStyle ??
|
||||
context.textTheme.bodyMedium?.copyWith(color: context.colors.onSurface);
|
||||
|
||||
return GptMarkdown(
|
||||
content,
|
||||
style: style,
|
||||
followLinkColor: false,
|
||||
linkBuilder: (context, linkText, url, linkStyle) =>
|
||||
_buildLink(context, linkText, url, linkStyle, style),
|
||||
inlineComponents: [
|
||||
_MentionMd(mentionNames: mentionNames),
|
||||
_ChannelLinkMd(channelNames: channelNames, onChannelTap: onChannelTap),
|
||||
...MarkdownComponent.inlineComponents,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Link builder (URL scheme validation)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Widget _buildLink(
|
||||
BuildContext context,
|
||||
InlineSpan linkText,
|
||||
String url,
|
||||
TextStyle linkStyle,
|
||||
TextStyle? fallbackStyle,
|
||||
) {
|
||||
String text = '';
|
||||
linkText.visitChildren((span) {
|
||||
if (span is TextSpan && span.text != null) {
|
||||
text += span.text!;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
final baseStyle = fallbackStyle ?? linkStyle;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri != null && (uri.scheme == 'http' || uri.scheme == 'https')) {
|
||||
launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
},
|
||||
child: Text(
|
||||
text,
|
||||
style: baseStyle.copyWith(
|
||||
color: context.colors.primary,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: context.colors.primary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MentionMd extends InlineMd {
|
||||
final Map<String, String> mentionNames;
|
||||
late final RegExp _exp = _buildPrefixPattern(
|
||||
prefix: '@',
|
||||
knownNames: _mentionAliases(mentionNames.values),
|
||||
genericTokenPattern: r'[A-Za-z0-9_][A-Za-z0-9_-]*',
|
||||
);
|
||||
|
||||
_MentionMd({required this.mentionNames});
|
||||
|
||||
@override
|
||||
RegExp get exp => _exp;
|
||||
|
||||
@override
|
||||
InlineSpan span(
|
||||
BuildContext context,
|
||||
String text,
|
||||
final GptMarkdownConfig config,
|
||||
) {
|
||||
final raw = exp.firstMatch(text.trim())?.group(0);
|
||||
if (raw == null) {
|
||||
return TextSpan(text: text, style: config.style);
|
||||
}
|
||||
|
||||
final name = raw.substring(1).toLowerCase();
|
||||
String? displayName;
|
||||
for (final entry in mentionNames.entries) {
|
||||
final entryName = entry.value.toLowerCase();
|
||||
final firstName = entryName.split(RegExp(r'\s+')).first;
|
||||
if (entryName == name || firstName == name) {
|
||||
displayName = entry.value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return WidgetSpan(
|
||||
alignment: PlaceholderAlignment.baseline,
|
||||
baseline: TextBaseline.alphabetic,
|
||||
child: _TokenPill(
|
||||
text: '@${displayName ?? raw.substring(1)}',
|
||||
textStyle: config.style,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChannelLinkMd extends InlineMd {
|
||||
final Map<String, String> channelNames;
|
||||
final void Function(String channelId)? onChannelTap;
|
||||
late final RegExp _exp = _buildPrefixPattern(
|
||||
prefix: '#',
|
||||
knownNames: channelNames.keys,
|
||||
genericTokenPattern: r'[A-Za-z0-9_][A-Za-z0-9_-]*',
|
||||
);
|
||||
|
||||
_ChannelLinkMd({required this.channelNames, this.onChannelTap});
|
||||
|
||||
@override
|
||||
RegExp get exp => _exp;
|
||||
|
||||
@override
|
||||
InlineSpan span(
|
||||
BuildContext context,
|
||||
String text,
|
||||
final GptMarkdownConfig config,
|
||||
) {
|
||||
final raw = exp.firstMatch(text.trim())?.group(0);
|
||||
if (raw == null) {
|
||||
return TextSpan(text: text, style: config.style);
|
||||
}
|
||||
|
||||
final channelId = channelNames[raw.substring(1).toLowerCase()];
|
||||
final child = _TokenPill(
|
||||
text: raw,
|
||||
textStyle: config.style?.copyWith(fontWeight: FontWeight.w500),
|
||||
);
|
||||
|
||||
return WidgetSpan(
|
||||
alignment: PlaceholderAlignment.baseline,
|
||||
baseline: TextBaseline.alphabetic,
|
||||
child: channelId != null && onChannelTap != null
|
||||
? GestureDetector(onTap: () => onChannelTap!(channelId), child: child)
|
||||
: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TokenPill extends StatelessWidget {
|
||||
final String text;
|
||||
final TextStyle? textStyle;
|
||||
|
||||
const _TokenPill({required this.text, this.textStyle});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.primary.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(Radii.sm),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style:
|
||||
textStyle?.copyWith(color: context.colors.primary) ??
|
||||
context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.colors.primary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
RegExp _buildPrefixPattern({
|
||||
required String prefix,
|
||||
required Iterable<String> knownNames,
|
||||
required String genericTokenPattern,
|
||||
}) {
|
||||
final names =
|
||||
knownNames
|
||||
.map((name) => name.trim())
|
||||
.where((name) => name.isNotEmpty)
|
||||
.toSet()
|
||||
.toList()
|
||||
..sort((a, b) => b.length.compareTo(a.length));
|
||||
|
||||
final escapedPrefix = RegExp.escape(prefix);
|
||||
const leadingBoundary = r'(?<![\w./:-])';
|
||||
const trailingBoundary = r'(?=$|[\s,;.!?:)\]}])';
|
||||
|
||||
if (names.isEmpty) {
|
||||
return RegExp(
|
||||
'$leadingBoundary$escapedPrefix(?:$genericTokenPattern)$trailingBoundary',
|
||||
caseSensitive: false,
|
||||
multiLine: true,
|
||||
);
|
||||
}
|
||||
|
||||
final knownAlternatives = names.map(RegExp.escape).join('|');
|
||||
return RegExp(
|
||||
'$leadingBoundary$escapedPrefix(?:(?:$knownAlternatives)$trailingBoundary|(?:$genericTokenPattern)$trailingBoundary)',
|
||||
caseSensitive: false,
|
||||
multiLine: true,
|
||||
);
|
||||
}
|
||||
|
||||
Iterable<String> _mentionAliases(Iterable<String> mentionNames) sync* {
|
||||
for (final name in mentionNames) {
|
||||
final trimmed = name.trim();
|
||||
if (trimmed.isEmpty) continue;
|
||||
yield trimmed;
|
||||
final firstName = trimmed.split(RegExp(r'\s+')).first;
|
||||
if (firstName.isNotEmpty) {
|
||||
yield firstName;
|
||||
}
|
||||
}
|
||||
}
|
||||
238
mobile/lib/features/channels/timeline_message.dart
Normal file
238
mobile/lib/features/channels/timeline_message.dart
Normal file
@@ -0,0 +1,238 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../shared/relay/relay.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// System event types (kind 40099)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
enum SystemEventType {
|
||||
memberJoined,
|
||||
memberLeft,
|
||||
memberRemoved,
|
||||
topicChanged,
|
||||
purposeChanged,
|
||||
channelCreated,
|
||||
channelArchived,
|
||||
channelUnarchived,
|
||||
}
|
||||
|
||||
@immutable
|
||||
class SystemEvent {
|
||||
final SystemEventType type;
|
||||
final String? actorPubkey;
|
||||
final String? targetPubkey;
|
||||
final String? topic;
|
||||
final String? purpose;
|
||||
|
||||
const SystemEvent({
|
||||
required this.type,
|
||||
this.actorPubkey,
|
||||
this.targetPubkey,
|
||||
this.topic,
|
||||
this.purpose,
|
||||
});
|
||||
|
||||
/// Parse a system event from the JSON content of a kind-40099 event.
|
||||
/// Returns null if the payload is unrecognised.
|
||||
static SystemEvent? fromContent(String content) {
|
||||
final Map<dynamic, dynamic> json;
|
||||
try {
|
||||
final decoded = jsonDecode(content);
|
||||
if (decoded is! Map) {
|
||||
return null;
|
||||
}
|
||||
json = decoded;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final type = switch (_readString(json, 'type')) {
|
||||
'member_joined' => SystemEventType.memberJoined,
|
||||
'member_left' => SystemEventType.memberLeft,
|
||||
'member_removed' => SystemEventType.memberRemoved,
|
||||
'topic_changed' => SystemEventType.topicChanged,
|
||||
'purpose_changed' => SystemEventType.purposeChanged,
|
||||
'channel_created' => SystemEventType.channelCreated,
|
||||
'channel_archived' => SystemEventType.channelArchived,
|
||||
'channel_unarchived' => SystemEventType.channelUnarchived,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (type == null) return null;
|
||||
|
||||
return SystemEvent(
|
||||
type: type,
|
||||
actorPubkey: _readString(json, 'actor'),
|
||||
targetPubkey: _readString(json, 'target'),
|
||||
topic: _readString(json, 'topic'),
|
||||
purpose: _readString(json, 'purpose'),
|
||||
);
|
||||
}
|
||||
|
||||
/// Human-readable description. [resolveLabel] maps a pubkey to a display
|
||||
/// name — the caller provides it so this class stays free of provider deps.
|
||||
String describe(String Function(String? pubkey) resolveLabel) {
|
||||
final actor = resolveLabel(actorPubkey);
|
||||
|
||||
return switch (type) {
|
||||
SystemEventType.memberJoined => () {
|
||||
if (actorPubkey != null && actorPubkey == targetPubkey) {
|
||||
return '$actor joined the channel';
|
||||
}
|
||||
final target = resolveLabel(targetPubkey);
|
||||
return '$actor added $target to the channel';
|
||||
}(),
|
||||
SystemEventType.memberLeft => '$actor left the channel',
|
||||
SystemEventType.memberRemoved => () {
|
||||
final target = resolveLabel(targetPubkey);
|
||||
return '$actor removed $target from the channel';
|
||||
}(),
|
||||
SystemEventType.topicChanged => '$actor changed the topic to "$topic"',
|
||||
SystemEventType.purposeChanged =>
|
||||
'$actor changed the purpose to "$purpose"',
|
||||
SystemEventType.channelCreated => '$actor created this channel',
|
||||
SystemEventType.channelArchived => '$actor archived this channel',
|
||||
SystemEventType.channelUnarchived => '$actor unarchived this channel',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TimelineMessage — a processed, display-ready message
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@immutable
|
||||
class TimelineMessage {
|
||||
final String id;
|
||||
final String pubkey;
|
||||
final int createdAt;
|
||||
final String content;
|
||||
final bool isSystem;
|
||||
final bool edited;
|
||||
final SystemEvent? systemEvent;
|
||||
|
||||
/// Pubkeys mentioned in this message (from p-tags).
|
||||
final List<String> mentionPubkeys;
|
||||
|
||||
const TimelineMessage({
|
||||
required this.id,
|
||||
required this.pubkey,
|
||||
required this.createdAt,
|
||||
required this.content,
|
||||
this.isSystem = false,
|
||||
this.edited = false,
|
||||
this.systemEvent,
|
||||
this.mentionPubkeys = const [],
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatTimeline — converts raw NostrEvents into display-ready messages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Process a chronologically-sorted list of [NostrEvent]s into a list of
|
||||
/// [TimelineMessage]s, applying deletions, edits, and system event parsing.
|
||||
///
|
||||
/// Mirrors the desktop's `formatTimelineMessages` logic.
|
||||
List<TimelineMessage> formatTimeline(List<NostrEvent> events) {
|
||||
// 1. Collect deletion targets.
|
||||
final deletedIds = <String>{};
|
||||
for (final event in events) {
|
||||
if (event.kind != EventKind.deletion) continue;
|
||||
for (final tag in event.tags) {
|
||||
if (tag.length >= 2 && tag[0] == 'e') {
|
||||
deletedIds.add(tag[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Build edit map: targetId → latest edit content.
|
||||
final edits = <String, _Edit>{};
|
||||
for (final event in events) {
|
||||
if (event.kind != EventKind.streamMessageEdit) continue;
|
||||
if (deletedIds.contains(event.id)) continue;
|
||||
|
||||
final targetId = _lastETag(event.tags);
|
||||
if (targetId == null || deletedIds.contains(targetId)) continue;
|
||||
|
||||
final existing = edits[targetId];
|
||||
if (existing == null || event.createdAt > existing.createdAt) {
|
||||
edits[targetId] = _Edit(
|
||||
content: event.content,
|
||||
createdAt: event.createdAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Filter to visible content events and build TimelineMessages.
|
||||
final result = <TimelineMessage>[];
|
||||
for (final event in events) {
|
||||
if (deletedIds.contains(event.id)) continue;
|
||||
|
||||
if (event.kind == EventKind.systemMessage) {
|
||||
final systemEvent = SystemEvent.fromContent(event.content);
|
||||
if (systemEvent != null) {
|
||||
result.add(
|
||||
TimelineMessage(
|
||||
id: event.id,
|
||||
pubkey: event.pubkey,
|
||||
createdAt: event.createdAt,
|
||||
content: event.content,
|
||||
isSystem: true,
|
||||
systemEvent: systemEvent,
|
||||
),
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.kind == EventKind.streamMessage ||
|
||||
event.kind == EventKind.streamMessageV2 ||
|
||||
event.kind == EventKind.streamMessageDiff) {
|
||||
final edit = edits[event.id];
|
||||
final mentions = <String>[
|
||||
for (final tag in event.tags)
|
||||
if (tag.length >= 2 && tag[0] == 'p') tag[1],
|
||||
];
|
||||
result.add(
|
||||
TimelineMessage(
|
||||
id: event.id,
|
||||
pubkey: event.pubkey,
|
||||
createdAt: event.createdAt,
|
||||
content: edit?.content ?? event.content,
|
||||
edited: edit != null,
|
||||
mentionPubkeys: mentions,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _Edit {
|
||||
final String content;
|
||||
final int createdAt;
|
||||
const _Edit({required this.content, required this.createdAt});
|
||||
}
|
||||
|
||||
/// Get the last `e` tag value (reaction/edit target convention).
|
||||
String? _lastETag(List<List<String>> tags) {
|
||||
for (var i = tags.length - 1; i >= 0; i--) {
|
||||
final tag = tags[i];
|
||||
if (tag.length >= 2 && tag[0] == 'e') return tag[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _readString(Map<dynamic, dynamic> json, String key) {
|
||||
final value = json[key];
|
||||
return value is String ? value : null;
|
||||
}
|
||||
@@ -286,6 +286,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_math_fork:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_math_fork
|
||||
sha256: "6d5f2f1aa57ae539ffb0a04bb39d2da67af74601d685a161aff7ce5bda5fa407"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.4"
|
||||
flutter_riverpod:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -342,6 +350,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
flutter_svg:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_svg
|
||||
sha256: "1ded017b39c8e15c8948ea855070a5ff8ff8b3d5e83f3446e02d6bb12add7ad9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.4"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
@@ -376,6 +392,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
gpt_markdown:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: gpt_markdown
|
||||
sha256: "5c565a438e569b3b546023d0d4eccccf87e260a3289a17c4e241c41d5e7545df"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.6"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -560,6 +584,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.17.6"
|
||||
nested:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: nested
|
||||
sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
nm:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -608,6 +640,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_parsing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_parsing
|
||||
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
path_provider:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -696,6 +736,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.2"
|
||||
provider:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: provider
|
||||
sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.5+1"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -933,6 +981,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.16"
|
||||
tuple:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: tuple
|
||||
sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -941,6 +997,70 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
url_launcher:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: url_launcher
|
||||
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.2"
|
||||
url_launcher_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_android
|
||||
sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.29"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_ios
|
||||
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.4.1"
|
||||
url_launcher_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_linux
|
||||
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.2"
|
||||
url_launcher_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_macos
|
||||
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.5"
|
||||
url_launcher_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_platform_interface
|
||||
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
url_launcher_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_web
|
||||
sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
url_launcher_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_windows
|
||||
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.5"
|
||||
uuid:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -949,6 +1069,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.3"
|
||||
vector_graphics:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_graphics
|
||||
sha256: "81da85e9ca8885ade47f9685b953cb098970d11be4821ac765580a6607ea4373"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.21"
|
||||
vector_graphics_codec:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_graphics_codec
|
||||
sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.13"
|
||||
vector_graphics_compiler:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_graphics_compiler
|
||||
sha256: "5a88dd14c0954a5398af544651c7fb51b457a2a556949bfb25369b210ef73a74"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -19,6 +19,8 @@ dependencies:
|
||||
web_socket_channel: ^3.0.1
|
||||
connectivity_plus: ^6.1.4
|
||||
nostr: ^1.5.0
|
||||
url_launcher: ^6.3.2
|
||||
gpt_markdown: ^1.1.6
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
926
mobile/test/features/channels/channel_detail_page_test.dart
Normal file
926
mobile/test/features/channels/channel_detail_page_test.dart
Normal file
@@ -0,0 +1,926 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
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_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/user_cache_provider.dart';
|
||||
import 'package:sprout_mobile/features/profile/user_profile.dart';
|
||||
import 'package:sprout_mobile/shared/relay/relay.dart';
|
||||
import 'package:sprout_mobile/shared/theme/theme.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const _channelId = 'test-channel';
|
||||
|
||||
final _testChannel = Channel(
|
||||
id: _channelId,
|
||||
name: 'general',
|
||||
channelType: 'stream',
|
||||
visibility: 'open',
|
||||
description: 'General discussion',
|
||||
createdBy: 'abc123',
|
||||
createdAt: DateTime(2025),
|
||||
memberCount: 5,
|
||||
isMember: true,
|
||||
);
|
||||
|
||||
NostrEvent _textMsg({
|
||||
required String id,
|
||||
required String pubkey,
|
||||
required String content,
|
||||
int createdAt = 1000,
|
||||
}) => NostrEvent(
|
||||
id: id,
|
||||
pubkey: pubkey,
|
||||
createdAt: createdAt,
|
||||
kind: EventKind.streamMessage,
|
||||
tags: [
|
||||
['h', _channelId],
|
||||
],
|
||||
content: content,
|
||||
sig: '',
|
||||
);
|
||||
|
||||
NostrEvent _systemMsg({
|
||||
required String id,
|
||||
required Map<String, dynamic> payload,
|
||||
int createdAt = 1000,
|
||||
}) => NostrEvent(
|
||||
id: id,
|
||||
pubkey: 'relay',
|
||||
createdAt: createdAt,
|
||||
kind: EventKind.systemMessage,
|
||||
tags: [
|
||||
['h', _channelId],
|
||||
],
|
||||
content: jsonEncode(payload),
|
||||
sig: '',
|
||||
);
|
||||
|
||||
NostrEvent _deletion({
|
||||
required String id,
|
||||
required List<String> targetIds,
|
||||
int createdAt = 2000,
|
||||
}) => NostrEvent(
|
||||
id: id,
|
||||
pubkey: 'abc123',
|
||||
createdAt: createdAt,
|
||||
kind: EventKind.deletion,
|
||||
tags: [
|
||||
['h', _channelId],
|
||||
for (final t in targetIds) ['e', t],
|
||||
],
|
||||
content: '',
|
||||
sig: '',
|
||||
);
|
||||
|
||||
NostrEvent _edit({
|
||||
required String id,
|
||||
required String targetId,
|
||||
required String content,
|
||||
int createdAt = 2000,
|
||||
}) => NostrEvent(
|
||||
id: id,
|
||||
pubkey: 'abc123',
|
||||
createdAt: createdAt,
|
||||
kind: EventKind.streamMessageEdit,
|
||||
tags: [
|
||||
['h', _channelId],
|
||||
['e', targetId],
|
||||
],
|
||||
content: content,
|
||||
sig: '',
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Widget _buildTestable({
|
||||
required List<NostrEvent> messages,
|
||||
List<TypingEntry> typing = const [],
|
||||
Map<String, UserProfile> users = const {},
|
||||
Channel? channel,
|
||||
List<Channel>? channels,
|
||||
List<NavigatorObserver> navigatorObservers = const [],
|
||||
}) {
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
channelMessagesProvider(
|
||||
_channelId,
|
||||
).overrideWith(() => _FakeMessagesNotifier(messages)),
|
||||
channelTypingProvider(
|
||||
_channelId,
|
||||
).overrideWith(() => _FakeTypingNotifier(typing)),
|
||||
userCacheProvider.overrideWith(() => _FakeUserCacheNotifier(users)),
|
||||
channelsProvider.overrideWith(
|
||||
() => _FakeChannelsNotifier(channels ?? [channel ?? _testChannel]),
|
||||
),
|
||||
// Stub the relay client provider so preloadMembers doesn't crash.
|
||||
relayClientProvider.overrideWithValue(
|
||||
RelayClient(baseUrl: 'http://localhost:3000'),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.lightTheme,
|
||||
navigatorObservers: navigatorObservers,
|
||||
home: ChannelDetailPage(channel: channel ?? _testChannel),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Finder that searches for text within RichText spans. [find.text] only
|
||||
/// matches the top-level text property; this also searches nested TextSpans.
|
||||
Finder findRichText(String text) {
|
||||
return find.byWidgetPredicate((widget) {
|
||||
if (widget is RichText) {
|
||||
return widget.text.toPlainText().contains(text);
|
||||
}
|
||||
return false;
|
||||
}, description: 'RichText containing "$text"');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void main() {
|
||||
group('ChannelDetailPage', () {
|
||||
testWidgets('shows empty state when no messages', (tester) async {
|
||||
await tester.pumpWidget(_buildTestable(messages: []));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('No messages yet'), findsOneWidget);
|
||||
expect(find.text('Be the first to say something!'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders text messages with author and content', (
|
||||
tester,
|
||||
) async {
|
||||
final messages = [
|
||||
_textMsg(
|
||||
id: 'msg1',
|
||||
pubkey: 'alice',
|
||||
content: 'Hello world!',
|
||||
createdAt: 1000,
|
||||
),
|
||||
_textMsg(
|
||||
id: 'msg2',
|
||||
pubkey: 'bob',
|
||||
content: 'Hey Alice!',
|
||||
createdAt: 1100,
|
||||
),
|
||||
];
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: messages,
|
||||
users: {
|
||||
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
'bob': const UserProfile(pubkey: 'bob', displayName: 'Bob'),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(findRichText('Hello world!'), findsOneWidget);
|
||||
expect(findRichText('Hey Alice!'), findsOneWidget);
|
||||
expect(find.text('Alice'), findsOneWidget);
|
||||
expect(find.text('Bob'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('groups consecutive messages from same author', (tester) async {
|
||||
final messages = [
|
||||
_textMsg(
|
||||
id: 'msg1',
|
||||
pubkey: 'alice',
|
||||
content: 'First message',
|
||||
createdAt: 1000,
|
||||
),
|
||||
_textMsg(
|
||||
id: 'msg2',
|
||||
pubkey: 'alice',
|
||||
content: 'Second message',
|
||||
createdAt: 1060, // within 5 min
|
||||
),
|
||||
];
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: messages,
|
||||
users: {
|
||||
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Author name should appear only once (grouped).
|
||||
expect(find.text('Alice'), findsOneWidget);
|
||||
expect(findRichText('First message'), findsOneWidget);
|
||||
expect(findRichText('Second message'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows author again after 5min gap', (tester) async {
|
||||
final messages = [
|
||||
_textMsg(
|
||||
id: 'msg1',
|
||||
pubkey: 'alice',
|
||||
content: 'First',
|
||||
createdAt: 1000,
|
||||
),
|
||||
_textMsg(
|
||||
id: 'msg2',
|
||||
pubkey: 'alice',
|
||||
content: 'Second',
|
||||
createdAt: 1400, // 6+ min later
|
||||
),
|
||||
];
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: messages,
|
||||
users: {
|
||||
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Author name appears twice since messages are >5min apart.
|
||||
expect(find.text('Alice'), findsNWidgets(2));
|
||||
});
|
||||
|
||||
testWidgets('shows pubkey fallback when no profile', (tester) async {
|
||||
final messages = [
|
||||
_textMsg(
|
||||
id: 'msg1',
|
||||
pubkey: 'abcdef1234567890',
|
||||
content: 'Hi',
|
||||
createdAt: 1000,
|
||||
),
|
||||
];
|
||||
|
||||
await tester.pumpWidget(_buildTestable(messages: messages));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(findRichText('Hi'), findsOneWidget);
|
||||
// Should show first 8 chars of pubkey + ellipsis
|
||||
expect(find.text('abcdef12…'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('System messages', () {
|
||||
testWidgets('renders channel_created system event', (tester) async {
|
||||
final messages = [
|
||||
_systemMsg(
|
||||
id: 'sys1',
|
||||
payload: {'type': 'channel_created', 'actor': 'alice'},
|
||||
),
|
||||
];
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: messages,
|
||||
users: {
|
||||
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Alice created this channel'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders member_joined (self-join) system event', (
|
||||
tester,
|
||||
) async {
|
||||
final messages = [
|
||||
_systemMsg(
|
||||
id: 'sys1',
|
||||
payload: {'type': 'member_joined', 'actor': 'bob', 'target': 'bob'},
|
||||
),
|
||||
];
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: messages,
|
||||
users: {'bob': const UserProfile(pubkey: 'bob', displayName: 'Bob')},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Bob joined the channel'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders member_joined (added by other) system event', (
|
||||
tester,
|
||||
) async {
|
||||
final messages = [
|
||||
_systemMsg(
|
||||
id: 'sys1',
|
||||
payload: {'type': 'member_joined', 'actor': 'alice', 'target': 'bob'},
|
||||
),
|
||||
];
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: messages,
|
||||
users: {
|
||||
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
'bob': const UserProfile(pubkey: 'bob', displayName: 'Bob'),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Alice added Bob to the channel'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders member_left system event', (tester) async {
|
||||
final messages = [
|
||||
_systemMsg(
|
||||
id: 'sys1',
|
||||
payload: {'type': 'member_left', 'actor': 'bob'},
|
||||
),
|
||||
];
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: messages,
|
||||
users: {'bob': const UserProfile(pubkey: 'bob', displayName: 'Bob')},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Bob left the channel'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders member_removed system event', (tester) async {
|
||||
final messages = [
|
||||
_systemMsg(
|
||||
id: 'sys1',
|
||||
payload: {
|
||||
'type': 'member_removed',
|
||||
'actor': 'alice',
|
||||
'target': 'bob',
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: messages,
|
||||
users: {
|
||||
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
'bob': const UserProfile(pubkey: 'bob', displayName: 'Bob'),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Alice removed Bob from the channel'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders topic_changed system event', (tester) async {
|
||||
final messages = [
|
||||
_systemMsg(
|
||||
id: 'sys1',
|
||||
payload: {
|
||||
'type': 'topic_changed',
|
||||
'actor': 'alice',
|
||||
'topic': 'Release planning',
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: messages,
|
||||
users: {
|
||||
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.text('Alice changed the topic to "Release planning"'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('renders purpose_changed system event', (tester) async {
|
||||
final messages = [
|
||||
_systemMsg(
|
||||
id: 'sys1',
|
||||
payload: {
|
||||
'type': 'purpose_changed',
|
||||
'actor': 'alice',
|
||||
'purpose': 'Team standup notes',
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: messages,
|
||||
users: {
|
||||
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.text('Alice changed the purpose to "Team standup notes"'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('system message breaks author grouping', (tester) async {
|
||||
final messages = [
|
||||
_textMsg(
|
||||
id: 'msg1',
|
||||
pubkey: 'alice',
|
||||
content: 'Before',
|
||||
createdAt: 1000,
|
||||
),
|
||||
_systemMsg(
|
||||
id: 'sys1',
|
||||
payload: {'type': 'member_joined', 'actor': 'bob', 'target': 'bob'},
|
||||
createdAt: 1010,
|
||||
),
|
||||
_textMsg(
|
||||
id: 'msg2',
|
||||
pubkey: 'alice',
|
||||
content: 'After',
|
||||
createdAt: 1020,
|
||||
),
|
||||
];
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: messages,
|
||||
users: {
|
||||
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
'bob': const UserProfile(pubkey: 'bob', displayName: 'Bob'),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Alice should appear twice — system message breaks grouping.
|
||||
expect(find.text('Alice'), findsNWidgets(2));
|
||||
});
|
||||
|
||||
testWidgets('skips unknown system event types', (tester) async {
|
||||
final messages = [
|
||||
_systemMsg(
|
||||
id: 'sys1',
|
||||
payload: {'type': 'unknown_future_type', 'actor': 'alice'},
|
||||
),
|
||||
_textMsg(
|
||||
id: 'msg1',
|
||||
pubkey: 'alice',
|
||||
content: 'Hello',
|
||||
createdAt: 1100,
|
||||
),
|
||||
];
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: messages,
|
||||
users: {
|
||||
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Only the text message should render, unknown system event is skipped.
|
||||
expect(findRichText('Hello'), findsOneWidget);
|
||||
// No system message row rendered for unknown type.
|
||||
expect(find.byIcon(LucideIcons.arrowLeftRight), findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
group('Deletions', () {
|
||||
testWidgets('deleted messages are not shown', (tester) async {
|
||||
final messages = [
|
||||
_textMsg(
|
||||
id: 'msg1',
|
||||
pubkey: 'alice',
|
||||
content: 'Keep this',
|
||||
createdAt: 1000,
|
||||
),
|
||||
_textMsg(
|
||||
id: 'msg2',
|
||||
pubkey: 'bob',
|
||||
content: 'Delete this',
|
||||
createdAt: 1100,
|
||||
),
|
||||
_deletion(id: 'del1', targetIds: ['msg2'], createdAt: 1200),
|
||||
];
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: messages,
|
||||
users: {
|
||||
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
'bob': const UserProfile(pubkey: 'bob', displayName: 'Bob'),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(findRichText('Keep this'), findsOneWidget);
|
||||
expect(findRichText('Delete this'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('deletion of multiple messages', (tester) async {
|
||||
final messages = [
|
||||
_textMsg(id: 'msg1', pubkey: 'a', content: 'One', createdAt: 1000),
|
||||
_textMsg(id: 'msg2', pubkey: 'a', content: 'Two', createdAt: 1100),
|
||||
_textMsg(id: 'msg3', pubkey: 'a', content: 'Three', createdAt: 1200),
|
||||
_deletion(id: 'del1', targetIds: ['msg1', 'msg3'], createdAt: 1300),
|
||||
];
|
||||
|
||||
await tester.pumpWidget(_buildTestable(messages: messages));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(findRichText('One'), findsNothing);
|
||||
expect(findRichText('Two'), findsOneWidget);
|
||||
expect(findRichText('Three'), findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
group('Edits', () {
|
||||
testWidgets('edited message shows updated content and (edited) label', (
|
||||
tester,
|
||||
) async {
|
||||
final messages = [
|
||||
_textMsg(
|
||||
id: 'msg1',
|
||||
pubkey: 'alice',
|
||||
content: 'Original text',
|
||||
createdAt: 1000,
|
||||
),
|
||||
_edit(
|
||||
id: 'edit1',
|
||||
targetId: 'msg1',
|
||||
content: 'Edited text',
|
||||
createdAt: 1100,
|
||||
),
|
||||
];
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: messages,
|
||||
users: {
|
||||
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(findRichText('Edited text'), findsOneWidget);
|
||||
expect(findRichText('Original text'), findsNothing);
|
||||
expect(find.text('(edited)'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('latest edit wins when multiple edits exist', (tester) async {
|
||||
final messages = [
|
||||
_textMsg(id: 'msg1', pubkey: 'alice', content: 'V1', createdAt: 1000),
|
||||
_edit(id: 'e1', targetId: 'msg1', content: 'V2', createdAt: 1100),
|
||||
_edit(id: 'e2', targetId: 'msg1', content: 'V3', createdAt: 1200),
|
||||
];
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: messages,
|
||||
users: {
|
||||
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(findRichText('V3'), findsOneWidget);
|
||||
expect(findRichText('V1'), findsNothing);
|
||||
expect(findRichText('V2'), findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
group('Typing indicator', () {
|
||||
testWidgets('shows single typer', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: [],
|
||||
typing: [
|
||||
TypingEntry(
|
||||
pubkey: 'alice',
|
||||
expiresAtMs: DateTime.now().millisecondsSinceEpoch + 8000,
|
||||
),
|
||||
],
|
||||
users: {
|
||||
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Alice is typing…'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows two typers', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: [],
|
||||
typing: [
|
||||
TypingEntry(
|
||||
pubkey: 'alice',
|
||||
expiresAtMs: DateTime.now().millisecondsSinceEpoch + 8000,
|
||||
),
|
||||
TypingEntry(
|
||||
pubkey: 'bob',
|
||||
expiresAtMs: DateTime.now().millisecondsSinceEpoch + 8000,
|
||||
),
|
||||
],
|
||||
users: {
|
||||
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
'bob': const UserProfile(pubkey: 'bob', displayName: 'Bob'),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Alice and Bob are typing…'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows N others for 3+ typers', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: [],
|
||||
typing: [
|
||||
TypingEntry(
|
||||
pubkey: 'alice',
|
||||
expiresAtMs: DateTime.now().millisecondsSinceEpoch + 8000,
|
||||
),
|
||||
TypingEntry(
|
||||
pubkey: 'bob',
|
||||
expiresAtMs: DateTime.now().millisecondsSinceEpoch + 8000,
|
||||
),
|
||||
TypingEntry(
|
||||
pubkey: 'carol',
|
||||
expiresAtMs: DateTime.now().millisecondsSinceEpoch + 8000,
|
||||
),
|
||||
],
|
||||
users: {
|
||||
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
'bob': const UserProfile(pubkey: 'bob', displayName: 'Bob'),
|
||||
'carol': const UserProfile(pubkey: 'carol', displayName: 'Carol'),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Alice and 2 others are typing…'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('Compose bar', () {
|
||||
testWidgets('shows text field and send button', (tester) async {
|
||||
await tester.pumpWidget(_buildTestable(messages: []));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
expect(find.byIcon(LucideIcons.sendHorizontal), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows hint text', (tester) async {
|
||||
await tester.pumpWidget(_buildTestable(messages: []));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Message…'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('App bar', () {
|
||||
testWidgets('shows channel name with hash icon', (tester) async {
|
||||
await tester.pumpWidget(_buildTestable(messages: []));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('general'), findsOneWidget);
|
||||
expect(find.byIcon(LucideIcons.hash), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows lock icon for private channel', (tester) async {
|
||||
final privateChannel = Channel(
|
||||
id: _channelId,
|
||||
name: 'secret',
|
||||
channelType: 'stream',
|
||||
visibility: 'private',
|
||||
description: 'Private channel',
|
||||
createdBy: 'abc',
|
||||
createdAt: DateTime(2025),
|
||||
memberCount: 3,
|
||||
isMember: true,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(messages: [], channel: privateChannel),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('secret'), findsOneWidget);
|
||||
expect(find.byIcon(LucideIcons.lock), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('Error and loading states', () {
|
||||
testWidgets('shows error message on failure', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
channelMessagesProvider(
|
||||
_channelId,
|
||||
).overrideWith(() => _ErrorMessagesNotifier()),
|
||||
channelTypingProvider(
|
||||
_channelId,
|
||||
).overrideWith(() => _FakeTypingNotifier([])),
|
||||
userCacheProvider.overrideWith(() => _FakeUserCacheNotifier({})),
|
||||
channelsProvider.overrideWith(
|
||||
() => _FakeChannelsNotifier([_testChannel]),
|
||||
),
|
||||
relayClientProvider.overrideWithValue(
|
||||
RelayClient(baseUrl: 'http://localhost:3000'),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.lightTheme,
|
||||
home: ChannelDetailPage(channel: _testChannel),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Failed to load messages'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('Mixed message timeline', () {
|
||||
testWidgets('interleaves text and system messages correctly', (
|
||||
tester,
|
||||
) async {
|
||||
final messages = [
|
||||
_systemMsg(
|
||||
id: 'sys1',
|
||||
payload: {'type': 'channel_created', 'actor': 'alice'},
|
||||
createdAt: 900,
|
||||
),
|
||||
_textMsg(
|
||||
id: 'msg1',
|
||||
pubkey: 'alice',
|
||||
content: 'Welcome everyone!',
|
||||
createdAt: 1000,
|
||||
),
|
||||
_systemMsg(
|
||||
id: 'sys2',
|
||||
payload: {'type': 'member_joined', 'actor': 'bob', 'target': 'bob'},
|
||||
createdAt: 1100,
|
||||
),
|
||||
_textMsg(
|
||||
id: 'msg2',
|
||||
pubkey: 'bob',
|
||||
content: 'Thanks for the invite!',
|
||||
createdAt: 1200,
|
||||
),
|
||||
];
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: messages,
|
||||
users: {
|
||||
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
'bob': const UserProfile(pubkey: 'bob', displayName: 'Bob'),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Alice created this channel'), findsOneWidget);
|
||||
expect(findRichText('Welcome everyone!'), findsOneWidget);
|
||||
expect(find.text('Bob joined the channel'), findsOneWidget);
|
||||
expect(findRichText('Thanks for the invite!'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('Channel links', () {
|
||||
testWidgets('tapping a channel link opens that channel', (tester) async {
|
||||
final randomChannel = Channel(
|
||||
id: 'random-channel',
|
||||
name: 'random',
|
||||
channelType: 'stream',
|
||||
visibility: 'open',
|
||||
description: 'Random discussion',
|
||||
createdBy: 'abc123',
|
||||
createdAt: DateTime(2025),
|
||||
memberCount: 3,
|
||||
isMember: true,
|
||||
);
|
||||
final observer = _TestNavigatorObserver();
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: [
|
||||
_textMsg(
|
||||
id: 'msg1',
|
||||
pubkey: 'alice',
|
||||
content: 'Take this to #random',
|
||||
createdAt: 1000,
|
||||
),
|
||||
],
|
||||
users: {
|
||||
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
},
|
||||
channels: [_testChannel, randomChannel],
|
||||
navigatorObservers: [observer],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final initialPushCount = observer.pushCount;
|
||||
|
||||
await tester.tap(find.text('#random'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(observer.pushCount, initialPushCount + 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake providers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _FakeMessagesNotifier extends ChannelMessagesNotifier {
|
||||
final List<NostrEvent> _messages;
|
||||
_FakeMessagesNotifier(this._messages) : super(_channelId);
|
||||
|
||||
@override
|
||||
AsyncValue<List<NostrEvent>> build() => AsyncData(_messages);
|
||||
}
|
||||
|
||||
class _ErrorMessagesNotifier extends ChannelMessagesNotifier {
|
||||
_ErrorMessagesNotifier() : super(_channelId);
|
||||
|
||||
@override
|
||||
AsyncValue<List<NostrEvent>> build() =>
|
||||
AsyncError('Connection failed', StackTrace.current);
|
||||
}
|
||||
|
||||
class _FakeTypingNotifier extends ChannelTypingNotifier {
|
||||
final List<TypingEntry> _entries;
|
||||
_FakeTypingNotifier(this._entries) : super(_channelId);
|
||||
|
||||
@override
|
||||
List<TypingEntry> build() => _entries;
|
||||
}
|
||||
|
||||
class _FakeUserCacheNotifier extends UserCacheNotifier {
|
||||
final Map<String, UserProfile> _users;
|
||||
_FakeUserCacheNotifier(this._users);
|
||||
|
||||
@override
|
||||
Map<String, UserProfile> build() => _users;
|
||||
|
||||
@override
|
||||
UserProfile? get(String pubkey) => _users[pubkey.toLowerCase()];
|
||||
}
|
||||
|
||||
class _FakeChannelsNotifier extends ChannelsNotifier {
|
||||
final List<Channel> _channels;
|
||||
_FakeChannelsNotifier(this._channels);
|
||||
|
||||
@override
|
||||
Future<List<Channel>> build() => SynchronousFuture(_channels);
|
||||
}
|
||||
|
||||
class _TestNavigatorObserver extends NavigatorObserver {
|
||||
int pushCount = 0;
|
||||
|
||||
@override
|
||||
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
|
||||
pushCount += 1;
|
||||
super.didPush(route, previousRoute);
|
||||
}
|
||||
}
|
||||
355
mobile/test/features/channels/message_content_test.dart
Normal file
355
mobile/test/features/channels/message_content_test.dart
Normal file
@@ -0,0 +1,355 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sprout_mobile/features/channels/message_content.dart';
|
||||
import 'package:sprout_mobile/shared/theme/theme.dart';
|
||||
|
||||
Widget _testable(Widget child) {
|
||||
return MaterialApp(
|
||||
theme: AppTheme.lightTheme,
|
||||
home: Scaffold(body: child),
|
||||
);
|
||||
}
|
||||
|
||||
/// Extracts all plain text from all RichText widgets in the tree.
|
||||
String _allRichText(WidgetTester tester) {
|
||||
final richTexts = tester.widgetList<RichText>(find.byType(RichText));
|
||||
return richTexts.map((rt) => rt.text.toPlainText()).join('\n');
|
||||
}
|
||||
|
||||
/// Finds a RichText widget whose plain text contains [text].
|
||||
Finder _findRich(String text) {
|
||||
return find.byWidgetPredicate(
|
||||
(widget) => widget is RichText && widget.text.toPlainText().contains(text),
|
||||
description: 'RichText containing "$text"',
|
||||
);
|
||||
}
|
||||
|
||||
/// Checks that the given text appears as bold (fontWeight >= w600) in some
|
||||
/// TextSpan within any RichText widget.
|
||||
bool _hasBoldSpan(WidgetTester tester, String text) {
|
||||
for (final rt in tester.widgetList<RichText>(find.byType(RichText))) {
|
||||
if (_spanHasStyle(
|
||||
rt.text,
|
||||
text,
|
||||
(s) =>
|
||||
s.fontWeight != null && s.fontWeight!.value >= FontWeight.w600.value,
|
||||
)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _hasItalicSpan(WidgetTester tester, String text) {
|
||||
for (final rt in tester.widgetList<RichText>(find.byType(RichText))) {
|
||||
if (_spanHasStyle(rt.text, text, (s) => s.fontStyle == FontStyle.italic)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _hasStrikethroughSpan(WidgetTester tester, String text) {
|
||||
for (final rt in tester.widgetList<RichText>(find.byType(RichText))) {
|
||||
if (_spanHasStyle(
|
||||
rt.text,
|
||||
text,
|
||||
(s) => s.decoration == TextDecoration.lineThrough,
|
||||
)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _spanHasStyle(
|
||||
InlineSpan root,
|
||||
String text,
|
||||
bool Function(TextStyle) check,
|
||||
) {
|
||||
var found = false;
|
||||
root.visitChildren((span) {
|
||||
if (span is TextSpan &&
|
||||
span.text != null &&
|
||||
span.text!.contains(text) &&
|
||||
span.style != null &&
|
||||
check(span.style!)) {
|
||||
found = true;
|
||||
return false; // stop visiting
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('MessageContent', () {
|
||||
group('plain text', () {
|
||||
testWidgets('renders simple text', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_testable(const MessageContent(content: 'Hello world')),
|
||||
);
|
||||
|
||||
expect(_findRich('Hello world'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders empty content', (tester) async {
|
||||
await tester.pumpWidget(_testable(const MessageContent(content: '')));
|
||||
|
||||
// Should not crash.
|
||||
expect(find.byType(MessageContent), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('inline formatting', () {
|
||||
testWidgets('renders bold text', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_testable(const MessageContent(content: 'This is **bold** text')),
|
||||
);
|
||||
|
||||
final allText = _allRichText(tester);
|
||||
expect(allText, contains('bold'));
|
||||
expect(allText, isNot(contains('**')));
|
||||
expect(_hasBoldSpan(tester, 'bold'), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('renders italic text', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_testable(const MessageContent(content: 'This is *italic* text')),
|
||||
);
|
||||
|
||||
final allText = _allRichText(tester);
|
||||
expect(allText, contains('italic'));
|
||||
expect(_hasItalicSpan(tester, 'italic'), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('renders strikethrough text', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_testable(const MessageContent(content: 'This is ~~struck~~ text')),
|
||||
);
|
||||
|
||||
final allText = _allRichText(tester);
|
||||
expect(allText, contains('struck'));
|
||||
expect(allText, isNot(contains('~~')));
|
||||
expect(_hasStrikethroughSpan(tester, 'struck'), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('renders inline code', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_testable(const MessageContent(content: 'Use `flutter test` to run')),
|
||||
);
|
||||
|
||||
// Inline code is rendered inside a styled span.
|
||||
expect(_findRich('flutter test'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('renders markdown link', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_testable(
|
||||
const MessageContent(
|
||||
content: 'Check [Sprout](https://example.com)',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final allText = _allRichText(tester);
|
||||
expect(allText, contains('Sprout'));
|
||||
// Should not show raw markdown syntax.
|
||||
expect(allText, isNot(contains('[Sprout]')));
|
||||
expect(allText, isNot(contains('(https://example.com)')));
|
||||
});
|
||||
|
||||
testWidgets('renders bare URL as link', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_testable(
|
||||
const MessageContent(content: 'Visit https://example.com today'),
|
||||
),
|
||||
);
|
||||
|
||||
final allText = _allRichText(tester);
|
||||
expect(allText, contains('https://example.com'));
|
||||
});
|
||||
});
|
||||
|
||||
group('code blocks', () {
|
||||
testWidgets('renders fenced code block', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_testable(
|
||||
const MessageContent(content: 'Before\n```\ncode here\n```\nAfter'),
|
||||
),
|
||||
);
|
||||
|
||||
expect(_findRich('code here'), findsWidgets);
|
||||
expect(_findRich('Before'), findsWidgets);
|
||||
expect(_findRich('After'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('renders code block with language tag', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_testable(
|
||||
const MessageContent(content: '```dart\nvoid main() {}\n```'),
|
||||
),
|
||||
);
|
||||
|
||||
expect(_findRich('void main() {}'), findsWidgets);
|
||||
});
|
||||
});
|
||||
|
||||
group('blockquotes', () {
|
||||
testWidgets('renders blockquote with left border', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_testable(const MessageContent(content: '> This is a quote')),
|
||||
);
|
||||
|
||||
final allText = _allRichText(tester);
|
||||
expect(allText, contains('This is a quote'));
|
||||
// Should strip the > prefix.
|
||||
expect(allText, isNot(contains('> This')));
|
||||
});
|
||||
});
|
||||
|
||||
group('@mentions', () {
|
||||
testWidgets('renders @mention with highlight', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_testable(
|
||||
const MessageContent(
|
||||
content: 'Hey @Alice check this out',
|
||||
mentionNames: {'pk1': 'Alice'},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Mention should be rendered as @Alice in a highlighted container.
|
||||
expect(find.text('@Alice'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders unknown @mention as-is', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_testable(
|
||||
const MessageContent(
|
||||
content: 'Hey @unknown check this',
|
||||
mentionNames: {},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('@unknown'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('does not treat email addresses as mentions', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_testable(
|
||||
const MessageContent(
|
||||
content: 'Email alice@example.com for access',
|
||||
mentionNames: {'pk1': 'Alice'},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(_allRichText(tester), contains('alice@example.com'));
|
||||
expect(find.text('@example.com'), findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
group('#channel links', () {
|
||||
testWidgets('renders #channel with highlight', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_testable(
|
||||
const MessageContent(
|
||||
content: 'Check out #general',
|
||||
channelNames: {'general': 'ch-id-1'},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('#general'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('channel tap callback fires', (tester) async {
|
||||
String? tappedId;
|
||||
await tester.pumpWidget(
|
||||
_testable(
|
||||
MessageContent(
|
||||
content: 'See #general',
|
||||
channelNames: const {'general': 'ch-id-1'},
|
||||
onChannelTap: (id) => tappedId = id,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('#general'));
|
||||
expect(tappedId, 'ch-id-1');
|
||||
});
|
||||
|
||||
testWidgets('unknown channel renders without tap', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_testable(
|
||||
const MessageContent(content: 'Check #unknown', channelNames: {}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('#unknown'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('does not treat URL fragments as channel links', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_testable(
|
||||
const MessageContent(
|
||||
content: 'See https://example.com/docs#frag',
|
||||
channelNames: {'frag': 'ch-id-1'},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(_allRichText(tester), contains('https://example.com/docs#frag'));
|
||||
expect(find.text('#frag'), findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
group('mixed content', () {
|
||||
testWidgets('renders bold with mentions', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_testable(
|
||||
const MessageContent(
|
||||
content: '**Important** @Alice please review',
|
||||
mentionNames: {'pk1': 'Alice'},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(_hasBoldSpan(tester, 'Important'), isTrue);
|
||||
expect(find.text('@Alice'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('preserves markdown around mentions', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_testable(
|
||||
const MessageContent(
|
||||
content: '**@Alice** please review',
|
||||
mentionNames: {'pk1': 'Alice'},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('@Alice'), findsOneWidget);
|
||||
expect(_allRichText(tester), isNot(contains('**')));
|
||||
});
|
||||
|
||||
testWidgets('renders code block between paragraphs', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_testable(
|
||||
const MessageContent(
|
||||
content: 'Try this:\n```\nflutter test\n```\nDid it work?',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(_findRich('flutter test'), findsWidgets);
|
||||
expect(_findRich('Try this:'), findsWidgets);
|
||||
expect(_findRich('Did it work?'), findsWidgets);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
446
mobile/test/features/channels/timeline_message_test.dart
Normal file
446
mobile/test/features/channels/timeline_message_test.dart
Normal file
@@ -0,0 +1,446 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sprout_mobile/features/channels/timeline_message.dart';
|
||||
import 'package:sprout_mobile/shared/relay/relay.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
NostrEvent _textMsg({
|
||||
required String id,
|
||||
String pubkey = 'alice',
|
||||
String content = 'hello',
|
||||
int createdAt = 1000,
|
||||
}) => NostrEvent(
|
||||
id: id,
|
||||
pubkey: pubkey,
|
||||
createdAt: createdAt,
|
||||
kind: EventKind.streamMessage,
|
||||
tags: [
|
||||
['h', 'ch1'],
|
||||
],
|
||||
content: content,
|
||||
sig: '',
|
||||
);
|
||||
|
||||
NostrEvent _systemMsg({
|
||||
required String id,
|
||||
required Map<String, dynamic> payload,
|
||||
int createdAt = 1000,
|
||||
}) => NostrEvent(
|
||||
id: id,
|
||||
pubkey: 'relay',
|
||||
createdAt: createdAt,
|
||||
kind: EventKind.systemMessage,
|
||||
tags: [
|
||||
['h', 'ch1'],
|
||||
],
|
||||
content: jsonEncode(payload),
|
||||
sig: '',
|
||||
);
|
||||
|
||||
NostrEvent _deletion({
|
||||
required String id,
|
||||
required List<String> targets,
|
||||
int createdAt = 2000,
|
||||
}) => NostrEvent(
|
||||
id: id,
|
||||
pubkey: 'alice',
|
||||
createdAt: createdAt,
|
||||
kind: EventKind.deletion,
|
||||
tags: [
|
||||
['h', 'ch1'],
|
||||
for (final t in targets) ['e', t],
|
||||
],
|
||||
content: '',
|
||||
sig: '',
|
||||
);
|
||||
|
||||
NostrEvent _edit({
|
||||
required String id,
|
||||
required String targetId,
|
||||
required String content,
|
||||
int createdAt = 2000,
|
||||
}) => NostrEvent(
|
||||
id: id,
|
||||
pubkey: 'alice',
|
||||
createdAt: createdAt,
|
||||
kind: EventKind.streamMessageEdit,
|
||||
tags: [
|
||||
['h', 'ch1'],
|
||||
['e', targetId],
|
||||
],
|
||||
content: content,
|
||||
sig: '',
|
||||
);
|
||||
|
||||
NostrEvent _reaction({
|
||||
required String id,
|
||||
required String targetId,
|
||||
int createdAt = 2000,
|
||||
}) => NostrEvent(
|
||||
id: id,
|
||||
pubkey: 'bob',
|
||||
createdAt: createdAt,
|
||||
kind: EventKind.reaction,
|
||||
tags: [
|
||||
['h', 'ch1'],
|
||||
['e', targetId],
|
||||
],
|
||||
content: '👍',
|
||||
sig: '',
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SystemEvent.fromContent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void main() {
|
||||
group('SystemEvent.fromContent', () {
|
||||
test('parses all known event types', () {
|
||||
final types = {
|
||||
'member_joined': SystemEventType.memberJoined,
|
||||
'member_left': SystemEventType.memberLeft,
|
||||
'member_removed': SystemEventType.memberRemoved,
|
||||
'topic_changed': SystemEventType.topicChanged,
|
||||
'purpose_changed': SystemEventType.purposeChanged,
|
||||
'channel_created': SystemEventType.channelCreated,
|
||||
'channel_archived': SystemEventType.channelArchived,
|
||||
'channel_unarchived': SystemEventType.channelUnarchived,
|
||||
};
|
||||
|
||||
for (final entry in types.entries) {
|
||||
final event = SystemEvent.fromContent(
|
||||
jsonEncode({'type': entry.key, 'actor': 'pk1'}),
|
||||
);
|
||||
expect(event, isNotNull, reason: 'Failed for ${entry.key}');
|
||||
expect(event!.type, entry.value);
|
||||
expect(event.actorPubkey, 'pk1');
|
||||
}
|
||||
});
|
||||
|
||||
test('returns null for unknown type', () {
|
||||
final event = SystemEvent.fromContent(
|
||||
jsonEncode({'type': 'unknown_type'}),
|
||||
);
|
||||
expect(event, isNull);
|
||||
});
|
||||
|
||||
test('returns null for invalid JSON', () {
|
||||
expect(SystemEvent.fromContent('not json'), isNull);
|
||||
});
|
||||
|
||||
test('returns null for wrong JSON field types', () {
|
||||
expect(
|
||||
SystemEvent.fromContent(
|
||||
jsonEncode({
|
||||
'type': ['member_joined'],
|
||||
'actor': 123,
|
||||
}),
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('parses target, topic, and purpose fields', () {
|
||||
final event = SystemEvent.fromContent(
|
||||
jsonEncode({
|
||||
'type': 'topic_changed',
|
||||
'actor': 'pk1',
|
||||
'target': 'pk2',
|
||||
'topic': 'New topic',
|
||||
'purpose': 'New purpose',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(event, isNotNull);
|
||||
expect(event!.targetPubkey, 'pk2');
|
||||
expect(event.topic, 'New topic');
|
||||
expect(event.purpose, 'New purpose');
|
||||
});
|
||||
});
|
||||
|
||||
group('SystemEvent.describe', () {
|
||||
String resolve(String? pk) => pk == 'pk1' ? 'Alice' : 'Bob';
|
||||
|
||||
test('member_joined self', () {
|
||||
final event = SystemEvent(
|
||||
type: SystemEventType.memberJoined,
|
||||
actorPubkey: 'pk1',
|
||||
targetPubkey: 'pk1',
|
||||
);
|
||||
expect(event.describe(resolve), 'Alice joined the channel');
|
||||
});
|
||||
|
||||
test('member_joined by other', () {
|
||||
final event = SystemEvent(
|
||||
type: SystemEventType.memberJoined,
|
||||
actorPubkey: 'pk1',
|
||||
targetPubkey: 'pk2',
|
||||
);
|
||||
expect(event.describe(resolve), 'Alice added Bob to the channel');
|
||||
});
|
||||
|
||||
test('member_left', () {
|
||||
final event = SystemEvent(
|
||||
type: SystemEventType.memberLeft,
|
||||
actorPubkey: 'pk1',
|
||||
);
|
||||
expect(event.describe(resolve), 'Alice left the channel');
|
||||
});
|
||||
|
||||
test('member_removed', () {
|
||||
final event = SystemEvent(
|
||||
type: SystemEventType.memberRemoved,
|
||||
actorPubkey: 'pk1',
|
||||
targetPubkey: 'pk2',
|
||||
);
|
||||
expect(event.describe(resolve), 'Alice removed Bob from the channel');
|
||||
});
|
||||
|
||||
test('topic_changed', () {
|
||||
final event = SystemEvent(
|
||||
type: SystemEventType.topicChanged,
|
||||
actorPubkey: 'pk1',
|
||||
topic: 'Release v2',
|
||||
);
|
||||
expect(
|
||||
event.describe(resolve),
|
||||
'Alice changed the topic to "Release v2"',
|
||||
);
|
||||
});
|
||||
|
||||
test('purpose_changed', () {
|
||||
final event = SystemEvent(
|
||||
type: SystemEventType.purposeChanged,
|
||||
actorPubkey: 'pk1',
|
||||
purpose: 'Daily standups',
|
||||
);
|
||||
expect(
|
||||
event.describe(resolve),
|
||||
'Alice changed the purpose to "Daily standups"',
|
||||
);
|
||||
});
|
||||
|
||||
test('channel_created', () {
|
||||
final event = SystemEvent(
|
||||
type: SystemEventType.channelCreated,
|
||||
actorPubkey: 'pk1',
|
||||
);
|
||||
expect(event.describe(resolve), 'Alice created this channel');
|
||||
});
|
||||
|
||||
test('channel_archived', () {
|
||||
final event = SystemEvent(
|
||||
type: SystemEventType.channelArchived,
|
||||
actorPubkey: 'pk1',
|
||||
);
|
||||
expect(event.describe(resolve), 'Alice archived this channel');
|
||||
});
|
||||
|
||||
test('channel_unarchived', () {
|
||||
final event = SystemEvent(
|
||||
type: SystemEventType.channelUnarchived,
|
||||
actorPubkey: 'pk1',
|
||||
);
|
||||
expect(event.describe(resolve), 'Alice unarchived this channel');
|
||||
});
|
||||
});
|
||||
|
||||
group('formatTimeline', () {
|
||||
test('passes through text messages', () {
|
||||
final events = [
|
||||
_textMsg(id: 'a', content: 'hello'),
|
||||
_textMsg(id: 'b', content: 'world', createdAt: 1100),
|
||||
];
|
||||
|
||||
final result = formatTimeline(events);
|
||||
expect(result, hasLength(2));
|
||||
expect(result[0].content, 'hello');
|
||||
expect(result[1].content, 'world');
|
||||
expect(result[0].isSystem, false);
|
||||
expect(result[0].edited, false);
|
||||
});
|
||||
|
||||
test('filters deleted messages', () {
|
||||
final events = [
|
||||
_textMsg(id: 'a', content: 'keep'),
|
||||
_textMsg(id: 'b', content: 'delete', createdAt: 1100),
|
||||
_deletion(id: 'd1', targets: ['b']),
|
||||
];
|
||||
|
||||
final result = formatTimeline(events);
|
||||
expect(result, hasLength(1));
|
||||
expect(result[0].content, 'keep');
|
||||
});
|
||||
|
||||
test('applies edits', () {
|
||||
final events = [
|
||||
_textMsg(id: 'a', content: 'original'),
|
||||
_edit(id: 'e1', targetId: 'a', content: 'edited'),
|
||||
];
|
||||
|
||||
final result = formatTimeline(events);
|
||||
expect(result, hasLength(1));
|
||||
expect(result[0].content, 'edited');
|
||||
expect(result[0].edited, true);
|
||||
});
|
||||
|
||||
test('latest edit wins', () {
|
||||
final events = [
|
||||
_textMsg(id: 'a', content: 'v1'),
|
||||
_edit(id: 'e1', targetId: 'a', content: 'v2', createdAt: 2000),
|
||||
_edit(id: 'e2', targetId: 'a', content: 'v3', createdAt: 3000),
|
||||
];
|
||||
|
||||
final result = formatTimeline(events);
|
||||
expect(result[0].content, 'v3');
|
||||
});
|
||||
|
||||
test('deleted edit is ignored', () {
|
||||
final events = [
|
||||
_textMsg(id: 'a', content: 'original'),
|
||||
_edit(id: 'e1', targetId: 'a', content: 'edited'),
|
||||
_deletion(id: 'd1', targets: ['e1']),
|
||||
];
|
||||
|
||||
final result = formatTimeline(events);
|
||||
expect(result[0].content, 'original');
|
||||
expect(result[0].edited, false);
|
||||
});
|
||||
|
||||
test('edit of deleted message is ignored', () {
|
||||
final events = [
|
||||
_textMsg(id: 'a', content: 'original'),
|
||||
_edit(id: 'e1', targetId: 'a', content: 'edited'),
|
||||
_deletion(id: 'd1', targets: ['a']),
|
||||
];
|
||||
|
||||
final result = formatTimeline(events);
|
||||
expect(result, isEmpty);
|
||||
});
|
||||
|
||||
test('system messages are parsed', () {
|
||||
final events = [
|
||||
_systemMsg(
|
||||
id: 's1',
|
||||
payload: {'type': 'channel_created', 'actor': 'pk1'},
|
||||
),
|
||||
];
|
||||
|
||||
final result = formatTimeline(events);
|
||||
expect(result, hasLength(1));
|
||||
expect(result[0].isSystem, true);
|
||||
expect(result[0].systemEvent, isNotNull);
|
||||
expect(result[0].systemEvent!.type, SystemEventType.channelCreated);
|
||||
});
|
||||
|
||||
test('unknown system messages are dropped', () {
|
||||
final events = [
|
||||
_systemMsg(id: 's1', payload: {'type': 'unknown'}),
|
||||
];
|
||||
|
||||
final result = formatTimeline(events);
|
||||
expect(result, isEmpty);
|
||||
});
|
||||
|
||||
test('malformed system messages are skipped safely', () {
|
||||
final events = [
|
||||
_systemMsg(
|
||||
id: 's1',
|
||||
payload: {
|
||||
'type': ['member_joined'],
|
||||
'actor': 123,
|
||||
},
|
||||
),
|
||||
_textMsg(id: 'a', content: 'hello', createdAt: 1100),
|
||||
];
|
||||
|
||||
final result = formatTimeline(events);
|
||||
expect(result, hasLength(1));
|
||||
expect(result[0].content, 'hello');
|
||||
});
|
||||
|
||||
test('reactions and typing indicators are filtered out', () {
|
||||
final events = [
|
||||
_textMsg(id: 'a', content: 'hello'),
|
||||
_reaction(id: 'r1', targetId: 'a'),
|
||||
NostrEvent(
|
||||
id: 'typing1',
|
||||
pubkey: 'bob',
|
||||
createdAt: 1000,
|
||||
kind: EventKind.typingIndicator,
|
||||
tags: [
|
||||
['h', 'ch1'],
|
||||
],
|
||||
content: '',
|
||||
sig: '',
|
||||
),
|
||||
];
|
||||
|
||||
final result = formatTimeline(events);
|
||||
expect(result, hasLength(1));
|
||||
expect(result[0].content, 'hello');
|
||||
});
|
||||
|
||||
test('preserves chronological order', () {
|
||||
final events = [
|
||||
_textMsg(id: 'a', content: 'first', createdAt: 1000),
|
||||
_systemMsg(
|
||||
id: 's1',
|
||||
payload: {'type': 'member_joined', 'actor': 'pk1', 'target': 'pk1'},
|
||||
createdAt: 1100,
|
||||
),
|
||||
_textMsg(id: 'b', content: 'second', createdAt: 1200),
|
||||
];
|
||||
|
||||
final result = formatTimeline(events);
|
||||
expect(result, hasLength(3));
|
||||
expect(result[0].content, 'first');
|
||||
expect(result[1].isSystem, true);
|
||||
expect(result[2].content, 'second');
|
||||
});
|
||||
|
||||
test('streamMessageV2 events are included', () {
|
||||
final events = [
|
||||
NostrEvent(
|
||||
id: 'v2msg',
|
||||
pubkey: 'alice',
|
||||
createdAt: 1000,
|
||||
kind: EventKind.streamMessageV2,
|
||||
tags: [
|
||||
['h', 'ch1'],
|
||||
],
|
||||
content: 'legacy message',
|
||||
sig: '',
|
||||
),
|
||||
];
|
||||
|
||||
final result = formatTimeline(events);
|
||||
expect(result, hasLength(1));
|
||||
expect(result[0].content, 'legacy message');
|
||||
});
|
||||
|
||||
test('streamMessageDiff events are included', () {
|
||||
final events = [
|
||||
NostrEvent(
|
||||
id: 'diff1',
|
||||
pubkey: 'alice',
|
||||
createdAt: 1000,
|
||||
kind: EventKind.streamMessageDiff,
|
||||
tags: [
|
||||
['h', 'ch1'],
|
||||
],
|
||||
content: '```diff\n-old\n+new\n```',
|
||||
sig: '',
|
||||
),
|
||||
];
|
||||
|
||||
final result = formatTimeline(events);
|
||||
expect(result, hasLength(1));
|
||||
expect(result[0].content, contains('diff'));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user