mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-09 06:57:07 +02:00
feat: dynamic titles, event footer
This commit is contained in:
274
src/components/DynamicWindowTitle.tsx
Normal file
274
src/components/DynamicWindowTitle.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import { useMemo } from "react";
|
||||
import { WindowInstance } from "@/types/app";
|
||||
import { useProfile } from "@/hooks/useProfile";
|
||||
import { useNostrEvent } from "@/hooks/useNostrEvent";
|
||||
import { getKindName, getKindIcon } from "@/constants/kinds";
|
||||
import { getNipTitle } from "@/constants/nips";
|
||||
import { getCommandIcon, getCommandDescription } from "@/constants/command-icons";
|
||||
import type { EventPointer, AddressPointer } from "nostr-tools/nip19";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
export interface WindowTitleData {
|
||||
title: string;
|
||||
icon?: LucideIcon;
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* useDynamicWindowTitle - Hook to generate dynamic window titles based on loaded data
|
||||
* Similar to WindowRenderer but for titles instead of content
|
||||
*/
|
||||
export function useDynamicWindowTitle(window: WindowInstance): WindowTitleData {
|
||||
return useDynamicTitle(window);
|
||||
}
|
||||
|
||||
function useDynamicTitle(window: WindowInstance): WindowTitleData {
|
||||
const { appId, props, title: staticTitle } = window;
|
||||
|
||||
// Profile titles
|
||||
const profilePubkey = appId === "profile" ? props.pubkey : null;
|
||||
const profile = useProfile(profilePubkey || "");
|
||||
const profileTitle = useMemo(() => {
|
||||
if (appId !== "profile" || !profilePubkey) return null;
|
||||
|
||||
if (profile) {
|
||||
const displayName = profile.display_name || profile.name;
|
||||
if (displayName) {
|
||||
return `@${displayName}`;
|
||||
}
|
||||
}
|
||||
|
||||
return `Profile ${profilePubkey.slice(0, 8)}...`;
|
||||
}, [appId, profilePubkey, profile]);
|
||||
|
||||
// Event titles
|
||||
const eventPointer: EventPointer | AddressPointer | undefined =
|
||||
appId === "open" ? props.pointer : undefined;
|
||||
const event = useNostrEvent(eventPointer);
|
||||
const eventTitle = useMemo(() => {
|
||||
if (appId !== "open" || !event) return null;
|
||||
|
||||
const kindName = getKindName(event.kind);
|
||||
|
||||
// For text-based events, show a preview
|
||||
if (event.kind === 1 && event.content) {
|
||||
const preview = event.content.slice(0, 40).trim();
|
||||
return preview ? `${kindName}: ${preview}...` : kindName;
|
||||
}
|
||||
|
||||
// For articles (kind 30023), show title tag
|
||||
if (event.kind === 30023) {
|
||||
const titleTag = event.tags.find((t) => t[0] === "title")?.[1];
|
||||
if (titleTag) {
|
||||
return titleTag.length > 50
|
||||
? `${titleTag.slice(0, 50)}...`
|
||||
: titleTag;
|
||||
}
|
||||
}
|
||||
|
||||
// For highlights (kind 9802), show preview
|
||||
if (event.kind === 9802 && event.content) {
|
||||
const preview = event.content.slice(0, 40).trim();
|
||||
return preview ? `Highlight: ${preview}...` : "Highlight";
|
||||
}
|
||||
|
||||
return kindName;
|
||||
}, [appId, event]);
|
||||
|
||||
// Kind titles
|
||||
const kindTitle = useMemo(() => {
|
||||
if (appId !== "kind") return null;
|
||||
const kindNum = parseInt(props.number);
|
||||
return getKindName(kindNum);
|
||||
}, [appId, props]);
|
||||
|
||||
// Relay titles (clean up URL)
|
||||
const relayTitle = useMemo(() => {
|
||||
if (appId !== "relay") return null;
|
||||
try {
|
||||
const url = new URL(props.url);
|
||||
return url.hostname;
|
||||
} catch {
|
||||
return props.url;
|
||||
}
|
||||
}, [appId, props]);
|
||||
|
||||
// REQ titles
|
||||
const reqTitle = useMemo(() => {
|
||||
if (appId !== "req") return null;
|
||||
const { filter } = props;
|
||||
|
||||
// Generate a descriptive title from the filter
|
||||
const parts: string[] = [];
|
||||
|
||||
if (filter.kinds && filter.kinds.length > 0) {
|
||||
// Show actual kind names
|
||||
const kindNames = filter.kinds.map((k: number) => getKindName(k));
|
||||
if (kindNames.length <= 3) {
|
||||
parts.push(kindNames.join(", "));
|
||||
} else {
|
||||
parts.push(`${kindNames.slice(0, 3).join(", ")}, +${kindNames.length - 3}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (filter.authors && filter.authors.length > 0) {
|
||||
parts.push(`${filter.authors.length} author${filter.authors.length > 1 ? "s" : ""}`);
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join(" • ") : "REQ";
|
||||
}, [appId, props]);
|
||||
|
||||
// Encode/Decode titles
|
||||
const encodeTitle = useMemo(() => {
|
||||
if (appId !== "encode") return null;
|
||||
const { args } = props;
|
||||
if (args && args[0]) {
|
||||
return `ENCODE ${args[0].toUpperCase()}`;
|
||||
}
|
||||
return "ENCODE";
|
||||
}, [appId, props]);
|
||||
|
||||
const decodeTitle = useMemo(() => {
|
||||
if (appId !== "decode") return null;
|
||||
const { args } = props;
|
||||
if (args && args[0]) {
|
||||
const prefix = args[0].match(/^(npub|nprofile|note|nevent|naddr|nsec)/i)?.[1];
|
||||
if (prefix) {
|
||||
return `DECODE ${prefix.toUpperCase()}`;
|
||||
}
|
||||
}
|
||||
return "DECODE";
|
||||
}, [appId, props]);
|
||||
|
||||
// NIP titles
|
||||
const nipTitle = useMemo(() => {
|
||||
if (appId !== "nip") return null;
|
||||
const title = getNipTitle(props.number);
|
||||
return `NIP-${props.number}: ${title}`;
|
||||
}, [appId, props]);
|
||||
|
||||
// Man page titles - just show the command description, icon shows on hover
|
||||
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`;
|
||||
}, [appId, props]);
|
||||
|
||||
// Feed title
|
||||
const feedTitle = useMemo(() => {
|
||||
if (appId !== "feed") return null;
|
||||
return "Feed";
|
||||
}, [appId]);
|
||||
|
||||
// Win viewer title
|
||||
const winTitle = useMemo(() => {
|
||||
if (appId !== "win") return null;
|
||||
return "Windows";
|
||||
}, [appId]);
|
||||
|
||||
// Kinds viewer title
|
||||
const kindsTitle = useMemo(() => {
|
||||
if (appId !== "kinds") return null;
|
||||
return "Kinds";
|
||||
}, [appId]);
|
||||
|
||||
// Debug viewer title
|
||||
const debugTitle = useMemo(() => {
|
||||
if (appId !== "debug") return null;
|
||||
return "Debug";
|
||||
}, [appId]);
|
||||
|
||||
// Generate final title data with icon and tooltip
|
||||
return useMemo(() => {
|
||||
let title: string;
|
||||
let icon: LucideIcon | undefined;
|
||||
let tooltip: string | undefined;
|
||||
|
||||
// Priority order for title selection
|
||||
if (profileTitle) {
|
||||
title = profileTitle;
|
||||
icon = getCommandIcon("profile");
|
||||
tooltip = `profile: ${getCommandDescription("profile")}`;
|
||||
} else if (eventTitle && appId === "open") {
|
||||
title = eventTitle;
|
||||
// Use the event's kind icon if we have the event loaded
|
||||
if (event) {
|
||||
icon = getKindIcon(event.kind);
|
||||
const kindName = getKindName(event.kind);
|
||||
tooltip = `${kindName} (kind ${event.kind})`;
|
||||
} else {
|
||||
icon = getCommandIcon("open");
|
||||
tooltip = `open: ${getCommandDescription("open")}`;
|
||||
}
|
||||
} else if (kindTitle && appId === "kind") {
|
||||
title = kindTitle;
|
||||
const kindNum = parseInt(props.number);
|
||||
icon = getKindIcon(kindNum);
|
||||
tooltip = `kind: ${getCommandDescription("kind")}`;
|
||||
} else if (relayTitle) {
|
||||
title = relayTitle;
|
||||
icon = getCommandIcon("relay");
|
||||
tooltip = `relay: ${getCommandDescription("relay")}`;
|
||||
} else if (reqTitle) {
|
||||
title = reqTitle;
|
||||
icon = getCommandIcon("req");
|
||||
tooltip = `req: ${getCommandDescription("req")}`;
|
||||
} else if (encodeTitle) {
|
||||
title = encodeTitle;
|
||||
icon = getCommandIcon("encode");
|
||||
tooltip = `encode: ${getCommandDescription("encode")}`;
|
||||
} else if (decodeTitle) {
|
||||
title = decodeTitle;
|
||||
icon = getCommandIcon("decode");
|
||||
tooltip = `decode: ${getCommandDescription("decode")}`;
|
||||
} else if (nipTitle) {
|
||||
title = nipTitle;
|
||||
icon = getCommandIcon("nip");
|
||||
tooltip = `nip: ${getCommandDescription("nip")}`;
|
||||
} else if (manTitle) {
|
||||
title = manTitle;
|
||||
// Use the specific command's icon, not the generic "man" icon
|
||||
icon = getCommandIcon(props.cmd);
|
||||
tooltip = `${props.cmd}: ${getCommandDescription(props.cmd)}`;
|
||||
} else if (feedTitle) {
|
||||
title = feedTitle;
|
||||
icon = getCommandIcon("feed");
|
||||
tooltip = `feed: ${getCommandDescription("feed")}`;
|
||||
} else if (winTitle) {
|
||||
title = winTitle;
|
||||
icon = getCommandIcon("win");
|
||||
tooltip = `win: ${getCommandDescription("win")}`;
|
||||
} else if (kindsTitle) {
|
||||
title = kindsTitle;
|
||||
icon = getCommandIcon("kinds");
|
||||
tooltip = `kinds: ${getCommandDescription("kinds")}`;
|
||||
} else if (debugTitle) {
|
||||
title = debugTitle;
|
||||
icon = getCommandIcon("debug");
|
||||
tooltip = `debug: ${getCommandDescription("debug")}`;
|
||||
} else {
|
||||
title = staticTitle;
|
||||
}
|
||||
|
||||
return { title, icon, tooltip };
|
||||
}, [
|
||||
appId,
|
||||
props,
|
||||
event,
|
||||
profileTitle,
|
||||
eventTitle,
|
||||
kindTitle,
|
||||
relayTitle,
|
||||
reqTitle,
|
||||
encodeTitle,
|
||||
decodeTitle,
|
||||
nipTitle,
|
||||
manTitle,
|
||||
feedTitle,
|
||||
winTitle,
|
||||
kindsTitle,
|
||||
debugTitle,
|
||||
staticTitle,
|
||||
]);
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import { Kind3DetailView } from "./nostr/kinds/Kind3Renderer";
|
||||
import { Kind30023DetailRenderer } from "./nostr/kinds/Kind30023DetailRenderer";
|
||||
import { Kind9802DetailRenderer } from "./nostr/kinds/Kind9802DetailRenderer";
|
||||
import { Kind10002DetailRenderer } from "./nostr/kinds/Kind10002DetailRenderer";
|
||||
import { KindBadge } from "./KindBadge";
|
||||
import {
|
||||
Copy,
|
||||
Check,
|
||||
@@ -93,19 +92,8 @@ export function EventDetailViewer({ pointer }: EventDetailViewerProps) {
|
||||
</code>
|
||||
</button>
|
||||
|
||||
{/* Right: Kind Badge, Relay Count, and JSON Toggle */}
|
||||
{/* Right: Relay Count and JSON Toggle */}
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
<div className="flex items-center gap-1">
|
||||
<KindBadge kind={event.kind} variant="compact" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<KindBadge
|
||||
kind={event.kind}
|
||||
showName
|
||||
showKindNumber={false}
|
||||
showIcon={false}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
{relays && relays.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowRelays(!showRelays)}
|
||||
|
||||
88
src/components/EventFooter.tsx
Normal file
88
src/components/EventFooter.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { NostrEvent } from "@/types/nostr";
|
||||
import { KindBadge } from "./KindBadge";
|
||||
import { Wifi } from "lucide-react";
|
||||
import { getSeenRelays } from "applesauce-core/helpers/relays";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { getKindName } from "@/constants/kinds";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { RelayLink } from "./nostr/RelayLink";
|
||||
|
||||
interface EventFooterProps {
|
||||
event: NostrEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* EventFooter - Subtle footer for events showing kind and relay information
|
||||
* Left: Kind badge (clickable to open KIND command)
|
||||
* Right: Relay count dropdown
|
||||
*/
|
||||
export function EventFooter({ event }: EventFooterProps) {
|
||||
const { addWindow } = useGrimoire();
|
||||
|
||||
// Get relays this event was seen on
|
||||
const seenRelaysSet = getSeenRelays(event);
|
||||
const relays = seenRelaysSet ? Array.from(seenRelaysSet) : [];
|
||||
const kindName = getKindName(event.kind);
|
||||
|
||||
const handleKindClick = () => {
|
||||
// Open KIND command to show NIP documentation for this kind
|
||||
addWindow("kind", { number: event.kind }, `KIND ${event.kind}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pt-2">
|
||||
{/* Footer Bar */}
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
{/* Left: Kind Badge */}
|
||||
<button
|
||||
onClick={handleKindClick}
|
||||
className="group flex items-center gap-1.5 cursor-crosshair hover:text-foreground transition-colors"
|
||||
title={`View documentation for kind ${event.kind}`}
|
||||
>
|
||||
<KindBadge
|
||||
kind={event.kind}
|
||||
variant="compact"
|
||||
iconClassname="text-muted-foreground group-hover:text-foreground transition-colors size-3"
|
||||
/>
|
||||
<span className="text-[10px] leading-[10px]">{kindName}</span>
|
||||
</button>
|
||||
|
||||
{/* Right: Relay Dropdown */}
|
||||
{relays.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="flex items-center gap-1 cursor-pointer hover:text-foreground transition-colors"
|
||||
title={`Seen on ${relays.length} relay${relays.length > 1 ? "s" : ""}`}
|
||||
>
|
||||
<Wifi className="size-3" />
|
||||
<span className="text-[10px] leading-[10px]">
|
||||
{relays.length}
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="max-h-64 overflow-y-auto p-1"
|
||||
>
|
||||
<DropdownMenuLabel>Seen on</DropdownMenuLabel>
|
||||
{relays.map((relay) => (
|
||||
<RelayLink
|
||||
key={relay}
|
||||
url={relay}
|
||||
showInboxOutbox={false}
|
||||
className="px-2 py-1 rounded-sm"
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { MosaicWindow, MosaicBranch } from "react-mosaic-component";
|
||||
import { WindowInstance } from "@/types/app";
|
||||
import { WindowToolbar } from "./WindowToolbar";
|
||||
import { WindowRenderer } from "./WindowRenderer";
|
||||
import { useDynamicWindowTitle } from "./DynamicWindowTitle";
|
||||
|
||||
interface WindowTileProps {
|
||||
id: string;
|
||||
@@ -11,11 +12,31 @@ interface WindowTileProps {
|
||||
}
|
||||
|
||||
export function WindowTile({ id, window, path, onClose }: WindowTileProps) {
|
||||
const { title, icon, tooltip } = useDynamicWindowTitle(window);
|
||||
const Icon = icon;
|
||||
|
||||
// Custom toolbar renderer to include icon
|
||||
const renderToolbar = () => {
|
||||
return (
|
||||
<div className="mosaic-window-toolbar draggable flex items-center justify-between w-full">
|
||||
<div className="mosaic-window-title flex items-center gap-2 flex-1">
|
||||
{Icon && (
|
||||
<span title={tooltip} className="flex-shrink-0">
|
||||
<Icon className="size-4 text-muted-foreground" />
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate">{title}</span>
|
||||
</div>
|
||||
<WindowToolbar onClose={() => onClose(id)} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<MosaicWindow
|
||||
path={path}
|
||||
title={window.title}
|
||||
toolbarControls={<WindowToolbar onClose={() => onClose(id)} />}
|
||||
title={title}
|
||||
renderToolbar={renderToolbar}
|
||||
>
|
||||
<WindowRenderer window={window} onClose={() => onClose(id)} />
|
||||
</MosaicWindow>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { X } from "lucide-react";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
interface WindowToolbarProps {
|
||||
onClose?: () => void;
|
||||
@@ -7,17 +6,16 @@ interface WindowToolbarProps {
|
||||
|
||||
export function WindowToolbar({ onClose }: WindowToolbarProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<>
|
||||
{onClose && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 text-muted-foreground hover:text-foreground"
|
||||
<button
|
||||
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
|
||||
onClick={onClose}
|
||||
title="Close window"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</Button>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useCopy } from "@/hooks/useCopy";
|
||||
import { JsonViewer } from "@/components/JsonViewer";
|
||||
import { formatTimestamp } from "@/hooks/useLocale";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { EventFooter } from "@/components/EventFooter";
|
||||
|
||||
// NIP-01 Kind ranges
|
||||
const REPLACEABLE_START = 10000;
|
||||
@@ -199,6 +200,7 @@ export function BaseEventContainer({
|
||||
<EventMenu event={event} />
|
||||
</div>
|
||||
{children}
|
||||
<EventFooter event={event} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,7 +51,6 @@ function DefaultKindRenderer({ event }: BaseEventProps) {
|
||||
return (
|
||||
<BaseEventContainer event={event}>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<div className="text-xs mb-1">Kind {event.kind} event</div>
|
||||
<pre className="text-xs overflow-x-auto whitespace-pre-wrap break-words">
|
||||
{event.content || "(empty content)"}
|
||||
</pre>
|
||||
|
||||
100
src/constants/command-icons.ts
Normal file
100
src/constants/command-icons.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
Book,
|
||||
Podcast,
|
||||
FileText,
|
||||
HelpCircle,
|
||||
List,
|
||||
BookOpen,
|
||||
ExternalLink,
|
||||
User,
|
||||
Lock,
|
||||
Unlock,
|
||||
Radio,
|
||||
Rss,
|
||||
Layout,
|
||||
Bug,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
/**
|
||||
* Icon mapping for all commands/apps
|
||||
* Each command has an icon and optional tooltip description
|
||||
*/
|
||||
export interface CommandIcon {
|
||||
icon: LucideIcon;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const COMMAND_ICONS: Record<string, CommandIcon> = {
|
||||
// Documentation commands
|
||||
nip: {
|
||||
icon: Book,
|
||||
description: "View Nostr Implementation Possibility specification",
|
||||
},
|
||||
kind: {
|
||||
icon: FileText,
|
||||
description: "View information about a Nostr event kind",
|
||||
},
|
||||
kinds: {
|
||||
icon: List,
|
||||
description: "Display all supported Nostr event kinds",
|
||||
},
|
||||
man: {
|
||||
icon: BookOpen,
|
||||
description: "Display manual page for a command",
|
||||
},
|
||||
help: {
|
||||
icon: HelpCircle,
|
||||
description: "Display general help information",
|
||||
},
|
||||
|
||||
// Nostr commands
|
||||
req: {
|
||||
icon: Podcast,
|
||||
description: "Active subscription to Nostr relays with filters",
|
||||
},
|
||||
open: {
|
||||
icon: ExternalLink,
|
||||
description: "Open and view a Nostr event",
|
||||
},
|
||||
profile: {
|
||||
icon: User,
|
||||
description: "View a Nostr user profile",
|
||||
},
|
||||
relay: {
|
||||
icon: Radio,
|
||||
description: "View relay information and statistics",
|
||||
},
|
||||
feed: {
|
||||
icon: Rss,
|
||||
description: "View event feed",
|
||||
},
|
||||
|
||||
// Utility commands
|
||||
encode: {
|
||||
icon: Lock,
|
||||
description: "Encode data to NIP-19 format",
|
||||
},
|
||||
decode: {
|
||||
icon: Unlock,
|
||||
description: "Decode NIP-19 encoded identifiers",
|
||||
},
|
||||
|
||||
// System commands
|
||||
win: {
|
||||
icon: Layout,
|
||||
description: "View all open windows",
|
||||
},
|
||||
debug: {
|
||||
icon: Bug,
|
||||
description: "Display application state for debugging",
|
||||
},
|
||||
};
|
||||
|
||||
export function getCommandIcon(command: string): LucideIcon {
|
||||
return COMMAND_ICONS[command]?.icon || FileText;
|
||||
}
|
||||
|
||||
export function getCommandDescription(command: string): string {
|
||||
return COMMAND_ICONS[command]?.description || "";
|
||||
}
|
||||
@@ -2,12 +2,14 @@ import { MosaicNode } from "react-mosaic-component";
|
||||
|
||||
export type AppId =
|
||||
| "nip"
|
||||
//| "nips"
|
||||
| "kind"
|
||||
| "kinds"
|
||||
| "man"
|
||||
| "feed"
|
||||
| "win"
|
||||
| "req"
|
||||
//| "event"
|
||||
| "open"
|
||||
| "profile"
|
||||
| "encode"
|
||||
|
||||
Reference in New Issue
Block a user