(feat): add draft caching
This commit is contained in:
parent
85633a1c81
commit
ab577283f9
5 changed files with 194 additions and 23 deletions
30
packages/cache/src/index.ts
vendored
30
packages/cache/src/index.ts
vendored
|
|
@ -13,9 +13,11 @@ import {
|
||||||
} from "./helpers";
|
} from "./helpers";
|
||||||
import {
|
import {
|
||||||
accountIdSchema,
|
accountIdSchema,
|
||||||
|
chatDraftSchema,
|
||||||
contactsSchema,
|
contactsSchema,
|
||||||
conversationWindowSchema,
|
conversationWindowSchema,
|
||||||
userProfileSchema,
|
userProfileSchema,
|
||||||
|
type ChatDraft,
|
||||||
type Contact,
|
type Contact,
|
||||||
type ConversationWindow,
|
type ConversationWindow,
|
||||||
type UserProfile,
|
type UserProfile,
|
||||||
|
|
@ -24,7 +26,7 @@ import {
|
||||||
export * from "./helpers";
|
export * from "./helpers";
|
||||||
export * from "./schemas";
|
export * from "./schemas";
|
||||||
|
|
||||||
type CacheStore = "contacts" | "profiles" | "conversations";
|
type CacheStore = "contacts" | "profiles" | "conversations" | "drafts";
|
||||||
|
|
||||||
export interface SecureValueCodec {
|
export interface SecureValueCodec {
|
||||||
encode(value: unknown): unknown | Promise<unknown>;
|
encode(value: unknown): unknown | Promise<unknown>;
|
||||||
|
|
@ -237,19 +239,25 @@ export function createCache(accountId: string, options: CacheOptions = {}) {
|
||||||
},
|
},
|
||||||
delete: (userId: number) => remove("conversations", String(userId)),
|
delete: (userId: number) => remove("conversations", String(userId)),
|
||||||
},
|
},
|
||||||
|
drafts: {
|
||||||
|
get: (userId: number) => read("drafts", String(userId), chatDraftSchema),
|
||||||
|
put: (userId: number, draft: ChatDraft) =>
|
||||||
|
write("drafts", String(userId), chatDraftSchema, draft),
|
||||||
|
delete: (userId: number) => remove("drafts", String(userId)),
|
||||||
|
},
|
||||||
clearAccount: async () => {
|
clearAccount: async () => {
|
||||||
ensureOpen();
|
ensureOpen();
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
(["contacts", "profiles", "conversations"] as CacheStore[]).map(
|
(
|
||||||
async (store) => {
|
["contacts", "profiles", "conversations", "drafts"] as CacheStore[]
|
||||||
const storedEntries = await entries(store);
|
).map(async (store) => {
|
||||||
await Promise.all(
|
const storedEntries = await entries(store);
|
||||||
storedEntries.map(([key]) =>
|
await Promise.all(
|
||||||
deleteDatabaseEntry("cache", storedKey(store, key)),
|
storedEntries.map(([key]) =>
|
||||||
),
|
deleteDatabaseEntry("cache", storedKey(store, key)),
|
||||||
);
|
),
|
||||||
},
|
);
|
||||||
),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
close: async () => {
|
close: async () => {
|
||||||
|
|
|
||||||
6
packages/cache/src/schemas.ts
vendored
6
packages/cache/src/schemas.ts
vendored
|
|
@ -20,8 +20,14 @@ export const conversationWindowSchema = z.object({
|
||||||
LastMessageAt: z.number(),
|
LastMessageAt: z.number(),
|
||||||
Messages: z.array(cachedMessageSchema),
|
Messages: z.array(cachedMessageSchema),
|
||||||
});
|
});
|
||||||
|
// Unlike cached messages, draft content is plaintext and must use a secure codec.
|
||||||
|
export const chatDraftSchema = z.object({
|
||||||
|
Content: z.string(),
|
||||||
|
ReplyId: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
export type Contact = z.infer<typeof contactSchema>;
|
export type Contact = z.infer<typeof contactSchema>;
|
||||||
export type UserProfile = z.infer<typeof userProfileSchema>;
|
export type UserProfile = z.infer<typeof userProfileSchema>;
|
||||||
export type CachedMessage = z.infer<typeof cachedMessageSchema>;
|
export type CachedMessage = z.infer<typeof cachedMessageSchema>;
|
||||||
export type ConversationWindow = z.infer<typeof conversationWindowSchema>;
|
export type ConversationWindow = z.infer<typeof conversationWindowSchema>;
|
||||||
|
export type ChatDraft = z.infer<typeof chatDraftSchema>;
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ import { useMTP } from "@tensamin/mtp";
|
||||||
import { log, toast } from "@tensamin/shared/log";
|
import { log, toast } from "@tensamin/shared/log";
|
||||||
import { useSession } from "@tensamin/storage/session";
|
import { useSession } from "@tensamin/storage/session";
|
||||||
import { useUser } from "@tensamin/user/context";
|
import { useUser } from "@tensamin/user/context";
|
||||||
import { createCache } from "@tensamin/cache";
|
import { createCache, type ChatDraft } from "@tensamin/cache";
|
||||||
import { secureValueCodec } from "@tensamin/storage/secure";
|
import { secureValueCodec } from "@tensamin/storage/secure";
|
||||||
|
|
||||||
export const context = createContext<contextType | undefined>(undefined);
|
export const context = createContext<contextType | undefined>(undefined);
|
||||||
|
|
@ -147,6 +147,17 @@ type SendMessageGet = (
|
||||||
|
|
||||||
type GetChatSecret = (userId: number) => Promise<Uint8Array | null>;
|
type GetChatSecret = (userId: number) => Promise<Uint8Array | null>;
|
||||||
|
|
||||||
|
type StoredDraftState = ChatDraft & {
|
||||||
|
accountId: number;
|
||||||
|
userId: number;
|
||||||
|
loaded: boolean;
|
||||||
|
revision: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function draftKey(accountId: number, userId: number) {
|
||||||
|
return `${accountId}:${userId}`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getMessage({
|
export async function getMessage({
|
||||||
sendTime,
|
sendTime,
|
||||||
ownId,
|
ownId,
|
||||||
|
|
@ -226,6 +237,10 @@ export default function Provider({ children }: { children: ReactNode }) {
|
||||||
|
|
||||||
const [liveMessagesState, setLiveMessagesState] = useState<LiveMessage[]>([]);
|
const [liveMessagesState, setLiveMessagesState] = useState<LiveMessage[]>([]);
|
||||||
const [ownId, setOwnId] = useState(0);
|
const [ownId, setOwnId] = useState(0);
|
||||||
|
const [drafts, setDrafts] = useState<Record<string, StoredDraftState>>({});
|
||||||
|
const draftsRef = useRef<Record<string, StoredDraftState>>({});
|
||||||
|
const loadingDraftsRef = useRef(new Set<string>());
|
||||||
|
const draftWriteQueuesRef = useRef(new Map<string, Promise<void>>());
|
||||||
const [currentChatSecretState, setCurrentChatSecretState] = useState<{
|
const [currentChatSecretState, setCurrentChatSecretState] = useState<{
|
||||||
userId: number;
|
userId: number;
|
||||||
value: Uint8Array | null;
|
value: Uint8Array | null;
|
||||||
|
|
@ -258,6 +273,142 @@ export default function Provider({ children }: { children: ReactNode }) {
|
||||||
load("user_id").then(setOwnId);
|
load("user_id").then(setOwnId);
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
|
const persistDraft = useCallback(
|
||||||
|
(key: string, accountId: number, userId: number, draft: ChatDraft) => {
|
||||||
|
const previous =
|
||||||
|
draftWriteQueuesRef.current.get(key) ?? Promise.resolve();
|
||||||
|
const next = previous
|
||||||
|
.catch(() => undefined)
|
||||||
|
.then(async () => {
|
||||||
|
const cache = createCache(String(accountId), {
|
||||||
|
codec: secureValueCodec,
|
||||||
|
});
|
||||||
|
if (draft.Content === "" && draft.ReplyId === undefined) {
|
||||||
|
await cache.drafts.delete(userId);
|
||||||
|
} else {
|
||||||
|
await cache.drafts.put(userId, draft);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
log(1, "chat", "red", "Failed to cache chat draft", err);
|
||||||
|
});
|
||||||
|
draftWriteQueuesRef.current.set(key, next);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateDraft = useCallback(
|
||||||
|
(
|
||||||
|
accountId: number,
|
||||||
|
userId: number,
|
||||||
|
update: (current: ChatDraft) => ChatDraft,
|
||||||
|
) => {
|
||||||
|
const key = draftKey(accountId, userId);
|
||||||
|
const current = draftsRef.current[key] ?? {
|
||||||
|
accountId,
|
||||||
|
userId,
|
||||||
|
Content: "",
|
||||||
|
loaded: false,
|
||||||
|
revision: 0,
|
||||||
|
};
|
||||||
|
const changed = update(current);
|
||||||
|
const next: StoredDraftState = {
|
||||||
|
...current,
|
||||||
|
...changed,
|
||||||
|
revision: current.revision + 1,
|
||||||
|
};
|
||||||
|
const nextDrafts = { ...draftsRef.current, [key]: next };
|
||||||
|
draftsRef.current = nextDrafts;
|
||||||
|
setDrafts(nextDrafts);
|
||||||
|
|
||||||
|
if (next.loaded) {
|
||||||
|
persistDraft(key, accountId, userId, {
|
||||||
|
Content: next.Content,
|
||||||
|
ReplyId: next.ReplyId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[persistDraft],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(ownId) ||
|
||||||
|
ownId <= 0 ||
|
||||||
|
!Number.isSafeInteger(userIdValue) ||
|
||||||
|
userIdValue <= 0
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = draftKey(ownId, userIdValue);
|
||||||
|
if (draftsRef.current[key]?.loaded || loadingDraftsRef.current.has(key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadingDraftsRef.current.add(key);
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
let stored: ChatDraft | undefined;
|
||||||
|
try {
|
||||||
|
stored = await createCache(String(ownId), {
|
||||||
|
codec: secureValueCodec,
|
||||||
|
}).drafts.get(userIdValue);
|
||||||
|
} catch (err) {
|
||||||
|
log(1, "chat", "red", "Failed to restore chat draft", err);
|
||||||
|
} finally {
|
||||||
|
const current = draftsRef.current[key];
|
||||||
|
const next: StoredDraftState =
|
||||||
|
current && current.revision > 0
|
||||||
|
? { ...current, loaded: true }
|
||||||
|
: {
|
||||||
|
accountId: ownId,
|
||||||
|
userId: userIdValue,
|
||||||
|
Content: stored?.Content ?? "",
|
||||||
|
ReplyId: stored?.ReplyId,
|
||||||
|
loaded: true,
|
||||||
|
revision: 0,
|
||||||
|
};
|
||||||
|
const nextDrafts = { ...draftsRef.current, [key]: next };
|
||||||
|
draftsRef.current = nextDrafts;
|
||||||
|
setDrafts(nextDrafts);
|
||||||
|
loadingDraftsRef.current.delete(key);
|
||||||
|
|
||||||
|
if (next.revision > 0) {
|
||||||
|
persistDraft(key, ownId, userIdValue, {
|
||||||
|
Content: next.Content,
|
||||||
|
ReplyId: next.ReplyId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [ownId, persistDraft, userIdValue]);
|
||||||
|
|
||||||
|
const activeDraftKey =
|
||||||
|
ownId > 0 && userIdValue > 0 ? draftKey(ownId, userIdValue) : undefined;
|
||||||
|
const activeDraft = activeDraftKey ? drafts[activeDraftKey] : undefined;
|
||||||
|
const composerValue = activeDraft?.Content ?? "";
|
||||||
|
const replyTo = activeDraft?.ReplyId;
|
||||||
|
const setComposerValue = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
if (ownId <= 0 || userIdValue <= 0) return;
|
||||||
|
updateDraft(ownId, userIdValue, (current) => ({
|
||||||
|
...current,
|
||||||
|
Content: value,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[ownId, updateDraft, userIdValue],
|
||||||
|
);
|
||||||
|
const setReplyTo = useCallback(
|
||||||
|
(value: number | undefined) => {
|
||||||
|
if (ownId <= 0 || userIdValue <= 0) return;
|
||||||
|
updateDraft(ownId, userIdValue, (current) => ({
|
||||||
|
...current,
|
||||||
|
ReplyId: value,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[ownId, updateDraft, userIdValue],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!userIdValue) return;
|
if (!userIdValue) return;
|
||||||
|
|
||||||
|
|
@ -907,12 +1058,6 @@ export default function Provider({ children }: { children: ReactNode }) {
|
||||||
userIdValue,
|
userIdValue,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Replys
|
|
||||||
const [replyTo, setReplyTo] = useState<number | undefined>(undefined);
|
|
||||||
useEffect(() => {
|
|
||||||
setReplyTo(undefined);
|
|
||||||
}, [userIdValue]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<context.Provider
|
<context.Provider
|
||||||
|
|
@ -932,6 +1077,8 @@ export default function Provider({ children }: { children: ReactNode }) {
|
||||||
inputBoxRef,
|
inputBoxRef,
|
||||||
error,
|
error,
|
||||||
errorDescription,
|
errorDescription,
|
||||||
|
composerValue,
|
||||||
|
setComposerValue,
|
||||||
replyTo,
|
replyTo,
|
||||||
setReplyTo,
|
setReplyTo,
|
||||||
}}
|
}}
|
||||||
|
|
@ -960,6 +1107,8 @@ type contextType = {
|
||||||
inputBoxRef: React.RefObject<HTMLDivElement | null>;
|
inputBoxRef: React.RefObject<HTMLDivElement | null>;
|
||||||
error: string;
|
error: string;
|
||||||
errorDescription: string;
|
errorDescription: string;
|
||||||
|
composerValue: string;
|
||||||
|
setComposerValue: (value: string) => void;
|
||||||
replyTo: number | undefined;
|
replyTo: number | undefined;
|
||||||
setReplyTo: (value: number | undefined) => void;
|
setReplyTo: (value: number | undefined) => void;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,8 @@ export default function Screen() {
|
||||||
inputBoxRef,
|
inputBoxRef,
|
||||||
error,
|
error,
|
||||||
errorDescription,
|
errorDescription,
|
||||||
|
composerValue,
|
||||||
|
setComposerValue,
|
||||||
} = useChat();
|
} = useChat();
|
||||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
const topSentinelRef = useRef<HTMLDivElement | null>(null);
|
const topSentinelRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
@ -104,7 +106,6 @@ export default function Screen() {
|
||||||
const [lastLiveMessageCount, setLastLiveMessageCount] = useState(0);
|
const [lastLiveMessageCount, setLastLiveMessageCount] = useState(0);
|
||||||
const [didInitialScroll, setDidInitialScroll] = useState(false);
|
const [didInitialScroll, setDidInitialScroll] = useState(false);
|
||||||
const [viewportHeight, setViewportHeight] = useState(0);
|
const [viewportHeight, setViewportHeight] = useState(0);
|
||||||
const [value, setValue] = useState("");
|
|
||||||
const [editingMessageId, setEditingMessageId] = useState<number | null>(null);
|
const [editingMessageId, setEditingMessageId] = useState<number | null>(null);
|
||||||
const previousEditingMessageIdRef = useRef<number | null>(null);
|
const previousEditingMessageIdRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
|
@ -558,8 +559,8 @@ export default function Screen() {
|
||||||
<div className="z-10 shrink-0">
|
<div className="z-10 shrink-0">
|
||||||
<InputComponent
|
<InputComponent
|
||||||
onEditLastMessage={editLastMessage}
|
onEditLastMessage={editLastMessage}
|
||||||
setValue={setValue}
|
setValue={setComposerValue}
|
||||||
value={value}
|
value={composerValue}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -324,6 +324,7 @@ export default function Input(props: InputProps) {
|
||||||
|
|
||||||
const elementRef = useRef<HTMLDivElement | null>(null);
|
const elementRef = useRef<HTMLDivElement | null>(null);
|
||||||
const viewRef = useRef<EditorView | undefined>(undefined);
|
const viewRef = useRef<EditorView | undefined>(undefined);
|
||||||
|
const setValueRef = useRef(props.setValue);
|
||||||
const onSubmitRef = useRef<InputProps["onSubmit"]>(props.onSubmit);
|
const onSubmitRef = useRef<InputProps["onSubmit"]>(props.onSubmit);
|
||||||
const onEmojiSelectRef = useRef<InputProps["onEmojiSelect"]>(
|
const onEmojiSelectRef = useRef<InputProps["onEmojiSelect"]>(
|
||||||
props.onEmojiSelect,
|
props.onEmojiSelect,
|
||||||
|
|
@ -334,10 +335,16 @@ export default function Input(props: InputProps) {
|
||||||
const completionCompartment = completionCompartmentRef.current;
|
const completionCompartment = completionCompartmentRef.current;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setValueRef.current = props.setValue;
|
||||||
onSubmitRef.current = props.onSubmit;
|
onSubmitRef.current = props.onSubmit;
|
||||||
onEmojiSelectRef.current = props.onEmojiSelect;
|
onEmojiSelectRef.current = props.onEmojiSelect;
|
||||||
invertEnterBehaviorRef.current = Boolean(props.invertEnterBehavior);
|
invertEnterBehaviorRef.current = Boolean(props.invertEnterBehavior);
|
||||||
}, [props.onEmojiSelect, props.onSubmit, props.invertEnterBehavior]);
|
}, [
|
||||||
|
props.onEmojiSelect,
|
||||||
|
props.onSubmit,
|
||||||
|
props.invertEnterBehavior,
|
||||||
|
props.setValue,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!elementRef.current) return;
|
if (!elementRef.current) return;
|
||||||
|
|
@ -346,7 +353,7 @@ export default function Input(props: InputProps) {
|
||||||
doc: props.value,
|
doc: props.value,
|
||||||
extensions: createEditorExtensions(
|
extensions: createEditorExtensions(
|
||||||
(value) => {
|
(value) => {
|
||||||
props.setValue(value);
|
setValueRef.current(value);
|
||||||
},
|
},
|
||||||
() => props.placeholder,
|
() => props.placeholder,
|
||||||
() => invertEnterBehaviorRef.current,
|
() => invertEnterBehaviorRef.current,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue