761 lines
		
	
	
		
			26 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			761 lines
		
	
	
		
			26 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
import 'dart:io';
 | 
						|
 | 
						|
import 'package:collection/collection.dart';
 | 
						|
import 'package:easy_localization/easy_localization.dart' hide TextDirection;
 | 
						|
import 'package:flutter/foundation.dart';
 | 
						|
import 'package:flutter/material.dart';
 | 
						|
import 'package:flutter/services.dart';
 | 
						|
import 'package:flutter_hooks/flutter_hooks.dart';
 | 
						|
import 'package:gap/gap.dart';
 | 
						|
import 'package:go_router/go_router.dart';
 | 
						|
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
						|
import 'package:island/models/post.dart';
 | 
						|
import 'package:island/pods/config.dart';
 | 
						|
import 'package:island/pods/network.dart';
 | 
						|
import 'package:island/pods/translate.dart';
 | 
						|
import 'package:island/pods/userinfo.dart';
 | 
						|
import 'package:island/screens/posts/compose.dart';
 | 
						|
import 'package:island/widgets/alert.dart';
 | 
						|
import 'package:island/widgets/content/markdown.dart';
 | 
						|
import 'package:island/widgets/content/image.dart';
 | 
						|
import 'package:island/widgets/post/post_item_screenshot.dart';
 | 
						|
import 'package:island/widgets/post/post_award_sheet.dart';
 | 
						|
import 'package:island/widgets/post/post_pin_sheet.dart';
 | 
						|
import 'package:island/widgets/post/post_shared.dart';
 | 
						|
import 'package:island/widgets/post/embed_view_renderer.dart';
 | 
						|
import 'package:island/widgets/post/post_reaction_sheet.dart';
 | 
						|
import 'package:island/widgets/safety/abuse_report_helper.dart';
 | 
						|
import 'package:island/widgets/share/share_sheet.dart';
 | 
						|
import 'package:island/widgets/post/compose_sheet.dart';
 | 
						|
import 'package:material_symbols_icons/symbols.dart';
 | 
						|
import 'package:path_provider/path_provider.dart' show getTemporaryDirectory;
 | 
						|
import 'package:screenshot/screenshot.dart';
 | 
						|
import 'package:share_plus/share_plus.dart';
 | 
						|
import 'package:styled_widget/styled_widget.dart';
 | 
						|
import 'package:super_context_menu/super_context_menu.dart';
 | 
						|
 | 
						|
const kAvailableStickers = {
 | 
						|
  'angry',
 | 
						|
  'clap',
 | 
						|
  'confuse',
 | 
						|
  'pray',
 | 
						|
  'thumb_up',
 | 
						|
  'party',
 | 
						|
};
 | 
						|
 | 
						|
bool _getReactionImageAvailable(String symbol) {
 | 
						|
  return kAvailableStickers.contains(symbol);
 | 
						|
}
 | 
						|
 | 
						|
Widget _buildReactionIcon(String symbol, double size, {double iconSize = 24}) {
 | 
						|
  if (_getReactionImageAvailable(symbol)) {
 | 
						|
    return Image.asset(
 | 
						|
      'assets/images/stickers/$symbol.png',
 | 
						|
      width: size,
 | 
						|
      height: size,
 | 
						|
      fit: BoxFit.contain,
 | 
						|
      alignment: Alignment.bottomCenter,
 | 
						|
    );
 | 
						|
  } else {
 | 
						|
    return Text(
 | 
						|
      kReactionTemplates[symbol]?.icon ?? '',
 | 
						|
      style: TextStyle(fontSize: iconSize),
 | 
						|
    );
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
class PostActionableItem extends HookConsumerWidget {
 | 
						|
  final SnPost item;
 | 
						|
  final EdgeInsets? padding;
 | 
						|
  final bool isFullPost;
 | 
						|
  final bool isShowReference;
 | 
						|
  final bool isEmbedReply;
 | 
						|
  final bool isEmbedOpenable;
 | 
						|
  final bool isCompact;
 | 
						|
  final double? borderRadius;
 | 
						|
  final VoidCallback? onRefresh;
 | 
						|
  final Function(SnPost)? onUpdate;
 | 
						|
  final VoidCallback? onOpen;
 | 
						|
  const PostActionableItem({
 | 
						|
    super.key,
 | 
						|
    required this.item,
 | 
						|
    this.padding,
 | 
						|
    this.isFullPost = false,
 | 
						|
    this.isShowReference = true,
 | 
						|
    this.isEmbedReply = true,
 | 
						|
    this.isEmbedOpenable = false,
 | 
						|
    this.isCompact = false,
 | 
						|
    this.borderRadius,
 | 
						|
    this.onRefresh,
 | 
						|
    this.onUpdate,
 | 
						|
    this.onOpen,
 | 
						|
  });
 | 
						|
 | 
						|
  @override
 | 
						|
  Widget build(BuildContext context, WidgetRef ref) {
 | 
						|
    final user = ref.watch(userInfoProvider);
 | 
						|
    final isAuthor = useMemoized(
 | 
						|
      () => user.value != null && user.value?.id == item.publisher.accountId,
 | 
						|
      [user],
 | 
						|
    );
 | 
						|
 | 
						|
    final config = ref.watch(appSettingsNotifierProvider);
 | 
						|
 | 
						|
    final widgetItem = InkWell(
 | 
						|
      borderRadius:
 | 
						|
          borderRadius != null
 | 
						|
              ? BorderRadius.all(Radius.circular(borderRadius!))
 | 
						|
              : null,
 | 
						|
      child: PostItem(
 | 
						|
        key: key,
 | 
						|
        item: item,
 | 
						|
        padding: padding,
 | 
						|
        isFullPost: isFullPost,
 | 
						|
        isShowReference: isShowReference,
 | 
						|
        isEmbedReply: isEmbedReply,
 | 
						|
        isEmbedOpenable: isEmbedOpenable,
 | 
						|
        isTextSelectable: false,
 | 
						|
        isCompact: isCompact,
 | 
						|
        onRefresh: onRefresh,
 | 
						|
        onUpdate: onUpdate,
 | 
						|
        onOpen: onOpen,
 | 
						|
      ),
 | 
						|
      onTap: () {
 | 
						|
        onOpen?.call();
 | 
						|
        context.pushNamed('postDetail', pathParameters: {'id': item.id});
 | 
						|
      },
 | 
						|
    );
 | 
						|
 | 
						|
    final screenshotController = useMemoized(() => ScreenshotController(), []);
 | 
						|
 | 
						|
    void shareAsScreenshot() async {
 | 
						|
      if (kIsWeb) return;
 | 
						|
      showLoadingModal(context);
 | 
						|
      await screenshotController
 | 
						|
          .captureFromWidget(
 | 
						|
            ProviderScope(
 | 
						|
              overrides: [
 | 
						|
                sharedPreferencesProvider.overrideWithValue(
 | 
						|
                  ref.watch(sharedPreferencesProvider),
 | 
						|
                ),
 | 
						|
              ],
 | 
						|
              child: Directionality(
 | 
						|
                textDirection: TextDirection.ltr,
 | 
						|
                child: SizedBox(
 | 
						|
                  width: 520,
 | 
						|
                  child: PostItemScreenshot(item: item, isFullPost: isFullPost),
 | 
						|
                ),
 | 
						|
              ),
 | 
						|
            ),
 | 
						|
            context: context,
 | 
						|
            pixelRatio: MediaQuery.of(context).devicePixelRatio,
 | 
						|
            delay: const Duration(seconds: 1),
 | 
						|
          )
 | 
						|
          .then((Uint8List? image) async {
 | 
						|
            if (image == null) return;
 | 
						|
            final directory = await getTemporaryDirectory();
 | 
						|
            final imagePath =
 | 
						|
                await File('${directory.path}/image.png').create();
 | 
						|
            await imagePath.writeAsBytes(image);
 | 
						|
 | 
						|
            if (!context.mounted) return;
 | 
						|
            hideLoadingModal(context);
 | 
						|
            final box = context.findRenderObject() as RenderBox?;
 | 
						|
            await Share.shareXFiles([
 | 
						|
              XFile(imagePath.path),
 | 
						|
            ], sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size);
 | 
						|
          })
 | 
						|
          .catchError((err) {
 | 
						|
            if (context.mounted) hideLoadingModal(context);
 | 
						|
            showErrorAlert(err);
 | 
						|
          });
 | 
						|
    }
 | 
						|
 | 
						|
    return ContextMenuWidget(
 | 
						|
      menuProvider: (_) {
 | 
						|
        return Menu(
 | 
						|
          children: [
 | 
						|
            if (isAuthor)
 | 
						|
              MenuAction(
 | 
						|
                title: 'edit'.tr(),
 | 
						|
                image: MenuImage.icon(Symbols.edit),
 | 
						|
                callback: () async {
 | 
						|
                  final result = await PostComposeSheet.show(
 | 
						|
                    context,
 | 
						|
                    originalPost: item,
 | 
						|
                  );
 | 
						|
                  if (result != null) {
 | 
						|
                    onRefresh?.call();
 | 
						|
                  }
 | 
						|
                },
 | 
						|
              ),
 | 
						|
            if (isAuthor)
 | 
						|
              MenuAction(
 | 
						|
                title: 'delete'.tr(),
 | 
						|
                image: MenuImage.icon(Symbols.delete),
 | 
						|
                callback: () {
 | 
						|
                  showConfirmAlert(
 | 
						|
                    'deletePostHint'.tr(),
 | 
						|
                    'deletePost'.tr(),
 | 
						|
                  ).then((confirm) {
 | 
						|
                    if (confirm) {
 | 
						|
                      final client = ref.watch(apiClientProvider);
 | 
						|
                      client
 | 
						|
                          .delete('/sphere/posts/${item.id}')
 | 
						|
                          .catchError((err) {
 | 
						|
                            showErrorAlert(err);
 | 
						|
                            return err;
 | 
						|
                          })
 | 
						|
                          .then((_) {
 | 
						|
                            onRefresh?.call();
 | 
						|
                          });
 | 
						|
                    }
 | 
						|
                  });
 | 
						|
                },
 | 
						|
              ),
 | 
						|
            if (isAuthor) MenuSeparator(),
 | 
						|
            MenuAction(
 | 
						|
              title: 'copyLink'.tr(),
 | 
						|
              image: MenuImage.icon(Symbols.link),
 | 
						|
              callback: () {
 | 
						|
                Clipboard.setData(
 | 
						|
                  ClipboardData(text: 'https://solian.app/posts/${item.id}'),
 | 
						|
                );
 | 
						|
              },
 | 
						|
            ),
 | 
						|
            MenuAction(
 | 
						|
              title: 'reply'.tr(),
 | 
						|
              image: MenuImage.icon(Symbols.reply),
 | 
						|
              callback: () async {
 | 
						|
                final result = await PostComposeSheet.show(
 | 
						|
                  context,
 | 
						|
                  initialState: PostComposeInitialState(replyingTo: item),
 | 
						|
                );
 | 
						|
                if (result != null) {
 | 
						|
                  onRefresh?.call();
 | 
						|
                }
 | 
						|
              },
 | 
						|
            ),
 | 
						|
            MenuAction(
 | 
						|
              title: 'forward'.tr(),
 | 
						|
              image: MenuImage.icon(Symbols.forward),
 | 
						|
              callback: () async {
 | 
						|
                final result = await PostComposeSheet.show(
 | 
						|
                  context,
 | 
						|
                  initialState: PostComposeInitialState(forwardingTo: item),
 | 
						|
                );
 | 
						|
                if (result != null) {
 | 
						|
                  onRefresh?.call();
 | 
						|
                }
 | 
						|
              },
 | 
						|
            ),
 | 
						|
            if (isAuthor && item.pinMode == null)
 | 
						|
              MenuAction(
 | 
						|
                title: 'pinPost'.tr(),
 | 
						|
                image: MenuImage.icon(Symbols.keep),
 | 
						|
                callback: () {
 | 
						|
                  showModalBottomSheet(
 | 
						|
                    context: context,
 | 
						|
                    isScrollControlled: true,
 | 
						|
                    builder: (context) => PostPinSheet(post: item),
 | 
						|
                  ).then((value) {
 | 
						|
                    if (value is int) {
 | 
						|
                      onUpdate?.call(item.copyWith(pinMode: value));
 | 
						|
                    }
 | 
						|
                  });
 | 
						|
                },
 | 
						|
              )
 | 
						|
            else if (isAuthor && item.pinMode != null)
 | 
						|
              MenuAction(
 | 
						|
                title: 'unpinPost'.tr(),
 | 
						|
                image: MenuImage.icon(Symbols.keep_off),
 | 
						|
                callback: () {
 | 
						|
                  showConfirmAlert('unpinPostHint'.tr(), 'unpinPost'.tr()).then(
 | 
						|
                    (confirm) async {
 | 
						|
                      if (confirm) {
 | 
						|
                        final client = ref.watch(apiClientProvider);
 | 
						|
                        try {
 | 
						|
                          if (context.mounted) showLoadingModal(context);
 | 
						|
                          await client.delete('/sphere/posts/${item.id}/pin');
 | 
						|
                          onUpdate?.call(item.copyWith(pinMode: null));
 | 
						|
                        } catch (err) {
 | 
						|
                          showErrorAlert(err);
 | 
						|
                        } finally {
 | 
						|
                          if (context.mounted) hideLoadingModal(context);
 | 
						|
                        }
 | 
						|
                      }
 | 
						|
                    },
 | 
						|
                  );
 | 
						|
                },
 | 
						|
              ),
 | 
						|
            MenuAction(
 | 
						|
              title: 'award'.tr(),
 | 
						|
              image: MenuImage.icon(Symbols.star),
 | 
						|
              callback: () {
 | 
						|
                showModalBottomSheet(
 | 
						|
                  context: context,
 | 
						|
                  isScrollControlled: true,
 | 
						|
                  useRootNavigator: true,
 | 
						|
                  builder: (context) => PostAwardSheet(post: item),
 | 
						|
                );
 | 
						|
              },
 | 
						|
            ),
 | 
						|
            MenuSeparator(),
 | 
						|
            MenuAction(
 | 
						|
              title: 'share'.tr(),
 | 
						|
              image: MenuImage.icon(Symbols.share),
 | 
						|
              callback: () {
 | 
						|
                showShareSheetLink(
 | 
						|
                  context: context,
 | 
						|
                  link: 'https://solian.app/posts/${item.id}',
 | 
						|
                  title: 'sharePost'.tr(),
 | 
						|
                  toSystem: true,
 | 
						|
                );
 | 
						|
              },
 | 
						|
            ),
 | 
						|
            if (!kIsWeb)
 | 
						|
              MenuAction(
 | 
						|
                title: 'sharePostPhoto'.tr(),
 | 
						|
                image: MenuImage.icon(Symbols.share_reviews),
 | 
						|
                callback: () {
 | 
						|
                  shareAsScreenshot();
 | 
						|
                },
 | 
						|
              ),
 | 
						|
            MenuSeparator(),
 | 
						|
            MenuAction(
 | 
						|
              title: 'abuseReport'.tr(),
 | 
						|
              image: MenuImage.icon(Symbols.flag),
 | 
						|
              callback: () {
 | 
						|
                showAbuseReportSheet(
 | 
						|
                  context,
 | 
						|
                  resourceIdentifier: 'post/${item.id}',
 | 
						|
                );
 | 
						|
              },
 | 
						|
            ),
 | 
						|
          ],
 | 
						|
        );
 | 
						|
      },
 | 
						|
      child: Material(
 | 
						|
        color:
 | 
						|
            config.cardTransparency < 1
 | 
						|
                ? Colors.transparent
 | 
						|
                : Theme.of(context).cardTheme.color,
 | 
						|
        borderRadius:
 | 
						|
            borderRadius != null
 | 
						|
                ? BorderRadius.all(Radius.circular(borderRadius!))
 | 
						|
                : null,
 | 
						|
        child: widgetItem,
 | 
						|
      ),
 | 
						|
    );
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
class PostItem extends HookConsumerWidget {
 | 
						|
  final SnPost item;
 | 
						|
  final EdgeInsets? padding;
 | 
						|
  final bool isFullPost;
 | 
						|
  final bool isShowReference;
 | 
						|
  final bool isEmbedReply;
 | 
						|
  final bool isEmbedOpenable;
 | 
						|
  final bool isTextSelectable;
 | 
						|
  final bool isTranslatable;
 | 
						|
  final bool isCompact;
 | 
						|
  final VoidCallback? onRefresh;
 | 
						|
  final Function(SnPost)? onUpdate;
 | 
						|
  final VoidCallback? onOpen;
 | 
						|
  const PostItem({
 | 
						|
    super.key,
 | 
						|
    required this.item,
 | 
						|
    this.padding,
 | 
						|
    this.isFullPost = false,
 | 
						|
    this.isShowReference = true,
 | 
						|
    this.isEmbedReply = true,
 | 
						|
    this.isEmbedOpenable = false,
 | 
						|
    this.isTextSelectable = true,
 | 
						|
    this.isTranslatable = true,
 | 
						|
    this.isCompact = false,
 | 
						|
    this.onRefresh,
 | 
						|
    this.onUpdate,
 | 
						|
    this.onOpen,
 | 
						|
  });
 | 
						|
 | 
						|
  @override
 | 
						|
  Widget build(BuildContext context, WidgetRef ref) {
 | 
						|
    final renderingPadding =
 | 
						|
        padding ?? const EdgeInsets.symmetric(horizontal: 8, vertical: 8);
 | 
						|
 | 
						|
    final reacting = useState(false);
 | 
						|
 | 
						|
    Future<void> reactPost(String symbol, int attitude) async {
 | 
						|
      final client = ref.watch(apiClientProvider);
 | 
						|
      reacting.value = true;
 | 
						|
      await client
 | 
						|
          .post(
 | 
						|
            '/sphere/posts/${item.id}/reactions',
 | 
						|
            data: {'symbol': symbol, 'attitude': attitude},
 | 
						|
          )
 | 
						|
          .catchError((err) {
 | 
						|
            showErrorAlert(err);
 | 
						|
            return err;
 | 
						|
          })
 | 
						|
          .then((resp) {
 | 
						|
            final isRemoving = resp.statusCode == 204;
 | 
						|
            final delta = isRemoving ? -1 : 1;
 | 
						|
            final reactionsCount = Map<String, int>.from(item.reactionsCount);
 | 
						|
            reactionsCount[symbol] = (reactionsCount[symbol] ?? 0) + delta;
 | 
						|
            final reactionsMade = Map<String, bool>.from(item.reactionsMade);
 | 
						|
            reactionsMade[symbol] = delta == 1 ? true : false;
 | 
						|
            onUpdate?.call(
 | 
						|
              item.copyWith(
 | 
						|
                reactionsCount: reactionsCount,
 | 
						|
                reactionsMade: reactionsMade,
 | 
						|
              ),
 | 
						|
            );
 | 
						|
            HapticFeedback.heavyImpact();
 | 
						|
          });
 | 
						|
      reacting.value = false;
 | 
						|
    }
 | 
						|
 | 
						|
    final mostReaction =
 | 
						|
        item.reactionsCount.isEmpty
 | 
						|
            ? null
 | 
						|
            : item.reactionsCount.entries
 | 
						|
                .sortedBy((e) => e.value)
 | 
						|
                .map((e) => e.key)
 | 
						|
                .last;
 | 
						|
 | 
						|
    final postLanguage =
 | 
						|
        item.content != null && isTranslatable
 | 
						|
            ? ref.watch(detectStringLanguageProvider(item.content!))
 | 
						|
            : null;
 | 
						|
 | 
						|
    final currentLanguage = isTranslatable ? context.locale.toString() : null;
 | 
						|
    final translatableLanguage =
 | 
						|
        postLanguage != null && isTranslatable
 | 
						|
            ? postLanguage.substring(0, 2) != currentLanguage!.substring(0, 2)
 | 
						|
            : false;
 | 
						|
 | 
						|
    final translating = useState(false);
 | 
						|
    final translatedText = useState<String?>(null);
 | 
						|
 | 
						|
    Future<void> translate() async {
 | 
						|
      if (!isTranslatable) return;
 | 
						|
      if (translatedText.value != null) {
 | 
						|
        translatedText.value = null;
 | 
						|
        return;
 | 
						|
      }
 | 
						|
 | 
						|
      if (translating.value) return;
 | 
						|
      if (item.content == null) return;
 | 
						|
      translating.value = true;
 | 
						|
      try {
 | 
						|
        final text = await ref.watch(
 | 
						|
          translateStringProvider(
 | 
						|
            TranslateQuery(
 | 
						|
              text: item.content!,
 | 
						|
              lang: currentLanguage!.substring(0, 2),
 | 
						|
            ),
 | 
						|
          ).future,
 | 
						|
        );
 | 
						|
        translatedText.value = text;
 | 
						|
      } catch (err) {
 | 
						|
        showErrorAlert(err);
 | 
						|
      } finally {
 | 
						|
        translating.value = false;
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    final translatedWidget =
 | 
						|
        (translatedText.value?.isNotEmpty ?? false)
 | 
						|
            ? Column(
 | 
						|
              crossAxisAlignment: CrossAxisAlignment.stretch,
 | 
						|
              children: [
 | 
						|
                Row(
 | 
						|
                  children: [
 | 
						|
                    const Expanded(child: Divider()),
 | 
						|
                    const Gap(8),
 | 
						|
                    const Text('translated').tr().fontSize(11).opacity(0.75),
 | 
						|
                  ],
 | 
						|
                ),
 | 
						|
                MarkdownTextContent(
 | 
						|
                  content: translatedText.value!,
 | 
						|
                  isSelectable: isTextSelectable,
 | 
						|
                  attachments: item.attachments,
 | 
						|
                ),
 | 
						|
              ],
 | 
						|
            )
 | 
						|
            : null;
 | 
						|
 | 
						|
    final translatableWidget =
 | 
						|
        (isTranslatable && translatableLanguage)
 | 
						|
            ? Align(
 | 
						|
              alignment: Alignment.centerLeft,
 | 
						|
              child: TextButton.icon(
 | 
						|
                onPressed: translating.value ? null : translate,
 | 
						|
                style: ButtonStyle(
 | 
						|
                  padding: const WidgetStatePropertyAll(
 | 
						|
                    EdgeInsets.symmetric(horizontal: 2),
 | 
						|
                  ),
 | 
						|
                  visualDensity: const VisualDensity(
 | 
						|
                    horizontal: 0,
 | 
						|
                    vertical: -4,
 | 
						|
                  ),
 | 
						|
                  foregroundColor: WidgetStatePropertyAll(
 | 
						|
                    translatedText.value == null ? null : Colors.grey,
 | 
						|
                  ),
 | 
						|
                ),
 | 
						|
                icon: const Icon(Symbols.translate),
 | 
						|
                label:
 | 
						|
                    translatedText.value != null
 | 
						|
                        ? const Text('translated').tr()
 | 
						|
                        : translating.value
 | 
						|
                        ? const Text('translating').tr()
 | 
						|
                        : const Text('translate').tr(),
 | 
						|
              ),
 | 
						|
            )
 | 
						|
            : null;
 | 
						|
 | 
						|
    final translationSection = Column(
 | 
						|
      crossAxisAlignment: CrossAxisAlignment.stretch,
 | 
						|
      children: [
 | 
						|
        if (translatedWidget != null) translatedWidget,
 | 
						|
        if (translatableWidget != null) translatableWidget,
 | 
						|
      ],
 | 
						|
    );
 | 
						|
 | 
						|
    return Column(
 | 
						|
      mainAxisSize: MainAxisSize.min,
 | 
						|
      crossAxisAlignment: CrossAxisAlignment.start,
 | 
						|
      children: [
 | 
						|
        Gap(renderingPadding.vertical),
 | 
						|
        PostHeader(
 | 
						|
          item: item,
 | 
						|
          isFullPost: isFullPost,
 | 
						|
          isCompact: isCompact,
 | 
						|
          renderingPadding: renderingPadding,
 | 
						|
          trailing:
 | 
						|
              isCompact
 | 
						|
                  ? null
 | 
						|
                  : SizedBox(
 | 
						|
                    width: 36,
 | 
						|
                    height: 36,
 | 
						|
                    child: IconButton(
 | 
						|
                      icon:
 | 
						|
                          mostReaction == null
 | 
						|
                              ? const Icon(Symbols.add_reaction)
 | 
						|
                              : Badge(
 | 
						|
                                label: Center(
 | 
						|
                                  child: Text(
 | 
						|
                                    'x${item.reactionsCount[mostReaction]}',
 | 
						|
                                    style: const TextStyle(fontSize: 11),
 | 
						|
                                    textAlign: TextAlign.center,
 | 
						|
                                  ),
 | 
						|
                                ),
 | 
						|
                                offset: const Offset(4, 20),
 | 
						|
                                backgroundColor: Theme.of(
 | 
						|
                                  context,
 | 
						|
                                ).colorScheme.primary.withOpacity(0.75),
 | 
						|
                                textColor:
 | 
						|
                                    Theme.of(context).colorScheme.onPrimary,
 | 
						|
                                child:
 | 
						|
                                    mostReaction.contains('+')
 | 
						|
                                        ? HookConsumer(
 | 
						|
                                          builder: (context, ref, child) {
 | 
						|
                                            final baseUrl = ref.watch(
 | 
						|
                                              serverUrlProvider,
 | 
						|
                                            );
 | 
						|
                                            final stickerUri =
 | 
						|
                                                '$baseUrl/sphere/stickers/lookup/$mostReaction/open';
 | 
						|
                                            return SizedBox(
 | 
						|
                                              width: 32,
 | 
						|
                                              height: 32,
 | 
						|
                                              child:
 | 
						|
                                                  UniversalImage(
 | 
						|
                                                    uri: stickerUri,
 | 
						|
                                                    width: 28,
 | 
						|
                                                    height: 28,
 | 
						|
                                                    fit: BoxFit.contain,
 | 
						|
                                                  ).center(),
 | 
						|
                                            );
 | 
						|
                                          },
 | 
						|
                                        )
 | 
						|
                                        : _buildReactionIcon(
 | 
						|
                                          mostReaction,
 | 
						|
                                          32,
 | 
						|
                                        ).padding(
 | 
						|
                                          bottom:
 | 
						|
                                              _getReactionImageAvailable(
 | 
						|
                                                    mostReaction,
 | 
						|
                                                  )
 | 
						|
                                                  ? 2
 | 
						|
                                                  : 0,
 | 
						|
                                        ),
 | 
						|
                              ),
 | 
						|
                      style: ButtonStyle(
 | 
						|
                        backgroundColor: WidgetStatePropertyAll(
 | 
						|
                          (item.reactionsMade[mostReaction] ?? false)
 | 
						|
                              ? Theme.of(
 | 
						|
                                context,
 | 
						|
                              ).colorScheme.primary.withOpacity(0.5)
 | 
						|
                              : null,
 | 
						|
                        ),
 | 
						|
                      ),
 | 
						|
                      onPressed: () {
 | 
						|
                        showModalBottomSheet(
 | 
						|
                          context: context,
 | 
						|
                          useRootNavigator: true,
 | 
						|
                          builder: (BuildContext context) {
 | 
						|
                            return PostReactionSheet(
 | 
						|
                              reactionsCount: item.reactionsCount,
 | 
						|
                              reactionsMade: item.reactionsMade,
 | 
						|
                              onReact: (symbol, attitude) {
 | 
						|
                                reactPost(symbol, attitude);
 | 
						|
                              },
 | 
						|
                              postId: item.id,
 | 
						|
                            );
 | 
						|
                          },
 | 
						|
                        );
 | 
						|
                      },
 | 
						|
                      padding: EdgeInsets.zero,
 | 
						|
                      visualDensity: const VisualDensity(
 | 
						|
                        horizontal: -3,
 | 
						|
                        vertical: -3,
 | 
						|
                      ),
 | 
						|
                    ),
 | 
						|
                  ),
 | 
						|
        ),
 | 
						|
        PostBody(
 | 
						|
          item: item,
 | 
						|
          isFullPost: isFullPost,
 | 
						|
          isTextSelectable: isTextSelectable,
 | 
						|
          translationSection: translationSection,
 | 
						|
          renderingPadding: renderingPadding,
 | 
						|
        ),
 | 
						|
        if (item.embedView != null)
 | 
						|
          EmbedViewRenderer(
 | 
						|
            embedView: item.embedView!,
 | 
						|
            maxHeight: 400,
 | 
						|
            borderRadius: BorderRadius.circular(12),
 | 
						|
          ).padding(horizontal: renderingPadding.horizontal, vertical: 8),
 | 
						|
        if (isShowReference)
 | 
						|
          ReferencedPostWidget(item: item, renderingPadding: renderingPadding),
 | 
						|
        if (item.repliesCount > 0 && isEmbedReply)
 | 
						|
          PostReplyPreview(
 | 
						|
            parent: item,
 | 
						|
            isOpenable: isEmbedOpenable,
 | 
						|
            onOpen: onOpen,
 | 
						|
          ).padding(horizontal: renderingPadding.horizontal, top: 8),
 | 
						|
        Gap(renderingPadding.vertical),
 | 
						|
      ],
 | 
						|
    );
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
class PostReactionList extends HookConsumerWidget {
 | 
						|
  final String parentId;
 | 
						|
  final Map<String, int> reactions;
 | 
						|
  final Map<String, bool> reactionsMade;
 | 
						|
  final Function(String symbol, int attitude, int delta)? onReact;
 | 
						|
  final EdgeInsets? padding;
 | 
						|
  const PostReactionList({
 | 
						|
    super.key,
 | 
						|
    required this.parentId,
 | 
						|
    required this.reactions,
 | 
						|
    required this.reactionsMade,
 | 
						|
    this.padding,
 | 
						|
    this.onReact,
 | 
						|
  });
 | 
						|
 | 
						|
  @override
 | 
						|
  Widget build(BuildContext context, WidgetRef ref) {
 | 
						|
    final submitting = useState(false);
 | 
						|
 | 
						|
    Future<void> reactPost(String symbol, int attitude) async {
 | 
						|
      final client = ref.watch(apiClientProvider);
 | 
						|
      submitting.value = true;
 | 
						|
      await client
 | 
						|
          .post(
 | 
						|
            '/sphere/posts/$parentId/reactions',
 | 
						|
            data: {'symbol': symbol, 'attitude': attitude},
 | 
						|
          )
 | 
						|
          .catchError((err) {
 | 
						|
            showErrorAlert(err);
 | 
						|
            return err;
 | 
						|
          })
 | 
						|
          .then((resp) {
 | 
						|
            var isRemoving = resp.statusCode == 204;
 | 
						|
            onReact?.call(symbol, attitude, isRemoving ? -1 : 1);
 | 
						|
            HapticFeedback.heavyImpact();
 | 
						|
          });
 | 
						|
      submitting.value = false;
 | 
						|
    }
 | 
						|
 | 
						|
    return SizedBox(
 | 
						|
      height: 40,
 | 
						|
      child: ListView(
 | 
						|
        scrollDirection: Axis.horizontal,
 | 
						|
        padding: padding ?? EdgeInsets.zero,
 | 
						|
        children: [
 | 
						|
          if (onReact != null)
 | 
						|
            Padding(
 | 
						|
              padding: const EdgeInsets.only(right: 8),
 | 
						|
              child: ActionChip(
 | 
						|
                avatar: const Icon(Symbols.add_reaction),
 | 
						|
                label: const Text('react').tr(),
 | 
						|
                visualDensity: const VisualDensity(
 | 
						|
                  horizontal: VisualDensity.minimumDensity,
 | 
						|
                  vertical: VisualDensity.minimumDensity,
 | 
						|
                ),
 | 
						|
                onPressed:
 | 
						|
                    submitting.value
 | 
						|
                        ? null
 | 
						|
                        : () {
 | 
						|
                          showModalBottomSheet(
 | 
						|
                            context: context,
 | 
						|
                            builder: (BuildContext context) {
 | 
						|
                              return PostReactionSheet(
 | 
						|
                                reactionsCount: reactions,
 | 
						|
                                reactionsMade: reactionsMade,
 | 
						|
                                onReact: (symbol, attitude) {
 | 
						|
                                  reactPost(symbol, attitude);
 | 
						|
                                },
 | 
						|
                                postId: parentId,
 | 
						|
                              );
 | 
						|
                            },
 | 
						|
                          );
 | 
						|
                        },
 | 
						|
              ),
 | 
						|
            ),
 | 
						|
          for (final symbol in reactions.keys)
 | 
						|
            Padding(
 | 
						|
              padding: const EdgeInsets.only(right: 8),
 | 
						|
              child: ActionChip(
 | 
						|
                avatar: _buildReactionIcon(symbol, 24),
 | 
						|
                label: Row(
 | 
						|
                  spacing: 4,
 | 
						|
                  children: [
 | 
						|
                    Text(symbol),
 | 
						|
                    Text('x${reactions[symbol]}').bold(),
 | 
						|
                  ],
 | 
						|
                ),
 | 
						|
                onPressed:
 | 
						|
                    submitting.value
 | 
						|
                        ? null
 | 
						|
                        : () {
 | 
						|
                          reactPost(
 | 
						|
                            symbol,
 | 
						|
                            kReactionTemplates[symbol]?.attitude ?? 0,
 | 
						|
                          );
 | 
						|
                        },
 | 
						|
                visualDensity: const VisualDensity(
 | 
						|
                  horizontal: VisualDensity.minimumDensity,
 | 
						|
                  vertical: VisualDensity.minimumDensity,
 | 
						|
                ),
 | 
						|
              ),
 | 
						|
            ),
 | 
						|
        ],
 | 
						|
      ),
 | 
						|
    );
 | 
						|
  }
 | 
						|
}
 |