(feat): improved mobile notifications

(feat): add lint rules
(feat): improve markdown inline code box
This commit is contained in:
Alois 2026-08-05 21:41:55 +02:00
commit 4a841de073
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
35 changed files with 777 additions and 287 deletions

View file

@ -17,22 +17,6 @@ type MediaShareStoreState = {
cameraSession: LocalMediaShareSession | null;
};
type MediaShareStoreSetState = (
updater:
| Partial<MediaShareStoreState>
| ((state: MediaShareStoreState) => Partial<MediaShareStoreState>),
) => void;
type MediaShareControllerOptions = {
room: Room;
getState: () => MediaShareStoreState;
setState: MediaShareStoreSetState;
getLocalParticipantId: () => number | null;
startWatching: (participantId: number) => void;
stopWatching: (participantId: number) => void;
syncParticipantState: () => void;
};
export function createMediaShareController({
room,
getState,
@ -41,7 +25,19 @@ export function createMediaShareController({
startWatching,
stopWatching,
syncParticipantState,
}: MediaShareControllerOptions) {
}: {
room: Room;
getState: () => MediaShareStoreState;
setState: (
updater:
| Partial<MediaShareStoreState>
| ((state: MediaShareStoreState) => Partial<MediaShareStoreState>),
) => void;
getLocalParticipantId: () => number | null;
startWatching: (participantId: number) => void;
stopWatching: (participantId: number) => void;
syncParticipantState: () => void;
}) {
function getSession(kind: MediaShareKind) {
return kind === "screen"
? getState().screenShareSession

View file

@ -8,32 +8,16 @@ import type {
MediaShareSource,
} from "./types";
type MobileMediaApi = {
startScreenShare: (includeAudio: boolean) => void;
stopScreenShare: () => void;
requestCameraPermission: () => void;
};
declare global {
interface Window {
tensaminMobileMedia?: MobileMediaApi;
tensaminMobileMedia?: {
startScreenShare: (includeAudio: boolean) => void;
stopScreenShare: () => void;
requestCameraPermission: () => void;
};
}
}
type FrameDetail = {
data: string;
mimeType: string;
width: number;
height: number;
};
type AudioDetail = {
data: string;
sampleRate: number;
channelCount: number;
encoding: "pcm16le";
};
function eventDetail<T>(event: Event): T {
return (event as CustomEvent<T>).detail;
}
@ -164,7 +148,12 @@ async function startMobileScreen(
};
const onFrame = (event: Event) => {
const detail = eventDetail<FrameDetail>(event);
const detail = eventDetail<{
data: string;
mimeType: string;
width: number;
height: number;
}>(event);
const image = new Image();
image.onload = () => {
if (canvas.width !== detail.width || canvas.height !== detail.height) {
@ -179,7 +168,14 @@ async function startMobileScreen(
};
const onAudio = (event: Event) => {
const bytes = decodeBase64(eventDetail<AudioDetail>(event).data);
const bytes = decodeBase64(
eventDetail<{
data: string;
sampleRate: number;
channelCount: number;
encoding: "pcm16le";
}>(event).data,
);
const samples = new Int16Array(
bytes.buffer,
bytes.byteOffset,

View file

@ -10,18 +10,19 @@ const SPEAKING_HANGTIME_MS = 500;
const ANALYSIS_INTERVAL_MS = 30;
const FFT_SIZE = 256;
type AnalyserEntry = {
source: MediaStreamAudioSourceNode;
analyser: AnalyserNode;
track: MediaStreamTrack;
originalTrack?: MediaStreamTrack;
lastSpeakingTime: number;
isSpeaking: boolean;
};
class SpeakingDetector {
private audioContext: AudioContext | null = null;
private entries = new Map<number, AnalyserEntry>();
private entries = new Map<
number,
{
source: MediaStreamAudioSourceNode;
analyser: AnalyserNode;
track: MediaStreamTrack;
originalTrack?: MediaStreamTrack;
lastSpeakingTime: number;
isSpeaking: boolean;
}
>();
private intervalId: ReturnType<typeof setInterval> | null = null;
private deaf = false;
private gateThresholdStart = -50;

View file

@ -1,12 +1,10 @@
import { create } from "zustand";
type SpeakingState = {
const useSpeakingState = create<{
speakingParticipantIds: Set<number>;
lastSpeakingParticipantId: number | null;
micGated: boolean;
};
const useSpeakingState = create<SpeakingState>(() => ({
}>(() => ({
speakingParticipantIds: new Set(),
lastSpeakingParticipantId: null,
micGated: false,

View file

@ -51,7 +51,6 @@ setLogExtension(
getLogger("tensamin"),
);
type CallState = "closed" | "closing" | "connecting" | "open" | "encrypting";
type CallView = "preview" | "focused" | "grid";
type ProtocolCallSecret = NonNullable<
z.infer<typeof mtp.CallInvite.response>["CallSecret"]
@ -63,18 +62,9 @@ type WrappedCallSecret = {
kemCiphertext: Uint8Array;
wrappingScheme: string;
};
type IncomingCallInvite = {
callId: string;
callSecret: WrappedCallSecret;
senderId: number;
};
type CurrentCallData =
(z.infer<typeof mtp.CallData.response> & { exists: boolean }) | null;
type NavigateFn = (options: {
to: string;
search?: Record<string, unknown>;
}) => Promise<void>;
type SendFn = (
type: string,
data: Record<string, unknown>,
@ -84,44 +74,15 @@ type GetUserFn = (userId: number) => Promise<{ PublicKey: string }>;
type RemoteVideoTrackSelector = Track.Kind | Track.Source;
type Runtime = {
navigate: NavigateFn;
navigate: (options: {
to: string;
search?: Record<string, unknown>;
}) => Promise<void>;
send: SendFn;
load: LoadFn;
getUser: GetUserFn;
};
type CallStore = {
state: CallState;
view: CallView;
invitedUserId: number | null;
callId: string | null;
incomingCallInvite: IncomingCallInvite | null;
callSecret: string | null;
livekitToken: string | null;
currentCallData: CurrentCallData;
deaf: boolean;
micEnabled: boolean;
cameraEnabled: boolean;
screenShareEnabled: boolean;
screenShareSession: LocalMediaShareSession | null;
cameraSession: LocalMediaShareSession | null;
disabledCameraParticipantIds: number[];
focusedParticipantId: number | null;
focusedParticipantType: "user" | "stream" | null;
usersInFocusedViewHidden: boolean;
watchedStreamParticipantIds: number[];
pendingWatchedParticipantIds: number[];
activeScreenShareParticipantIds: number[];
isEncrypted: boolean;
ownCallSecretInvitePending: boolean;
callIsFullscreen: boolean;
callIsPopout: boolean;
layoutVersion: number;
screenRef: React.RefObject<HTMLDivElement | null> | null;
runtime: Runtime | null;
lastFocusedParticipantId: number | null;
};
let _keyProvider: ExternalE2EEKeyProvider | null = null;
let _e2eeWorker: Worker | null = null;
let _room: Room | null = null;
@ -1195,7 +1156,41 @@ async function ensureNoiseFilter(
}
}
export const useCall = create<CallStore>(() => ({
export const useCall = create<{
state: "closed" | "closing" | "connecting" | "open" | "encrypting";
view: CallView;
invitedUserId: number | null;
callId: string | null;
incomingCallInvite: {
callId: string;
callSecret: WrappedCallSecret;
senderId: number;
} | null;
callSecret: string | null;
livekitToken: string | null;
currentCallData: CurrentCallData;
deaf: boolean;
micEnabled: boolean;
cameraEnabled: boolean;
screenShareEnabled: boolean;
screenShareSession: LocalMediaShareSession | null;
cameraSession: LocalMediaShareSession | null;
disabledCameraParticipantIds: number[];
focusedParticipantId: number | null;
focusedParticipantType: "user" | "stream" | null;
usersInFocusedViewHidden: boolean;
watchedStreamParticipantIds: number[];
pendingWatchedParticipantIds: number[];
activeScreenShareParticipantIds: number[];
isEncrypted: boolean;
ownCallSecretInvitePending: boolean;
callIsFullscreen: boolean;
callIsPopout: boolean;
layoutVersion: number;
screenRef: React.RefObject<HTMLDivElement | null> | null;
runtime: Runtime | null;
lastFocusedParticipantId: number | null;
}>(() => ({
state: "closed",
view: "preview",
invitedUserId: null,