mirror of
https://github.com/dergigi/boris.git
synced 2025-12-17 06:34:24 +01:00
refactor: replace custom NIP-19 parsing with applesauce helpers and add progressive profile resolution
- Replace custom regex patterns with Tokens.nostrLink from applesauce-content - Use getContentPointers() and getPubkeyFromDecodeResult() from applesauce helpers - Add useProfileLabels hook for shared profile resolution logic - Implement progressive profile name updates in markdown articles - Remove unused ContentWithResolvedProfiles component - Simplify useMarkdownToHTML by extracting profile resolution to shared hook - Fix TypeScript and ESLint errors
This commit is contained in:
@@ -1,38 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import { useEventModel } from 'applesauce-react/hooks'
|
|
||||||
import { Models, Helpers } from 'applesauce-core'
|
|
||||||
import { decode } from 'nostr-tools/nip19'
|
|
||||||
import { extractNprofilePubkeys } from '../utils/helpers'
|
|
||||||
|
|
||||||
const { getPubkeyFromDecodeResult } = Helpers
|
|
||||||
|
|
||||||
interface Props { content: string }
|
|
||||||
|
|
||||||
const ContentWithResolvedProfiles: React.FC<Props> = ({ content }) => {
|
|
||||||
const matches = extractNprofilePubkeys(content)
|
|
||||||
const decoded = matches
|
|
||||||
.map((m) => {
|
|
||||||
try { return decode(m) } catch { return undefined as undefined }
|
|
||||||
})
|
|
||||||
.filter((v): v is ReturnType<typeof decode> => Boolean(v))
|
|
||||||
|
|
||||||
const lookups = decoded
|
|
||||||
.map((res) => getPubkeyFromDecodeResult(res))
|
|
||||||
.filter((v): v is string => typeof v === 'string')
|
|
||||||
|
|
||||||
const profiles = lookups.map((pubkey) => ({ pubkey, profile: useEventModel(Models.ProfileModel, [pubkey]) }))
|
|
||||||
|
|
||||||
let rendered = content
|
|
||||||
matches.forEach((m, i) => {
|
|
||||||
const pk = getPubkeyFromDecodeResult(decoded[i])
|
|
||||||
const found = profiles.find((p) => p.pubkey === pk)
|
|
||||||
const name = found?.profile?.name || found?.profile?.display_name || found?.profile?.nip05 || `${pk?.slice(0,8)}...`
|
|
||||||
if (name) rendered = rendered.replace(m, `@${name}`)
|
|
||||||
})
|
|
||||||
|
|
||||||
return <div className="bookmark-content">{rendered}</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ContentWithResolvedProfiles
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
import { useEventModel } from 'applesauce-react/hooks'
|
import { useEventModel } from 'applesauce-react/hooks'
|
||||||
import { Models } from 'applesauce-core'
|
import { Models, Helpers } from 'applesauce-core'
|
||||||
|
|
||||||
|
const { getPubkeyFromDecodeResult } = Helpers
|
||||||
|
|
||||||
interface NostrMentionLinkProps {
|
interface NostrMentionLinkProps {
|
||||||
nostrUri: string
|
nostrUri: string
|
||||||
@@ -20,22 +22,17 @@ const NostrMentionLink: React.FC<NostrMentionLinkProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
// Decode the nostr URI first
|
// Decode the nostr URI first
|
||||||
let decoded: ReturnType<typeof nip19.decode> | null = null
|
let decoded: ReturnType<typeof nip19.decode> | null = null
|
||||||
let pubkey: string | undefined
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const identifier = nostrUri.replace(/^nostr:/, '')
|
const identifier = nostrUri.replace(/^nostr:/, '')
|
||||||
decoded = nip19.decode(identifier)
|
decoded = nip19.decode(identifier)
|
||||||
|
|
||||||
// Extract pubkey for profile fetching (works for npub and nprofile)
|
|
||||||
if (decoded.type === 'npub') {
|
|
||||||
pubkey = decoded.data
|
|
||||||
} else if (decoded.type === 'nprofile') {
|
|
||||||
pubkey = decoded.data.pubkey
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Decoding failed, will fallback to shortened identifier
|
// Decoding failed, will fallback to shortened identifier
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract pubkey for profile fetching using applesauce helper (works for npub and nprofile)
|
||||||
|
const pubkey = decoded ? getPubkeyFromDecodeResult(decoded) : undefined
|
||||||
|
|
||||||
// Fetch profile at top level (Rules of Hooks)
|
// Fetch profile at top level (Rules of Hooks)
|
||||||
const profile = useEventModel(Models.ProfileModel, pubkey ? [pubkey] : null)
|
const profile = useEventModel(Models.ProfileModel, pubkey ? [pubkey] : null)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import NostrMentionLink from './NostrMentionLink'
|
import NostrMentionLink from './NostrMentionLink'
|
||||||
|
import { Tokens } from 'applesauce-content/helpers'
|
||||||
|
|
||||||
interface RichContentProps {
|
interface RichContentProps {
|
||||||
content: string
|
content: string
|
||||||
@@ -19,17 +20,24 @@ const RichContent: React.FC<RichContentProps> = ({
|
|||||||
className = 'bookmark-content'
|
className = 'bookmark-content'
|
||||||
}) => {
|
}) => {
|
||||||
// Pattern to match:
|
// Pattern to match:
|
||||||
// 1. nostr: URIs (nostr:npub1..., nostr:note1..., etc.)
|
// 1. nostr: URIs (nostr:npub1..., nostr:note1..., etc.) using applesauce Tokens.nostrLink
|
||||||
// 2. Plain nostr identifiers (npub1..., nprofile1..., note1..., etc.)
|
// 2. http(s) URLs
|
||||||
// 3. http(s) URLs
|
const nostrPattern = Tokens.nostrLink
|
||||||
const pattern = /(nostr:[a-z0-9]+|npub1[a-z0-9]+|nprofile1[a-z0-9]+|note1[a-z0-9]+|nevent1[a-z0-9]+|naddr1[a-z0-9]+|https?:\/\/[^\s]+)/gi
|
const urlPattern = /https?:\/\/[^\s]+/gi
|
||||||
|
const combinedPattern = new RegExp(`(${nostrPattern.source}|${urlPattern.source})`, 'gi')
|
||||||
|
|
||||||
const parts = content.split(pattern)
|
const parts = content.split(combinedPattern)
|
||||||
|
|
||||||
|
// Helper to check if a string is a nostr identifier (without mutating regex state)
|
||||||
|
const isNostrIdentifier = (str: string): boolean => {
|
||||||
|
const testPattern = new RegExp(nostrPattern.source, nostrPattern.flags)
|
||||||
|
return testPattern.test(str)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
{parts.map((part, index) => {
|
{parts.map((part, index) => {
|
||||||
// Handle nostr: URIs
|
// Handle nostr: URIs - Tokens.nostrLink matches both formats
|
||||||
if (part.startsWith('nostr:')) {
|
if (part.startsWith('nostr:')) {
|
||||||
return (
|
return (
|
||||||
<NostrMentionLink
|
<NostrMentionLink
|
||||||
@@ -39,10 +47,8 @@ const RichContent: React.FC<RichContentProps> = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle plain nostr identifiers (add nostr: prefix)
|
// Handle plain nostr identifiers (Tokens.nostrLink matches these too)
|
||||||
if (
|
if (isNostrIdentifier(part)) {
|
||||||
part.match(/^(npub1|nprofile1|note1|nevent1|naddr1)[a-z0-9]+$/i)
|
|
||||||
) {
|
|
||||||
return (
|
return (
|
||||||
<NostrMentionLink
|
<NostrMentionLink
|
||||||
key={index}
|
key={index}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react'
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { extractNaddrUris, replaceNostrUrisInMarkdown, replaceNostrUrisInMarkdownWithTitles } from '../utils/nostrUriResolver'
|
import { extractNaddrUris, replaceNostrUrisInMarkdownWithProfileLabels } from '../utils/nostrUriResolver'
|
||||||
import { fetchArticleTitles } from '../services/articleTitleResolver'
|
import { fetchArticleTitles } from '../services/articleTitleResolver'
|
||||||
|
import { useProfileLabels } from './useProfileLabels'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to convert markdown to HTML using a hidden ReactMarkdown component
|
* Hook to convert markdown to HTML using a hidden ReactMarkdown component
|
||||||
@@ -18,7 +19,43 @@ export const useMarkdownToHTML = (
|
|||||||
const previewRef = useRef<HTMLDivElement>(null)
|
const previewRef = useRef<HTMLDivElement>(null)
|
||||||
const [renderedHtml, setRenderedHtml] = useState<string>('')
|
const [renderedHtml, setRenderedHtml] = useState<string>('')
|
||||||
const [processedMarkdown, setProcessedMarkdown] = useState<string>('')
|
const [processedMarkdown, setProcessedMarkdown] = useState<string>('')
|
||||||
|
const [articleTitles, setArticleTitles] = useState<Map<string, string>>(new Map())
|
||||||
|
|
||||||
|
// Resolve profile labels progressively as profiles load
|
||||||
|
const profileLabels = useProfileLabels(markdown || '')
|
||||||
|
|
||||||
|
// Fetch article titles
|
||||||
|
useEffect(() => {
|
||||||
|
if (!markdown || !relayPool) {
|
||||||
|
setArticleTitles(new Map())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let isCancelled = false
|
||||||
|
|
||||||
|
const fetchTitles = async () => {
|
||||||
|
const naddrs = extractNaddrUris(markdown)
|
||||||
|
if (naddrs.length === 0) {
|
||||||
|
setArticleTitles(new Map())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const titlesMap = await fetchArticleTitles(relayPool!, naddrs)
|
||||||
|
if (!isCancelled) {
|
||||||
|
setArticleTitles(titlesMap)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to fetch article titles:', error)
|
||||||
|
if (!isCancelled) setArticleTitles(new Map())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchTitles()
|
||||||
|
return () => { isCancelled = true }
|
||||||
|
}, [markdown, relayPool])
|
||||||
|
|
||||||
|
// Process markdown with progressive profile labels and article titles
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Always clear previous render immediately to avoid showing stale content while processing
|
// Always clear previous render immediately to avoid showing stale content while processing
|
||||||
setRenderedHtml('')
|
setRenderedHtml('')
|
||||||
@@ -30,36 +67,18 @@ export const useMarkdownToHTML = (
|
|||||||
|
|
||||||
let isCancelled = false
|
let isCancelled = false
|
||||||
|
|
||||||
const processMarkdown = async () => {
|
const processMarkdown = () => {
|
||||||
// Extract all naddr references
|
// Replace nostr URIs with profile labels (progressive) and article titles
|
||||||
const naddrs = extractNaddrUris(markdown)
|
const processed = replaceNostrUrisInMarkdownWithProfileLabels(
|
||||||
|
markdown,
|
||||||
let processed: string
|
profileLabels,
|
||||||
|
articleTitles
|
||||||
if (naddrs.length > 0 && relayPool) {
|
)
|
||||||
// Fetch article titles for all naddrs
|
|
||||||
try {
|
|
||||||
const articleTitles = await fetchArticleTitles(relayPool, naddrs)
|
|
||||||
|
|
||||||
if (isCancelled) return
|
|
||||||
|
|
||||||
// Replace nostr URIs with resolved titles
|
|
||||||
processed = replaceNostrUrisInMarkdownWithTitles(markdown, articleTitles)
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to fetch article titles:', error)
|
|
||||||
// Fall back to basic replacement
|
|
||||||
processed = replaceNostrUrisInMarkdown(markdown)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No articles to resolve, use basic replacement
|
|
||||||
processed = replaceNostrUrisInMarkdown(markdown)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isCancelled) return
|
if (isCancelled) return
|
||||||
|
|
||||||
setProcessedMarkdown(processed)
|
setProcessedMarkdown(processed)
|
||||||
|
|
||||||
|
|
||||||
const rafId = requestAnimationFrame(() => {
|
const rafId = requestAnimationFrame(() => {
|
||||||
if (previewRef.current && !isCancelled) {
|
if (previewRef.current && !isCancelled) {
|
||||||
const html = previewRef.current.innerHTML
|
const html = previewRef.current.innerHTML
|
||||||
@@ -77,7 +96,7 @@ export const useMarkdownToHTML = (
|
|||||||
return () => {
|
return () => {
|
||||||
isCancelled = true
|
isCancelled = true
|
||||||
}
|
}
|
||||||
}, [markdown, relayPool])
|
}, [markdown, profileLabels, articleTitles])
|
||||||
|
|
||||||
return { renderedHtml, previewRef, processedMarkdown }
|
return { renderedHtml, previewRef, processedMarkdown }
|
||||||
}
|
}
|
||||||
|
|||||||
45
src/hooks/useProfileLabels.ts
Normal file
45
src/hooks/useProfileLabels.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useEventModel } from 'applesauce-react/hooks'
|
||||||
|
import { Models, Helpers } from 'applesauce-core'
|
||||||
|
import { getContentPointers } from 'applesauce-factory/helpers'
|
||||||
|
|
||||||
|
const { getPubkeyFromDecodeResult, encodeDecodeResult } = Helpers
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to resolve profile labels from content containing npub/nprofile identifiers
|
||||||
|
* Returns a Map of encoded identifier -> display name that updates progressively as profiles load
|
||||||
|
*/
|
||||||
|
export function useProfileLabels(content: string): Map<string, string> {
|
||||||
|
// Extract profile pointers (npub and nprofile) using applesauce helpers
|
||||||
|
const profileData = useMemo(() => {
|
||||||
|
const pointers = getContentPointers(content)
|
||||||
|
return pointers
|
||||||
|
.filter(p => p.type === 'npub' || p.type === 'nprofile')
|
||||||
|
.map(pointer => ({
|
||||||
|
pubkey: getPubkeyFromDecodeResult(pointer),
|
||||||
|
encoded: encodeDecodeResult(pointer)
|
||||||
|
}))
|
||||||
|
.filter(p => p.pubkey)
|
||||||
|
}, [content])
|
||||||
|
|
||||||
|
// Fetch profiles for all found pubkeys (progressive loading)
|
||||||
|
const profiles = profileData.map(({ pubkey }) =>
|
||||||
|
useEventModel(Models.ProfileModel, pubkey ? [pubkey] : null)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Build profile labels map that updates reactively as profiles load
|
||||||
|
return useMemo(() => {
|
||||||
|
const labels = new Map<string, string>()
|
||||||
|
profileData.forEach(({ encoded }, index) => {
|
||||||
|
const profile = profiles[index]
|
||||||
|
if (profile) {
|
||||||
|
const displayName = profile.name || profile.display_name || profile.nip05
|
||||||
|
if (displayName) {
|
||||||
|
labels.set(encoded, `@${displayName}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return labels
|
||||||
|
}, [profileData, profiles])
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,13 +1,5 @@
|
|||||||
// Extract pubkeys from nprofile strings in content
|
|
||||||
import { READING_PROGRESS } from '../config/kinds'
|
import { READING_PROGRESS } from '../config/kinds'
|
||||||
|
|
||||||
export const extractNprofilePubkeys = (content: string): string[] => {
|
|
||||||
const nprofileRegex = /nprofile1[a-z0-9]+/gi
|
|
||||||
const matches = content.match(nprofileRegex) || []
|
|
||||||
const unique = new Set<string>(matches)
|
|
||||||
return Array.from(unique)
|
|
||||||
}
|
|
||||||
|
|
||||||
export type UrlType = 'video' | 'image' | 'youtube' | 'article'
|
export type UrlType = 'video' | 'image' | 'youtube' | 'article'
|
||||||
|
|
||||||
export interface UrlClassification {
|
export interface UrlClassification {
|
||||||
|
|||||||
@@ -1,40 +1,33 @@
|
|||||||
import { decode, npubEncode, noteEncode } from 'nostr-tools/nip19'
|
import { decode, npubEncode, noteEncode } from 'nostr-tools/nip19'
|
||||||
import { getNostrUrl } from '../config/nostrGateways'
|
import { getNostrUrl } from '../config/nostrGateways'
|
||||||
|
import { Tokens } from 'applesauce-content/helpers'
|
||||||
|
import { getContentPointers } from 'applesauce-factory/helpers'
|
||||||
|
import { encodeDecodeResult } from 'applesauce-core/helpers'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Regular expression to match nostr: URIs and bare NIP-19 identifiers
|
* Regular expression to match nostr: URIs and bare NIP-19 identifiers
|
||||||
|
* Uses applesauce Tokens.nostrLink which includes word boundary checks
|
||||||
* Matches: nostr:npub1..., nostr:note1..., nostr:nprofile1..., nostr:nevent1..., nostr:naddr1...
|
* Matches: nostr:npub1..., nostr:note1..., nostr:nprofile1..., nostr:nevent1..., nostr:naddr1...
|
||||||
* Also matches bare identifiers without the nostr: prefix
|
* Also matches bare identifiers without the nostr: prefix
|
||||||
*/
|
*/
|
||||||
const NOSTR_URI_REGEX = /(?:nostr:)?((npub|note|nprofile|nevent|naddr)1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58,})/gi
|
const NOSTR_URI_REGEX = Tokens.nostrLink
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract all nostr URIs from text
|
* Extract all nostr URIs from text using applesauce helpers
|
||||||
*/
|
*/
|
||||||
export function extractNostrUris(text: string): string[] {
|
export function extractNostrUris(text: string): string[] {
|
||||||
const matches = text.match(NOSTR_URI_REGEX)
|
const pointers = getContentPointers(text)
|
||||||
if (!matches) return []
|
return pointers.map(pointer => encodeDecodeResult(pointer))
|
||||||
|
|
||||||
// Extract just the NIP-19 identifier (without nostr: prefix)
|
|
||||||
return matches.map(match => {
|
|
||||||
const cleanMatch = match.replace(/^nostr:/, '')
|
|
||||||
return cleanMatch
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract all naddr (article) identifiers from text
|
* Extract all naddr (article) identifiers from text using applesauce helpers
|
||||||
*/
|
*/
|
||||||
export function extractNaddrUris(text: string): string[] {
|
export function extractNaddrUris(text: string): string[] {
|
||||||
const allUris = extractNostrUris(text)
|
const pointers = getContentPointers(text)
|
||||||
return allUris.filter(uri => {
|
return pointers
|
||||||
try {
|
.filter(pointer => pointer.type === 'naddr')
|
||||||
const decoded = decode(uri)
|
.map(pointer => encodeDecodeResult(pointer))
|
||||||
return decoded.type === 'naddr'
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -258,14 +251,52 @@ export function replaceNostrUrisInMarkdownWithTitles(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace nostr: URIs in markdown with proper markdown links, using resolved profile names and article titles
|
||||||
|
* This converts: nostr:npub1... to [@username](link) and nostr:naddr1... to [Article Title](link)
|
||||||
|
* Labels update progressively as profiles load
|
||||||
|
* @param markdown The markdown content to process
|
||||||
|
* @param profileLabels Map of encoded identifier -> display name (e.g., npub1... -> @username)
|
||||||
|
* @param articleTitles Map of naddr -> title for resolved articles
|
||||||
|
*/
|
||||||
|
export function replaceNostrUrisInMarkdownWithProfileLabels(
|
||||||
|
markdown: string,
|
||||||
|
profileLabels: Map<string, string> = new Map(),
|
||||||
|
articleTitles: Map<string, string> = new Map()
|
||||||
|
): string {
|
||||||
|
return replaceNostrUrisSafely(markdown, (encoded) => {
|
||||||
|
const link = createNostrLink(encoded)
|
||||||
|
|
||||||
|
// Check if we have a resolved profile name
|
||||||
|
if (profileLabels.has(encoded)) {
|
||||||
|
const displayName = profileLabels.get(encoded)!
|
||||||
|
return `[${displayName}](${link})`
|
||||||
|
}
|
||||||
|
|
||||||
|
// For articles, use the resolved title if available
|
||||||
|
try {
|
||||||
|
const decoded = decode(encoded)
|
||||||
|
if (decoded.type === 'naddr' && articleTitles.has(encoded)) {
|
||||||
|
const title = articleTitles.get(encoded)!
|
||||||
|
return `[${title}](${link})`
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore decode errors, fall through to default label
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other types or if not resolved, use default label (shortened npub format)
|
||||||
|
const label = getNostrUriLabel(encoded)
|
||||||
|
return `[${label}](${link})`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replace nostr: URIs in HTML with clickable links
|
* Replace nostr: URIs in HTML with clickable links
|
||||||
* This is used when processing HTML content directly
|
* This is used when processing HTML content directly
|
||||||
*/
|
*/
|
||||||
export function replaceNostrUrisInHTML(html: string): string {
|
export function replaceNostrUrisInHTML(html: string): string {
|
||||||
return html.replace(NOSTR_URI_REGEX, (match) => {
|
return html.replace(NOSTR_URI_REGEX, (_match, encoded) => {
|
||||||
// Extract just the NIP-19 identifier (without nostr: prefix)
|
// encoded is already the NIP-19 identifier without nostr: prefix (from capture group)
|
||||||
const encoded = match.replace(/^nostr:/, '')
|
|
||||||
const link = createNostrLink(encoded)
|
const link = createNostrLink(encoded)
|
||||||
const label = getNostrUriLabel(encoded)
|
const label = getNostrUriLabel(encoded)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user