[Upd] User states
This commit is contained in:
parent
d07202386f
commit
ec019a4dff
16 changed files with 667 additions and 87 deletions
45
packages/user/src/context.test.tsx
Normal file
45
packages/user/src/context.test.tsx
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 }}>
|
||||
|
|
|
|||
Loading…
Reference in a new issue