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
|
## See Also
|
||||||
|
|
||||||
- [CONTRIBUTING.md](CONTRIBUTING.md) — setup, code style, PR process, how to add event kinds / MCP tools / API endpoints
|
- [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]]
|
[[package]]
|
||||||
name = "rustls-webpki"
|
name = "rustls-webpki"
|
||||||
version = "0.103.10"
|
version = "0.103.12"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
|
checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-rs",
|
"aws-lc-rs",
|
||||||
"ring",
|
"ring",
|
||||||
|
|||||||
@@ -3,13 +3,6 @@ ignore = [
|
|||||||
# instant 0.1.13 — unmaintained crate. Transitive dep: nostr → instant.
|
# instant 0.1.13 — unmaintained crate. Transitive dep: nostr → instant.
|
||||||
# Will be resolved when nostr crate updates its dependencies.
|
# Will be resolved when nostr crate updates its dependencies.
|
||||||
{ id = "RUSTSEC-2024-0384", reason = "transitive dep via nostr; no upstream fix available" },
|
{ 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]
|
[licenses]
|
||||||
|
|||||||
@@ -10,12 +10,17 @@ import '../profile/user_profile.dart';
|
|||||||
import 'channel.dart';
|
import 'channel.dart';
|
||||||
import 'channel_messages_provider.dart';
|
import 'channel_messages_provider.dart';
|
||||||
import 'channel_typing_provider.dart';
|
import 'channel_typing_provider.dart';
|
||||||
|
import 'channels_provider.dart';
|
||||||
|
import 'message_content.dart';
|
||||||
import 'send_message_provider.dart';
|
import 'send_message_provider.dart';
|
||||||
|
import 'timeline_message.dart';
|
||||||
|
|
||||||
/// Fetch channel members and preload their profiles into the user cache.
|
/// Fetch channel members and preload their profiles into the user cache.
|
||||||
Future<void> _preloadMembers(WidgetRef ref, String channelId) async {
|
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 {
|
try {
|
||||||
final client = ref.read(relayClientProvider);
|
|
||||||
final json =
|
final json =
|
||||||
await client.get('/api/channels/$channelId/members')
|
await client.get('/api/channels/$channelId/members')
|
||||||
as Map<String, dynamic>;
|
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)
|
.map((m) => (m as Map<String, dynamic>)['pubkey'] as String)
|
||||||
.toList();
|
.toList();
|
||||||
if (pubkeys.isNotEmpty) {
|
if (pubkeys.isNotEmpty) {
|
||||||
ref.read(userCacheProvider.notifier).preload(pubkeys);
|
notifier.preload(pubkeys);
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Non-fatal — mentions will just fall back to cache from messages.
|
// Non-fatal — mentions will just fall back to cache from messages.
|
||||||
@@ -76,8 +81,10 @@ class ChannelDetailPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
data: (messages) =>
|
data: (events) {
|
||||||
_MessageList(messages: messages, channelId: channel.id),
|
final messages = formatTimeline(events);
|
||||||
|
return _MessageList(messages: messages, channelId: channel.id);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (typingEntries.isNotEmpty)
|
if (typingEntries.isNotEmpty)
|
||||||
@@ -90,7 +97,7 @@ class ChannelDetailPage extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _MessageList extends ConsumerWidget {
|
class _MessageList extends ConsumerWidget {
|
||||||
final List<NostrEvent> messages;
|
final List<TimelineMessage> messages;
|
||||||
final String channelId;
|
final String channelId;
|
||||||
|
|
||||||
const _MessageList({required this.messages, required this.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.
|
// Build channel names map once for all message bubbles.
|
||||||
final displayMessages = messages
|
final channelsAsync = ref.watch(channelsProvider);
|
||||||
.where(
|
final channelNamesMap = <String, String>{};
|
||||||
(e) =>
|
channelsAsync.whenData((channels) {
|
||||||
e.kind == EventKind.streamMessage ||
|
for (final ch in channels) {
|
||||||
e.kind == EventKind.streamMessageV2,
|
channelNamesMap[ch.name.toLowerCase()] = ch.id;
|
||||||
)
|
}
|
||||||
.toList();
|
});
|
||||||
|
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
reverse: true,
|
reverse: true,
|
||||||
@@ -141,39 +148,134 @@ class _MessageList extends ConsumerWidget {
|
|||||||
horizontal: Grid.xs,
|
horizontal: Grid.xs,
|
||||||
vertical: Grid.xxs,
|
vertical: Grid.xxs,
|
||||||
),
|
),
|
||||||
itemCount: displayMessages.length,
|
itemCount: messages.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
// Reversed list: index 0 = newest (bottom of screen).
|
// Reversed list: index 0 = newest (bottom of screen).
|
||||||
final chronIdx = displayMessages.length - 1 - index;
|
final chronIdx = messages.length - 1 - index;
|
||||||
final message = displayMessages[chronIdx];
|
final message = messages[chronIdx];
|
||||||
// The message visually above is the one earlier in time.
|
|
||||||
final prevMessage = chronIdx > 0 ? displayMessages[chronIdx - 1] : null;
|
|
||||||
|
|
||||||
|
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 =
|
final showAuthor =
|
||||||
prevMessage == null ||
|
prevMessage == null ||
|
||||||
|
prevMessage.isSystem ||
|
||||||
prevMessage.pubkey.toLowerCase() != message.pubkey.toLowerCase() ||
|
prevMessage.pubkey.toLowerCase() != message.pubkey.toLowerCase() ||
|
||||||
(message.createdAt - prevMessage.createdAt) > 300;
|
(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;
|
// System message row
|
||||||
final bool showAuthor;
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const _MessageBubble({required this.event, required this.showAuthor});
|
class _SystemMessageRow extends ConsumerWidget {
|
||||||
|
final TimelineMessage message;
|
||||||
|
|
||||||
|
const _SystemMessageRow({required this.message});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
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);
|
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 =
|
final profile =
|
||||||
userCache[event.pubkey.toLowerCase()] ??
|
ref.watch(userCacheProvider.select((cache) => cache[pk])) ??
|
||||||
ref.read(userCacheProvider.notifier).get(event.pubkey.toLowerCase());
|
ref.read(userCacheProvider.notifier).get(pk);
|
||||||
final displayName = profile?.label ?? _shortPubkey(event.pubkey);
|
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(
|
return Padding(
|
||||||
padding: EdgeInsets.only(top: showAuthor ? Grid.xxs : Grid.quarter),
|
padding: EdgeInsets.only(top: showAuthor ? Grid.xxs : Grid.quarter),
|
||||||
@@ -181,7 +283,7 @@ class _MessageBubble extends ConsumerWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
if (showAuthor)
|
if (showAuthor)
|
||||||
_UserAvatar(profile: profile, pubkey: event.pubkey)
|
_UserAvatar(profile: profile, pubkey: message.pubkey)
|
||||||
else
|
else
|
||||||
const SizedBox(width: 28),
|
const SizedBox(width: 28),
|
||||||
const SizedBox(width: Grid.xxs),
|
const SizedBox(width: Grid.xxs),
|
||||||
@@ -203,19 +305,51 @@ class _MessageBubble extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: Grid.xxs),
|
const SizedBox(width: Grid.xxs),
|
||||||
Text(
|
Text(
|
||||||
_formatTime(event.createdAt),
|
_formatTime(message.createdAt),
|
||||||
style: context.textTheme.labelSmall?.copyWith(
|
style: context.textTheme.labelSmall?.copyWith(
|
||||||
color: context.colors.outline,
|
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(
|
MessageContent(
|
||||||
event.content,
|
content: message.content,
|
||||||
style: context.textTheme.bodyMedium?.copyWith(
|
mentionNames: mentionNames,
|
||||||
color: context.colors.onSurface,
|
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 {
|
class _UserAvatar extends StatelessWidget {
|
||||||
@@ -278,6 +389,10 @@ class _UserAvatar extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Typing indicator
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class _TypingIndicator extends ConsumerWidget {
|
class _TypingIndicator extends ConsumerWidget {
|
||||||
final List<TypingEntry> entries;
|
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 {
|
class _ComposeBar extends HookConsumerWidget {
|
||||||
final String channelId;
|
final String channelId;
|
||||||
|
|
||||||
@@ -339,9 +453,9 @@ class _ComposeBar extends HookConsumerWidget {
|
|||||||
await ref
|
await ref
|
||||||
.read(sendMessageProvider)
|
.read(sendMessageProvider)
|
||||||
.call(channelId: channelId, content: text);
|
.call(channelId: channelId, content: text);
|
||||||
controller.clear();
|
if (context.mounted) controller.clear();
|
||||||
} finally {
|
} 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"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.0"
|
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:
|
flutter_riverpod:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -342,6 +350,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.2"
|
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:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -376,6 +392,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.3"
|
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:
|
hooks:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -560,6 +584,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.17.6"
|
version: "0.17.6"
|
||||||
|
nested:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: nested
|
||||||
|
sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0"
|
||||||
nm:
|
nm:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -608,6 +640,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
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:
|
path_provider:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -696,6 +736,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.5.2"
|
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:
|
pub_semver:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -933,6 +981,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.16"
|
version: "0.6.16"
|
||||||
|
tuple:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: tuple
|
||||||
|
sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.2"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -941,6 +997,70 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
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:
|
uuid:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -949,6 +1069,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.5.3"
|
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:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ dependencies:
|
|||||||
web_socket_channel: ^3.0.1
|
web_socket_channel: ^3.0.1
|
||||||
connectivity_plus: ^6.1.4
|
connectivity_plus: ^6.1.4
|
||||||
nostr: ^1.5.0
|
nostr: ^1.5.0
|
||||||
|
url_launcher: ^6.3.2
|
||||||
|
gpt_markdown: ^1.1.6
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
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