You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
193 lines
6.2 KiB
193 lines
6.2 KiB
import { Button } from '@/components/ui/button' |
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' |
|
import { useMuteList } from '@/contexts/mute-list-context' |
|
import { muteSetHas } from '@/lib/mute-set' |
|
import { useNostr } from '@/providers/NostrProvider' |
|
import { eventService } from '@/services/client.service' |
|
import { NOSTR_URI_FOR_REPLY_PUBKEYS_REGEX } from '@/lib/content-patterns' |
|
import logger from '@/lib/logger' |
|
import { Check } from 'lucide-react' |
|
import { Event, nip19 } from 'nostr-tools' |
|
import { HTMLAttributes, useEffect, useState } from 'react' |
|
import { useTranslation } from 'react-i18next' |
|
import { SimpleUserAvatar } from '../UserAvatar' |
|
import { SimpleUsername } from '../Username' |
|
|
|
export default function Mentions({ |
|
content, |
|
mentions, |
|
setMentions, |
|
parentEvent |
|
}: { |
|
content: string |
|
mentions: string[] |
|
setMentions: (mentions: string[]) => void |
|
parentEvent?: Event |
|
}) { |
|
const { t } = useTranslation() |
|
const { pubkey } = useNostr() |
|
const { mutePubkeySet } = useMuteList() |
|
const [potentialMentions, setPotentialMentions] = useState<string[]>([]) |
|
const [parentEventPubkey, setParentEventPubkey] = useState<string | undefined>() |
|
const [removedPubkeys, setRemovedPubkeys] = useState<string[]>([]) |
|
|
|
useEffect(() => { |
|
extractMentions(content, parentEvent).then(({ pubkeys, relatedPubkeys, parentEventPubkey }) => { |
|
const _parentEventPubkey = parentEventPubkey !== pubkey ? parentEventPubkey : undefined |
|
setParentEventPubkey(_parentEventPubkey) |
|
const potentialMentions = [...pubkeys, ...relatedPubkeys].filter((p) => p !== pubkey) |
|
if (_parentEventPubkey) { |
|
potentialMentions.push(_parentEventPubkey) |
|
} |
|
// Deduplicate the potential mentions array |
|
setPotentialMentions(Array.from(new Set(potentialMentions))) |
|
setRemovedPubkeys((pubkeys) => { |
|
return Array.from( |
|
new Set( |
|
pubkeys |
|
.filter((p) => potentialMentions.includes(p)) |
|
.concat( |
|
potentialMentions.filter((p) => muteSetHas(mutePubkeySet, p) && p !== _parentEventPubkey) |
|
) |
|
) |
|
) |
|
}) |
|
}) |
|
}, [content, parentEvent, pubkey, mutePubkeySet]) |
|
|
|
useEffect(() => { |
|
const newMentions = potentialMentions.filter((pubkey) => !removedPubkeys.includes(pubkey)) |
|
setMentions(newMentions) |
|
}, [potentialMentions, removedPubkeys]) |
|
|
|
return ( |
|
<Popover> |
|
<PopoverTrigger asChild> |
|
<Button |
|
type="button" |
|
className="px-3" |
|
variant="ghost" |
|
title={ |
|
potentialMentions.length === 0 |
|
? t('Mentions') |
|
: `${t('Mentions')} (${mentions.length}/${potentialMentions.length})` |
|
} |
|
disabled={potentialMentions.length === 0} |
|
onClick={(e) => e.stopPropagation()} |
|
> |
|
{t('Mentions')}{' '} |
|
{potentialMentions.length > 0 && `(${mentions.length}/${potentialMentions.length})`} |
|
</Button> |
|
</PopoverTrigger> |
|
<PopoverContent className="w-52 p-0 py-1"> |
|
<div className="space-y-1"> |
|
{potentialMentions.map((_, index) => { |
|
const pubkey = potentialMentions[potentialMentions.length - 1 - index] |
|
const isParentPubkey = pubkey === parentEventPubkey |
|
return ( |
|
<PopoverCheckboxItem |
|
key={`${pubkey}-${index}`} |
|
checked={isParentPubkey ? true : mentions.includes(pubkey)} |
|
onCheckedChange={(checked) => { |
|
if (isParentPubkey) { |
|
return |
|
} |
|
if (checked) { |
|
setRemovedPubkeys((pubkeys) => pubkeys.filter((p) => p !== pubkey)) |
|
} else { |
|
setRemovedPubkeys((pubkeys) => [...pubkeys, pubkey]) |
|
} |
|
}} |
|
disabled={isParentPubkey} |
|
> |
|
<SimpleUserAvatar userId={pubkey} size="small" /> |
|
<SimpleUsername |
|
userId={pubkey} |
|
className="font-semibold text-sm truncate" |
|
skeletonClassName="h-3" |
|
/> |
|
</PopoverCheckboxItem> |
|
) |
|
})} |
|
</div> |
|
</PopoverContent> |
|
</Popover> |
|
) |
|
} |
|
|
|
function PopoverCheckboxItem({ |
|
children, |
|
checked, |
|
onCheckedChange, |
|
disabled, |
|
...props |
|
}: HTMLAttributes<HTMLButtonElement> & { |
|
disabled?: boolean |
|
checked: boolean |
|
onCheckedChange?: (checked: boolean) => void |
|
}) { |
|
return ( |
|
<div className="px-1"> |
|
<Button |
|
variant="ghost" |
|
className="w-full rounded-md justify-start px-2" |
|
onClick={() => onCheckedChange?.(!checked)} |
|
disabled={disabled} |
|
{...props} |
|
> |
|
{checked ? <Check className="shrink-0" /> : <div className="w-4 shrink-0" />} |
|
{children} |
|
</Button> |
|
</div> |
|
) |
|
} |
|
|
|
export async function extractMentions(content: string, parentEvent?: Event) { |
|
const parentEventPubkey = parentEvent ? parentEvent.pubkey : undefined |
|
const pubkeys: string[] = [] |
|
const relatedPubkeys: string[] = [] |
|
|
|
// Always include parent event author in pubkeys if there's a parent event |
|
if (parentEventPubkey) { |
|
pubkeys.push(parentEventPubkey) |
|
} |
|
|
|
const matches = content.match(NOSTR_URI_FOR_REPLY_PUBKEYS_REGEX) |
|
|
|
const addToSet = (arr: string[], pubkey: string) => { |
|
if (!arr.includes(pubkey)) arr.push(pubkey) |
|
} |
|
|
|
for (const m of matches || []) { |
|
try { |
|
const id = m.split(':')[1] |
|
const { type, data } = nip19.decode(id) |
|
if (type === 'nprofile') { |
|
addToSet(pubkeys, data.pubkey) |
|
} else if (type === 'npub') { |
|
addToSet(pubkeys, data) |
|
} else if (['nevent', 'note'].includes(type)) { |
|
const event = await eventService.fetchEvent(id) |
|
if (event) { |
|
addToSet(pubkeys, event.pubkey) |
|
} |
|
} |
|
} catch (e) { |
|
logger.error('Failed to decode mention', { error: e, match: m }) |
|
} |
|
} |
|
|
|
if (parentEvent) { |
|
parentEvent.tags.forEach(([tagName, tagValue]) => { |
|
if (['p', 'P'].includes(tagName) && !!tagValue) { |
|
addToSet(relatedPubkeys, tagValue) |
|
} |
|
}) |
|
} |
|
|
|
return { |
|
pubkeys, |
|
relatedPubkeys: relatedPubkeys.filter((p) => !pubkeys.includes(p)), |
|
parentEventPubkey |
|
} |
|
}
|
|
|