(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
This commit is contained in:
Alois 2026-07-31 00:29:48 +02:00
commit f1874042ba
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
10 changed files with 203 additions and 88 deletions

View file

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

View file

@ -18,7 +18,6 @@ export default function CacheSync() {
const { load } = useStorage();
const [accountId, setAccountId] = useState(0);
const queueRef = useRef(Promise.resolve());
const reactionPartnersRef = useRef(new Map<number, number>());
useEffect(() => {
void load("user_id").then(setAccountId);
@ -195,23 +194,6 @@ export default function CacheSync() {
);
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],
);
@ -259,13 +241,28 @@ export default function CacheSync() {
return;
}
if (message.type === "MessageReactionLive") {
reactionPartnersRef.current.set(
Number(data.SendTime),
Number(data.ChatPartnerId),
const partnerId = Number(data.ChatPartnerId);
const sendTime = Number(data.SendTime);
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(() => {

View file

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

View file

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

View file

@ -155,8 +155,8 @@ export function getRoom(): Room {
}
const remoteAudioElements = new Map<string, HTMLMediaElement>();
let outgoingJingle: HTMLAudioElement | null = null;
let outgoingJingleGeneration = 0;
let callJingle: HTMLAudioElement | null = null;
let callJingleGeneration = 0;
const CALL_SECRET_VERSION = 1;
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_TIMEOUT_MS = 5000;
async function startOutgoingJingle() {
const generation = ++outgoingJingleGeneration;
stopSound(outgoingJingle);
outgoingJingle = null;
async function startCallJingle(shouldPlay: () => boolean) {
const generation = ++callJingleGeneration;
stopSound(callJingle);
callJingle = null;
const jingle = await requireRuntime(useCall.getState().runtime).load(
"settings.call_jingle",
);
if (
generation !== outgoingJingleGeneration ||
useCall.getState().invitedUserId == null
generation !== callJingleGeneration ||
!shouldPlay()
) {
return;
}
outgoingJingle = playSound(
callJingle = playSound(
jingle === "jingle_2" ? "call_jingle_2" : "call_jingle_1",
true,
);
}
function stopOutgoingJingle() {
outgoingJingleGeneration += 1;
stopSound(outgoingJingle);
outgoingJingle = null;
function stopCallJingle() {
callJingleGeneration += 1;
stopSound(callJingle);
callJingle = null;
}
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.
export async function disconnect() {
stopOutgoingJingle();
stopCallJingle();
disposeSpeakingDetector();
await clearScreenSharePreview();
@ -993,7 +993,7 @@ export async function joinCall(
});
if (sendInvite && !existingCallId) {
void startOutgoingJingle();
void startCallJingle(() => useCall.getState().invitedUserId != null);
}
if (callSecret) {
@ -1231,12 +1231,14 @@ export function useInitializeCall() {
useCall.setState({
incomingCallInvite: { callId, callSecret, senderId },
});
void startCallJingle(() => useCall.getState().incomingCallInvite != null);
},
[],
);
const setInvitePopupOpen = useCallback((open: boolean) => {
if (!open) {
stopCallJingle();
useCall.setState({ incomingCallInvite: null });
}
}, []);
@ -1245,6 +1247,7 @@ export function useInitializeCall() {
(accepted: boolean) => {
const invite = useCall.getState().incomingCallInvite;
stopCallJingle();
useCall.setState({ incomingCallInvite: null });
if (!invite) {
@ -1292,6 +1295,11 @@ export function useInitializeCall() {
return;
}
const currentCall = useCall.getState();
if (currentCall.callId === CallId && currentCall.state !== "closed") {
return;
}
showCallingScreen(
CallId,
normalizeWrappedCallSecret(CallSecret),
@ -1434,7 +1442,7 @@ export function useInitializeCall() {
};
const onDisconnected = () => {
stopOutgoingJingle();
stopCallJingle();
playSound("call_leave");
useCall.setState({ state: "closed" });
syncParticipantState();
@ -1445,7 +1453,7 @@ export function useInitializeCall() {
};
const onParticipantConnected = () => {
stopOutgoingJingle();
stopCallJingle();
playSound("call_join");
syncAllRemoteTrackSubscriptions();
syncParticipantState();

View file

@ -185,7 +185,7 @@ export default function View() {
}
>
<Base
fill={isImmersiveFocusedView}
fill
flush={isImmersiveFocusedView || isFocusedTileFlush}
type={focusedTileType}
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 }) {
if (response.type.startsWith("Error")) {
throw new Error(`${type} failed: ${response.type}`);
@ -553,6 +581,51 @@ export default function Provider({ children }: { children: ReactNode }) {
[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(
async (sendTime: number) => {
try {
@ -743,20 +816,28 @@ export default function Provider({ children }: { children: ReactNode }) {
const rawData = message.data as {
ChatPartnerId: unknown;
SendTime: unknown;
Reaction: string;
SenderId: unknown;
Accepted: boolean;
};
const chatPartnerId = Number(rawData.ChatPartnerId);
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 })
.then((response) => {
assertProtocolSuccess("MessageGet", response);
editMessage(sendTime, { Reactions: response.data.Reactions ?? [] });
})
.catch((err) => {
log(1, "chat", "red", "Failed to refresh message reactions", err);
});
applyLiveReaction(
sendTime,
rawData.Reaction,
senderId,
rawData.Accepted,
);
return;
}
@ -819,9 +900,9 @@ export default function Provider({ children }: { children: ReactNode }) {
});
}, [
currentChatSecret,
applyLiveReaction,
editMessage,
removeMessage,
send,
subscribePush,
userIdValue,
]);

View file

@ -57,7 +57,19 @@ export type BoundSendFn = <T extends keyof Schemas & string>(
options?: { id?: number },
) => 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 = {
type: keyof Schemas & string;
@ -157,6 +169,7 @@ export function Provider(props: {
null,
);
const interceptorsRef = useRef(new Set<MTPInterceptor>());
const pushHandlersRef = useRef(new Set<PushHandler>());
const connected = readyState === ConnectionState.Connected;
@ -197,28 +210,8 @@ export function Provider(props: {
}, []);
const subscribePush = useCallback((handler: PushHandler) => {
const client = clientRef.current;
if (!client) {
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());
};
pushHandlersRef.current.add(handler);
return () => pushHandlersRef.current.delete(handler);
}, []);
const addInterceptor = useCallback((interceptor: MTPInterceptor) => {
@ -419,6 +412,28 @@ export function Provider(props: {
const activeClient = client;
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);
clearReconnectTimer();

View file

@ -28,7 +28,7 @@ export default function Provider(props: { children: React.ReactNode }) {
const { subscribePush, send } = useMTP();
const { load } = useStorage();
const { get } = useUser();
const { addLiveMessage, getChatSecret, userId } = useChat();
const { addLiveMessage, chatSecret, getChatSecret, userId } = useChat();
const { moveUserIdToTop } = useSession();
const navigate = useNavigate();
@ -42,13 +42,15 @@ export default function Provider(props: { children: React.ReactNode }) {
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");
const user = await get(data.SenderId);
void decryptChatText(chatSecret, data.Message.Content)
void decryptChatText(messageSecret, data.Message.Content)
.catch((err) => {
log(1, "chat", "red", "Failed to decrypt live message", err, {
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)
moveUserIdToTop(data.SenderId);
const user = await get(data.SenderId);
if (await load("settings.receive_confirmations")) {
void send(
@ -127,6 +130,7 @@ export default function Provider(props: { children: React.ReactNode }) {
});
}, [
addLiveMessage,
chatSecret,
get,
load,
navigate,

View file

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