Compare commits
16 changed files with 89 additions and 668 deletions
|
|
@ -1,18 +0,0 @@
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import { mtp } from "@tensamin/shared/data";
|
|
||||||
import { onlineStatusOptions } from "./status-options";
|
|
||||||
|
|
||||||
describe("sidebar status requests", () => {
|
|
||||||
it.each(onlineStatusOptions)("sends $label as $value", ({ value }) => {
|
|
||||||
expect(mtp.SetUserState.request.parse({ UserState: value })).toEqual({
|
|
||||||
UserState: value,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps status text on the profile operation", () => {
|
|
||||||
expect(mtp.ChangeUserData.request.parse({ Status: "At lunch" })).toEqual({
|
|
||||||
Status: "At lunch",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -36,19 +36,23 @@ import { useShowMobileNavbar } from "@/routes/app/useShowMobileNavbar";
|
||||||
import { Ellipsis, Check } from "lucide-react";
|
import { Ellipsis, Check } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { User } from "@tensamin/user/context";
|
import type { User } from "@tensamin/user/context";
|
||||||
import { mtp, userPresencePreferenceSchema } from "@tensamin/shared/data";
|
import type z from "zod";
|
||||||
|
import { mtp } from "@tensamin/shared/data";
|
||||||
import { useMTP } from "@tensamin/mtp";
|
import { useMTP } from "@tensamin/mtp";
|
||||||
import {
|
|
||||||
onlineStatusLabels,
|
|
||||||
onlineStatusOptions,
|
|
||||||
type OnlineStatus,
|
|
||||||
} from "./status-options";
|
|
||||||
|
|
||||||
function accountPreference(status: User["OnlineStatus"]): OnlineStatus {
|
type OnlineStatus = z.infer<typeof mtp.GetUserData.response.shape.OnlineStatus>;
|
||||||
return userPresencePreferenceSchema.safeParse(status).success
|
|
||||||
? (status as OnlineStatus)
|
const onlineStatusLabels: Record<OnlineStatus, string> = {
|
||||||
: "user_online";
|
user_online: "Online",
|
||||||
}
|
user_offline: "Offline",
|
||||||
|
user_dnd: "Do not disturb",
|
||||||
|
user_idle: "Idle",
|
||||||
|
user_wc: "Away",
|
||||||
|
user_borked: "Borked",
|
||||||
|
iota_offline: "Iota offline",
|
||||||
|
iota_online: "Iota online",
|
||||||
|
iota_borked: "Iota borked",
|
||||||
|
};
|
||||||
|
|
||||||
function StatusDialog({
|
function StatusDialog({
|
||||||
user,
|
user,
|
||||||
|
|
@ -83,7 +87,7 @@ function StatusDialog({
|
||||||
onOpenChange={(nextOpen) => {
|
onOpenChange={(nextOpen) => {
|
||||||
if (!nextOpen) {
|
if (!nextOpen) {
|
||||||
setDraftStatus(user.Status ?? "");
|
setDraftStatus(user.Status ?? "");
|
||||||
setDraftOnlineStatus(accountPreference(user.OnlineStatus));
|
setDraftOnlineStatus(user.OnlineStatus);
|
||||||
setErrorMessage("");
|
setErrorMessage("");
|
||||||
setSaveSucceeded(false);
|
setSaveSucceeded(false);
|
||||||
}
|
}
|
||||||
|
|
@ -122,11 +126,10 @@ function StatusDialog({
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="p-1">
|
<SelectContent className="p-1">
|
||||||
{onlineStatusOptions.map((option) => (
|
<SelectItem value="user_online">Online</SelectItem>
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<SelectItem value="user_offline">Offline</SelectItem>
|
||||||
{option.label}
|
<SelectItem value="user_idle">Idle</SelectItem>
|
||||||
</SelectItem>
|
<SelectItem value="user_dnd">Do not disturb</SelectItem>
|
||||||
))}
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -137,9 +140,12 @@ function StatusDialog({
|
||||||
<DialogClose render={<Button variant="destructive">Cancel</Button>} />
|
<DialogClose render={<Button variant="destructive">Cancel</Button>} />
|
||||||
<Button
|
<Button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const payload = { UserState: draftOnlineStatus };
|
const payload = {
|
||||||
|
...(draftStatus && { status: draftStatus }),
|
||||||
|
OnlineStatus: draftOnlineStatus,
|
||||||
|
};
|
||||||
|
|
||||||
const validation = mtp.SetUserState.request.safeParse(payload);
|
const validation = mtp.ChangeUserData.request.safeParse(payload);
|
||||||
|
|
||||||
if (!validation.success) {
|
if (!validation.success) {
|
||||||
setSaveSucceeded(false);
|
setSaveSucceeded(false);
|
||||||
|
|
@ -150,17 +156,7 @@ function StatusDialog({
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await send("SetUserState", validation.data);
|
await send("ChangeUserData", validation.data);
|
||||||
const profileValidation = mtp.ChangeUserData.request.safeParse({
|
|
||||||
Status: draftStatus,
|
|
||||||
});
|
|
||||||
if (!profileValidation.success) {
|
|
||||||
throw new Error(
|
|
||||||
profileValidation.error.issues[0]?.message ??
|
|
||||||
"Invalid status data",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await send("ChangeUserData", profileValidation.data);
|
|
||||||
setSaveSucceeded(true);
|
setSaveSucceeded(true);
|
||||||
setErrorMessage("");
|
setErrorMessage("");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -231,9 +227,7 @@ export default function Sidebar() {
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDraftStatus(user.Status ?? "");
|
setDraftStatus(user.Status ?? "");
|
||||||
setDraftOnlineStatus(
|
setDraftOnlineStatus(user.OnlineStatus);
|
||||||
accountPreference(user.OnlineStatus),
|
|
||||||
);
|
|
||||||
setStatusErrorMessage("");
|
setStatusErrorMessage("");
|
||||||
setStatusSaveSucceeded(false);
|
setStatusSaveSucceeded(false);
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
|
|
@ -252,9 +246,7 @@ export default function Sidebar() {
|
||||||
onOpenChange={(nextOpen) => {
|
onOpenChange={(nextOpen) => {
|
||||||
if (!nextOpen) {
|
if (!nextOpen) {
|
||||||
setDraftStatus(user.Status ?? "");
|
setDraftStatus(user.Status ?? "");
|
||||||
setDraftOnlineStatus(
|
setDraftOnlineStatus(user.OnlineStatus);
|
||||||
accountPreference(user.OnlineStatus),
|
|
||||||
);
|
|
||||||
setStatusErrorMessage("");
|
setStatusErrorMessage("");
|
||||||
setStatusSaveSucceeded(false);
|
setStatusSaveSucceeded(false);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import { onlineStatusOptions } from "./status-options";
|
|
||||||
|
|
||||||
describe("status options", () => {
|
|
||||||
it("uses the exact client protocol values", () => {
|
|
||||||
expect(onlineStatusOptions).toEqual([
|
|
||||||
{ label: "Online", value: "user_online" },
|
|
||||||
{ label: "Idle", value: "user_idle" },
|
|
||||||
{ label: "Do not disturb", value: "user_dnd" },
|
|
||||||
{ label: "Away", value: "user_wc" },
|
|
||||||
{ label: "Offline", value: "user_invisible" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
export const onlineStatusOptions = [
|
|
||||||
{ label: "Online", value: "user_online" },
|
|
||||||
{ label: "Idle", value: "user_idle" },
|
|
||||||
{ label: "Do not disturb", value: "user_dnd" },
|
|
||||||
{ label: "Away", value: "user_wc" },
|
|
||||||
{ label: "Offline", value: "user_invisible" },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export type OnlineStatus = (typeof onlineStatusOptions)[number]["value"];
|
|
||||||
|
|
||||||
export const onlineStatusLabels = Object.fromEntries(
|
|
||||||
onlineStatusOptions.map((option) => [option.value, option.label]),
|
|
||||||
) as Record<OnlineStatus, string>;
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
Subproject commit 486541b9483356ff49ff3ec7016f87d3ecbeaa0e
|
Subproject commit 11a1d79409857b734e948dd8c3e28e6ba721d15f
|
||||||
|
|
@ -45,10 +45,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@methanium/ui": "*",
|
"@methanium/ui": "*",
|
||||||
"mtp": "*",
|
"mtp": "*",
|
||||||
"sonner": "^2.0.7"
|
"sonner": "^2.0.7",
|
||||||
},
|
"yaml": "^2.9.0"
|
||||||
"overrides": {
|
|
||||||
"@methanium/ui": "https://git.methanium.net/methanium/ui/releases/download/0.0.22/methanium-ui.tgz",
|
|
||||||
"mtp": "https://git.methanium.net/methanium/mtp/releases/download/0.2.0-dev-a692bed/mtp-0.2.0.tgz"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
19
packages/cache/src/sync.test.tsx
vendored
19
packages/cache/src/sync.test.tsx
vendored
|
|
@ -1,19 +0,0 @@
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import { removeMissingContactSnapshots } from "./sync";
|
|
||||||
|
|
||||||
describe("authoritative contact cache updates", () => {
|
|
||||||
it("removes missing users and keeps the latest authoritative set", () => {
|
|
||||||
const contacts = [{ UserId: 2 }, { UserId: 3 }, { UserId: 4 }];
|
|
||||||
expect(removeMissingContactSnapshots(contacts, [3, 99])).toEqual([
|
|
||||||
{ UserId: 2 },
|
|
||||||
{ UserId: 4 },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not let a delayed state notification recreate a removed contact", () => {
|
|
||||||
const contacts = [{ UserId: 2 }];
|
|
||||||
const afterMissing = removeMissingContactSnapshots(contacts, [2]);
|
|
||||||
expect(removeMissingContactSnapshots(afterMissing, [])).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
57
packages/cache/src/sync.tsx
vendored
57
packages/cache/src/sync.tsx
vendored
|
|
@ -12,14 +12,6 @@ function isError(message: ProtocolMessage) {
|
||||||
return message.type.startsWith("Error");
|
return message.type.startsWith("Error");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeMissingContactSnapshots<T extends { UserId: number }>(
|
|
||||||
contacts: T[],
|
|
||||||
missingUserIds: readonly number[],
|
|
||||||
): T[] {
|
|
||||||
const missing = new Set(missingUserIds);
|
|
||||||
return contacts.filter((contact) => !missing.has(contact.UserId));
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CacheSync() {
|
export default function CacheSync() {
|
||||||
const { addInterceptor, contextReady, freshContacts, subscribePush } =
|
const { addInterceptor, contextReady, freshContacts, subscribePush } =
|
||||||
useMTP();
|
useMTP();
|
||||||
|
|
@ -45,20 +37,6 @@ export default function CacheSync() {
|
||||||
[accountId],
|
[accountId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const removeMissingContacts = useCallback(
|
|
||||||
async (userIds: number[]) => {
|
|
||||||
if (userIds.length === 0) return;
|
|
||||||
const cache = secureCache();
|
|
||||||
const contacts = await cache.contacts.get();
|
|
||||||
if (!contacts) return;
|
|
||||||
const remaining = removeMissingContactSnapshots(contacts, userIds);
|
|
||||||
if (remaining.length === contacts.length) return;
|
|
||||||
await cache.contacts.replace(remaining);
|
|
||||||
await cache.conversations.replaceSelected(remaining);
|
|
||||||
},
|
|
||||||
[secureCache],
|
|
||||||
);
|
|
||||||
|
|
||||||
const replaceMessage = useCallback(
|
const replaceMessage = useCallback(
|
||||||
async (
|
async (
|
||||||
partnerId: number,
|
partnerId: number,
|
||||||
|
|
@ -132,15 +110,6 @@ export default function CacheSync() {
|
||||||
const request = (data ?? {}) as Record<string, unknown>;
|
const request = (data ?? {}) as Record<string, unknown>;
|
||||||
const result = response.data as Record<string, unknown>;
|
const result = response.data as Record<string, unknown>;
|
||||||
|
|
||||||
if (type === "GetStates" && Array.isArray(result.MissingUserIds)) {
|
|
||||||
await removeMissingContacts(
|
|
||||||
result.MissingUserIds.filter(
|
|
||||||
(userId): userId is number => typeof userId === "number",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "GetUserData") {
|
if (type === "GetUserData") {
|
||||||
await createCache(String(accountId)).profiles.put(
|
await createCache(String(accountId)).profiles.put(
|
||||||
result as unknown as UserProfile,
|
result as unknown as UserProfile,
|
||||||
|
|
@ -227,14 +196,7 @@ export default function CacheSync() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[accountId, insertMessage, removeMessage, replaceMessage, secureCache],
|
||||||
accountId,
|
|
||||||
insertMessage,
|
|
||||||
removeMissingContacts,
|
|
||||||
removeMessage,
|
|
||||||
replaceMessage,
|
|
||||||
secureCache,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -248,14 +210,6 @@ export default function CacheSync() {
|
||||||
async (message: ProtocolMessage) => {
|
async (message: ProtocolMessage) => {
|
||||||
if (!accountId || isError(message)) return;
|
if (!accountId || isError(message)) return;
|
||||||
const data = message.data as Record<string, unknown>;
|
const data = message.data as Record<string, unknown>;
|
||||||
if (message.type === "GetStates" && Array.isArray(data.MissingUserIds)) {
|
|
||||||
await removeMissingContacts(
|
|
||||||
data.MissingUserIds.filter(
|
|
||||||
(userId): userId is number => typeof userId === "number",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (message.type === "MessageLive") {
|
if (message.type === "MessageLive") {
|
||||||
await insertMessage(
|
await insertMessage(
|
||||||
Number(data.SenderId),
|
Number(data.SenderId),
|
||||||
|
|
@ -309,14 +263,7 @@ export default function CacheSync() {
|
||||||
await replaceMessage(partnerId, sendTime, { Reactions: reactions });
|
await replaceMessage(partnerId, sendTime, { Reactions: reactions });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[accountId, insertMessage, removeMessage, replaceMessage, secureCache],
|
||||||
accountId,
|
|
||||||
insertMessage,
|
|
||||||
removeMessage,
|
|
||||||
removeMissingContacts,
|
|
||||||
replaceMessage,
|
|
||||||
secureCache,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import { isPushType, validateResponse } from "./context";
|
|
||||||
|
|
||||||
describe("MTP protocol dispatch", () => {
|
|
||||||
it("preserves protocol errors for the request layer", () => {
|
|
||||||
const error = validateResponse("GetStates", {
|
|
||||||
id: 12,
|
|
||||||
type: "ErrorInternal",
|
|
||||||
data: { ErrorType: "temporary" },
|
|
||||||
});
|
|
||||||
expect(error.type).toBe("ErrorInternal");
|
|
||||||
expect(error.id).toBe(12);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("recognizes initial and live presence pushes", () => {
|
|
||||||
expect(isPushType("GetStates")).toBe(true);
|
|
||||||
expect(isPushType("ClientChanged")).toBe(true);
|
|
||||||
expect(isPushType("UnknownMessage")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -24,7 +24,6 @@ import {
|
||||||
type MTP as Schemas,
|
type MTP as Schemas,
|
||||||
} from "@tensamin/shared/data";
|
} from "@tensamin/shared/data";
|
||||||
import { log } from "@tensamin/shared/log";
|
import { log } from "@tensamin/shared/log";
|
||||||
import { ProtocolError } from "@tensamin/shared/errors";
|
|
||||||
import { useStorage } from "@tensamin/storage/context";
|
import { useStorage } from "@tensamin/storage/context";
|
||||||
|
|
||||||
import { RECONNECT_RESET, RECONNECT_TRIES, RETRY_INTERVAL } from "./values";
|
import { RECONNECT_RESET, RECONNECT_TRIES, RETRY_INTERVAL } from "./values";
|
||||||
|
|
@ -63,30 +62,9 @@ const PUSH_TYPES = [
|
||||||
"MessageDeleteLive",
|
"MessageDeleteLive",
|
||||||
"MessageState",
|
"MessageState",
|
||||||
"CallInvite",
|
"CallInvite",
|
||||||
"GetStates",
|
|
||||||
"ClientChanged",
|
|
||||||
"ErrorNoIota",
|
"ErrorNoIota",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function isPushType(type: string): boolean {
|
|
||||||
return (PUSH_TYPES as readonly string[]).includes(type);
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeMissingContacts(
|
|
||||||
contacts: Contacts,
|
|
||||||
message: ProtocolMessage,
|
|
||||||
): Contacts {
|
|
||||||
if (message.type !== "GetStates") return contacts;
|
|
||||||
const data = message.data as { MissingUserIds?: unknown };
|
|
||||||
if (!Array.isArray(data.MissingUserIds)) return contacts;
|
|
||||||
const missing = new Set(
|
|
||||||
data.MissingUserIds.filter(
|
|
||||||
(userId): userId is number => typeof userId === "number",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return contacts.filter((contact) => !missing.has(contact.UserId));
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MTPExchange = {
|
export type MTPExchange = {
|
||||||
type: keyof Schemas & string;
|
type: keyof Schemas & string;
|
||||||
data: unknown;
|
data: unknown;
|
||||||
|
|
@ -132,7 +110,7 @@ function getProtocolErrorDetails(error: unknown) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zod schema validation
|
// Zod schema validation
|
||||||
export function validateResponse<T extends keyof Schemas & string>(
|
function validateResponse<T extends keyof Schemas & string>(
|
||||||
type: T,
|
type: T,
|
||||||
message: { id?: number; type: string; data: unknown },
|
message: { id?: number; type: string; data: unknown },
|
||||||
): ProtocolMessage<T> {
|
): ProtocolMessage<T> {
|
||||||
|
|
@ -164,26 +142,15 @@ export function validateResponse<T extends keyof Schemas & string>(
|
||||||
function useMessageHandlers() {
|
function useMessageHandlers() {
|
||||||
const interceptorsRef = useRef(new Set<MTPInterceptor>());
|
const interceptorsRef = useRef(new Set<MTPInterceptor>());
|
||||||
const pushHandlersRef = useRef(new Set<PushHandler>());
|
const pushHandlersRef = useRef(new Set<PushHandler>());
|
||||||
const lastInitialStateRef = useRef<ProtocolMessage | null>(null);
|
|
||||||
const subscribePush = useCallback((handler: PushHandler) => {
|
const subscribePush = useCallback((handler: PushHandler) => {
|
||||||
pushHandlersRef.current.add(handler);
|
pushHandlersRef.current.add(handler);
|
||||||
const initialState = lastInitialStateRef.current;
|
|
||||||
if (initialState?.type === "GetStates") {
|
|
||||||
void Promise.resolve(handler(initialState)).catch(() => undefined);
|
|
||||||
}
|
|
||||||
return () => pushHandlersRef.current.delete(handler);
|
return () => pushHandlersRef.current.delete(handler);
|
||||||
}, []);
|
}, []);
|
||||||
const addInterceptor = useCallback((interceptor: MTPInterceptor) => {
|
const addInterceptor = useCallback((interceptor: MTPInterceptor) => {
|
||||||
interceptorsRef.current.add(interceptor);
|
interceptorsRef.current.add(interceptor);
|
||||||
return () => interceptorsRef.current.delete(interceptor);
|
return () => interceptorsRef.current.delete(interceptor);
|
||||||
}, []);
|
}, []);
|
||||||
return {
|
return { addInterceptor, interceptorsRef, pushHandlersRef, subscribePush };
|
||||||
addInterceptor,
|
|
||||||
interceptorsRef,
|
|
||||||
lastInitialStateRef,
|
|
||||||
pushHandlersRef,
|
|
||||||
subscribePush,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function BrowserProvider(props: {
|
function BrowserProvider(props: {
|
||||||
|
|
@ -205,13 +172,8 @@ function BrowserProvider(props: {
|
||||||
const clientRef = useRef<Awaited<ReturnType<typeof MTPClient.create>> | null>(
|
const clientRef = useRef<Awaited<ReturnType<typeof MTPClient.create>> | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const {
|
const { addInterceptor, interceptorsRef, pushHandlersRef, subscribePush } =
|
||||||
addInterceptor,
|
useMessageHandlers();
|
||||||
interceptorsRef,
|
|
||||||
lastInitialStateRef,
|
|
||||||
pushHandlersRef,
|
|
||||||
subscribePush,
|
|
||||||
} = useMessageHandlers();
|
|
||||||
|
|
||||||
const connected = readyState === ConnectionState.Connected;
|
const connected = readyState === ConnectionState.Connected;
|
||||||
|
|
||||||
|
|
@ -235,20 +197,7 @@ function BrowserProvider(props: {
|
||||||
(data ?? {}) as Record<string, unknown>,
|
(data ?? {}) as Record<string, unknown>,
|
||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
const response = validateResponse(type, message);
|
return validateResponse(type, message);
|
||||||
setFreshContacts((contacts) => removeMissingContacts(contacts, response));
|
|
||||||
if (response.type.startsWith("Error")) {
|
|
||||||
const errorData = response.data as Record<string, unknown>;
|
|
||||||
throw new ProtocolError({
|
|
||||||
type: response.type,
|
|
||||||
requestId: response.id,
|
|
||||||
errorType:
|
|
||||||
typeof errorData.ErrorType === "string"
|
|
||||||
? errorData.ErrorType
|
|
||||||
: undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return response;
|
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
@ -469,9 +418,6 @@ function BrowserProvider(props: {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setFreshContacts((contacts) =>
|
|
||||||
removeMissingContacts(contacts, validated),
|
|
||||||
);
|
|
||||||
for (const handler of [...pushHandlersRef.current]) {
|
for (const handler of [...pushHandlersRef.current]) {
|
||||||
void Promise.resolve()
|
void Promise.resolve()
|
||||||
.then(() => handler(validated))
|
.then(() => handler(validated))
|
||||||
|
|
@ -479,9 +425,6 @@ function BrowserProvider(props: {
|
||||||
log(1, "mtp", "red", "Push handler failed", error, { type });
|
log(1, "mtp", "red", "Push handler failed", error, { type });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (validated.type === "GetStates") {
|
|
||||||
lastInitialStateRef.current = validated;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setReadyState(activeClient.state);
|
setReadyState(activeClient.state);
|
||||||
|
|
@ -594,13 +537,7 @@ function BrowserProvider(props: {
|
||||||
setIdentifying(false);
|
setIdentifying(false);
|
||||||
sonnerToast.dismiss("mtp-connection-toast");
|
sonnerToast.dismiss("mtp-connection-toast");
|
||||||
};
|
};
|
||||||
}, [
|
}, [mtpUrl, props.blockConnection, load, pushHandlersRef]);
|
||||||
lastInitialStateRef,
|
|
||||||
mtpUrl,
|
|
||||||
props.blockConnection,
|
|
||||||
load,
|
|
||||||
pushHandlersRef,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// No Iota check
|
// No Iota check
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -707,13 +644,8 @@ function TauriProvider(props: {
|
||||||
const [freshCommunities, setFreshCommunities] = useState<Communities>([]);
|
const [freshCommunities, setFreshCommunities] = useState<Communities>([]);
|
||||||
const [freshCalls, setFreshCalls] = useState<Calls>([]);
|
const [freshCalls, setFreshCalls] = useState<Calls>([]);
|
||||||
const generationRef = useRef(0);
|
const generationRef = useRef(0);
|
||||||
const {
|
const { addInterceptor, interceptorsRef, pushHandlersRef, subscribePush } =
|
||||||
addInterceptor,
|
useMessageHandlers();
|
||||||
interceptorsRef,
|
|
||||||
lastInitialStateRef,
|
|
||||||
pushHandlersRef,
|
|
||||||
subscribePush,
|
|
||||||
} = useMessageHandlers();
|
|
||||||
const subscriptionsRef = useRef(
|
const subscriptionsRef = useRef(
|
||||||
new Map<string, Set<(message: ProtocolMessage) => void>>(),
|
new Map<string, Set<(message: ProtocolMessage) => void>>(),
|
||||||
);
|
);
|
||||||
|
|
@ -754,10 +686,7 @@ function TauriProvider(props: {
|
||||||
[]) {
|
[]) {
|
||||||
handler(validated);
|
handler(validated);
|
||||||
}
|
}
|
||||||
if (!isPushType(validated.type)) return;
|
if (!(PUSH_TYPES as readonly string[]).includes(validated.type)) return;
|
||||||
setFreshContacts((contacts) =>
|
|
||||||
removeMissingContacts(contacts, validated),
|
|
||||||
);
|
|
||||||
for (const handler of [...pushHandlersRef.current]) {
|
for (const handler of [...pushHandlersRef.current]) {
|
||||||
void Promise.resolve(handler(validated)).catch((error) => {
|
void Promise.resolve(handler(validated)).catch((error) => {
|
||||||
log(1, "mtp", "red", "Native MTP push handler failed", error, {
|
log(1, "mtp", "red", "Native MTP push handler failed", error, {
|
||||||
|
|
@ -765,11 +694,8 @@ function TauriProvider(props: {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (validated.type === "GetStates") {
|
|
||||||
lastInitialStateRef.current = validated;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[lastInitialStateRef, pushHandlersRef],
|
[pushHandlersRef],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -844,20 +770,6 @@ function TauriProvider(props: {
|
||||||
id: options?.id,
|
id: options?.id,
|
||||||
});
|
});
|
||||||
const validated = validateResponse(type, response);
|
const validated = validateResponse(type, response);
|
||||||
setFreshContacts((contacts) =>
|
|
||||||
removeMissingContacts(contacts, validated),
|
|
||||||
);
|
|
||||||
if (validated.type.startsWith("Error")) {
|
|
||||||
const errorData = validated.data as Record<string, unknown>;
|
|
||||||
throw new ProtocolError({
|
|
||||||
type: validated.type,
|
|
||||||
requestId: validated.id,
|
|
||||||
errorType:
|
|
||||||
typeof errorData.ErrorType === "string"
|
|
||||||
? errorData.ErrorType
|
|
||||||
: undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
for (const interceptor of interceptorsRef.current) {
|
for (const interceptor of interceptorsRef.current) {
|
||||||
void Promise.resolve(
|
void Promise.resolve(
|
||||||
interceptor({ type, data, response: validated as ProtocolMessage }),
|
interceptor({ type, data, response: validated as ProtocolMessage }),
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@
|
||||||
"exports": {
|
"exports": {
|
||||||
"./asyncQueue": "./src/asyncQueue.ts",
|
"./asyncQueue": "./src/asyncQueue.ts",
|
||||||
"./code": "./src/code.ts",
|
"./code": "./src/code.ts",
|
||||||
"./errors": "./src/errors.ts",
|
|
||||||
"./data": "./src/data.ts",
|
"./data": "./src/data.ts",
|
||||||
"./desktopMedia": "./src/desktopMedia.tsx",
|
"./desktopMedia": "./src/desktopMedia.tsx",
|
||||||
"./log": "./src/log.tsx",
|
"./log": "./src/log.tsx",
|
||||||
|
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import {
|
|
||||||
accountUserSchema,
|
|
||||||
mtp,
|
|
||||||
publicUserSchema,
|
|
||||||
userPresencePreferenceSchema,
|
|
||||||
} from "./data";
|
|
||||||
|
|
||||||
const profile = {
|
|
||||||
Display: "Alice",
|
|
||||||
IotaId: 1,
|
|
||||||
OmikronConnections: [],
|
|
||||||
PublicKey: "aGVsbG8=",
|
|
||||||
SubEnd: 0,
|
|
||||||
SubLevel: 0,
|
|
||||||
UserId: 1,
|
|
||||||
Username: "alice",
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("presence protocol schemas", () => {
|
|
||||||
it.each([
|
|
||||||
"user_online",
|
|
||||||
"user_idle",
|
|
||||||
"user_dnd",
|
|
||||||
"user_wc",
|
|
||||||
"user_invisible",
|
|
||||||
])("accepts writable preference %s", (state) => {
|
|
||||||
expect(userPresencePreferenceSchema.parse(state)).toBe(state);
|
|
||||||
expect(mtp.SetUserState.request.parse({ UserState: state })).toEqual({
|
|
||||||
UserState: state,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps invisible private to the account schema", () => {
|
|
||||||
expect(
|
|
||||||
accountUserSchema.parse({ ...profile, OnlineStatus: "user_invisible" })
|
|
||||||
.OnlineStatus,
|
|
||||||
).toBe("user_invisible");
|
|
||||||
expect(
|
|
||||||
publicUserSchema.safeParse({ ...profile, OnlineStatus: "user_invisible" })
|
|
||||||
.success,
|
|
||||||
).toBe(false);
|
|
||||||
expect(
|
|
||||||
publicUserSchema.parse({ ...profile, OnlineStatus: "user_offline" })
|
|
||||||
.OnlineStatus,
|
|
||||||
).toBe("user_offline");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not put state changes or lowercase status in ChangeUserData", () => {
|
|
||||||
const statePayload = mtp.ChangeUserData.request.safeParse({
|
|
||||||
OnlineStatus: "user_online",
|
|
||||||
});
|
|
||||||
expect(statePayload.success ? statePayload.data : undefined).toEqual({});
|
|
||||||
expect(mtp.ChangeUserData.request.parse({ Status: "At lunch" })).toEqual({
|
|
||||||
Status: "At lunch",
|
|
||||||
});
|
|
||||||
const lowercasePayload = mtp.ChangeUserData.request.safeParse({
|
|
||||||
status: "At lunch",
|
|
||||||
});
|
|
||||||
expect(
|
|
||||||
lowercasePayload.success ? lowercasePayload.data : undefined,
|
|
||||||
).toEqual({});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -143,71 +143,34 @@ export type Contacts = z.infer<typeof authPayload.shape.Contacts>;
|
||||||
export type Communities = z.infer<typeof authPayload.shape.Communities>;
|
export type Communities = z.infer<typeof authPayload.shape.Communities>;
|
||||||
export type Calls = z.infer<typeof authPayload.shape.Calls>;
|
export type Calls = z.infer<typeof authPayload.shape.Calls>;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// MTP
|
// MTP
|
||||||
const userFields = {
|
const user = z.object({
|
||||||
About: z.string().max(255).optional(),
|
About: z.string().max(255).optional(),
|
||||||
Avatar: z.string().optional(),
|
Avatar: z.string().optional(),
|
||||||
Display: z.string().min(1).max(15),
|
Display: z.string().min(1).max(15),
|
||||||
IotaId: z.number(),
|
IotaId: z.number(),
|
||||||
OmikronConnections: z.array(z.number()),
|
OmikronConnections: z.array(z.number()),
|
||||||
OmikronId: z.number().optional(),
|
OmikronId: z.number().optional(),
|
||||||
|
OnlineStatus: z.enum([
|
||||||
|
"user_offline",
|
||||||
|
"user_online",
|
||||||
|
"user_dnd",
|
||||||
|
"user_idle",
|
||||||
|
"user_wc",
|
||||||
|
"user_borked",
|
||||||
|
"iota_offline",
|
||||||
|
"iota_online",
|
||||||
|
"iota_borked",
|
||||||
|
]),
|
||||||
PublicKey: z.base64(),
|
PublicKey: z.base64(),
|
||||||
Status: z.string().max(15).optional(),
|
Status: z.string().max(15).optional(),
|
||||||
SubEnd: z.number(),
|
SubEnd: z.number(),
|
||||||
SubLevel: z.number(),
|
SubLevel: z.number(),
|
||||||
UserId: z.number(),
|
UserId: z.number(),
|
||||||
Username: z.string().min(1).max(15),
|
Username: z.string().min(1).max(15),
|
||||||
};
|
|
||||||
|
|
||||||
export const publicUserStateSchema = z.enum([
|
|
||||||
"user_online",
|
|
||||||
"user_idle",
|
|
||||||
"user_dnd",
|
|
||||||
"user_wc",
|
|
||||||
"user_offline",
|
|
||||||
"iota_offline",
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const userPresencePreferenceSchema = z.enum([
|
|
||||||
"user_online",
|
|
||||||
"user_idle",
|
|
||||||
"user_dnd",
|
|
||||||
"user_wc",
|
|
||||||
"user_invisible",
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const clientUserStateSchema = z.union([
|
|
||||||
publicUserStateSchema,
|
|
||||||
userPresencePreferenceSchema,
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const publicUserSchema = z.object({
|
|
||||||
...userFields,
|
|
||||||
OnlineStatus: z.enum([
|
|
||||||
"user_online",
|
|
||||||
"user_idle",
|
|
||||||
"user_dnd",
|
|
||||||
"user_wc",
|
|
||||||
"user_offline",
|
|
||||||
"iota_offline",
|
|
||||||
"user_borked",
|
|
||||||
"iota_online",
|
|
||||||
"iota_borked",
|
|
||||||
]),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const accountUserSchema = z.object({
|
|
||||||
...userFields,
|
|
||||||
OnlineStatus: userPresencePreferenceSchema,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const userSchema = z.union([publicUserSchema, accountUserSchema]);
|
|
||||||
|
|
||||||
export const userStateEntrySchema = z.object({
|
|
||||||
UserId: z.number().int().positive(),
|
|
||||||
UserState: publicUserStateSchema,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const mtp = {
|
export const mtp = {
|
||||||
IdentificationResponse: {
|
IdentificationResponse: {
|
||||||
request: z.object({}).optional(),
|
request: z.object({}).optional(),
|
||||||
|
|
@ -238,47 +201,10 @@ export const mtp = {
|
||||||
UserId: z.number().optional(),
|
UserId: z.number().optional(),
|
||||||
Username: z.string().optional(),
|
Username: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
response: userSchema,
|
response: user,
|
||||||
},
|
|
||||||
GetStates: {
|
|
||||||
request: z.object({
|
|
||||||
SessionId: z.number().int().positive(),
|
|
||||||
UserIds: z.array(z.number().int().positive()),
|
|
||||||
}),
|
|
||||||
response: z.object({
|
|
||||||
SessionId: z.number().int().positive(),
|
|
||||||
// Keep valid entries when one entry in a server snapshot is malformed.
|
|
||||||
UserStates: z.array(userStateEntrySchema.nullable().catch(null)),
|
|
||||||
MissingUserIds: z.array(z.number().int().positive()).optional(),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
ClientChanged: {
|
|
||||||
request: z.object({}).optional(),
|
|
||||||
response: z.object({
|
|
||||||
SessionId: z.number().int().positive(),
|
|
||||||
UserId: z.number().int().positive(),
|
|
||||||
UserState: clientUserStateSchema,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
SetUserState: {
|
|
||||||
request: z.object({
|
|
||||||
UserState: userPresencePreferenceSchema,
|
|
||||||
}),
|
|
||||||
response: z.object({
|
|
||||||
UserState: userPresencePreferenceSchema,
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
ChangeUserData: {
|
ChangeUserData: {
|
||||||
request: z
|
request: user.partial(),
|
||||||
.object({
|
|
||||||
About: userFields.About,
|
|
||||||
Avatar: userFields.Avatar,
|
|
||||||
Display: userFields.Display,
|
|
||||||
PublicKey: userFields.PublicKey,
|
|
||||||
Status: userFields.Status,
|
|
||||||
Username: userFields.Username,
|
|
||||||
})
|
|
||||||
.partial(),
|
|
||||||
response: z.object({}),
|
response: z.object({}),
|
||||||
},
|
},
|
||||||
MessageDelete: {
|
MessageDelete: {
|
||||||
|
|
@ -529,24 +455,24 @@ export interface Storage extends SettingsStorageDefaults {
|
||||||
call_mute_range_end: number;
|
call_mute_range_end: number;
|
||||||
theme_color: string;
|
theme_color: string;
|
||||||
theme_palette: Record<
|
theme_palette: Record<
|
||||||
| "base00"
|
| "base00"
|
||||||
| "base01"
|
| "base01"
|
||||||
| "base02"
|
| "base02"
|
||||||
| "base03"
|
| "base03"
|
||||||
| "base04"
|
| "base04"
|
||||||
| "base05"
|
| "base05"
|
||||||
| "base06"
|
| "base06"
|
||||||
| "base07"
|
| "base07"
|
||||||
| "base08"
|
| "base08"
|
||||||
| "base09"
|
| "base09"
|
||||||
| "base0A"
|
| "base0A"
|
||||||
| "base0B"
|
| "base0B"
|
||||||
| "base0C"
|
| "base0C"
|
||||||
| "base0D"
|
| "base0D"
|
||||||
| "base0E"
|
| "base0E"
|
||||||
| "base0F",
|
| "base0F",
|
||||||
string
|
string
|
||||||
> | null;
|
> | null;
|
||||||
theme_primary_color: string;
|
theme_primary_color: string;
|
||||||
theme_polarity: "dark" | "light" | "system";
|
theme_polarity: "dark" | "light" | "system";
|
||||||
theme_tint: "soft" | "hard" | "extreme";
|
theme_tint: "soft" | "hard" | "extreme";
|
||||||
|
|
@ -659,9 +585,7 @@ export const storageDefaults: Storage = {
|
||||||
|
|
||||||
// User Status
|
// User Status
|
||||||
export function getStatusColor(
|
export function getStatusColor(
|
||||||
status:
|
status: z.infer<typeof mtp.GetUserData.response.shape.OnlineStatus>,
|
||||||
| z.infer<typeof publicUserSchema.shape.OnlineStatus>
|
|
||||||
| z.infer<typeof userPresencePreferenceSchema>,
|
|
||||||
) {
|
) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "user_online":
|
case "user_online":
|
||||||
|
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
export class ProtocolError extends Error {
|
|
||||||
readonly type: string;
|
|
||||||
readonly id: number | undefined;
|
|
||||||
readonly communicationType: string;
|
|
||||||
readonly requestId: number | undefined;
|
|
||||||
readonly errorType: string | undefined;
|
|
||||||
|
|
||||||
constructor(options: {
|
|
||||||
type: string;
|
|
||||||
requestId?: number;
|
|
||||||
errorType?: string;
|
|
||||||
}) {
|
|
||||||
super(
|
|
||||||
options.errorType
|
|
||||||
? `${options.type}: ${options.errorType}`
|
|
||||||
: options.type,
|
|
||||||
);
|
|
||||||
this.name = "ProtocolError";
|
|
||||||
this.type = options.type;
|
|
||||||
this.id = options.requestId;
|
|
||||||
this.communicationType = options.type;
|
|
||||||
this.requestId = options.requestId;
|
|
||||||
this.errorType = options.errorType;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import { accountUserSchema, publicUserSchema } from "@tensamin/shared/data";
|
|
||||||
import { mergeTransientPresence } from "./context";
|
|
||||||
|
|
||||||
const profile = {
|
|
||||||
Display: "Alice",
|
|
||||||
IotaId: 1,
|
|
||||||
OmikronConnections: [],
|
|
||||||
PublicKey: "aGVsbG8=",
|
|
||||||
SubEnd: 0,
|
|
||||||
SubLevel: 0,
|
|
||||||
UserId: 7,
|
|
||||||
Username: "alice",
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("transient presence overlay", () => {
|
|
||||||
it("keeps private invisible state on the account without persisting it", () => {
|
|
||||||
const account = accountUserSchema.parse({
|
|
||||||
...profile,
|
|
||||||
OnlineStatus: "user_online",
|
|
||||||
});
|
|
||||||
const overlay = new Map([[7, "user_invisible" as const]]);
|
|
||||||
|
|
||||||
expect(mergeTransientPresence(account, overlay).OnlineStatus).toBe(
|
|
||||||
"user_invisible",
|
|
||||||
);
|
|
||||||
expect(publicUserSchema.safeParse(account).success).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("merges public live state into a profile without changing durable fields", () => {
|
|
||||||
const contact = publicUserSchema.parse({
|
|
||||||
...profile,
|
|
||||||
OnlineStatus: "user_online",
|
|
||||||
});
|
|
||||||
const overlay = new Map([[7, "user_offline" as const]]);
|
|
||||||
const merged = mergeTransientPresence(contact, overlay);
|
|
||||||
|
|
||||||
expect(merged).toMatchObject({
|
|
||||||
UserId: 7,
|
|
||||||
Username: "alice",
|
|
||||||
OnlineStatus: "user_offline",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -7,29 +7,15 @@ import {
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useMTP, type ProtocolMessage } from "@tensamin/mtp";
|
import { useMTP } from "@tensamin/mtp";
|
||||||
|
|
||||||
import {
|
import { mtp as schemas } from "@tensamin/shared/data";
|
||||||
clientUserStateSchema,
|
|
||||||
mtp as schemas,
|
|
||||||
publicUserStateSchema,
|
|
||||||
userStateEntrySchema,
|
|
||||||
} from "@tensamin/shared/data";
|
|
||||||
import type z from "zod";
|
import type z from "zod";
|
||||||
import { createCache } from "@tensamin/cache";
|
import { createCache } from "@tensamin/cache";
|
||||||
import { useStorage } from "@tensamin/storage/context";
|
import { useStorage } from "@tensamin/storage/context";
|
||||||
import { useSession } from "@tensamin/storage/session";
|
import { useSession } from "@tensamin/storage/session";
|
||||||
|
|
||||||
export type User = z.infer<typeof schemas.GetUserData.response>;
|
export type User = z.infer<typeof schemas.GetUserData.response>;
|
||||||
type ClientUserState = z.infer<typeof clientUserStateSchema>;
|
|
||||||
|
|
||||||
export function mergeTransientPresence<T extends { UserId: number }>(
|
|
||||||
user: T,
|
|
||||||
presence: ReadonlyMap<number, ClientUserState>,
|
|
||||||
): T {
|
|
||||||
const state = presence.get(user.UserId);
|
|
||||||
return state === undefined ? user : ({ ...user, OnlineStatus: state } as T);
|
|
||||||
}
|
|
||||||
|
|
||||||
const USER_CACHE_MAX_AGE = 5 * 60 * 1000;
|
const USER_CACHE_MAX_AGE = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
|
@ -49,105 +35,19 @@ export default function UserProvider(props: { children: ReactNode }) {
|
||||||
const storageRef = useRef<Record<number, User>>({});
|
const storageRef = useRef<Record<number, User>>({});
|
||||||
const pendingRef = useRef<Record<number, Promise<User> | undefined>>({});
|
const pendingRef = useRef<Record<number, Promise<User> | undefined>>({});
|
||||||
const checkedAtRef = useRef<Record<number, number>>({});
|
const checkedAtRef = useRef<Record<number, number>>({});
|
||||||
const presenceRef = useRef(new Map<number, ClientUserState>());
|
|
||||||
const durableProfileRef = useRef<Record<number, User>>({});
|
|
||||||
|
|
||||||
const { send, subscribePush } = useMTP();
|
const { send } = useMTP();
|
||||||
const { load } = useStorage();
|
const { load } = useStorage();
|
||||||
const { contacts } = useSession();
|
const { contacts } = useSession();
|
||||||
const [accountId, setAccountId] = useState<number | null>(null);
|
const [accountId, setAccountId] = useState<number | null>(null);
|
||||||
const [cacheVersion, setCacheVersion] = useState(0);
|
const [cacheVersion, setCacheVersion] = useState(0);
|
||||||
const [sessionId, setSessionId] = useState<number | null>(null);
|
|
||||||
const contactsRef = useRef(contacts);
|
|
||||||
const initialStatesRef = useRef(
|
|
||||||
new Map<number, z.infer<typeof publicUserStateSchema>>(),
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
contactsRef.current = contacts;
|
|
||||||
}, [contacts]);
|
|
||||||
|
|
||||||
const mergeUserPresence = useCallback((user: User): User => {
|
|
||||||
return mergeTransientPresence(user, presenceRef.current);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const applyUserState = useCallback(
|
|
||||||
(userId: number, state: ClientUserState, privateState = false) => {
|
|
||||||
const known =
|
|
||||||
userId === accountId ||
|
|
||||||
contactsRef.current.some((contact) => contact.UserId === userId);
|
|
||||||
if (!known) return false;
|
|
||||||
|
|
||||||
if (state === "user_invisible" && userId !== accountId) return false;
|
|
||||||
if (userId === accountId && !privateState) return false;
|
|
||||||
|
|
||||||
presenceRef.current.set(userId, state);
|
|
||||||
const current = storageRef.current[userId];
|
|
||||||
if (current) storageRef.current[userId] = mergeUserPresence(current);
|
|
||||||
setCacheVersion((version) => version + 1);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
[accountId, mergeUserPresence],
|
|
||||||
);
|
|
||||||
|
|
||||||
const removePresence = useCallback((userId: number) => {
|
|
||||||
presenceRef.current.delete(userId);
|
|
||||||
initialStatesRef.current.delete(userId);
|
|
||||||
delete checkedAtRef.current[userId];
|
|
||||||
setCacheVersion((version) => version + 1);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleStatePush = useCallback(
|
|
||||||
async (message: ProtocolMessage) => {
|
|
||||||
if (!accountId || !sessionId) return;
|
|
||||||
const data = message.data as Record<string, unknown>;
|
|
||||||
if (message.type === "GetStates") {
|
|
||||||
if (Number(data.SessionId) !== sessionId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (Array.isArray(data.MissingUserIds)) {
|
|
||||||
for (const userId of data.MissingUserIds) {
|
|
||||||
if (typeof userId === "number") removePresence(userId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!Array.isArray(data.UserStates)) return;
|
|
||||||
for (const entry of data.UserStates) {
|
|
||||||
const parsed = userStateEntrySchema.safeParse(entry);
|
|
||||||
if (!parsed.success) continue;
|
|
||||||
if (parsed.data.UserId === accountId) continue;
|
|
||||||
initialStatesRef.current.set(
|
|
||||||
parsed.data.UserId,
|
|
||||||
parsed.data.UserState,
|
|
||||||
);
|
|
||||||
if (applyUserState(parsed.data.UserId, parsed.data.UserState)) {
|
|
||||||
initialStatesRef.current.delete(parsed.data.UserId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (message.type !== "ClientChanged") return;
|
|
||||||
if (Number(data.SessionId) !== sessionId) return;
|
|
||||||
const parsed = schemas.ClientChanged.response.safeParse(data);
|
|
||||||
if (!parsed.success) return;
|
|
||||||
applyUserState(parsed.data.UserId, parsed.data.UserState, true);
|
|
||||||
},
|
|
||||||
[accountId, applyUserState, removePresence, sessionId],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load("user_id").then((accountId) => {
|
void load("user_id").then((accountId) => {
|
||||||
setAccountId(accountId);
|
setAccountId(accountId);
|
||||||
});
|
});
|
||||||
void load("session_id").then((value) => {
|
|
||||||
setSessionId(value);
|
|
||||||
});
|
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!accountId || !sessionId) return;
|
|
||||||
return subscribePush(handleStatePush);
|
|
||||||
}, [accountId, handleStatePush, sessionId, subscribePush]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Executes get.
|
* Executes get.
|
||||||
* @param userId Parameter userId.
|
* @param userId Parameter userId.
|
||||||
|
|
@ -173,20 +73,17 @@ export default function UserProvider(props: { children: ReactNode }) {
|
||||||
schemas.GetUserData.response.safeParse(cachedValue);
|
schemas.GetUserData.response.safeParse(cachedValue);
|
||||||
const cached = cachedResult.success ? cachedResult.data : undefined;
|
const cached = cachedResult.success ? cachedResult.data : undefined;
|
||||||
if (cached) {
|
if (cached) {
|
||||||
durableProfileRef.current[userId] = cached;
|
storageRef.current[userId] = cached;
|
||||||
const merged = mergeUserPresence(cached);
|
|
||||||
storageRef.current[userId] = merged;
|
|
||||||
const checkedAt = checkedAtRef.current[userId];
|
const checkedAt = checkedAtRef.current[userId];
|
||||||
if (!checkedAt || Date.now() - checkedAt < USER_CACHE_MAX_AGE) {
|
if (!checkedAt || Date.now() - checkedAt < USER_CACHE_MAX_AGE) {
|
||||||
checkedAtRef.current[userId] = Date.now();
|
checkedAtRef.current[userId] = Date.now();
|
||||||
return merged;
|
return cached;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const userData = await send("GetUserData", { UserId: userId });
|
const userData = await send("GetUserData", { UserId: userId });
|
||||||
if (userData.type === "ErrorNotFound" || userData.data.UserId === 0) {
|
if (userData.type === "ErrorNotFound" || userData.data.UserId === 0) {
|
||||||
delete storageRef.current[userId];
|
delete storageRef.current[userId];
|
||||||
delete durableProfileRef.current[userId];
|
|
||||||
delete checkedAtRef.current[userId];
|
delete checkedAtRef.current[userId];
|
||||||
await cache.profiles.delete(userId);
|
await cache.profiles.delete(userId);
|
||||||
throw new Error("GetUserData failed: user not found");
|
throw new Error("GetUserData failed: user not found");
|
||||||
|
|
@ -194,15 +91,14 @@ export default function UserProvider(props: { children: ReactNode }) {
|
||||||
if (userData.type.startsWith("Error")) {
|
if (userData.type.startsWith("Error")) {
|
||||||
throw new Error(`GetUserData failed: ${userData.type}`);
|
throw new Error(`GetUserData failed: ${userData.type}`);
|
||||||
}
|
}
|
||||||
const user = mergeUserPresence(userData.data);
|
const user = userData.data;
|
||||||
durableProfileRef.current[userId] = userData.data;
|
|
||||||
storageRef.current[userId] = user;
|
storageRef.current[userId] = user;
|
||||||
checkedAtRef.current[userId] = Date.now();
|
checkedAtRef.current[userId] = Date.now();
|
||||||
return user;
|
return user;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (cached && storageRef.current[userId]) {
|
if (cached && storageRef.current[userId]) {
|
||||||
checkedAtRef.current[userId] = Date.now();
|
checkedAtRef.current[userId] = Date.now();
|
||||||
return storageRef.current[userId];
|
return cached;
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
@ -216,48 +112,23 @@ export default function UserProvider(props: { children: ReactNode }) {
|
||||||
delete pendingRef.current[userId];
|
delete pendingRef.current[userId];
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[accountId, cacheVersion, mergeUserPresence, send],
|
[accountId, cacheVersion, send],
|
||||||
);
|
);
|
||||||
|
|
||||||
const update = useCallback(
|
const update = useCallback(
|
||||||
async (user: User) => {
|
async (user: User) => {
|
||||||
const durableProfile = {
|
await createCache(String(accountId ?? user.UserId)).profiles.put(user);
|
||||||
...user,
|
storageRef.current[user.UserId] = user;
|
||||||
OnlineStatus:
|
|
||||||
durableProfileRef.current[user.UserId]?.OnlineStatus ??
|
|
||||||
user.OnlineStatus,
|
|
||||||
} as User;
|
|
||||||
await createCache(String(accountId ?? user.UserId)).profiles.put(
|
|
||||||
durableProfile,
|
|
||||||
);
|
|
||||||
durableProfileRef.current[user.UserId] = durableProfile;
|
|
||||||
storageRef.current[user.UserId] = mergeUserPresence(durableProfile);
|
|
||||||
checkedAtRef.current[user.UserId] = Date.now();
|
checkedAtRef.current[user.UserId] = Date.now();
|
||||||
setCacheVersion((version) => version + 1);
|
setCacheVersion((version) => version + 1);
|
||||||
},
|
},
|
||||||
[accountId, mergeUserPresence],
|
[accountId],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!accountId) return;
|
if (!accountId) return;
|
||||||
void (async () => {
|
for (const contact of contacts) void get(contact.UserId);
|
||||||
const userIds = [
|
}, [accountId, contacts, get]);
|
||||||
accountId,
|
|
||||||
...contacts.map((contact) => contact.UserId),
|
|
||||||
].filter((userId, index, all) => all.indexOf(userId) === index);
|
|
||||||
for (const userId of userIds) {
|
|
||||||
try {
|
|
||||||
await get(userId);
|
|
||||||
const state = initialStatesRef.current.get(userId);
|
|
||||||
if (state && applyUserState(userId, state)) {
|
|
||||||
initialStatesRef.current.delete(userId);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// The normal user loading path reports profile failures to its caller.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, [accountId, applyUserState, contacts, get]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserContext.Provider value={{ get, update }}>
|
<UserContext.Provider value={{ get, update }}>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue