629 lines
		
	
	
		
			19 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			629 lines
		
	
	
		
			19 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
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<SnCloudFile>? attachments;
 | 
						|
  final List<markdown.InlineSyntax> extraInlineSyntaxList;
 | 
						|
  final List<markdown.BlockSyntax> extraBlockSyntaxList;
 | 
						|
  final List<dynamic> extraGenerators;
 | 
						|
 | 
						|
  const MarkdownTextContent({
 | 
						|
    super.key,
 | 
						|
    required this.content,
 | 
						|
    this.isAutoWarp = false,
 | 
						|
    this.textScaler,
 | 
						|
    this.textStyle,
 | 
						|
    this.linkStyle,
 | 
						|
    this.isSelectable = false,
 | 
						|
    this.linesMargin,
 | 
						|
    this.attachments,
 | 
						|
    this.extraInlineSyntaxList = const [],
 | 
						|
    this.extraBlockSyntaxList = const [],
 | 
						|
    this.extraGenerators = const [],
 | 
						|
  });
 | 
						|
 | 
						|
  @override
 | 
						|
  Widget build(BuildContext context, WidgetRef ref) {
 | 
						|
    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,
 | 
						|
    );
 | 
						|
 | 
						|
    final highlightGenerator = HighlightGenerator(
 | 
						|
      highlightColor: Theme.of(context).colorScheme.primaryContainer,
 | 
						|
    );
 | 
						|
 | 
						|
    final spoilerRevealed = useState(false);
 | 
						|
 | 
						|
    final spoilerGenerator = SpoilerGenerator(
 | 
						|
      backgroundColor: Theme.of(context).colorScheme.tertiary,
 | 
						|
      foregroundColor: Theme.of(context).colorScheme.onTertiary,
 | 
						|
      outlineColor: Theme.of(context).colorScheme.outline,
 | 
						|
      revealed: spoilerRevealed.value,
 | 
						|
      onToggle: () => spoilerRevealed.value = !spoilerRevealed.value,
 | 
						|
    );
 | 
						|
 | 
						|
    final baseUrl = ref.watch(serverUrlProvider);
 | 
						|
    final stickerGenerator = StickerGenerator(
 | 
						|
      backgroundColor: Theme.of(context).colorScheme.primary,
 | 
						|
      foregroundColor: Theme.of(context).colorScheme.onPrimary,
 | 
						|
      isEnlarged: doesEnlargeSticker,
 | 
						|
      baseUrl: baseUrl,
 | 
						|
    );
 | 
						|
 | 
						|
    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),
 | 
						|
                      ),
 | 
						|
                    );
 | 
						|
                }
 | 
						|
              }
 | 
						|
              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,
 | 
						|
          highlightGenerator,
 | 
						|
          spoilerGenerator,
 | 
						|
          stickerGenerator,
 | 
						|
          ...extraGenerators,
 | 
						|
        ],
 | 
						|
        extraInlineSyntaxList: extraInlineSyntaxList,
 | 
						|
        extraBlockSyntaxList: extraBlockSyntaxList,
 | 
						|
      ),
 | 
						|
    );
 | 
						|
  }
 | 
						|
 | 
						|
  static MarkdownGenerator buildGenerator({
 | 
						|
    bool isDark = false,
 | 
						|
    EdgeInsets? linesMargin,
 | 
						|
    List<dynamic> generators = const [],
 | 
						|
    List<markdown.InlineSyntax> extraInlineSyntaxList = const [],
 | 
						|
    List<markdown.BlockSyntax> extraBlockSyntaxList = const [],
 | 
						|
  }) {
 | 
						|
    return MarkdownGenerator(
 | 
						|
      generators: [latexGenerator, ...generators],
 | 
						|
      inlineSyntaxList: [
 | 
						|
        _MetionInlineSyntax(),
 | 
						|
        _HighlightInlineSyntax(),
 | 
						|
        _SpoilerInlineSyntax(),
 | 
						|
        _StickerInlineSyntax(),
 | 
						|
        LatexSyntax(isDark),
 | 
						|
        ...extraInlineSyntaxList,
 | 
						|
      ],
 | 
						|
      blockSyntaxList: extraBlockSyntaxList,
 | 
						|
      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 element = markdown.Element('sticker', [markdown.Text(placeholder)]);
 | 
						|
    parser.addNode(element);
 | 
						|
 | 
						|
    return true;
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
class _HighlightInlineSyntax extends markdown.InlineSyntax {
 | 
						|
  _HighlightInlineSyntax() : super(r'==([^=]+)==');
 | 
						|
 | 
						|
  @override
 | 
						|
  bool onMatch(markdown.InlineParser parser, Match match) {
 | 
						|
    final text = match[1]!;
 | 
						|
    final element = markdown.Element('highlight', [markdown.Text(text)]);
 | 
						|
    parser.addNode(element);
 | 
						|
 | 
						|
    return true;
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
class _SpoilerInlineSyntax extends markdown.InlineSyntax {
 | 
						|
  _SpoilerInlineSyntax() : super(r'=!([^!]+)!=');
 | 
						|
 | 
						|
  @override
 | 
						|
  bool onMatch(markdown.InlineParser parser, Match match) {
 | 
						|
    final text = match[1]!;
 | 
						|
    final element = markdown.Element('spoiler', [markdown.Text(text)]);
 | 
						|
    parser.addNode(element);
 | 
						|
 | 
						|
    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<String, String> attributes,
 | 
						|
    List<SpanNode> 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<String, String> 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),
 | 
						|
        borderRadius: BorderRadius.circular(32),
 | 
						|
        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,
 | 
						|
                ),
 | 
						|
              ),
 | 
						|
            ],
 | 
						|
          ),
 | 
						|
        ),
 | 
						|
      ),
 | 
						|
    );
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
class HighlightGenerator extends SpanNodeGeneratorWithTag {
 | 
						|
  HighlightGenerator({required Color highlightColor})
 | 
						|
    : super(
 | 
						|
        tag: 'highlight',
 | 
						|
        generator: (
 | 
						|
          markdown.Element element,
 | 
						|
          MarkdownConfig config,
 | 
						|
          WidgetVisitor visitor,
 | 
						|
        ) {
 | 
						|
          return HighlightSpanNode(
 | 
						|
            text: element.textContent,
 | 
						|
            highlightColor: highlightColor,
 | 
						|
          );
 | 
						|
        },
 | 
						|
      );
 | 
						|
}
 | 
						|
 | 
						|
class HighlightSpanNode extends SpanNode {
 | 
						|
  final String text;
 | 
						|
  final Color highlightColor;
 | 
						|
 | 
						|
  HighlightSpanNode({required this.text, required this.highlightColor});
 | 
						|
 | 
						|
  @override
 | 
						|
  InlineSpan build() {
 | 
						|
    return TextSpan(
 | 
						|
      text: text,
 | 
						|
      style: TextStyle(backgroundColor: highlightColor),
 | 
						|
    );
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
class SpoilerGenerator extends SpanNodeGeneratorWithTag {
 | 
						|
  SpoilerGenerator({
 | 
						|
    required Color backgroundColor,
 | 
						|
    required Color foregroundColor,
 | 
						|
    required Color outlineColor,
 | 
						|
    required bool revealed,
 | 
						|
    required VoidCallback onToggle,
 | 
						|
  }) : super(
 | 
						|
         tag: 'spoiler',
 | 
						|
         generator: (
 | 
						|
           markdown.Element element,
 | 
						|
           MarkdownConfig config,
 | 
						|
           WidgetVisitor visitor,
 | 
						|
         ) {
 | 
						|
           return SpoilerSpanNode(
 | 
						|
             text: element.textContent,
 | 
						|
             backgroundColor: backgroundColor,
 | 
						|
             foregroundColor: foregroundColor,
 | 
						|
             outlineColor: outlineColor,
 | 
						|
             revealed: revealed,
 | 
						|
             onToggle: onToggle,
 | 
						|
           );
 | 
						|
         },
 | 
						|
       );
 | 
						|
}
 | 
						|
 | 
						|
class SpoilerSpanNode extends SpanNode {
 | 
						|
  final String text;
 | 
						|
  final Color backgroundColor;
 | 
						|
  final Color foregroundColor;
 | 
						|
  final Color outlineColor;
 | 
						|
  final bool revealed;
 | 
						|
  final VoidCallback onToggle;
 | 
						|
 | 
						|
  SpoilerSpanNode({
 | 
						|
    required this.text,
 | 
						|
    required this.backgroundColor,
 | 
						|
    required this.foregroundColor,
 | 
						|
    required this.outlineColor,
 | 
						|
    required this.revealed,
 | 
						|
    required this.onToggle,
 | 
						|
  });
 | 
						|
 | 
						|
  @override
 | 
						|
  InlineSpan build() {
 | 
						|
    return WidgetSpan(
 | 
						|
      child: InkWell(
 | 
						|
        onTap: onToggle,
 | 
						|
        child: Container(
 | 
						|
          padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
 | 
						|
          decoration: BoxDecoration(
 | 
						|
            color: revealed ? Colors.transparent : backgroundColor,
 | 
						|
            border: revealed ? Border.all(color: outlineColor, width: 1) : null,
 | 
						|
            borderRadius: BorderRadius.circular(4),
 | 
						|
          ),
 | 
						|
          child:
 | 
						|
              revealed
 | 
						|
                  ? Row(
 | 
						|
                    spacing: 6,
 | 
						|
                    mainAxisSize: MainAxisSize.min,
 | 
						|
                    crossAxisAlignment: CrossAxisAlignment.start,
 | 
						|
                    children: [
 | 
						|
                      Icon(Symbols.visibility, size: 18).padding(top: 1),
 | 
						|
                      Flexible(child: Text(text)),
 | 
						|
                    ],
 | 
						|
                  )
 | 
						|
                  : Row(
 | 
						|
                    spacing: 6,
 | 
						|
                    mainAxisSize: MainAxisSize.min,
 | 
						|
                    children: [
 | 
						|
                      Icon(
 | 
						|
                        Symbols.visibility_off,
 | 
						|
                        color: foregroundColor,
 | 
						|
                        size: 18,
 | 
						|
                      ),
 | 
						|
                      Flexible(
 | 
						|
                        child:
 | 
						|
                            Text(
 | 
						|
                              'spoiler',
 | 
						|
                              style: TextStyle(color: foregroundColor),
 | 
						|
                            ).tr(),
 | 
						|
                      ),
 | 
						|
                    ],
 | 
						|
                  ),
 | 
						|
        ),
 | 
						|
      ),
 | 
						|
    );
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
class StickerGenerator extends SpanNodeGeneratorWithTag {
 | 
						|
  StickerGenerator({
 | 
						|
    required Color backgroundColor,
 | 
						|
    required Color foregroundColor,
 | 
						|
    required bool isEnlarged,
 | 
						|
    required String baseUrl,
 | 
						|
  }) : super(
 | 
						|
         tag: 'sticker',
 | 
						|
         generator: (
 | 
						|
           markdown.Element element,
 | 
						|
           MarkdownConfig config,
 | 
						|
           WidgetVisitor visitor,
 | 
						|
         ) {
 | 
						|
           return StickerSpanNode(
 | 
						|
             placeholder: element.textContent,
 | 
						|
             backgroundColor: backgroundColor,
 | 
						|
             foregroundColor: foregroundColor,
 | 
						|
             isEnlarged: isEnlarged,
 | 
						|
             baseUrl: baseUrl,
 | 
						|
           );
 | 
						|
         },
 | 
						|
       );
 | 
						|
}
 | 
						|
 | 
						|
class StickerSpanNode extends SpanNode {
 | 
						|
  final String placeholder;
 | 
						|
  final Color backgroundColor;
 | 
						|
  final Color foregroundColor;
 | 
						|
  final bool isEnlarged;
 | 
						|
  final String baseUrl;
 | 
						|
 | 
						|
  StickerSpanNode({
 | 
						|
    required this.placeholder,
 | 
						|
    required this.backgroundColor,
 | 
						|
    required this.foregroundColor,
 | 
						|
    required this.isEnlarged,
 | 
						|
    required this.baseUrl,
 | 
						|
  });
 | 
						|
 | 
						|
  @override
 | 
						|
  InlineSpan build() {
 | 
						|
    final size = isEnlarged ? 96.0 : 24.0;
 | 
						|
    final stickerUri = '$baseUrl/sphere/stickers/lookup/$placeholder/open';
 | 
						|
    return WidgetSpan(
 | 
						|
      alignment: PlaceholderAlignment.middle,
 | 
						|
      child: ClipRRect(
 | 
						|
        borderRadius: const BorderRadius.all(Radius.circular(8)),
 | 
						|
        child: Container(
 | 
						|
          decoration: BoxDecoration(
 | 
						|
            color: backgroundColor.withOpacity(0.1),
 | 
						|
            borderRadius: const BorderRadius.all(Radius.circular(8)),
 | 
						|
          ),
 | 
						|
          child: UniversalImage(
 | 
						|
            uri: stickerUri,
 | 
						|
            width: size,
 | 
						|
            height: size,
 | 
						|
            fit: BoxFit.contain,
 | 
						|
            noCacheOptimization: true,
 | 
						|
          ),
 | 
						|
        ),
 | 
						|
      ),
 | 
						|
    );
 | 
						|
  }
 | 
						|
}
 |