feat: add delete message support for stream messages (#215)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wes
2026-04-02 19:41:57 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 3e006a2d9e
commit 3450d8a7fb
11 changed files with 141 additions and 18 deletions
+9 -2
View File
@@ -22,6 +22,7 @@ import { HomeView } from "@/features/home/ui/HomeView";
import {
useChannelMessagesQuery,
mergeMessages,
useDeleteMessageMutation,
useEditMessageMutation,
useSendMessageMutation,
useChannelSubscription,
@@ -140,6 +141,7 @@ export function AppShell() {
identityQuery.data,
);
const toggleReactionMutation = useToggleReactionMutation();
const deleteMessageMutation = useDeleteMessageMutation(activeChannel);
const editMessageMutation = useEditMessageMutation(activeChannel);
const availableChannelIds = React.useMemo(
() => new Set(channels.map((channel) => channel.id)),
@@ -222,12 +224,14 @@ export function AppShell() {
const {
handleCancelEdit,
handleCancelReply,
handleDelete,
handleEdit,
handleEditSave,
handleReply,
handleSend,
handleToggleReaction,
} = useChannelPaneHandlers({
deleteMessageMutation,
editMessageMutation,
editTargetId,
replyTargetId,
@@ -424,7 +428,6 @@ export function AppShell() {
if (previousActiveChannelIdRef.current === activeChannelId) {
return;
}
previousActiveChannelIdRef.current = activeChannelId;
setReplyTargetId(null);
requestedAncestorIdsRef.current.clear();
@@ -440,7 +443,10 @@ export function AppShell() {
if (replyTargetId && !replyTargetMessage) {
setReplyTargetId(null);
}
}, [replyTargetId, replyTargetMessage]);
if (editTargetId && !editTargetMessage) {
setEditTargetId(null);
}
}, [editTargetId, editTargetMessage, replyTargetId, replyTargetMessage]);
React.useEffect(() => {
if (!activeChannel || activeChannel.channelType === "forum") {
return;
@@ -753,6 +759,7 @@ export function AppShell() {
messages={timelineMessages}
onCancelEdit={handleCancelEdit}
onCancelReply={handleCancelReply}
onDelete={handleDelete}
onEdit={handleEdit}
onEditSave={handleEditSave}
onReply={handleReply}
+3
View File
@@ -23,6 +23,7 @@ type ChannelPaneProps = {
messages: TimelineMessage[];
onCancelEdit?: () => void;
onCancelReply: () => void;
onDelete?: (message: TimelineMessage) => void;
onEdit?: (message: TimelineMessage) => void;
onEditSave?: (content: string) => Promise<void>;
onReply: (message: TimelineMessage) => void;
@@ -56,6 +57,7 @@ export const ChannelPane = React.memo(function ChannelPane({
messages,
onCancelEdit,
onCancelReply,
onDelete,
onEdit,
onEditSave,
onReply,
@@ -92,6 +94,7 @@ export const ChannelPane = React.memo(function ChannelPane({
}
isLoading={isTimelineLoading}
messages={messages}
onDelete={onDelete}
onEdit={onEdit}
onReply={onReply}
onTargetReached={onTargetReached}
+11
View File
@@ -1,6 +1,7 @@
import * as React from "react";
import type {
useDeleteMessageMutation,
useEditMessageMutation,
useSendMessageMutation,
useToggleReactionMutation,
@@ -15,6 +16,7 @@ import type {
* rather than listing the whole mutation as a dependency.
*/
export function useChannelPaneHandlers({
deleteMessageMutation,
editMessageMutation,
editTargetId,
replyTargetId,
@@ -23,6 +25,7 @@ export function useChannelPaneHandlers({
setReplyTargetId,
toggleReactionMutation,
}: {
deleteMessageMutation: ReturnType<typeof useDeleteMessageMutation>;
editMessageMutation: ReturnType<typeof useEditMessageMutation>;
editTargetId: string | null;
replyTargetId: string | null;
@@ -41,6 +44,9 @@ export function useChannelPaneHandlers({
const sendMutateRef = React.useRef(sendMessageMutation.mutateAsync);
sendMutateRef.current = sendMessageMutation.mutateAsync;
const deleteMutateRef = React.useRef(deleteMessageMutation.mutateAsync);
deleteMutateRef.current = deleteMessageMutation.mutateAsync;
const editMutateRef = React.useRef(editMessageMutation.mutateAsync);
editMutateRef.current = editMessageMutation.mutateAsync;
@@ -55,6 +61,10 @@ export function useChannelPaneHandlers({
setEditTargetId(null);
}, [setEditTargetId]);
const handleDelete = React.useCallback(async (message: { id: string }) => {
await deleteMutateRef.current({ eventId: message.id });
}, []);
const handleEdit = React.useCallback(
(message: { id: string }) => {
setEditTargetId((current) =>
@@ -121,6 +131,7 @@ export function useChannelPaneHandlers({
return {
handleCancelEdit,
handleCancelReply,
handleDelete,
handleEdit,
handleEditSave,
handleReply,
+2 -6
View File
@@ -1,11 +1,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
deleteMessage,
getForumPosts,
getForumThread,
} from "@/shared/api/forum";
import { sendChannelMessage } from "@/shared/api/tauri";
import { getForumPosts, getForumThread } from "@/shared/api/forum";
import { deleteMessage, sendChannelMessage } from "@/shared/api/tauri";
import type {
Channel,
ForumPostsResponse,
+18
View File
@@ -16,6 +16,7 @@ import {
import { relayClient } from "@/shared/api/relayClient";
import {
addReaction,
deleteMessage,
editMessage,
removeReaction,
sendChannelMessage,
@@ -421,6 +422,23 @@ export function useToggleReactionMutation() {
});
}
export function useDeleteMessageMutation(channel: Channel | null) {
const queryClient = useQueryClient();
return useMutation<void, Error, { eventId: string }>({
mutationFn: async ({ eventId }) => {
await deleteMessage(eventId);
},
onSuccess: (_data, { eventId }) => {
if (!channel) return;
queryClient.setQueryData<RelayEvent[]>(
channelMessagesKey(channel.id),
(current = []) => current.filter((message) => message.id !== eventId),
);
},
});
}
export function useEditMessageMutation(channel: Channel | null) {
const queryClient = useQueryClient();
@@ -1,6 +1,12 @@
import Picker from "@emoji-mart/react";
import data from "@emoji-mart/data";
import { CornerUpLeft, LoaderCircle, Pencil, SmilePlus } from "lucide-react";
import {
CornerUpLeft,
LoaderCircle,
Pencil,
SmilePlus,
Trash2,
} from "lucide-react";
import * as React from "react";
import type {
@@ -8,12 +14,23 @@ import type {
TimelineReaction,
} from "@/features/messages/types";
import { cn } from "@/shared/lib/cn";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/ui/alert-dialog";
import { Button } from "@/shared/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
export function MessageActionBar({
activeReplyTargetId = null,
message,
onDelete,
onEdit,
onReactionSelect,
onReply,
@@ -23,6 +40,7 @@ export function MessageActionBar({
}: {
activeReplyTargetId?: string | null;
message: TimelineMessage;
onDelete?: (message: TimelineMessage) => void;
onEdit?: (message: TimelineMessage) => void;
onReactionSelect?: (emoji: string) => Promise<void>;
onReply?: (message: TimelineMessage) => void;
@@ -31,11 +49,18 @@ export function MessageActionBar({
reactionPending?: boolean;
}) {
const [isReactionPickerOpen, setIsReactionPickerOpen] = React.useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false);
const hasDeleteAction = Boolean(onDelete);
const hasEditAction = Boolean(onEdit);
const hasReplyAction = Boolean(onReply);
const hasReactionAction = Boolean(onReactionSelect);
if (!hasReplyAction && !hasReactionAction && !hasEditAction) {
if (
!hasReplyAction &&
!hasReactionAction &&
!hasEditAction &&
!hasDeleteAction
) {
return null;
}
@@ -47,12 +72,12 @@ export function MessageActionBar({
return (
<div
className={cn(
"max-w-28 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-36 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",
"translate-y-0 opacity-100 sm:max-w-0 sm:translate-y-1 sm:opacity-0",
"sm:group-hover/message:max-w-28 sm:group-hover/message:translate-y-0 sm:group-hover/message:opacity-100",
"sm:group-focus-within/message:max-w-28 sm:group-focus-within/message:translate-y-0 sm:group-focus-within/message:opacity-100",
"sm:group-hover/message:max-w-36 sm:group-hover/message:translate-y-0 sm:group-hover/message:opacity-100",
"sm:group-focus-within/message:max-w-36 sm:group-focus-within/message:translate-y-0 sm:group-focus-within/message:opacity-100",
isReplyingToMessage || isReactionPickerOpen
? "sm:max-w-28 sm:translate-y-0 sm:opacity-100"
? "sm:max-w-36 sm:translate-y-0 sm:opacity-100"
: "",
)}
data-testid={`message-action-bar-${message.id}`}
@@ -141,6 +166,56 @@ export function MessageActionBar({
</Button>
) : null}
{hasDeleteAction ? (
<>
<Button
aria-label="Delete"
className="h-6 w-6 rounded-full p-0"
data-testid={`delete-message-${message.id}`}
onClick={() => {
setIsDeleteDialogOpen(true);
}}
size="sm"
title="Delete"
type="button"
variant="ghost"
>
<Trash2 className="h-3 w-3" />
</Button>
<AlertDialog
onOpenChange={setIsDeleteDialogOpen}
open={isDeleteDialogOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete message?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete this message and cannot be
undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Button type="button" variant="outline">
Cancel
</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button
onClick={() => onDelete?.(message)}
type="button"
variant="destructive"
>
Delete
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
) : null}
{hasReplyAction ? (
<Button
aria-label={isReplyingToMessage ? "Cancel reply" : "Reply"}
@@ -20,6 +20,7 @@ export const MessageRow = React.memo(
activeReplyTargetId = null,
highlighted = false,
message,
onDelete,
onEdit,
onToggleReaction,
onReply,
@@ -28,6 +29,7 @@ export const MessageRow = React.memo(
activeReplyTargetId?: string | null;
highlighted?: boolean;
message: TimelineMessage;
onDelete?: (message: TimelineMessage) => void;
onEdit?: (message: TimelineMessage) => void;
onToggleReaction?: (
message: TimelineMessage,
@@ -243,6 +245,7 @@ export const MessageRow = React.memo(
<MessageActionBar
activeReplyTargetId={activeReplyTargetId}
message={message}
onDelete={onDelete}
onEdit={onEdit}
onReactionSelect={
canToggleReactions ? handleReactionSelect : undefined
@@ -24,6 +24,7 @@ type MessageTimelineProps = {
hasOlderMessages?: boolean;
isFetchingOlder?: boolean;
profiles?: UserProfileLookup;
onDelete?: (message: TimelineMessage) => void;
onEdit?: (message: TimelineMessage) => void;
onReply?: (message: TimelineMessage) => void;
onToggleReaction?: (
@@ -47,6 +48,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
hasOlderMessages = true,
isFetchingOlder = false,
profiles,
onDelete,
onEdit,
onReply,
onToggleReaction,
@@ -151,6 +153,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
currentPubkey={currentPubkey}
highlightedMessageId={highlightedMessageId}
messages={messages}
onDelete={onDelete}
onEdit={onEdit}
onReply={onReply}
onToggleReaction={onToggleReaction}
@@ -16,6 +16,7 @@ type TimelineMessageListProps = {
currentPubkey?: string;
highlightedMessageId?: string | null;
messages: TimelineMessage[];
onDelete?: (message: TimelineMessage) => void;
onEdit?: (message: TimelineMessage) => void;
onReply?: (message: TimelineMessage) => void;
onToggleReaction?: (
@@ -31,6 +32,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
currentPubkey,
highlightedMessageId = null,
messages,
onDelete,
onEdit,
onReply,
onToggleReaction,
@@ -69,6 +71,11 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
activeReplyTargetId={activeReplyTargetId}
highlighted={message.id === highlightedMessageId}
message={message}
onDelete={
onDelete && currentPubkey && message.pubkey === currentPubkey
? onDelete
: undefined
}
onEdit={
onEdit && currentPubkey && message.pubkey === currentPubkey
? onEdit
-4
View File
@@ -96,10 +96,6 @@ function fromRawThreadReply(reply: RawThreadReply): ThreadReply {
};
}
export async function deleteMessage(eventId: string): Promise<void> {
await invokeTauri("delete_message", { eventId });
}
export async function getForumPosts(
channelId: string,
limit?: number,
+4
View File
@@ -745,6 +745,10 @@ export async function editMessage(
await invokeTauri("edit_message", { channelId, eventId, content });
}
export async function deleteMessage(eventId: string): Promise<void> {
await invokeTauri("delete_message", { eventId });
}
export async function addReaction(
eventId: string,
emoji: string,