Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 61 additions & 36 deletions mobile/lib/features/channels/message_content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'dart:io';
import 'dart:math' as math;

import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
Expand Down Expand Up @@ -33,6 +34,7 @@ import 'message_media.dart';
import 'voice_note_attachment.dart';

part 'message_content/media_carousel.dart';
part 'message_content/inline_components.dart';
part 'message_content/token_pill.dart';
part 'message_content/video_preview.dart';

Expand Down Expand Up @@ -176,13 +178,33 @@ class MessageContent extends HookConsumerWidget {
in ref.watch(channelsProvider).asData?.value ?? const [])
channel.name.toLowerCase(): channel.id,
};
final resolvedChannelTap =
onChannelTap ??
(String channelId) {
final channelHandler = useRef(onChannelTap)..value = onChannelTap;
final resolvedChannelTap = useMemoized(
() => (String channelId) {
final handler = channelHandler.value;
if (handler != null) {
handler(channelId);
} else {
ref
.read(pendingDeepLinkProvider.notifier)
.open(Uri(scheme: 'buzz', host: 'channel', path: channelId));
};
}
},
const [],
);
final replyHandler = useRef(onMediaReply)..value = onMediaReply;
final moreHandler = useRef(onMediaMore)..value = onMediaMore;
final mediaReply = useMemoized(
() =>
() => replyHandler.value?.call(),
const [],
);
final mediaMore = useMemoized(
() =>
(BuildContext context, String url) =>
moreHandler.value?.call(context, url),
const [],
);
final channelPresentationKey = [
for (final entry
in (resolvedChannelNames.entries.toList()
Expand Down Expand Up @@ -265,7 +287,23 @@ class MessageContent extends HookConsumerWidget {
result = '\u200B$result';
}
return result;
}, [linkNormalizedContent, resolvedMentionNames]);
}, [linkNormalizedContent, mentionPresentationKey]);

final inlineComponents = _useMessageInlineComponents(
content: content,
finalContent: finalContent,
mentionNames: resolvedMentionNames,
bindings: mentionBindings,
agentPubkeys: resolvedAgentMentionPubkeys,
channelNames: resolvedChannelNames,
customEmoji: customEmoji,
emojiSize: inlineCustomEmojiSize,
tags: tags,
hasMediaReply: onMediaReply != null,
hasMediaMore: onMediaMore != null,
onMentionTap: onMentionTap,
onChannelTap: resolvedChannelTap,
);

final markdown = KeyedSubtree(
key: ValueKey(
Expand All @@ -291,35 +329,16 @@ class MessageContent extends HookConsumerWidget {
resolvedChannelTap,
resolvedChannelNames,
),
imageBuilder: (context, imageUrl, _, _) =>
_buildMedia(context, imageUrl, imetaByUrl[imageUrl]),
imageBuilder: (context, imageUrl, _, _) => _buildMedia(
context,
imageUrl,
imetaByUrl[imageUrl],
onReply: onMediaReply == null ? null : mediaReply,
onMore: onMediaMore == null ? null : mediaMore,
),
textAlign: textAlign,
maxLines: maxLines,
inlineComponents: [
_MentionMd(
mentionNames: resolvedMentionNames,
bindings: mentionBindings,
displayLabels: {
for (final range in mentionOccurrences(
content,
mentionBindings.keys,
))
range.label: content.substring(range.start + 1, range.end),
},
agentMentionPubkeys: resolvedAgentMentionPubkeys,
onMentionTap: onMentionTap,
),
CustomEmojiMd(
customEmoji,
content: finalContent,
size: inlineCustomEmojiSize,
),
_ChannelLinkMd(
channelNames: resolvedChannelNames,
onChannelTap: resolvedChannelTap,
),
...MarkdownComponent.inlineComponents,
],
inlineComponents: inlineComponents,
),
);
if (trailingGallery == null) return markdown;
Expand All @@ -343,7 +362,13 @@ class MessageContent extends HookConsumerWidget {
);
}

Widget _buildMedia(BuildContext context, String imageUrl, ImetaEntry? imeta) {
Widget _buildMedia(
BuildContext context,
String imageUrl,
ImetaEntry? imeta, {
VoidCallback? onReply,
MediaViewerMoreAction? onMore,
}) {
final mediaKind = classifyMediaUrl(imageUrl, imeta: imeta);
if (mediaKind == MessageMediaKind.audio) {
return Padding(
Expand All @@ -360,15 +385,15 @@ class MessageContent extends HookConsumerWidget {
return _MessageVideoPreview(
url: imageUrl,
imeta: imeta,
onReply: onMediaReply,
onReply: onReply,
);
}
return _MessageImagePreview(
url: imageUrl,
imeta: imeta,
semanticLabel: imeta?.alt ?? 'Message image',
onReply: onMediaReply,
onMore: onMediaMore,
onReply: onReply,
onMore: onMore,
);
}

Expand Down
136 changes: 136 additions & 0 deletions mobile/lib/features/channels/message_content/inline_components.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
part of '../message_content.dart';

List<MarkdownComponent> _useMessageInlineComponents({
required String content,
required String finalContent,
required Map<String, String> mentionNames,
required Map<String, Set<String>> bindings,
required Set<String> agentPubkeys,
required Map<String, String> channelNames,
required List<CustomEmoji> customEmoji,
required double emojiSize,
required List<List<String>> tags,
required bool hasMediaReply,
required bool hasMediaMore,
required void Function(String)? onMentionTap,
required void Function(String) onChannelTap,
}) {
// Parsed spans retain these callbacks. Forward to the current handlers without
// invalidating the parser for a new closure from an otherwise unchanged row.
final mentionHandler = useRef(onMentionTap)..value = onMentionTap;
final channelHandler = useRef(onChannelTap)..value = onChannelTap;
final mentionTap = useMemoized(
() =>
(String pubkey) => mentionHandler.value?.call(pubkey),
const [],
);
final channelTap = useMemoized(
() =>
(String channelId) => channelHandler.value(channelId),
const [],
);
final inputs = _InlineComponentInputs(
content: content,
finalContent: finalContent,
mentionNames: mentionNames,
bindings: bindings,
agentPubkeys: agentPubkeys,
channelNames: channelNames,
customEmoji: customEmoji,
emojiSize: emojiSize,
tags: tags,
hasMediaReply: hasMediaReply,
hasMediaMore: hasMediaMore,
hasMentionHandler: onMentionTap != null,
);

// gpt_markdown compares components by identity. Recreating them on every
// parent rebuild reparses unchanged message bodies on the UI isolate.
return useMemoized(
() => [
_MentionMd(
mentionNames: inputs.mentionNames,
bindings: inputs.bindings,
displayLabels: {
for (final range in mentionOccurrences(content, bindings.keys))
range.label: content.substring(range.start + 1, range.end),
},
agentMentionPubkeys: inputs.agentPubkeys,
onMentionTap: inputs.hasMentionHandler ? mentionTap : null,
),
CustomEmojiMd(inputs.customEmoji, content: finalContent, size: emojiSize),
_ChannelLinkMd(
channelNames: inputs.channelNames,
onChannelTap: channelTap,
),
...MarkdownComponent.inlineComponents,
],
[inputs],
);
}

class _InlineComponentInputs {
final String content;
final String finalContent;
final Map<String, String> mentionNames;
final Map<String, Set<String>> bindings;
final Set<String> agentPubkeys;
final Map<String, String> channelNames;
final List<CustomEmoji> customEmoji;
final double emojiSize;
final bool hasMentionHandler;
final List<List<String>> tags;
final bool hasMediaReply;
final bool hasMediaMore;

_InlineComponentInputs({
required this.content,
required this.finalContent,
required Map<String, String> mentionNames,
required Map<String, Set<String>> bindings,
required Set<String> agentPubkeys,
required Map<String, String> channelNames,
required List<CustomEmoji> customEmoji,
required this.emojiSize,
required this.hasMentionHandler,
required List<List<String>> tags,
required this.hasMediaReply,
required this.hasMediaMore,
}) : tags = List.unmodifiable(
tags.map((tag) => List<String>.unmodifiable(tag)),
),
mentionNames = Map.unmodifiable(mentionNames),
bindings = Map.unmodifiable({
for (final entry in bindings.entries)
entry.key: Set<String>.unmodifiable(entry.value),
}),
agentPubkeys = Set.unmodifiable(agentPubkeys),
channelNames = Map.unmodifiable(channelNames),
customEmoji = List.unmodifiable(customEmoji);

@override
bool operator ==(Object other) =>
other is _InlineComponentInputs &&
content == other.content &&
finalContent == other.finalContent &&
emojiSize == other.emojiSize &&
hasMentionHandler == other.hasMentionHandler &&
hasMediaReply == other.hasMediaReply &&
hasMediaMore == other.hasMediaMore &&
tags.length == other.tags.length &&
tags.indexed.every(
(entry) => listEquals(entry.$2, other.tags[entry.$1]),
) &&
mapEquals(mentionNames, other.mentionNames) &&
mapEquals(channelNames, other.channelNames) &&
setEquals(agentPubkeys, other.agentPubkeys) &&
listEquals(customEmoji, other.customEmoji) &&
bindings.length == other.bindings.length &&
bindings.entries.every(
(entry) => setEquals(entry.value, other.bindings[entry.key]),
);

@override
int get hashCode =>
Object.hash(content, finalContent, emojiSize, hasMentionHandler);
}
Loading
Loading