(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
50
packages/cache/src/helpers.test.ts
vendored
Normal file
50
packages/cache/src/helpers.test.ts
vendored
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
replaceConversation,
|
||||
selectConversationWindows,
|
||||
trimMessages,
|
||||
} from "./helpers";
|
||||
import type { CachedMessage, ConversationWindow } from "./schemas";
|
||||
|
||||
const message = (SendTime: number): CachedMessage => ({
|
||||
SenderId: 1,
|
||||
SendTime,
|
||||
Content: "Y2lwaGVydGV4dA==",
|
||||
MessageState: "received",
|
||||
});
|
||||
const window = (UserId: number, LastMessageAt: number): ConversationWindow => ({
|
||||
UserId,
|
||||
LastMessageAt,
|
||||
Messages: [],
|
||||
});
|
||||
|
||||
describe("conversation cache helpers", () => {
|
||||
it("selects the five most recent windows", () => {
|
||||
const selected = selectConversationWindows(
|
||||
[
|
||||
window(1, 1),
|
||||
window(2, 6),
|
||||
window(3, 3),
|
||||
window(4, 4),
|
||||
window(5, 5),
|
||||
window(6, 2),
|
||||
],
|
||||
5,
|
||||
);
|
||||
expect(selected.map(({ UserId }) => UserId)).toEqual([2, 5, 4, 3, 6]);
|
||||
});
|
||||
|
||||
it("replaces only the matching conversation", () => {
|
||||
expect(
|
||||
replaceConversation([window(1, 1), window(2, 2)], window(1, 9)),
|
||||
).toEqual([window(1, 9), window(2, 2)]);
|
||||
});
|
||||
|
||||
it("retains the newest messages in chronological order", () => {
|
||||
expect(
|
||||
trimMessages([message(2), message(3), message(1)], 2).map(
|
||||
(item) => item.SendTime,
|
||||
),
|
||||
).toEqual([2, 3]);
|
||||
});
|
||||
});
|
||||
30
packages/cache/src/helpers.ts
vendored
Normal file
30
packages/cache/src/helpers.ts
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import type { CachedMessage, ConversationWindow } from "./schemas";
|
||||
|
||||
export function trimMessages(
|
||||
messages: readonly CachedMessage[],
|
||||
limit: number,
|
||||
): CachedMessage[] {
|
||||
return [...messages]
|
||||
.sort((a, b) => b.SendTime - a.SendTime)
|
||||
.slice(0, limit)
|
||||
.sort((a, b) => a.SendTime - b.SendTime);
|
||||
}
|
||||
|
||||
export function replaceConversation(
|
||||
windows: readonly ConversationWindow[],
|
||||
replacement: ConversationWindow,
|
||||
): ConversationWindow[] {
|
||||
return [
|
||||
replacement,
|
||||
...windows.filter((item) => item.UserId !== replacement.UserId),
|
||||
];
|
||||
}
|
||||
|
||||
export function selectConversationWindows(
|
||||
windows: readonly ConversationWindow[],
|
||||
limit: number,
|
||||
): ConversationWindow[] {
|
||||
return [...windows]
|
||||
.sort((a, b) => b.LastMessageAt - a.LastMessageAt || a.UserId - b.UserId)
|
||||
.slice(0, limit);
|
||||
}
|
||||
276
packages/cache/src/index.ts
vendored
Normal file
276
packages/cache/src/index.ts
vendored
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
import type { z } from "zod";
|
||||
import { storageDefaults } from "@tensamin/shared/data";
|
||||
import {
|
||||
deleteDatabaseEntry,
|
||||
getDatabaseEntry,
|
||||
listDatabaseEntries,
|
||||
setDatabaseEntry,
|
||||
} from "@tensamin/shared/indexedDb";
|
||||
import {
|
||||
replaceConversation,
|
||||
selectConversationWindows,
|
||||
trimMessages,
|
||||
} from "./helpers";
|
||||
import {
|
||||
accountIdSchema,
|
||||
contactsSchema,
|
||||
conversationWindowSchema,
|
||||
userProfileSchema,
|
||||
type Contact,
|
||||
type ConversationWindow,
|
||||
type UserProfile,
|
||||
} from "./schemas";
|
||||
|
||||
export * from "./helpers";
|
||||
export * from "./schemas";
|
||||
|
||||
type CacheStore = "contacts" | "profiles" | "conversations";
|
||||
|
||||
export interface SecureValueCodec {
|
||||
encode(value: unknown): unknown | Promise<unknown>;
|
||||
decode(value: unknown): unknown | Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface CacheOptions {
|
||||
codec?: SecureValueCodec;
|
||||
contacts?: number;
|
||||
messagesPerChat?: number;
|
||||
}
|
||||
|
||||
export interface CacheLifecycle {
|
||||
clearAccount(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
const identityCodec: SecureValueCodec = {
|
||||
encode: (value) => value,
|
||||
decode: (value) => value,
|
||||
};
|
||||
|
||||
export function createCache(accountId: string, options: CacheOptions = {}) {
|
||||
const account = accountIdSchema.parse(accountId);
|
||||
const codec = options.codec ?? identityCodec;
|
||||
const getLimits = async () => {
|
||||
const [storedContacts, storedMessagesPerChat] = await Promise.all([
|
||||
getDatabaseEntry("storage", "cache_contacts"),
|
||||
getDatabaseEntry("storage", "cache_messages_per_chat"),
|
||||
]);
|
||||
return {
|
||||
contacts: Math.max(
|
||||
0,
|
||||
Math.floor(
|
||||
options.contacts ??
|
||||
(typeof storedContacts === "number"
|
||||
? storedContacts
|
||||
: storageDefaults.cache_contacts),
|
||||
),
|
||||
),
|
||||
messagesPerChat: Math.max(
|
||||
0,
|
||||
Math.floor(
|
||||
options.messagesPerChat ??
|
||||
(typeof storedMessagesPerChat === "number"
|
||||
? storedMessagesPerChat
|
||||
: storageDefaults.cache_messages_per_chat),
|
||||
),
|
||||
),
|
||||
};
|
||||
};
|
||||
const prefix = `${account}:`;
|
||||
const storedPrefix = (store: CacheStore) => `${store}:${prefix}`;
|
||||
const storedKey = (store: CacheStore, key: string) =>
|
||||
`${storedPrefix(store)}${key}`;
|
||||
const entries = async (store: CacheStore) => {
|
||||
const storePrefix = storedPrefix(store);
|
||||
return (await listDatabaseEntries("cache", storePrefix)).map(
|
||||
([key, value]) => [key.slice(storePrefix.length), value] as const,
|
||||
);
|
||||
};
|
||||
let closed = false;
|
||||
const ensureOpen = () => {
|
||||
if (closed) throw new Error("Cache is closed");
|
||||
};
|
||||
const read = async <T>(
|
||||
store: CacheStore,
|
||||
key: string,
|
||||
schema: z.ZodType<T>,
|
||||
) => {
|
||||
ensureOpen();
|
||||
const value = await getDatabaseEntry("cache", storedKey(store, key));
|
||||
return value === undefined
|
||||
? undefined
|
||||
: schema.parse(await codec.decode(value));
|
||||
};
|
||||
const write = async <T>(
|
||||
store: CacheStore,
|
||||
key: string,
|
||||
schema: z.ZodType<T>,
|
||||
value: T,
|
||||
) => {
|
||||
ensureOpen();
|
||||
await setDatabaseEntry(
|
||||
"cache",
|
||||
storedKey(store, key),
|
||||
await codec.encode(schema.parse(value)),
|
||||
);
|
||||
};
|
||||
const remove = async (store: CacheStore, key: string) => {
|
||||
ensureOpen();
|
||||
await deleteDatabaseEntry("cache", storedKey(store, key));
|
||||
};
|
||||
const listConversations = async () => {
|
||||
ensureOpen();
|
||||
const storedEntries = await entries("conversations");
|
||||
const windows = await Promise.all(
|
||||
storedEntries.map(async ([, value]) =>
|
||||
conversationWindowSchema.parse(await codec.decode(value)),
|
||||
),
|
||||
);
|
||||
const { contacts } = await getLimits();
|
||||
return selectConversationWindows(windows, contacts);
|
||||
};
|
||||
|
||||
return {
|
||||
contacts: {
|
||||
get: () => read("contacts", "authoritative", contactsSchema),
|
||||
replace: (contacts: Contact[]) =>
|
||||
write("contacts", "authoritative", contactsSchema, contacts),
|
||||
clear: () => remove("contacts", "authoritative"),
|
||||
},
|
||||
profiles: {
|
||||
get: (userId: number) =>
|
||||
read("profiles", String(userId), userProfileSchema),
|
||||
put: (profile: UserProfile) =>
|
||||
write("profiles", String(profile.UserId), userProfileSchema, profile),
|
||||
delete: (userId: number) => remove("profiles", String(userId)),
|
||||
},
|
||||
conversations: {
|
||||
list: listConversations,
|
||||
get: (userId: number) =>
|
||||
read("conversations", String(userId), conversationWindowSchema),
|
||||
replace: async (window: ConversationWindow) => {
|
||||
const { contacts, messagesPerChat } = await getLimits();
|
||||
const candidate = conversationWindowSchema.parse({
|
||||
...window,
|
||||
Messages: trimMessages(window.Messages, messagesPerChat),
|
||||
});
|
||||
const selected = selectConversationWindows(
|
||||
replaceConversation(await listConversations(), candidate),
|
||||
contacts,
|
||||
);
|
||||
await Promise.all(
|
||||
selected.map((item) =>
|
||||
write(
|
||||
"conversations",
|
||||
String(item.UserId),
|
||||
conversationWindowSchema,
|
||||
item,
|
||||
),
|
||||
),
|
||||
);
|
||||
const retained = new Set(selected.map((item) => item.UserId));
|
||||
const storedEntries = await entries("conversations");
|
||||
await Promise.all(
|
||||
storedEntries.flatMap(([key]) => {
|
||||
const userId = Number(key);
|
||||
return retained.has(userId)
|
||||
? []
|
||||
: [
|
||||
deleteDatabaseEntry(
|
||||
"cache",
|
||||
storedKey("conversations", key),
|
||||
),
|
||||
];
|
||||
}),
|
||||
);
|
||||
},
|
||||
replaceSelected: async (windows: ConversationWindow[]) => {
|
||||
const { contacts, messagesPerChat } = await getLimits();
|
||||
const selected = selectConversationWindows(
|
||||
windows.map((window) => ({
|
||||
...window,
|
||||
Messages: trimMessages(window.Messages, messagesPerChat),
|
||||
})),
|
||||
contacts,
|
||||
);
|
||||
await Promise.all(
|
||||
selected.map((item) =>
|
||||
write(
|
||||
"conversations",
|
||||
String(item.UserId),
|
||||
conversationWindowSchema,
|
||||
item,
|
||||
),
|
||||
),
|
||||
);
|
||||
const retained = new Set(selected.map((item) => item.UserId));
|
||||
const storedEntries = await entries("conversations");
|
||||
await Promise.all(
|
||||
storedEntries.flatMap(([key]) => {
|
||||
const userId = Number(key);
|
||||
return retained.has(userId)
|
||||
? []
|
||||
: [
|
||||
deleteDatabaseEntry(
|
||||
"cache",
|
||||
storedKey("conversations", key),
|
||||
),
|
||||
];
|
||||
}),
|
||||
);
|
||||
},
|
||||
prune: async () => {
|
||||
const { messagesPerChat } = await getLimits();
|
||||
const windows = await listConversations();
|
||||
await Promise.all(
|
||||
windows.map((window) =>
|
||||
write(
|
||||
"conversations",
|
||||
String(window.UserId),
|
||||
conversationWindowSchema,
|
||||
{
|
||||
...window,
|
||||
Messages: trimMessages(window.Messages, messagesPerChat),
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
const retained = new Set(windows.map((window) => window.UserId));
|
||||
const storedEntries = await entries("conversations");
|
||||
await Promise.all(
|
||||
storedEntries.flatMap(([key]) =>
|
||||
retained.has(Number(key))
|
||||
? []
|
||||
: [
|
||||
deleteDatabaseEntry(
|
||||
"cache",
|
||||
storedKey("conversations", key),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
delete: (userId: number) => remove("conversations", String(userId)),
|
||||
},
|
||||
clearAccount: async () => {
|
||||
ensureOpen();
|
||||
await Promise.all(
|
||||
(["contacts", "profiles", "conversations"] as CacheStore[]).map(
|
||||
async (store) => {
|
||||
const storedEntries = await entries(store);
|
||||
await Promise.all(
|
||||
storedEntries.map(([key]) =>
|
||||
deleteDatabaseEntry("cache", storedKey(store, key)),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
close: async () => {
|
||||
closed = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type Cache = ReturnType<typeof createCache>;
|
||||
27
packages/cache/src/schemas.ts
vendored
Normal file
27
packages/cache/src/schemas.ts
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { mtp } from "@tensamin/shared/data";
|
||||
import { z } from "zod";
|
||||
|
||||
export const accountIdSchema = z.string().min(1);
|
||||
export const contactSchema = z.object({
|
||||
LastMessageAt: z.number(),
|
||||
UserId: z.number(),
|
||||
LastMessage: z
|
||||
.object({ Content: z.base64(), SenderId: z.number() })
|
||||
.optional(),
|
||||
Messages: z.array(mtp.MessageGet.response),
|
||||
});
|
||||
export const contactsSchema = z.array(contactSchema);
|
||||
export const userProfileSchema = mtp.GetUserData.response;
|
||||
|
||||
// Content remains the protocol base64 ciphertext. This package never decrypts messages.
|
||||
export const cachedMessageSchema = mtp.MessageGet.response;
|
||||
export const conversationWindowSchema = z.object({
|
||||
UserId: z.number(),
|
||||
LastMessageAt: z.number(),
|
||||
Messages: z.array(cachedMessageSchema),
|
||||
});
|
||||
|
||||
export type Contact = z.infer<typeof contactSchema>;
|
||||
export type UserProfile = z.infer<typeof userProfileSchema>;
|
||||
export type CachedMessage = z.infer<typeof cachedMessageSchema>;
|
||||
export type ConversationWindow = z.infer<typeof conversationWindowSchema>;
|
||||
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