Merge pull request #5 from recurse-chat/finalized-messages-mvp
Finalized messages mvp
This commit is contained in:
+33
-7
@@ -165,19 +165,45 @@ export default class Enclave<P = Page> {
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
case "Messages":
|
case "Messages":
|
||||||
Object.entries(msg.messages).forEach(([channelId, messages]) => {
|
const authors = Object.entries(msg.messages).flatMap(
|
||||||
if (!server.messages[channelId]) {
|
([channelId, messages]) => {
|
||||||
server.messages[channelId] = {};
|
if (!server.messages[channelId]) {
|
||||||
}
|
server.messages[channelId] = {};
|
||||||
|
}
|
||||||
|
|
||||||
messages.forEach((message) => {
|
return messages.map((message) => {
|
||||||
server.messages[channelId][message.id] = message;
|
server.messages[channelId][message.id] = message;
|
||||||
});
|
return message.author;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const dedupedAuthors = Array.from(new Set(authors));
|
||||||
|
|
||||||
|
this.server?.getUsers(dedupedAuthors);
|
||||||
|
|
||||||
|
this.forceRender();
|
||||||
|
|
||||||
|
return;
|
||||||
|
|
||||||
|
case "Users":
|
||||||
|
Object.entries(msg.users).forEach(([pubKey, user]) => {
|
||||||
|
server.users[pubKey] = user;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.forceRender();
|
this.forceRender();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
case "MessageDeleted":
|
||||||
|
delete server.messages[msg.channel_id][msg.message_id];
|
||||||
|
this.forceRender();
|
||||||
|
return;
|
||||||
|
|
||||||
|
case "MessageEdited":
|
||||||
|
server.messages[msg.channel_id][msg.message.id] = msg.message;
|
||||||
|
this.forceRender();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,20 @@ export type ClientMethod =
|
|||||||
| {
|
| {
|
||||||
method: "Messages";
|
method: "Messages";
|
||||||
messages: Record<string, StoredMessage[]>;
|
messages: Record<string, StoredMessage[]>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
method: "Users";
|
||||||
|
users: Record<string, ClientMeta>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
method: "MessageEdited";
|
||||||
|
channel_id: string;
|
||||||
|
message: StoredMessage;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
method: "MessageDeleted";
|
||||||
|
channel_id: string;
|
||||||
|
message_id: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ServerMethod =
|
export type ServerMethod =
|
||||||
@@ -41,4 +55,20 @@ export type ServerMethod =
|
|||||||
method: "GetMessages";
|
method: "GetMessages";
|
||||||
channel_id: string;
|
channel_id: string;
|
||||||
chunk: number;
|
chunk: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
method: "GetUsers";
|
||||||
|
pubkeys: string[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
method: "EditMessage";
|
||||||
|
message_id: string;
|
||||||
|
channel_id: string;
|
||||||
|
content: string;
|
||||||
|
signature: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
method: "DeleteMessage";
|
||||||
|
channel_id: string;
|
||||||
|
message_id: string;
|
||||||
};
|
};
|
||||||
|
|||||||
+13
-1
@@ -3,7 +3,7 @@ import * as ed from "@noble/ed25519";
|
|||||||
import EnclaveWebSocket from "./ws";
|
import EnclaveWebSocket from "./ws";
|
||||||
import { sha512 } from "@noble/hashes/sha2.js";
|
import { sha512 } from "@noble/hashes/sha2.js";
|
||||||
import { getWSUrl } from "@/lib/serverList";
|
import { getWSUrl } from "@/lib/serverList";
|
||||||
import { ServerMeta, StoredMessage } from "@/lib/types";
|
import { ClientMeta, ServerMeta, StoredMessage } from "@/lib/types";
|
||||||
|
|
||||||
ed.hashes.sha512 = sha512;
|
ed.hashes.sha512 = sha512;
|
||||||
|
|
||||||
@@ -30,11 +30,13 @@ export default class EnclaveServer {
|
|||||||
public websocket?: EnclaveWebSocket;
|
public websocket?: EnclaveWebSocket;
|
||||||
public meta?: ServerMeta;
|
public meta?: ServerMeta;
|
||||||
public messages: Record<string, Record<string, StoredMessage>>;
|
public messages: Record<string, Record<string, StoredMessage>>;
|
||||||
|
public users: Record<string, ClientMeta | null>;
|
||||||
|
|
||||||
public constructor(hostname: string, isSecure: boolean) {
|
public constructor(hostname: string, isSecure: boolean) {
|
||||||
this.hostname = hostname;
|
this.hostname = hostname;
|
||||||
this.isSecure = isSecure;
|
this.isSecure = isSecure;
|
||||||
this.messages = {};
|
this.messages = {};
|
||||||
|
this.users = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
public disconnect() {
|
public disconnect() {
|
||||||
@@ -94,4 +96,14 @@ export default class EnclaveServer {
|
|||||||
throw Error("Invalid signature");
|
throw Error("Invalid signature");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getUsers(pubkeys: string[]) {
|
||||||
|
const filteredPubkeys = pubkeys.filter((pubkey) => {
|
||||||
|
const exists = this.users[pubkey] !== undefined;
|
||||||
|
if (!exists) this.users[pubkey] = null;
|
||||||
|
return !exists;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.websocket?.send({ method: "GetUsers", pubkeys: filteredPubkeys });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
import Enclave from "@/app/app";
|
import Enclave from "@/app/app";
|
||||||
import { ChannelPageProps } from "@/components/page/PageView";
|
import { ChannelPageProps } from "@/components/page/PageView";
|
||||||
import { Avatar, AvatarFallback } 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 { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { StoredMessage } from "@/lib/types";
|
import { Channel, StoredMessage } from "@/lib/types";
|
||||||
import { useEffect, useState } from "react";
|
import { base58 } from "@scure/base";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import * as ed from "@noble/ed25519";
|
||||||
|
import { CircleXIcon, PencilIcon, Trash2Icon } from "lucide-react";
|
||||||
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
} from "@/components/ui/context-menu";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
||||||
export default function TextChannel({
|
export default function TextChannel({
|
||||||
appRef,
|
appRef,
|
||||||
@@ -14,38 +24,74 @@ export default function TextChannel({
|
|||||||
const [currentMessage, setCurrentMessage] = useState("");
|
const [currentMessage, setCurrentMessage] = useState("");
|
||||||
const channel = appRef.current?.page?.channel;
|
const channel = appRef.current?.page?.channel;
|
||||||
|
|
||||||
|
const intersectionRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const chunkRef = useRef(0);
|
||||||
|
|
||||||
|
const account = appRef.current?.getAccount();
|
||||||
|
|
||||||
|
const secretKey = (account && base58.decode(account.privateKey)) || null;
|
||||||
|
|
||||||
|
const userPubKey = secretKey && base58.encode(ed.getPublicKey(secretKey));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!channel) return;
|
if (!intersectionRef.current) return;
|
||||||
|
|
||||||
appRef.current?.server?.websocket?.send({
|
const observer = new IntersectionObserver(
|
||||||
method: "GetMessages",
|
([entry]) => {
|
||||||
channel_id: channel.id,
|
if (entry.isIntersecting) {
|
||||||
chunk: 0,
|
console.log("Element is in view!");
|
||||||
});
|
if (!channel) return;
|
||||||
|
|
||||||
appRef.current?.forceRender();
|
appRef.current?.server?.websocket?.send({
|
||||||
}, [channel]);
|
method: "GetMessages",
|
||||||
|
channel_id: channel.id,
|
||||||
|
chunk: chunkRef.current,
|
||||||
|
});
|
||||||
|
|
||||||
|
appRef.current?.forceRender();
|
||||||
|
chunkRef.current += 1;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
threshold: 0.5,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(intersectionRef.current);
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (!channel) return null;
|
if (!channel) return null;
|
||||||
|
|
||||||
|
const messages = appRef.current?.server?.messages[channel.id];
|
||||||
|
|
||||||
const sendMessage = () => {
|
const sendMessage = () => {
|
||||||
appRef.current?.sendMessage(currentMessage, channel.id);
|
appRef.current?.sendMessage(currentMessage, channel.id);
|
||||||
setCurrentMessage("");
|
setCurrentMessage("");
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-screen gap-2">
|
||||||
<header className="px-3 pt-3 pb-3 text-sm text-muted-foreground border-b border-b-border">
|
<header className="px-3 pt-3 pb-3 text-sm text-muted-foreground border-b border-b-border">
|
||||||
<h2>{channel.name}</h2>
|
<h2>{channel.name}</h2>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="h-full flex flex-col gap-2.5 px-3 pt-4 overflow-y-scroll">
|
<div className="h-full flex flex-col-reverse gap-2.5 px-3 pt-4 overflow-y-scroll scrollbar-none [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||||
{appRef.current?.server?.messages[channel.id] &&
|
{messages &&
|
||||||
Object.entries(appRef.current?.server?.messages[channel.id]).map(
|
Object.entries(messages)
|
||||||
([_, message]) => (
|
.sort((a, b) => b[1].timestamp - a[1].timestamp)
|
||||||
<TextMessage key={message.id} appRef={appRef} message={message} />
|
.map(([_, message]) => (
|
||||||
),
|
<TextMessage
|
||||||
)}
|
key={message.id}
|
||||||
|
appRef={appRef}
|
||||||
|
message={message}
|
||||||
|
channel={channel}
|
||||||
|
userPubKey={userPubKey}
|
||||||
|
secretKey={secretKey}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<div ref={intersectionRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pb-6 px-4 flex flex-row gap-3">
|
<div className="pb-6 px-4 flex flex-row gap-3">
|
||||||
@@ -69,35 +115,188 @@ export default function TextChannel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TextMessage({
|
export function TextMessage({
|
||||||
|
appRef,
|
||||||
message,
|
message,
|
||||||
|
channel,
|
||||||
|
userPubKey,
|
||||||
|
secretKey,
|
||||||
}: {
|
}: {
|
||||||
appRef: React.RefObject<Enclave<ChannelPageProps> | null>;
|
appRef: React.RefObject<Enclave<ChannelPageProps> | null>;
|
||||||
message: StoredMessage;
|
message: StoredMessage;
|
||||||
|
channel: Channel;
|
||||||
|
userPubKey: string | null;
|
||||||
|
secretKey: Uint8Array | null;
|
||||||
}) {
|
}) {
|
||||||
const time = new Date(message.timestamp).toLocaleTimeString([], {
|
const time = new Date(message.timestamp).toLocaleTimeString([], {
|
||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
const author = appRef.current?.server?.users[message.author];
|
||||||
|
|
||||||
|
const [verified, setVerified] = useState(true);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [draft, setDraft] = useState(message.content);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const serverPubKey =
|
||||||
|
appRef.current?.server?.serverPublicKey &&
|
||||||
|
base58.encode(appRef.current.server.serverPublicKey);
|
||||||
|
|
||||||
|
if (!serverPubKey) return;
|
||||||
|
|
||||||
|
const authorPubKey = base58.decode(message.author);
|
||||||
|
|
||||||
|
setVerified(
|
||||||
|
ed.verify(
|
||||||
|
base58.decode(message.signature),
|
||||||
|
new TextEncoder().encode(
|
||||||
|
`${message.timestamp}@${serverPubKey}@${message.content}`,
|
||||||
|
),
|
||||||
|
authorPubKey,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isOwnMessage = message.author === userPubKey;
|
||||||
|
|
||||||
|
function handleDelete() {
|
||||||
|
appRef.current?.server?.websocket?.send({
|
||||||
|
method: "DeleteMessage",
|
||||||
|
message_id: message.id,
|
||||||
|
channel_id: channel.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEditing() {
|
||||||
|
setDraft(message.content);
|
||||||
|
setEditing(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEditing() {
|
||||||
|
setDraft(message.content);
|
||||||
|
setEditing(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
const content = draft.trim();
|
||||||
|
|
||||||
|
if (
|
||||||
|
!content ||
|
||||||
|
content === message.content ||
|
||||||
|
!secretKey ||
|
||||||
|
!appRef.current?.server?.serverPublicKey
|
||||||
|
) {
|
||||||
|
cancelEditing();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const signature = new TextEncoder().encode(
|
||||||
|
`${message.timestamp}@${base58.encode(appRef.current.server?.serverPublicKey)}@${content}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
appRef.current?.server?.websocket?.send({
|
||||||
|
method: "EditMessage",
|
||||||
|
message_id: message.id,
|
||||||
|
channel_id: channel.id,
|
||||||
|
signature: base58.encode(ed.sign(signature, secretKey)),
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
|
||||||
|
setEditing(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
cancelEditing();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSave();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = (
|
||||||
<div className="rounded-lg flex gap-3 px-3 py-3 hover:bg-muted/40">
|
<div className="rounded-lg flex gap-3 px-3 py-3 hover:bg-muted/40">
|
||||||
<Avatar className="h-10 w-10">
|
<Avatar className="h-10 w-10">
|
||||||
|
<AvatarImage src={author?.avatar} />
|
||||||
<AvatarFallback>
|
<AvatarFallback>
|
||||||
{message.author.slice(0, 2).toUpperCase()}
|
{author?.displayName.slice(0, 1).toUpperCase() ||
|
||||||
|
message.author.slice(0, 2).toUpperCase()}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium flex items-center gap-1">
|
||||||
{message.author.slice(0, 8)}
|
{!verified && (
|
||||||
|
<CircleXIcon className="h-3.5 w-3.5 text-destructive" />
|
||||||
|
)}
|
||||||
|
{author?.displayName || message.author.slice(0, 8)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className="text-xs text-muted-foreground">{time}</span>
|
<span className="text-xs text-muted-foreground">{time}</span>
|
||||||
|
|
||||||
|
{message.is_edited && !editing && (
|
||||||
|
<span className="text-xs text-muted-foreground">(edited)</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="whitespace-pre-wrap break-words text-sm leading-snug">
|
|
||||||
{message.content}
|
{editing ? (
|
||||||
</p>
|
<div className="mt-2">
|
||||||
|
<Textarea
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
autoFocus
|
||||||
|
className="min-h-20 resize-none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<Button size="sm" onClick={handleSave}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button size="sm" variant="ghost" onClick={cancelEditing}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Enter to save · Shift + Enter for new line · Esc to cancel
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="whitespace-pre-wrap break-words text-sm leading-snug">
|
||||||
|
{message.content}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Don't allow the context menu while editing
|
||||||
|
if (!isOwnMessage || editing) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContextMenu>
|
||||||
|
<ContextMenuTrigger>{content}</ContextMenuTrigger>
|
||||||
|
|
||||||
|
<ContextMenuContent>
|
||||||
|
<ContextMenuItem onClick={startEditing}>
|
||||||
|
<PencilIcon className="mr-2 h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</ContextMenuItem>
|
||||||
|
|
||||||
|
<ContextMenuItem onClick={handleDelete} variant="destructive">
|
||||||
|
<Trash2Icon className="mr-2 h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</ContextMenuItem>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { ContextMenu as ContextMenuPrimitive } from "@base-ui/react/context-menu"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ChevronRightIcon, CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function ContextMenu({ ...props }: ContextMenuPrimitive.Root.Props) {
|
||||||
|
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuPortal({ ...props }: ContextMenuPrimitive.Portal.Props) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuTrigger({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ContextMenuPrimitive.Trigger.Props) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Trigger
|
||||||
|
data-slot="context-menu-trigger"
|
||||||
|
className={cn("select-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuContent({
|
||||||
|
className,
|
||||||
|
align = "start",
|
||||||
|
alignOffset = 4,
|
||||||
|
side = "right",
|
||||||
|
sideOffset = 0,
|
||||||
|
...props
|
||||||
|
}: ContextMenuPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
ContextMenuPrimitive.Positioner.Props,
|
||||||
|
"align" | "alignOffset" | "side" | "sideOffset"
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Portal>
|
||||||
|
<ContextMenuPrimitive.Positioner
|
||||||
|
className="isolate z-50 outline-none"
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
>
|
||||||
|
<ContextMenuPrimitive.Popup
|
||||||
|
data-slot="context-menu-content"
|
||||||
|
className={cn("z-50 max-h-(--available-height) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</ContextMenuPrimitive.Positioner>
|
||||||
|
</ContextMenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuGroup({ ...props }: ContextMenuPrimitive.Group.Props) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: ContextMenuPrimitive.GroupLabel.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.GroupLabel
|
||||||
|
data-slot="context-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: ContextMenuPrimitive.Item.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
variant?: "default" | "destructive"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Item
|
||||||
|
data-slot="context-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"group/context-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus:*:[svg]:text-accent-foreground data-[variant=destructive]:*:[svg]:text-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSub({ ...props }: ContextMenuPrimitive.SubmenuRoot.Props) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.SubmenuRoot data-slot="context-menu-sub" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: ContextMenuPrimitive.SubmenuTrigger.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.SubmenuTrigger
|
||||||
|
data-slot="context-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto" />
|
||||||
|
</ContextMenuPrimitive.SubmenuTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSubContent({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuContent>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuContent
|
||||||
|
data-slot="context-menu-sub-content"
|
||||||
|
className="shadow-lg"
|
||||||
|
side="right"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: ContextMenuPrimitive.CheckboxItem.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.CheckboxItem
|
||||||
|
data-slot="context-menu-checkbox-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute right-2">
|
||||||
|
<ContextMenuPrimitive.CheckboxItemIndicator>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</ContextMenuPrimitive.CheckboxItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</ContextMenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuRadioGroup({
|
||||||
|
...props
|
||||||
|
}: ContextMenuPrimitive.RadioGroup.Props) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.RadioGroup
|
||||||
|
data-slot="context-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: ContextMenuPrimitive.RadioItem.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.RadioItem
|
||||||
|
data-slot="context-menu-radio-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute right-2">
|
||||||
|
<ContextMenuPrimitive.RadioItemIndicator>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</ContextMenuPrimitive.RadioItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</ContextMenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ContextMenuPrimitive.Separator.Props) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Separator
|
||||||
|
data-slot="context-menu-separator"
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="context-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/context-menu-item:text-accent-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuCheckboxItem,
|
||||||
|
ContextMenuRadioItem,
|
||||||
|
ContextMenuLabel,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuShortcut,
|
||||||
|
ContextMenuGroup,
|
||||||
|
ContextMenuPortal,
|
||||||
|
ContextMenuSub,
|
||||||
|
ContextMenuSubContent,
|
||||||
|
ContextMenuSubTrigger,
|
||||||
|
ContextMenuRadioGroup,
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import Enclave from "@/app/app";
|
import Enclave from "@/app/app";
|
||||||
import { getHTTPUrl, saveServerList } from "@/lib/serverList";
|
import { getHTTPUrl, saveServerList } from "@/lib/serverList";
|
||||||
import { AddServerDialog } from "../dialog/AddServerDialog";
|
import { AddServerDialog } from "../dialog/AddServerDialog";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export default function ServerList({
|
export default function ServerList({
|
||||||
appRef,
|
appRef,
|
||||||
@@ -16,11 +17,11 @@ export default function ServerList({
|
|||||||
<img
|
<img
|
||||||
src={getHTTPUrl(hostname, server.isSecure, "/icon")}
|
src={getHTTPUrl(hostname, server.isSecure, "/icon")}
|
||||||
key={hostname}
|
key={hostname}
|
||||||
className={
|
className={cn(
|
||||||
hostname === appRef.current?.server?.hostname
|
"rounded-lg",
|
||||||
? "rounded-lg"
|
hostname === appRef.current?.server?.hostname &&
|
||||||
: "rounded-full"
|
"border-primary border border-2",
|
||||||
}
|
)}
|
||||||
|
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (!appRef.current) {
|
if (!appRef.current) {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export interface MessageData {
|
|||||||
export interface StoredMessage extends MessageData {
|
export interface StoredMessage extends MessageData {
|
||||||
id: string;
|
id: string;
|
||||||
author: string;
|
author: string;
|
||||||
|
is_edited: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Channel = { id: string; name: string } & ChannelKind;
|
export type Channel = { id: string; name: string } & ChannelKind;
|
||||||
|
|||||||
Reference in New Issue
Block a user