[Upd] User states

This commit is contained in:
Alex 2026-08-07 23:05:26 +02:00
commit ec019a4dff
Signed by: alex
SSH key fingerprint: SHA256:D1+Ub8o0v4K5y1JNivW8IxEOelqLSvPmUzBbDIoZkRQ
16 changed files with 667 additions and 87 deletions

View file

@ -0,0 +1,18 @@
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",
});
});
});

View file

@ -36,23 +36,19 @@ 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 type z from "zod"; import { mtp, userPresencePreferenceSchema } from "@tensamin/shared/data";
import { mtp } from "@tensamin/shared/data";
import { useMTP } from "@tensamin/mtp"; import { useMTP } from "@tensamin/mtp";
import {
onlineStatusLabels,
onlineStatusOptions,
type OnlineStatus,
} from "./status-options";
type OnlineStatus = z.infer<typeof mtp.GetUserData.response.shape.OnlineStatus>; function accountPreference(status: User["OnlineStatus"]): OnlineStatus {
return userPresencePreferenceSchema.safeParse(status).success
const onlineStatusLabels: Record<OnlineStatus, string> = { ? (status as OnlineStatus)
user_online: "Online", : "user_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,
@ -87,7 +83,7 @@ function StatusDialog({
onOpenChange={(nextOpen) => { onOpenChange={(nextOpen) => {
if (!nextOpen) { if (!nextOpen) {
setDraftStatus(user.Status ?? ""); setDraftStatus(user.Status ?? "");
setDraftOnlineStatus(user.OnlineStatus); setDraftOnlineStatus(accountPreference(user.OnlineStatus));
setErrorMessage(""); setErrorMessage("");
setSaveSucceeded(false); setSaveSucceeded(false);
} }
@ -126,10 +122,11 @@ function StatusDialog({
</SelectValue> </SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent className="p-1"> <SelectContent className="p-1">
<SelectItem value="user_online">Online</SelectItem> {onlineStatusOptions.map((option) => (
<SelectItem value="user_offline">Offline</SelectItem> <SelectItem key={option.value} value={option.value}>
<SelectItem value="user_idle">Idle</SelectItem> {option.label}
<SelectItem value="user_dnd">Do not disturb</SelectItem> </SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@ -140,12 +137,9 @@ function StatusDialog({
<DialogClose render={<Button variant="destructive">Cancel</Button>} /> <DialogClose render={<Button variant="destructive">Cancel</Button>} />
<Button <Button
onClick={async () => { onClick={async () => {
const payload = { const payload = { UserState: draftOnlineStatus };
...(draftStatus && { status: draftStatus }),
OnlineStatus: draftOnlineStatus,
};
const validation = mtp.ChangeUserData.request.safeParse(payload); const validation = mtp.SetUserState.request.safeParse(payload);
if (!validation.success) { if (!validation.success) {
setSaveSucceeded(false); setSaveSucceeded(false);
@ -156,7 +150,17 @@ function StatusDialog({
} }
try { try {
await send("ChangeUserData", validation.data); await send("SetUserState", 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) {
@ -227,7 +231,9 @@ export default function Sidebar() {
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
setDraftStatus(user.Status ?? ""); setDraftStatus(user.Status ?? "");
setDraftOnlineStatus(user.OnlineStatus); setDraftOnlineStatus(
accountPreference(user.OnlineStatus),
);
setStatusErrorMessage(""); setStatusErrorMessage("");
setStatusSaveSucceeded(false); setStatusSaveSucceeded(false);
setDialogOpen(true); setDialogOpen(true);
@ -246,7 +252,9 @@ export default function Sidebar() {
onOpenChange={(nextOpen) => { onOpenChange={(nextOpen) => {
if (!nextOpen) { if (!nextOpen) {
setDraftStatus(user.Status ?? ""); setDraftStatus(user.Status ?? "");
setDraftOnlineStatus(user.OnlineStatus); setDraftOnlineStatus(
accountPreference(user.OnlineStatus),
);
setStatusErrorMessage(""); setStatusErrorMessage("");
setStatusSaveSucceeded(false); setStatusSaveSucceeded(false);
} }

View file

@ -0,0 +1,15 @@
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" },
]);
});
});

View file

@ -0,0 +1,13 @@
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 11a1d79409857b734e948dd8c3e28e6ba721d15f Subproject commit 486541b9483356ff49ff3ec7016f87d3ecbeaa0e

View file

@ -49,5 +49,9 @@
"@methanium/ui": "https://git.methanium.net/methanium/ui/releases/download/0.0.2/methanium-ui.tgz", "@methanium/ui": "https://git.methanium.net/methanium/ui/releases/download/0.0.2/methanium-ui.tgz",
"mtp": "*", "mtp": "*",
"sonner": "^2.0.7" "sonner": "^2.0.7"
},
"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 Normal file
View file

@ -0,0 +1,19 @@
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([]);
});
});

View file

@ -12,6 +12,14 @@ 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();
@ -37,6 +45,20 @@ 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,
@ -110,6 +132,15 @@ 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,
@ -196,7 +227,14 @@ export default function CacheSync() {
return; return;
} }
}, },
[accountId, insertMessage, removeMessage, replaceMessage, secureCache], [
accountId,
insertMessage,
removeMissingContacts,
removeMessage,
replaceMessage,
secureCache,
],
); );
useEffect(() => { useEffect(() => {
@ -210,6 +248,14 @@ 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),
@ -263,7 +309,14 @@ 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(() => {

View file

@ -0,0 +1,21 @@
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);
});
});

View file

@ -24,6 +24,7 @@ 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";
@ -62,9 +63,30 @@ 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;
@ -110,7 +132,7 @@ function getProtocolErrorDetails(error: unknown) {
} }
// Zod schema validation // Zod schema validation
function validateResponse<T extends keyof Schemas & string>( export 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> {
@ -142,15 +164,26 @@ 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 { addInterceptor, interceptorsRef, pushHandlersRef, subscribePush }; return {
addInterceptor,
interceptorsRef,
lastInitialStateRef,
pushHandlersRef,
subscribePush,
};
} }
function BrowserProvider(props: { function BrowserProvider(props: {
@ -172,8 +205,13 @@ function BrowserProvider(props: {
const clientRef = useRef<Awaited<ReturnType<typeof MTPClient.create>> | null>( const clientRef = useRef<Awaited<ReturnType<typeof MTPClient.create>> | null>(
null, null,
); );
const { addInterceptor, interceptorsRef, pushHandlersRef, subscribePush } = const {
useMessageHandlers(); addInterceptor,
interceptorsRef,
lastInitialStateRef,
pushHandlersRef,
subscribePush,
} = useMessageHandlers();
const connected = readyState === ConnectionState.Connected; const connected = readyState === ConnectionState.Connected;
@ -197,7 +235,20 @@ function BrowserProvider(props: {
(data ?? {}) as Record<string, unknown>, (data ?? {}) as Record<string, unknown>,
options, options,
); );
return validateResponse(type, message); const response = 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;
}, },
[], [],
); );
@ -418,6 +469,9 @@ 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))
@ -425,6 +479,9 @@ 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);
@ -537,7 +594,13 @@ 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(() => {
@ -644,8 +707,13 @@ 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 { addInterceptor, interceptorsRef, pushHandlersRef, subscribePush } = const {
useMessageHandlers(); addInterceptor,
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>>(),
); );
@ -686,7 +754,10 @@ function TauriProvider(props: {
[]) { []) {
handler(validated); handler(validated);
} }
if (!(PUSH_TYPES as readonly string[]).includes(validated.type)) return; if (!isPushType(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, {
@ -694,8 +765,11 @@ function TauriProvider(props: {
}); });
}); });
} }
if (validated.type === "GetStates") {
lastInitialStateRef.current = validated;
}
}, },
[pushHandlersRef], [lastInitialStateRef, pushHandlersRef],
); );
useEffect(() => { useEffect(() => {
@ -770,6 +844,20 @@ 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 }),

View file

@ -6,6 +6,7 @@
"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",

View file

@ -0,0 +1,65 @@
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({});
});
});

View file

@ -143,34 +143,71 @@ 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 user = z.object({ const userFields = {
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(),
@ -201,10 +238,47 @@ export const mtp = {
UserId: z.number().optional(), UserId: z.number().optional(),
Username: z.string().optional(), Username: z.string().optional(),
}), }),
response: user, response: userSchema,
},
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: user.partial(), request: z
.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: {
@ -472,7 +546,7 @@ export interface Storage extends SettingsStorageDefaults {
| "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";
@ -585,7 +659,9 @@ export const storageDefaults: Storage = {
// User Status // User Status
export function getStatusColor( export function getStatusColor(
status: z.infer<typeof mtp.GetUserData.response.shape.OnlineStatus>, status:
| z.infer<typeof publicUserSchema.shape.OnlineStatus>
| z.infer<typeof userPresencePreferenceSchema>,
) { ) {
switch (status) { switch (status) {
case "user_online": case "user_online":

View file

@ -0,0 +1,25 @@
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;
}
}

View file

@ -0,0 +1,45 @@
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",
});
});
});

View file

@ -7,15 +7,29 @@ import {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { useMTP } from "@tensamin/mtp"; import { useMTP, type ProtocolMessage } from "@tensamin/mtp";
import { mtp as schemas } from "@tensamin/shared/data"; import {
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;
@ -35,19 +49,105 @@ 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 } = useMTP(); const { send, subscribePush } = 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.
@ -73,17 +173,20 @@ 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) {
storageRef.current[userId] = cached; durableProfileRef.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 cached; return merged;
} }
} }
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");
@ -91,14 +194,15 @@ 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 = userData.data; const user = mergeUserPresence(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 cached; return storageRef.current[userId];
} }
throw error; throw error;
} }
@ -112,23 +216,48 @@ export default function UserProvider(props: { children: ReactNode }) {
delete pendingRef.current[userId]; delete pendingRef.current[userId];
} }
}, },
[accountId, cacheVersion, send], [accountId, cacheVersion, mergeUserPresence, send],
); );
const update = useCallback( const update = useCallback(
async (user: User) => { async (user: User) => {
await createCache(String(accountId ?? user.UserId)).profiles.put(user); const durableProfile = {
storageRef.current[user.UserId] = user; ...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], [accountId, mergeUserPresence],
); );
useEffect(() => { useEffect(() => {
if (!accountId) return; if (!accountId) return;
for (const contact of contacts) void get(contact.UserId); void (async () => {
}, [accountId, contacts, get]); const userIds = [
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 }}>