mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 23:47:12 +02:00
feat: kind schemas and better man pages
This commit is contained in:
@@ -23,6 +23,9 @@ import {
|
||||
import { getEventDisplayTitle } from "@/lib/event-title";
|
||||
import { UserName } from "./nostr/UserName";
|
||||
import { getTagValues } from "@/lib/nostr-utils";
|
||||
import { getLiveHost } from "@/lib/live-activity";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import { getZapSender } from "applesauce-core/helpers/zap";
|
||||
|
||||
export interface WindowTitleData {
|
||||
title: string | ReactElement;
|
||||
@@ -30,6 +33,31 @@ export interface WindowTitleData {
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the semantic author of an event based on kind-specific logic
|
||||
* Returns the pubkey that should be displayed as the "author" for UI purposes
|
||||
*
|
||||
* Examples:
|
||||
* - Zaps (9735): Returns the zapper (P tag), not the lightning service pubkey
|
||||
* - Live activities (30311): Returns the host (first p tag with "Host" role)
|
||||
* - Regular events: Returns event.pubkey
|
||||
*/
|
||||
function getSemanticAuthor(event: NostrEvent): string {
|
||||
switch (event.kind) {
|
||||
case 9735: {
|
||||
// Zap: show the zapper, not the lightning service pubkey
|
||||
const zapSender = getZapSender(event);
|
||||
return zapSender || event.pubkey;
|
||||
}
|
||||
case 30311: {
|
||||
// Live activity: show the host
|
||||
return getLiveHost(event);
|
||||
}
|
||||
default:
|
||||
return event.pubkey;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format profile names with prefix, handling $me and $contacts aliases
|
||||
* @param prefix - Prefix to use (e.g., 'by ', '@ ')
|
||||
@@ -266,6 +294,17 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
|
||||
const eventPointer: EventPointer | AddressPointer | undefined =
|
||||
appId === "open" ? props.pointer : undefined;
|
||||
const event = useNostrEvent(eventPointer);
|
||||
|
||||
// Get semantic author for events (e.g., zapper for zaps, host for live activities)
|
||||
const semanticAuthorPubkey = useMemo(() => {
|
||||
if (appId !== "open" || !event) return null;
|
||||
return getSemanticAuthor(event);
|
||||
}, [appId, event]);
|
||||
|
||||
// Fetch semantic author profile to ensure it's cached for rendering
|
||||
// Called for side effects (preloading profile data)
|
||||
void useProfile(semanticAuthorPubkey || "");
|
||||
|
||||
const eventTitle = useMemo(() => {
|
||||
if (appId !== "open" || !event) return null;
|
||||
|
||||
@@ -277,10 +316,13 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
|
||||
</div>
|
||||
{getEventDisplayTitle(event, false)}
|
||||
<span> - </span>
|
||||
<UserName pubkey={event.pubkey} className="text-inherit" />
|
||||
<UserName
|
||||
pubkey={semanticAuthorPubkey || event.pubkey}
|
||||
className="text-inherit"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}, [appId, event]);
|
||||
}, [appId, event, semanticAuthorPubkey]);
|
||||
|
||||
// Kind titles
|
||||
const kindTitle = useMemo(() => {
|
||||
@@ -471,12 +513,12 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
|
||||
return `NIP-${props.number}: ${title}`;
|
||||
}, [appId, props]);
|
||||
|
||||
// Man page titles - just show the command description, icon shows on hover
|
||||
// Man page titles - show command name first, then description
|
||||
const manTitle = useMemo(() => {
|
||||
if (appId !== "man") return null;
|
||||
// For man pages, we'll show the command's description via tooltip
|
||||
// The title can just be generic or empty, as the icon conveys meaning
|
||||
return getCommandDescription(props.cmd) || `${props.cmd} manual`;
|
||||
const cmdName = props.cmd?.toUpperCase() || "MAN";
|
||||
const description = getCommandDescription(props.cmd);
|
||||
return description ? `${cmdName} - ${description}` : cmdName;
|
||||
}, [appId, props]);
|
||||
|
||||
// Kinds viewer title
|
||||
|
||||
@@ -4,6 +4,11 @@ import { NIPBadge } from "./NIPBadge";
|
||||
import { Copy, CopyCheck } from "lucide-react";
|
||||
import { Button } from "./ui/button";
|
||||
import { useCopy } from "@/hooks/useCopy";
|
||||
import {
|
||||
getKindSchema,
|
||||
parseTagStructure,
|
||||
getContentTypeDescription,
|
||||
} from "@/lib/nostr-schema";
|
||||
|
||||
// NIP-01 Kind ranges
|
||||
const REPLACEABLE_START = 10000;
|
||||
@@ -15,6 +20,7 @@ const PARAMETERIZED_REPLACEABLE_END = 40000;
|
||||
|
||||
export default function KindRenderer({ kind }: { kind: number }) {
|
||||
const kindInfo = getKindInfo(kind);
|
||||
const schema = getKindSchema(kind);
|
||||
const Icon = kindInfo?.icon;
|
||||
const category = getKindCategory(kind);
|
||||
const eventType = getEventType(kind);
|
||||
@@ -94,6 +100,84 @@ export default function KindRenderer({ kind }: { kind: number }) {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Schema Information */}
|
||||
{schema && (
|
||||
<>
|
||||
{/* Content Type */}
|
||||
{schema.content && (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-2">Content</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{getContentTypeDescription(schema.content.type)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{schema.tags && schema.tags.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-3">
|
||||
Supported Tags
|
||||
{schema.required && schema.required.length > 0 && (
|
||||
<span className="text-sm font-normal text-muted-foreground ml-2">
|
||||
({schema.required.length} required)
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<div className="border border-border rounded-md overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="text-left p-3 font-semibold w-20">name</th>
|
||||
<th className="text-left p-3 font-semibold">value</th>
|
||||
<th className="text-left p-3 font-semibold">other parameters</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{schema.tags.map((tag, i) => {
|
||||
const isRequired = schema.required?.includes(tag.name);
|
||||
const structure = parseTagStructure(tag);
|
||||
return (
|
||||
<tr
|
||||
key={i}
|
||||
className="border-t border-border hover:bg-muted/30"
|
||||
>
|
||||
<td className="p-3 align-top">
|
||||
<code className="font-mono text-primary">
|
||||
{tag.name}
|
||||
</code>
|
||||
{isRequired && (
|
||||
<span className="ml-2 text-[10px] bg-destructive/20 text-destructive px-1.5 py-0.5 rounded whitespace-nowrap align-middle">
|
||||
required
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground align-top">
|
||||
{structure.primaryValue || "—"}
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground align-top">
|
||||
{structure.otherParameters.length > 0
|
||||
? structure.otherParameters.join(", ")
|
||||
: "—"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Usage Status */}
|
||||
<div className="text-xs text-muted-foreground pt-2">
|
||||
{schema.in_use
|
||||
? "✓ Actively used in the Nostr ecosystem"
|
||||
: "⚠ Deprecated or experimental"}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,48 @@
|
||||
import { manPages } from "@/types/man";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
|
||||
interface ManPageProps {
|
||||
cmd: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ExecutableCommand - Renders a clickable command that executes when clicked
|
||||
*/
|
||||
function ExecutableCommand({
|
||||
commandLine,
|
||||
children,
|
||||
}: {
|
||||
commandLine: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { addWindow } = useGrimoire();
|
||||
|
||||
const handleClick = async () => {
|
||||
const parts = commandLine.trim().split(/\s+/);
|
||||
const commandName = parts[0]?.toLowerCase();
|
||||
const cmdArgs = parts.slice(1);
|
||||
|
||||
const command = manPages[commandName];
|
||||
if (command) {
|
||||
// argParser can be async
|
||||
const cmdProps = command.argParser
|
||||
? await Promise.resolve(command.argParser(cmdArgs))
|
||||
: command.defaultProps || {};
|
||||
|
||||
addWindow(command.appId, cmdProps);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="text-accent font-medium hover:underline cursor-crosshair text-left"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ManPage({ cmd }: ManPageProps) {
|
||||
const page = manPages[cmd];
|
||||
|
||||
@@ -23,11 +62,11 @@ export default function ManPage({ cmd }: ManPageProps) {
|
||||
{/* Header */}
|
||||
<div className="flex justify-between border-b border-border pb-2">
|
||||
<span className="font-bold">
|
||||
{page.name.toUpperCase()}({page.section})
|
||||
{page.name.toUpperCase()}
|
||||
</span>
|
||||
<span className="text-muted-foreground">Grimoire Manual</span>
|
||||
<span className="font-bold">
|
||||
{page.name.toUpperCase()}({page.section})
|
||||
{page.name.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -81,7 +120,9 @@ export default function ManPage({ cmd }: ManPageProps) {
|
||||
const [, command, description] = match;
|
||||
return (
|
||||
<div key={i}>
|
||||
<div className="text-accent font-medium">{command}</div>
|
||||
<ExecutableCommand commandLine={command.trim()}>
|
||||
{command}
|
||||
</ExecutableCommand>
|
||||
<div className="ml-8 text-muted-foreground text-sm">
|
||||
{description.trim()}
|
||||
</div>
|
||||
@@ -90,8 +131,10 @@ export default function ManPage({ cmd }: ManPageProps) {
|
||||
}
|
||||
// Fallback for examples without descriptions
|
||||
return (
|
||||
<div key={i} className="text-accent font-medium">
|
||||
{example}
|
||||
<div key={i}>
|
||||
<ExecutableCommand commandLine={example.trim()}>
|
||||
{example}
|
||||
</ExecutableCommand>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -104,14 +147,16 @@ export default function ManPage({ cmd }: ManPageProps) {
|
||||
<section>
|
||||
<h2 className="font-bold mb-2">SEE ALSO</h2>
|
||||
<div className="ml-8">
|
||||
<span className="text-accent">
|
||||
{page.seeAlso.map((cmd, i) => (
|
||||
<span key={i}>
|
||||
{cmd}(1)
|
||||
{i < page.seeAlso!.length - 1 ? ", " : ""}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
{page.seeAlso.map((cmd, i) => (
|
||||
<span key={i}>
|
||||
<ExecutableCommand commandLine={`man ${cmd}`}>
|
||||
<span className="text-accent">{cmd}</span>
|
||||
</ExecutableCommand>
|
||||
{i < page.seeAlso!.length - 1 && (
|
||||
<span className="text-accent">, </span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
import { useMemo } from "react";
|
||||
import { useLiveTimeline } from "@/hooks/useLiveTimeline";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import type { EventPointer, AddressPointer } from "nostr-tools/nip19";
|
||||
import { kinds } from "nostr-tools";
|
||||
import { UserName } from "../nostr/UserName";
|
||||
import { RichText } from "../nostr/RichText";
|
||||
import { Zap } from "lucide-react";
|
||||
import { UserName } from "./UserName";
|
||||
import { RichText } from "./RichText";
|
||||
import { Zap, CornerDownRight, Quote } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getZapAmount, getZapSender } from "applesauce-core/helpers";
|
||||
import { getZapAmount, getZapSender, getTagValue } from "applesauce-core/helpers";
|
||||
import { getNip10References } from "applesauce-core/helpers/threading";
|
||||
import { useNostrEvent } from "@/hooks/useNostrEvent";
|
||||
|
||||
interface StreamChatProps {
|
||||
streamEvent: NostrEvent;
|
||||
streamRelays: string[];
|
||||
hostRelays: string[];
|
||||
interface ChatViewProps {
|
||||
events: NostrEvent[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// isConsecutive removed
|
||||
|
||||
const isSameDay = (date1: Date, date2: Date) => {
|
||||
return (
|
||||
date1.getFullYear() === date2.getFullYear() &&
|
||||
@@ -25,63 +23,14 @@ const isSameDay = (date1: Date, date2: Date) => {
|
||||
);
|
||||
};
|
||||
|
||||
export function StreamChat({
|
||||
streamEvent,
|
||||
streamRelays,
|
||||
hostRelays,
|
||||
className,
|
||||
}: StreamChatProps) {
|
||||
// const [message, setMessage] = useState("");
|
||||
|
||||
// Combine stream relays + host relays
|
||||
const allRelays = useMemo(
|
||||
() => Array.from(new Set([...streamRelays, ...hostRelays])),
|
||||
[streamRelays, hostRelays],
|
||||
);
|
||||
|
||||
// Fetch chat messages (kind 1311) and zaps (kind 9735) that a-tag this stream
|
||||
const timelineFilter = useMemo(
|
||||
() => ({
|
||||
kinds: [1311, 9735],
|
||||
"#a": [
|
||||
`${streamEvent.kind}:${streamEvent.pubkey}:${streamEvent.tags.find((t) => t[0] === "d")?.[1] || ""}`,
|
||||
],
|
||||
limit: 100,
|
||||
}),
|
||||
[streamEvent],
|
||||
);
|
||||
|
||||
const { events: allMessages } = useLiveTimeline(
|
||||
`stream-feed-${streamEvent.id}`,
|
||||
timelineFilter,
|
||||
allRelays,
|
||||
{ stream: true },
|
||||
);
|
||||
|
||||
/*
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
// TODO: Implement sending chat message
|
||||
console.log("Send message:", message);
|
||||
setMessage("");
|
||||
};
|
||||
*/
|
||||
|
||||
export function ChatView({ events, className }: ChatViewProps) {
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full", className)}>
|
||||
{/* Chat messages area */}
|
||||
<div className="flex-1 flex flex-col-reverse gap-0.5 overflow-y-auto p-0 scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent">
|
||||
{allMessages.map((event, index) => {
|
||||
{events.map((event, index) => {
|
||||
const currentDate = new Date(event.created_at * 1000);
|
||||
const prevEvent = allMessages[index + 1];
|
||||
// If prevEvent exists, compare days. If different, we need a separator AFTER this message (visually before/above it)
|
||||
// Actually, in flex-col-reverse:
|
||||
// [Newest Message] (index 0)
|
||||
// <Day Label Today>
|
||||
// [Old Message] (index 1)
|
||||
|
||||
// Wait, logic is simpler:
|
||||
// Loop through events. Determine if Date Header is needed between this event and the next one (older one).
|
||||
const prevEvent = events[index + 1];
|
||||
|
||||
const prevDate = prevEvent
|
||||
? new Date(prevEvent.created_at * 1000)
|
||||
@@ -138,17 +87,93 @@ export function StreamChat({
|
||||
}
|
||||
|
||||
function ChatMessage({ event }: { event: NostrEvent }) {
|
||||
const threadRefs = useMemo(() => getNip10References(event), [event]);
|
||||
const replyToId = threadRefs.reply?.e?.id;
|
||||
const qTagValue = useMemo(() => getTagValue(event, "q"), [event]);
|
||||
|
||||
return (
|
||||
<RichText
|
||||
className="text-xs leading-tight text-foreground/90"
|
||||
event={event}
|
||||
options={{ showMedia: false, showEventEmbeds: false }}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{replyToId && <ReplyIndicator eventId={replyToId} />}
|
||||
{qTagValue && <QuoteIndicator qValue={qTagValue} />}
|
||||
<RichText
|
||||
className="text-xs leading-tight text-foreground/90"
|
||||
event={event}
|
||||
options={{ showMedia: false, showEventEmbeds: false }}
|
||||
>
|
||||
<UserName
|
||||
pubkey={event.pubkey}
|
||||
className="font-bold leading-tight flex-shrink-0 mr-1.5 text-accent"
|
||||
/>
|
||||
</RichText>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReplyIndicator({ eventId }: { eventId: string }) {
|
||||
const replyToEvent = useNostrEvent(eventId);
|
||||
|
||||
if (!replyToEvent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-[10px] text-muted-foreground pl-2 opacity-60">
|
||||
<CornerDownRight className="w-3 h-3 flex-shrink-0" />
|
||||
<UserName
|
||||
pubkey={event.pubkey}
|
||||
className="font-bold leading-tight flex-shrink-0 mr-1.5 text-accent"
|
||||
pubkey={replyToEvent.pubkey}
|
||||
className="font-semibold flex-shrink-0"
|
||||
/>
|
||||
</RichText>
|
||||
<span className="truncate">{replyToEvent.content}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse q-tag value into EventPointer or AddressPointer
|
||||
* Format can be:
|
||||
* - Event ID: "abc123..." (64-char hex)
|
||||
* - Address: "kind:pubkey:d-tag"
|
||||
*/
|
||||
function parseQTag(qValue: string): EventPointer | AddressPointer | null {
|
||||
// Check if it's an address (contains colons)
|
||||
if (qValue.includes(":")) {
|
||||
const parts = qValue.split(":");
|
||||
if (parts.length >= 2) {
|
||||
const kind = parseInt(parts[0], 10);
|
||||
const pubkey = parts[1];
|
||||
const identifier = parts.slice(2).join(":") || "";
|
||||
|
||||
if (!isNaN(kind) && pubkey) {
|
||||
return { kind, pubkey, identifier };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Assume it's an event ID (hex string)
|
||||
if (/^[0-9a-f]{64}$/i.test(qValue)) {
|
||||
return { id: qValue };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function QuoteIndicator({ qValue }: { qValue: string }) {
|
||||
const pointer = useMemo(() => parseQTag(qValue), [qValue]);
|
||||
const quotedEvent = useNostrEvent(pointer || undefined);
|
||||
|
||||
if (!quotedEvent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-[10px] text-muted-foreground pl-2 opacity-60">
|
||||
<Quote className="w-3 h-3 flex-shrink-0" />
|
||||
<UserName
|
||||
pubkey={quotedEvent.pubkey}
|
||||
className="font-semibold flex-shrink-0"
|
||||
/>
|
||||
<span className="truncate">{quotedEvent.content}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -91,15 +91,15 @@ export function RichText({
|
||||
// Call hook unconditionally - it will handle undefined/null
|
||||
const trimmedEvent = event
|
||||
? {
|
||||
...event,
|
||||
content: event.content.trim(),
|
||||
}
|
||||
...event,
|
||||
content: event.content.trim(),
|
||||
}
|
||||
: undefined;
|
||||
const renderedContent = useRenderedContent(
|
||||
content
|
||||
? ({
|
||||
content,
|
||||
} as NostrEvent)
|
||||
content,
|
||||
} as NostrEvent)
|
||||
: trimmedEvent,
|
||||
contentComponents,
|
||||
);
|
||||
|
||||
@@ -6,11 +6,12 @@ import {
|
||||
getLiveHost,
|
||||
} from "@/lib/live-activity";
|
||||
import { VideoPlayer } from "@/components/live/VideoPlayer";
|
||||
import { StreamChat } from "@/components/live/StreamChat";
|
||||
import { ChatView } from "@/components/nostr/ChatView";
|
||||
import { StatusBadge } from "@/components/live/StatusBadge";
|
||||
import { UserName } from "../UserName";
|
||||
import { Calendar } from "lucide-react";
|
||||
import { useOutboxRelays } from "@/hooks/useOutboxRelays";
|
||||
import { useLiveTimeline } from "@/hooks/useLiveTimeline";
|
||||
|
||||
interface LiveActivityDetailRendererProps {
|
||||
event: NostrEvent;
|
||||
@@ -28,6 +29,31 @@ export function LiveActivityDetailRenderer({
|
||||
authors: [hostPubkey],
|
||||
});
|
||||
|
||||
// Combine stream relays + host relays for chat events
|
||||
const allRelays = useMemo(
|
||||
() => Array.from(new Set([...activity.relays, ...hostRelays])),
|
||||
[activity.relays, hostRelays],
|
||||
);
|
||||
|
||||
// Fetch chat messages (kind 1311) and zaps (kind 9735) that a-tag this stream
|
||||
const timelineFilter = useMemo(
|
||||
() => ({
|
||||
kinds: [1311, 9735],
|
||||
"#a": [
|
||||
`${event.kind}:${event.pubkey}:${event.tags.find((t) => t[0] === "d")?.[1] || ""}`,
|
||||
],
|
||||
limit: 100,
|
||||
}),
|
||||
[event],
|
||||
);
|
||||
|
||||
const { events: chatEvents } = useLiveTimeline(
|
||||
`stream-feed-${event.id}`,
|
||||
timelineFilter,
|
||||
allRelays,
|
||||
{ stream: true },
|
||||
);
|
||||
|
||||
const videoUrl =
|
||||
status === "live" && activity.streaming
|
||||
? activity.streaming
|
||||
@@ -80,17 +106,15 @@ export function LiveActivityDetailRenderer({
|
||||
<h1 className="text-lg font-bold flex-1 line-clamp-1">
|
||||
{activity.title || "Untitled Live Activity"}
|
||||
</h1>
|
||||
<UserName pubkey={hostPubkey} className="text-sm font-semibold line-clamp-1" />
|
||||
<UserName
|
||||
pubkey={hostPubkey}
|
||||
className="text-sm font-semibold line-clamp-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Chat Section */}
|
||||
<div className="flex-1 min-h-0">
|
||||
<StreamChat
|
||||
streamEvent={event}
|
||||
streamRelays={activity.relays}
|
||||
hostRelays={hostRelays}
|
||||
className="h-full"
|
||||
/>
|
||||
<ChatView events={chatEvents} className="h-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user