diff --git a/packages/chat/src/components/message.tsx b/packages/chat/src/components/message.tsx
index 9848fbf..aa050fd 100644
--- a/packages/chat/src/components/message.tsx
+++ b/packages/chat/src/components/message.tsx
@@ -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 (
)}
- {isValidURL ? (
+ {editing ? (
+
{
+ editMessage(editDraft);
+ setEditing(false);
+ }}
+ />
+ ) : isValidURL ? (
) : (
diff --git a/packages/chat/src/components/messageContextMenu.tsx b/packages/chat/src/components/messageContextMenu.tsx
index a4be6f7..7d3415e 100644
--- a/packages/chat/src/components/messageContextMenu.tsx
+++ b/packages/chat/src/components/messageContextMenu.tsx
@@ -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
;
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 (
@@ -172,9 +183,16 @@ function MessageMenuContent({
- -
-
Edit Message
-
+ {senderId === ownId && (
+ - {
+ onSetEditing(true);
+ }}
+ >
+
Edit Message
+
+ )}
- {
@@ -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}
/>
@@ -282,6 +306,8 @@ export default function MessageContextMenu({
content={content}
devEnabled={devEnabled}
messageId={messageId}
+ onSetEditing={onSetEditing}
+ senderId={senderId}
/>
);
diff --git a/packages/chat/src/context.tsx b/packages/chat/src/context.tsx
index a2b55ff..a2ed7b3 100644
--- a/packages/chat/src/context.tsx
+++ b/packages/chat/src/context.tsx
@@ -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>(
+ 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(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;
diff --git a/packages/chat/todo.md b/packages/chat/todo.md
index 4bff343..1bed8c5 100644
--- a/packages/chat/todo.md
+++ b/packages/chat/todo.md
@@ -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
diff --git a/packages/mtp/src/context.tsx b/packages/mtp/src/context.tsx
index 9f96555..a781c77 100644
--- a/packages/mtp/src/context.tsx
+++ b/packages/mtp/src/context.tsx
@@ -200,6 +200,7 @@ export function Provider(props: {
const unsubscribers = [
"MessageLive",
+ "MessageEditLive",
"MessageState",
"CallInvite",
"ErrorNoIota",
diff --git a/packages/shared/src/data.ts b/packages/shared/src/data.ts
index 4d1fdb4..5bba99a 100644
--- a/packages/shared/src/data.ts
+++ b/packages/shared/src/data.ts
@@ -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({
diff --git a/todo.md b/todo.md
index 6927b1a..71b7816 100644
--- a/todo.md
+++ b/todo.md
@@ -1,3 +1,4 @@
- Move legal to extra onboarding package
- Add a bunch of tests
- Add packages/cache/ to handle caching
+- Full accessability
diff --git a/type-maps.yaml b/type-maps.yaml
index 15f49d7..1e9583b 100644
--- a/type-maps.yaml
+++ b/type-maps.yaml
@@ -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