import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:flutter/services.dart'; import 'package:flutter_highlight/themes/a11y-dark.dart'; import 'package:flutter_highlight/themes/a11y-light.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/models/file.dart'; import 'package:island/pods/config.dart'; import 'package:island/widgets/alert.dart'; import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/content/markdown_latex.dart'; import 'package:markdown/markdown.dart' as markdown; import 'package:markdown_widget/markdown_widget.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:url_launcher/url_launcher.dart'; import 'image.dart'; class MarkdownTextContent extends HookConsumerWidget { static const String stickerRegex = r':([-\w]*\+[-\w]*):'; final String content; final bool isAutoWarp; final TextScaler? textScaler; final TextStyle? textStyle; final TextStyle? linkStyle; final EdgeInsets? linesMargin; final bool isSelectable; final List? attachments; const MarkdownTextContent({ super.key, required this.content, this.isAutoWarp = false, this.textScaler, this.textStyle, this.linkStyle, this.isSelectable = false, this.linesMargin, this.attachments, }); @override Widget build(BuildContext context, WidgetRef ref) { final baseUrl = ref.watch(serverUrlProvider); final doesEnlargeSticker = useMemoized(() { // Check if content only contains one sticker by matching the sticker pattern final stickerPattern = RegExp(stickerRegex); final matches = stickerPattern.allMatches(content); // Content should only contain one sticker and nothing else (except whitespace) final contentWithoutStickers = content.replaceAll(stickerPattern, '').trim(); return matches.length == 1 && contentWithoutStickers.isEmpty; }, [content]); final isDark = Theme.of(context).brightness == Brightness.dark; final config = isDark ? MarkdownConfig.darkConfig : MarkdownConfig.defaultConfig; final onMentionTap = useCallback((String type, String id) { final fullPath = '/$type/$id'; context.push(fullPath); }, [context]); final mentionGenerator = MentionChipGenerator( backgroundColor: Theme.of(context).colorScheme.secondary, foregroundColor: Theme.of(context).colorScheme.onSecondary, onTap: onMentionTap, ); return MarkdownBlock( data: content, selectable: isSelectable, config: config.copy( configs: [ isDark ? PreConfig.darkConfig.copy(textStyle: textStyle) : PreConfig().copy(textStyle: textStyle), PConfig( textStyle: textStyle ?? Theme.of(context).textTheme.bodyMedium!, ), HrConfig(height: 1, color: Theme.of(context).dividerColor), PreConfig( theme: isDark ? a11yDarkTheme : a11yLightTheme, textStyle: GoogleFonts.robotoMono(fontSize: 14), styleNotMatched: GoogleFonts.robotoMono(fontSize: 14), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.all(Radius.circular(8.0)), ), ), TableConfig( wrapper: (child) => SingleChildScrollView( scrollDirection: Axis.horizontal, child: child, ), ), LinkConfig( style: linkStyle ?? TextStyle(color: Theme.of(context).colorScheme.primary), onTap: (href) { final url = Uri.tryParse(href); if (url != null) { if (url.scheme == 'solian') { final fullPath = ['/', url.host, url.path].join(''); context.push(fullPath); return; } final whitelistDomains = ['solian.app', 'solsynth.dev']; if (whitelistDomains.any( (domain) => url.host == domain || url.host.endsWith('.$domain'), )) { launchUrl(url, mode: LaunchMode.externalApplication); return; } showConfirmAlert( 'openLinkConfirmDescription'.tr(args: [url.toString()]), 'openLinkConfirm'.tr(), ).then((value) { if (value) { launchUrl(url, mode: LaunchMode.externalApplication); } }); } else { showSnackBar( 'brokenLink'.tr(args: [href]), action: SnackBarAction( label: 'copyToClipboard'.tr(), onPressed: () { Clipboard.setData(ClipboardData(text: href)); clearSnackBar(context); }, ), ); } }, ), ImgConfig( builder: (url, attributes) { final uri = Uri.parse(url); if (uri.scheme == 'solian') { switch (uri.host) { case 'files': final file = attachments?.firstWhereOrNull( (file) => file.id == uri.pathSegments[0], ); if (file == null) { return const SizedBox.shrink(); } return ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(8)), child: Container( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainer, borderRadius: const BorderRadius.all( Radius.circular(8), ), ), child: CloudFileWidget( item: file, fit: BoxFit.cover, ).clipRRect(all: 8), ), ); case 'stickers': final size = doesEnlargeSticker ? 96.0 : 24.0; final stickerUri = '$baseUrl/sphere/stickers/lookup/${uri.pathSegments[0]}/open'; return ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(8)), child: Container( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainer, borderRadius: const BorderRadius.all( Radius.circular(8), ), ), child: UniversalImage( uri: stickerUri, width: size, height: size, fit: BoxFit.contain, noCacheOptimization: true, ), ), ); } } final content = ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(8)), child: ConstrainedBox( constraints: BoxConstraints(maxHeight: 360), child: UniversalImage( uri: uri.toString(), fit: BoxFit.contain, ), ), ); return content; }, ), ], ), generator: MarkdownTextContent.buildGenerator( isDark: isDark, linesMargin: linesMargin, generators: [mentionGenerator], ), ); } static MarkdownGenerator buildGenerator({ bool isDark = false, EdgeInsets? linesMargin, List generators = const [], }) { return MarkdownGenerator( generators: [latexGenerator, ...generators], inlineSyntaxList: [ _MetionInlineSyntax(), _StickerInlineSyntax(), LatexSyntax(isDark), ], linesMargin: linesMargin ?? EdgeInsets.symmetric(vertical: 4), ); } } class _MetionInlineSyntax extends markdown.InlineSyntax { _MetionInlineSyntax() : super(r'@[-a-zA-Z0-9_./]+'); @override bool onMatch(markdown.InlineParser parser, Match match) { final alias = match[0]!; final parts = alias.substring(1).split('/'); final typeShortcut = parts.length == 1 ? 'u' : parts.first; final type = switch (typeShortcut) { 'u' => 'accounts', 'r' => 'realms', 'p' => 'publishers', "c" => 'chat', _ => '', }; final element = markdown.Element('mention-chip', [markdown.Text(alias)]) ..attributes['alias'] = alias ..attributes['type'] = type ..attributes['id'] = parts.last; parser.addNode(element); return true; } } class _StickerInlineSyntax extends markdown.InlineSyntax { _StickerInlineSyntax() : super(MarkdownTextContent.stickerRegex); @override bool onMatch(markdown.InlineParser parser, Match match) { final placeholder = match[1]!; final image = markdown.Element.text('img', '') ..attributes['src'] = Uri.encodeFull('solian://stickers/$placeholder'); parser.addNode(image); return true; } } class MentionSpanNodeGenerator { final Color backgroundColor; final Color foregroundColor; final void Function(String type, String id) onTap; MentionSpanNodeGenerator({ required this.backgroundColor, required this.foregroundColor, required this.onTap, }); SpanNode? call( String tag, Map attributes, List children, ) { if (tag == 'mention-chip') { return MentionChipSpanNode( attributes: attributes, backgroundColor: backgroundColor, foregroundColor: foregroundColor, onTap: onTap, ); } return null; } } class MentionChipGenerator extends SpanNodeGeneratorWithTag { MentionChipGenerator({ required Color backgroundColor, required Color foregroundColor, required void Function(String type, String id) onTap, }) : super( tag: 'mention-chip', generator: ( markdown.Element element, MarkdownConfig config, WidgetVisitor visitor, ) { return MentionChipSpanNode( attributes: element.attributes, backgroundColor: backgroundColor, foregroundColor: foregroundColor, onTap: onTap, ); }, ); } class MentionChipSpanNode extends SpanNode { final Map attributes; final Color backgroundColor; final Color foregroundColor; final void Function(String type, String id) onTap; MentionChipSpanNode({ required this.attributes, required this.backgroundColor, required this.foregroundColor, required this.onTap, }); @override InlineSpan build() { final alias = attributes['alias'] ?? ''; final type = attributes['type'] ?? ''; final id = attributes['id'] ?? ''; final parts = alias.substring(1).split('/'); return WidgetSpan( alignment: PlaceholderAlignment.middle, child: InkWell( onTap: () => onTap(type, id), child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: backgroundColor.withOpacity(0.1), borderRadius: BorderRadius.circular(32), ), child: Row( mainAxisSize: MainAxisSize.min, spacing: 6, children: [ Container( decoration: BoxDecoration( color: backgroundColor.withOpacity(0.5), borderRadius: const BorderRadius.all(Radius.circular(32)), ), child: Icon( switch (parts.first.isEmpty ? 'u' : parts.first) { 'c' => Symbols.forum_rounded, 'r' => Symbols.group_rounded, 'u' => Symbols.person_rounded, 'p' => Symbols.edit_rounded, _ => Symbols.person_rounded, }, size: 14, color: foregroundColor, fill: 1, ).padding(all: 2), ), Text( parts.last, style: TextStyle( color: backgroundColor, fontSize: 14, fontWeight: FontWeight.w500, ), ), ], ), ), ), ); } }