(feat): add context menu to call users
Some checks failed
/ build-desktop (linux) (push) Failing after 1m9s
/ build-web (push) Failing after 1m25s
/ build-mobile (push) Failing after 4m7s
/ release (push) Has been skipped

(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
This commit is contained in:
Alois 2026-06-08 15:40:04 +02:00
commit 4068669704
9 changed files with 213 additions and 27 deletions

View file

@ -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 ? (
<Tooltip>
<TooltipTrigger
render={

View file

@ -7,7 +7,7 @@ import {
DialogContent,
} from "@tensamin/ui";
import Wrapper from "@tensamin/user/wrapper";
import { PhoneIncoming, PhoneMissed } from "lucide-react";
import { PhoneIncoming, X } from "lucide-react";
export default function InvitePopup({
open,
@ -35,6 +35,13 @@ export default function InvitePopup({
</Avatar>
<p className="text-xl font-medium">{user.display}</p>
<div className="w-full flex justify-center gap-3">
<Button
className="w-14 h-14"
variant="destructive"
onClick={() => onAccept(false)}
>
<X className="size-5" />
</Button>
<Button
className="w-14 h-14"
variant="subtleDefault"
@ -42,13 +49,6 @@ export default function InvitePopup({
>
<PhoneIncoming className="size-5" />
</Button>
<Button
className="w-14 h-14"
variant="destructive"
onClick={() => onAccept(false)}
>
<PhoneMissed className="size-5" />
</Button>
</div>
</DialogContent>
</Dialog>

View file

@ -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<string | undefined>((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<User | null>(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<HTMLDivElement>(null);
@ -169,7 +259,7 @@ export default function Base({
view === "focused" && user.user_id === focusedParticipantId;
return (
<ContextMenu>
<UIContextMenu>
<ContextMenuTrigger
render={
<div
@ -216,7 +306,10 @@ export default function Base({
className={`transition-all duration-150 z-10 bg-card absolute top-0 left-0 w-full h-full flex gap-2 items-center justify-center ${
flush ? "rounded-none" : "rounded-sm"
} ${type === "user" && isSpeaking ? "border-4 border-(--primary-foreground-alt)/75" : "border-0"}`}
style={{ containerType: "size" }}
style={{
backgroundColor: avatarBackgroundColor,
containerType: "size",
}}
>
{/* Detect video / user and place here */}
@ -264,9 +357,7 @@ export default function Base({
</div>
}
/>
<ContextMenuContent>
<ContextMenuItem>Stop Watching</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
<ContextMenu user={user} ownId={ownId} />
</UIContextMenu>
);
}

View file

@ -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 (
<span className="pointer-events-none absolute right-2 size-4 rounded-[4px] border border-current opacity-70" />
);
}
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 (
<ContextMenuContent className="p-1">
{watchedStreamParticipantIds.includes(user.user_id) &&
user.user_id !== ownId ? (
<ContextMenuItem variant="destructive">Stop Watching</ContextMenuItem>
) : null}
<ContextMenuItem>Profile</ContextMenuItem>
<ContextMenuItem>Change Nickname</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
onSelect={(e) => e.preventDefault()}
className="flex flex-col items-start pb-2"
>
<p>Volume</p>
<Slider
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
/>
</ContextMenuItem>
<ContextMenuCheckboxItem
checked={muted}
onCheckedChange={setMuted}
onSelect={(e) => e.preventDefault()}
className="flex justify-between"
>
<p>Mute</p>
<EmptyCheckboxIndicator checked={muted} />
</ContextMenuCheckboxItem>
<ContextMenuCheckboxItem
checked={soundboardMuted}
onCheckedChange={setSoundboardMuted}
onSelect={(e) => e.preventDefault()}
className="flex justify-between"
>
<p>Mute Soundboard</p>
<EmptyCheckboxIndicator checked={soundboardMuted} />
</ContextMenuCheckboxItem>
<ContextMenuCheckboxItem
checked={serverDeafened}
onCheckedChange={setServerDeafened}
onSelect={(e) => e.preventDefault()}
className="flex justify-between text-destructive focus:text-destructive"
>
<p>Server Deaf</p>
<EmptyCheckboxIndicator checked={serverDeafened} />
</ContextMenuCheckboxItem>
<ContextMenuCheckboxItem
checked={serverMuted}
onCheckedChange={setServerMuted}
onSelect={(e) => e.preventDefault()}
className="flex justify-between text-destructive focus:text-destructive"
>
<p>Server Mute</p>
<EmptyCheckboxIndicator checked={serverMuted} />
</ContextMenuCheckboxItem>
<ContextMenuItem variant="destructive">Disconnect</ContextMenuItem>
</ContextMenuContent>
);
}

View file

@ -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]);

View file

@ -92,7 +92,6 @@ export default function ScreenShareDialog({
let active = true;
// eslint-disable-next-line
setLoading(true);
setSelectedSourceId(null);
setSelectedAudioOutputId(NONE_AUDIO_OUTPUT);

View file

@ -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

View file

@ -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;
}