mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user