9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
504 lines
17 KiB
Dart
504 lines
17 KiB
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/l10n/l10n.dart';
|
|
import '../../shared/widgets/avatar_image.dart';
|
|
import '../../shared/widgets/modal_presentation.dart';
|
|
import '../../shared/custom_emoji/custom_emoji_render.dart';
|
|
import '../../shared/emoji/emoji_burst.dart';
|
|
import '../../shared/emoji/emoji_data_provider.dart';
|
|
import '../../shared/emoji/native_emoji_glyph.dart';
|
|
import '../../shared/emoji/positive_emoji.dart';
|
|
import '../profile/user_cache_provider.dart';
|
|
import '../profile/user_profile.dart';
|
|
import 'channel_management_provider.dart';
|
|
import 'emoji_picker.dart';
|
|
import 'recent_emoji_provider.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;
|
|
|
|
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.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,
|
|
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 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();
|
|
},
|
|
onLongPress: onLongPress,
|
|
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: context.l10n.addReaction,
|
|
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);
|
|
}
|
|
}
|
|
|
|
void showReactionDetailSheet({
|
|
required BuildContext context,
|
|
required List<TimelineReaction> reactions,
|
|
required String initialEmoji,
|
|
}) {
|
|
showBuzzModalBottomSheet<void>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
showDragHandle: true,
|
|
backgroundColor: context.colors.surfaceContainerHighest,
|
|
builder: (sheetContext) =>
|
|
_ReactionDetailSheet(reactions: reactions, initialEmoji: initialEmoji),
|
|
);
|
|
}
|
|
|
|
class _ReactionDetailSheet extends HookConsumerWidget {
|
|
final List<TimelineReaction> reactions;
|
|
final String initialEmoji;
|
|
|
|
const _ReactionDetailSheet({
|
|
required this.reactions,
|
|
required this.initialEmoji,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final selectedEmoji = useState(initialEmoji);
|
|
final userCache = ref.watch(userCacheProvider);
|
|
|
|
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);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ReactorTile extends StatelessWidget {
|
|
final UserProfile? profile;
|
|
final String pubkey;
|
|
|
|
const _ReactorTile({required this.profile, required this.pubkey});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final displayName =
|
|
profile?.label ??
|
|
(pubkey.length >= 8 ? '${pubkey.substring(0, 8)}...' : pubkey);
|
|
final about = profile?.about;
|
|
|
|
return ListTile(
|
|
leading: _ReactorAvatar(
|
|
avatarUrl: profile?.avatarUrl,
|
|
initial:
|
|
profile?.initial ??
|
|
(pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?'),
|
|
),
|
|
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;
|
|
|
|
const _ReactorAvatar({required this.avatarUrl, required this.initial});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AvatarImage(
|
|
imageUrl: avatarUrl,
|
|
radius: 20,
|
|
fallback: Text(initial),
|
|
);
|
|
}
|
|
}
|