Emoji reaction detail views (desktop popover + mobile bottom sheet) (#416)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Wes
2026-04-28 11:17:08 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 00a4aaba3d
commit 8a2562dfd5
8 changed files with 574 additions and 468 deletions
@@ -234,7 +234,11 @@ export function formatTimelineMessages(
profile?.displayName?.trim() ||
profile?.nip05Handle?.trim() ||
`${actorPubkey.slice(0, 8)}…`;
existing.users.push({ pubkey: actorPubkey, displayName });
existing.users.push({
pubkey: actorPubkey,
displayName,
avatarUrl: profile?.avatarUrl ?? null,
});
current.set(emoji, existing);
reactionsByEventId.set(targetId, current);
+5 -1
View File
@@ -2,7 +2,11 @@ export type TimelineReaction = {
emoji: string;
count: number;
reactedByCurrentUser?: boolean;
users: Array<{ pubkey: string; displayName: string }>;
users: Array<{
pubkey: string;
displayName: string;
avatarUrl: string | null;
}>;
};
export type TimelineMessage = {
@@ -2,12 +2,46 @@ import * as React from "react";
import type { TimelineReaction } from "@/features/messages/types";
import { cn } from "@/shared/lib/cn";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/shared/ui/tooltip";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { UserAvatar } from "@/shared/ui/UserAvatar";
const MAX_VISIBLE_REACTORS = 10;
function ReactionPopoverContent({ reaction }: { reaction: TimelineReaction }) {
const visible = reaction.users.slice(0, MAX_VISIBLE_REACTORS);
const overflow = reaction.users.length - MAX_VISIBLE_REACTORS;
return (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 pb-1 border-b border-border/50">
<span className="text-2xl">{reaction.emoji}</span>
<span className="text-xs text-muted-foreground">
{reaction.count} {reaction.count === 1 ? "reaction" : "reactions"}
</span>
</div>
<div className="flex flex-col gap-1.5">
{visible.map((user) => (
<div key={user.pubkey} className="flex items-center gap-2 min-w-0">
<UserAvatar
avatarUrl={user.avatarUrl}
displayName={user.displayName}
size="xs"
/>
<span className="text-sm truncate">{user.displayName}</span>
</div>
))}
</div>
{overflow > 0 && (
<span className="text-xs text-muted-foreground">+{overflow} more</span>
)}
{reaction.reactedByCurrentUser && (
<span className="text-xs text-muted-foreground border-t border-border/50 pt-1.5">
Click to remove your reaction
</span>
)}
</div>
);
}
export function MessageReactions({
messageId,
@@ -27,59 +61,134 @@ export function MessageReactions({
}
return (
<TooltipProvider delayDuration={200}>
<div className="mt-1.5 flex flex-wrap items-center gap-1.5 pt-1">
{reactions.map((reaction) => {
const tooltipText =
reaction.users.length > 0
? reaction.users.map((u) => u.displayName).join(", ")
: undefined;
const pill = (
<button
aria-label={`Toggle ${reaction.emoji} reaction`}
aria-pressed={reaction.reactedByCurrentUser}
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium transition-colors",
reaction.reactedByCurrentUser
? "border-primary/40 bg-primary/10 text-primary"
: "border-border/70 bg-muted/70 text-foreground/90",
canToggle
? "hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
: "cursor-default",
)}
disabled={!canToggle || pending}
onClick={() => {
if (!canToggle) return;
onSelect(reaction.emoji);
}}
type="button"
>
<span>{reaction.emoji}</span>
<span className="text-muted-foreground">{reaction.count}</span>
</button>
);
if (!tooltipText) {
return (
<React.Fragment key={`${messageId}-${reaction.emoji}`}>
{pill}
</React.Fragment>
);
}
// Wrap in a span so the tooltip trigger receives hover/focus events
// even when the inner button is disabled (Radix tooltips require it).
return (
<Tooltip key={`${messageId}-${reaction.emoji}`}>
<TooltipTrigger asChild>
<span className="inline-flex">{pill}</span>
</TooltipTrigger>
<TooltipContent>{tooltipText}</TooltipContent>
</Tooltip>
);
})}
</div>
</TooltipProvider>
<div className="mt-1.5 flex flex-wrap items-center gap-1.5 pt-1">
{reactions.map((reaction) => (
<ReactionPill
key={`${messageId}-${reaction.emoji}`}
canToggle={canToggle}
pending={pending}
reaction={reaction}
onSelect={onSelect}
/>
))}
</div>
);
}
function ReactionPill({
reaction,
canToggle,
pending,
onSelect,
}: {
reaction: TimelineReaction;
canToggle: boolean;
pending: boolean;
onSelect: (emoji: string) => void;
}) {
const [open, setOpen] = React.useState(false);
const openTimeout = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const closeTimeout = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const clearTimers = React.useCallback(() => {
if (openTimeout.current) {
clearTimeout(openTimeout.current);
openTimeout.current = null;
}
if (closeTimeout.current) {
clearTimeout(closeTimeout.current);
closeTimeout.current = null;
}
}, []);
const handleMouseEnter = React.useCallback(() => {
if (reaction.users.length === 0) return;
clearTimers();
openTimeout.current = setTimeout(() => setOpen(true), 200);
}, [reaction.users.length, clearTimers]);
const scheduleClose = React.useCallback(() => {
clearTimers();
closeTimeout.current = setTimeout(() => setOpen(false), 150);
}, [clearTimers]);
const handleFocus = React.useCallback(() => {
if (reaction.users.length === 0) return;
clearTimers();
setOpen(true);
}, [reaction.users.length, clearTimers]);
React.useEffect(() => {
return clearTimers;
}, [clearTimers]);
const pillClasses = cn(
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium transition-colors",
reaction.reactedByCurrentUser
? "border-primary/40 bg-primary/10 text-primary"
: "border-border/70 bg-muted/70 text-foreground/90",
canToggle
? "hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
: "cursor-default",
);
const handleClick = () => {
if (!canToggle) return;
onSelect(reaction.emoji);
};
if (reaction.users.length === 0) {
return (
<button
aria-label={`Toggle ${reaction.emoji} reaction`}
aria-pressed={reaction.reactedByCurrentUser}
className={pillClasses}
disabled={!canToggle || pending}
onClick={handleClick}
type="button"
>
<span>{reaction.emoji}</span>
<span className="text-muted-foreground">{reaction.count}</span>
</button>
);
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
{/* biome-ignore lint/a11y/noStaticElementInteractions: span delegates hover/focus to disabled button */}
<span
className="inline-flex"
onMouseEnter={handleMouseEnter}
onMouseLeave={scheduleClose}
onFocus={handleFocus}
onBlur={scheduleClose}
>
<button
aria-label={`Toggle ${reaction.emoji} reaction`}
aria-pressed={reaction.reactedByCurrentUser}
className={pillClasses}
disabled={!canToggle || pending}
onClick={handleClick}
type="button"
>
<span>{reaction.emoji}</span>
<span className="text-muted-foreground">{reaction.count}</span>
</button>
</span>
</PopoverTrigger>
<PopoverContent
align="start"
side="top"
sideOffset={6}
className="w-auto min-w-48 max-w-64 p-3"
onMouseEnter={handleMouseEnter}
onMouseLeave={scheduleClose}
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<ReactionPopoverContent reaction={reaction} />
</PopoverContent>
</Popover>
);
}
@@ -26,7 +26,9 @@ import 'manage_channel_sheet.dart';
import 'members_sheet.dart';
import 'message_actions.dart';
import 'message_content.dart';
import 'reaction_row.dart';
import 'send_message_provider.dart';
import 'small_avatar.dart';
import 'thread_detail_page.dart';
import 'timeline_message.dart';
@@ -432,7 +434,7 @@ class _SystemMessageRow extends ConsumerWidget {
final profile =
userCache[pubkey.toLowerCase()] ??
ref.read(userCacheProvider.notifier).get(pubkey.toLowerCase());
return profile?.label ?? _shortPubkey(pubkey);
return profile?.label ?? shortPubkey(pubkey);
}
final description = systemEvent.describe(resolveLabel);
@@ -464,7 +466,7 @@ class _SystemMessageRow extends ConsumerWidget {
),
),
Text(
_formatTime(message.createdAt),
formatMessageTime(message.createdAt),
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.outline,
),
@@ -535,7 +537,7 @@ class _ThreadSummaryRow extends ConsumerWidget {
for (var i = 0; i < summary.participantPubkeys.length; i++)
Positioned(
left: i * 12.0,
child: _SmallAvatar(
child: SmallAvatar(
pubkey: summary.participantPubkeys[i],
userCache: userCache,
),
@@ -564,45 +566,6 @@ class _ThreadSummaryRow extends ConsumerWidget {
}
}
class _SmallAvatar extends StatelessWidget {
final String pubkey;
final Map<String, UserProfile> userCache;
const _SmallAvatar({required this.pubkey, required this.userCache});
@override
Widget build(BuildContext context) {
final profile = userCache[pubkey.toLowerCase()];
final avatarUrl = profile?.avatarUrl;
final initial =
profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?');
return Container(
width: 20,
height: 20,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: context.colors.surface, width: 1.5),
),
child: CircleAvatar(
radius: 9,
backgroundColor: context.colors.primaryContainer,
backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl) : null,
child: avatarUrl == null
? Text(
initial,
style: TextStyle(
fontSize: 8,
fontWeight: FontWeight.w600,
color: context.colors.onPrimaryContainer,
),
)
: null,
),
);
}
}
// ---------------------------------------------------------------------------
// User message bubble
// ---------------------------------------------------------------------------
@@ -635,7 +598,7 @@ class _MessageBubble extends ConsumerWidget {
final profile =
ref.watch(userCacheProvider.select((cache) => cache[pk])) ??
ref.read(userCacheProvider.notifier).get(pk);
final displayName = profile?.label ?? _shortPubkey(message.pubkey);
final displayName = profile?.label ?? shortPubkey(message.pubkey);
// Build mention names map from event p-tags.
final userCache = ref.watch(userCacheProvider);
@@ -689,7 +652,7 @@ class _MessageBubble extends ConsumerWidget {
),
const SizedBox(width: Grid.xxs),
Text(
_formatTime(message.createdAt),
formatMessageTime(message.createdAt),
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.outline,
),
@@ -735,7 +698,7 @@ class _MessageBubble extends ConsumerWidget {
},
),
if (message.reactions.isNotEmpty)
_ReactionRow(
ReactionRow(
reactions: message.reactions,
onToggle: (emoji) {
final actions = ref.read(channelActionsProvider);
@@ -792,69 +755,6 @@ class _UserAvatar extends StatelessWidget {
}
}
// ---------------------------------------------------------------------------
// Reaction pills row
// ---------------------------------------------------------------------------
class _ReactionRow extends StatelessWidget {
final List<TimelineReaction> reactions;
final void Function(String emoji) onToggle;
const _ReactionRow({required this.reactions, required this.onToggle});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: Grid.half),
child: Wrap(
spacing: Grid.half,
runSpacing: Grid.half,
children: [
for (final reaction in reactions)
GestureDetector(
onTap: () => onToggle(reaction.emoji),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: Grid.xxs,
vertical: Grid.quarter,
),
decoration: BoxDecoration(
color: reaction.reactedByCurrentUser
? context.colors.primary.withValues(alpha: 0.12)
: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.lg),
border: Border.all(
color: reaction.reactedByCurrentUser
? context.colors.primary.withValues(alpha: 0.4)
: context.colors.outlineVariant,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(reaction.emoji, style: const TextStyle(fontSize: 14)),
if (reaction.count > 1) ...[
const SizedBox(width: Grid.quarter),
Text(
'${reaction.count}',
style: context.textTheme.labelSmall?.copyWith(
color: reaction.reactedByCurrentUser
? context.colors.primary
: context.colors.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
],
],
),
),
),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Channel management
// ---------------------------------------------------------------------------
@@ -961,7 +861,7 @@ class _TypingIndicator extends ConsumerWidget {
final profile =
userCache[e.pubkey.toLowerCase()] ??
ref.read(userCacheProvider.notifier).get(e.pubkey.toLowerCase());
return profile?.label ?? _shortPubkey(e.pubkey);
return profile?.label ?? shortPubkey(e.pubkey);
}).toList();
final text = switch (names.length) {
1 => '${names[0]} is typing…',
@@ -986,33 +886,6 @@ class _TypingIndicator extends ConsumerWidget {
}
}
// ---------------------------------------------------------------------------
// Compose bar
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
String _shortPubkey(String pubkey) {
if (pubkey.length > 12) return '${pubkey.substring(0, 8)}…';
return pubkey;
}
String _formatTime(int createdAt) {
final dt = DateTime.fromMillisecondsSinceEpoch(
createdAt * 1000,
isUtc: true,
).toLocal();
final now = DateTime.now();
final diff = now.difference(dt);
if (diff.inDays > 0) {
return '${dt.month}/${dt.day} ${_pad(dt.hour)}:${_pad(dt.minute)}';
}
return '${_pad(dt.hour)}:${_pad(dt.minute)}';
}
String _pad(int n) => n.toString().padLeft(2, '0');
class _DmAppBarTitle extends ConsumerWidget {
final Channel channel;
final String? currentPubkey;
@@ -59,6 +59,24 @@ String relativeTime(int unixSeconds) {
return '${time.month}/${time.day}/${time.year}';
}
/// Compact time label: "HH:MM" for today, "M/D HH:MM" for older messages.
String formatMessageTime(int unixSeconds) {
final dt = DateTime.fromMillisecondsSinceEpoch(
unixSeconds * 1000,
isUtc: true,
).toLocal();
final now = DateTime.now();
final diff = now.difference(dt);
final hh = dt.hour.toString().padLeft(2, '0');
final mm = dt.minute.toString().padLeft(2, '0');
if (diff.inDays > 0) {
return '${dt.month}/${dt.day} $hh:$mm';
}
return '$hh:$mm';
}
/// Truncates a hex pubkey to the first 8 characters with an ellipsis.
String shortPubkey(String pubkey) {
if (pubkey.length > 12) return '${pubkey.substring(0, 8)}\u2026';
@@ -0,0 +1,313 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/theme/theme.dart';
import '../profile/user_cache_provider.dart';
import '../profile/user_profile.dart';
import 'timeline_message.dart';
// ---------------------------------------------------------------------------
// Reaction pills row (shared between channel + thread detail pages)
// ---------------------------------------------------------------------------
class ReactionRow extends StatelessWidget {
final List<TimelineReaction> reactions;
final void Function(String emoji) onToggle;
const ReactionRow({
super.key,
required this.reactions,
required this.onToggle,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: Grid.half),
child: Wrap(
spacing: Grid.half,
runSpacing: Grid.half,
children: [
for (final reaction in reactions)
GestureDetector(
onTap: () => onToggle(reaction.emoji),
onLongPress: () => showReactionDetailSheet(
context: context,
reactions: reactions,
initialEmoji: reaction.emoji,
),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: Grid.xxs,
vertical: Grid.quarter,
),
decoration: BoxDecoration(
color: reaction.reactedByCurrentUser
? context.colors.primary.withValues(alpha: 0.12)
: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.lg),
border: Border.all(
color: reaction.reactedByCurrentUser
? context.colors.primary.withValues(alpha: 0.4)
: context.colors.outlineVariant,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(reaction.emoji, style: const TextStyle(fontSize: 14)),
if (reaction.count > 1) ...[
const SizedBox(width: Grid.quarter),
Text(
'${reaction.count}',
style: context.textTheme.labelSmall?.copyWith(
color: reaction.reactedByCurrentUser
? context.colors.primary
: context.colors.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
],
],
),
),
),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Reaction detail bottom sheet
// ---------------------------------------------------------------------------
void showReactionDetailSheet({
required BuildContext context,
required List<TimelineReaction> reactions,
required String initialEmoji,
}) {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
backgroundColor: Theme.of(context).colorScheme.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.xs),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (final reaction in reactions)
Padding(
padding: const EdgeInsets.only(right: Grid.half),
child: ChoiceChip(
label: Text('${reaction.emoji} ${reaction.count}'),
selected: reaction.emoji == selectedEmoji.value,
onSelected: (_) {
selectedEmoji.value = reaction.emoji;
},
),
),
],
),
),
),
// Header: emoji + shortcode.
Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.xs,
vertical: Grid.half,
),
child: Row(
children: [
Text(
currentReaction.emoji,
style: const TextStyle(fontSize: 28),
),
const SizedBox(width: Grid.half),
Text(
':${_emojiToShortcode(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 HookWidget {
final String? avatarUrl;
final String initial;
const _ReactorAvatar({required this.avatarUrl, required this.initial});
@override
Widget build(BuildContext context) {
final failed = useState(false);
useEffect(() {
failed.value = false;
return null;
}, [avatarUrl]);
final url = avatarUrl;
if (url == null || failed.value) {
return CircleAvatar(child: Text(initial));
}
return CircleAvatar(
backgroundImage: NetworkImage(url),
onBackgroundImageError: (_, _) => failed.value = true,
child: null,
);
}
}
// ---------------------------------------------------------------------------
// Emoji shortcode mapping (common subset)
// ---------------------------------------------------------------------------
String _emojiToShortcode(String emoji) {
const map = <String, String>{
'👍': 'thumbsup',
'👎': 'thumbsdown',
'❤️': 'heart',
'🎉': 'tada',
'😂': 'joy',
'😢': 'cry',
'😮': 'open_mouth',
'🔥': 'fire',
'👀': 'eyes',
'🚀': 'rocket',
'💯': '100',
'👏': 'clap',
'🙏': 'pray',
'💬': 'speech_balloon',
'✅': 'white_check_mark',
'❌': 'x',
'⭐': 'star',
'🤔': 'thinking',
'😄': 'smile',
'😎': 'sunglasses',
'🤣': 'rofl',
'😍': 'heart_eyes',
'🥳': 'partying_face',
'👋': 'wave',
'💪': 'muscle',
'🙌': 'raised_hands',
'😅': 'sweat_smile',
'🫡': 'saluting_face',
};
return map[emoji] ?? 'emoji';
}
@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import '../../shared/theme/theme.dart';
import '../profile/user_profile.dart';
/// 20px circle avatar used in thread summary rows and other compact lists.
class SmallAvatar extends StatelessWidget {
final String pubkey;
final Map<String, UserProfile> userCache;
const SmallAvatar({super.key, required this.pubkey, required this.userCache});
@override
Widget build(BuildContext context) {
final profile = userCache[pubkey.toLowerCase()];
final avatarUrl = profile?.avatarUrl;
final initial =
profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?');
return Container(
width: 20,
height: 20,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: context.colors.surface, width: 1.5),
),
child: CircleAvatar(
radius: 9,
backgroundColor: context.colors.primaryContainer,
backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl) : null,
child: avatarUrl == null
? Text(
initial,
style: TextStyle(
fontSize: 8,
fontWeight: FontWeight.w600,
color: context.colors.onPrimaryContainer,
),
)
: null,
),
);
}
}
@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
@@ -13,8 +12,12 @@ import 'channel_messages_provider.dart';
import 'channel_typing_provider.dart';
import 'channels_provider.dart';
import 'compose_bar.dart';
import 'date_formatters.dart';
import 'message_actions.dart';
import 'message_content.dart';
import 'reaction_row.dart';
import 'send_message_provider.dart';
import 'small_avatar.dart';
import 'timeline_message.dart';
/// Full-screen thread detail page.
@@ -298,7 +301,7 @@ class _NestedThreadSummaryRow extends ConsumerWidget {
for (var i = 0; i < summary.participantPubkeys.length; i++)
Positioned(
left: i * 12.0,
child: _SmallAvatar(
child: SmallAvatar(
pubkey: summary.participantPubkeys[i],
userCache: userCache,
),
@@ -327,45 +330,6 @@ class _NestedThreadSummaryRow extends ConsumerWidget {
}
}
class _SmallAvatar extends StatelessWidget {
final String pubkey;
final Map<String, UserProfile> userCache;
const _SmallAvatar({required this.pubkey, required this.userCache});
@override
Widget build(BuildContext context) {
final profile = userCache[pubkey.toLowerCase()];
final avatarUrl = profile?.avatarUrl;
final initial =
profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?');
return Container(
width: 20,
height: 20,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: context.colors.surface, width: 1.5),
),
child: CircleAvatar(
radius: 9,
backgroundColor: context.colors.primaryContainer,
backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl) : null,
child: avatarUrl == null
? Text(
initial,
style: TextStyle(
fontSize: 8,
fontWeight: FontWeight.w600,
color: context.colors.onPrimaryContainer,
),
)
: null,
),
);
}
}
// ---------------------------------------------------------------------------
// Thread message row (reusable for head and replies)
// ---------------------------------------------------------------------------
@@ -397,7 +361,7 @@ class _ThreadMessage extends ConsumerWidget {
final profile =
ref.watch(userCacheProvider.select((cache) => cache[pk])) ??
ref.read(userCacheProvider.notifier).get(pk);
final displayName = profile?.label ?? _shortPubkey(message.pubkey);
final displayName = profile?.label ?? shortPubkey(message.pubkey);
final userCache = ref.watch(userCacheProvider);
final mentionNames = <String, String>{};
@@ -410,7 +374,7 @@ class _ThreadMessage extends ConsumerWidget {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onLongPress: () => _showThreadMessageActions(
onLongPress: () => showMessageActions(
context: context,
ref: ref,
message: message,
@@ -450,7 +414,7 @@ class _ThreadMessage extends ConsumerWidget {
),
const SizedBox(width: Grid.xxs),
Text(
_formatTime(message.createdAt),
formatMessageTime(message.createdAt),
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.outline,
),
@@ -476,7 +440,7 @@ class _ThreadMessage extends ConsumerWidget {
onChannelTap: (_) {},
),
if (message.reactions.isNotEmpty)
_ReactionRow(
ReactionRow(
reactions: message.reactions,
onToggle: (emoji) {
final actions = ref.read(channelActionsProvider);
@@ -504,10 +468,6 @@ class _ThreadMessage extends ConsumerWidget {
}
}
// ---------------------------------------------------------------------------
// Thread compose bar
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Thread-scoped typing indicator
// ---------------------------------------------------------------------------
@@ -524,7 +484,7 @@ class _ThreadTypingIndicator extends ConsumerWidget {
final profile =
userCache[e.pubkey.toLowerCase()] ??
ref.read(userCacheProvider.notifier).get(e.pubkey.toLowerCase());
return profile?.label ?? _shortPubkey(e.pubkey);
return profile?.label ?? shortPubkey(e.pubkey);
}).toList();
final text = switch (names.length) {
1 => '${names[0]} is typing...',
@@ -550,146 +510,7 @@ class _ThreadTypingIndicator extends ConsumerWidget {
}
// ---------------------------------------------------------------------------
// Thread message actions (long-press sheet — reactions, copy, edit, delete)
// ---------------------------------------------------------------------------
const _quickEmojis = [
'\u{1F44D}',
'\u{2764}\u{FE0F}',
'\u{1F602}',
'\u{1F389}',
'\u{1F440}',
'\u{1F64F}',
];
void _showThreadMessageActions({
required BuildContext context,
required WidgetRef ref,
required TimelineMessage message,
required String channelId,
required bool isOwnMessage,
List<TimelineMessage>? allMessages,
String? currentPubkey,
bool isMember = false,
bool isArchived = false,
}) {
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(Grid.xs, 0, Grid.xs, Grid.xs),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
for (final emoji in _quickEmojis)
GestureDetector(
onTap: () {
Navigator.of(sheetContext).pop();
ref
.read(channelActionsProvider)
.addReaction(message.id, emoji);
},
child: Container(
width: 44,
height: 44,
alignment: Alignment.center,
decoration: BoxDecoration(
color: Theme.of(
sheetContext,
).colorScheme.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: Text(emoji, style: const TextStyle(fontSize: 20)),
),
),
],
),
const SizedBox(height: Grid.xs),
if (allMessages != null)
ListTile(
leading: const Icon(LucideIcons.messageSquareReply),
title: const Text('Reply in thread'),
onTap: () {
Navigator.of(sheetContext).pop();
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ThreadDetailPage(
threadHead: message,
allMessages: allMessages,
channelId: channelId,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
),
),
);
},
),
ListTile(
leading: const Icon(LucideIcons.copy),
title: const Text('Copy text'),
onTap: () {
Navigator.of(sheetContext).pop();
Clipboard.setData(ClipboardData(text: message.content));
},
),
if (isOwnMessage) ...[
ListTile(
leading: Icon(
LucideIcons.trash2,
color: Theme.of(sheetContext).colorScheme.error,
),
title: Text(
'Delete message',
style: TextStyle(
color: Theme.of(sheetContext).colorScheme.error,
),
),
onTap: () {
Navigator.of(sheetContext).pop();
showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Delete message'),
content: const Text('This cannot be undone.'),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
Navigator.of(dialogContext).pop();
ref
.read(channelActionsProvider)
.deleteMessage(message.id);
},
style: FilledButton.styleFrom(
backgroundColor: Theme.of(
dialogContext,
).colorScheme.error,
),
child: const Text('Delete'),
),
],
),
);
},
),
],
],
),
),
),
);
}
// ---------------------------------------------------------------------------
// Shared helpers (duplicated here to keep the file self-contained)
// Shared helpers
// ---------------------------------------------------------------------------
class _Avatar extends StatelessWidget {
@@ -720,83 +541,3 @@ class _Avatar extends StatelessWidget {
);
}
}
class _ReactionRow extends StatelessWidget {
final List<TimelineReaction> reactions;
final void Function(String emoji) onToggle;
const _ReactionRow({required this.reactions, required this.onToggle});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: Grid.half),
child: Wrap(
spacing: Grid.half,
runSpacing: Grid.half,
children: [
for (final reaction in reactions)
GestureDetector(
onTap: () => onToggle(reaction.emoji),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: Grid.xxs,
vertical: Grid.quarter,
),
decoration: BoxDecoration(
color: reaction.reactedByCurrentUser
? context.colors.primary.withValues(alpha: 0.12)
: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.lg),
border: Border.all(
color: reaction.reactedByCurrentUser
? context.colors.primary.withValues(alpha: 0.4)
: context.colors.outlineVariant,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(reaction.emoji, style: const TextStyle(fontSize: 14)),
if (reaction.count > 1) ...[
const SizedBox(width: Grid.quarter),
Text(
'${reaction.count}',
style: context.textTheme.labelSmall?.copyWith(
color: reaction.reactedByCurrentUser
? context.colors.primary
: context.colors.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
],
],
),
),
),
],
),
);
}
}
String _shortPubkey(String pubkey) {
if (pubkey.length > 12) return '${pubkey.substring(0, 8)}...';
return pubkey;
}
String _formatTime(int createdAt) {
final dt = DateTime.fromMillisecondsSinceEpoch(
createdAt * 1000,
isUtc: true,
).toLocal();
final now = DateTime.now();
final diff = now.difference(dt);
if (diff.inDays > 0) {
return '${dt.month}/${dt.day} ${_pad(dt.hour)}:${_pad(dt.minute)}';
}
return '${_pad(dt.hour)}:${_pad(dt.minute)}';
}
String _pad(int n) => n.toString().padLeft(2, '0');