(feat): unify message patching into a single interface
All checks were successful
/ build-web (push) Successful in 7m11s
/ build-desktop (linux) (push) Successful in 11m52s
/ build-mobile (push) Successful in 19m27s
/ release (push) Successful in 2m53s

This commit is contained in:
Alois 2026-07-07 18:21:52 +02:00
commit a08c41067e
2 changed files with 64 additions and 153 deletions

View file

@ -143,7 +143,7 @@ function MessageComponent({
}, [message.Content]); }, [message.Content]);
// Message editing // Message editing
const { chatSecret, editMessageContent, userId } = useChat(); const { chatSecret, editMessage, userId } = useChat();
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [editDraft, setEditDraft] = useState(message.Content); const [editDraft, setEditDraft] = useState(message.Content);
useEffect(() => { useEffect(() => {
@ -151,7 +151,7 @@ function MessageComponent({
setEditDraft(message.Content); setEditDraft(message.Content);
} }
}, [editing, message.Content]); }, [editing, message.Content]);
const editMessage = useCallback( const submitEditMessage = useCallback(
async (newContent: string) => { async (newContent: string) => {
if (!chatSecret) return; if (!chatSecret) return;
@ -165,7 +165,7 @@ function MessageComponent({
if (!encryptedContent) return; if (!encryptedContent) return;
const previousContent = message.Content; const previousContent = message.Content;
editMessageContent(message.SendTime, newContent); editMessage(message.SendTime, { Content: newContent, Edited: true });
try { try {
const response = await send("MessageEdit", { const response = await send("MessageEdit", {
@ -178,18 +178,17 @@ function MessageComponent({
throw new Error(response.type); throw new Error(response.type);
} }
} catch (err) { } catch (err) {
editMessageContent( editMessage(message.SendTime, {
message.SendTime, Content: previousContent,
previousContent, Edited: message.Edited ?? false,
message.Edited ?? false, });
);
log(1, "chat", "red", "Failed to edit message", err); log(1, "chat", "red", "Failed to edit message", err);
toast("error", "Failed to edit message", String(err)); toast("error", "Failed to edit message", String(err));
} }
}, },
[ [
chatSecret, chatSecret,
editMessageContent, editMessage,
userId, userId,
message.Content, message.Content,
message.Edited, message.Edited,
@ -288,7 +287,7 @@ function MessageComponent({
setValue={setEditDraft} setValue={setEditDraft}
value={editDraft} value={editDraft}
onSubmit={() => { onSubmit={() => {
editMessage(editDraft); submitEditMessage(editDraft);
setEditing(false); setEditing(false);
}} }}
/> />

View file

@ -59,40 +59,15 @@ function bytesFromProtocol(value: unknown): Uint8Array {
throw new Error("expected protocol bytes"); throw new Error("expected protocol bytes");
} }
function updateMessageStateBySendTime< type EditableMessage = RawMessage & { failed?: boolean };
T extends { SendTime: number; MessageState: RawMessage["MessageState"] }, type MessageEdit = Partial<
>( Pick<EditableMessage, "Content" | "Edited" | "MessageState" | "failed">
>;
function updateMessagesBySendTime<T extends EditableMessage>(
messages: T[], messages: T[],
sendTime: number, sendTime: number,
messageState: RawMessage["MessageState"], edit: MessageEdit,
): { next: T[]; updated: boolean } {
let updated = false;
const next = messages.map((item) => {
if (item.SendTime !== sendTime || item.MessageState === messageState) {
return item;
}
updated = true;
return {
...item,
MessageState: messageState,
};
});
return {
next,
updated,
};
}
function updateMessageContentBySendTime<
T extends { SendTime: number; Content: string; Edited?: boolean },
>(
messages: T[],
sendTime: number,
content: string,
edited = true,
): { next: T[]; updated: boolean } { ): { next: T[]; updated: boolean } {
let updated = false; let updated = false;
@ -101,15 +76,18 @@ function updateMessageContentBySendTime<
return item; return item;
} }
if (item.Content === content && item.Edited === edited) { const entries = Object.entries(edit) as Array<
[keyof MessageEdit, MessageEdit[keyof MessageEdit]]
>;
if (entries.every(([key, value]) => item[key] === value)) {
return item; return item;
} }
updated = true; updated = true;
return { return {
...item, ...item,
Content: content, ...edit,
Edited: edited,
}; };
}); });
@ -408,52 +386,14 @@ export default function Provider({ children }: { children: ReactNode }) {
useEffect(() => { useEffect(() => {
load("user_id").then(setOwnId); load("user_id").then(setOwnId);
}, [load]); }, [load]);
const addLiveMessage = useCallback(
(message: RawMessage) => {
const localId =
globalThis.crypto?.randomUUID?.() ??
`${Date.now()}-${Math.random().toString(36).slice(2)}`;
if (message.SenderId !== ownId) { const editMessage = useCallback(
moveUserIdToTop(userIdValue); (sendTime: number, edit: MessageEdit) => {
}
setLiveMessagesState((prev) => [
...prev,
{
...message,
localId,
failed: false,
},
]);
return {
setFailed: (failed: boolean) => {
setLiveMessagesState((prev) =>
prev.map((liveMessage) =>
liveMessage.localId === localId
? { ...liveMessage, failed }
: liveMessage,
),
);
},
};
},
[userIdValue, moveUserIdToTop, ownId],
);
const clearLiveMessages = useCallback(() => {
setLiveMessagesState([]);
}, []);
const editMessageContent = useCallback(
(sendTime: number, content: string, edited = true) => {
setLiveMessagesState((prev) => { setLiveMessagesState((prev) => {
const { next, updated } = updateMessageContentBySendTime( const { next, updated } = updateMessagesBySendTime(
prev, prev,
sendTime, sendTime,
content, edit,
edited,
); );
return updated ? next : prev; return updated ? next : prev;
}); });
@ -473,12 +413,7 @@ export default function Provider({ children }: { children: ReactNode }) {
let updated = false; let updated = false;
const pages = current.pages.map((page) => { const pages = current.pages.map((page) => {
const nextPage = updateMessageContentBySendTime( const nextPage = updateMessagesBySendTime(page, sendTime, edit);
page,
sendTime,
content,
edited,
);
if (nextPage.updated) { if (nextPage.updated) {
updated = true; updated = true;
@ -501,6 +436,38 @@ export default function Provider({ children }: { children: ReactNode }) {
[currentChatSecret, userIdValue], [currentChatSecret, userIdValue],
); );
const addLiveMessage = useCallback(
(message: RawMessage) => {
const localId =
globalThis.crypto?.randomUUID?.() ??
`${Date.now()}-${Math.random().toString(36).slice(2)}`;
if (message.SenderId !== ownId) {
moveUserIdToTop(userIdValue);
}
setLiveMessagesState((prev) => [
...prev,
{
...message,
localId,
failed: false,
},
]);
return {
setFailed: (failed: boolean) => {
editMessage(message.SendTime, { failed });
},
};
},
[editMessage, userIdValue, moveUserIdToTop, ownId],
);
const clearLiveMessages = useCallback(() => {
setLiveMessagesState([]);
}, []);
// Get live updates for message states // Get live updates for message states
useEffect(() => { useEffect(() => {
return subscribePush((message) => { return subscribePush((message) => {
@ -542,7 +509,7 @@ export default function Provider({ children }: { children: ReactNode }) {
void decryptChatText(currentChatSecret, rawData.Content) void decryptChatText(currentChatSecret, rawData.Content)
.then((content) => { .then((content) => {
editMessageContent(sendTime, content); editMessage(sendTime, { Content: content, Edited: true });
}) })
.catch((err) => { .catch((err) => {
log(1, "chat", "red", "Failed to decrypt message edit", err, { log(1, "chat", "red", "Failed to decrypt message edit", err, {
@ -593,62 +560,11 @@ export default function Provider({ children }: { children: ReactNode }) {
return; return;
} }
setLiveMessagesState((prev) => { editMessage(nextState.SendTime, {
const { next, updated } = updateMessageStateBySendTime( MessageState: nextState.MessageState,
prev,
nextState.SendTime,
nextState.MessageState,
);
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 = updateMessageStateBySendTime(
page,
nextState.SendTime,
nextState.MessageState,
);
if (nextPage.updated) {
updated = true;
}
return nextPage.next;
});
if (!updated) {
return current;
}
return {
...current,
pages,
};
},
);
}); });
}, [ }, [currentChatSecret, editMessage, subscribePush, userIdValue]);
addLiveMessage,
currentChatSecret,
editMessageContent,
send,
subscribePush,
userIdValue,
]);
// Replys // Replys
const [replyTo, setReplyTo] = useState<number | undefined>(undefined); const [replyTo, setReplyTo] = useState<number | undefined>(undefined);
@ -661,7 +577,7 @@ export default function Provider({ children }: { children: ReactNode }) {
getChatSecret, getChatSecret,
liveMessages: () => liveMessagesState, liveMessages: () => liveMessagesState,
addLiveMessage, addLiveMessage,
editMessageContent, editMessage,
clearLiveMessages, clearLiveMessages,
chatSecret: currentChatSecret, chatSecret: currentChatSecret,
userId: userIdValue, userId: userIdValue,
@ -685,11 +601,7 @@ type contextType = {
addLiveMessage: (message: RawMessage) => { addLiveMessage: (message: RawMessage) => {
setFailed: (failed: boolean) => void; setFailed: (failed: boolean) => void;
}; };
editMessageContent: ( editMessage: (sendTime: number, edit: MessageEdit) => void;
sendTime: number,
content: string,
edited?: boolean,
) => void;
clearLiveMessages: () => void; clearLiveMessages: () => void;
chatSecret: Uint8Array | null; chatSecret: Uint8Array | null;
userId: number; userId: number;