mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
688 lines
23 KiB
Dart
688 lines
23 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/gestures.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
|
|
|
import '../../shared/theme/theme.dart';
|
|
import '../../shared/widgets/avatar_image.dart';
|
|
import '../../shared/widgets/modal_presentation.dart';
|
|
import '../../shared/widgets/native_message_presentation.dart';
|
|
import '../../shared/relay/media_auth.dart';
|
|
import '../../shared/custom_emoji/custom_emoji_render.dart';
|
|
import '../../shared/emoji/emoji_burst.dart';
|
|
import '../../shared/emoji/emoji_avatar.dart';
|
|
import '../../shared/emoji/emoji_data_provider.dart';
|
|
import '../../shared/emoji/native_emoji_glyph.dart';
|
|
import '../../shared/emoji/positive_emoji.dart';
|
|
import '../../shared/profile/user_cache_provider.dart';
|
|
import '../../shared/profile/user_profile.dart';
|
|
import 'channel_identity_names_provider.dart';
|
|
import 'channel_management_provider.dart';
|
|
import 'emoji_picker.dart';
|
|
import 'recent_emoji_provider.dart';
|
|
import 'message_long_press_region.dart';
|
|
import 'timeline_message.dart';
|
|
|
|
/// Pill geometry, ported from desktop's `REACTION_PILL_BASE_CLASSES` in
|
|
/// `desktop/src/features/messages/ui/MessageReactions.tsx`: a fully-rounded
|
|
/// 28px pill with a fixed minimum width so a row of single-count reactions
|
|
/// doesn't look ragged.
|
|
const _pillHeight = 28.0;
|
|
const _pillMinWidth = 48.0;
|
|
const _pillGap = 6.0;
|
|
|
|
/// The `+` pill is narrower than a reaction pill — desktop's `w-10` vs `min-w-12`.
|
|
const _addPillWidth = 40.0;
|
|
|
|
/// Glyph size inside a pill. Desktop uses 12px in a 28px pill; mobile steps that
|
|
/// up because the app's base type is 15sp and a 12px glyph reads as a smudge at
|
|
/// phone viewing distance.
|
|
const _pillGlyphSize = 16.0;
|
|
|
|
/// Toggle a reaction on [message]. If the current user already reacted with
|
|
/// [emoji], removes the reaction; otherwise adds it.
|
|
///
|
|
/// Used by channel detail, thread detail, and system message rows to avoid
|
|
/// duplicating the toggle wiring.
|
|
void toggleReaction(WidgetRef ref, TimelineMessage message, String emoji) {
|
|
final actions = ref.read(channelActionsProvider);
|
|
final reaction = message.reactions.firstWhere((r) => r.emoji == emoji);
|
|
if (reaction.reactedByCurrentUser && reaction.currentUserReactionId != null) {
|
|
actions.removeReaction(reaction.currentUserReactionId!, emoji);
|
|
} else {
|
|
// Only adding counts as a use — removing a reaction shouldn't promote the
|
|
// emoji in the frequently-used ranking.
|
|
ref.read(recentEmojiProvider.notifier).record(emoji);
|
|
actions.addReaction(message.id, emoji);
|
|
}
|
|
}
|
|
|
|
/// Arm a burst for a reaction that is about to be added from somewhere the pill
|
|
/// doesn't exist yet — the quick-reaction row or the emoji picker.
|
|
///
|
|
/// Pill taps don't go through here: they burst immediately, because the pill is
|
|
/// already on screen. Adding a reaction you already have is a no-op on the
|
|
/// relay, so it shouldn't celebrate either.
|
|
void armReactionBurst(WidgetRef ref, TimelineMessage message, String emoji) {
|
|
final alreadyReacted = message.reactions.any(
|
|
(reaction) => reaction.emoji == emoji && reaction.reactedByCurrentUser,
|
|
);
|
|
if (alreadyReacted) return;
|
|
ref.read(pendingReactionBurstProvider.notifier).arm(message.id, emoji);
|
|
}
|
|
|
|
/// Open the emoji picker and add whatever the user chooses as a reaction to
|
|
/// [message].
|
|
///
|
|
/// Shared by the `+` pill in the channel timeline and in the thread view so the
|
|
/// recency bookkeeping and burst arming can't drift apart between them.
|
|
void showAddReactionPicker({
|
|
required BuildContext context,
|
|
required WidgetRef ref,
|
|
required TimelineMessage message,
|
|
}) {
|
|
showEmojiPicker(
|
|
context: context,
|
|
onSelect: (emoji) {
|
|
ref.read(recentEmojiProvider.notifier).record(emoji);
|
|
armReactionBurst(ref, message, emoji);
|
|
ref.read(channelActionsProvider).addReaction(message.id, emoji);
|
|
},
|
|
);
|
|
}
|
|
|
|
class ReactionRow extends StatelessWidget {
|
|
/// The message these reactions belong to. Used to match a pending burst to
|
|
/// the right pill — the same emoji can be pending on a different message.
|
|
final String messageId;
|
|
|
|
/// Channel whose members define the reactor names' comparison context.
|
|
final String channelId;
|
|
|
|
final List<TimelineReaction> reactions;
|
|
final void Function(String emoji) onToggle;
|
|
|
|
/// Show a trailing `+` pill that opens the emoji picker, mirroring desktop's
|
|
/// `InlineReactionPicker`. Thread messages set this so reacting is always one
|
|
/// tap away; the channel timeline leaves it off to keep the list dense.
|
|
final bool showAddButton;
|
|
|
|
/// Invoked by the `+` pill. Required when [showAddButton] is set.
|
|
final VoidCallback? onAddReaction;
|
|
|
|
const ReactionRow({
|
|
super.key,
|
|
required this.messageId,
|
|
required this.channelId,
|
|
required this.reactions,
|
|
required this.onToggle,
|
|
this.showAddButton = false,
|
|
this.onAddReaction,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// With the add button on, an empty row still renders — that affordance is
|
|
// the point. Without it, an empty row would be dead space.
|
|
if (reactions.isEmpty && !showAddButton) return const SizedBox.shrink();
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.only(top: Grid.half),
|
|
child: Wrap(
|
|
spacing: Grid.half,
|
|
runSpacing: Grid.half,
|
|
children: [
|
|
for (final reaction in reactions)
|
|
_ReactionPill(
|
|
messageId: messageId,
|
|
reaction: reaction,
|
|
onTap: () => onToggle(reaction.emoji),
|
|
onLongPress: () => showReactionDetailSheet(
|
|
context: context,
|
|
channelId: channelId,
|
|
reactions: reactions,
|
|
initialEmoji: reaction.emoji,
|
|
),
|
|
),
|
|
if (showAddButton && onAddReaction != null)
|
|
_AddReactionPill(onTap: onAddReaction!),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Shared pill chrome so the reaction pills and the `+` pill can't drift.
|
|
class _PillSurface extends StatelessWidget {
|
|
final bool highlighted;
|
|
final double minWidth;
|
|
final Widget child;
|
|
|
|
const _PillSurface({
|
|
required this.highlighted,
|
|
required this.minWidth,
|
|
required this.child,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colors = context.colors;
|
|
return Container(
|
|
height: _pillHeight,
|
|
// No `alignment:` — it wraps the child in an expanding Align, which makes
|
|
// every pill fill the Wrap's full width and stack one per line.
|
|
// Children center themselves instead.
|
|
constraints: BoxConstraints(minWidth: minWidth),
|
|
padding: const EdgeInsets.symmetric(horizontal: Grid.xxs),
|
|
decoration: BoxDecoration(
|
|
color: highlighted
|
|
? colors.primary.withValues(alpha: 0.10)
|
|
: colors.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(_pillHeight / 2),
|
|
border: Border.all(
|
|
color: highlighted
|
|
? colors.primary.withValues(alpha: 0.40)
|
|
: colors.outlineVariant,
|
|
),
|
|
),
|
|
child: child,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ReactionPill extends HookConsumerWidget {
|
|
final String messageId;
|
|
final TimelineReaction reaction;
|
|
final VoidCallback onTap;
|
|
final VoidCallback onLongPress;
|
|
|
|
const _ReactionPill({
|
|
required this.messageId,
|
|
required this.reaction,
|
|
required this.onTap,
|
|
required this.onLongPress,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final reacted = reaction.reactedByCurrentUser;
|
|
|
|
// Fire a burst armed elsewhere (quick-reaction row, emoji picker) now that
|
|
// the pill it belongs to is finally on screen. Keyed on [reacted] rather
|
|
// than watching the provider: watching would rebuild every pill in the
|
|
// timeline each time a burst is armed or claimed, and the only moments that
|
|
// matter are this pill's first build and the frame our own reaction lands.
|
|
useEffect(() {
|
|
if (!reacted) return null;
|
|
final target = PendingReactionBurst(
|
|
messageId: messageId,
|
|
emoji: reaction.emoji,
|
|
);
|
|
if (ref.read(pendingReactionBurstProvider) != target) return null;
|
|
// Post-frame so the pill has been laid out and can report its centre.
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!context.mounted) return;
|
|
// The same message is mounted on two routes at once when a thread is
|
|
// pushed over its channel, and the channel's copy builds first. Without
|
|
// this the channel pill claims the burst and it only becomes visible
|
|
// after popping back — the burst has to play where the user is looking.
|
|
final route = ModalRoute.of(context);
|
|
if (route != null && !route.isCurrent) return;
|
|
if (!ref.read(pendingReactionBurstProvider.notifier).claim(target)) {
|
|
return;
|
|
}
|
|
burstEmojiFromContext(ref, context, reaction.emoji);
|
|
});
|
|
return null;
|
|
}, [reacted, messageId, reaction.emoji]);
|
|
|
|
return Semantics(
|
|
onLongPress: onLongPress,
|
|
child: RawGestureDetector(
|
|
gestures: {
|
|
LongPressGestureRecognizer:
|
|
GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
|
|
() => LongPressGestureRecognizer(
|
|
duration: defaultTargetPlatform == TargetPlatform.iOS
|
|
? iosMessageLongPressDuration
|
|
: null,
|
|
),
|
|
(recognizer) => recognizer.onLongPress = onLongPress,
|
|
),
|
|
},
|
|
child: GestureDetector(
|
|
key: ValueKey('reaction-pill-${reaction.emoji}'),
|
|
onTap: () {
|
|
// The pill is already here, so burst straight away rather than waiting
|
|
// for the relay echo — desktop does the same on a pill click.
|
|
if (!reacted && isPositiveEmojiParticle(reaction.emoji)) {
|
|
burstEmojiFromContext(ref, context, reaction.emoji);
|
|
}
|
|
onTap();
|
|
},
|
|
child: _PillSurface(
|
|
highlighted: reacted,
|
|
minWidth: _pillMinWidth,
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
// Centered so a short reaction sits mid-pill once the min width kicks
|
|
// in rather than hugging the left edge.
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
_ReactionEmoji(reaction: reaction, size: _pillGlyphSize),
|
|
const SizedBox(width: _pillGap),
|
|
// Desktop shows the count even at 1; hiding it made a fresh
|
|
// reaction jump in width the moment a second person joined.
|
|
Text(
|
|
'${reaction.count}',
|
|
style: reactionCountTextStyle.copyWith(
|
|
color: reacted
|
|
? context.colors.primary
|
|
: context.colors.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _AddReactionPill extends StatelessWidget {
|
|
final VoidCallback onTap;
|
|
|
|
const _AddReactionPill({required this.onTap});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Semantics(
|
|
button: true,
|
|
label: 'Add reaction',
|
|
child: GestureDetector(
|
|
key: const ValueKey('add-reaction-pill'),
|
|
onTap: onTap,
|
|
child: _PillSurface(
|
|
highlighted: false,
|
|
minWidth: _addPillWidth,
|
|
child: Center(
|
|
widthFactor: 1,
|
|
child: Icon(
|
|
LucideIcons.smilePlus,
|
|
size: _pillGlyphSize,
|
|
color: context.colors.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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 NativeEmojiGlyph(emoji: reaction.emoji, size: size);
|
|
}
|
|
final shortcode = reaction.emoji.substring(1, reaction.emoji.length - 1);
|
|
return CustomEmojiImage(shortcode: shortcode, url: emojiUrl, size: size);
|
|
}
|
|
}
|
|
|
|
Future<void> showReactionDetailSheet({
|
|
required BuildContext context,
|
|
required String channelId,
|
|
required List<TimelineReaction> reactions,
|
|
required String initialEmoji,
|
|
}) async {
|
|
if (reactions.isEmpty) return;
|
|
if (await _showNativeReactionDetails(
|
|
context,
|
|
channelId,
|
|
reactions,
|
|
initialEmoji,
|
|
)) {
|
|
return;
|
|
}
|
|
if (!context.mounted) return;
|
|
showBuzzModalBottomSheet<void>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
showDragHandle: true,
|
|
backgroundColor: context.colors.surfaceContainerHighest,
|
|
builder: (sheetContext) => _ReactionDetailSheet(
|
|
channelId: channelId,
|
|
reactions: reactions,
|
|
initialEmoji: initialEmoji,
|
|
),
|
|
);
|
|
}
|
|
|
|
class _ReactionDetailSheet extends HookConsumerWidget {
|
|
final String channelId;
|
|
final List<TimelineReaction> reactions;
|
|
final String initialEmoji;
|
|
|
|
const _ReactionDetailSheet({
|
|
required this.channelId,
|
|
required this.reactions,
|
|
required this.initialEmoji,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final selectedEmoji = useState(initialEmoji);
|
|
final userCache = ref.watch(userCacheProvider);
|
|
final identityNames = ref.watch(channelIdentityNamesProvider(channelId));
|
|
|
|
final currentReaction = reactions.firstWhere(
|
|
(r) => r.emoji == selectedEmoji.value,
|
|
orElse: () => reactions.first,
|
|
);
|
|
|
|
// Preload profiles for reactors.
|
|
useEffect(() {
|
|
if (currentReaction.userPubkeys.isNotEmpty) {
|
|
ref
|
|
.read(userCacheProvider.notifier)
|
|
.preload(currentReaction.userPubkeys);
|
|
}
|
|
return null;
|
|
}, [currentReaction.userPubkeys]);
|
|
|
|
return ConstrainedBox(
|
|
constraints: BoxConstraints(
|
|
maxHeight: MediaQuery.sizeOf(context).height * 0.5,
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// Emoji filter chips (if multiple reaction types).
|
|
if (reactions.length > 1)
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
|
|
child: SingleChildScrollView(
|
|
scrollDirection: Axis.horizontal,
|
|
child: Row(
|
|
children: [
|
|
for (final reaction in reactions)
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: Grid.half),
|
|
child: ChoiceChip(
|
|
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;
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
|
|
// Header: emoji + shortcode.
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: Grid.gutter,
|
|
vertical: Grid.half,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
_ReactionEmoji(reaction: currentReaction, size: 32),
|
|
const SizedBox(width: Grid.half),
|
|
Text(
|
|
// Resolved from the shared emoji-mart dataset, so this name
|
|
// matches desktop's `emojiDisplayName` for the whole set
|
|
// rather than the 28 glyphs a hardcoded map used to cover.
|
|
ref
|
|
.watch(emojiDatasetOrEmptyProvider)
|
|
.displayName(currentReaction.emoji),
|
|
style: context.textTheme.titleSmall?.copyWith(
|
|
color: context.colors.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
const Divider(height: 1),
|
|
|
|
// Reactor list.
|
|
Flexible(
|
|
child: ListView.builder(
|
|
shrinkWrap: true,
|
|
padding: EdgeInsets.only(
|
|
top: Grid.half,
|
|
bottom: MediaQuery.viewPaddingOf(context).bottom + Grid.half,
|
|
),
|
|
itemCount: currentReaction.userPubkeys.length,
|
|
itemBuilder: (context, index) {
|
|
final pubkey = currentReaction.userPubkeys[index];
|
|
final profile = userCache[pubkey.toLowerCase()];
|
|
return _ReactorTile(
|
|
profile: profile,
|
|
pubkey: pubkey,
|
|
displayName: identityNames.labelFor(pubkey),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ReactorTile extends StatelessWidget {
|
|
final UserProfile? profile;
|
|
final String pubkey;
|
|
final String displayName;
|
|
|
|
const _ReactorTile({
|
|
required this.profile,
|
|
required this.pubkey,
|
|
required this.displayName,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final about = profile?.about;
|
|
|
|
return ListTile(
|
|
leading: _ReactorAvatar(
|
|
avatarUrl: profile?.avatarUrl,
|
|
initial:
|
|
profile?.initial ??
|
|
(pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?'),
|
|
isAgent: profile?.isAgent == true,
|
|
),
|
|
title: Text(
|
|
displayName,
|
|
style: context.textTheme.bodyMedium?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
subtitle: about != null && about.isNotEmpty
|
|
? Text(
|
|
about,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: context.textTheme.bodySmall?.copyWith(
|
|
color: context.colors.onSurfaceVariant,
|
|
),
|
|
)
|
|
: null,
|
|
dense: true,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ReactorAvatar extends StatelessWidget {
|
|
final String? avatarUrl;
|
|
final String initial;
|
|
final bool isAgent;
|
|
|
|
const _ReactorAvatar({
|
|
required this.avatarUrl,
|
|
required this.initial,
|
|
required this.isAgent,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AvatarImage(
|
|
imageUrl: avatarUrl,
|
|
radius: 20,
|
|
fallback: Text(initial),
|
|
isAgent: isAgent,
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<bool> _showNativeReactionDetails(
|
|
BuildContext context,
|
|
String channelId,
|
|
List<TimelineReaction> reactions,
|
|
String initialEmoji,
|
|
) async {
|
|
if (!NativeMessagePresentation.isSupportedPlatform) return false;
|
|
return NativeMessagePresentation.withLease(() async {
|
|
final container = ProviderScope.containerOf(context, listen: false);
|
|
final requestId = DateTime.now().microsecondsSinceEpoch.toString();
|
|
final pubkeys = reactions.expand((r) => r.userPubkeys).toSet().toList();
|
|
final sent = <String, ({String name, String? avatarUrl})>{};
|
|
Map<String, Object?> profiles({bool onlyChanges = false}) {
|
|
final cache = container.read(userCacheProvider);
|
|
final names = container.read(channelIdentityNamesProvider(channelId));
|
|
final result = <String, Object?>{};
|
|
for (final pubkey in pubkeys) {
|
|
final value = (
|
|
name: names.labelFor(pubkey),
|
|
avatarUrl: cache[pubkey.toLowerCase()]?.avatarUrl,
|
|
);
|
|
if (onlyChanges && sent[pubkey] == value) continue;
|
|
sent[pubkey] = value;
|
|
final url = value.avatarUrl;
|
|
// Buzz's emoji-avatar SVG is a small value object, not arbitrary native SVG.
|
|
final avatar = url != null && url.length <= 8192
|
|
? parseEmojiAvatarDataUrl(url)
|
|
: null;
|
|
final validAvatar =
|
|
avatar != null &&
|
|
avatar.emoji.length <= 64 &&
|
|
avatar.emoji.characters.length == 1;
|
|
result[pubkey] = {
|
|
'name': value.name,
|
|
if (validAvatar) ...{
|
|
'avatarEmoji': avatar.emoji,
|
|
'avatarColor': avatar.colorValue,
|
|
} else if (url != null && !url.startsWith('data:')) ...{
|
|
'url': url,
|
|
'headers': container
|
|
.read(mediaGetAuthServiceProvider)
|
|
.headersFor(url),
|
|
},
|
|
};
|
|
}
|
|
return result;
|
|
}
|
|
|
|
Timer? timer;
|
|
var disposed = false;
|
|
var flushing = false;
|
|
var dirty = false;
|
|
void scheduleProfiles() {
|
|
dirty = true;
|
|
if (disposed || timer != null || flushing) return;
|
|
timer = Timer(const Duration(milliseconds: 50), () async {
|
|
timer = null;
|
|
flushing = true;
|
|
dirty = false;
|
|
try {
|
|
final changes = profiles(onlyChanges: true).entries.toList();
|
|
// Backpressure bounds each platform message; only changed users cross
|
|
// the bridge, even when hydration publishes several thousand-user pages.
|
|
for (
|
|
var start = 0;
|
|
start < changes.length && !disposed;
|
|
start += 256
|
|
) {
|
|
await NativeMessagePresentation.present('updateProfiles', {
|
|
'requestId': requestId,
|
|
'profiles': Map.fromEntries(changes.skip(start).take(256)),
|
|
});
|
|
}
|
|
} finally {
|
|
flushing = false;
|
|
if (dirty && !disposed) scheduleProfiles();
|
|
}
|
|
});
|
|
}
|
|
|
|
final subscription = container.listen(
|
|
userCacheProvider,
|
|
(_, _) => scheduleProfiles(),
|
|
);
|
|
final namesSubscription = container.listen(
|
|
channelIdentityNamesProvider(channelId),
|
|
(_, _) => scheduleProfiles(),
|
|
);
|
|
try {
|
|
final dataset = container.read(emojiDatasetOrEmptyProvider);
|
|
final sheetColors = utilitySurfaceThemeData(
|
|
Theme.of(context),
|
|
).colorScheme;
|
|
final pending = NativeMessagePresentation.present('reactions', {
|
|
'requestId': requestId,
|
|
'initialEmoji': initialEmoji,
|
|
'sheetColor': sheetColors.surface.toARGB32(),
|
|
'foregroundColor': sheetColors.primary.toARGB32(),
|
|
'dark': Theme.of(context).brightness == Brightness.dark,
|
|
'profiles': profiles(),
|
|
'reactions': [
|
|
for (final reaction in reactions)
|
|
{
|
|
'emoji': reaction.emoji,
|
|
'label': dataset.displayName(reaction.emoji),
|
|
'count': reaction.count,
|
|
'users': reaction.userPubkeys.toSet().toList(),
|
|
if (reaction.emojiUrl case final String url) ...{
|
|
'url': url,
|
|
'headers': container
|
|
.read(mediaGetAuthServiceProvider)
|
|
.headersFor(url),
|
|
},
|
|
},
|
|
],
|
|
});
|
|
container.read(userCacheProvider.notifier).preload(pubkeys);
|
|
return await pending != null;
|
|
} finally {
|
|
disposed = true;
|
|
timer?.cancel();
|
|
subscription.close();
|
|
namesSubscription.close();
|
|
}
|
|
});
|
|
}
|