(feat): big call and chatting stuff #23

Merged
alois merged 33 commits from dev into main 2026-08-01 03:05:40 +03:00
19 changed files with 207 additions and 16 deletions
Showing only changes of commit 184b85190b - Show all commits

(feat): add sounds
Some checks failed
/ release (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
/ build-web (push) Has been cancelled
/ build-mobile (push) Has been cancelled

Alois 2026-07-30 23:51:38 +02:00
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -4,6 +4,7 @@ import { useLocation, useNavigate } from "@tanstack/react-router";
import { useMTP } from "@tensamin/mtp"; import { useMTP } from "@tensamin/mtp";
import { log, toast } from "@tensamin/shared/log"; import { log, toast } from "@tensamin/shared/log";
import { mtp } from "@tensamin/shared/data"; import { mtp } from "@tensamin/shared/data";
import { playSound, stopSound } from "@tensamin/shared/sounds";
import { bytesToBase64 } from "mtp"; import { bytesToBase64 } from "mtp";
import { import {
deriveCallSecretId, deriveCallSecretId,
@ -154,6 +155,8 @@ export function getRoom(): Room {
} }
const remoteAudioElements = new Map<string, HTMLMediaElement>(); const remoteAudioElements = new Map<string, HTMLMediaElement>();
let outgoingJingle: HTMLAudioElement | null = null;
let outgoingJingleGeneration = 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;
@ -161,6 +164,34 @@ 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() {
const generation = ++outgoingJingleGeneration;
stopSound(outgoingJingle);
outgoingJingle = null;
const jingle = await requireRuntime(useCall.getState().runtime).load(
"settings.call_jingle",
);
if (
generation !== outgoingJingleGeneration ||
useCall.getState().invitedUserId == null
) {
return;
}
outgoingJingle = playSound(
jingle === "jingle_2" ? "call_jingle_2" : "call_jingle_1",
true,
);
}
function stopOutgoingJingle() {
outgoingJingleGeneration += 1;
stopSound(outgoingJingle);
outgoingJingle = null;
}
function protocolBytes(bytes: Uint8Array): Uint8Array<ArrayBuffer> { function protocolBytes(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
return new Uint8Array(bytes); return new Uint8Array(bytes);
} }
@ -695,6 +726,16 @@ export async function sendCallInvite(userId: number) {
// Start tracking a participant's shared screen in the call UI. // Start tracking a participant's shared screen in the call UI.
export function startWatchingStream(participantId: number) { export function startWatchingStream(participantId: number) {
const trackReady = getScreenShareTrackForParticipant(participantId) != null; const trackReady = getScreenShareTrackForParticipant(participantId) != null;
const alreadyWatching = useCall
.getState()
.watchedStreamParticipantIds.includes(participantId);
const localParticipantId = getParticipantId(
getRoom().localParticipant.identity,
);
if (!alreadyWatching && participantId !== localParticipantId) {
playSound("stream_watch_start");
}
setParticipantTrackSubscribed(participantId, Track.Source.ScreenShare); setParticipantTrackSubscribed(participantId, Track.Source.ScreenShare);
setParticipantTrackSubscribed(participantId, Track.Source.ScreenShareAudio); setParticipantTrackSubscribed(participantId, Track.Source.ScreenShareAudio);
@ -745,6 +786,17 @@ export function focusParticipant(
// Stop tracking a participant's shared screen and clean up related UI state. // Stop tracking a participant's shared screen and clean up related UI state.
export function stopWatchingStream(participantId: number) { export function stopWatchingStream(participantId: number) {
const wasWatching = useCall
.getState()
.watchedStreamParticipantIds.includes(participantId);
const localParticipantId = getParticipantId(
getRoom().localParticipant.identity,
);
if (wasWatching && participantId !== localParticipantId) {
playSound("stream_watch_end");
}
setParticipantTrackSubscribed(participantId, Track.Source.ScreenShare, false); setParticipantTrackSubscribed(participantId, Track.Source.ScreenShare, false);
setParticipantTrackSubscribed( setParticipantTrackSubscribed(
participantId, participantId,
@ -866,6 +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();
disposeSpeakingDetector(); disposeSpeakingDetector();
await clearScreenSharePreview(); await clearScreenSharePreview();
@ -939,6 +992,10 @@ export async function joinCall(
ownCallSecretInvitePending: isNewCall, ownCallSecretInvitePending: isNewCall,
}); });
if (sendInvite && !existingCallId) {
void startOutgoingJingle();
}
if (callSecret) { if (callSecret) {
try { try {
if (!existingCallId) { if (!existingCallId) {
@ -1303,6 +1360,7 @@ export function useInitializeCall() {
const onConnected = async () => { const onConnected = async () => {
useCall.setState({ state: "open" }); useCall.setState({ state: "open" });
playSound("call_join");
syncParticipantState(); syncParticipantState();
const detector = getSpeakingDetector(); const detector = getSpeakingDetector();
@ -1376,6 +1434,8 @@ export function useInitializeCall() {
}; };
const onDisconnected = () => { const onDisconnected = () => {
stopOutgoingJingle();
playSound("call_leave");
useCall.setState({ state: "closed" }); useCall.setState({ state: "closed" });
syncParticipantState(); syncParticipantState();
log(2, "call", "purple", "Disconnected from call", { log(2, "call", "purple", "Disconnected from call", {
@ -1385,11 +1445,14 @@ export function useInitializeCall() {
}; };
const onParticipantConnected = () => { const onParticipantConnected = () => {
stopOutgoingJingle();
playSound("call_join");
syncAllRemoteTrackSubscriptions(); syncAllRemoteTrackSubscriptions();
syncParticipantState(); syncParticipantState();
}; };
const onParticipantDisconnected = (participant: Participant) => { const onParticipantDisconnected = (participant: Participant) => {
playSound("call_leave");
const participantId = getParticipantId(participant.identity); const participantId = getParticipantId(participant.identity);
if (participantId != null) { if (participantId != null) {
@ -1416,6 +1479,10 @@ export function useInitializeCall() {
}; };
const onLocalTrackPublished = (publication: LocalTrackPublication) => { const onLocalTrackPublished = (publication: LocalTrackPublication) => {
if (publication.source === Track.Source.ScreenShare) {
playSound("stream_start_self");
}
if ( if (
publication.kind === Track.Kind.Audio && publication.kind === Track.Kind.Audio &&
publication.source === Track.Source.Microphone && publication.source === Track.Source.Microphone &&
@ -1437,6 +1504,10 @@ export function useInitializeCall() {
}; };
const onLocalTrackUnpublished = (publication: LocalTrackPublication) => { const onLocalTrackUnpublished = (publication: LocalTrackPublication) => {
if (publication.source === Track.Source.ScreenShare) {
playSound("stream_end_self");
}
if ( if (
publication.kind === Track.Kind.Audio && publication.kind === Track.Kind.Audio &&
publication.source === Track.Source.Microphone publication.source === Track.Source.Microphone
@ -1453,6 +1524,10 @@ export function useInitializeCall() {
publication: RemoteTrackPublication, publication: RemoteTrackPublication,
participant: RemoteParticipant, participant: RemoteParticipant,
) => { ) => {
if (publication.source === Track.Source.ScreenShare) {
playSound("stream_start_other");
}
const participantId = getParticipantId(participant.identity); const participantId = getParticipantId(participant.identity);
if (participantId != null) { if (participantId != null) {
@ -1469,6 +1544,14 @@ export function useInitializeCall() {
onParticipantStateChange(); onParticipantStateChange();
}; };
const onTrackUnpublished = (publication: RemoteTrackPublication) => {
if (publication.source === Track.Source.ScreenShare) {
playSound("stream_end_other");
}
onParticipantStateChange();
};
const onTrackSubscribed = ( const onTrackSubscribed = (
track: RemoteTrack, track: RemoteTrack,
publication: RemoteTrackPublication, publication: RemoteTrackPublication,
@ -1526,7 +1609,7 @@ export function useInitializeCall() {
room.on(RoomEvent.TrackSubscribed, onTrackSubscribed); room.on(RoomEvent.TrackSubscribed, onTrackSubscribed);
room.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed); room.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed);
room.on(RoomEvent.TrackPublished, onTrackPublished); room.on(RoomEvent.TrackPublished, onTrackPublished);
room.on(RoomEvent.TrackUnpublished, onParticipantStateChange); room.on(RoomEvent.TrackUnpublished, onTrackUnpublished);
room.on(RoomEvent.ParticipantConnected, onParticipantConnected); room.on(RoomEvent.ParticipantConnected, onParticipantConnected);
room.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected); room.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected);
room.on(RoomEvent.TrackMuted, onParticipantStateChange); room.on(RoomEvent.TrackMuted, onParticipantStateChange);
@ -1547,7 +1630,7 @@ export function useInitializeCall() {
room.off(RoomEvent.TrackSubscribed, onTrackSubscribed); room.off(RoomEvent.TrackSubscribed, onTrackSubscribed);
room.off(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed); room.off(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed);
room.off(RoomEvent.TrackPublished, onTrackPublished); room.off(RoomEvent.TrackPublished, onTrackPublished);
room.off(RoomEvent.TrackUnpublished, onParticipantStateChange); room.off(RoomEvent.TrackUnpublished, onTrackUnpublished);
room.off(RoomEvent.ParticipantConnected, onParticipantConnected); room.off(RoomEvent.ParticipantConnected, onParticipantConnected);
room.off(RoomEvent.ParticipantDisconnected, onParticipantDisconnected); room.off(RoomEvent.ParticipantDisconnected, onParticipantDisconnected);
room.off(RoomEvent.TrackMuted, onParticipantStateChange); room.off(RoomEvent.TrackMuted, onParticipantStateChange);

View file

@ -571,8 +571,10 @@ export default function MessageContextMenu({
function selectReaction(emoji: string) { function selectReaction(emoji: string) {
void onReact(emoji); void onReact(emoji);
setActiveMiniMenu(null);
setReactionDrawerOpen(false); setReactionDrawerOpen(false);
setPickerVisibility(false); setPickerOpen(false);
setPickerClosing(false);
} }
function setPickerVisibility(open: boolean) { function setPickerVisibility(open: boolean) {
setPickerOpen(open); setPickerOpen(open);

View file

@ -10,6 +10,7 @@ import { useSession } from "@tensamin/storage/session";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { decryptChatText } from "@tensamin/crypto/chatSecret"; import { decryptChatText } from "@tensamin/crypto/chatSecret";
import { log } from "@tensamin/shared/log"; import { log } from "@tensamin/shared/log";
import { playSound } from "@tensamin/shared/sounds";
import { type RawMessage } from "@tensamin/chat/values"; import { type RawMessage } from "@tensamin/chat/values";
export const context = createContext<contextType | undefined>(undefined); export const context = createContext<contextType | undefined>(undefined);
@ -44,6 +45,7 @@ export default function Provider(props: { children: React.ReactNode }) {
const chatSecret = await getChatSecret(data.SenderId); const chatSecret = await getChatSecret(data.SenderId);
if (!data.Message || !chatSecret) return; if (!data.Message || !chatSecret) return;
playSound("message");
const user = await get(data.SenderId); const user = await get(data.SenderId);
void decryptChatText(chatSecret, data.Message.Content) void decryptChatText(chatSecret, data.Message.Content)

View file

@ -1,4 +1,5 @@
import Cache from "./pages/cache"; import Cache from "./pages/cache";
import Call from "./pages/call";
import Chat from "./pages/chat"; import Chat from "./pages/chat";
import Index from "./pages/index"; import Index from "./pages/index";
import Licenses from "./pages/licenses"; import Licenses from "./pages/licenses";
@ -21,6 +22,7 @@ export const settingsPages = [
component: Security, component: Security,
}, },
{ category: "general", path: "chat", label: "Chat", component: Chat }, { category: "general", path: "chat", label: "Chat", component: Chat },
{ category: "general", path: "call", label: "Call", component: Call },
{ category: "application", path: "cache", label: "Cache", component: Cache }, { category: "application", path: "cache", label: "Cache", component: Cache },
{ category: "application", path: "theme", label: "Theme", component: Theme }, { category: "application", path: "theme", label: "Theme", component: Theme },
{ {

View file

@ -0,0 +1,51 @@
import {
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@methanium/ui";
import { settingsStorageDefaults } from "@tensamin/shared/settings";
import { useStorage } from "@tensamin/storage/context";
import { useEffect, useState } from "react";
type CallJingle = (typeof settingsStorageDefaults)["settings.call_jingle"];
const jingleLabels: Record<CallJingle, string> = {
jingle_1: "Jingle 1",
jingle_2: "Jingle 2",
};
export default function Page() {
const { load, save } = useStorage();
const [jingle, setJingle] = useState<CallJingle>(
settingsStorageDefaults["settings.call_jingle"],
);
useEffect(() => {
void load("settings.call_jingle").then(setJingle);
}, [load]);
return (
<div className="flex max-w-md flex-col gap-2">
<Label htmlFor="call-jingle">Outgoing call jingle</Label>
<Select
value={jingle}
onValueChange={(value) => {
const nextJingle = value ?? "jingle_1";
setJingle(nextJingle);
void save("settings.call_jingle", nextJingle);
}}
>
<SelectTrigger className="w-full" id="call-jingle">
<SelectValue>{jingleLabels[jingle]}</SelectValue>
</SelectTrigger>
<SelectContent className="p-1">
<SelectItem value="jingle_1">Jingle 1</SelectItem>
<SelectItem value="jingle_2">Jingle 2</SelectItem>
</SelectContent>
</Select>
</div>
);
}

View file

@ -11,6 +11,7 @@
"./log": "./src/log.tsx", "./log": "./src/log.tsx",
"./indexedDb": "./src/indexedDb.ts", "./indexedDb": "./src/indexedDb.ts",
"./settings": "./src/settings.ts", "./settings": "./src/settings.ts",
"./sounds": "./src/sounds.ts",
"./features/legal/schema": "./src/features/legal/schema.ts", "./features/legal/schema": "./src/features/legal/schema.ts",
"./features/conversation/schema": "./src/features/conversation/schema.ts" "./features/conversation/schema": "./src/features/conversation/schema.ts"
}, },

View file

@ -45,6 +45,15 @@ const settings = {
}, },
}, },
}, },
call: {
call: {
call_jingle: {
display: "Jingle",
type: "select",
default: "jingle_1" as "jingle_1" | "jingle_2",
},
},
},
application: { application: {
cache: {}, cache: {},
theme: {}, theme: {},
@ -55,26 +64,35 @@ const settings = {
// Assemble storage defaults // Assemble storage defaults
export default settings; export default settings;
type BooleanSettingNames<T extends SettingsSchema> = { type SettingStorageEntry<T extends SettingsSchema> = {
[C in StringKeyOf<T>]: { [C in StringKeyOf<T>]: {
[P in StringKeyOf<T[C]>]: { [P in StringKeyOf<T[C]>]: {
[S in StringKeyOf<T[C][P]>]: T[C][P][S] extends { [S in StringKeyOf<T[C][P]>]: T[C][P][S] extends { default: infer V }
type: "boolean"; ? { key: `settings.${S}`; value: V }
default: boolean;
}
? S
: never; : never;
}[StringKeyOf<T[C][P]>]; }[StringKeyOf<T[C][P]>];
}[StringKeyOf<T[C]>]; }[StringKeyOf<T[C]>];
}[StringKeyOf<T>]; }[StringKeyOf<T>];
type Widen<T> = T extends boolean
? boolean
: T extends string
? string extends T
? string
: T
: T extends number
? number
: T;
export type SettingsStorageKey<T extends SettingsSchema = typeof settings> = export type SettingsStorageKey<T extends SettingsSchema = typeof settings> =
`settings.${BooleanSettingNames<T>}`; SettingStorageEntry<T>["key"];
export type SettingsStorageDefaults< export type SettingsStorageDefaults<
T extends SettingsSchema = typeof settings, T extends SettingsSchema = typeof settings,
> = { > = {
[K in SettingsStorageKey<T>]: boolean; [K in SettingsStorageKey<T>]: Widen<
Extract<SettingStorageEntry<T>, { key: K }>["value"]
>;
}; };
function buildSettingsStorageDefaults<T extends SettingsSchema>( function buildSettingsStorageDefaults<T extends SettingsSchema>(
@ -85,12 +103,10 @@ function buildSettingsStorageDefaults<T extends SettingsSchema>(
for (const category of Object.values(schema)) { for (const category of Object.values(schema)) {
for (const page of Object.values(category)) { for (const page of Object.values(category)) {
for (const [settingName, setting] of Object.entries(page)) { for (const [settingName, setting] of Object.entries(page)) {
if ( if ("default" in setting) {
setting.type === "boolean" &&
typeof setting.default === "boolean"
) {
const key = `settings.${settingName}` as SettingsStorageKey<T>; const key = `settings.${settingName}` as SettingsStorageKey<T>;
defaults[key] = setting.default; defaults[key] =
setting.default as SettingsStorageDefaults<T>[typeof key];
} }
} }
} }

View file

@ -0,0 +1,34 @@
export type SoundName =
| "call_jingle_1"
| "call_jingle_2"
| "call_join"
| "call_leave"
| "message"
| "stream_end_other"
| "stream_end_self"
| "stream_start_other"
| "stream_start_self"
| "stream_watch_end"
| "stream_watch_start";
function soundUrl(sound: SoundName) {
if (window.location.protocol === "file:") {
return new URL(`./sounds/${sound}.wav`, document.baseURI).href;
}
return `/sounds/${sound}.wav`;
}
export function playSound(sound: SoundName, loop = false) {
const audio = new Audio(soundUrl(sound));
audio.loop = loop;
void audio.play().catch(() => undefined);
return audio;
}
export function stopSound(audio: HTMLAudioElement | null) {
if (!audio) return;
audio.pause();
audio.currentTime = 0;
}