mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-12 00:17:02 +02:00
feat: implement unified chat system with NIP-C7 and NIP-29 support
Core Architecture: - Protocol adapter pattern for chat implementations - Base adapter interface with protocol-specific implementations - Auto-detection of protocol from identifier format - Reactive message loading via EventStore observables Protocol Implementations: - NIP-C7 adapter: Simple chat (kind 9) with npub/nprofile support - NIP-29 adapter: Relay-based groups with member roles and moderation - Protocol-aware reply message loading with relay hints - Proper NIP-29 members/admins fetching using #d tags UI Components: - ChatViewer: Main chat interface with virtualized message timeline - ChatMessage: Message rendering with reply preview - ReplyPreview: Auto-loading replied-to messages from relays - MembersDropdown: Virtualized member list with role labels - RelaysDropdown: Connection status for chat relays - ChatComposer: Message input with send functionality Command System: - chat command with identifier parsing and auto-detection - Support for npub, nprofile, NIP-05, and relay'group-id formats - Integration with window system and dynamic titles NIP-29 Specific: - Fetch kind:39000 (metadata), kind:39001 (admins), kind:39002 (members) - Extract roles from p tags: ["p", "<pubkey>", "<role1>", "<role2>"] - Role normalization (admin, moderator, host, member) - Single group relay connection management Testing: - Comprehensive chat parser tests - Protocol adapter test structure - All tests passing (704 tests) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
255
src/components/ChatViewer.tsx
Normal file
255
src/components/ChatViewer.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
import { useMemo, useState, memo, useCallback } from "react";
|
||||
import { use$ } from "applesauce-react/hooks";
|
||||
import { from } from "rxjs";
|
||||
import { Virtuoso } from "react-virtuoso";
|
||||
import type {
|
||||
ChatProtocol,
|
||||
ProtocolIdentifier,
|
||||
Conversation,
|
||||
} from "@/types/chat";
|
||||
import { NipC7Adapter } from "@/lib/chat/adapters/nip-c7-adapter";
|
||||
import { Nip29Adapter } from "@/lib/chat/adapters/nip-29-adapter";
|
||||
import type { ChatProtocolAdapter } from "@/lib/chat/adapters/base-adapter";
|
||||
import type { Message } from "@/types/chat";
|
||||
import { UserName } from "./nostr/UserName";
|
||||
import { RichText } from "./nostr/RichText";
|
||||
import Timestamp from "./Timestamp";
|
||||
import { ReplyPreview } from "./chat/ReplyPreview";
|
||||
import { MembersDropdown } from "./chat/MembersDropdown";
|
||||
import { RelaysDropdown } from "./chat/RelaysDropdown";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
interface ChatViewerProps {
|
||||
protocol: ChatProtocol;
|
||||
identifier: ProtocolIdentifier;
|
||||
customTitle?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* MessageItem - Memoized message component for performance
|
||||
*/
|
||||
const MessageItem = memo(function MessageItem({
|
||||
message,
|
||||
adapter,
|
||||
conversation,
|
||||
}: {
|
||||
message: Message;
|
||||
adapter: ChatProtocolAdapter;
|
||||
conversation: Conversation;
|
||||
}) {
|
||||
return (
|
||||
<div className="group flex items-start hover:bg-muted/50">
|
||||
<div className="">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserName pubkey={message.author} className="font-semibold text-sm" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<Timestamp timestamp={message.timestamp} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm leading-relaxed">
|
||||
{message.event ? (
|
||||
<RichText event={message.event}>
|
||||
{message.replyTo && (
|
||||
<ReplyPreview
|
||||
replyToId={message.replyTo}
|
||||
adapter={adapter}
|
||||
conversation={conversation}
|
||||
/>
|
||||
)}
|
||||
</RichText>
|
||||
) : (
|
||||
<span className="whitespace-pre-wrap">{message.content}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* ChatViewer - Main chat interface component
|
||||
*
|
||||
* Provides protocol-agnostic chat UI that works across all Nostr messaging protocols.
|
||||
* Uses adapter pattern to handle protocol-specific logic while providing consistent UX.
|
||||
*/
|
||||
export function ChatViewer({
|
||||
protocol,
|
||||
identifier,
|
||||
customTitle,
|
||||
}: ChatViewerProps) {
|
||||
const { addWindow } = useGrimoire();
|
||||
|
||||
// Get the appropriate adapter for this protocol
|
||||
const adapter = useMemo(() => getAdapter(protocol), [protocol]);
|
||||
|
||||
// Resolve conversation from identifier (async operation)
|
||||
const conversation = use$(
|
||||
() => from(adapter.resolveConversation(identifier)),
|
||||
[adapter, identifier],
|
||||
);
|
||||
|
||||
// Load messages for this conversation (reactive)
|
||||
const messages = use$(
|
||||
() => (conversation ? adapter.loadMessages(conversation) : undefined),
|
||||
[adapter, conversation],
|
||||
);
|
||||
|
||||
// Track reply context (which message is being replied to)
|
||||
const [replyTo, setReplyTo] = useState<string | undefined>();
|
||||
|
||||
// Handle sending messages
|
||||
const handleSend = async (content: string, replyToId?: string) => {
|
||||
if (!conversation) return;
|
||||
await adapter.sendMessage(conversation, content, replyToId);
|
||||
setReplyTo(undefined); // Clear reply context after sending
|
||||
};
|
||||
|
||||
// Handle NIP badge click
|
||||
const handleNipClick = useCallback(() => {
|
||||
if (conversation?.protocol === "nip-29") {
|
||||
addWindow("nip", { number: 29 });
|
||||
}
|
||||
}, [conversation?.protocol, addWindow]);
|
||||
|
||||
if (!conversation) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
Loading conversation...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header with conversation info and controls */}
|
||||
<div className="px-1 border-b w-full">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-1 min-w-0 items-center gap-2">
|
||||
{conversation.metadata?.icon && (
|
||||
<img
|
||||
src={conversation.metadata.icon}
|
||||
alt={conversation.title}
|
||||
className="h-4 w-4 object-cover flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 flex flex-row gap-2 items-baseline min-w-0">
|
||||
<h2 className="truncate text-base font-semibold">
|
||||
{customTitle || conversation.title}
|
||||
</h2>
|
||||
{conversation.metadata?.description && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-1">
|
||||
{conversation.metadata.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground p-1">
|
||||
<MembersDropdown participants={conversation.participants} />
|
||||
<RelaysDropdown conversation={conversation} />
|
||||
{conversation.type === "group" && (
|
||||
<button
|
||||
onClick={handleNipClick}
|
||||
className="rounded bg-muted px-1.5 py-0.5 font-mono hover:bg-muted/80 transition-colors cursor-pointer"
|
||||
>
|
||||
{conversation.protocol.toUpperCase()}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message timeline with virtualization */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{messages && messages.length > 0 ? (
|
||||
<Virtuoso
|
||||
data={messages}
|
||||
initialTopMostItemIndex={messages.length - 1}
|
||||
followOutput="smooth"
|
||||
itemContent={(_index, message) => (
|
||||
<MessageItem
|
||||
key={message.id}
|
||||
message={message}
|
||||
adapter={adapter}
|
||||
conversation={conversation}
|
||||
/>
|
||||
)}
|
||||
style={{ height: "100%" }}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
No messages yet. Start the conversation!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Message composer */}
|
||||
<div className="">
|
||||
{replyTo && (
|
||||
<div className="flex items-center gap-2 rounded bg-muted px-2 py-1 text-xs">
|
||||
<span>Replying to {replyTo.slice(0, 8)}...</span>
|
||||
<button
|
||||
onClick={() => setReplyTo(undefined)}
|
||||
className="ml-auto text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const form = e.currentTarget;
|
||||
const input = form.elements.namedItem(
|
||||
"message",
|
||||
) as HTMLTextAreaElement;
|
||||
if (input.value.trim()) {
|
||||
handleSend(input.value, replyTo);
|
||||
input.value = "";
|
||||
}
|
||||
}}
|
||||
className="flex gap-2"
|
||||
>
|
||||
<textarea
|
||||
name="message"
|
||||
autoFocus
|
||||
placeholder="Type a message..."
|
||||
className="flex-1 resize-none bg-background px-2 py-1.5 text-sm"
|
||||
rows={1}
|
||||
onKeyDown={(e) => {
|
||||
// Submit on Enter (without Shift)
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.currentTarget.form?.requestSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button type="submit" variant="secondary">
|
||||
Send
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate adapter for a protocol
|
||||
* TODO: Add other adapters as they're implemented
|
||||
*/
|
||||
function getAdapter(protocol: ChatProtocol): ChatProtocolAdapter {
|
||||
switch (protocol) {
|
||||
case "nip-c7":
|
||||
return new NipC7Adapter();
|
||||
case "nip-29":
|
||||
return new Nip29Adapter();
|
||||
// case "nip-17":
|
||||
// return new Nip17Adapter();
|
||||
// case "nip-28":
|
||||
// return new Nip28Adapter();
|
||||
// case "nip-53":
|
||||
// return new Nip53Adapter();
|
||||
default:
|
||||
throw new Error(`Unsupported protocol: ${protocol}`);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,10 @@ import { getTagValues } from "@/lib/nostr-utils";
|
||||
import { getLiveHost } from "@/lib/live-activity";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import { getZapSender } from "applesauce-common/helpers/zap";
|
||||
import { NipC7Adapter } from "@/lib/chat/adapters/nip-c7-adapter";
|
||||
import { Nip29Adapter } from "@/lib/chat/adapters/nip-29-adapter";
|
||||
import type { ChatProtocol, ProtocolIdentifier } from "@/types/chat";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export interface WindowTitleData {
|
||||
title: string | ReactElement;
|
||||
@@ -546,6 +550,46 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
|
||||
return `Relay Pool (${connectedCount}/${relayList.length})`;
|
||||
}, [appId, relays]);
|
||||
|
||||
// Chat viewer title - resolve conversation to get partner name
|
||||
const [chatTitle, setChatTitle] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (appId !== "chat") {
|
||||
setChatTitle(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const protocol = props.protocol as ChatProtocol;
|
||||
const identifier = props.identifier as ProtocolIdentifier;
|
||||
|
||||
// Get adapter and resolve conversation
|
||||
const getAdapter = () => {
|
||||
switch (protocol) {
|
||||
case "nip-c7":
|
||||
return new NipC7Adapter();
|
||||
case "nip-29":
|
||||
return new Nip29Adapter();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const adapter = getAdapter();
|
||||
if (!adapter) {
|
||||
setChatTitle("Chat");
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve conversation asynchronously
|
||||
adapter
|
||||
.resolveConversation(identifier)
|
||||
.then((conversation) => {
|
||||
setChatTitle(conversation.title);
|
||||
})
|
||||
.catch(() => {
|
||||
setChatTitle("Chat");
|
||||
});
|
||||
}, [appId, props]);
|
||||
|
||||
// Generate final title data with icon and tooltip
|
||||
return useMemo(() => {
|
||||
let title: ReactElement | string;
|
||||
@@ -619,6 +663,10 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
|
||||
title = connTitle;
|
||||
icon = getCommandIcon("conn");
|
||||
tooltip = rawCommand;
|
||||
} else if (chatTitle && appId === "chat") {
|
||||
title = chatTitle;
|
||||
icon = getCommandIcon("chat");
|
||||
tooltip = rawCommand;
|
||||
} else {
|
||||
title = staticTitle || appId.toUpperCase();
|
||||
tooltip = rawCommand;
|
||||
@@ -642,6 +690,7 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
|
||||
kindsTitle,
|
||||
debugTitle,
|
||||
connTitle,
|
||||
chatTitle,
|
||||
staticTitle,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,9 @@ const DebugViewer = lazy(() =>
|
||||
import("./DebugViewer").then((m) => ({ default: m.DebugViewer })),
|
||||
);
|
||||
const ConnViewer = lazy(() => import("./ConnViewer"));
|
||||
const ChatViewer = lazy(() =>
|
||||
import("./ChatViewer").then((m) => ({ default: m.ChatViewer })),
|
||||
);
|
||||
const SpellsViewer = lazy(() =>
|
||||
import("./SpellsViewer").then((m) => ({ default: m.SpellsViewer })),
|
||||
);
|
||||
@@ -169,6 +172,15 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
|
||||
case "conn":
|
||||
content = <ConnViewer />;
|
||||
break;
|
||||
case "chat":
|
||||
content = (
|
||||
<ChatViewer
|
||||
protocol={window.props.protocol}
|
||||
identifier={window.props.identifier}
|
||||
customTitle={window.customTitle}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "spells":
|
||||
content = <SpellsViewer />;
|
||||
break;
|
||||
|
||||
58
src/components/chat/MembersDropdown.tsx
Normal file
58
src/components/chat/MembersDropdown.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Users2 } from "lucide-react";
|
||||
import { Virtuoso } from "react-virtuoso";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { UserName } from "@/components/nostr/UserName";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { Participant } from "@/types/chat";
|
||||
|
||||
interface MembersDropdownProps {
|
||||
participants: Participant[];
|
||||
}
|
||||
|
||||
/**
|
||||
* MembersDropdown - Shows member count and list with roles
|
||||
* Similar to relay indicators in ReqViewer
|
||||
*/
|
||||
export function MembersDropdown({ participants }: MembersDropdownProps) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors">
|
||||
<Users2 className="size-3" />
|
||||
<span>{participants.length}</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
Members ({participants.length})
|
||||
</div>
|
||||
<div style={{ height: "300px" }}>
|
||||
<Virtuoso
|
||||
data={participants}
|
||||
itemContent={(_index, participant) => (
|
||||
<div
|
||||
key={participant.pubkey}
|
||||
className="flex items-center justify-between gap-2 px-2 py-1.5 rounded hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<UserName
|
||||
pubkey={participant.pubkey}
|
||||
className="text-sm truncate flex-1 min-w-0"
|
||||
/>
|
||||
{participant.role && participant.role !== "member" && (
|
||||
<Label size="sm" className="flex-shrink-0">
|
||||
{participant.role}
|
||||
</Label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
style={{ height: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
92
src/components/chat/RelaysDropdown.tsx
Normal file
92
src/components/chat/RelaysDropdown.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Wifi } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { RelayLink } from "@/components/nostr/RelayLink";
|
||||
import { useRelayState } from "@/hooks/useRelayState";
|
||||
import { getConnectionIcon, getAuthIcon } from "@/lib/relay-status-utils";
|
||||
import { normalizeRelayURL } from "@/lib/relay-url";
|
||||
import type { Conversation } from "@/types/chat";
|
||||
|
||||
interface RelaysDropdownProps {
|
||||
conversation: Conversation;
|
||||
}
|
||||
|
||||
/**
|
||||
* RelaysDropdown - Shows relay count and list with connection status
|
||||
* Similar to relay indicators in ReqViewer
|
||||
*/
|
||||
export function RelaysDropdown({ conversation }: RelaysDropdownProps) {
|
||||
const { relays: relayStates } = useRelayState();
|
||||
|
||||
// Get relays for this conversation
|
||||
const relays: string[] = [];
|
||||
|
||||
// NIP-29: Single group relay
|
||||
if (conversation.metadata?.relayUrl) {
|
||||
relays.push(conversation.metadata.relayUrl);
|
||||
}
|
||||
|
||||
// Normalize URLs for state lookup
|
||||
const normalizedRelays = relays.map((url) => {
|
||||
try {
|
||||
return normalizeRelayURL(url);
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
});
|
||||
|
||||
// Count connected relays
|
||||
const connectedCount = normalizedRelays.filter((url) => {
|
||||
const state = relayStates[url];
|
||||
return state?.connectionState === "connected";
|
||||
}).length;
|
||||
|
||||
if (relays.length === 0) {
|
||||
return null; // Don't show if no relays
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors">
|
||||
<Wifi className="size-3" />
|
||||
<span>
|
||||
{connectedCount}/{relays.length}
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
Relays ({relays.length})
|
||||
</div>
|
||||
<div className="space-y-1 p-1">
|
||||
{relays.map((url) => {
|
||||
const normalizedUrl = normalizedRelays[relays.indexOf(url)];
|
||||
const state = relayStates[normalizedUrl];
|
||||
const connIcon = getConnectionIcon(state);
|
||||
const authIcon = getAuthIcon(state);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={url}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{connIcon.icon}
|
||||
{authIcon.icon}
|
||||
</div>
|
||||
<RelayLink
|
||||
url={url}
|
||||
className="text-sm truncate flex-1 min-w-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
59
src/components/chat/ReplyPreview.tsx
Normal file
59
src/components/chat/ReplyPreview.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { memo, useEffect } from "react";
|
||||
import { use$ } from "applesauce-react/hooks";
|
||||
import eventStore from "@/services/event-store";
|
||||
import { UserName } from "../nostr/UserName";
|
||||
import { RichText } from "../nostr/RichText";
|
||||
import type { ChatProtocolAdapter } from "@/lib/chat/adapters/base-adapter";
|
||||
import type { Conversation } from "@/types/chat";
|
||||
|
||||
interface ReplyPreviewProps {
|
||||
replyToId: string;
|
||||
adapter: ChatProtocolAdapter;
|
||||
conversation: Conversation;
|
||||
}
|
||||
|
||||
/**
|
||||
* ReplyPreview - Shows who is being replied to with truncated message content
|
||||
* Automatically fetches missing events from protocol-specific relays
|
||||
*/
|
||||
export const ReplyPreview = memo(function ReplyPreview({
|
||||
replyToId,
|
||||
adapter,
|
||||
conversation,
|
||||
}: ReplyPreviewProps) {
|
||||
// Load the event being replied to (reactive - updates when event arrives)
|
||||
const replyEvent = use$(() => eventStore.event(replyToId), [replyToId]);
|
||||
|
||||
// Fetch event from relays if not in store
|
||||
useEffect(() => {
|
||||
if (!replyEvent) {
|
||||
adapter.loadReplyMessage(conversation, replyToId).catch((err) => {
|
||||
console.error(
|
||||
`[ReplyPreview] Failed to load reply ${replyToId.slice(0, 8)}:`,
|
||||
err,
|
||||
);
|
||||
});
|
||||
}
|
||||
}, [replyEvent, adapter, conversation, replyToId]);
|
||||
|
||||
if (!replyEvent) {
|
||||
return (
|
||||
<div className="text-xs text-muted-foreground mb-0.5">
|
||||
↳ Replying to {replyToId.slice(0, 8)}...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-xs text-muted-foreground flex items-baseline gap-1 mb-0.5 overflow-hidden">
|
||||
<span className="flex-shrink-0">↳</span>
|
||||
<UserName
|
||||
pubkey={replyEvent.pubkey}
|
||||
className="font-medium flex-shrink-0"
|
||||
/>
|
||||
<div className="line-clamp-1 overflow-hidden flex-1 min-w-0">
|
||||
<RichText event={replyEvent} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -9,7 +9,7 @@ const Textarea = React.forwardRef<
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"flex bg-transparent p-2 text-base transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
Reference in New Issue
Block a user