mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
Mobile custom emoji + settings redesign (#906)
Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
134
mobile/lib/features/custom_emoji/custom_emoji.dart
Normal file
134
mobile/lib/features/custom_emoji/custom_emoji.dart
Normal 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;
|
||||
}
|
||||
60
mobile/lib/features/custom_emoji/custom_emoji_provider.dart
Normal file
60
mobile/lib/features/custom_emoji/custom_emoji_provider.dart
Normal 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 [];
|
||||
});
|
||||
93
mobile/lib/features/custom_emoji/custom_emoji_render.dart
Normal file
93
mobile/lib/features/custom_emoji/custom_emoji_render.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
188
mobile/lib/shared/widgets/app_list.dart
Normal file
188
mobile/lib/shared/widgets/app_list.dart
Normal 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,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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'),
|
||||
|
||||
146
mobile/test/features/custom_emoji/custom_emoji_test.dart
Normal file
146
mobile/test/features/custom_emoji/custom_emoji_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user