(feat): add live messages
(feat): update licenses (feat): update livekit logging (feat): add basic grid layout (fix): remove deeplink log message
This commit is contained in:
parent
3768488e49
commit
8e294d230e
21 changed files with 510 additions and 51 deletions
|
|
@ -5,14 +5,20 @@ import ScreenshareButton from "./buttons/screenshare";
|
|||
import LeaveButton from "./buttons/leave";
|
||||
|
||||
export default function Actions() {
|
||||
const sharedClasses = "w-14 h-10";
|
||||
const sharedIconSize = 15;
|
||||
|
||||
return (
|
||||
<div className="w-full flex justify-center py-3">
|
||||
<Card className="p-1.75">
|
||||
<CardContent className="p-0! flex gap-1.75">
|
||||
<MuteButton className="w-14 h-10" iconSize={25} />
|
||||
<DeafButton className="w-14 h-10" iconSize={25} />
|
||||
<ScreenshareButton className="w-14 h-10" iconSize={25} />
|
||||
<LeaveButton className="w-14 h-10" iconSize={25} />
|
||||
<MuteButton className={sharedClasses} iconSize={sharedIconSize} />
|
||||
<DeafButton className={sharedClasses} iconSize={sharedIconSize} />
|
||||
<ScreenshareButton
|
||||
className={sharedClasses}
|
||||
iconSize={sharedIconSize}
|
||||
/>
|
||||
<LeaveButton className={sharedClasses} iconSize={sharedIconSize} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { LeaveIcon } from "@livekit/components-react";
|
||||
import { Button } from "@tensamin/ui";
|
||||
import { disconnect } from "../../store";
|
||||
import { disconnect, useCall } from "../../store";
|
||||
|
||||
export default function LeaveButton({
|
||||
className,
|
||||
|
|
@ -9,10 +9,13 @@ export default function LeaveButton({
|
|||
className?: string;
|
||||
iconSize?: number;
|
||||
}) {
|
||||
const state = useCall((store) => store.state);
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={className}
|
||||
onClick={() => disconnect()}
|
||||
disabled={state === "closing" || state === "closed"}
|
||||
variant="destructive"
|
||||
>
|
||||
<LeaveIcon style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }} />
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ export function TinyPingGraph() {
|
|||
<TooltipTrigger
|
||||
render={
|
||||
<div
|
||||
className="w-full h-6"
|
||||
className="h-6 w-24 min-w-0 shrink-0"
|
||||
style={{
|
||||
WebkitMaskImage:
|
||||
"linear-gradient(to right, transparent 0%, var(--primary) 15%, var(--primary) 85%, transparent 100%)",
|
||||
|
|
@ -122,13 +122,24 @@ export function TinyPingGraph() {
|
|||
}}
|
||||
>
|
||||
{data.length > 1 && (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
height="100%"
|
||||
minWidth={0}
|
||||
minHeight={24}
|
||||
>
|
||||
<AreaChart
|
||||
data={data}
|
||||
margin={{ top: 2, right: 0, bottom: 2, left: 0 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="pingGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<linearGradient
|
||||
id="pingGradient"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor="currentColor"
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ export default function TopBar() {
|
|||
let active = true;
|
||||
|
||||
if (userIds.length === 0) {
|
||||
setUsers([]);
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
|
|
@ -42,7 +41,7 @@ export default function TopBar() {
|
|||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [get, userIdsKey]);
|
||||
}, [get, userIdsKey, userIds]);
|
||||
|
||||
return (
|
||||
<div className="w-full flex justify-between h-12">
|
||||
|
|
|
|||
|
|
@ -15,10 +15,21 @@ import {
|
|||
RoomEvent,
|
||||
type RemoteTrack,
|
||||
Track,
|
||||
setLogExtension,
|
||||
getLogger,
|
||||
} from "livekit-client";
|
||||
import z from "zod";
|
||||
import { toast as sonnerToast } from "sonner";
|
||||
|
||||
// logging
|
||||
setLogExtension(
|
||||
(level, message, context) =>
|
||||
context
|
||||
? log(level, "livekit", "blue", message, context)
|
||||
: log(level, "livekit", "blue", message),
|
||||
getLogger("tensamin"),
|
||||
);
|
||||
|
||||
type CallState = "closed" | "closing" | "connecting" | "open" | "encrypting";
|
||||
type CallView = "preview" | "focused" | "grid";
|
||||
type CurrentCallData =
|
||||
|
|
@ -76,6 +87,7 @@ const e2eeWorker = new Worker(
|
|||
const room = new Room({
|
||||
dynacast: true,
|
||||
adaptiveStream: true,
|
||||
loggerName: "tensamin",
|
||||
encryption: {
|
||||
keyProvider,
|
||||
worker: e2eeWorker,
|
||||
|
|
@ -83,6 +95,7 @@ const room = new Room({
|
|||
});
|
||||
const remoteAudioElements = new Map<string, HTMLMediaElement>();
|
||||
|
||||
// audio helpers
|
||||
function attachRemoteAudio(trackSid: string, element: HTMLMediaElement) {
|
||||
const existingElement = remoteAudioElements.get(trackSid);
|
||||
|
||||
|
|
@ -113,6 +126,7 @@ function clearRemoteAudio() {
|
|||
}
|
||||
}
|
||||
|
||||
// utils
|
||||
function requireRuntime(runtime: Runtime | null): Runtime {
|
||||
if (!runtime) {
|
||||
throw new Error("Call store is not initialized");
|
||||
|
|
@ -132,6 +146,7 @@ export function syncParticipantState() {
|
|||
});
|
||||
}
|
||||
|
||||
// set state functions
|
||||
export function setCallRuntime(runtime: Runtime) {
|
||||
useCall.setState({ runtime });
|
||||
}
|
||||
|
|
@ -154,6 +169,7 @@ export function setCurrentCallData(
|
|||
useCall.setState({ currentCallData });
|
||||
}
|
||||
|
||||
// more utils
|
||||
export async function openCallPage(callId: string) {
|
||||
await requireRuntime(useCall.getState().runtime).navigate({
|
||||
to: "/call",
|
||||
|
|
@ -225,6 +241,7 @@ export function disconnect() {
|
|||
});
|
||||
|
||||
room.disconnect();
|
||||
useCall.setState({ state: "closed" });
|
||||
syncParticipantState();
|
||||
}
|
||||
|
||||
|
|
@ -594,7 +611,7 @@ export function useInitializeCall() {
|
|||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
log(1, "Call", "red", "Failed to get call data", {
|
||||
log(1, "call", "red", "Failed to get call data", {
|
||||
callId,
|
||||
error: err,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,173 @@
|
|||
import { RoomEvent } from "livekit-client";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCall } from "../../store";
|
||||
|
||||
const TILE_ASPECT_RATIO = 16 / 9;
|
||||
const GRID_GAP = 12;
|
||||
|
||||
type GridLayout = {
|
||||
columns: number;
|
||||
rows: number;
|
||||
tileWidth: number;
|
||||
tileHeight: number;
|
||||
rowCounts: number[];
|
||||
};
|
||||
|
||||
function buildBalancedRows(itemCount: number, rows: number) {
|
||||
if (itemCount === 0 || rows === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const baseCount = Math.floor(itemCount / rows);
|
||||
const remainder = itemCount % rows;
|
||||
|
||||
return Array.from(
|
||||
{ length: rows },
|
||||
(_, index) => baseCount + (index < remainder ? 1 : 0),
|
||||
);
|
||||
}
|
||||
|
||||
function calculateOptimalGridLayout(
|
||||
containerWidth: number,
|
||||
containerHeight: number,
|
||||
itemCount: number,
|
||||
): GridLayout {
|
||||
if (containerWidth <= 0 || containerHeight <= 0 || itemCount <= 0) {
|
||||
return {
|
||||
columns: 0,
|
||||
rows: 0,
|
||||
tileWidth: 0,
|
||||
tileHeight: 0,
|
||||
rowCounts: [],
|
||||
};
|
||||
}
|
||||
|
||||
let bestLayout: GridLayout = {
|
||||
columns: 1,
|
||||
rows: itemCount,
|
||||
tileWidth: 0,
|
||||
tileHeight: 0,
|
||||
rowCounts: Array.from({ length: itemCount }, () => 1),
|
||||
};
|
||||
|
||||
for (let columns = 1; columns <= itemCount; columns++) {
|
||||
const rows = Math.ceil(itemCount / columns);
|
||||
const maxTileWidth =
|
||||
(containerWidth - GRID_GAP * Math.max(columns - 1, 0)) / columns;
|
||||
const maxTileHeight =
|
||||
(containerHeight - GRID_GAP * Math.max(rows - 1, 0)) / rows;
|
||||
const tileWidth = Math.min(maxTileWidth, maxTileHeight * TILE_ASPECT_RATIO);
|
||||
const tileHeight = tileWidth / TILE_ASPECT_RATIO;
|
||||
|
||||
if (tileWidth <= 0 || tileHeight <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentArea = tileWidth * tileHeight;
|
||||
const bestArea = bestLayout.tileWidth * bestLayout.tileHeight;
|
||||
|
||||
if (currentArea > bestArea) {
|
||||
bestLayout = {
|
||||
columns,
|
||||
rows,
|
||||
tileWidth,
|
||||
tileHeight,
|
||||
rowCounts: buildBalancedRows(itemCount, rows),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return bestLayout;
|
||||
}
|
||||
|
||||
export default function View() {
|
||||
return <div className="w-full h-full bg-red-500">Grid</div>;
|
||||
const room = useCall((state) => state.room);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
|
||||
const [participantCount, setParticipantCount] = useState(
|
||||
() => room.remoteParticipants.size + 1,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const element = containerRef.current;
|
||||
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
const { width, height } = entry.contentRect;
|
||||
setContainerSize({ width, height });
|
||||
});
|
||||
|
||||
observer.observe(element);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const syncParticipantCount = () => {
|
||||
setParticipantCount(room.remoteParticipants.size + 1);
|
||||
};
|
||||
|
||||
syncParticipantCount();
|
||||
|
||||
room.on(RoomEvent.Connected, syncParticipantCount);
|
||||
room.on(RoomEvent.Disconnected, syncParticipantCount);
|
||||
room.on(RoomEvent.ParticipantConnected, syncParticipantCount);
|
||||
room.on(RoomEvent.ParticipantDisconnected, syncParticipantCount);
|
||||
|
||||
return () => {
|
||||
room.off(RoomEvent.Connected, syncParticipantCount);
|
||||
room.off(RoomEvent.Disconnected, syncParticipantCount);
|
||||
room.off(RoomEvent.ParticipantConnected, syncParticipantCount);
|
||||
room.off(RoomEvent.ParticipantDisconnected, syncParticipantCount);
|
||||
};
|
||||
}, [room]);
|
||||
|
||||
const layout = useMemo(
|
||||
() =>
|
||||
calculateOptimalGridLayout(
|
||||
containerSize.width,
|
||||
containerSize.height,
|
||||
participantCount,
|
||||
),
|
||||
[containerSize.height, containerSize.width, participantCount],
|
||||
);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
let nextParticipant = 1;
|
||||
|
||||
return layout.rowCounts.map((count, rowIndex) => {
|
||||
const participants = Array.from(
|
||||
{ length: count },
|
||||
() => nextParticipant++,
|
||||
);
|
||||
return { rowIndex, participants };
|
||||
});
|
||||
}, [layout.rowCounts]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="h-full w-full overflow-hidden p-3">
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3">
|
||||
{rows.map((row) => (
|
||||
<div key={row.rowIndex} className="flex justify-center gap-3">
|
||||
{row.participants.map((participant) => (
|
||||
<div
|
||||
key={participant}
|
||||
className="flex items-center justify-center rounded-xl bg-red-500"
|
||||
style={{
|
||||
width: layout.tileWidth,
|
||||
height: layout.tileHeight,
|
||||
}}
|
||||
>
|
||||
Test {participant}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ export default function Preview() {
|
|||
let active = true;
|
||||
|
||||
if (!currentCallData?.exists) {
|
||||
setData([]);
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
|
|
@ -52,7 +51,7 @@ export default function Preview() {
|
|||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-2xl">Invalid call</p>
|
||||
<p className="text-2xl">Call expired</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -12,7 +12,11 @@
|
|||
"build": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tensamin/ttp": "git+https://github.com/Tensamin/TTP.git",
|
||||
"@tensamin/ttp": "workspace:*",
|
||||
"@tensamin/shared": "workspace:*",
|
||||
"@tensamin/crypto": "workspace:*",
|
||||
"@tensamin/storage": "workspace:*",
|
||||
"@tensamin/user": "workspace:*",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"zod": "^4.3.6"
|
||||
|
|
|
|||
|
|
@ -1,14 +1,43 @@
|
|||
import * as React from "react";
|
||||
import { useCrypto } from "@tensamin/crypto/context";
|
||||
import { useStorage } from "@tensamin/storage/context";
|
||||
import { useUser } from "@tensamin/user/context";
|
||||
import { useTTP } from "@tensamin/ttp";
|
||||
import { createContext, useEffect, useContext } from "react";
|
||||
import z from "zod";
|
||||
import { toast as sonnerToast } from "sonner";
|
||||
import { message as messageSchema } from "@tensamin/shared/data";
|
||||
|
||||
export const context = React.createContext<contextType | undefined>(undefined);
|
||||
export const context = createContext<contextType | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* Executes Provider.
|
||||
* @param props Parameter props.
|
||||
* @returns unknown.
|
||||
*/
|
||||
export default function Provider(props: { children: React.ReactNode }) {
|
||||
// live_messages
|
||||
const { subscribePush } = useTTP();
|
||||
const { load } = useStorage();
|
||||
const { get } = useUser();
|
||||
const { decryptText, getSharedSecret } = useCrypto();
|
||||
|
||||
useEffect(() => {
|
||||
subscribePush(async (ttpMessage) => {
|
||||
if (ttpMessage.type === "message_live") {
|
||||
const { message, sender_id } = ttpMessage.data as {
|
||||
message: z.infer<typeof messageSchema>;
|
||||
sender_id: number;
|
||||
};
|
||||
|
||||
const decryptedContent = await decryptText(
|
||||
await getSharedSecret(
|
||||
await load("private_key"),
|
||||
await get(await load("user_id")).then((data) => data.public_key),
|
||||
await get(sender_id).then((data) => data.public_key),
|
||||
),
|
||||
message.content,
|
||||
);
|
||||
|
||||
sonnerToast(decryptedContent, {
|
||||
icon: <div>A</div>,
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [subscribePush, decryptText, getSharedSecret, load, get]);
|
||||
|
||||
return (
|
||||
<context.Provider value={{ test: () => {} }}>
|
||||
|
|
@ -27,7 +56,7 @@ type contextType = {
|
|||
* @returns contextType.
|
||||
*/
|
||||
export function useNotifications(): contextType {
|
||||
const ctx = React.useContext(context);
|
||||
const ctx = useContext(context);
|
||||
if (!ctx) {
|
||||
throw new Error("useNotifications must be used within a ChatProvider");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,10 +12,10 @@ const fileFromMessage = z.object({
|
|||
type: z.enum(["image", "image_top_right", "file"]),
|
||||
});
|
||||
|
||||
const message = z.object({
|
||||
export const message = z.object({
|
||||
height: z.number(),
|
||||
not_encrypted: z.boolean().optional(),
|
||||
sent_by_self: z.boolean(),
|
||||
sent_by_self: z.boolean().optional(),
|
||||
send_time: z.number(),
|
||||
content: z.base64(),
|
||||
files: z.array(fileFromMessage).optional(),
|
||||
|
|
@ -113,10 +113,9 @@ export const ttp = {
|
|||
}),
|
||||
},
|
||||
message_live: {
|
||||
request: z.object({}),
|
||||
request: z.object({}).optional(),
|
||||
response: z.object({
|
||||
sender_id: z.number(),
|
||||
send_time: z.number(),
|
||||
message,
|
||||
}),
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue