(feat): big call and chatting stuff #23

Merged
alois merged 33 commits from dev into main 2026-08-01 03:05:40 +03:00
10 changed files with 203 additions and 88 deletions
Showing only changes of commit f1874042ba - Show all commits

(fix): live reactions, message and call invites
All checks were successful
/ build-web (push) Successful in 7m40s
/ build-desktop (linux) (push) Successful in 12m10s
/ build-mobile (push) Successful in 19m43s
/ release (push) Successful in 3m35s

(fix): call ui
Alois 2026-07-31 00:29:48 +02:00
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24

View file

@ -332,7 +332,7 @@ function createCallTrayIcon(color: string, speaking: boolean) {
} }
function CallInit() { function CallInit() {
useInitializeCall(); const callInvitePopup = useInitializeCall();
const { load } = useStorage(); const { load } = useStorage();
const { const {
@ -395,7 +395,7 @@ function CallInit() {
}); });
}, [inCall, primaryColor, speaking]); }, [inCall, primaryColor, speaking]);
return null; return callInvitePopup;
} }
const rootRoute = createRootRoute({ const rootRoute = createRootRoute({

View file

@ -18,7 +18,6 @@ export default function CacheSync() {
const { load } = useStorage(); const { load } = useStorage();
const [accountId, setAccountId] = useState(0); const [accountId, setAccountId] = useState(0);
const queueRef = useRef(Promise.resolve()); const queueRef = useRef(Promise.resolve());
const reactionPartnersRef = useRef(new Map<number, number>());
useEffect(() => { useEffect(() => {
void load("user_id").then(setAccountId); void load("user_id").then(setAccountId);
@ -195,23 +194,6 @@ export default function CacheSync() {
); );
return; return;
} }
if (type === "MessageGet") {
const message = result as unknown as CachedMessage;
const mappedPartner = reactionPartnersRef.current.get(message.SendTime);
reactionPartnersRef.current.delete(message.SendTime);
if (mappedPartner) {
await insertMessage(mappedPartner, message);
return;
}
const windows = await secureCache().conversations.list();
const window = windows.find((candidate) =>
candidate.Messages.some(
(cached) => cached.SendTime === message.SendTime,
),
);
if (window) await insertMessage(window.UserId, message);
}
}, },
[accountId, insertMessage, removeMessage, replaceMessage, secureCache], [accountId, insertMessage, removeMessage, replaceMessage, secureCache],
); );
@ -259,13 +241,28 @@ export default function CacheSync() {
return; return;
} }
if (message.type === "MessageReactionLive") { if (message.type === "MessageReactionLive") {
reactionPartnersRef.current.set( const partnerId = Number(data.ChatPartnerId);
Number(data.SendTime), const sendTime = Number(data.SendTime);
Number(data.ChatPartnerId), const senderId = Number(data.SenderId);
const reaction = String(data.Reaction);
const cache = secureCache();
const window = await cache.conversations.get(partnerId);
const target = window?.Messages.find(
(candidate) => candidate.SendTime === sendTime,
); );
if (!window || !target) return;
const reactions = (target.Reactions ?? []).filter(
(candidate) =>
candidate.SenderId !== senderId || candidate.Reaction !== reaction,
);
if (data.Accepted === true) {
reactions.push({ SenderId: senderId, Reaction: reaction });
}
await replaceMessage(partnerId, sendTime, { Reactions: reactions });
} }
}, },
[accountId, insertMessage, removeMessage, replaceMessage], [accountId, insertMessage, removeMessage, replaceMessage, secureCache],
); );
useEffect(() => { useEffect(() => {

View file

@ -26,7 +26,10 @@ export default function InvitePopup({
loading={null} loading={null}
component={(user) => ( component={(user) => (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="flex flex-col gap-5 items-center justify-center w-65 h-80"> <DialogContent
showCloseButton={false}
className="flex flex-col gap-5 items-center justify-center w-65 h-80"
>
<Avatar className="size-30"> <Avatar className="size-30">
<AvatarImage src={user.Avatar} /> <AvatarImage src={user.Avatar} />
<AvatarFallback className="text-5xl"> <AvatarFallback className="text-5xl">
@ -36,14 +39,14 @@ export default function InvitePopup({
<p className="text-xl font-medium">{user.Display}</p> <p className="text-xl font-medium">{user.Display}</p>
<div className="w-full flex justify-center gap-3"> <div className="w-full flex justify-center gap-3">
<Button <Button
className="w-14 h-14" className="w-13 h-13!"
variant="destructive" variant="destructive"
onClick={() => onAccept(false)} onClick={() => onAccept(false)}
> >
<X className="size-5" /> <X className="size-5" />
</Button> </Button>
<Button <Button
className="w-14 h-14" className="w-13 h-13!"
variant="subtleDefault" variant="subtleDefault"
onClick={() => onAccept(true)} onClick={() => onAccept(true)}
> >

View file

@ -268,7 +268,7 @@ export default function Base({
flush || fill ? "rounded-none" : "rounded-md" flush || fill ? "rounded-none" : "rounded-md"
} ${ } ${
type === "user" && isSpeaking type === "user" && isSpeaking
? "border-4 border-(--primary-foreground-alt)/75" ? "border-4 border-(--primary-foreground-alt)/75!"
: fill : fill
? "border-0" ? "border-0"
: "border" : "border"

View file

@ -155,8 +155,8 @@ export function getRoom(): Room {
} }
const remoteAudioElements = new Map<string, HTMLMediaElement>(); const remoteAudioElements = new Map<string, HTMLMediaElement>();
let outgoingJingle: HTMLAudioElement | null = null; let callJingle: HTMLAudioElement | null = null;
let outgoingJingleGeneration = 0; let callJingleGeneration = 0;
const CALL_SECRET_VERSION = 1; const CALL_SECRET_VERSION = 1;
const SCREEN_SHARE_PREVIEW_MAX_WIDTH = 320; const SCREEN_SHARE_PREVIEW_MAX_WIDTH = 320;
@ -164,32 +164,32 @@ const SCREEN_SHARE_PREVIEW_MAX_HEIGHT = 180;
const SCREEN_SHARE_PREVIEW_QUALITY = 0.7; const SCREEN_SHARE_PREVIEW_QUALITY = 0.7;
const SCREEN_SHARE_PREVIEW_TIMEOUT_MS = 5000; const SCREEN_SHARE_PREVIEW_TIMEOUT_MS = 5000;
async function startOutgoingJingle() { async function startCallJingle(shouldPlay: () => boolean) {
const generation = ++outgoingJingleGeneration; const generation = ++callJingleGeneration;
stopSound(outgoingJingle); stopSound(callJingle);
outgoingJingle = null; callJingle = null;
const jingle = await requireRuntime(useCall.getState().runtime).load( const jingle = await requireRuntime(useCall.getState().runtime).load(
"settings.call_jingle", "settings.call_jingle",
); );
if ( if (
generation !== outgoingJingleGeneration || generation !== callJingleGeneration ||
useCall.getState().invitedUserId == null !shouldPlay()
) { ) {
return; return;
} }
outgoingJingle = playSound( callJingle = playSound(
jingle === "jingle_2" ? "call_jingle_2" : "call_jingle_1", jingle === "jingle_2" ? "call_jingle_2" : "call_jingle_1",
true, true,
); );
} }
function stopOutgoingJingle() { function stopCallJingle() {
outgoingJingleGeneration += 1; callJingleGeneration += 1;
stopSound(outgoingJingle); stopSound(callJingle);
outgoingJingle = null; callJingle = null;
} }
function protocolBytes(bytes: Uint8Array): Uint8Array<ArrayBuffer> { function protocolBytes(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
@ -918,7 +918,7 @@ export async function connect(callId: string) {
// Tear down the active call session and return the store to a closed state. // Tear down the active call session and return the store to a closed state.
export async function disconnect() { export async function disconnect() {
stopOutgoingJingle(); stopCallJingle();
disposeSpeakingDetector(); disposeSpeakingDetector();
await clearScreenSharePreview(); await clearScreenSharePreview();
@ -993,7 +993,7 @@ export async function joinCall(
}); });
if (sendInvite && !existingCallId) { if (sendInvite && !existingCallId) {
void startOutgoingJingle(); void startCallJingle(() => useCall.getState().invitedUserId != null);
} }
if (callSecret) { if (callSecret) {
@ -1231,12 +1231,14 @@ export function useInitializeCall() {
useCall.setState({ useCall.setState({
incomingCallInvite: { callId, callSecret, senderId }, incomingCallInvite: { callId, callSecret, senderId },
}); });
void startCallJingle(() => useCall.getState().incomingCallInvite != null);
}, },
[], [],
); );
const setInvitePopupOpen = useCallback((open: boolean) => { const setInvitePopupOpen = useCallback((open: boolean) => {
if (!open) { if (!open) {
stopCallJingle();
useCall.setState({ incomingCallInvite: null }); useCall.setState({ incomingCallInvite: null });
} }
}, []); }, []);
@ -1245,6 +1247,7 @@ export function useInitializeCall() {
(accepted: boolean) => { (accepted: boolean) => {
const invite = useCall.getState().incomingCallInvite; const invite = useCall.getState().incomingCallInvite;
stopCallJingle();
useCall.setState({ incomingCallInvite: null }); useCall.setState({ incomingCallInvite: null });
if (!invite) { if (!invite) {
@ -1292,6 +1295,11 @@ export function useInitializeCall() {
return; return;
} }
const currentCall = useCall.getState();
if (currentCall.callId === CallId && currentCall.state !== "closed") {
return;
}
showCallingScreen( showCallingScreen(
CallId, CallId,
normalizeWrappedCallSecret(CallSecret), normalizeWrappedCallSecret(CallSecret),
@ -1434,7 +1442,7 @@ export function useInitializeCall() {
}; };
const onDisconnected = () => { const onDisconnected = () => {
stopOutgoingJingle(); stopCallJingle();
playSound("call_leave"); playSound("call_leave");
useCall.setState({ state: "closed" }); useCall.setState({ state: "closed" });
syncParticipantState(); syncParticipantState();
@ -1445,7 +1453,7 @@ export function useInitializeCall() {
}; };
const onParticipantConnected = () => { const onParticipantConnected = () => {
stopOutgoingJingle(); stopCallJingle();
playSound("call_join"); playSound("call_join");
syncAllRemoteTrackSubscriptions(); syncAllRemoteTrackSubscriptions();
syncParticipantState(); syncParticipantState();

View file

@ -185,7 +185,7 @@ export default function View() {
} }
> >
<Base <Base
fill={isImmersiveFocusedView} fill
flush={isImmersiveFocusedView || isFocusedTileFlush} flush={isImmersiveFocusedView || isFocusedTileFlush}
type={focusedTileType} type={focusedTileType}
participant={focusedParticipant} participant={focusedParticipant}

View file

@ -102,6 +102,34 @@ function updateMessagesBySendTime<T extends EditableMessage>(
}; };
} }
function updateMessageReaction<T extends EditableMessage>(
message: T,
reaction: string,
senderId: number,
accepted: boolean,
): T {
const current = message.Reactions ?? [];
const withoutReaction = current.filter(
(item) => item.Reaction !== reaction || item.SenderId !== senderId,
);
const next = accepted
? [...withoutReaction, { Reaction: reaction, SenderId: senderId }]
: withoutReaction;
if (
next.length === current.length &&
next.every(
(item, index) =>
item.Reaction === current[index]?.Reaction &&
item.SenderId === current[index]?.SenderId,
)
) {
return message;
}
return { ...message, Reactions: next };
}
function assertProtocolSuccess(type: string, response: { type: string }) { function assertProtocolSuccess(type: string, response: { type: string }) {
if (response.type.startsWith("Error")) { if (response.type.startsWith("Error")) {
throw new Error(`${type} failed: ${response.type}`); throw new Error(`${type} failed: ${response.type}`);
@ -553,6 +581,51 @@ export default function Provider({ children }: { children: ReactNode }) {
[currentChatSecret, userIdValue], [currentChatSecret, userIdValue],
); );
const applyLiveReaction = useCallback(
(
sendTime: number,
reaction: string,
senderId: number,
accepted: boolean,
) => {
setLiveMessagesState((current) =>
current.map((message) =>
message.SendTime === sendTime
? updateMessageReaction(message, reaction, senderId, accepted)
: message,
),
);
const queryKey = [
"chat-messages",
String(userIdValue),
currentChatSecret !== null,
] as const;
queryClient.setQueryData<InfiniteData<RawMessages>>(
queryKey,
(current) =>
current
? {
...current,
pages: current.pages.map((page) =>
page.map((message) =>
message.SendTime === sendTime
? updateMessageReaction(
message,
reaction,
senderId,
accepted,
)
: message,
),
),
}
: current,
);
},
[currentChatSecret, userIdValue],
);
const deleteMessage = useCallback( const deleteMessage = useCallback(
async (sendTime: number) => { async (sendTime: number) => {
try { try {
@ -743,20 +816,28 @@ export default function Provider({ children }: { children: ReactNode }) {
const rawData = message.data as { const rawData = message.data as {
ChatPartnerId: unknown; ChatPartnerId: unknown;
SendTime: unknown; SendTime: unknown;
Reaction: string;
SenderId: unknown;
Accepted: boolean;
}; };
const chatPartnerId = Number(rawData.ChatPartnerId); const chatPartnerId = Number(rawData.ChatPartnerId);
const sendTime = Number(rawData.SendTime); const sendTime = Number(rawData.SendTime);
const senderId = Number(rawData.SenderId);
if (chatPartnerId !== userIdValue || !Number.isFinite(sendTime)) return; if (
chatPartnerId !== userIdValue ||
!Number.isFinite(sendTime) ||
!Number.isFinite(senderId)
) {
return;
}
void send("MessageGet", { SendTime: sendTime }) applyLiveReaction(
.then((response) => { sendTime,
assertProtocolSuccess("MessageGet", response); rawData.Reaction,
editMessage(sendTime, { Reactions: response.data.Reactions ?? [] }); senderId,
}) rawData.Accepted,
.catch((err) => { );
log(1, "chat", "red", "Failed to refresh message reactions", err);
});
return; return;
} }
@ -819,9 +900,9 @@ export default function Provider({ children }: { children: ReactNode }) {
}); });
}, [ }, [
currentChatSecret, currentChatSecret,
applyLiveReaction,
editMessage, editMessage,
removeMessage, removeMessage,
send,
subscribePush, subscribePush,
userIdValue, userIdValue,
]); ]);

View file

@ -57,7 +57,19 @@ export type BoundSendFn = <T extends keyof Schemas & string>(
options?: { id?: number }, options?: { id?: number },
) => Promise<ProtocolMessage<T>>; ) => Promise<ProtocolMessage<T>>;
export type PushHandler = (message: ProtocolMessage) => void; export type PushHandler = (
message: ProtocolMessage,
) => void | Promise<void>;
const PUSH_TYPES = [
"MessageLive",
"MessageEditLive",
"MessageReactionLive",
"MessageDeleteLive",
"MessageState",
"CallInvite",
"ErrorNoIota",
] as const;
export type MTPExchange = { export type MTPExchange = {
type: keyof Schemas & string; type: keyof Schemas & string;
@ -157,6 +169,7 @@ export function Provider(props: {
null, null,
); );
const interceptorsRef = useRef(new Set<MTPInterceptor>()); const interceptorsRef = useRef(new Set<MTPInterceptor>());
const pushHandlersRef = useRef(new Set<PushHandler>());
const connected = readyState === ConnectionState.Connected; const connected = readyState === ConnectionState.Connected;
@ -197,28 +210,8 @@ export function Provider(props: {
}, []); }, []);
const subscribePush = useCallback((handler: PushHandler) => { const subscribePush = useCallback((handler: PushHandler) => {
const client = clientRef.current; pushHandlersRef.current.add(handler);
if (!client) { return () => pushHandlersRef.current.delete(handler);
return () => {};
}
const unsubscribers = [
"MessageLive",
"MessageEditLive",
"MessageReactionLive",
"MessageDeleteLive",
"MessageState",
"CallInvite",
"ErrorNoIota",
].map((type) =>
client.subscribe(type, (message) => {
handler(validateResponse(type as keyof Schemas & string, message));
}),
);
return () => {
unsubscribers.forEach((unsubscribe) => unsubscribe());
};
}, []); }, []);
const addInterceptor = useCallback((interceptor: MTPInterceptor) => { const addInterceptor = useCallback((interceptor: MTPInterceptor) => {
@ -419,6 +412,28 @@ export function Provider(props: {
const activeClient = client; const activeClient = client;
clientRef.current = activeClient; clientRef.current = activeClient;
for (const type of PUSH_TYPES) {
activeClient.subscribe(type, (message) => {
let validated: ProtocolMessage;
try {
validated = validateResponse(type, message);
} catch (error) {
log(1, "mtp", "red", "Failed to validate push message", error, {
type,
data: message.data,
});
return;
}
for (const handler of [...pushHandlersRef.current]) {
void Promise.resolve()
.then(() => handler(validated))
.catch((error) => {
log(1, "mtp", "red", "Push handler failed", error, { type });
});
}
});
}
setReadyState(activeClient.state); setReadyState(activeClient.state);
clearReconnectTimer(); clearReconnectTimer();

View file

@ -28,7 +28,7 @@ export default function Provider(props: { children: React.ReactNode }) {
const { subscribePush, send } = useMTP(); const { subscribePush, send } = useMTP();
const { load } = useStorage(); const { load } = useStorage();
const { get } = useUser(); const { get } = useUser();
const { addLiveMessage, getChatSecret, userId } = useChat(); const { addLiveMessage, chatSecret, getChatSecret, userId } = useChat();
const { moveUserIdToTop } = useSession(); const { moveUserIdToTop } = useSession();
const navigate = useNavigate(); const navigate = useNavigate();
@ -42,13 +42,15 @@ export default function Provider(props: { children: React.ReactNode }) {
if (!data.SenderId) return; if (!data.SenderId) return;
const chatSecret = await getChatSecret(data.SenderId); const messageSecret =
userId === data.SenderId && chatSecret
? chatSecret
: await getChatSecret(data.SenderId);
if (!data.Message || !chatSecret) return; if (!data.Message || !messageSecret) return;
playSound("message"); playSound("message");
const user = await get(data.SenderId);
void decryptChatText(chatSecret, data.Message.Content) void decryptChatText(messageSecret, data.Message.Content)
.catch((err) => { .catch((err) => {
log(1, "chat", "red", "Failed to decrypt live message", err, { log(1, "chat", "red", "Failed to decrypt live message", err, {
SendTime: data.Message?.SendTime, SendTime: data.Message?.SendTime,
@ -69,6 +71,7 @@ export default function Provider(props: { children: React.ReactNode }) {
// todo: add notification symbol to conversation cards (incl. message start) // todo: add notification symbol to conversation cards (incl. message start)
moveUserIdToTop(data.SenderId); moveUserIdToTop(data.SenderId);
const user = await get(data.SenderId);
if (await load("settings.receive_confirmations")) { if (await load("settings.receive_confirmations")) {
void send( void send(
@ -127,6 +130,7 @@ export default function Provider(props: { children: React.ReactNode }) {
}); });
}, [ }, [
addLiveMessage, addLiveMessage,
chatSecret,
get, get,
load, load,
navigate, navigate,

View file

@ -277,14 +277,21 @@ export const mtp = {
Reaction: z.string(), Reaction: z.string(),
SendTime: z.number(), SendTime: z.number(),
ChatPartnerId: z.number(), ChatPartnerId: z.number(),
SenderId: z.number(),
Accepted: z.boolean(),
}), }),
}, },
MessageLive: { MessageLive: {
request: z.object({}).optional(), request: z.object({}).optional(),
response: z.object({ response: z
SenderId: z.number(), .object({
Message, SenderId: z.number(),
}), Message: Message.extend({ SenderId: z.number().optional() }),
})
.transform(({ SenderId, Message }) => ({
SenderId,
Message: { ...Message, SenderId: Message.SenderId ?? SenderId },
})),
}, },
MessageGet: { MessageGet: {
request: z.object({ request: z.object({