(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
This commit is contained in:
parent
9edd548466
commit
4068669704
9 changed files with 213 additions and 27 deletions
|
|
@ -31,6 +31,7 @@ export default [
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
...reactHooks.configs.recommended.rules,
|
...reactHooks.configs.recommended.rules,
|
||||||
|
"react-hooks/set-state-in-effect": "off",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import {
|
||||||
SquareArrowOutDownLeft,
|
SquareArrowOutDownLeft,
|
||||||
SquareArrowOutUpRight,
|
SquareArrowOutUpRight,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useStorage } from "@tensamin/storage/context";
|
||||||
|
|
||||||
export default function Actions() {
|
export default function Actions() {
|
||||||
const sharedClasses = "w-14 h-10";
|
const sharedClasses = "w-14 h-10";
|
||||||
|
|
@ -42,6 +43,12 @@ export default function Actions() {
|
||||||
focusedParticipantId != null &&
|
focusedParticipantId != null &&
|
||||||
watchedStreamParticipantIds.includes(focusedParticipantId);
|
watchedStreamParticipantIds.includes(focusedParticipantId);
|
||||||
|
|
||||||
|
const { load } = useStorage();
|
||||||
|
const [ownId, setOwnId] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
load("user_id").then(setOwnId);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
// Fullscreen stuff
|
// Fullscreen stuff
|
||||||
const callIsPopout = useCall((state) => state.callIsPopout);
|
const callIsPopout = useCall((state) => state.callIsPopout);
|
||||||
const callIsFullscreen = useCall((state) => state.callIsFullscreen);
|
const callIsFullscreen = useCall((state) => state.callIsFullscreen);
|
||||||
|
|
@ -122,7 +129,7 @@ export default function Actions() {
|
||||||
iconSize={sharedIconSize}
|
iconSize={sharedIconSize}
|
||||||
tooltip="Invite"
|
tooltip="Invite"
|
||||||
/>
|
/>
|
||||||
{isWatchingFocusedStream ? (
|
{isWatchingFocusedStream && focusedParticipantId !== ownId ? (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
render={
|
render={
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import {
|
||||||
DialogContent,
|
DialogContent,
|
||||||
} from "@tensamin/ui";
|
} from "@tensamin/ui";
|
||||||
import Wrapper from "@tensamin/user/wrapper";
|
import Wrapper from "@tensamin/user/wrapper";
|
||||||
import { PhoneIncoming, PhoneMissed } from "lucide-react";
|
import { PhoneIncoming, X } from "lucide-react";
|
||||||
|
|
||||||
export default function InvitePopup({
|
export default function InvitePopup({
|
||||||
open,
|
open,
|
||||||
|
|
@ -35,6 +35,13 @@ export default function InvitePopup({
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<p className="text-xl font-medium">{user.display}</p>
|
<p className="text-xl font-medium">{user.display}</p>
|
||||||
<div className="w-full flex justify-center gap-3">
|
<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
|
<Button
|
||||||
className="w-14 h-14"
|
className="w-14 h-14"
|
||||||
variant="subtleDefault"
|
variant="subtleDefault"
|
||||||
|
|
@ -42,13 +49,6 @@ export default function InvitePopup({
|
||||||
>
|
>
|
||||||
<PhoneIncoming className="size-5" />
|
<PhoneIncoming className="size-5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
className="w-14 h-14"
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => onAccept(false)}
|
|
||||||
>
|
|
||||||
<PhoneMissed className="size-5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,7 @@ import {
|
||||||
AvatarFallback,
|
AvatarFallback,
|
||||||
AvatarImage,
|
AvatarImage,
|
||||||
Button,
|
Button,
|
||||||
ContextMenu,
|
ContextMenu as UIContextMenu,
|
||||||
ContextMenuContent,
|
|
||||||
ContextMenuItem,
|
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from "@tensamin/ui";
|
} from "@tensamin/ui";
|
||||||
import {
|
import {
|
||||||
|
|
@ -21,6 +19,8 @@ import { useUser, type User } from "@tensamin/user/context";
|
||||||
import { useIsSpeaking } from "../../speakingState";
|
import { useIsSpeaking } from "../../speakingState";
|
||||||
import VideoViewer from "../videoViewer";
|
import VideoViewer from "../videoViewer";
|
||||||
import { HeadphoneOff, MicOff, Monitor, Plus, Shield } from "lucide-react";
|
import { HeadphoneOff, MicOff, Monitor, Plus, Shield } from "lucide-react";
|
||||||
|
import ContextMenu from "./contextMenu";
|
||||||
|
import { useStorage } from "@tensamin/storage/context";
|
||||||
|
|
||||||
function getTrackPublicationBySource(
|
function getTrackPublicationBySource(
|
||||||
participant: Participant | undefined,
|
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({
|
function Overlay({
|
||||||
type,
|
type,
|
||||||
user,
|
user,
|
||||||
|
|
@ -100,10 +163,14 @@ export default function Base({
|
||||||
flush?: boolean;
|
flush?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { get } = useUser();
|
const { get } = useUser();
|
||||||
|
const { load } = useStorage();
|
||||||
|
|
||||||
const focusedParticipantId = useCall((state) => state.focusedParticipantId);
|
const focusedParticipantId = useCall((state) => state.focusedParticipantId);
|
||||||
const view = useCall((state) => state.view);
|
const view = useCall((state) => state.view);
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [avatarBackgroundColor, setAvatarBackgroundColor] = useState<
|
||||||
|
string | undefined
|
||||||
|
>(undefined);
|
||||||
const isSpeaking = useIsSpeaking(user?.user_id ?? -1);
|
const isSpeaking = useIsSpeaking(user?.user_id ?? -1);
|
||||||
const screenSharePublication = getTrackPublicationBySource(
|
const screenSharePublication = getTrackPublicationBySource(
|
||||||
participant,
|
participant,
|
||||||
|
|
@ -111,6 +178,11 @@ export default function Base({
|
||||||
);
|
);
|
||||||
const screenSharePreview = participant?.attributes["screenSharePreview"];
|
const screenSharePreview = participant?.attributes["screenSharePreview"];
|
||||||
|
|
||||||
|
const [ownId, setOwnId] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
load("user_id").then(setOwnId);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const participantId = Number(participant?.identity);
|
const participantId = Number(participant?.identity);
|
||||||
|
|
||||||
|
|
@ -119,7 +191,6 @@ export default function Base({
|
||||||
!Number.isInteger(participantId) ||
|
!Number.isInteger(participantId) ||
|
||||||
participantId <= 0
|
participantId <= 0
|
||||||
) {
|
) {
|
||||||
// eslint-disable-next-line
|
|
||||||
setUser(null);
|
setUser(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -137,6 +208,25 @@ export default function Base({
|
||||||
};
|
};
|
||||||
}, [participant, get]);
|
}, [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
|
// Avatar calc
|
||||||
const currentCard = useRef<HTMLDivElement>(null);
|
const currentCard = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
|
@ -169,7 +259,7 @@ export default function Base({
|
||||||
view === "focused" && user.user_id === focusedParticipantId;
|
view === "focused" && user.user_id === focusedParticipantId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextMenu>
|
<UIContextMenu>
|
||||||
<ContextMenuTrigger
|
<ContextMenuTrigger
|
||||||
render={
|
render={
|
||||||
<div
|
<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 ${
|
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"
|
flush ? "rounded-none" : "rounded-sm"
|
||||||
} ${type === "user" && isSpeaking ? "border-4 border-(--primary-foreground-alt)/75" : "border-0"}`}
|
} ${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 */}
|
{/* Detect video / user and place here */}
|
||||||
|
|
||||||
|
|
@ -264,9 +357,7 @@ export default function Base({
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<ContextMenuContent>
|
<ContextMenu user={user} ownId={ownId} />
|
||||||
<ContextMenuItem>Stop Watching</ContextMenuItem>
|
</UIContextMenu>
|
||||||
</ContextMenuContent>
|
|
||||||
</ContextMenu>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
95
packages/call/src/components/modals/contextMenu.tsx
Normal file
95
packages/call/src/components/modals/contextMenu.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -120,7 +120,6 @@ export function Popout({ participant }: { participant: Participant }) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isDragging) return;
|
if (isDragging) return;
|
||||||
|
|
||||||
// eslint-disable-next-line
|
|
||||||
setCoordsSafe(getCoordsForPosition(position));
|
setCoordsSafe(getCoordsForPosition(position));
|
||||||
}, [position, size, isDragging, getCoordsForPosition]);
|
}, [position, size, isDragging, getCoordsForPosition]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,6 @@ export default function ScreenShareDialog({
|
||||||
|
|
||||||
let active = true;
|
let active = true;
|
||||||
|
|
||||||
// eslint-disable-next-line
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setSelectedSourceId(null);
|
setSelectedSourceId(null);
|
||||||
setSelectedAudioOutputId(NONE_AUDIO_OUTPUT);
|
setSelectedAudioOutputId(NONE_AUDIO_OUTPUT);
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,9 @@
|
||||||
- Overlay for stream modals
|
- Overlay for stream modals
|
||||||
- User modals
|
|
||||||
- Bg based on avatar
|
|
||||||
- Mobile
|
- Mobile
|
||||||
- Call invite popup
|
|
||||||
- Save call invite in `calls` array
|
|
||||||
- Sounds
|
- Sounds
|
||||||
- Admin call actions
|
- Admin call actions
|
||||||
- Timeout
|
- Timeout
|
||||||
- Disconnect
|
- Disconnect
|
||||||
- Context menus
|
- Implement context menu features
|
||||||
- Popout Window
|
|
||||||
- Add quality selection
|
- Add quality selection
|
||||||
- Good preview page
|
- Good preview page
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,6 @@ export default function Wrapper({ children }: { children: ReactNode }) {
|
||||||
const redirect = params.get("redirect");
|
const redirect = params.get("redirect");
|
||||||
const challenge = params.get("challenge");
|
const challenge = params.get("challenge");
|
||||||
if (!identifier || !redirect) {
|
if (!identifier || !redirect) {
|
||||||
// eslint-disable-next-line
|
|
||||||
setAllowChildern(true);
|
setAllowChildern(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue