(feat): add message editing
Some checks failed
/ build-web (push) Successful in 6m58s
/ release (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
/ build-mobile (push) Has been cancelled

(qol): update todos
This commit is contained in:
Alois 2026-07-07 18:13:43 +02:00
commit 8dd73568ce
8 changed files with 310 additions and 6 deletions

View file

@ -27,6 +27,10 @@ import MessageContextMenu from "./messageContextMenu";
import Media from "./media";
import { useStorage } from "@tensamin/storage/context";
import { useMTP } from "@tensamin/mtp";
import Input from "@tensamin/markdown/input";
import { useChat } from "../context";
import { encryptChatText } from "@tensamin/crypto/chatSecret";
import { log, toast } from "@tensamin/shared/log";
function MessageComponent({
grouped,
@ -138,6 +142,62 @@ function MessageComponent({
}
}, [message.Content]);
// Message editing
const { chatSecret, editMessageContent, userId } = useChat();
const [editing, setEditing] = useState(false);
const [editDraft, setEditDraft] = useState(message.Content);
useEffect(() => {
if (!editing) {
setEditDraft(message.Content);
}
}, [editing, message.Content]);
const editMessage = useCallback(
async (newContent: string) => {
if (!chatSecret) return;
const encryptedContent = await encryptChatText(
chatSecret,
newContent,
).catch((err) => {
toast("error", "Failed to encrypt edit", String(err));
});
if (!encryptedContent) return;
const previousContent = message.Content;
editMessageContent(message.SendTime, newContent);
try {
const response = await send("MessageEdit", {
ChatPartnerId: userId,
Content: encryptedContent,
SendTime: message.SendTime,
});
if (response.type.startsWith("Error")) {
throw new Error(response.type);
}
} catch (err) {
editMessageContent(
message.SendTime,
previousContent,
message.Edited ?? false,
);
log(1, "chat", "red", "Failed to edit message", err);
toast("error", "Failed to edit message", String(err));
}
},
[
chatSecret,
editMessageContent,
userId,
message.Content,
message.Edited,
message.SendTime,
send,
],
);
return (
<div
// pt-3 is to get a gap between messages
@ -147,6 +207,8 @@ function MessageComponent({
<MessageContextMenu
content={message.Content}
messageId={message.SendTime}
onSetEditing={setEditing}
senderId={message.SenderId}
>
<div
className={cn(
@ -221,7 +283,16 @@ function MessageComponent({
</div>
</div>
)}
{isValidURL ? (
{editing ? (
<Input
setValue={setEditDraft}
value={editDraft}
onSubmit={() => {
editMessage(editDraft);
setEditing(false);
}}
/>
) : isValidURL ? (
<Media link={message.Content} />
) : (
<Text value={message.Content} />

View file

@ -17,9 +17,10 @@ import {
useIsMobile,
} from "@tensamin/ui";
import { Pin, Clipboard, Pen, Reply, Forward, Trash } from "lucide-react";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import type { ReactElement, ReactNode } from "react";
import { useChat } from "../context";
import { useStorage } from "@tensamin/storage/context";
async function copyText(text: string) {
await navigator.clipboard.writeText(text);
@ -136,6 +137,8 @@ function MessageMenuContent({
messageId,
onAddReaction,
showReactionItems = true,
onSetEditing,
senderId,
}: {
components: MenuComponents;
content: string;
@ -143,12 +146,20 @@ function MessageMenuContent({
messageId: number;
onAddReaction?: () => void | Promise<void>;
showReactionItems?: boolean;
onSetEditing: (value: boolean) => void;
senderId: number;
}) {
const { Content, Group, Item, Separator, Sub, SubContent, SubTrigger } =
components;
const { load } = useStorage();
const { setReplyTo } = useChat();
const [ownId, setOwnId] = useState(0);
useEffect(() => {
load("user_id").then(setOwnId);
}, [load]);
return (
<Content>
<Group>
@ -172,9 +183,16 @@ function MessageMenuContent({
</Group>
<Separator />
<Group>
<Item disabled className="flex justify-between">
<p>Edit Message</p> <Pen />
</Item>
{senderId === ownId && (
<Item
className="flex justify-between"
onClick={() => {
onSetEditing(true);
}}
>
<p>Edit Message</p> <Pen />
</Item>
)}
<Item
className="flex justify-between"
onClick={() => {
@ -214,10 +232,14 @@ export default function MessageContextMenu({
children,
content,
messageId,
onSetEditing,
senderId,
}: {
children: ReactElement;
content: string;
messageId: number;
onSetEditing: (value: boolean) => void;
senderId: number;
}) {
const isMobile = useIsMobile();
const devEnabled = useMemo(
@ -257,6 +279,8 @@ export default function MessageContextMenu({
messageId={messageId}
onAddReaction={() => setReactionDrawerOpen(true)}
showReactionItems={false}
onSetEditing={onSetEditing}
senderId={senderId}
/>
</Drawer>
<Drawer open={reactionDrawerOpen} onOpenChange={setReactionDrawerOpen}>
@ -282,6 +306,8 @@ export default function MessageContextMenu({
content={content}
devEnabled={devEnabled}
messageId={messageId}
onSetEditing={onSetEditing}
senderId={senderId}
/>
</ContextMenu>
);

View file

@ -86,6 +86,39 @@ function updateMessageStateBySendTime<
};
}
function updateMessageContentBySendTime<
T extends { SendTime: number; Content: string; Edited?: boolean },
>(
messages: T[],
sendTime: number,
content: string,
edited = true,
): { next: T[]; updated: boolean } {
let updated = false;
const next = messages.map((item) => {
if (item.SendTime !== sendTime) {
return item;
}
if (item.Content === content && item.Edited === edited) {
return item;
}
updated = true;
return {
...item,
Content: content,
Edited: edited,
};
});
return {
next,
updated,
};
}
function assertProtocolSuccess(type: string, response: { type: string }) {
if (response.type.startsWith("Error")) {
throw new Error(`${type} failed: ${response.type}`);
@ -413,9 +446,112 @@ export default function Provider({ children }: { children: ReactNode }) {
setLiveMessagesState([]);
}, []);
const editMessageContent = useCallback(
(sendTime: number, content: string, edited = true) => {
setLiveMessagesState((prev) => {
const { next, updated } = updateMessageContentBySendTime(
prev,
sendTime,
content,
edited,
);
return updated ? next : prev;
});
const queryKey = [
"chat-messages",
String(userIdValue),
currentChatSecret !== null,
] as const;
queryClient.setQueryData<InfiniteData<RawMessages>>(
queryKey,
(current) => {
if (!current) {
return current;
}
let updated = false;
const pages = current.pages.map((page) => {
const nextPage = updateMessageContentBySendTime(
page,
sendTime,
content,
edited,
);
if (nextPage.updated) {
updated = true;
}
return nextPage.next;
});
if (!updated) {
return current;
}
return {
...current,
pages,
};
},
);
},
[currentChatSecret, userIdValue],
);
// Get live updates for message states
useEffect(() => {
return subscribePush((message) => {
if (message.type === "MessageEditLive") {
if (!currentChatSecret) return;
const rawData = message.data as {
ChatPartnerId: unknown;
SendTime: unknown;
Content: string;
};
const chatPartnerId = Number(rawData.ChatPartnerId);
const sendTime = Number(rawData.SendTime);
if (!Number.isFinite(chatPartnerId) || !Number.isFinite(sendTime)) {
log(
3,
"chat",
"yellow",
"Cancel message edit update due to invalid data",
);
return;
}
if (chatPartnerId !== userIdValue) {
log(
3,
"chat",
"yellow",
"Cancel message edit update due to user ID mismatch",
{
expected: userIdValue,
received: chatPartnerId,
},
);
return;
}
void decryptChatText(currentChatSecret, rawData.Content)
.then((content) => {
editMessageContent(sendTime, content);
})
.catch((err) => {
log(1, "chat", "red", "Failed to decrypt message edit", err, {
SendTime: sendTime,
});
});
return;
}
if (message.type !== "MessageState") return;
const rawData = message.data as {
@ -505,7 +641,14 @@ export default function Provider({ children }: { children: ReactNode }) {
},
);
});
}, [addLiveMessage, currentChatSecret, send, subscribePush, userIdValue]);
}, [
addLiveMessage,
currentChatSecret,
editMessageContent,
send,
subscribePush,
userIdValue,
]);
// Replys
const [replyTo, setReplyTo] = useState<number | undefined>(undefined);
@ -518,6 +661,7 @@ export default function Provider({ children }: { children: ReactNode }) {
getChatSecret,
liveMessages: () => liveMessagesState,
addLiveMessage,
editMessageContent,
clearLiveMessages,
chatSecret: currentChatSecret,
userId: userIdValue,
@ -541,6 +685,11 @@ type contextType = {
addLiveMessage: (message: RawMessage) => {
setFailed: (failed: boolean) => void;
};
editMessageContent: (
sendTime: number,
content: string,
edited?: boolean,
) => void;
clearLiveMessages: () => void;
chatSecret: Uint8Array | null;
userId: number;

View file

@ -3,3 +3,4 @@
- Placeholder image if media fails to load
- Signature verifications via ed25519 key
- Confirmation when exiting with text in the input box.
- Add arrow up hotkey to edit last message

View file

@ -200,6 +200,7 @@ export function Provider(props: {
const unsubscribers = [
"MessageLive",
"MessageEditLive",
"MessageState",
"CallInvite",
"ErrorNoIota",

View file

@ -66,6 +66,11 @@ const callSecretEnvelopeRequest = z.object({
WrappingScheme: z.string(),
});
export const Reaction = z.object({
Reaction: z.string(),
SenderId: z.number(),
});
export const Message = z.object({
NotEncrypted: z.boolean().optional(),
SenderId: z.number(),
@ -79,6 +84,8 @@ export const Message = z.object({
MessageState: z
.enum(["read", "received", "sent", "sending", "awaiting"]) // awaiting for 'internal' use
.default("received"),
Edited: z.boolean().optional(),
Reactions: z.array(Reaction).optional(),
});
export const failedUser = {
@ -195,6 +202,46 @@ export const mtp = {
PingIota: z.number(),
}),
},
MessageEditLive: {
request: z.object({}),
response: z.object({
Content: z.base64(),
ChatPartnerId: z.number(),
SendTime: z.number(),
}),
},
MessageEdit: {
request: z.object({
Content: z.base64(),
ChatPartnerId: z.number(),
SendTime: z.number(),
}),
response: z.object({}),
},
MessageReactionAdd: {
request: z.object({
Reaction: z.string(),
SendTime: z.number(),
ChatPartnerId: z.number(),
}),
response: z.object(),
},
MessageReactionRemove: {
request: z.object({
Reaction: z.string(),
SendTime: z.number(),
ChatPartnerId: z.number(),
}),
response: z.object(),
},
MessageReactionLive: {
request: z.object(),
response: z.object({
Reaction: z.string(),
SendTime: z.number(),
ChatPartnerId: z.number(),
}),
},
MessageLive: {
request: z.object({}).optional(),
response: z.object({

View file

@ -1,3 +1,4 @@
- Move legal to extra onboarding package
- Add a bunch of tests
- Add packages/cache/ to handle caching
- Full accessability

View file

@ -143,6 +143,11 @@ type_maps:
GetChatSecret: 140
ChatSecretResponse: 141
ChatSecretForward: 142
MessageEditLive: 144
MessageEdit: 145
MessageReactionAdd: 146
MessageReactionRemove: 147
MessageReactionLive: 148
DataTypes:
ErrorType: 32
ErrorProtocol: 33
@ -257,3 +262,6 @@ type_maps:
SenderUserId: 152
RecipientUserId: 153
Recipients: 154
Edited: 155
Reactions: 156
Reaction: 157