mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat: add reaction support to system events on desktop and mobile (#432)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -130,13 +130,9 @@ export function MessageActionBar({
|
||||
return;
|
||||
}
|
||||
|
||||
void onReactionSelect(emoji.native)
|
||||
.then(() => {
|
||||
setIsReactionPickerOpen(false);
|
||||
})
|
||||
.catch(() => {
|
||||
return;
|
||||
});
|
||||
void onReactionSelect(emoji.native).finally(() => {
|
||||
setIsReactionPickerOpen(false);
|
||||
});
|
||||
}}
|
||||
theme="auto"
|
||||
previewPosition="none"
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as React from "react";
|
||||
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import { MessageReactions } from "@/features/messages/ui/MessageReactions";
|
||||
import { useReactionHandler } from "@/features/messages/ui/useReactionHandler";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
|
||||
import { KIND_STREAM_MESSAGE_DIFF } from "@/shared/constants/kinds";
|
||||
@@ -50,10 +51,13 @@ export const MessageRow = React.memo(
|
||||
const [expandedDiffId, setExpandedDiffId] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [reactionErrorMessage, setReactionErrorMessage] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [reactionPending, setReactionPending] = React.useState(false);
|
||||
const {
|
||||
reactions,
|
||||
canToggle: canToggleReactions,
|
||||
pending: reactionPending,
|
||||
errorMessage: reactionErrorMessage,
|
||||
select: handleReactionSelect,
|
||||
} = useReactionHandler(message, onToggleReaction);
|
||||
const mentionNames = React.useMemo(
|
||||
() => resolveMentionNames(message.tags, profiles),
|
||||
[profiles, message.tags],
|
||||
@@ -120,45 +124,6 @@ export const MessageRow = React.memo(
|
||||
}
|
||||
};
|
||||
|
||||
const reactions = [...(message.reactions ?? [])].sort((left, right) => {
|
||||
if (left.count !== right.count) {
|
||||
return right.count - left.count;
|
||||
}
|
||||
|
||||
return left.emoji.localeCompare(right.emoji);
|
||||
});
|
||||
const canToggleReactions = Boolean(onToggleReaction && !message.pending);
|
||||
|
||||
const handleReactionSelect = React.useCallback(
|
||||
async (emoji: string) => {
|
||||
if (!onToggleReaction || reactionPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
const remove = reactions.some(
|
||||
(reaction) =>
|
||||
reaction.emoji === emoji && reaction.reactedByCurrentUser,
|
||||
);
|
||||
|
||||
setReactionErrorMessage(null);
|
||||
setReactionPending(true);
|
||||
|
||||
try {
|
||||
await onToggleReaction(message, emoji, remove);
|
||||
} catch (error) {
|
||||
const nextMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to update the reaction.";
|
||||
setReactionErrorMessage(nextMessage);
|
||||
throw error;
|
||||
} finally {
|
||||
setReactionPending(false);
|
||||
}
|
||||
},
|
||||
[message, onToggleReaction, reactionPending, reactions],
|
||||
);
|
||||
|
||||
const isThreadReplyLayout = layoutVariant === "thread-reply";
|
||||
const guideBleedPx = isThreadReplyLayout ? 4 : 0;
|
||||
const avatarSizeClass = isThreadReplyLayout
|
||||
@@ -264,9 +229,7 @@ export const MessageRow = React.memo(
|
||||
canToggle={canToggleReactions}
|
||||
pending={reactionPending}
|
||||
onSelect={(emoji) => {
|
||||
void handleReactionSelect(emoji).catch(() => {
|
||||
return;
|
||||
});
|
||||
void handleReactionSelect(emoji);
|
||||
}}
|
||||
/>
|
||||
{reactionErrorMessage ? (
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import { ArrowRightLeft } from "lucide-react";
|
||||
import { ArrowRightLeft, SmilePlus } from "lucide-react";
|
||||
import Picker from "@emoji-mart/react";
|
||||
import data from "@emoji-mart/data";
|
||||
import * as React from "react";
|
||||
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import { MessageReactions } from "@/features/messages/ui/MessageReactions";
|
||||
import { useReactionHandler } from "@/features/messages/ui/useReactionHandler";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { resolveUserLabel } from "@/features/profile/lib/identity";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
||||
import { Spinner } from "@/shared/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import { MessageTimestamp } from "./MessageTimestamp";
|
||||
|
||||
type SystemMessagePayload = {
|
||||
@@ -67,25 +78,36 @@ function describeSystemEvent(
|
||||
}
|
||||
}
|
||||
|
||||
export function SystemMessageRow({
|
||||
body,
|
||||
createdAt,
|
||||
time,
|
||||
export const SystemMessageRow = React.memo(function SystemMessageRow({
|
||||
message,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
personaLookup,
|
||||
onToggleReaction,
|
||||
}: {
|
||||
body: string;
|
||||
createdAt: number;
|
||||
time: string;
|
||||
message: TimelineMessage;
|
||||
currentPubkey?: string;
|
||||
profiles?: UserProfileLookup;
|
||||
/** Map from lowercase pubkey → persona display name for bot members. */
|
||||
personaLookup?: Map<string, string>;
|
||||
onToggleReaction?: (
|
||||
message: TimelineMessage,
|
||||
emoji: string,
|
||||
remove: boolean,
|
||||
) => Promise<void>;
|
||||
}) {
|
||||
const [isReactionPickerOpen, setIsReactionPickerOpen] = React.useState(false);
|
||||
const {
|
||||
reactions,
|
||||
canToggle: canToggleReactions,
|
||||
pending: reactionPending,
|
||||
errorMessage: reactionErrorMessage,
|
||||
select: handleReactionSelect,
|
||||
} = useReactionHandler(message, onToggleReaction);
|
||||
|
||||
let payload: SystemMessagePayload;
|
||||
try {
|
||||
payload = JSON.parse(body);
|
||||
payload = JSON.parse(message.body);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -102,16 +124,110 @@ export function SystemMessageRow({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2.5 px-2 py-1"
|
||||
className="group/message rounded-lg px-2 py-1"
|
||||
data-testid="system-message-row"
|
||||
>
|
||||
<div className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-muted">
|
||||
<ArrowRightLeft className="h-3 w-3 text-muted-foreground" />
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-muted">
|
||||
<ArrowRightLeft className="h-3 w-3 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
<div className="ml-auto flex items-center gap-1 text-xs text-muted-foreground/60">
|
||||
<div className="relative">
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2">
|
||||
{canToggleReactions ? (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-full border border-border/70 bg-background/95 shadow-sm backdrop-blur supports-[backdrop-filter]:bg-background/85 transition-all duration-150 ease-out",
|
||||
"max-w-0 border-0 shadow-none translate-y-1 opacity-0",
|
||||
"group-hover/message:max-w-9 group-hover/message:border group-hover/message:border-border/70 group-hover/message:shadow-sm group-hover/message:translate-y-0 group-hover/message:opacity-100",
|
||||
"group-focus-within/message:max-w-9 group-focus-within/message:border group-focus-within/message:border-border/70 group-focus-within/message:shadow-sm group-focus-within/message:translate-y-0 group-focus-within/message:opacity-100",
|
||||
isReactionPickerOpen
|
||||
? "max-w-9 border border-border/70 shadow-sm translate-y-0 opacity-100"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1 p-1">
|
||||
<Popover
|
||||
onOpenChange={setIsReactionPickerOpen}
|
||||
open={isReactionPickerOpen}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
aria-label="Open reactions"
|
||||
className="h-6 w-6 rounded-full p-0"
|
||||
disabled={reactionPending}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant={
|
||||
isReactionPickerOpen ? "secondary" : "ghost"
|
||||
}
|
||||
>
|
||||
{reactionPending ? (
|
||||
<Spinner className="h-3 w-3" />
|
||||
) : (
|
||||
<SmilePlus className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>React</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
className="w-auto p-0 rounded-2xl overflow-hidden border-0 bg-transparent shadow-none"
|
||||
side="top"
|
||||
sideOffset={10}
|
||||
>
|
||||
{reactionErrorMessage ? (
|
||||
<div className="px-3 pt-3 pb-0">
|
||||
<p className="text-xs text-destructive">
|
||||
{reactionErrorMessage}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
<Picker
|
||||
data={data}
|
||||
onEmojiSelect={(emoji: { native: string }) => {
|
||||
void handleReactionSelect(emoji.native).finally(
|
||||
() => {
|
||||
setIsReactionPickerOpen(false);
|
||||
},
|
||||
);
|
||||
}}
|
||||
theme="auto"
|
||||
previewPosition="none"
|
||||
skinTonePosition="search"
|
||||
set="native"
|
||||
maxFrequentRows={2}
|
||||
perLine={8}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<MessageTimestamp createdAt={message.createdAt} time={message.time} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
<span className="ml-auto text-xs text-muted-foreground/60">
|
||||
<MessageTimestamp createdAt={createdAt} time={time} />
|
||||
</span>
|
||||
<MessageReactions
|
||||
messageId={message.id}
|
||||
reactions={reactions}
|
||||
canToggle={canToggleReactions}
|
||||
pending={reactionPending}
|
||||
onSelect={(emoji) => {
|
||||
void handleReactionSelect(emoji);
|
||||
}}
|
||||
/>
|
||||
{reactionErrorMessage ? (
|
||||
<p className="mt-1.5 text-xs text-destructive">
|
||||
{reactionErrorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -75,12 +75,11 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
elements.push(
|
||||
<SystemMessageRow
|
||||
key={message.id}
|
||||
body={message.body}
|
||||
createdAt={message.createdAt}
|
||||
message={message}
|
||||
currentPubkey={currentPubkey}
|
||||
onToggleReaction={onToggleReaction}
|
||||
personaLookup={personaLookup}
|
||||
profiles={profiles}
|
||||
time={message.time}
|
||||
/>,
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type {
|
||||
TimelineMessage,
|
||||
TimelineReaction,
|
||||
} from "@/features/messages/types";
|
||||
|
||||
type ReactionHandler = {
|
||||
/** Reactions sorted by count (desc) then emoji (asc). */
|
||||
reactions: TimelineReaction[];
|
||||
/** Whether the user can currently toggle reactions. */
|
||||
canToggle: boolean;
|
||||
/** Whether a reaction toggle is in flight. */
|
||||
pending: boolean;
|
||||
/** Error message from the last failed toggle, if any. */
|
||||
errorMessage: string | null;
|
||||
/** Call to toggle an emoji reaction. Safe to fire-and-forget. */
|
||||
select: (emoji: string) => Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared reaction state + toggle logic used by both MessageRow and
|
||||
* SystemMessageRow. Keeps the pending/error/sorting concerns in one place.
|
||||
*/
|
||||
export function useReactionHandler(
|
||||
message: TimelineMessage,
|
||||
onToggleReaction?: (
|
||||
message: TimelineMessage,
|
||||
emoji: string,
|
||||
remove: boolean,
|
||||
) => Promise<void>,
|
||||
): ReactionHandler {
|
||||
const [pending, setPending] = React.useState(false);
|
||||
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
|
||||
|
||||
const reactions = React.useMemo(() => {
|
||||
return [...(message.reactions ?? [])].sort((left, right) => {
|
||||
if (left.count !== right.count) {
|
||||
return right.count - left.count;
|
||||
}
|
||||
return left.emoji.localeCompare(right.emoji);
|
||||
});
|
||||
}, [message.reactions]);
|
||||
|
||||
const canToggle = Boolean(onToggleReaction && !message.pending);
|
||||
|
||||
const select = React.useCallback(
|
||||
async (emoji: string) => {
|
||||
if (!onToggleReaction || pending) {
|
||||
return;
|
||||
}
|
||||
|
||||
const remove = reactions.some(
|
||||
(reaction) => reaction.emoji === emoji && reaction.reactedByCurrentUser,
|
||||
);
|
||||
|
||||
setErrorMessage(null);
|
||||
setPending(true);
|
||||
try {
|
||||
await onToggleReaction(message, emoji, remove);
|
||||
} catch (error) {
|
||||
const nextMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to update the reaction.";
|
||||
setErrorMessage(nextMessage);
|
||||
throw error;
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
},
|
||||
[message, onToggleReaction, pending, reactions],
|
||||
);
|
||||
|
||||
return { reactions, canToggle, pending, errorMessage, select };
|
||||
}
|
||||
@@ -489,7 +489,14 @@ class _MessageList extends HookConsumerWidget {
|
||||
if (showDayDivider)
|
||||
DayDivider(label: formatDayHeading(message.createdAt)),
|
||||
if (message.isSystem)
|
||||
_SystemMessageRow(message: message)
|
||||
_SystemMessageRow(
|
||||
message: message,
|
||||
channelId: channelId,
|
||||
currentPubkey: currentPubkey,
|
||||
allMessages: null,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
)
|
||||
else ...[
|
||||
_MessageBubble(
|
||||
message: message,
|
||||
@@ -549,8 +556,20 @@ class _MessageList extends HookConsumerWidget {
|
||||
|
||||
class _SystemMessageRow extends ConsumerWidget {
|
||||
final TimelineMessage message;
|
||||
final String channelId;
|
||||
final String? currentPubkey;
|
||||
final List<TimelineMessage>? allMessages;
|
||||
final bool isMember;
|
||||
final bool isArchived;
|
||||
|
||||
const _SystemMessageRow({required this.message});
|
||||
const _SystemMessageRow({
|
||||
required this.message,
|
||||
required this.channelId,
|
||||
this.currentPubkey,
|
||||
this.allMessages,
|
||||
this.isMember = false,
|
||||
this.isArchived = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -569,39 +588,66 @@ class _SystemMessageRow extends ConsumerWidget {
|
||||
|
||||
final description = systemEvent.describe(resolveLabel);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surfaceContainerHighest,
|
||||
shape: BoxShape.circle,
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onLongPress: () => showMessageActions(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: channelId,
|
||||
isOwnMessage: false,
|
||||
allMessages: null,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surfaceContainerHighest,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
LucideIcons.arrowLeftRight,
|
||||
size: 12,
|
||||
color: context.colors.outline,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
Expanded(
|
||||
child: Text(
|
||||
description,
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
formatMessageTime(message.createdAt),
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: context.colors.outline,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Icon(
|
||||
LucideIcons.arrowLeftRight,
|
||||
size: 12,
|
||||
color: context.colors.outline,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
Expanded(
|
||||
child: Text(
|
||||
description,
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
if (message.reactions.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 28),
|
||||
child: ReactionRow(
|
||||
reactions: message.reactions,
|
||||
onToggle: (emoji) => toggleReaction(ref, message, emoji),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
formatMessageTime(message.createdAt),
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: context.colors.outline,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -830,21 +876,7 @@ class _MessageBubble extends ConsumerWidget {
|
||||
if (message.reactions.isNotEmpty)
|
||||
ReactionRow(
|
||||
reactions: message.reactions,
|
||||
onToggle: (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 {
|
||||
actions.addReaction(message.id, emoji);
|
||||
}
|
||||
},
|
||||
onToggle: (emoji) => toggleReaction(ref, message, emoji),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -97,7 +97,7 @@ void showMessageActions({
|
||||
],
|
||||
),
|
||||
const SizedBox(height: Grid.xs),
|
||||
if (allMessages != null)
|
||||
if (allMessages != null && !message.isSystem)
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.messageSquareReply),
|
||||
title: const Text('Reply in thread'),
|
||||
@@ -117,16 +117,17 @@ void showMessageActions({
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.copy),
|
||||
title: const Text('Copy text'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
// Copy to clipboard
|
||||
final data = ClipboardData(text: message.content);
|
||||
Clipboard.setData(data);
|
||||
},
|
||||
),
|
||||
if (!message.isSystem)
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.copy),
|
||||
title: const Text('Copy text'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
// Copy to clipboard
|
||||
final data = ClipboardData(text: message.content);
|
||||
Clipboard.setData(data);
|
||||
},
|
||||
),
|
||||
if (isOwnMessage) ...[
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.pencil),
|
||||
|
||||
@@ -5,8 +5,24 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../profile/user_cache_provider.dart';
|
||||
import '../profile/user_profile.dart';
|
||||
import 'channel_management_provider.dart';
|
||||
import 'timeline_message.dart';
|
||||
|
||||
/// 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 {
|
||||
actions.addReaction(message.id, emoji);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reaction pills row (shared between channel + thread detail pages)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -7,7 +7,6 @@ import '../../shared/widgets/frosted_app_bar.dart';
|
||||
import '../../shared/widgets/frosted_scaffold.dart';
|
||||
import '../profile/user_cache_provider.dart';
|
||||
import '../profile/user_profile.dart';
|
||||
import 'channel_management_provider.dart';
|
||||
import 'channel_messages_provider.dart';
|
||||
import 'channel_typing_provider.dart';
|
||||
import 'channels_provider.dart';
|
||||
@@ -442,21 +441,7 @@ class _ThreadMessage extends ConsumerWidget {
|
||||
if (message.reactions.isNotEmpty)
|
||||
ReactionRow(
|
||||
reactions: message.reactions,
|
||||
onToggle: (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 {
|
||||
actions.addReaction(message.id, emoji);
|
||||
}
|
||||
},
|
||||
onToggle: (emoji) => toggleReaction(ref, message, emoji),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -265,6 +265,23 @@ List<TimelineMessage> formatTimeline(
|
||||
if (event.kind == EventKind.systemMessage) {
|
||||
final systemEvent = SystemEvent.fromContent(event.content);
|
||||
if (systemEvent != null) {
|
||||
final emojiMap = reactionMap[event.id];
|
||||
final reactions = <TimelineReaction>[
|
||||
if (emojiMap != null)
|
||||
for (final entry in emojiMap.entries)
|
||||
TimelineReaction(
|
||||
emoji: entry.key,
|
||||
count: entry.value.length,
|
||||
reactedByCurrentUser:
|
||||
normalizedCurrentPubkey != null &&
|
||||
entry.value.containsKey(normalizedCurrentPubkey),
|
||||
userPubkeys: entry.value.keys.toList(),
|
||||
currentUserReactionId: normalizedCurrentPubkey != null
|
||||
? entry.value[normalizedCurrentPubkey]
|
||||
: null,
|
||||
),
|
||||
];
|
||||
|
||||
result.add(
|
||||
TimelineMessage(
|
||||
id: event.id,
|
||||
@@ -274,6 +291,7 @@ List<TimelineMessage> formatTimeline(
|
||||
tags: event.tags,
|
||||
isSystem: true,
|
||||
systemEvent: systemEvent,
|
||||
reactions: reactions,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user