(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 Media from "./media";
import { useStorage } from "@tensamin/storage/context"; import { useStorage } from "@tensamin/storage/context";
import { useMTP } from "@tensamin/mtp"; 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({ function MessageComponent({
grouped, grouped,
@ -138,6 +142,62 @@ function MessageComponent({
} }
}, [message.Content]); }, [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 ( return (
<div <div
// pt-3 is to get a gap between messages // pt-3 is to get a gap between messages
@ -147,6 +207,8 @@ function MessageComponent({
<MessageContextMenu <MessageContextMenu
content={message.Content} content={message.Content}
messageId={message.SendTime} messageId={message.SendTime}
onSetEditing={setEditing}
senderId={message.SenderId}
> >
<div <div
className={cn( className={cn(
@ -221,7 +283,16 @@ function MessageComponent({
</div> </div>
</div> </div>
)} )}
{isValidURL ? ( {editing ? (
<Input
setValue={setEditDraft}
value={editDraft}
onSubmit={() => {
editMessage(editDraft);
setEditing(false);
}}
/>
) : isValidURL ? (
<Media link={message.Content} /> <Media link={message.Content} />
) : ( ) : (
<Text value={message.Content} /> <Text value={message.Content} />

View file

@ -17,9 +17,10 @@ import {
useIsMobile, useIsMobile,
} from "@tensamin/ui"; } from "@tensamin/ui";
import { Pin, Clipboard, Pen, Reply, Forward, Trash } from "lucide-react"; 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 type { ReactElement, ReactNode } from "react";
import { useChat } from "../context"; import { useChat } from "../context";
import { useStorage } from "@tensamin/storage/context";
async function copyText(text: string) { async function copyText(text: string) {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
@ -136,6 +137,8 @@ function MessageMenuContent({
messageId, messageId,
onAddReaction, onAddReaction,
showReactionItems = true, showReactionItems = true,
onSetEditing,
senderId,
}: { }: {
components: MenuComponents; components: MenuComponents;
content: string; content: string;
@ -143,12 +146,20 @@ function MessageMenuContent({
messageId: number; messageId: number;
onAddReaction?: () => void | Promise<void>; onAddReaction?: () => void | Promise<void>;
showReactionItems?: boolean; showReactionItems?: boolean;
onSetEditing: (value: boolean) => void;
senderId: number;
}) { }) {
const { Content, Group, Item, Separator, Sub, SubContent, SubTrigger } = const { Content, Group, Item, Separator, Sub, SubContent, SubTrigger } =
components; components;
const { load } = useStorage();
const { setReplyTo } = useChat(); const { setReplyTo } = useChat();
const [ownId, setOwnId] = useState(0);
useEffect(() => {
load("user_id").then(setOwnId);
}, [load]);
return ( return (
<Content> <Content>
<Group> <Group>
@ -172,9 +183,16 @@ function MessageMenuContent({
</Group> </Group>
<Separator /> <Separator />
<Group> <Group>
<Item disabled className="flex justify-between"> {senderId === ownId && (
<Item
className="flex justify-between"
onClick={() => {
onSetEditing(true);
}}
>
<p>Edit Message</p> <Pen /> <p>Edit Message</p> <Pen />
</Item> </Item>
)}
<Item <Item
className="flex justify-between" className="flex justify-between"
onClick={() => { onClick={() => {
@ -214,10 +232,14 @@ export default function MessageContextMenu({
children, children,
content, content,
messageId, messageId,
onSetEditing,
senderId,
}: { }: {
children: ReactElement; children: ReactElement;
content: string; content: string;
messageId: number; messageId: number;
onSetEditing: (value: boolean) => void;
senderId: number;
}) { }) {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const devEnabled = useMemo( const devEnabled = useMemo(
@ -257,6 +279,8 @@ export default function MessageContextMenu({
messageId={messageId} messageId={messageId}
onAddReaction={() => setReactionDrawerOpen(true)} onAddReaction={() => setReactionDrawerOpen(true)}
showReactionItems={false} showReactionItems={false}
onSetEditing={onSetEditing}
senderId={senderId}
/> />
</Drawer> </Drawer>
<Drawer open={reactionDrawerOpen} onOpenChange={setReactionDrawerOpen}> <Drawer open={reactionDrawerOpen} onOpenChange={setReactionDrawerOpen}>
@ -282,6 +306,8 @@ export default function MessageContextMenu({
content={content} content={content}
devEnabled={devEnabled} devEnabled={devEnabled}
messageId={messageId} messageId={messageId}
onSetEditing={onSetEditing}
senderId={senderId}
/> />
</ContextMenu> </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 }) { function assertProtocolSuccess(type: string, response: { type: string }) {
if (response.type.startsWith("Error")) { if (response.type.startsWith("Error")) {
throw new Error(`${type} failed: ${response.type}`); throw new Error(`${type} failed: ${response.type}`);
@ -413,9 +446,112 @@ export default function Provider({ children }: { children: ReactNode }) {
setLiveMessagesState([]); 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 // Get live updates for message states
useEffect(() => { useEffect(() => {
return subscribePush((message) => { 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; if (message.type !== "MessageState") return;
const rawData = message.data as { 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 // Replys
const [replyTo, setReplyTo] = useState<number | undefined>(undefined); const [replyTo, setReplyTo] = useState<number | undefined>(undefined);
@ -518,6 +661,7 @@ export default function Provider({ children }: { children: ReactNode }) {
getChatSecret, getChatSecret,
liveMessages: () => liveMessagesState, liveMessages: () => liveMessagesState,
addLiveMessage, addLiveMessage,
editMessageContent,
clearLiveMessages, clearLiveMessages,
chatSecret: currentChatSecret, chatSecret: currentChatSecret,
userId: userIdValue, userId: userIdValue,
@ -541,6 +685,11 @@ type contextType = {
addLiveMessage: (message: RawMessage) => { addLiveMessage: (message: RawMessage) => {
setFailed: (failed: boolean) => void; setFailed: (failed: boolean) => void;
}; };
editMessageContent: (
sendTime: number,
content: string,
edited?: boolean,
) => void;
clearLiveMessages: () => void; clearLiveMessages: () => void;
chatSecret: Uint8Array | null; chatSecret: Uint8Array | null;
userId: number; userId: number;

View file

@ -3,3 +3,4 @@
- Placeholder image if media fails to load - Placeholder image if media fails to load
- Signature verifications via ed25519 key - Signature verifications via ed25519 key
- Confirmation when exiting with text in the input box. - 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 = [ const unsubscribers = [
"MessageLive", "MessageLive",
"MessageEditLive",
"MessageState", "MessageState",
"CallInvite", "CallInvite",
"ErrorNoIota", "ErrorNoIota",

View file

@ -66,6 +66,11 @@ const callSecretEnvelopeRequest = z.object({
WrappingScheme: z.string(), WrappingScheme: z.string(),
}); });
export const Reaction = z.object({
Reaction: z.string(),
SenderId: z.number(),
});
export const Message = z.object({ export const Message = z.object({
NotEncrypted: z.boolean().optional(), NotEncrypted: z.boolean().optional(),
SenderId: z.number(), SenderId: z.number(),
@ -79,6 +84,8 @@ export const Message = z.object({
MessageState: z MessageState: z
.enum(["read", "received", "sent", "sending", "awaiting"]) // awaiting for 'internal' use .enum(["read", "received", "sent", "sending", "awaiting"]) // awaiting for 'internal' use
.default("received"), .default("received"),
Edited: z.boolean().optional(),
Reactions: z.array(Reaction).optional(),
}); });
export const failedUser = { export const failedUser = {
@ -195,6 +202,46 @@ export const mtp = {
PingIota: z.number(), 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: { MessageLive: {
request: z.object({}).optional(), request: z.object({}).optional(),
response: z.object({ response: z.object({

View file

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

View file

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