(feat): add cache package
(feat): improve local storage security (feat): move settings to dedicated settings package
This commit is contained in:
parent
fb095db7a6
commit
790a1db788
54 changed files with 1984 additions and 947 deletions
271
packages/cache/src/sync.tsx
vendored
Normal file
271
packages/cache/src/sync.tsx
vendored
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
createCache,
|
||||
type CachedMessage,
|
||||
type UserProfile,
|
||||
} from "@tensamin/cache";
|
||||
import { useMTP, type MTPExchange, type ProtocolMessage } from "@tensamin/mtp";
|
||||
import { useStorage } from "@tensamin/storage/context";
|
||||
import { secureValueCodec } from "@tensamin/storage/secure";
|
||||
|
||||
function isError(message: ProtocolMessage) {
|
||||
return message.type.startsWith("Error");
|
||||
}
|
||||
|
||||
export default function CacheSync() {
|
||||
const { addInterceptor, contextReady, freshContacts, subscribePush } =
|
||||
useMTP();
|
||||
const { load } = useStorage();
|
||||
const [accountId, setAccountId] = useState(0);
|
||||
const queueRef = useRef(Promise.resolve());
|
||||
const reactionPartnersRef = useRef(new Map<number, number>());
|
||||
|
||||
useEffect(() => {
|
||||
void load("user_id").then(setAccountId);
|
||||
}, [load]);
|
||||
|
||||
const enqueue = useCallback((operation: () => Promise<void>) => {
|
||||
const next = queueRef.current.then(operation);
|
||||
queueRef.current = next.catch(() => undefined);
|
||||
return next;
|
||||
}, []);
|
||||
|
||||
const secureCache = useCallback(
|
||||
() =>
|
||||
createCache(String(accountId), {
|
||||
codec: secureValueCodec,
|
||||
}),
|
||||
[accountId],
|
||||
);
|
||||
|
||||
const replaceMessage = useCallback(
|
||||
async (
|
||||
partnerId: number,
|
||||
sendTime: number,
|
||||
edit: Partial<CachedMessage>,
|
||||
) => {
|
||||
const cache = secureCache();
|
||||
const window = await cache.conversations.get(partnerId);
|
||||
if (!window) return;
|
||||
await cache.conversations.replace({
|
||||
...window,
|
||||
Messages: window.Messages.map((message) =>
|
||||
message.SendTime === sendTime ? { ...message, ...edit } : message,
|
||||
),
|
||||
});
|
||||
},
|
||||
[secureCache],
|
||||
);
|
||||
|
||||
const insertMessage = useCallback(
|
||||
async (partnerId: number, message: CachedMessage) => {
|
||||
const cache = secureCache();
|
||||
const window = await cache.conversations.get(partnerId);
|
||||
await cache.conversations.replace({
|
||||
UserId: partnerId,
|
||||
LastMessageAt: Math.max(window?.LastMessageAt ?? 0, message.SendTime),
|
||||
Messages: [
|
||||
...(window?.Messages ?? []).filter(
|
||||
(cached) => cached.SendTime !== message.SendTime,
|
||||
),
|
||||
message,
|
||||
],
|
||||
});
|
||||
},
|
||||
[secureCache],
|
||||
);
|
||||
|
||||
const removeMessage = useCallback(
|
||||
async (partnerId: number, sendTime: number) => {
|
||||
const cache = secureCache();
|
||||
const window = await cache.conversations.get(partnerId);
|
||||
if (!window) return;
|
||||
await cache.conversations.replace({
|
||||
...window,
|
||||
Messages: window.Messages.filter(
|
||||
(message) => message.SendTime !== sendTime,
|
||||
),
|
||||
});
|
||||
},
|
||||
[secureCache],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accountId || !contextReady) return;
|
||||
void enqueue(async () => {
|
||||
const cache = secureCache();
|
||||
await cache.contacts.replace(freshContacts);
|
||||
await cache.conversations.replaceSelected(
|
||||
freshContacts.map((contact) => ({
|
||||
UserId: contact.UserId,
|
||||
LastMessageAt: contact.LastMessageAt,
|
||||
Messages: contact.Messages,
|
||||
})),
|
||||
);
|
||||
});
|
||||
}, [accountId, contextReady, enqueue, freshContacts, secureCache]);
|
||||
|
||||
const synchronizeExchange = useCallback(
|
||||
async ({ type, data, response }: MTPExchange) => {
|
||||
if (!accountId || isError(response)) return;
|
||||
const request = (data ?? {}) as Record<string, unknown>;
|
||||
const result = response.data as Record<string, unknown>;
|
||||
|
||||
if (type === "GetUserData") {
|
||||
await createCache(String(accountId)).profiles.put(
|
||||
result as unknown as UserProfile,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "MessagesGet" && Number(request.Offset) === 0) {
|
||||
const partnerId = Number(request.UserId);
|
||||
const messages = result.Messages as CachedMessage[];
|
||||
const cache = secureCache();
|
||||
const previous = await cache.conversations.get(partnerId);
|
||||
await cache.conversations.replace({
|
||||
UserId: partnerId,
|
||||
LastMessageAt: Math.max(
|
||||
previous?.LastMessageAt ?? 0,
|
||||
...messages.map((message) => message.SendTime),
|
||||
),
|
||||
// This replacement is authoritative: absent server messages are deleted.
|
||||
Messages: messages,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "MessageSend") {
|
||||
const partnerId = Number(request.ReceiverId);
|
||||
await insertMessage(partnerId, {
|
||||
Content: String(request.Content),
|
||||
Files: request.Files as CachedMessage["Files"],
|
||||
MessageState: "sent",
|
||||
SenderId: accountId,
|
||||
SendTime: Number(request.SendTime),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "MessageEdit") {
|
||||
await replaceMessage(
|
||||
Number(request.ChatPartnerId),
|
||||
Number(request.SendTime),
|
||||
{ Content: String(request.Content), Edited: true },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "MessageDelete") {
|
||||
await removeMessage(
|
||||
Number(request.ChatPartnerId),
|
||||
Number(request.SendTime),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "MessageReactionAdd" || type === "MessageReactionRemove") {
|
||||
const partnerId = Number(request.ChatPartnerId);
|
||||
const sendTime = Number(request.SendTime);
|
||||
const reaction = String(request.Reaction);
|
||||
const cache = secureCache();
|
||||
const window = await cache.conversations.get(partnerId);
|
||||
const message = window?.Messages.find(
|
||||
(candidate) => candidate.SendTime === sendTime,
|
||||
);
|
||||
if (!message) return;
|
||||
const reactions = (message.Reactions ?? []).filter(
|
||||
(candidate) =>
|
||||
candidate.SenderId !== accountId || candidate.Reaction !== reaction,
|
||||
);
|
||||
if (type === "MessageReactionAdd") {
|
||||
reactions.push({ SenderId: accountId, Reaction: reaction });
|
||||
}
|
||||
await replaceMessage(partnerId, sendTime, { Reactions: reactions });
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "MessageState") {
|
||||
await replaceMessage(
|
||||
Number(result.ChatPartnerId),
|
||||
Number(result.SendTime),
|
||||
{
|
||||
MessageState: result.MessageState as CachedMessage["MessageState"],
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "MessageGet") {
|
||||
const message = result as unknown as CachedMessage;
|
||||
const mappedPartner = reactionPartnersRef.current.get(message.SendTime);
|
||||
reactionPartnersRef.current.delete(message.SendTime);
|
||||
if (mappedPartner) {
|
||||
await insertMessage(mappedPartner, message);
|
||||
return;
|
||||
}
|
||||
const windows = await secureCache().conversations.list();
|
||||
const window = windows.find((candidate) =>
|
||||
candidate.Messages.some(
|
||||
(cached) => cached.SendTime === message.SendTime,
|
||||
),
|
||||
);
|
||||
if (window) await insertMessage(window.UserId, message);
|
||||
}
|
||||
},
|
||||
[accountId, insertMessage, removeMessage, replaceMessage, secureCache],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accountId) return;
|
||||
return addInterceptor((exchange) =>
|
||||
enqueue(() => synchronizeExchange(exchange)),
|
||||
);
|
||||
}, [accountId, addInterceptor, enqueue, synchronizeExchange]);
|
||||
|
||||
const synchronizePush = useCallback(
|
||||
async (message: ProtocolMessage) => {
|
||||
if (!accountId || isError(message)) return;
|
||||
const data = message.data as Record<string, unknown>;
|
||||
if (message.type === "MessageLive") {
|
||||
await insertMessage(
|
||||
Number(data.SenderId),
|
||||
data.Message as CachedMessage,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (message.type === "MessageEditLive") {
|
||||
await replaceMessage(
|
||||
Number(data.ChatPartnerId),
|
||||
Number(data.SendTime),
|
||||
{ Content: String(data.Content), Edited: true },
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (message.type === "MessageState") {
|
||||
await replaceMessage(
|
||||
Number(data.ChatPartnerId),
|
||||
Number(data.SendTime),
|
||||
{ MessageState: data.MessageState as CachedMessage["MessageState"] },
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (message.type === "MessageReactionLive") {
|
||||
reactionPartnersRef.current.set(
|
||||
Number(data.SendTime),
|
||||
Number(data.ChatPartnerId),
|
||||
);
|
||||
}
|
||||
},
|
||||
[accountId, insertMessage, replaceMessage],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accountId || !contextReady) return;
|
||||
return subscribePush((message) => {
|
||||
void enqueue(() => synchronizePush(message));
|
||||
});
|
||||
}, [accountId, contextReady, enqueue, subscribePush, synchronizePush]);
|
||||
|
||||
return null;
|
||||
}
|
||||
Loading…
Reference in a new issue