mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-12 16:37:06 +02:00
fix: Improve NIP-17 chat UI and fix decrypt handling
- Fix garbled messages by creating synthetic events from rumors - Add rumor events to EventStore so ReplyPreview can find them - Fix decrypt toast showing success even on failure - Show profile names in chat title using DmTitle component - Support $me alias for saved messages (chat $me) - Make inbox conversations more compact, remove icon - Show "Saved Messages" for self-conversation in inbox - Wire up inbox conversation click to open chat window - Show per-participant inbox relays in RelaysDropdown - Add participantInboxRelays metadata type for NIP-17
This commit is contained in:
@@ -121,6 +121,40 @@ function isDifferentDay(timestamp1: number, timestamp2: number): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* DmTitle - Renders profile names for NIP-17 DM conversations
|
||||
*/
|
||||
const DmTitle = memo(function DmTitle({
|
||||
participants,
|
||||
activePubkey,
|
||||
}: {
|
||||
participants: { pubkey: string }[];
|
||||
activePubkey: string | undefined;
|
||||
}) {
|
||||
// Filter out the current user from participants
|
||||
const others = participants.filter((p) => p.pubkey !== activePubkey);
|
||||
|
||||
// Self-conversation (saved messages)
|
||||
if (others.length === 0) {
|
||||
return <span>Saved Messages</span>;
|
||||
}
|
||||
|
||||
// 1-on-1 or group
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 flex-wrap">
|
||||
{others.slice(0, 3).map((p, i) => (
|
||||
<span key={p.pubkey} className="inline-flex items-center">
|
||||
{i > 0 && <span className="text-muted-foreground">, </span>}
|
||||
<UserName pubkey={p.pubkey} className="font-semibold" />
|
||||
</span>
|
||||
))}
|
||||
{others.length > 3 && (
|
||||
<span className="text-muted-foreground">+{others.length - 3}</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Type guard for LiveActivityMetadata
|
||||
*/
|
||||
@@ -794,7 +828,16 @@ export function ChatViewer({
|
||||
className="text-sm font-semibold truncate cursor-help text-left"
|
||||
onClick={() => setTooltipOpen(!tooltipOpen)}
|
||||
>
|
||||
{customTitle || conversation.title}
|
||||
{customTitle ? (
|
||||
customTitle
|
||||
) : conversation.protocol === "nip-17" ? (
|
||||
<DmTitle
|
||||
participants={conversation.participants}
|
||||
activePubkey={activeAccount?.pubkey}
|
||||
/>
|
||||
) : (
|
||||
conversation.title
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
@@ -817,7 +860,14 @@ export function ChatViewer({
|
||||
/>
|
||||
)}
|
||||
<span className="font-semibold">
|
||||
{conversation.title}
|
||||
{conversation.protocol === "nip-17" ? (
|
||||
<DmTitle
|
||||
participants={conversation.participants}
|
||||
activePubkey={activeAccount?.pubkey}
|
||||
/>
|
||||
) : (
|
||||
conversation.title
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{/* Description */}
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
Clock,
|
||||
Radio,
|
||||
RefreshCw,
|
||||
Users,
|
||||
MessageSquare,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
@@ -32,11 +31,13 @@ import accounts from "@/services/accounts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatTimestamp } from "@/hooks/useLocale";
|
||||
import type { DecryptStatus } from "@/services/gift-wrap";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
|
||||
/**
|
||||
* InboxViewer - Manage private messages (NIP-17/59 gift wraps)
|
||||
*/
|
||||
function InboxViewer() {
|
||||
const { addWindow } = useGrimoire();
|
||||
const account = use$(accounts.active$);
|
||||
const settings = use$(giftWrapService.settings$);
|
||||
const syncStatus = use$(giftWrapService.syncStatus$);
|
||||
@@ -293,9 +294,17 @@ function InboxViewer() {
|
||||
conversation={conv}
|
||||
currentUserPubkey={account.pubkey}
|
||||
onClick={() => {
|
||||
// Open chat window - for now just show a toast
|
||||
// In future, this would open the conversation in a chat viewer
|
||||
toast.info("Chat viewer coming soon");
|
||||
// Build chat identifier from participants
|
||||
// For self-chat, use $me; for others, use comma-separated npubs
|
||||
const others = conv.participants.filter(
|
||||
(p) => p !== account.pubkey,
|
||||
);
|
||||
const identifier =
|
||||
others.length === 0 ? "$me" : others.join(",");
|
||||
addWindow("chat", {
|
||||
identifier,
|
||||
protocol: "nip-17",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
@@ -331,8 +340,14 @@ function InboxViewer() {
|
||||
giftWraps={giftWraps ?? []}
|
||||
onDecrypt={async (id) => {
|
||||
try {
|
||||
await giftWrapService.decrypt(id);
|
||||
toast.success("Message decrypted");
|
||||
const result = await giftWrapService.decrypt(id);
|
||||
if (result) {
|
||||
toast.success("Message decrypted");
|
||||
} else {
|
||||
// Decryption failed but didn't throw
|
||||
const state = giftWrapService.decryptStates$.value.get(id);
|
||||
toast.error(state?.error || "Failed to decrypt message");
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Decryption failed",
|
||||
@@ -398,39 +413,39 @@ function ConversationRow({
|
||||
(p) => p !== currentUserPubkey,
|
||||
);
|
||||
|
||||
// Self-conversation (saved messages)
|
||||
const isSelfConversation = otherParticipants.length === 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="border-b border-border px-4 py-3 hover:bg-muted/30 cursor-crosshair transition-colors"
|
||||
className="border-b border-border px-4 py-2 hover:bg-muted/30 cursor-pointer transition-colors"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
{otherParticipants.length === 1 ? (
|
||||
<div className="size-8 rounded-full bg-muted flex items-center justify-center">
|
||||
<Users className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="size-8 rounded-full bg-muted flex items-center justify-center">
|
||||
<Users className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{otherParticipants.slice(0, 3).map((pubkey, i) => (
|
||||
<span key={pubkey}>
|
||||
{i > 0 && <span className="text-muted-foreground">, </span>}
|
||||
<UserName pubkey={pubkey} className="text-sm" />
|
||||
</span>
|
||||
))}
|
||||
{otherParticipants.length > 3 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
+{otherParticipants.length - 3} more
|
||||
</span>
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{isSelfConversation ? (
|
||||
<span className="text-sm font-medium">Saved Messages</span>
|
||||
) : (
|
||||
<>
|
||||
{otherParticipants.slice(0, 3).map((pubkey, i) => (
|
||||
<span key={pubkey} className="inline-flex items-center">
|
||||
{i > 0 && (
|
||||
<span className="text-muted-foreground mr-1">,</span>
|
||||
)}
|
||||
<UserName pubkey={pubkey} className="text-sm font-medium" />
|
||||
</span>
|
||||
))}
|
||||
{otherParticipants.length > 3 && (
|
||||
<span className="text-xs text-muted-foreground ml-1">
|
||||
+{otherParticipants.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{conversation.lastMessage && (
|
||||
<p className="text-sm text-muted-foreground truncate mt-0.5">
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{conversation.lastMessage.content}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { RelayLink } from "@/components/nostr/RelayLink";
|
||||
import { UserName } from "@/components/nostr/UserName";
|
||||
import { useRelayState } from "@/hooks/useRelayState";
|
||||
import { getConnectionIcon, getAuthIcon } from "@/lib/relay-status-utils";
|
||||
import { normalizeRelayURL } from "@/lib/relay-url";
|
||||
@@ -17,21 +18,35 @@ interface RelaysDropdownProps {
|
||||
/**
|
||||
* RelaysDropdown - Shows relay count and list with connection status
|
||||
* Similar to relay indicators in ReqViewer
|
||||
* For NIP-17 DMs, shows per-participant inbox relays
|
||||
*/
|
||||
export function RelaysDropdown({ conversation }: RelaysDropdownProps) {
|
||||
const { relays: relayStates } = useRelayState();
|
||||
|
||||
// Check for per-participant inbox relays (NIP-17)
|
||||
const participantInboxRelays = conversation.metadata?.participantInboxRelays;
|
||||
const hasParticipantRelays =
|
||||
participantInboxRelays && Object.keys(participantInboxRelays).length > 0;
|
||||
|
||||
// Get relays for this conversation (immutable pattern)
|
||||
// Priority: liveActivity relays > inbox relays (NIP-17) > single relayUrl
|
||||
const liveActivityRelays = conversation.metadata?.liveActivity?.relays;
|
||||
const inboxRelays = conversation.metadata?.inboxRelays;
|
||||
const relays: string[] =
|
||||
Array.isArray(liveActivityRelays) && liveActivityRelays.length > 0
|
||||
? liveActivityRelays
|
||||
: conversation.metadata?.relayUrl
|
||||
? [conversation.metadata.relayUrl]
|
||||
: [];
|
||||
: Array.isArray(inboxRelays) && inboxRelays.length > 0
|
||||
? inboxRelays
|
||||
: conversation.metadata?.relayUrl
|
||||
? [conversation.metadata.relayUrl]
|
||||
: [];
|
||||
|
||||
// Pre-compute normalized URLs and state lookups in a single pass (O(n))
|
||||
const relayData = relays.map((url) => {
|
||||
// Get label for the relays section
|
||||
const relayLabel =
|
||||
conversation.protocol === "nip-17" ? "Inbox Relays" : "Relays";
|
||||
|
||||
// Helper to normalize and get state for a relay URL
|
||||
const getRelayInfo = (url: string) => {
|
||||
let normalizedUrl: string;
|
||||
try {
|
||||
normalizedUrl = normalizeRelayURL(url);
|
||||
@@ -45,12 +60,15 @@ export function RelaysDropdown({ conversation }: RelaysDropdownProps) {
|
||||
state,
|
||||
isConnected: state?.connectionState === "connected",
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// Pre-compute relay data for all relays
|
||||
const relayData = relays.map(getRelayInfo);
|
||||
|
||||
// Count connected relays
|
||||
const connectedCount = relayData.filter((r) => r.isConnected).length;
|
||||
|
||||
if (relays.length === 0) {
|
||||
if (relays.length === 0 && !hasParticipantRelays) {
|
||||
return null; // Don't show if no relays
|
||||
}
|
||||
|
||||
@@ -64,32 +82,75 @@ export function RelaysDropdown({ conversation }: RelaysDropdownProps) {
|
||||
</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">
|
||||
{relayData.map(({ url, state }) => {
|
||||
const connIcon = getConnectionIcon(state);
|
||||
const authIcon = getAuthIcon(state);
|
||||
<DropdownMenuContent align="end" className="w-72">
|
||||
{/* For NIP-17, show per-participant breakdown */}
|
||||
{hasParticipantRelays ? (
|
||||
<div className="space-y-2 p-1">
|
||||
{Object.entries(participantInboxRelays).map(
|
||||
([pubkey, pubkeyRelays]) => (
|
||||
<div key={pubkey}>
|
||||
<div className="px-2 py-1 text-xs font-medium text-muted-foreground flex items-center gap-1">
|
||||
<UserName pubkey={pubkey} className="font-medium" />
|
||||
<span className="text-muted-foreground/60">
|
||||
({pubkeyRelays.length})
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{pubkeyRelays.map((url) => {
|
||||
const info = getRelayInfo(url);
|
||||
const connIcon = getConnectionIcon(info.state);
|
||||
const authIcon = getAuthIcon(info.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}
|
||||
return (
|
||||
<div
|
||||
key={url}
|
||||
className="flex items-center gap-2 px-2 py-1 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>
|
||||
</div>
|
||||
<RelayLink
|
||||
url={url}
|
||||
className="text-sm truncate flex-1 min-w-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
{relayLabel} ({relays.length})
|
||||
</div>
|
||||
<div className="space-y-1 p-1">
|
||||
{relayData.map(({ url, state }) => {
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { NostrEvent } from "@/types/nostr";
|
||||
import giftWrapService, { type Rumor } from "@/services/gift-wrap";
|
||||
import accountManager from "@/services/accounts";
|
||||
import { resolveNip05 } from "@/lib/nip05";
|
||||
import eventStore from "@/services/event-store";
|
||||
|
||||
/** Kind 14: Private direct message (NIP-17) */
|
||||
const PRIVATE_DM_KIND = 14;
|
||||
@@ -28,7 +29,7 @@ function computeConversationId(participants: string[]): string {
|
||||
|
||||
/**
|
||||
* Parse participants from a comma-separated list or single identifier
|
||||
* Supports: npub, nprofile, hex pubkey (32 bytes), NIP-05
|
||||
* Supports: npub, nprofile, hex pubkey (32 bytes), NIP-05, $me
|
||||
*/
|
||||
async function parseParticipants(input: string): Promise<string[]> {
|
||||
const parts = input
|
||||
@@ -36,8 +37,17 @@ async function parseParticipants(input: string): Promise<string[]> {
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
const pubkeys: string[] = [];
|
||||
const activePubkey = accountManager.active$.value?.pubkey;
|
||||
|
||||
for (const part of parts) {
|
||||
// Handle $me alias
|
||||
if (part === "$me") {
|
||||
if (activePubkey && !pubkeys.includes(activePubkey)) {
|
||||
pubkeys.push(activePubkey);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const pubkey = await resolveToPubkey(part);
|
||||
if (pubkey && !pubkeys.includes(pubkey)) {
|
||||
pubkeys.push(pubkey);
|
||||
@@ -117,12 +127,21 @@ export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
readonly type = "dm" as const;
|
||||
|
||||
/**
|
||||
* Parse identifier - accepts pubkeys, npubs, nprofiles, NIP-05, or comma-separated list
|
||||
* Parse identifier - accepts pubkeys, npubs, nprofiles, NIP-05, $me, or comma-separated list
|
||||
*/
|
||||
parseIdentifier(input: string): ProtocolIdentifier | null {
|
||||
// Quick check: must look like a pubkey identifier or NIP-05
|
||||
const trimmed = input.trim();
|
||||
|
||||
// Check for $me alias (for saved messages)
|
||||
if (trimmed === "$me") {
|
||||
return {
|
||||
type: "dm-recipient",
|
||||
value: trimmed,
|
||||
relays: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Check for npub, nprofile, hex, or NIP-05 patterns
|
||||
const looksLikePubkey =
|
||||
trimmed.startsWith("npub1") ||
|
||||
@@ -133,13 +152,14 @@ export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
!trimmed.includes("'") &&
|
||||
!trimmed.includes("/"));
|
||||
|
||||
// Also check for comma-separated list
|
||||
// Also check for comma-separated list (may include $me)
|
||||
const looksLikeList =
|
||||
trimmed.includes(",") &&
|
||||
trimmed
|
||||
.split(",")
|
||||
.some(
|
||||
(p) =>
|
||||
p.trim() === "$me" ||
|
||||
p.trim().startsWith("npub1") ||
|
||||
p.trim().startsWith("nprofile1") ||
|
||||
/^[0-9a-fA-F]{64}$/.test(p.trim()) ||
|
||||
@@ -226,6 +246,15 @@ export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
role: pubkey === activePubkey ? "member" : undefined,
|
||||
}));
|
||||
|
||||
// Get inbox relays for the current user
|
||||
const userInboxRelays = giftWrapService.inboxRelays$.value;
|
||||
|
||||
// Build per-participant inbox relay map (start with current user)
|
||||
const participantInboxRelays: Record<string, string[]> = {};
|
||||
if (userInboxRelays.length > 0) {
|
||||
participantInboxRelays[activePubkey] = userInboxRelays;
|
||||
}
|
||||
|
||||
return {
|
||||
id: conversationId,
|
||||
type: "dm",
|
||||
@@ -235,6 +264,9 @@ export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
metadata: {
|
||||
encrypted: true,
|
||||
giftWrapped: true,
|
||||
// Store inbox relays for display in header
|
||||
inboxRelays: userInboxRelays,
|
||||
participantInboxRelays,
|
||||
},
|
||||
unreadCount: 0,
|
||||
};
|
||||
@@ -297,10 +329,11 @@ export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
|
||||
/**
|
||||
* Convert a rumor to a Message
|
||||
* Creates a synthetic event from the rumor for display purposes
|
||||
*/
|
||||
private rumorToMessage(
|
||||
conversationId: string,
|
||||
giftWrap: NostrEvent,
|
||||
_giftWrap: NostrEvent,
|
||||
rumor: Rumor,
|
||||
): Message {
|
||||
// Find reply-to from e tags
|
||||
@@ -312,6 +345,21 @@ export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
// Create a synthetic event from the rumor for display
|
||||
// This allows RichText to parse content correctly
|
||||
const syntheticEvent: NostrEvent = {
|
||||
id: rumor.id,
|
||||
pubkey: rumor.pubkey,
|
||||
created_at: rumor.created_at,
|
||||
kind: rumor.kind,
|
||||
tags: rumor.tags,
|
||||
content: rumor.content,
|
||||
sig: "", // Empty sig - this is a display-only synthetic event
|
||||
};
|
||||
|
||||
// Add to eventStore so ReplyPreview can find it by rumor ID
|
||||
eventStore.add(syntheticEvent);
|
||||
|
||||
return {
|
||||
id: rumor.id,
|
||||
conversationId,
|
||||
@@ -324,8 +372,8 @@ export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
encrypted: true,
|
||||
},
|
||||
protocol: "nip-17",
|
||||
// Use gift wrap as the event since rumor is unsigned
|
||||
event: giftWrap,
|
||||
// Use synthetic event with decrypted content
|
||||
event: syntheticEvent,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -370,18 +418,35 @@ export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a replied-to message by ID
|
||||
* Load a replied-to message by ID (rumor ID)
|
||||
* Creates a synthetic event from the rumor if found
|
||||
*/
|
||||
async loadReplyMessage(
|
||||
_conversation: Conversation,
|
||||
eventId: string,
|
||||
): Promise<NostrEvent | null> {
|
||||
// First check if it's already in eventStore (synthetic event may have been added)
|
||||
const existingEvent = eventStore.database.getEvent(eventId);
|
||||
if (existingEvent) {
|
||||
return existingEvent;
|
||||
}
|
||||
|
||||
// Check decrypted rumors for the message
|
||||
const rumors = giftWrapService.decryptedRumors$.value;
|
||||
const found = rumors.find(({ rumor }) => rumor.id === eventId);
|
||||
if (found) {
|
||||
// Return the gift wrap event
|
||||
return found.giftWrap;
|
||||
// Create and add synthetic event from rumor
|
||||
const syntheticEvent: NostrEvent = {
|
||||
id: found.rumor.id,
|
||||
pubkey: found.rumor.pubkey,
|
||||
created_at: found.rumor.created_at,
|
||||
kind: found.rumor.kind,
|
||||
tags: found.rumor.tags,
|
||||
content: found.rumor.content,
|
||||
sig: "",
|
||||
};
|
||||
eventStore.add(syntheticEvent);
|
||||
return syntheticEvent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -76,6 +76,8 @@ export interface ConversationMetadata {
|
||||
// NIP-17 DM
|
||||
encrypted?: boolean;
|
||||
giftWrapped?: boolean;
|
||||
inboxRelays?: string[]; // User's DM inbox relays (kind 10050)
|
||||
participantInboxRelays?: Record<string, string[]>; // Per-participant inbox relays
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user