Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@ const rules = [
// Exceptions should stay rare and temporary. Prefer splitting files instead.
const overrides = new Map([
["src-tauri/src/managed_agents/persona_card.rs", 772], // PNG/ZIP persona card codec + provider/model fields + 27 unit tests (~350 lines of tests); rustfmt adds line breaks around long literals/builders
["src/app/AppShell.tsx", 775],
["src/app/AppShell.tsx", 810], // message edit state + handlers + ChannelPane edit prop threading
["src/features/channels/hooks.ts", 550], // canvas query + mutation hooks + DM hide mutation
["src/features/channels/ui/ChannelManagementSheet.tsx", 800],
["src/features/messages/ui/MessageComposer.tsx", 665], // media upload handlers (paste, drop, dialog) + channelId reset effect
["src/features/messages/ui/MessageComposer.tsx", 700], // media upload handlers (paste, drop, dialog) + channelId reset effect + edit mode (pre-fill, save, cancel, escape)
["src/features/settings/ui/SettingsView.tsx", 600],
["src/features/sidebar/ui/AppSidebar.tsx", 850], // channels + forums creation forms
["src/features/tokens/ui/TokenSettingsCard.tsx", 800],
Expand Down
19 changes: 19 additions & 0 deletions desktop/src-tauri/src/commands/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,25 @@ pub async fn remove_reaction(
Ok(())
}

#[tauri::command]
pub async fn edit_message(
channel_id: String,
event_id: String,
content: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let channel_uuid = uuid::Uuid::parse_str(&channel_id)
.map_err(|_| format!("invalid channel UUID: {channel_id}"))?;
let target_eid = EventId::from_hex(&event_id).map_err(|e| format!("invalid event ID: {e}"))?;
let trimmed = content.trim();
if trimmed.is_empty() {
return Err("edit content must not be empty".into());
}
let builder = events::build_message_edit(channel_uuid, target_eid, trimmed)?;
submit_event(builder, &state).await?;
Ok(())
}

#[tauri::command]
pub async fn delete_message(event_id: String, state: State<'_, AppState>) -> Result<(), String> {
let target_eid = EventId::from_hex(&event_id).map_err(|e| format!("invalid event ID: {e}"))?;
Expand Down
14 changes: 14 additions & 0 deletions desktop/src-tauri/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,20 @@ pub fn build_forum_comment(
Ok(EventBuilder::new(Kind::Custom(45003), content).tags(tags))
}

/// Kind 40003 — edit a message.
pub fn build_message_edit(
channel_id: Uuid,
target_event_id: EventId,
content: &str,
) -> Result<EventBuilder, String> {
check_content(content)?;
let tags = vec![
tag(vec!["h", &channel_id.to_string()])?,
tag(vec!["e", &target_event_id.to_hex()])?,
];
Ok(EventBuilder::new(Kind::Custom(40003), content).tags(tags))
}

/// Kind 5 — NIP-09 deletion (messages).
pub fn build_delete_compat(target_event_id: EventId) -> Result<EventBuilder, String> {
let tags = vec![tag(vec!["e", &target_event_id.to_hex()])?];
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ pub fn run() {
send_channel_message,
get_forum_posts,
get_forum_thread,
edit_message,
delete_message,
add_reaction,
remove_reaction,
Expand Down
44 changes: 37 additions & 7 deletions desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { HomeView } from "@/features/home/ui/HomeView";
import {
useChannelMessagesQuery,
mergeMessages,
useEditMessageMutation,
useSendMessageMutation,
useChannelSubscription,
useToggleReactionMutation,
Expand Down Expand Up @@ -86,6 +87,7 @@ export function AppShell() {
const [searchAnchorEvent, setSearchAnchorEvent] =
React.useState<RelayEvent | null>(null);
const [replyTargetId, setReplyTargetId] = React.useState<string | null>(null);
const [editTargetId, setEditTargetId] = React.useState<string | null>(null);
const lastNonSettingsViewRef = React.useRef<MainView>("home");
const queryClient = useQueryClient();
const identityQuery = useIdentityQuery();
Expand Down Expand Up @@ -134,6 +136,7 @@ export function AppShell() {
identityQuery.data,
);
const toggleReactionMutation = useToggleReactionMutation();
const editMessageMutation = useEditMessageMutation(activeChannel);
const availableChannelIds = React.useMemo(
() => new Set(channels.map((channel) => channel.id)),
[channels],
Expand Down Expand Up @@ -206,14 +209,29 @@ export function AppShell() {
timelineMessages.find((message) => message.id === replyTargetId) ?? null,
[replyTargetId, timelineMessages],
);
const editTargetMessage = React.useMemo(
() =>
timelineMessages.find((message) => message.id === editTargetId) ?? null,
[editTargetId, timelineMessages],
);

const { handleCancelReply, handleReply, handleSend, handleToggleReaction } =
useChannelPaneHandlers({
replyTargetId,
sendMessageMutation,
setReplyTargetId,
toggleReactionMutation,
});
const {
handleCancelEdit,
handleCancelReply,
handleEdit,
handleEditSave,
handleReply,
handleSend,
handleToggleReaction,
} = useChannelPaneHandlers({
editMessageMutation,
editTargetId,
replyTargetId,
sendMessageMutation,
setEditTargetId,
setReplyTargetId,
toggleReactionMutation,
});

const handleTargetReached = React.useCallback((messageId: string) => {
setSearchAnchor((current) =>
Expand Down Expand Up @@ -713,10 +731,22 @@ export function AppShell() {
<ChannelPane
activeChannel={activeChannel}
currentPubkey={identityQuery.data?.pubkey}
editTarget={
editTargetMessage
? {
author: editTargetMessage.author,
body: editTargetMessage.body,
id: editTargetMessage.id,
}
: null
}
isSending={sendMessageMutation.isPending}
isTimelineLoading={isTimelineLoading}
messages={timelineMessages}
onCancelEdit={handleCancelEdit}
onCancelReply={handleCancelReply}
onEdit={handleEdit}
onEditSave={handleEditSave}
onReply={handleReply}
onSend={handleSend}
onTargetReached={handleTargetReached}
Expand Down
16 changes: 16 additions & 0 deletions desktop/src/app/ChannelPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,18 @@ import type { Channel } from "@/shared/api/types";
type ChannelPaneProps = {
activeChannel: Channel | null;
currentPubkey?: string;
editTarget?: {
author: string;
body: string;
id: string;
} | null;
isSending: boolean;
isTimelineLoading: boolean;
messages: TimelineMessage[];
onCancelEdit?: () => void;
onCancelReply: () => void;
onEdit?: (message: TimelineMessage) => void;
onEditSave?: (content: string) => Promise<void>;
onReply: (message: TimelineMessage) => void;
onSend: (
content: string,
Expand All @@ -36,10 +44,14 @@ type ChannelPaneProps = {
export const ChannelPane = React.memo(function ChannelPane({
activeChannel,
currentPubkey,
editTarget = null,
isSending,
isTimelineLoading,
messages,
onCancelEdit,
onCancelReply,
onEdit,
onEditSave,
onReply,
onSend,
onTargetReached,
Expand Down Expand Up @@ -71,6 +83,7 @@ export const ChannelPane = React.memo(function ChannelPane({
}
isLoading={isTimelineLoading}
messages={messages}
onEdit={onEdit}
onReply={onReply}
onTargetReached={onTargetReached}
onToggleReaction={onToggleReaction}
Expand All @@ -92,8 +105,11 @@ export const ChannelPane = React.memo(function ChannelPane({
activeChannel.channelType === "forum" ||
isSending
}
editTarget={editTarget}
isSending={isSending}
onCancelEdit={onCancelEdit}
onCancelReply={onCancelReply}
onEditSave={onEditSave}
onSend={onSend}
placeholder={
activeChannel?.archivedAt
Expand Down
54 changes: 51 additions & 3 deletions desktop/src/app/useChannelPaneHandlers.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import * as React from "react";

import type { useSendMessageMutation } from "@/features/messages/hooks";
import type { useToggleReactionMutation } from "@/features/messages/hooks";
import type {
useEditMessageMutation,
useSendMessageMutation,
useToggleReactionMutation,
} from "@/features/messages/hooks";

/**
* Stable callback references for ChannelPane so that keystroke-driven
Expand All @@ -12,37 +15,79 @@ import type { useToggleReactionMutation } from "@/features/messages/hooks";
* rather than listing the whole mutation as a dependency.
*/
export function useChannelPaneHandlers({
editMessageMutation,
editTargetId,
replyTargetId,
sendMessageMutation,
setEditTargetId,
setReplyTargetId,
toggleReactionMutation,
}: {
editMessageMutation: ReturnType<typeof useEditMessageMutation>;
editTargetId: string | null;
replyTargetId: string | null;
sendMessageMutation: ReturnType<typeof useSendMessageMutation>;
setEditTargetId: React.Dispatch<React.SetStateAction<string | null>>;
setReplyTargetId: React.Dispatch<React.SetStateAction<string | null>>;
toggleReactionMutation: ReturnType<typeof useToggleReactionMutation>;
}) {
// Keep mutable values in refs so callbacks never need to list them as deps.
const replyTargetIdRef = React.useRef(replyTargetId);
replyTargetIdRef.current = replyTargetId;

const editTargetIdRef = React.useRef(editTargetId);
editTargetIdRef.current = editTargetId;

const sendMutateRef = React.useRef(sendMessageMutation.mutateAsync);
sendMutateRef.current = sendMessageMutation.mutateAsync;

const editMutateRef = React.useRef(editMessageMutation.mutateAsync);
editMutateRef.current = editMessageMutation.mutateAsync;

const toggleMutateRef = React.useRef(toggleReactionMutation.mutateAsync);
toggleMutateRef.current = toggleReactionMutation.mutateAsync;

const handleCancelReply = React.useCallback(() => {
setReplyTargetId(null);
}, [setReplyTargetId]);

const handleCancelEdit = React.useCallback(() => {
setEditTargetId(null);
}, [setEditTargetId]);

const handleEdit = React.useCallback(
(message: { id: string }) => {
setEditTargetId((current) =>
current === message.id ? null : message.id,
);
// Clear reply when entering edit mode.
setReplyTargetId(null);
},
[setEditTargetId, setReplyTargetId],
);

const handleEditSave = React.useCallback(
async (content: string) => {
const eventId = editTargetIdRef.current;
if (!eventId) {
return;
}

await editMutateRef.current({ eventId, content });
setEditTargetId(null);
},
[setEditTargetId],
);

const handleReply = React.useCallback(
(message: { id: string }) => {
setReplyTargetId((current) =>
current === message.id ? null : message.id,
);
// Clear edit when entering reply mode.
setEditTargetId(null);
},
[setReplyTargetId],
[setReplyTargetId, setEditTargetId],
);

const handleSend = React.useCallback(
Expand Down Expand Up @@ -74,7 +119,10 @@ export function useChannelPaneHandlers({
);

return {
handleCancelEdit,
handleCancelReply,
handleEdit,
handleEditSave,
handleReply,
handleSend,
handleToggleReaction,
Expand Down
35 changes: 35 additions & 0 deletions desktop/src/features/messages/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import { relayClient } from "@/shared/api/relayClient";
import {
addReaction,
editMessage,
removeReaction,
sendChannelMessage,
} from "@/shared/api/tauri";
Expand Down Expand Up @@ -451,3 +452,37 @@ export function useToggleReactionMutation() {
},
});
}

export function useEditMessageMutation(channel: Channel | null) {
const queryClient = useQueryClient();

return useMutation<
void,
Error,
{
eventId: string;
content: string;
}
>({
mutationFn: async ({ eventId, content }) => {
if (!channel) {
throw new Error("No channel selected.");
}

await editMessage(channel.id, eventId, content);
},
onSuccess: (_data, { eventId, content }) => {
if (!channel) {
return;
}

queryClient.setQueryData<RelayEvent[]>(
["channel-messages", channel.id],
(current = []) =>
current.map((message) =>
message.id === eventId ? { ...message, content } : message,
),
);
},
});
}
Loading
Loading