mirror of
https://gitlab.com/soapbox-pub/mkstack.git
synced 2025-08-27 04:59:22 +00:00
further refinement
This commit is contained in:
parent
7eecec7506
commit
0bc03017bb
@ -24,7 +24,7 @@ export function ZapButton({
|
|||||||
const { webln, activeNWC } = useWallet();
|
const { webln, activeNWC } = useWallet();
|
||||||
|
|
||||||
// Only fetch data if not provided externally
|
// Only fetch data if not provided externally
|
||||||
const { getZapData, isLoading } = useZaps(
|
const { totalSats: fetchedTotalSats, isLoading } = useZaps(
|
||||||
externalZapData ? [] : target, // Empty array prevents fetching if external data provided
|
externalZapData ? [] : target, // Empty array prevents fetching if external data provided
|
||||||
webln,
|
webln,
|
||||||
activeNWC
|
activeNWC
|
||||||
@ -36,20 +36,18 @@ export function ZapButton({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Use external data if provided, otherwise use fetched data
|
// Use external data if provided, otherwise use fetched data
|
||||||
const zapInfo = externalZapData || getZapData(target.id);
|
const totalSats = externalZapData?.totalSats ?? fetchedTotalSats;
|
||||||
const { count: zapCount, totalSats } = zapInfo;
|
const showLoading = externalZapData?.isLoading || isLoading;
|
||||||
const dataLoading = 'isLoading' in zapInfo ? zapInfo.isLoading : false;
|
|
||||||
const showLoading = externalZapData?.isLoading || dataLoading || isLoading;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ZapDialog target={target}>
|
<ZapDialog target={target}>
|
||||||
<div className={className}>
|
<div className={`flex items-center gap-1 ${className}`}>
|
||||||
<Zap className="h-4 w-4 mr-1" />
|
<Zap className="h-4 w-4" />
|
||||||
<span className="text-xs">
|
<span className="text-xs">
|
||||||
{showLoading ? (
|
{showLoading ? (
|
||||||
'...'
|
'...'
|
||||||
) : showCount && zapCount > 0 ? (
|
) : showCount && totalSats > 0 ? (
|
||||||
totalSats > 0 ? `${totalSats.toLocaleString()}` : zapCount
|
`${totalSats.toLocaleString()}`
|
||||||
) : (
|
) : (
|
||||||
'Zap'
|
'Zap'
|
||||||
)}
|
)}
|
||||||
|
@ -28,15 +28,12 @@ export function useNWCInternal() {
|
|||||||
// Parse and validate NWC URI
|
// Parse and validate NWC URI
|
||||||
const parseNWCUri = (uri: string): { connectionString: string } | null => {
|
const parseNWCUri = (uri: string): { connectionString: string } | null => {
|
||||||
try {
|
try {
|
||||||
console.debug('Parsing NWC URI:', { uri: uri.substring(0, 50) + '...' });
|
|
||||||
|
|
||||||
if (!uri.startsWith('nostr+walletconnect://') && !uri.startsWith('nostrwalletconnect://')) {
|
if (!uri.startsWith('nostr+walletconnect://') && !uri.startsWith('nostrwalletconnect://')) {
|
||||||
console.error('Invalid NWC URI protocol:', { protocol: uri.split('://')[0] });
|
console.error('Invalid NWC URI protocol:', { protocol: uri.split('://')[0] });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Basic validation - let the SDK handle the detailed parsing
|
// Basic validation - let the SDK handle the detailed parsing
|
||||||
console.debug('NWC URI parsing successful');
|
|
||||||
return { connectionString: uri };
|
return { connectionString: uri };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to parse NWC URI:', error);
|
console.error('Failed to parse NWC URI:', error);
|
||||||
@ -152,11 +149,8 @@ export function useNWCInternal() {
|
|||||||
setActiveConnection(connections[0].connectionString);
|
setActiveConnection(connections[0].connectionString);
|
||||||
return connections[0];
|
return connections[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!activeConnection) {
|
if (!activeConnection) return null;
|
||||||
console.debug('No active connection and no connections');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const found = connections.find(c => c.connectionString === activeConnection);
|
const found = connections.find(c => c.connectionString === activeConnection);
|
||||||
return found || null;
|
return found || null;
|
||||||
@ -183,7 +177,7 @@ export function useNWCInternal() {
|
|||||||
try {
|
try {
|
||||||
// Add timeout to prevent hanging
|
// Add timeout to prevent hanging
|
||||||
const timeoutPromise = new Promise((_, reject) => {
|
const timeoutPromise = new Promise((_, reject) => {
|
||||||
setTimeout(() => reject(new Error('Payment timeout after 15 seconds')), 15);
|
setTimeout(() => reject(new Error('Payment timeout after 15 seconds')), 15000);
|
||||||
});
|
});
|
||||||
|
|
||||||
const paymentPromise = client.pay(invoice);
|
const paymentPromise = client.pay(invoice);
|
||||||
|
@ -85,17 +85,6 @@ export function useWallet() {
|
|||||||
preferredMethod,
|
preferredMethod,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Debug logging for wallet status changes
|
|
||||||
useEffect(() => {
|
|
||||||
console.debug('Wallet status updated:', {
|
|
||||||
hasWebLN: status.hasWebLN,
|
|
||||||
hasNWC: status.hasNWC,
|
|
||||||
connectionsCount: connections.length,
|
|
||||||
activeNWC: !!status.activeNWC,
|
|
||||||
preferredMethod: status.preferredMethod
|
|
||||||
});
|
|
||||||
}, [status.hasWebLN, status.hasNWC, connections.length, status.activeNWC, status.preferredMethod]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...status,
|
...status,
|
||||||
hasAttemptedDetection,
|
hasAttemptedDetection,
|
||||||
|
@ -4,14 +4,13 @@ import { useAuthor } from '@/hooks/useAuthor';
|
|||||||
import { useAppContext } from '@/hooks/useAppContext';
|
import { useAppContext } from '@/hooks/useAppContext';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
import { useNWC } from '@/hooks/useNWCContext';
|
import { useNWC } from '@/hooks/useNWCContext';
|
||||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
|
||||||
import type { NWCConnection } from '@/hooks/useNWC';
|
import type { NWCConnection } from '@/hooks/useNWC';
|
||||||
import { nip57 } from 'nostr-tools';
|
import { nip57 } from 'nostr-tools';
|
||||||
import type { Event } from 'nostr-tools';
|
import type { Event } from 'nostr-tools';
|
||||||
import type { WebLNProvider } from 'webln';
|
import type { WebLNProvider } from 'webln';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNostr } from '@nostrify/react';
|
import { useNostr } from '@nostrify/react';
|
||||||
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
import type { NostrEvent } from '@nostrify/nostrify';
|
||||||
|
|
||||||
// NWC utility functions
|
// NWC utility functions
|
||||||
function parseNWCUri(uri: string): NWCConnection | null {
|
function parseNWCUri(uri: string): NWCConnection | null {
|
||||||
@ -40,7 +39,7 @@ function parseNWCUri(uri: string): NWCConnection | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useZaps(
|
export function useZaps(
|
||||||
targets: Event | Event[],
|
target: Event | Event[],
|
||||||
webln: WebLNProvider | null,
|
webln: WebLNProvider | null,
|
||||||
_nwcConnection: NWCConnection | null,
|
_nwcConnection: NWCConnection | null,
|
||||||
onZapSuccess?: () => void
|
onZapSuccess?: () => void
|
||||||
@ -49,130 +48,102 @@ export function useZaps(
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
const { config } = useAppContext();
|
const { config } = useAppContext();
|
||||||
const { mutate: publishEvent } = useNostrPublish();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Normalize targets to array for consistent handling
|
// Handle the case where an empty array is passed (from ZapButton when external data is provided)
|
||||||
const targetArray = useMemo(() =>
|
const actualTarget = Array.isArray(target) ? (target.length > 0 ? target[0] : null) : target;
|
||||||
Array.isArray(targets) ? targets : (targets ? [targets] : []),
|
|
||||||
[targets]
|
|
||||||
);
|
|
||||||
const isBatchMode = Array.isArray(targets);
|
|
||||||
const primaryTarget = targetArray[0]; // For single-target operations like zapping
|
|
||||||
|
|
||||||
const author = useAuthor(primaryTarget?.pubkey);
|
const author = useAuthor(actualTarget?.pubkey);
|
||||||
const { sendPayment, getActiveConnection, connections, activeConnection } = useNWC();
|
const { sendPayment, getActiveConnection } = useNWC();
|
||||||
const [isZapping, setIsZapping] = useState(false);
|
const [isZapping, setIsZapping] = useState(false);
|
||||||
const [invoice, setInvoice] = useState<string | null>(null);
|
const [invoice, setInvoice] = useState<string | null>(null);
|
||||||
|
|
||||||
// Create query key based on mode
|
|
||||||
const queryKey = isBatchMode
|
|
||||||
? ['zaps-batch', targetArray.map(t => t.id).sort()]
|
|
||||||
: ['zaps-single', primaryTarget.id];
|
|
||||||
|
|
||||||
const { data: zapEvents, ...query } = useQuery<NostrEvent[], Error>({
|
const { data: zapEvents, ...query } = useQuery<NostrEvent[], Error>({
|
||||||
queryKey,
|
queryKey: ['zaps', actualTarget?.id],
|
||||||
|
staleTime: 30000, // 30 seconds
|
||||||
|
refetchInterval: 60000, // Refetch every minute to catch new zaps
|
||||||
queryFn: async (c) => {
|
queryFn: async (c) => {
|
||||||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(1500)]);
|
if (!actualTarget) return [];
|
||||||
const filters: NostrFilter[] = [];
|
|
||||||
|
|
||||||
if (isBatchMode) {
|
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(5000)]);
|
||||||
// Batch mode: get zaps for all events at once
|
|
||||||
const eventIds = targetArray.map(t => t.id).filter(Boolean);
|
|
||||||
const addressableEvents = targetArray.filter(t => t.kind >= 30000 && t.kind < 40000);
|
|
||||||
|
|
||||||
if (eventIds.length > 0) {
|
// Query for zap receipts for this specific event
|
||||||
filters.push({
|
if (actualTarget.kind >= 30000 && actualTarget.kind < 40000) {
|
||||||
kinds: [9735],
|
// Addressable event
|
||||||
'#e': eventIds,
|
const identifier = actualTarget.tags.find((t) => t[0] === 'd')?.[1] || '';
|
||||||
});
|
const events = await nostr.query([{
|
||||||
}
|
kinds: [9735],
|
||||||
|
'#a': [`${actualTarget.kind}:${actualTarget.pubkey}:${identifier}`],
|
||||||
// Handle addressable events
|
}], { signal });
|
||||||
if (addressableEvents.length > 0) {
|
return events;
|
||||||
const addresses = addressableEvents.map(event => {
|
|
||||||
const identifier = event.tags.find((t) => t[0] === 'd')?.[1] || '';
|
|
||||||
return `${event.kind}:${event.pubkey}:${identifier}`;
|
|
||||||
});
|
|
||||||
filters.push({
|
|
||||||
kinds: [9735],
|
|
||||||
'#a': addresses,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Single mode: get zaps for one event
|
// Regular event
|
||||||
const target = primaryTarget;
|
const events = await nostr.query([{
|
||||||
if (target.kind >= 30000 && target.kind < 40000) {
|
kinds: [9735],
|
||||||
const identifier = target.tags.find((t) => t[0] === 'd')?.[1] || '';
|
'#e': [actualTarget.id],
|
||||||
filters.push({
|
}], { signal });
|
||||||
kinds: [9735],
|
return events;
|
||||||
'#a': [`${target.kind}:${target.pubkey}:${identifier}`],
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
filters.push({
|
|
||||||
kinds: [9735],
|
|
||||||
'#e': [target.id],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filters.length === 0) return [];
|
|
||||||
const events = await nostr.query(filters, { signal });
|
|
||||||
return events;
|
|
||||||
},
|
},
|
||||||
enabled: targetArray.length > 0 && targetArray.every(t => t.id),
|
enabled: !!actualTarget?.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Process zap events into organized data
|
// Process zap events into simple counts and totals
|
||||||
const zapData = useMemo(() => {
|
const { zapCount, totalSats, zaps } = useMemo(() => {
|
||||||
if (!zapEvents) return {};
|
if (!zapEvents || !actualTarget) {
|
||||||
|
return { zapCount: 0, totalSats: 0, zaps: [] };
|
||||||
|
}
|
||||||
|
|
||||||
const organized: Record<string, { count: number; totalSats: number; events: NostrEvent[] }> = {};
|
let count = 0;
|
||||||
|
let sats = 0;
|
||||||
|
|
||||||
zapEvents.forEach(zap => {
|
zapEvents.forEach(zap => {
|
||||||
// Find which event this zap is for
|
count++;
|
||||||
const eventTag = zap.tags.find(([name]) => name === 'e')?.[1];
|
|
||||||
const addressTag = zap.tags.find(([name]) => name === 'a')?.[1];
|
|
||||||
|
|
||||||
let targetId: string | undefined;
|
// Try multiple methods to extract the amount:
|
||||||
|
|
||||||
if (eventTag) {
|
// Method 1: amount tag (from zap request, sometimes copied to receipt)
|
||||||
targetId = eventTag;
|
|
||||||
} else if (addressTag) {
|
|
||||||
// Find the target event that matches this address
|
|
||||||
const target = targetArray.find(t => {
|
|
||||||
if (t.kind >= 30000 && t.kind < 40000) {
|
|
||||||
const identifier = t.tags.find((tag) => tag[0] === 'd')?.[1] || '';
|
|
||||||
const address = `${t.kind}:${t.pubkey}:${identifier}`;
|
|
||||||
return address === addressTag;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
targetId = target?.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!targetId) return;
|
|
||||||
|
|
||||||
if (!organized[targetId]) {
|
|
||||||
organized[targetId] = { count: 0, totalSats: 0, events: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
organized[targetId].count++;
|
|
||||||
organized[targetId].events.push(zap);
|
|
||||||
|
|
||||||
// Extract amount from amount tag
|
|
||||||
const amountTag = zap.tags.find(([name]) => name === 'amount')?.[1];
|
const amountTag = zap.tags.find(([name]) => name === 'amount')?.[1];
|
||||||
if (amountTag) {
|
if (amountTag) {
|
||||||
const sats = Math.floor(parseInt(amountTag) / 1000); // Convert millisats to sats
|
const millisats = parseInt(amountTag);
|
||||||
organized[targetId].totalSats += sats;
|
sats += Math.floor(millisats / 1000);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Method 2: Extract from bolt11 invoice
|
||||||
|
const bolt11Tag = zap.tags.find(([name]) => name === 'bolt11')?.[1];
|
||||||
|
if (bolt11Tag) {
|
||||||
|
try {
|
||||||
|
const invoiceSats = nip57.getSatoshisAmountFromBolt11(bolt11Tag);
|
||||||
|
sats += invoiceSats;
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to parse bolt11 amount:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 3: Parse from description (zap request JSON)
|
||||||
|
const descriptionTag = zap.tags.find(([name]) => name === 'description')?.[1];
|
||||||
|
if (descriptionTag) {
|
||||||
|
try {
|
||||||
|
const zapRequest = JSON.parse(descriptionTag);
|
||||||
|
const requestAmountTag = zapRequest.tags?.find(([name]: string[]) => name === 'amount')?.[1];
|
||||||
|
if (requestAmountTag) {
|
||||||
|
const millisats = parseInt(requestAmountTag);
|
||||||
|
sats += Math.floor(millisats / 1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to parse description JSON:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn('Could not extract amount from zap receipt:', zap.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
return organized;
|
|
||||||
}, [zapEvents, targetArray]);
|
|
||||||
|
|
||||||
// For single mode, return the data for the primary target
|
return { zapCount: count, totalSats: sats, zaps: zapEvents };
|
||||||
const singleTargetData = isBatchMode ? undefined : zapData[primaryTarget.id];
|
}, [zapEvents, actualTarget]);
|
||||||
const zaps = singleTargetData?.events;
|
|
||||||
|
|
||||||
const zap = async (amount: number, comment: string) => {
|
const zap = async (amount: number, comment: string) => {
|
||||||
if (amount <= 0) {
|
if (amount <= 0) {
|
||||||
@ -192,6 +163,16 @@ export function useZaps(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!actualTarget) {
|
||||||
|
toast({
|
||||||
|
title: 'Event not found',
|
||||||
|
description: 'Could not find the event to zap.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
setIsZapping(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!author.data || !author.data?.metadata || !author.data?.event ) {
|
if (!author.data || !author.data?.metadata || !author.data?.event ) {
|
||||||
toast({
|
toast({
|
||||||
@ -226,21 +207,31 @@ export function useZaps(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create zap request
|
// Create zap request - use appropriate event format based on kind
|
||||||
|
// For addressable events (30000-39999), pass the object to get 'a' tag
|
||||||
|
// For all other events, pass the ID string to get 'e' tag
|
||||||
|
const event = (actualTarget.kind >= 30000 && actualTarget.kind < 40000)
|
||||||
|
? actualTarget
|
||||||
|
: actualTarget.id;
|
||||||
|
|
||||||
const zapAmount = amount * 1000; // convert to millisats
|
const zapAmount = amount * 1000; // convert to millisats
|
||||||
|
|
||||||
const zapRequest = nip57.makeZapRequest({
|
const zapRequest = nip57.makeZapRequest({
|
||||||
profile: primaryTarget.pubkey,
|
profile: actualTarget.pubkey,
|
||||||
event: primaryTarget,
|
event: event,
|
||||||
amount: zapAmount,
|
amount: zapAmount,
|
||||||
relays: [config.relayUrl],
|
relays: [config.relayUrl],
|
||||||
comment
|
comment
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sign and publish the zap request
|
// Sign the zap request (but don't publish to relays - only send to LNURL endpoint)
|
||||||
publishEvent(zapRequest, {
|
if (!user.signer) {
|
||||||
onSuccess: async (event) => {
|
throw new Error('No signer available');
|
||||||
try {
|
}
|
||||||
const res = await fetch(`${zapEndpoint}?amount=${zapAmount}&nostr=${encodeURI(JSON.stringify(event))}`);
|
const signedZapRequest = await user.signer.signEvent(zapRequest);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${zapEndpoint}?amount=${zapAmount}&nostr=${encodeURI(JSON.stringify(signedZapRequest))}`);
|
||||||
const responseData = await res.json();
|
const responseData = await res.json();
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@ -269,6 +260,9 @@ export function useZaps(
|
|||||||
description: `You sent ${amount} sats via NWC to the author.`,
|
description: `You sent ${amount} sats via NWC to the author.`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Invalidate zap queries to refresh counts
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['zaps'] });
|
||||||
|
|
||||||
// Close dialog last to ensure clean state
|
// Close dialog last to ensure clean state
|
||||||
onZapSuccess?.();
|
onZapSuccess?.();
|
||||||
return;
|
return;
|
||||||
@ -295,6 +289,9 @@ export function useZaps(
|
|||||||
description: `You sent ${amount} sats to the author.`,
|
description: `You sent ${amount} sats to the author.`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Invalidate zap queries to refresh counts
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['zaps'] });
|
||||||
|
|
||||||
// Close dialog last to ensure clean state
|
// Close dialog last to ensure clean state
|
||||||
onZapSuccess?.();
|
onZapSuccess?.();
|
||||||
} else { // Default - show QR code and manual Lightning URI
|
} else { // Default - show QR code and manual Lightning URI
|
||||||
@ -308,20 +305,8 @@ export function useZaps(
|
|||||||
description: (err as Error).message,
|
description: (err as Error).message,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
} finally {
|
|
||||||
setIsZapping(false);
|
setIsZapping(false);
|
||||||
}
|
}
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
console.error('Failed to publish zap request:', err);
|
|
||||||
toast({
|
|
||||||
title: 'Zap failed',
|
|
||||||
description: 'Failed to create zap request',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
setIsZapping(false);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Zap error:', err);
|
console.error('Zap error:', err);
|
||||||
toast({
|
toast({
|
||||||
@ -335,16 +320,13 @@ export function useZaps(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
zaps,
|
zaps,
|
||||||
|
zapCount,
|
||||||
|
totalSats,
|
||||||
...query,
|
...query,
|
||||||
zap,
|
zap,
|
||||||
isZapping,
|
isZapping,
|
||||||
invoice,
|
invoice,
|
||||||
setInvoice,
|
setInvoice,
|
||||||
parseNWCUri,
|
parseNWCUri,
|
||||||
zapData,
|
|
||||||
isBatchMode,
|
|
||||||
|
|
||||||
// Helper functions
|
|
||||||
getZapData: (eventId: string) => zapData[eventId] || { count: 0, totalSats: 0, events: [] },
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user