[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,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,
useState,
} 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 { createCache } from "@tensamin/cache";
import { useStorage } from "@tensamin/storage/context";
import { useSession } from "@tensamin/storage/session";
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;
@ -35,19 +49,105 @@ export default function UserProvider(props: { children: ReactNode }) {
const storageRef = useRef<Record<number, User>>({});
const pendingRef = useRef<Record<number, Promise<User> | undefined>>({});
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 { contacts } = useSession();
const [accountId, setAccountId] = useState<number | null>(null);
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(() => {
void load("user_id").then((accountId) => {
setAccountId(accountId);
});
void load("session_id").then((value) => {
setSessionId(value);
});
}, [load]);
useEffect(() => {
if (!accountId || !sessionId) return;
return subscribePush(handleStatePush);
}, [accountId, handleStatePush, sessionId, subscribePush]);
/**
* Executes get.
* @param userId Parameter userId.
@ -73,17 +173,20 @@ export default function UserProvider(props: { children: ReactNode }) {
schemas.GetUserData.response.safeParse(cachedValue);
const cached = cachedResult.success ? cachedResult.data : undefined;
if (cached) {
storageRef.current[userId] = cached;
durableProfileRef.current[userId] = cached;
const merged = mergeUserPresence(cached);
storageRef.current[userId] = merged;
const checkedAt = checkedAtRef.current[userId];
if (!checkedAt || Date.now() - checkedAt < USER_CACHE_MAX_AGE) {
checkedAtRef.current[userId] = Date.now();
return cached;
return merged;
}
}
try {
const userData = await send("GetUserData", { UserId: userId });
if (userData.type === "ErrorNotFound" || userData.data.UserId === 0) {
delete storageRef.current[userId];
delete durableProfileRef.current[userId];
delete checkedAtRef.current[userId];
await cache.profiles.delete(userId);
throw new Error("GetUserData failed: user not found");
@ -91,14 +194,15 @@ export default function UserProvider(props: { children: ReactNode }) {
if (userData.type.startsWith("Error")) {
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;
checkedAtRef.current[userId] = Date.now();
return user;
} catch (error) {
if (cached && storageRef.current[userId]) {
checkedAtRef.current[userId] = Date.now();
return cached;
return storageRef.current[userId];
}
throw error;
}
@ -112,23 +216,48 @@ export default function UserProvider(props: { children: ReactNode }) {
delete pendingRef.current[userId];
}
},
[accountId, cacheVersion, send],
[accountId, cacheVersion, mergeUserPresence, send],
);
const update = useCallback(
async (user: User) => {
await createCache(String(accountId ?? user.UserId)).profiles.put(user);
storageRef.current[user.UserId] = user;
const durableProfile = {
...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();
setCacheVersion((version) => version + 1);
},
[accountId],
[accountId, mergeUserPresence],
);
useEffect(() => {
if (!accountId) return;
for (const contact of contacts) void get(contact.UserId);
}, [accountId, contacts, get]);
void (async () => {
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 (
<UserContext.Provider value={{ get, update }}>