Wes
2026-06-08 10:28:32 -07:00
committed by GitHub
parent 732e23dd5c
commit 10b6674bd7
23 changed files with 1270 additions and 228 deletions

View File

@@ -9,6 +9,7 @@ import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart';
import '../channels/channel.dart';
import '../channels/channel_detail_page.dart';
import '../channels/message_content.dart';
import '../channels/channels_provider.dart';
import '../channels/small_avatar.dart';
import '../profile/user_cache_provider.dart';
@@ -229,11 +230,11 @@ class _FeedItemTile extends ConsumerWidget {
),
const SizedBox(height: Grid.half),
// Content preview (max 2 lines)
Text(
item.displayContent,
MessageContent(
content: item.displayContent,
tags: item.tags,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodySmall?.copyWith(
baseStyle: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),

View File

@@ -6,6 +6,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/auth/auth.dart';
import '../../shared/relay/relay.dart';
import '../profile/profile_provider.dart';
import '../custom_emoji/custom_emoji.dart';
import '../custom_emoji/custom_emoji_provider.dart';
import 'channel.dart';
import 'channels_provider.dart';
@@ -393,11 +395,18 @@ class ChannelActions {
}
Future<void> addReaction(String eventId, String emoji) async {
final shortcode = normalizeShortcode(emoji);
final emojiUrl = reactionEmojiUrl(
emoji,
_ref.read(customEmojiListProvider),
);
await _signedEventRelay.submit(
kind: EventKind.reaction,
content: emoji,
tags: [
['e', eventId],
if (shortcode != null && emojiUrl != null)
['emoji', shortcode, emojiUrl],
],
);
}
@@ -416,6 +425,7 @@ class ChannelActions {
required String channelId,
required String eventId,
required String content,
List<List<String>> mediaTags = const [],
}) async {
await _signedEventRelay.submit(
kind: EventKind.streamMessageEdit,
@@ -423,6 +433,7 @@ class ChannelActions {
tags: [
['h', channelId],
['e', eventId],
...mediaTags,
],
);
}

View File

@@ -9,6 +9,8 @@ import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
import '../profile/user_cache_provider.dart';
import '../profile/user_profile.dart';
import '../custom_emoji/custom_emoji.dart';
import '../custom_emoji/custom_emoji_provider.dart';
import 'channel.dart';
import 'channel_management_provider.dart';
import 'channels_provider.dart';
@@ -55,6 +57,7 @@ class ComposeBar extends HookConsumerWidget {
final uploadingCount = useState(0);
final hasAttachments = attachments.value.isNotEmpty;
final hasPendingUploads = uploadingCount.value > 0;
final customEmoji = ref.watch(customEmojiListProvider);
final resolvedHint =
hintText ??
@@ -239,6 +242,7 @@ class ComposeBar extends HookConsumerWidget {
final payload = _ComposeDraftPayload.fromDraft(
text: text,
attachments: attachments.value,
customEmoji: customEmoji,
);
isSending.value = true;
@@ -940,6 +944,7 @@ class _ComposeDraftPayload {
factory _ComposeDraftPayload.fromDraft({
required String text,
required List<BlobDescriptor> attachments,
required List<CustomEmoji> customEmoji,
}) {
var content = text;
final mediaTags = <List<String>>[];
@@ -947,6 +952,7 @@ class _ComposeDraftPayload {
mediaTags.add(attachment.toImetaTag());
content += '\n${attachment.toMarkdownImage()}';
}
mediaTags.addAll(buildCustomEmojiTags(content, customEmoji));
return _ComposeDraftPayload(content: content, mediaTags: mediaTags);
}
}

View File

@@ -1,7 +1,11 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/theme/theme.dart';
import '../custom_emoji/custom_emoji.dart';
import '../custom_emoji/custom_emoji_provider.dart';
import '../custom_emoji/custom_emoji_render.dart';
/// Opens the full emoji picker as a modal bottom sheet.
void showEmojiPicker({
@@ -185,16 +189,16 @@ const emojiCategories = <({String label, IconData icon, List<String> emoji})>[
),
];
class EmojiPickerSheet extends StatefulWidget {
class EmojiPickerSheet extends ConsumerStatefulWidget {
final void Function(String emoji) onSelect;
const EmojiPickerSheet({super.key, required this.onSelect});
@override
State<EmojiPickerSheet> createState() => _EmojiPickerSheetState();
ConsumerState<EmojiPickerSheet> createState() => _EmojiPickerSheetState();
}
class _EmojiPickerSheetState extends State<EmojiPickerSheet> {
class _EmojiPickerSheetState extends ConsumerState<EmojiPickerSheet> {
/// -1 = "All", 0..N = specific category.
int _selectedCategory = -1;
@@ -210,9 +214,14 @@ class _EmojiPickerSheetState extends State<EmojiPickerSheet> {
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final customEmoji = ref.watch(customEmojiListProvider);
final isCustomCategory =
customEmoji.isNotEmpty && _selectedCategory == emojiCategories.length;
final emoji = _selectedCategory < 0
? _allEmoji
: emojiCategories[_selectedCategory].emoji;
: _selectedCategory < emojiCategories.length
? emojiCategories[_selectedCategory].emoji
: const <String>[];
return SizedBox(
height: 340,
@@ -235,6 +244,14 @@ class _EmojiPickerSheetState extends State<EmojiPickerSheet> {
selected: _selectedCategory == i,
onTap: () => setState(() => _selectedCategory = i),
),
if (customEmoji.isNotEmpty)
CategoryIcon(
icon: LucideIcons.sparkles,
selected: isCustomCategory,
onTap: () => setState(
() => _selectedCategory = emojiCategories.length,
),
),
],
),
),
@@ -242,24 +259,30 @@ class _EmojiPickerSheetState extends State<EmojiPickerSheet> {
const SizedBox(height: Grid.xxs),
// Emoji grid.
Expanded(
child: GridView.builder(
padding: const EdgeInsets.symmetric(horizontal: Grid.xs),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 8,
mainAxisSpacing: Grid.half,
crossAxisSpacing: Grid.half,
),
itemCount: emoji.length,
itemBuilder: (context, index) {
final e = emoji[index];
return GestureDetector(
onTap: () => widget.onSelect(e),
child: Center(
child: Text(e, style: const TextStyle(fontSize: 28)),
child: isCustomCategory
? _CustomEmojiGrid(
emoji: customEmoji,
onSelect: widget.onSelect,
)
: GridView.builder(
padding: const EdgeInsets.symmetric(horizontal: Grid.xs),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 8,
mainAxisSpacing: Grid.half,
crossAxisSpacing: Grid.half,
),
itemCount: emoji.length,
itemBuilder: (context, index) {
final e = emoji[index];
return GestureDetector(
onTap: () => widget.onSelect(e),
child: Center(
child: Text(e, style: const TextStyle(fontSize: 28)),
),
);
},
),
);
},
),
),
],
),
@@ -267,6 +290,42 @@ class _EmojiPickerSheetState extends State<EmojiPickerSheet> {
}
}
class _CustomEmojiGrid extends StatelessWidget {
final List<CustomEmoji> emoji;
final void Function(String emoji) onSelect;
const _CustomEmojiGrid({required this.emoji, required this.onSelect});
@override
Widget build(BuildContext context) {
return GridView.builder(
padding: const EdgeInsets.symmetric(horizontal: Grid.xs),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 6,
mainAxisSpacing: Grid.half,
crossAxisSpacing: Grid.half,
),
itemCount: emoji.length,
itemBuilder: (context, index) {
final entry = emoji[index];
return GestureDetector(
onTap: () => onSelect(':${entry.shortcode}:'),
child: Tooltip(
message: ':${entry.shortcode}:',
child: Center(
child: CustomEmojiImage(
shortcode: entry.shortcode,
url: entry.url,
size: 32,
),
),
),
);
},
);
}
}
class CategoryIcon extends StatelessWidget {
final IconData icon;
final bool selected;

View File

@@ -4,6 +4,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/theme/theme.dart';
import '../custom_emoji/custom_emoji.dart';
import '../custom_emoji/custom_emoji_provider.dart';
import 'channel_management_provider.dart';
import 'emoji_picker.dart';
import 'thread_detail_page.dart';
@@ -220,6 +222,10 @@ void _showEditSheet({
channelId: channelId,
eventId: message.id,
content: text,
mediaTags: buildCustomEmojiTags(
text,
ref.read(customEmojiListProvider),
),
);
Navigator.of(sheetContext).pop();
},

View File

@@ -2,6 +2,7 @@ import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:gpt_markdown/gpt_markdown.dart';
import 'package:gpt_markdown/custom_widgets/markdown_config.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
@@ -10,6 +11,9 @@ import 'package:url_launcher/url_launcher.dart';
import '../../shared/clipboard_utils.dart';
import '../../shared/syntax_highlight.dart';
import '../../shared/theme/theme.dart';
import '../custom_emoji/custom_emoji.dart';
import '../custom_emoji/custom_emoji_provider.dart';
import '../custom_emoji/custom_emoji_render.dart';
import 'media_viewer_page.dart';
import 'message_media.dart';
@@ -18,7 +22,7 @@ const _messageMediaMaxImageHeight = 240.0;
/// Renders message content with markdown formatting, @mentions, #channel links,
/// and media-aware markdown images/videos.
class MessageContent extends HookWidget {
class MessageContent extends HookConsumerWidget {
final String content;
/// Display names for mentioned pubkeys, extracted from event p-tags.
@@ -37,6 +41,8 @@ class MessageContent extends HookWidget {
final TextStyle? baseStyle;
final int? maxLines;
const MessageContent({
super.key,
required this.content,
@@ -45,14 +51,19 @@ class MessageContent extends HookWidget {
this.tags = const [],
this.onChannelTap,
this.baseStyle,
this.maxLines,
});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final style =
baseStyle ??
context.textTheme.bodyMedium?.copyWith(color: context.colors.onSurface);
final imetaByUrl = parseImetaTags(tags);
final customEmoji = _mergeCustomEmoji(
customEmojiFromTags(tags),
ref.watch(customEmojiListProvider),
);
final finalContent = useMemoized(() {
// Convert autolinks and bare URLs to standard markdown links,
@@ -132,8 +143,10 @@ class MessageContent extends HookWidget {
_buildLink(context, linkText, url, linkStyle, style),
imageBuilder: (context, imageUrl) =>
_buildMedia(context, imageUrl, imetaByUrl[imageUrl]),
maxLines: maxLines,
inlineComponents: [
_MentionMd(mentionNames: mentionNames),
CustomEmojiMd(customEmoji),
_ChannelLinkMd(channelNames: channelNames, onChannelTap: onChannelTap),
...MarkdownComponent.inlineComponents,
],
@@ -192,6 +205,20 @@ class MessageContent extends HookWidget {
}
}
List<CustomEmoji> _mergeCustomEmoji(
List<CustomEmoji> eventEmoji,
List<CustomEmoji> paletteEmoji,
) {
if (eventEmoji.isEmpty) return paletteEmoji;
if (paletteEmoji.isEmpty) return eventEmoji;
final seen = <String>{};
final merged = <CustomEmoji>[];
for (final emoji in [...eventEmoji, ...paletteEmoji]) {
if (seen.add(emoji.shortcode)) merged.add(emoji);
}
return merged;
}
class _MessageImagePreview extends StatefulWidget {
final String url;
final ImetaEntry? imeta;

View File

@@ -3,6 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/theme/theme.dart';
import '../custom_emoji/custom_emoji_render.dart';
import '../profile/user_cache_provider.dart';
import '../profile/user_profile.dart';
import 'channel_management_provider.dart';
@@ -72,7 +73,7 @@ class ReactionRow extends StatelessWidget {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(reaction.emoji, style: const TextStyle(fontSize: 14)),
_ReactionEmoji(reaction: reaction, size: 18),
if (reaction.count > 1) ...[
const SizedBox(width: Grid.quarter),
Text(
@@ -95,6 +96,23 @@ class ReactionRow extends StatelessWidget {
}
}
class _ReactionEmoji extends StatelessWidget {
final TimelineReaction reaction;
final double size;
const _ReactionEmoji({required this.reaction, required this.size});
@override
Widget build(BuildContext context) {
final emojiUrl = reaction.emojiUrl;
if (emojiUrl == null || emojiUrl.isEmpty) {
return Text(reaction.emoji, style: TextStyle(fontSize: size));
}
final shortcode = reaction.emoji.substring(1, reaction.emoji.length - 1);
return CustomEmojiImage(shortcode: shortcode, url: emojiUrl, size: size);
}
}
// ---------------------------------------------------------------------------
// Reaction detail bottom sheet
// ---------------------------------------------------------------------------
@@ -162,7 +180,14 @@ class _ReactionDetailSheet extends HookConsumerWidget {
Padding(
padding: const EdgeInsets.only(right: Grid.half),
child: ChoiceChip(
label: Text('${reaction.emoji} ${reaction.count}'),
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
_ReactionEmoji(reaction: reaction, size: 16),
const SizedBox(width: Grid.quarter),
Text('${reaction.count}'),
],
),
selected: reaction.emoji == selectedEmoji.value,
onSelected: (_) {
selectedEmoji.value = reaction.emoji;
@@ -182,13 +207,12 @@ class _ReactionDetailSheet extends HookConsumerWidget {
),
child: Row(
children: [
Text(
currentReaction.emoji,
style: const TextStyle(fontSize: 28),
),
_ReactionEmoji(reaction: currentReaction, size: 32),
const SizedBox(width: Grid.half),
Text(
':${_emojiToShortcode(currentReaction.emoji)}:',
currentReaction.emoji.startsWith(':')
? currentReaction.emoji
: ':${_emojiToShortcode(currentReaction.emoji)}:',
style: context.textTheme.titleSmall?.copyWith(
color: context.colors.onSurfaceVariant,
),

View File

@@ -27,7 +27,7 @@ class SendMessage {
/// If [rootEventId] is null it defaults to [parentEventId] (direct reply to
/// thread head). Tags are built to match the desktop's `buildReplyTags`
/// convention with `root` / `reply` markers. Pass [mediaTags] to append
/// relay-validated `imeta` tags for uploaded media.
/// relay-validated `imeta` tags and NIP-30 `emoji` tags.
Future<void> call({
required String channelId,
required String content,

View File

@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:flutter/foundation.dart';
import '../../shared/relay/relay.dart';
import '../custom_emoji/custom_emoji.dart';
// ---------------------------------------------------------------------------
// System event types (kind 40099)
@@ -110,6 +111,7 @@ class TimelineReaction {
final int count;
final bool reactedByCurrentUser;
final List<String> userPubkeys;
final String? emojiUrl;
/// The event ID of the current user's reaction, for deletion.
final String? currentUserReactionId;
@@ -119,6 +121,7 @@ class TimelineReaction {
required this.count,
required this.reactedByCurrentUser,
required this.userPubkeys,
this.emojiUrl,
this.currentUserReactionId,
});
}
@@ -233,12 +236,14 @@ List<TimelineMessage> formatTimeline(
edits[targetId] = _Edit(
content: event.content,
createdAt: event.createdAt,
tags: event.tags,
);
}
}
// 3. Aggregate reactions: targetId → { emoji → { pubkey → eventId } }.
final reactionMap = <String, Map<String, Map<String, String>>>{};
final reactionEmojiUrls = <String, Map<String, String>>{};
for (final event in events) {
if (event.kind != EventKind.reaction) continue;
if (deletedIds.contains(event.id)) continue;
@@ -253,6 +258,17 @@ List<TimelineMessage> formatTimeline(
.putIfAbsent(targetId, () => {})
.putIfAbsent(emoji, () => {})[event.pubkey.toLowerCase()] =
event.id;
final shortcode = normalizeShortcode(emoji);
if (shortcode != null) {
for (final tag in event.tags) {
if (tag.length < 3 || tag[0] != 'emoji') continue;
if (normalizeShortcode(tag[1]) == shortcode) {
reactionEmojiUrls.putIfAbsent(targetId, () => {})[emoji] = tag[2];
break;
}
}
}
}
final normalizedCurrentPubkey = currentPubkey?.toLowerCase();
@@ -276,6 +292,7 @@ List<TimelineMessage> formatTimeline(
normalizedCurrentPubkey != null &&
entry.value.containsKey(normalizedCurrentPubkey),
userPubkeys: entry.value.keys.toList(),
emojiUrl: reactionEmojiUrls[event.id]?[entry.key],
currentUserReactionId: normalizedCurrentPubkey != null
? entry.value[normalizedCurrentPubkey]
: null,
@@ -302,8 +319,9 @@ List<TimelineMessage> formatTimeline(
event.kind == EventKind.streamMessageV2 ||
event.kind == EventKind.streamMessageDiff) {
final edit = edits[event.id];
final effectiveTags = edit?.tags ?? event.tags;
final mentions = <String>[
for (final tag in event.tags)
for (final tag in effectiveTags)
if (tag.length >= 2 && tag[0] == 'p') tag[1],
];
@@ -318,6 +336,7 @@ List<TimelineMessage> formatTimeline(
normalizedCurrentPubkey != null &&
entry.value.containsKey(normalizedCurrentPubkey),
userPubkeys: entry.value.keys.toList(),
emojiUrl: reactionEmojiUrls[event.id]?[entry.key],
currentUserReactionId: normalizedCurrentPubkey != null
? entry.value[normalizedCurrentPubkey]
: null,
@@ -332,7 +351,7 @@ List<TimelineMessage> formatTimeline(
pubkey: event.pubkey,
createdAt: event.createdAt,
content: edit?.content ?? event.content,
tags: event.tags,
tags: effectiveTags,
edited: edit != null,
mentionPubkeys: mentions,
reactions: reactions,
@@ -406,7 +425,13 @@ ThreadSummary? _buildSummary(
class _Edit {
final String content;
final int createdAt;
const _Edit({required this.content, required this.createdAt});
final List<List<String>> tags;
const _Edit({
required this.content,
required this.createdAt,
required this.tags,
});
}
/// Get the last `e` tag value (reaction/edit target convention).

View File

@@ -0,0 +1,134 @@
import 'package:flutter/foundation.dart';
import '../../shared/relay/nostr_models.dart';
/// A workspace custom emoji: a `:shortcode:` mapped to an image URL.
///
/// Mirrors desktop's NIP-30 model (kind:30030, per-user sets unioned into one
/// workspace palette). Identity downstream is shortcode-only — the palette must
/// never carry two URLs under one shortcode.
@immutable
class CustomEmoji {
final String shortcode;
final String url;
const CustomEmoji({required this.shortcode, required this.url});
@override
bool operator ==(Object other) =>
other is CustomEmoji && other.shortcode == shortcode && other.url == url;
@override
int get hashCode => Object.hash(shortcode, url);
@override
String toString() => 'CustomEmoji($shortcode -> $url)';
}
/// NIP-30 emoji set kind (parameterized-replaceable).
const int kindEmojiSet = 30030;
/// d-tag for a member's own custom emoji set.
const String customEmojiSetDTag = 'sprout:custom-emoji';
final RegExp _shortcodeRe = RegExp(r'^[a-z0-9_-]+$');
/// Normalize a shortcode the way the relay does: strip surrounding colons and
/// lowercase. Returns null if the result is empty or has invalid chars.
String? normalizeShortcode(String raw) {
final stripped = raw
.trim()
.replaceFirst(RegExp(r'^:+'), '')
.replaceFirst(RegExp(r':+$'), '');
final lower = stripped.toLowerCase();
return _shortcodeRe.hasMatch(lower) ? lower : null;
}
/// Parse NIP-30 `["emoji", shortcode, url]` tags from one event's tags into a
/// custom-emoji list. Shortcodes are normalized; malformed/duplicate entries
/// within the one event are skipped (first wins).
List<CustomEmoji> customEmojiFromTags(List<List<String>> tags) {
final seen = <String>{};
final emoji = <CustomEmoji>[];
for (final tag in tags) {
if (tag.isEmpty || tag[0] != 'emoji') continue;
if (tag.length < 3) continue;
final rawShortcode = tag[1];
final url = tag[2];
if (rawShortcode.isEmpty || url.isEmpty) continue;
final shortcode = normalizeShortcode(rawShortcode);
if (shortcode == null) continue;
if (seen.contains(shortcode)) continue;
seen.add(shortcode);
emoji.add(CustomEmoji(shortcode: shortcode, url: url));
}
return emoji;
}
/// Union every member's kind:30030 set into the workspace palette, collapsed to
/// one entry per shortcode. When members disagree on a shortcode's URL, the
/// lexicographically-smallest URL wins — deterministic and stable across
/// reloads. Output is sorted by shortcode.
List<CustomEmoji> unionCustomEmoji(Iterable<NostrEvent> events) {
final urlByShortcode = <String, String>{};
for (final event in events) {
for (final e in customEmojiFromTags(event.tags)) {
final existing = urlByShortcode[e.shortcode];
if (existing == null || e.url.compareTo(existing) < 0) {
urlByShortcode[e.shortcode] = e.url;
}
}
}
final result =
urlByShortcode.entries
.map((e) => CustomEmoji(shortcode: e.key, url: e.value))
.toList()
..sort((a, b) => a.shortcode.compareTo(b.shortcode));
return result;
}
final RegExp _shortcodeScan = RegExp(r':([a-z0-9_-]+):', caseSensitive: false);
/// Return one `["emoji", shortcode, url]` tag per *distinct* known custom emoji
/// referenced in [content]. Matched case-insensitively; the canonical lowercase
/// shortcode is emitted. Unknown `:foo:` are ignored. Order follows first
/// appearance; each shortcode emitted at most once.
///
/// Mirrors how @mentions become `p` tags: derived from the final content at
/// send time so the event is self-contained for any NIP-30 client.
List<List<String>> buildCustomEmojiTags(
String content,
List<CustomEmoji> customEmoji,
) {
if (customEmoji.isEmpty) return [];
final urlByShortcode = {for (final e in customEmoji) e.shortcode: e.url};
final emitted = <String>{};
final tags = <List<String>>[];
for (final match in _shortcodeScan.allMatches(content)) {
final shortcode = match.group(1)!.toLowerCase();
if (emitted.contains(shortcode)) continue;
final url = urlByShortcode[shortcode];
if (url == null) continue;
emitted.add(shortcode);
tags.add(['emoji', shortcode, url]);
}
return tags;
}
/// Resolve the image URL for a reaction whose content is a custom-emoji
/// `:shortcode:`, from the workspace palette. Returns null for unicode
/// reactions or unknown shortcodes.
String? reactionEmojiUrl(String emoji, List<CustomEmoji>? palette) {
if (palette == null) return null;
if (!emoji.startsWith(':') || !emoji.endsWith(':')) return null;
final shortcode = emoji.substring(1, emoji.length - 1).toLowerCase();
for (final e in palette) {
if (e.shortcode == shortcode) return e.url;
}
return null;
}

View File

@@ -0,0 +1,60 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/relay/relay.dart';
import 'custom_emoji.dart';
/// Workspace custom-emoji palette (NIP-30, per-user kind:30030 sets unioned).
///
/// On build, fetches every member's set and collapses to one entry per
/// shortcode (see [unionCustomEmoji]). Re-fetches when the relay session
/// reconnects. The palette is the single source of truth consumed by the
/// renderer, picker, autocomplete, and reaction/send tag plumbing.
class CustomEmojiPaletteNotifier extends AsyncNotifier<List<CustomEmoji>> {
@override
Future<List<CustomEmoji>> build() {
ref.watch(relayClientProvider);
ref.watch(relaySessionProvider);
return _fetch();
}
Future<List<CustomEmoji>> _fetch() async {
final sessionState = ref.read(relaySessionProvider);
if (sessionState.status != SessionStatus.connected) return [];
try {
final session = ref.read(relaySessionProvider.notifier);
final events = await session.fetchHistory(
const NostrFilter(
kinds: [kindEmojiSet],
tags: {
'#d': [customEmojiSetDTag],
},
// One 30030 per member; the relay keeps only the latest per
// (pubkey, d_tag), so this bounds member count, not history.
limit: 500,
),
);
return unionCustomEmoji(events);
} catch (_) {
return [];
}
}
/// Force a re-fetch of the workspace palette.
Future<void> refresh() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(_fetch);
}
}
final customEmojiPaletteProvider =
AsyncNotifierProvider<CustomEmojiPaletteNotifier, List<CustomEmoji>>(
CustomEmojiPaletteNotifier.new,
);
/// Synchronous read of the current palette (empty while loading/error).
/// Convenience for widgets that only need the resolved list, not the async
/// state — renderer, autocomplete, reaction resolution.
final customEmojiListProvider = Provider<List<CustomEmoji>>((ref) {
return ref.watch(customEmojiPaletteProvider).value ?? const [];
});

View File

@@ -0,0 +1,93 @@
import 'package:flutter/material.dart';
import 'package:gpt_markdown/gpt_markdown.dart';
import 'package:gpt_markdown/custom_widgets/markdown_config.dart';
import 'custom_emoji.dart';
/// Default rendered height of an inline custom emoji, in logical pixels.
/// Roughly matches a line of body text so emoji sit on the baseline cleanly.
const double kCustomEmojiInlineSize = 20.0;
/// A single custom-emoji image rendered as a square, network-loaded glyph.
///
/// Reused everywhere a `:shortcode:` resolves to an image: inline in message
/// bodies, reaction pills, status, and the picker. Falls back to the literal
/// `:shortcode:` text if the image fails to load, so a broken URL never leaves
/// a blank gap.
class CustomEmojiImage extends StatelessWidget {
final String shortcode;
final String url;
final double size;
const CustomEmojiImage({
super.key,
required this.shortcode,
required this.url,
this.size = kCustomEmojiInlineSize,
});
@override
Widget build(BuildContext context) {
final fallbackStyle = DefaultTextStyle.of(context).style;
return Image.network(
url,
width: size,
height: size,
fit: BoxFit.contain,
filterQuality: FilterQuality.medium,
semanticLabel: ':$shortcode:',
errorBuilder: (_, _, _) => Text(':$shortcode:', style: fallbackStyle),
);
}
}
/// gpt_markdown inline component that replaces `:shortcode:` with an inline
/// [CustomEmojiImage] for *known* shortcodes only. Unknown `:foo:` is left as
/// plain text. Matched case-insensitively; resolved via the lowercase palette.
///
/// Parallel to the `_MentionMd` / `_ChannelLinkMd` components in
/// message_content.dart — add an instance to a `GptMarkdown.inlineComponents`
/// list (before the default components) to enable custom emoji in any markdown
/// surface.
class CustomEmojiMd extends InlineMd {
final Map<String, String> _urlByShortcode;
final double size;
late final RegExp _exp = _buildPattern(_urlByShortcode.keys);
CustomEmojiMd(List<CustomEmoji> palette, {this.size = kCustomEmojiInlineSize})
: _urlByShortcode = {for (final e in palette) e.shortcode: e.url};
@override
RegExp get exp => _exp;
@override
InlineSpan span(BuildContext context, String text, GptMarkdownConfig config) {
final raw = exp.firstMatch(text.trim())?.group(0);
if (raw == null) {
return TextSpan(text: text, style: config.style);
}
final shortcode = raw.substring(1, raw.length - 1).toLowerCase();
final url = _urlByShortcode[shortcode];
if (url == null) {
return TextSpan(text: text, style: config.style);
}
return WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: CustomEmojiImage(shortcode: shortcode, url: url, size: size),
);
}
/// Build a regex matching `:shortcode:` for any known shortcode, longest
/// first so a longer name isn't shadowed by a shorter prefix. Matches nothing
/// when the palette is empty (a regex that can never match).
static RegExp _buildPattern(Iterable<String> shortcodes) {
final sorted = shortcodes.where((s) => s.trim().isNotEmpty).toSet().toList()
..sort((a, b) => b.length.compareTo(a.length));
if (sorted.isEmpty) {
// Never matches — gpt_markdown skips this component entirely.
return RegExp(r'(?!x)x');
}
final alternatives = sorted.map(RegExp.escape).join('|');
return RegExp(':(?:$alternatives):', caseSensitive: false);
}
}

View File

@@ -2,6 +2,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/relay/relay.dart';
import '../channels/channel_management_provider.dart';
import '../custom_emoji/custom_emoji.dart';
import '../custom_emoji/custom_emoji_provider.dart';
import 'forum_models.dart';
/// Fetches forum posts (kind:45001) for a channel from the relay.
@@ -79,6 +81,7 @@ Future<void> createForumPost(
['h', channelId],
for (final pk in normalizedMentions) ['p', pk],
...mediaTags,
...buildCustomEmojiTags(content, ref.read(customEmojiListProvider)),
],
);
ref.invalidate(forumPostsProvider(channelId));
@@ -112,6 +115,7 @@ Future<void> createForumReply(
['e', parentEventId, '', 'reply'],
for (final pk in normalizedMentions) ['p', pk],
...mediaTags,
...buildCustomEmojiTags(content, ref.read(customEmojiListProvider)),
],
);
ref.invalidate(forumPostsProvider(channelId));

View File

@@ -3,6 +3,10 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/theme/theme.dart';
import '../custom_emoji/custom_emoji.dart';
import '../custom_emoji/custom_emoji_provider.dart';
import '../custom_emoji/custom_emoji_render.dart';
import '../channels/emoji_picker.dart';
import 'user_status.dart';
import 'user_status_provider.dart';
@@ -59,6 +63,7 @@ class _SetStatusSheet extends HookConsumerWidget {
final emoji = useState(currentStatus?.emoji ?? '');
final text = useState(currentStatus?.text ?? '');
final isSaving = useState(false);
final customEmoji = ref.watch(customEmojiListProvider);
useEffect(() {
void listener() => text.value = textController.text;
@@ -125,10 +130,7 @@ class _SetStatusSheet extends HookConsumerWidget {
border: Border.all(color: context.colors.outlineVariant),
borderRadius: BorderRadius.circular(Radii.md),
),
child: Text(
emoji.value.isNotEmpty ? emoji.value : '\u{1F4AC}',
style: const TextStyle(fontSize: 18),
),
child: _StatusEmojiPreview(emoji: emoji.value),
),
const SizedBox(width: Grid.xxs),
Expanded(
@@ -169,6 +171,14 @@ class _SetStatusSheet extends HookConsumerWidget {
: option.emoji;
},
),
if (customEmoji.isNotEmpty)
_PickCustomEmojiButton(
selected: emoji.value.startsWith(':'),
onTap: () => showEmojiPicker(
context: context,
onSelect: (value) => emoji.value = value,
),
),
],
),
const SizedBox(height: Grid.twelve),
@@ -219,6 +229,68 @@ class _SetStatusSheet extends HookConsumerWidget {
}
}
class _StatusEmojiPreview extends ConsumerWidget {
final String emoji;
const _StatusEmojiPreview({required this.emoji});
@override
Widget build(BuildContext context, WidgetRef ref) {
if (emoji.isEmpty) {
return const Text('\u{1F4AC}', style: TextStyle(fontSize: 18));
}
final palette = ref.watch(customEmojiListProvider);
final shortcode = normalizeShortcode(emoji);
if (shortcode != null) {
for (final entry in palette) {
if (entry.shortcode == shortcode) {
return CustomEmojiImage(
shortcode: shortcode,
url: entry.url,
size: 22,
);
}
}
}
return Text(emoji, style: const TextStyle(fontSize: 18));
}
}
class _PickCustomEmojiButton extends StatelessWidget {
final bool selected;
final VoidCallback onTap;
const _PickCustomEmojiButton({required this.selected, required this.onTap});
@override
Widget build(BuildContext context) {
return Tooltip(
message: 'Custom emoji',
child: InkWell(
borderRadius: BorderRadius.circular(Radii.md),
onTap: onTap,
child: Container(
width: 36,
height: 36,
alignment: Alignment.center,
decoration: BoxDecoration(
color: selected
? context.colors.secondaryContainer
: Colors.transparent,
borderRadius: BorderRadius.circular(Radii.md),
border: selected ? Border.all(color: context.colors.outline) : null,
),
child: Icon(
Icons.add_reaction_outlined,
size: 18,
color: context.colors.onSurfaceVariant,
),
),
),
);
}
}
class _EmojiButton extends StatelessWidget {
final String emoji;
final String label;

View File

@@ -12,6 +12,7 @@ import '../channels/channel_management_provider.dart';
import 'presence_cache_provider.dart';
import 'user_cache_provider.dart';
import 'user_status_cache_provider.dart';
import '../channels/message_content.dart';
/// Show a user profile bottom sheet for the given [pubkey].
void showUserProfileSheet(BuildContext context, String pubkey) {
@@ -160,10 +161,12 @@ class UserProfileSheet extends HookConsumerWidget {
),
if (userStatus != null && !userStatus.isEmpty)
_InfoRow(
_InfoRowContent(
icon: LucideIcons.messageCircle,
text:
'${userStatus.emoji.isNotEmpty ? '${userStatus.emoji} ' : ''}${userStatus.text}',
child: MessageContent(
content:
'${userStatus.emoji.isNotEmpty ? '${userStatus.emoji} ' : ''}${userStatus.text}',
),
),
_InfoRow(
@@ -253,6 +256,29 @@ class UserProfileSheet extends HookConsumerWidget {
}
/// A row displaying an icon + text, used for profile info items.
class _InfoRowContent extends StatelessWidget {
final IconData icon;
final Widget child;
const _InfoRowContent({required this.icon, required this.child});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.quarter),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 16, color: context.colors.onSurfaceVariant),
const SizedBox(width: Grid.xxs),
Expanded(child: child),
],
),
);
}
}
class _InfoRow extends StatelessWidget {
final IconData icon;
final String text;

View File

@@ -2,6 +2,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/relay/relay.dart';
import '../channels/channel_management_provider.dart';
import '../custom_emoji/custom_emoji.dart';
import '../custom_emoji/custom_emoji_provider.dart';
import 'pulse_models.dart';
import 'pulse_provider.dart';
@@ -32,6 +34,7 @@ Future<void> publishNote(
if (seen.add(normalized)) tags.add(['p', normalized]);
}
tags.addAll(mediaTags);
tags.addAll(buildCustomEmojiTags(text, ref.read(customEmojiListProvider)));
await relay.submit(kind: EventKind.note, content: text, tags: tags);
ref.invalidate(globalNotesProvider);

View File

@@ -12,6 +12,7 @@ import '../channels/channel_detail_page.dart';
import '../channels/channel_management_provider.dart';
import '../channels/channels_provider.dart';
import '../channels/small_avatar.dart';
import '../channels/message_content.dart';
import '../channels/date_formatters.dart';
import '../forum/forum_thread_page.dart';
import '../profile/profile_provider.dart';
@@ -359,7 +360,12 @@ class _MessageTile extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 2),
Text(hit.content, maxLines: 2, overflow: TextOverflow.ellipsis),
MessageContent(
content: hit.content,
tags: hit.tags,
maxLines: 2,
baseStyle: context.textTheme.bodyMedium,
),
const SizedBox(height: 2),
Text(
timeAgo,

View File

@@ -18,6 +18,7 @@ class SearchHit {
final String? channelName;
final int createdAt;
final double score;
final List<List<String>> tags;
const SearchHit({
required this.eventId,
@@ -28,6 +29,7 @@ class SearchHit {
this.channelName,
required this.createdAt,
required this.score,
this.tags = const [],
});
factory SearchHit.fromJson(Map<String, dynamic> json) => SearchHit(
@@ -39,6 +41,11 @@ class SearchHit {
channelName: json['channel_name'] as String?,
createdAt: json['created_at'] as int? ?? 0,
score: (json['score'] as num?)?.toDouble() ?? 0.0,
tags:
(json['tags'] as List<dynamic>?)
?.map((t) => (t as List<dynamic>).map((e) => e as String).toList())
.toList() ??
const [],
);
}
@@ -155,6 +162,7 @@ class SearchNotifier extends Notifier<SearchState> {
: null,
createdAt: e.createdAt,
score: 0.0,
tags: e.tags,
),
)
.toList();

View File

@@ -9,10 +9,13 @@ import '../../shared/auth/auth.dart';
import '../../shared/clipboard_utils.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/app_list.dart';
import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart';
import '../profile/set_status_sheet.dart';
import '../profile/user_status_provider.dart';
import '../custom_emoji/custom_emoji_provider.dart';
import '../custom_emoji/custom_emoji_render.dart';
import 'theme_picker_page.dart';
class SettingsPage extends HookConsumerWidget {
@@ -28,151 +31,174 @@ class SettingsPage extends HookConsumerWidget {
return FrostedScaffold(
appBar: const FrostedAppBar(title: Text('Settings')),
body: ListView(
padding: EdgeInsets.only(
top: frostedAppBarHeight(context),
left: Grid.xs,
right: Grid.xs,
bottom: Grid.xs,
),
body: Column(
children: [
// Connection info
Text('Connection', style: context.textTheme.titleMedium),
const SizedBox(height: Grid.twelve),
ListTile(
leading: const Icon(LucideIcons.server),
title: const Text('Connected to'),
subtitle: Text(
config.baseUrl,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
Expanded(
child: ListView(
padding: EdgeInsets.only(
top: frostedAppBarHeight(context),
bottom: Grid.xs,
),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.md),
side: BorderSide(color: context.colors.outlineVariant),
),
),
if (config.nsec != null && config.nsec!.isNotEmpty) ...[
const SizedBox(height: Grid.xxs),
Builder(
builder: (context) {
final privHex = nostr.Nip19.decode(payload: config.nsec!).data;
final pubkey = privHex.isNotEmpty
? nostr.Keys(privHex).public
: 'unknown';
return ListTile(
leading: const Icon(LucideIcons.key),
title: const Text('Identity (pubkey)'),
subtitle: Text(
pubkey,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
fontFamily: 'GeistMono',
fontSize: 11,
children: [
const SizedBox(height: Grid.xxs),
// Status — flush header row, like Slack's profile/status block.
_StatusRow(),
// Appearance
AppListSection(
label: 'Appearance',
children: [
AppListRow(
icon: LucideIcons.palette,
title: 'Color Scheme',
subtitle: selectedScheme == null
? 'Default (Catppuccin)'
: findTheme(selectedScheme)?.displayName ??
selectedScheme,
trailing: Icon(
LucideIcons.chevronRight,
size: 18,
color: context.colors.onSurfaceVariant,
),
onTap: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => const ThemePickerPage(),
),
),
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
trailing: IconButton(
icon: const Icon(LucideIcons.copy, size: 16),
onPressed: () async {
await copyToClipboard(
context,
pubkey,
message: 'Pubkey copied',
);
},
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.md),
side: BorderSide(color: context.colors.outlineVariant),
),
);
},
),
],
const SizedBox(height: Grid.twelve),
OutlinedButton.icon(
onPressed: () => _confirmSignOut(context, ref),
icon: const Icon(LucideIcons.logOut),
label: const Text('Remove Workspace'),
style: OutlinedButton.styleFrom(
foregroundColor: context.colors.error,
),
),
const SizedBox(height: Grid.sm),
// Status
_StatusSection(),
const SizedBox(height: Grid.sm),
// Appearance
Text('Appearance', style: context.textTheme.titleMedium),
const SizedBox(height: Grid.twelve),
// Color scheme picker — navigates to dedicated page
ListTile(
leading: const Icon(LucideIcons.palette),
title: const Text('Color Scheme'),
subtitle: Text(
selectedScheme == null
? 'Default (Catppuccin)'
: findTheme(selectedScheme)?.displayName ?? selectedScheme,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
trailing: const Icon(LucideIcons.chevronRight, size: 18),
onTap: () => Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => const ThemePickerPage()),
),
),
const SizedBox(height: Grid.xs),
// Accent color picker
Text('Accent Color', style: context.textTheme.titleSmall),
const SizedBox(height: Grid.xxs),
Wrap(
spacing: Grid.xxs,
runSpacing: Grid.xxs,
children: [
// Default (Mauve) swatch
_AccentSwatch(
color: context.colors.brightness == Brightness.light
? const Color(0xFF8839EF)
: const Color(0xFFA875F5),
label: 'Mauve',
selected: selectedAccent == defaultAccentIndex,
onTap: () => ref
.read(accentProvider.notifier)
.setAccent(defaultAccentIndex),
),
for (var i = 0; i < accentColors.length; i++)
_AccentSwatch(
color: context.colors.brightness == Brightness.light
? accentColors[i].light
: accentColors[i].dark,
label: accentColors[i].name,
selected: selectedAccent == i,
onTap: () => ref.read(accentProvider.notifier).setAccent(i),
Padding(
padding: const EdgeInsets.fromLTRB(
Grid.xs,
Grid.xxs,
Grid.xs,
Grid.twelve,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Accent Color',
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
const SizedBox(height: Grid.twelve),
Wrap(
spacing: Grid.xxs,
runSpacing: Grid.xxs,
children: [
// Default (Mauve) swatch
_AccentSwatch(
color:
context.colors.brightness ==
Brightness.light
? const Color(0xFF8839EF)
: const Color(0xFFA875F5),
label: 'Mauve',
selected: selectedAccent == defaultAccentIndex,
onTap: () => ref
.read(accentProvider.notifier)
.setAccent(defaultAccentIndex),
),
for (var i = 0; i < accentColors.length; i++)
_AccentSwatch(
color:
context.colors.brightness ==
Brightness.light
? accentColors[i].light
: accentColors[i].dark,
label: accentColors[i].name,
selected: selectedAccent == i,
onTap: () => ref
.read(accentProvider.notifier)
.setAccent(i),
),
],
),
],
),
),
],
),
],
// Connection
AppListSection(
label: 'Connection',
children: [
AppListRow(
icon: LucideIcons.server,
title: 'Connected to',
subtitle: config.baseUrl,
),
if (config.nsec != null && config.nsec!.isNotEmpty)
Builder(
builder: (context) {
final privHex = nostr.Nip19.decode(
payload: config.nsec!,
).data;
final pubkey = privHex.isNotEmpty
? nostr.Keys(privHex).public
: 'unknown';
return AppListRow(
icon: LucideIcons.key,
title: 'Identity (pubkey)',
subtitle: pubkey,
subtitleStyle: context.textTheme.bodySmall
?.copyWith(
color: context.colors.onSurfaceVariant,
fontFamily: 'GeistMono',
fontSize: 11,
),
subtitleMaxLines: 2,
trailing: IconButton(
icon: const Icon(LucideIcons.copy, size: 16),
onPressed: () async {
await copyToClipboard(
context,
pubkey,
message: 'Pubkey copied',
);
},
),
);
},
),
Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
child: Center(
child: TextButton.icon(
onPressed: () => _confirmSignOut(context, ref),
icon: const Icon(LucideIcons.logOut, size: 18),
label: const Text('Remove Workspace'),
style: TextButton.styleFrom(
foregroundColor: context.colors.error,
),
),
),
),
],
),
],
),
),
if (packageInfo.hasData) ...[
const SizedBox(height: Grid.sm),
Center(
child: Text(
'v${packageInfo.data!.version}',
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant.withValues(alpha: 0.6),
if (packageInfo.hasData)
SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.only(bottom: Grid.xs, top: Grid.xxs),
child: Center(
child: Text(
'v${packageInfo.data!.version}',
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant.withValues(
alpha: 0.6,
),
),
),
),
),
),
],
],
),
);
@@ -211,55 +237,66 @@ class SettingsPage extends HookConsumerWidget {
}
}
class _StatusSection extends ConsumerWidget {
class _StatusRow extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final statusAsync = ref.watch(userStatusProvider);
final status = statusAsync.asData?.value;
final hasStatus = status != null && !status.isEmpty;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Status', style: context.textTheme.titleMedium),
const SizedBox(height: Grid.twelve),
ListTile(
leading: Text(
status != null && status.emoji.isNotEmpty
? status.emoji
: '\u{1F4AC}',
style: const TextStyle(fontSize: 20),
),
title: Text(
status != null && !status.isEmpty
? status.text.isNotEmpty
? status.text
: status.emoji
: 'Set a status',
style: status != null && !status.isEmpty
? null
: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
subtitle: status != null && !status.isEmpty
? Text(
'Tap to update',
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
)
: null,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.md),
side: BorderSide(color: context.colors.outlineVariant),
),
onTap: () => showSetStatusSheet(context, currentStatus: status),
),
],
return AppListRowRaw(
leading: _StatusEmojiIcon(emoji: status?.emoji ?? ''),
title: Text(
hasStatus
? (status.text.isNotEmpty ? status.text : status.emoji)
: 'Set a status',
style: hasStatus
? context.textTheme.bodyLarge
: context.textTheme.bodyLarge?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
subtitle: hasStatus
? Text(
'Tap to update',
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
)
: null,
onTap: () => showSetStatusSheet(context, currentStatus: status),
);
}
}
class _StatusEmojiIcon extends ConsumerWidget {
final String emoji;
const _StatusEmojiIcon({required this.emoji});
@override
Widget build(BuildContext context, WidgetRef ref) {
if (emoji.isEmpty) {
return const Text('\u{1F4AC}', style: TextStyle(fontSize: 20));
}
final shortcode = emoji.startsWith(':') && emoji.endsWith(':')
? emoji.substring(1, emoji.length - 1).toLowerCase()
: null;
if (shortcode != null) {
for (final entry in ref.watch(customEmojiListProvider)) {
if (entry.shortcode == shortcode) {
return CustomEmojiImage(
shortcode: shortcode,
url: entry.url,
size: 24,
);
}
}
}
return Text(emoji, style: const TextStyle(fontSize: 20));
}
}
class _AccentSwatch extends StatelessWidget {
const _AccentSwatch({
required this.color,

View File

@@ -0,0 +1,188 @@
import 'package:flutter/material.dart';
import '../theme/theme.dart';
/// A flush, borderless settings/list row: leading icon, title, optional
/// subtitle and trailing widget. No card, no background — groups are
/// separated by [AppListSection] dividers instead.
class AppListRow extends StatelessWidget {
const AppListRow({
super.key,
this.icon,
required this.title,
this.subtitle,
this.subtitleStyle,
this.subtitleMaxLines,
this.trailing,
this.titleColor,
this.onTap,
});
final IconData? icon;
final String title;
final String? subtitle;
final TextStyle? subtitleStyle;
final int? subtitleMaxLines;
final Widget? trailing;
final Color? titleColor;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final row = Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.xs,
vertical: Grid.twelve,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (icon != null) ...[
Padding(
padding: const EdgeInsets.only(top: 1),
child: Icon(
icon,
size: 22,
color: titleColor ?? context.colors.onSurfaceVariant,
),
),
const SizedBox(width: Grid.xs),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
style: context.textTheme.bodyLarge?.copyWith(
color: titleColor,
),
),
if (subtitle != null) ...[
const SizedBox(height: Grid.quarter),
Text(
subtitle!,
style:
subtitleStyle ??
context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
maxLines: subtitleMaxLines,
overflow: subtitleMaxLines == null
? null
: TextOverflow.ellipsis,
),
],
],
),
),
if (trailing != null) ...[const SizedBox(width: Grid.xxs), trailing!],
],
),
);
if (onTap == null) return row;
return InkWell(onTap: onTap, child: row);
}
}
/// A custom leading widget variant of [AppListRow] for rows whose leading
/// slot is not a plain [IconData] (e.g. an emoji or image).
class AppListRowRaw extends StatelessWidget {
const AppListRowRaw({
super.key,
required this.leading,
required this.title,
this.subtitle,
this.trailing,
this.onTap,
});
final Widget leading;
final Widget title;
final Widget? subtitle;
final Widget? trailing;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final row = Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.xs,
vertical: Grid.twelve,
),
child: Row(
children: [
leading,
const SizedBox(width: Grid.xs),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
title,
if (subtitle != null) ...[
const SizedBox(height: Grid.quarter),
subtitle!,
],
],
),
),
if (trailing != null) ...[const SizedBox(width: Grid.xxs), trailing!],
],
),
);
if (onTap == null) return row;
return InkWell(onTap: onTap, child: row);
}
}
/// A group of list rows separated from the previous group by a hairline
/// divider with breathing room, Slack-style. An optional [label] renders a
/// small muted header above the rows.
class AppListSection extends StatelessWidget {
const AppListSection({
super.key,
this.label,
required this.children,
this.showDivider = true,
});
final String? label;
final List<Widget> children;
final bool showDivider;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showDivider)
Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
child: Divider(height: 1, color: context.colors.outlineVariant),
),
if (label != null)
Padding(
padding: const EdgeInsets.fromLTRB(
Grid.xs,
Grid.xxs,
Grid.xs,
Grid.quarter,
),
child: Text(
label!.toUpperCase(),
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurfaceVariant,
fontWeight: FontWeight.w600,
letterSpacing: 0.6,
),
),
),
...children,
],
);
}
}

View File

@@ -1,14 +1,19 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:hooks_riverpod/misc.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:sprout_mobile/features/channels/message_content.dart';
import 'package:sprout_mobile/features/channels/media_viewer_page.dart';
import 'package:sprout_mobile/shared/theme/theme.dart';
Widget _testable(Widget child) {
return MaterialApp(
theme: AppTheme.light(),
home: Scaffold(body: child),
Widget _testable(Widget child, {List<Override> overrides = const []}) {
return ProviderScope(
overrides: overrides,
child: MaterialApp(
theme: AppTheme.light(),
home: Scaffold(body: child),
),
);
}
@@ -161,6 +166,40 @@ void main() {
});
});
group('custom emoji', () {
testWidgets('renders tagged custom emoji as inline image', (
tester,
) async {
await tester.pumpWidget(
_testable(
const MessageContent(
content: 'Narf :shipit:',
tags: [
['emoji', 'shipit', 'https://relay.example/shipit.png'],
],
),
),
);
expect(find.byType(Image), findsOneWidget);
final image = tester.widget<Image>(find.byType(Image));
expect(image.semanticLabel, ':shipit:');
expect(_allRichText(tester), contains('Narf'));
expect(_allRichText(tester), isNot(contains(':shipit:')));
});
testWidgets('leaves untagged custom emoji shortcode as text', (
tester,
) async {
await tester.pumpWidget(
_testable(const MessageContent(content: 'Missing :shipit:')),
);
expect(find.byType(Image), findsNothing);
expect(_allRichText(tester), contains(':shipit:'));
});
});
group('inline formatting', () {
testWidgets('renders bold text', (tester) async {
await tester.pumpWidget(

View File

@@ -93,6 +93,7 @@ NostrEvent _edit({
required String targetId,
required String content,
int createdAt = 2000,
List<List<String>> extraTags = const [],
}) => NostrEvent(
id: id,
pubkey: 'alice',
@@ -101,6 +102,7 @@ NostrEvent _edit({
tags: [
['h', 'ch1'],
['e', targetId],
...extraTags,
],
content: content,
sig: '',
@@ -110,6 +112,8 @@ NostrEvent _reaction({
required String id,
required String targetId,
int createdAt = 2000,
String content = '👍',
List<List<String>> extraTags = const [],
}) => NostrEvent(
id: id,
pubkey: 'bob',
@@ -118,8 +122,9 @@ NostrEvent _reaction({
tags: [
['h', 'ch1'],
['e', targetId],
...extraTags,
],
content: '👍',
content: content,
sig: '',
);
@@ -318,6 +323,68 @@ void main() {
expect(result[0].edited, true);
});
test('applies edit tags for custom emoji rendering', () {
final events = [
_textMsg(
id: 'a',
content: 'original :old:',
extraTags: [
['emoji', 'old', 'https://relay.example/old.png'],
],
),
_edit(
id: 'e1',
targetId: 'a',
content: 'edited :new:',
extraTags: [
['emoji', 'new', 'https://relay.example/new.png'],
],
),
];
final result = formatTimeline(events);
expect(result.single.content, 'edited :new:');
expect(
result.single.tags.any(
(tag) =>
tag.length >= 3 &&
tag[0] == 'emoji' &&
tag[1] == 'new' &&
tag[2] == 'https://relay.example/new.png',
),
isTrue,
);
expect(
result.single.tags.any(
(tag) =>
tag.length >= 3 &&
tag[0] == 'emoji' &&
tag[1] == 'old' &&
tag[2] == 'https://relay.example/old.png',
),
isFalse,
);
});
test('preserves custom emoji reaction image URL from emoji tag', () {
final messages = formatTimeline([
_textMsg(id: 'm1'),
_reaction(
id: 'r1',
targetId: 'm1',
content: ':shipit:',
extraTags: [
['emoji', 'shipit', 'https://relay.example/shipit.png'],
],
),
], currentPubkey: 'bob');
final reaction = messages.single.reactions.single;
expect(reaction.emoji, ':shipit:');
expect(reaction.emojiUrl, 'https://relay.example/shipit.png');
expect(reaction.reactedByCurrentUser, isTrue);
});
test('latest edit wins', () {
final events = [
_textMsg(id: 'a', content: 'v1'),

View File

@@ -0,0 +1,146 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:sprout_mobile/features/custom_emoji/custom_emoji.dart';
import 'package:sprout_mobile/shared/relay/nostr_models.dart';
NostrEvent _event(String pubkey, List<List<String>> emojiTags) {
return NostrEvent(
id: 'id-$pubkey',
pubkey: pubkey,
createdAt: 0,
kind: kindEmojiSet,
tags: [
['d', customEmojiSetDTag],
...emojiTags,
],
content: '',
sig: '',
);
}
void main() {
group('normalizeShortcode', () {
test('strips colons and lowercases', () {
expect(normalizeShortcode(':PartyParrot:'), 'partyparrot');
expect(normalizeShortcode(' meow '), 'meow');
expect(normalizeShortcode('a_b-c1'), 'a_b-c1');
});
test('rejects invalid chars and empty', () {
expect(normalizeShortcode(':: ::'), isNull);
expect(normalizeShortcode('has space'), isNull);
expect(normalizeShortcode('emoji!'), isNull);
expect(normalizeShortcode(''), isNull);
});
});
group('customEmojiFromTags', () {
test('parses valid emoji tags, normalizing shortcodes', () {
final result = customEmojiFromTags([
['emoji', 'Meow', 'https://a/meow.png'],
['p', 'someone'],
['emoji', ':Wave:', 'https://a/wave.png'],
]);
expect(result, [
const CustomEmoji(shortcode: 'meow', url: 'https://a/meow.png'),
const CustomEmoji(shortcode: 'wave', url: 'https://a/wave.png'),
]);
});
test('skips malformed and dup-within-event (first wins)', () {
final result = customEmojiFromTags([
['emoji', 'meow'], // missing url
['emoji', '', 'https://a/x.png'], // empty shortcode
['emoji', 'meow', 'https://a/meow1.png'],
['emoji', 'meow', 'https://a/meow2.png'], // dup
]);
expect(result, [
const CustomEmoji(shortcode: 'meow', url: 'https://a/meow1.png'),
]);
});
});
group('unionCustomEmoji', () {
test('collapses to one per shortcode, smallest URL wins, sorted', () {
final palette = unionCustomEmoji([
_event('alice', [
['emoji', 'meow', 'https://z/meow.png'],
['emoji', 'wave', 'https://a/wave.png'],
]),
_event('bob', [
['emoji', 'meow', 'https://a/meow.png'], // smaller URL wins
]),
]);
expect(palette, [
const CustomEmoji(shortcode: 'meow', url: 'https://a/meow.png'),
const CustomEmoji(shortcode: 'wave', url: 'https://a/wave.png'),
]);
});
test('deterministic regardless of event order', () {
final a = _event('alice', [
['emoji', 'x', 'https://z/x.png'],
]);
final b = _event('bob', [
['emoji', 'x', 'https://a/x.png'],
]);
expect(unionCustomEmoji([a, b]), unionCustomEmoji([b, a]));
});
test('empty input → empty palette', () {
expect(unionCustomEmoji([]), isEmpty);
});
});
group('buildCustomEmojiTags', () {
final palette = [
const CustomEmoji(shortcode: 'meow', url: 'https://a/meow.png'),
const CustomEmoji(shortcode: 'wave', url: 'https://a/wave.png'),
];
test(
'emits one tag per distinct known shortcode, first-appearance order',
() {
final tags = buildCustomEmojiTags(
'hi :wave: and :meow: and :wave:',
palette,
);
expect(tags, [
['emoji', 'wave', 'https://a/wave.png'],
['emoji', 'meow', 'https://a/meow.png'],
]);
},
);
test('case-insensitive match, canonical lowercase emitted', () {
final tags = buildCustomEmojiTags(':MEOW:', palette);
expect(tags, [
['emoji', 'meow', 'https://a/meow.png'],
]);
});
test('ignores unknown shortcodes', () {
expect(buildCustomEmojiTags(':unknown:', palette), isEmpty);
});
test('empty palette → no tags', () {
expect(buildCustomEmojiTags(':meow:', const []), isEmpty);
});
});
group('reactionEmojiUrl', () {
final palette = [
const CustomEmoji(shortcode: 'meow', url: 'https://a/meow.png'),
];
test('resolves a known custom-emoji reaction', () {
expect(reactionEmojiUrl(':meow:', palette), 'https://a/meow.png');
expect(reactionEmojiUrl(':MEOW:', palette), 'https://a/meow.png');
});
test('returns null for unicode / unknown / no palette', () {
expect(reactionEmojiUrl('👍', palette), isNull);
expect(reactionEmojiUrl(':unknown:', palette), isNull);
expect(reactionEmojiUrl(':meow:', null), isNull);
});
});
}