From 4068669704ec69445f6e73c7ce9ca9de5b9955c8 Mon Sep 17 00:00:00 2001 From: Alois Date: Mon, 8 Jun 2026 15:40:04 +0200 Subject: [PATCH] (feat): add context menu to call users (feat): ui changes to invite popup & call ui in general (feat): make eslint less aggressive (fix): stop watching button on own screenshares (qol): update todo --- eslint.config.ts | 1 + packages/call/src/components/actions.tsx | 9 +- packages/call/src/components/invitePopup.tsx | 16 +-- packages/call/src/components/modals/base.tsx | 111 ++++++++++++++++-- .../src/components/modals/contextMenu.tsx | 95 +++++++++++++++ packages/call/src/components/popout.tsx | 1 - .../call/src/components/screenshareDialog.tsx | 1 - packages/call/todo.md | 7 +- packages/tauth/src/context.tsx | 1 - 9 files changed, 214 insertions(+), 28 deletions(-) create mode 100644 packages/call/src/components/modals/contextMenu.tsx diff --git a/eslint.config.ts b/eslint.config.ts index 19d24ae..16951eb 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -31,6 +31,7 @@ export default [ }, rules: { ...reactHooks.configs.recommended.rules, + "react-hooks/set-state-in-effect": "off", }, }, ]; diff --git a/packages/call/src/components/actions.tsx b/packages/call/src/components/actions.tsx index ad990c3..a0d3a77 100644 --- a/packages/call/src/components/actions.tsx +++ b/packages/call/src/components/actions.tsx @@ -28,6 +28,7 @@ import { SquareArrowOutDownLeft, SquareArrowOutUpRight, } from "lucide-react"; +import { useStorage } from "@tensamin/storage/context"; export default function Actions() { const sharedClasses = "w-14 h-10"; @@ -42,6 +43,12 @@ export default function Actions() { focusedParticipantId != null && watchedStreamParticipantIds.includes(focusedParticipantId); + const { load } = useStorage(); + const [ownId, setOwnId] = useState(0); + useEffect(() => { + load("user_id").then(setOwnId); + }, [load]); + // Fullscreen stuff const callIsPopout = useCall((state) => state.callIsPopout); const callIsFullscreen = useCall((state) => state.callIsFullscreen); @@ -122,7 +129,7 @@ export default function Actions() { iconSize={sharedIconSize} tooltip="Invite" /> - {isWatchingFocusedStream ? ( + {isWatchingFocusedStream && focusedParticipantId !== ownId ? (

{user.display}

+ -
diff --git a/packages/call/src/components/modals/base.tsx b/packages/call/src/components/modals/base.tsx index 47e47cf..936765b 100644 --- a/packages/call/src/components/modals/base.tsx +++ b/packages/call/src/components/modals/base.tsx @@ -3,9 +3,7 @@ import { AvatarFallback, AvatarImage, Button, - ContextMenu, - ContextMenuContent, - ContextMenuItem, + ContextMenu as UIContextMenu, ContextMenuTrigger, } from "@tensamin/ui"; import { @@ -21,6 +19,8 @@ import { useUser, type User } from "@tensamin/user/context"; import { useIsSpeaking } from "../../speakingState"; import VideoViewer from "../videoViewer"; import { HeadphoneOff, MicOff, Monitor, Plus, Shield } from "lucide-react"; +import ContextMenu from "./contextMenu"; +import { useStorage } from "@tensamin/storage/context"; function getTrackPublicationBySource( participant: Participant | undefined, @@ -43,6 +43,69 @@ function TransparentButton({ children }: { children: React.ReactNode }) { ); } +function getAverageImageColor(src: string) { + return new Promise((resolve) => { + const image = new Image(); + + image.crossOrigin = "anonymous"; + image.referrerPolicy = "no-referrer"; + image.onload = () => { + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d", { willReadFrequently: true }); + + if (!context) { + resolve(undefined); + return; + } + + canvas.width = 32; + canvas.height = 32; + context.drawImage(image, 0, 0, canvas.width, canvas.height); + + try { + const { data } = context.getImageData( + 0, + 0, + canvas.width, + canvas.height, + ); + let red = 0; + let green = 0; + let blue = 0; + let total = 0; + + for (let index = 0; index < data.length; index += 4) { + const alpha = data[index + 3]; + + if (alpha < 128) { + continue; + } + + red += data[index] * alpha; + green += data[index + 1] * alpha; + blue += data[index + 2] * alpha; + total += alpha; + } + + if (total === 0) { + resolve(undefined); + return; + } + + const darken = 0.7; + + resolve( + `rgb(${Math.round((red / total) * darken)}, ${Math.round((green / total) * darken)}, ${Math.round((blue / total) * darken)})`, + ); + } catch { + resolve(undefined); + } + }; + image.onerror = () => resolve(undefined); + image.src = src; + }); +} + function Overlay({ type, user, @@ -100,10 +163,14 @@ export default function Base({ flush?: boolean; }) { const { get } = useUser(); + const { load } = useStorage(); const focusedParticipantId = useCall((state) => state.focusedParticipantId); const view = useCall((state) => state.view); const [user, setUser] = useState(null); + const [avatarBackgroundColor, setAvatarBackgroundColor] = useState< + string | undefined + >(undefined); const isSpeaking = useIsSpeaking(user?.user_id ?? -1); const screenSharePublication = getTrackPublicationBySource( participant, @@ -111,6 +178,11 @@ export default function Base({ ); const screenSharePreview = participant?.attributes["screenSharePreview"]; + const [ownId, setOwnId] = useState(0); + useEffect(() => { + load("user_id").then(setOwnId); + }, [load]); + useEffect(() => { const participantId = Number(participant?.identity); @@ -119,7 +191,6 @@ export default function Base({ !Number.isInteger(participantId) || participantId <= 0 ) { - // eslint-disable-next-line setUser(null); return; } @@ -137,6 +208,25 @@ export default function Base({ }; }, [participant, get]); + useEffect(() => { + if (type !== "user" || !user?.avatar) { + setAvatarBackgroundColor(undefined); + return; + } + + let active = true; + + void getAverageImageColor(user.avatar).then((color) => { + if (active) { + setAvatarBackgroundColor(color); + } + }); + + return () => { + active = false; + }; + }, [type, user?.avatar]); + // Avatar calc const currentCard = useRef(null); @@ -169,7 +259,7 @@ export default function Base({ view === "focused" && user.user_id === focusedParticipantId; return ( - + {/* Detect video / user and place here */} @@ -264,9 +357,7 @@ export default function Base({ } /> - - Stop Watching - - + + ); } diff --git a/packages/call/src/components/modals/contextMenu.tsx b/packages/call/src/components/modals/contextMenu.tsx new file mode 100644 index 0000000..4a1e5f0 --- /dev/null +++ b/packages/call/src/components/modals/contextMenu.tsx @@ -0,0 +1,95 @@ +import { + ContextMenuCheckboxItem, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + Slider, +} from "@tensamin/ui"; +import { User } from "@tensamin/user/context"; +import { useCall } from "../../store"; +import { useState } from "react"; + +function EmptyCheckboxIndicator({ checked }: { checked: boolean }) { + if (checked) { + return null; + } + + return ( + + ); +} + +export default function ContextMenu({ + user, + ownId, +}: { + user: User; + ownId: number; +}) { + const [muted, setMuted] = useState(false); + const [soundboardMuted, setSoundboardMuted] = useState(false); + const [serverDeafened, setServerDeafened] = useState(false); + const [serverMuted, setServerMuted] = useState(false); + const watchedStreamParticipantIds = useCall( + (state) => state.watchedStreamParticipantIds, + ); + + return ( + + {watchedStreamParticipantIds.includes(user.user_id) && + user.user_id !== ownId ? ( + Stop Watching + ) : null} + Profile + Change Nickname + + e.preventDefault()} + className="flex flex-col items-start pb-2" + > +

Volume

+ e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + /> +
+ e.preventDefault()} + className="flex justify-between" + > +

Mute

+ +
+ e.preventDefault()} + className="flex justify-between" + > +

Mute Soundboard

+ +
+ e.preventDefault()} + className="flex justify-between text-destructive focus:text-destructive" + > +

Server Deaf

+ +
+ e.preventDefault()} + className="flex justify-between text-destructive focus:text-destructive" + > +

Server Mute

+ +
+ Disconnect +
+ ); +} diff --git a/packages/call/src/components/popout.tsx b/packages/call/src/components/popout.tsx index 20611ca..398b9e2 100644 --- a/packages/call/src/components/popout.tsx +++ b/packages/call/src/components/popout.tsx @@ -120,7 +120,6 @@ export function Popout({ participant }: { participant: Participant }) { useEffect(() => { if (isDragging) return; - // eslint-disable-next-line setCoordsSafe(getCoordsForPosition(position)); }, [position, size, isDragging, getCoordsForPosition]); diff --git a/packages/call/src/components/screenshareDialog.tsx b/packages/call/src/components/screenshareDialog.tsx index 03ce110..a11e75d 100644 --- a/packages/call/src/components/screenshareDialog.tsx +++ b/packages/call/src/components/screenshareDialog.tsx @@ -92,7 +92,6 @@ export default function ScreenShareDialog({ let active = true; - // eslint-disable-next-line setLoading(true); setSelectedSourceId(null); setSelectedAudioOutputId(NONE_AUDIO_OUTPUT); diff --git a/packages/call/todo.md b/packages/call/todo.md index f44c6fd..4d00174 100644 --- a/packages/call/todo.md +++ b/packages/call/todo.md @@ -1,14 +1,9 @@ - Overlay for stream modals -- User modals - - Bg based on avatar - Mobile -- Call invite popup - - Save call invite in `calls` array - Sounds - Admin call actions - Timeout - Disconnect -- Context menus -- Popout Window +- Implement context menu features - Add quality selection - Good preview page diff --git a/packages/tauth/src/context.tsx b/packages/tauth/src/context.tsx index faa00bd..0e094a0 100644 --- a/packages/tauth/src/context.tsx +++ b/packages/tauth/src/context.tsx @@ -123,7 +123,6 @@ export default function Wrapper({ children }: { children: ReactNode }) { const redirect = params.get("redirect"); const challenge = params.get("challenge"); if (!identifier || !redirect) { - // eslint-disable-next-line setAllowChildern(true); return; }