Refactored status card

This commit is contained in:
2026-08-28 02:25:06 +02:00
parent 3a83226fd6
commit 58609bd7ec
6 changed files with 157 additions and 124 deletions
+40
View File
@@ -7,6 +7,8 @@ import { base58 } from "@scure/base";
import axios from "axios"; import axios from "axios";
import { Account, AccountsFile, getAccounts } from "@/lib/accounts"; import { Account, AccountsFile, getAccounts } from "@/lib/accounts";
import { Page } from "@/components/page/PageView"; import { Page } from "@/components/page/PageView";
import { Channel } from "@/lib/types";
import { invoke } from "@tauri-apps/api/core";
import { ClientMethod } from "./protocol"; import { ClientMethod } from "./protocol";
import { Config, getConfig } from "@/lib/config"; import { Config, getConfig } from "@/lib/config";
@@ -152,6 +154,44 @@ export default class Enclave<P = Page> {
this.forceRender(); this.forceRender();
} }
public joinVoice(channel: Channel) {
const server = this.server;
if (!server || channel.kind !== "voice") return;
server.voiceJoin = (pin, channelId) => {
invoke("connect_to_vc", { hostname: server.hostname, pin, channelId })
.then(() => {
server.isInVoiceChat = true;
server.voiceChannelId = channelId;
this.forceRender();
})
.catch(console.error);
};
server.isInVoiceChat = true;
server.voiceChannelId = channel.id;
server.websocket?.send({ method: "JoinVoice", channel_id: channel.id });
this.forceRender();
}
public leaveVoice() {
const server = this.server;
if (!server || !server.isInVoiceChat) return;
server.websocket?.send({ method: "LeaveVoice" });
invoke("disconnect_from_vc").catch(console.error);
server.isInVoiceChat = false;
server.voiceChannelId = undefined;
server.voiceChatSpeakers = {};
this.forceRender();
}
public async onMessage(msg: ClientMethod) { public async onMessage(msg: ClientMethod) {
const server = this.server; const server = this.server;
+3
View File
@@ -36,6 +36,8 @@ export default class EnclaveServer {
public voiceJoin?: (pin: bigint, channelId: string) => void; public voiceJoin?: (pin: bigint, channelId: string) => void;
public voiceChatUsers: Record<string, string[]>; public voiceChatUsers: Record<string, string[]>;
public voiceChatSpeakers: Record<string, NodeJS.Timeout>; public voiceChatSpeakers: Record<string, NodeJS.Timeout>;
public isInVoiceChat: boolean;
public voiceChannelId?: string;
public constructor(hostname: string, isSecure: boolean) { public constructor(hostname: string, isSecure: boolean) {
this.hostname = hostname; this.hostname = hostname;
@@ -44,6 +46,7 @@ export default class EnclaveServer {
this.users = {}; this.users = {};
this.voiceChatUsers = {}; this.voiceChatUsers = {};
this.voiceChatSpeakers = {}; this.voiceChatSpeakers = {};
this.isInVoiceChat = false;
} }
public disconnect() { public disconnect() {
+7 -59
View File
@@ -1,11 +1,9 @@
import Enclave from "@/app/app"; import Enclave from "@/app/app";
import { ChannelPageProps } from "../PageView"; import { ChannelPageProps } from "../PageView";
import { useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Phone, PhoneOff } from "lucide-react"; import { Phone } from "lucide-react";
export default function VoiceChannel({ export default function VoiceChannel({
appRef, appRef,
@@ -15,50 +13,9 @@ export default function VoiceChannel({
const channel = appRef.current?.page?.channel; const channel = appRef.current?.page?.channel;
if (!channel) return null; if (!channel) return null;
const [isConnected, setIsConnected] = useState(false); const isConnected =
const lastChannelId = useRef<string | null>(null); appRef.current?.server?.isInVoiceChat &&
appRef.current.server.voiceChannelId === channel.id;
// Leave current channel setup
const handleLeave = () => {
appRef.current?.server?.websocket?.send({ method: "LeaveVoice" });
invoke("disconnect_from_vc").catch(console.error);
setIsConnected(false);
};
// Explicit action to join the voice channel
const handleJoin = () => {
const hostname = appRef.current?.server?.hostname;
if (!hostname || channel.kind !== "voice" || !appRef.current?.server)
return;
appRef.current.server.voiceJoin = (pin, channelId) => {
invoke("connect_to_vc", { hostname, pin, channelId })
.then(() => setIsConnected(true))
.catch(console.error);
};
appRef.current.server.websocket?.send({
method: "JoinVoice",
channel_id: channel.id,
});
setIsConnected(true);
};
// Clean up when channel changes or component unmounts
useEffect(() => {
if (lastChannelId.current !== channel.id) {
if (isConnected) {
invoke("disconnect_from_vc").catch(console.error);
setIsConnected(false);
}
lastChannelId.current = channel.id;
}
return () => {
invoke("disconnect_from_vc").catch(console.error);
};
}, [channel.id]);
const speakers = const speakers =
appRef.current?.server?.voiceChatSpeakers && appRef.current?.server?.voiceChatSpeakers &&
@@ -141,27 +98,18 @@ export default function VoiceChannel({
</div> </div>
{/* Bottom Control Bar */} {/* Bottom Control Bar */}
{!isConnected && (
<footer className="flex items-center justify-center px-4 py-2 border-t border-border/40 bg-card/50 rounded-xl mx-2"> <footer className="flex items-center justify-center px-4 py-2 border-t border-border/40 bg-card/50 rounded-xl mx-2">
{isConnected ? (
<Button
variant="destructive"
onClick={handleLeave}
className="flex items-center gap-2 font-medium px-6"
>
<PhoneOff className="w-4 h-4" />
Disconnect
</Button>
) : (
<Button <Button
variant="default" variant="default"
onClick={handleJoin} onClick={() => appRef.current?.joinVoice(channel)}
className="flex items-center gap-2 font-medium px-6 bg-emerald-600 hover:bg-emerald-700 text-white" className="flex items-center gap-2 font-medium px-6 bg-emerald-600 hover:bg-emerald-700 text-white"
> >
<Phone className="w-4 h-4" /> <Phone className="w-4 h-4" />
Join Voice Join Voice
</Button> </Button>
)}
</footer> </footer>
)}
</div> </div>
); );
} }
-60
View File
@@ -1,60 +0,0 @@
import { Card } from "../ui/card";
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
import { CopyIcon, Settings2Icon } from "lucide-react";
import { Button } from "../ui/button";
import Enclave from "@/app/app";
import { base58 } from "@scure/base";
import * as ed from "@noble/ed25519";
export default function AccountCard({
appRef,
}: {
appRef: React.RefObject<Enclave | null>;
}) {
const account = appRef.current?.getAccount();
if (!account) return null;
return (
<div className="absolute -left-16 right-16 bottom-0 h-20 w-[calc(100%+4rem-2rem)] z-10 ml-4 mb-4 @max-[150px]:hidden">
<Card className="px-3 py-3 h-full w-full flex flex-row">
<div className="flex gap-2.5 items-center w-full">
<Avatar className="h-full w-auto aspect-square">
<AvatarImage src={account.meta.avatar} />
<AvatarFallback>{account.meta.displayName[0]}</AvatarFallback>
</Avatar>
<div className="flex flex-col w-full">
<span
className="flex items-center gap-1.5 cursor-pointer w-full"
onClick={() => {
navigator.clipboard.writeText(
base58.encode(
ed.getPublicKey(base58.decode(account.privateKey)),
),
);
}}
>
<p>{account.meta.displayName}</p>
<CopyIcon className="text-muted-foreground size-3" />
</span>
<span className="text-muted-foreground">Online</span>
</div>
</div>
<div className="flex items-center text-muted-foreground">
<Button
variant="ghost"
className="size-10"
onClick={() => {
if (!appRef.current) return;
appRef.current.isSettingsOpen = !appRef.current?.isSettingsOpen;
appRef.current.forceRender();
}}
>
<Settings2Icon className="size-5.5" />
</Button>
</div>
</Card>
</div>
);
}
+2 -2
View File
@@ -2,7 +2,7 @@ import Enclave from "@/app/app";
import { Channel, ChannelKind } from "@/lib/types"; import { Channel, ChannelKind } from "@/lib/types";
import { ChevronDown, ChevronUp, HashIcon, Volume2Icon } from "lucide-react"; import { ChevronDown, ChevronUp, HashIcon, Volume2Icon } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import AccountCard from "./AccountCard"; import StatusCard from "./StatusCard";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export function ChannelIcon({ kind }: { kind: ChannelKind["kind"] }) { export function ChannelIcon({ kind }: { kind: ChannelKind["kind"] }) {
@@ -106,7 +106,7 @@ export default function Sidebar({
</> </>
)} )}
<AccountCard appRef={appRef} /> <StatusCard appRef={appRef} />
</div> </div>
); );
} }
+102
View File
@@ -0,0 +1,102 @@
import { Card } from "../ui/card";
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
import { CopyIcon, PhoneOff, Settings2Icon, Signal } from "lucide-react";
import { Button } from "../ui/button";
import Enclave from "@/app/app";
import { base58 } from "@scure/base";
import * as ed from "@noble/ed25519";
import { Channel } from "@/lib/types";
function findChannel(channels: Channel[], id: string): Channel | undefined {
for (const channel of channels) {
if (channel.id === id) return channel;
if (channel.kind === "category") {
const found = findChannel(channel.channels, id);
if (found) return found;
}
}
return undefined;
}
export default function StatusCard({
appRef,
}: {
appRef: React.RefObject<Enclave | null>;
}) {
const account = appRef.current?.getAccount();
const server = appRef.current?.server;
const voiceChannelId = server?.voiceChannelId;
const voiceChannel =
voiceChannelId && server?.meta
? findChannel(server.meta.channels, voiceChannelId)
: undefined;
if (!account) return null;
return (
<div className="absolute -left-16 right-16 bottom-0 w-[calc(100%+4rem-2rem)] z-10 ml-4 mb-4 @max-[150px]:hidden">
<Card className="px-3 py-3 h-full w-full flex flex-col gap-0.5">
{server?.isInVoiceChat && voiceChannel && (
<div className="text-emerald-500 flex flex-row items-center gap-2 border-b pb-2.5">
<Signal className="h-5" />
<div className="flex flex-col">
<span>Voice Connected</span>
<span className="text-xs text-muted-foreground">
{voiceChannel.name}
</span>
</div>
<Button
className="ml-auto"
variant="destructive"
onClick={() => appRef.current?.leaveVoice()}
>
<PhoneOff />
Disconnect
</Button>
</div>
)}
<div className="h-full w-full flex flex-row">
<div className="flex gap-2.5 items-center w-full">
<Avatar className="h-14 w-auto aspect-square">
<AvatarImage src={account.meta.avatar} />
<AvatarFallback>{account.meta.displayName[0]}</AvatarFallback>
</Avatar>
<div className="flex flex-col w-full">
<span
className="flex items-center gap-1.5 cursor-pointer w-full"
onClick={() => {
navigator.clipboard.writeText(
base58.encode(
ed.getPublicKey(base58.decode(account.privateKey)),
),
);
}}
>
<p>{account.meta.displayName}</p>
<CopyIcon className="text-muted-foreground size-3" />
</span>
<span className="text-muted-foreground">Online</span>
</div>
</div>
<div className="flex items-center text-muted-foreground">
<Button
variant="ghost"
className="size-10"
onClick={() => {
if (!appRef.current) return;
appRef.current.isSettingsOpen = !appRef.current?.isSettingsOpen;
appRef.current.forceRender();
}}
>
<Settings2Icon className="size-5.5" />
</Button>
</div>
</div>
</Card>
</div>
);
}