client/packages/mtp/src/mtpContext.tsx
2026-08-30 18:26:32 +02:00

213 lines
6.4 KiB
TypeScript

import { createContext, useCallback, useRef } from "react";
import type {
MTPDataValueInput,
MTPEncodedBytesInput,
MTPFrame,
MTPRequestFunction,
MTPResponseFrame,
MTPSubscriptionFunction,
} from "mtp";
import {
mtp as mtpSchemas,
type Calls,
type Communities,
type Contacts,
} from "@tensamin/shared/data";
import { log } from "@tensamin/shared/log";
export type ProtocolMessage<
Type extends keyof typeof mtpSchemas & string = keyof typeof mtpSchemas &
string,
> = MTPResponseFrame<typeof mtpSchemas, Type>;
export type BoundSendFn = MTPRequestFunction<typeof mtpSchemas>;
export type RelayTarget =
| { kind: "user"; id: number }
| { kind: "iota"; id: number };
export type SealedRelayOptions = {
nextHop: RelayTarget;
finalRecipientId: number;
metadataRecipients: MTPEncodedBytesInput[];
contentRecipients: MTPEncodedBytesInput[];
};
export type SealedRelayResult = {
type: string;
data: Record<string, unknown>;
};
export function sealedRelayResultFromFrame(frame: MTPFrame): SealedRelayResult {
return {
type: frame.type,
data:
typeof frame.data === "object" &&
frame.data !== null &&
!Array.isArray(frame.data)
? (frame.data as Record<string, unknown>)
: {},
};
}
export class RelayRejectedError extends Error {
public readonly responseType: string;
constructor(responseType: string) {
super(`Relay rejected with ${responseType}`);
this.responseType = responseType;
this.name = "RelayRejectedError";
}
}
export function requireRelaySuccess(response: SealedRelayResult): void {
if (response.type !== "Success") {
throw new RelayRejectedError(response.type);
}
}
export type SealedRelaySend = (
type: string,
data: MTPDataValueInput,
options: SealedRelayOptions,
) => Promise<SealedRelayResult>;
export type MTPExchange = {
type: keyof typeof mtpSchemas & string;
data: unknown;
response: ProtocolMessage;
};
export type MTPInterceptor = (exchange: MTPExchange) => void | Promise<void>;
export type MTPContextType = {
send: BoundSendFn;
sendSealedRelay: SealedRelaySend;
subscribe: MTPSubscriptionFunction<typeof mtpSchemas>;
addInterceptor: (interceptor: MTPInterceptor) => () => void;
readyState: number;
identified: boolean;
freshContacts: Contacts;
freshCommunities: Communities;
freshCalls: Calls;
contextReady: boolean;
loadingDescription: string;
};
export const MTPContext = createContext<MTPContextType | undefined>(undefined);
export function removeMissingContacts(
contacts: Contacts,
message: ProtocolMessage<"GetStates">,
): Contacts {
const missing = new Set(message.data.MissingUserIds ?? []);
return contacts.filter((contact) => !missing.has(contact.UserId));
}
export function useMessageHandlers() {
const interceptorsRef = useRef(new Set<MTPInterceptor>());
const subscriptionHandlersRef = useRef(
new Map<string, Set<(message: ProtocolMessage) => void | Promise<void>>>(),
);
const transportRef = useRef<{
subscribe: MTPSubscriptionFunction<typeof mtpSchemas>;
} | null>(null);
const transportGenerationRef = useRef(0);
const transportUnsubscribersRef = useRef(new Map<string, () => void>());
const lastInitialStateRef = useRef<ProtocolMessage<"GetStates"> | null>(null);
const attachType = useCallback(
<Type extends keyof typeof mtpSchemas & string>(type: Type) => {
const transport = transportRef.current;
if (!transport || transportUnsubscribersRef.current.has(type)) return;
const generation = transportGenerationRef.current;
const unsubscribe = transport.subscribe(type, (message) => {
if (
transportRef.current !== transport ||
transportGenerationRef.current !== generation
)
return;
if (type === "GetStates") {
lastInitialStateRef.current = message as ProtocolMessage<"GetStates">;
}
for (const handler of [
...(subscriptionHandlersRef.current.get(type) ?? []),
]) {
void Promise.resolve(handler(message as ProtocolMessage)).catch(
(error) => {
log(1, "mtp", "red", "Subscription handler failed", error, {
type,
});
},
);
}
});
transportUnsubscribersRef.current.set(type, unsubscribe);
},
[],
);
const attachSubscriptions = useCallback(
(transport: { subscribe: MTPSubscriptionFunction<typeof mtpSchemas> }) => {
for (const unsubscribe of transportUnsubscribersRef.current.values()) {
unsubscribe();
}
transportUnsubscribersRef.current.clear();
transportRef.current = transport;
const generation = ++transportGenerationRef.current;
for (const type of subscriptionHandlersRef.current.keys()) {
attachType(type as keyof typeof mtpSchemas & string);
}
return () => {
if (
transportRef.current !== transport ||
transportGenerationRef.current !== generation
)
return;
transportRef.current = null;
transportGenerationRef.current += 1;
for (const unsubscribe of transportUnsubscribersRef.current.values()) {
unsubscribe();
}
transportUnsubscribersRef.current.clear();
};
},
[attachType],
);
const subscribe = useCallback<MTPSubscriptionFunction<typeof mtpSchemas>>(
(type, handler) => {
const handlers = subscriptionHandlersRef.current.get(type) ?? new Set();
const untypedHandler = handler as (
message: ProtocolMessage,
) => void | Promise<void>;
handlers.add(untypedHandler);
subscriptionHandlersRef.current.set(type, handlers);
attachType(type);
const initialState = lastInitialStateRef.current;
if (type === "GetStates" && initialState) {
void Promise.resolve(untypedHandler(initialState)).catch(
() => undefined,
);
}
return () => {
handlers.delete(untypedHandler);
if (handlers.size !== 0) return;
subscriptionHandlersRef.current.delete(type);
transportUnsubscribersRef.current.get(type)?.();
transportUnsubscribersRef.current.delete(type);
};
},
[attachType],
);
const addInterceptor = useCallback((interceptor: MTPInterceptor) => {
interceptorsRef.current.add(interceptor);
return () => interceptorsRef.current.delete(interceptor);
}, []);
return {
addInterceptor,
attachSubscriptions,
interceptorsRef,
subscribe,
};
}