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;
|
||||
|
||||
case "Messages":
|
||||
Object.entries(msg.messages).forEach(([channelId, messages]) => {
|
||||
if (!server.messages[channelId]) {
|
||||
server.messages[channelId] = {};
|
||||
}
|
||||
const authors = Object.entries(msg.messages).flatMap(
|
||||
([channelId, messages]) => {
|
||||
if (!server.messages[channelId]) {
|
||||
server.messages[channelId] = {};
|
||||
}
|
||||
|
||||
messages.forEach((message) => {
|
||||
server.messages[channelId][message.id] = message;
|
||||
});
|
||||
return messages.map((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();
|
||||
|
||||
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";
|
||||
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 =
|
||||
@@ -41,4 +55,20 @@ export type ServerMethod =
|
||||
method: "GetMessages";
|
||||
channel_id: string;
|
||||
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 { sha512 } from "@noble/hashes/sha2.js";
|
||||
import { getWSUrl } from "@/lib/serverList";
|
||||
import { ServerMeta, StoredMessage } from "@/lib/types";
|
||||
import { ClientMeta, ServerMeta, StoredMessage } from "@/lib/types";
|
||||
|
||||
ed.hashes.sha512 = sha512;
|
||||
|
||||
@@ -30,11 +30,13 @@ export default class EnclaveServer {
|
||||
public websocket?: EnclaveWebSocket;
|
||||
public meta?: ServerMeta;
|
||||
public messages: Record<string, Record<string, StoredMessage>>;
|
||||
public users: Record<string, ClientMeta | null>;
|
||||
|
||||
public constructor(hostname: string, isSecure: boolean) {
|
||||
this.hostname = hostname;
|
||||
this.isSecure = isSecure;
|
||||
this.messages = {};
|
||||
this.users = {};
|
||||
}
|
||||
|
||||
public disconnect() {
|
||||
@@ -94,4 +96,14 @@ export default class EnclaveServer {
|
||||
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 { 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 { Input } from "@/components/ui/input";
|
||||
import { StoredMessage } from "@/lib/types";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Channel, StoredMessage } from "@/lib/types";
|
||||
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({
|
||||
appRef,
|
||||
@@ -14,38 +24,74 @@ export default function TextChannel({
|
||||
const [currentMessage, setCurrentMessage] = useState("");
|
||||
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(() => {
|
||||
if (!channel) return;
|
||||
if (!intersectionRef.current) return;
|
||||
|
||||
appRef.current?.server?.websocket?.send({
|
||||
method: "GetMessages",
|
||||
channel_id: channel.id,
|
||||
chunk: 0,
|
||||
});
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
console.log("Element is in view!");
|
||||
if (!channel) return;
|
||||
|
||||
appRef.current?.forceRender();
|
||||
}, [channel]);
|
||||
appRef.current?.server?.websocket?.send({
|
||||
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;
|
||||
|
||||
const messages = appRef.current?.server?.messages[channel.id];
|
||||
|
||||
const sendMessage = () => {
|
||||
appRef.current?.sendMessage(currentMessage, channel.id);
|
||||
setCurrentMessage("");
|
||||
};
|
||||
|
||||
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">
|
||||
<h2>{channel.name}</h2>
|
||||
</header>
|
||||
|
||||
<div className="h-full flex flex-col gap-2.5 px-3 pt-4 overflow-y-scroll">
|
||||
{appRef.current?.server?.messages[channel.id] &&
|
||||
Object.entries(appRef.current?.server?.messages[channel.id]).map(
|
||||
([_, message]) => (
|
||||
<TextMessage key={message.id} appRef={appRef} message={message} />
|
||||
),
|
||||
)}
|
||||
<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">
|
||||
{messages &&
|
||||
Object.entries(messages)
|
||||
.sort((a, b) => b[1].timestamp - a[1].timestamp)
|
||||
.map(([_, message]) => (
|
||||
<TextMessage
|
||||
key={message.id}
|
||||
appRef={appRef}
|
||||
message={message}
|
||||
channel={channel}
|
||||
userPubKey={userPubKey}
|
||||
secretKey={secretKey}
|
||||
/>
|
||||
))}
|
||||
<div ref={intersectionRef} />
|
||||
</div>
|
||||
|
||||
<div className="pb-6 px-4 flex flex-row gap-3">
|
||||
@@ -69,35 +115,188 @@ export default function TextChannel({
|
||||
}
|
||||
|
||||
export function TextMessage({
|
||||
appRef,
|
||||
message,
|
||||
channel,
|
||||
userPubKey,
|
||||
secretKey,
|
||||
}: {
|
||||
appRef: React.RefObject<Enclave<ChannelPageProps> | null>;
|
||||
message: StoredMessage;
|
||||
channel: Channel;
|
||||
userPubKey: string | null;
|
||||
secretKey: Uint8Array | null;
|
||||
}) {
|
||||
const time = new Date(message.timestamp).toLocaleTimeString([], {
|
||||
hour: "numeric",
|
||||
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">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage src={author?.avatar} />
|
||||
<AvatarFallback>
|
||||
{message.author.slice(0, 2).toUpperCase()}
|
||||
{author?.displayName.slice(0, 1).toUpperCase() ||
|
||||
message.author.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
{message.author.slice(0, 8)}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium flex items-center gap-1">
|
||||
{!verified && (
|
||||
<CircleXIcon className="h-3.5 w-3.5 text-destructive" />
|
||||
)}
|
||||
{author?.displayName || message.author.slice(0, 8)}
|
||||
</span>
|
||||
|
||||
<span className="text-xs text-muted-foreground">{time}</span>
|
||||
|
||||
{message.is_edited && !editing && (
|
||||
<span className="text-xs text-muted-foreground">(edited)</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap break-words text-sm leading-snug">
|
||||
{message.content}
|
||||
</p>
|
||||
|
||||
{editing ? (
|
||||
<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>
|
||||
);
|
||||
|
||||
// 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 { getHTTPUrl, saveServerList } from "@/lib/serverList";
|
||||
import { AddServerDialog } from "../dialog/AddServerDialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function ServerList({
|
||||
appRef,
|
||||
@@ -16,11 +17,11 @@ export default function ServerList({
|
||||
<img
|
||||
src={getHTTPUrl(hostname, server.isSecure, "/icon")}
|
||||
key={hostname}
|
||||
className={
|
||||
hostname === appRef.current?.server?.hostname
|
||||
? "rounded-lg"
|
||||
: "rounded-full"
|
||||
}
|
||||
className={cn(
|
||||
"rounded-lg",
|
||||
hostname === appRef.current?.server?.hostname &&
|
||||
"border-primary border border-2",
|
||||
)}
|
||||
|
||||
onClick={async () => {
|
||||
if (!appRef.current) {
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface MessageData {
|
||||
export interface StoredMessage extends MessageData {
|
||||
id: string;
|
||||
author: string;
|
||||
is_edited: boolean;
|
||||
}
|
||||
|
||||
export type Channel = { id: string; name: string } & ChannelKind;
|
||||
|
||||
Reference in New Issue
Block a user