TTP -> MTP, A lot of other stuff #21
8 changed files with 310 additions and 6 deletions
(feat): add message editing
(qol): update todos
commit
8dd73568ce
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
<p>Edit Message</p> <Pen />
|
<Item
|
||||||
</Item>
|
className="flex justify-between"
|
||||||
|
onClick={() => {
|
||||||
|
onSetEditing(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p>Edit Message</p> <Pen />
|
||||||
|
</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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -200,6 +200,7 @@ export function Provider(props: {
|
||||||
|
|
||||||
const unsubscribers = [
|
const unsubscribers = [
|
||||||
"MessageLive",
|
"MessageLive",
|
||||||
|
"MessageEditLive",
|
||||||
"MessageState",
|
"MessageState",
|
||||||
"CallInvite",
|
"CallInvite",
|
||||||
"ErrorNoIota",
|
"ErrorNoIota",
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
1
todo.md
1
todo.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue