feat: support customizing mentioned users

This commit is contained in:
codytseng
2025-02-26 22:49:43 +08:00
parent c1d469b1f4
commit 3c23a7f9f8
5 changed files with 200 additions and 76 deletions

View File

@@ -1,57 +1,138 @@
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { extractMentions } from '@/lib/event'
import { useNostr } from '@/providers/NostrProvider'
import { Event } from 'nostr-tools'
import { useEffect, useState } from 'react'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
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 [pubkeys, setPubkeys] = useState<string[]>([])
const [relatedPubkeys, setRelatedPubkeys] = useState<string[]>([])
const [parentEventPubkey, setParentEventPubkey] = useState<string | undefined>()
const [addedPubkeys, setAddedPubkeys] = useState<string[]>([])
const [removedPubkeys, setRemovedPubkeys] = useState<string[]>([])
useEffect(() => {
extractMentions(content, parentEvent).then(({ pubkeys }) =>
extractMentions(content, parentEvent).then(({ pubkeys, relatedPubkeys, parentEventPubkey }) => {
setPubkeys(pubkeys.filter((p) => p !== pubkey))
)
setRelatedPubkeys(relatedPubkeys.filter((p) => p !== pubkey))
setParentEventPubkey(parentEventPubkey !== pubkey ? parentEventPubkey : undefined)
const potentialMentions = [...pubkeys, ...relatedPubkeys]
setAddedPubkeys((pubkeys) => {
return pubkeys.filter((p) => potentialMentions.includes(p))
})
setRemovedPubkeys((pubkeys) => {
return pubkeys.filter((p) => potentialMentions.includes(p))
})
})
}, [content, parentEvent, pubkey])
useEffect(() => {
const newMentions = [...pubkeys]
addedPubkeys.forEach((pubkey) => {
if (!newMentions.includes(pubkey) && pubkey !== parentEventPubkey) {
newMentions.push(pubkey)
}
})
removedPubkeys.forEach((pubkey) => {
const index = newMentions.indexOf(pubkey)
if (index !== -1) {
newMentions.splice(index, 1)
}
})
if (parentEventPubkey) {
newMentions.push(parentEventPubkey)
}
setMentions(newMentions)
}, [pubkeys, relatedPubkeys, parentEventPubkey, addedPubkeys, removedPubkeys])
return (
<Popover>
<PopoverTrigger asChild>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="px-3"
variant="ghost"
disabled={pubkeys.length === 0}
disabled={pubkeys.length === 0 && relatedPubkeys.length === 0 && !parentEventPubkey}
onClick={(e) => e.stopPropagation()}
>
{t('Mentions')} {pubkeys.length > 0 && `(${pubkeys.length})`}
{t('Mentions')} {mentions.length > 0 && `(${mentions.length})`}
</Button>
</PopoverTrigger>
<PopoverContent className="w-48">
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48">
<div className="space-y-2">
<div className="text-sm font-semibold">{t('Mentions')}:</div>
{pubkeys.map((pubkey, index) => (
<div key={`${pubkey}-${index}`} className="flex gap-1 items-center">
<UserAvatar userId={pubkey} size="small" />
<Username
<DropdownMenuLabel>{t('Mentions')}:</DropdownMenuLabel>
{parentEventPubkey && (
<DropdownMenuCheckboxItem className="flex gap-1 items-center" checked disabled>
<SimpleUserAvatar userId={parentEventPubkey} size="small" />
<SimpleUsername
userId={parentEventPubkey}
className="font-semibold text-sm truncate"
skeletonClassName="h-3"
/>
</DropdownMenuCheckboxItem>
)}
{(pubkeys.length > 0 || relatedPubkeys.length > 0) && <DropdownMenuSeparator />}
{pubkeys.concat(relatedPubkeys).map((pubkey, index) => (
<DropdownMenuCheckboxItem
key={`${pubkey}-${index}`}
className="flex gap-1 items-center cursor-pointer"
checked={mentions.includes(pubkey)}
onCheckedChange={(checked) => {
if (checked) {
setAddedPubkeys((pubkeys) => [...pubkeys, pubkey])
setRemovedPubkeys((pubkeys) => pubkeys.filter((p) => p !== pubkey))
} else {
setRemovedPubkeys((pubkeys) => [...pubkeys, pubkey])
setAddedPubkeys((pubkeys) => pubkeys.filter((p) => p !== pubkey))
}
}}
>
<SimpleUserAvatar userId={pubkey} size="small" />
<SimpleUsername
userId={pubkey}
className="font-semibold text-sm truncate"
skeletonClassName="h-3"
/>
</div>
</DropdownMenuCheckboxItem>
))}
{(relatedPubkeys.length > 0 || pubkeys.length > 0) && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
setAddedPubkeys([...relatedPubkeys])
setRemovedPubkeys([])
}}
>
{t('Select all')}
</DropdownMenuItem>
</>
)}
</div>
</PopoverContent>
</Popover>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -34,6 +34,7 @@ export default function NormalPostContent({
const [addClientTag, setAddClientTag] = useState(false)
const [specifiedRelayUrls, setSpecifiedRelayUrls] = useState<string[] | undefined>(undefined)
const [uploadingPicture, setUploadingPicture] = useState(false)
const [mentions, setMentions] = useState<string[]>([])
const canPost = !!content && !posting
const post = async (e: React.MouseEvent) => {
@@ -79,11 +80,11 @@ export default function NormalPostContent({
}
const draftEvent =
parentEvent && parentEvent.kind !== kinds.ShortTextNote
? await createCommentDraftEvent(content, parentEvent, pictureInfos, {
? await createCommentDraftEvent(content, parentEvent, pictureInfos, mentions, {
addClientTag,
protectedEvent: !!specifiedRelayUrls
})
: await createShortTextNoteDraftEvent(content, pictureInfos, {
: await createShortTextNoteDraftEvent(content, pictureInfos, mentions, {
parentEvent,
addClientTag,
protectedEvent: !!specifiedRelayUrls
@@ -162,7 +163,12 @@ export default function NormalPostContent({
</Button>
</div>
<div className="flex gap-2 items-center">
<Mentions content={content} parentEvent={parentEvent} />
<Mentions
content={content}
parentEvent={parentEvent}
mentions={mentions}
setMentions={setMentions}
/>
<div className="flex gap-2 items-center max-sm:hidden">
<Button
variant="secondary"

View File

@@ -22,6 +22,7 @@ export default function PicturePostContent({ close }: { close: () => void }) {
const [posting, setPosting] = useState(false)
const [showMoreOptions, setShowMoreOptions] = useState(false)
const [addClientTag, setAddClientTag] = useState(false)
const [mentions, setMentions] = useState<string[]>([])
const [specifiedRelayUrls, setSpecifiedRelayUrls] = useState<string[] | undefined>(undefined)
const canPost = !!content && !posting && pictureInfos.length > 0
@@ -38,7 +39,7 @@ export default function PicturePostContent({ close }: { close: () => void }) {
if (!pictureInfos.length) {
throw new Error(t('Picture note requires images'))
}
const draftEvent = await createPictureNoteDraftEvent(content, pictureInfos, {
const draftEvent = await createPictureNoteDraftEvent(content, pictureInfos, mentions, {
addClientTag,
protectedEvent: !!specifiedRelayUrls
})
@@ -99,7 +100,7 @@ export default function PicturePostContent({ close }: { close: () => void }) {
<ChevronDown className={`transition-transform ${showMoreOptions ? 'rotate-180' : ''}`} />
</Button>
<div className="flex gap-2 items-center">
<Mentions content={content} />
<Mentions content={content} mentions={mentions} setMentions={setMentions} />
<div className="flex gap-2 items-center max-sm:hidden">
<Button
variant="secondary"

View File

@@ -7,7 +7,7 @@ import {
extractCommentMentions,
extractHashtags,
extractImagesFromContent,
extractMentions,
extractRelatedEventIds,
getEventCoordinate,
isProtectedEvent,
isReplaceable
@@ -54,21 +54,29 @@ export function createRepostDraftEvent(event: Event): TDraftEvent {
export async function createShortTextNoteDraftEvent(
content: string,
pictureInfos: { url: string; tags: string[][] }[],
mentions: string[],
options: {
parentEvent?: Event
addClientTag?: boolean
protectedEvent?: boolean
} = {}
): Promise<TDraftEvent> {
const { pubkeys, otherRelatedEventIds, quoteEventIds, rootEventId, parentEventId } =
await extractMentions(content, options.parentEvent)
const { otherRelatedEventIds, quoteEventIds, rootEventId, parentEventId } =
await extractRelatedEventIds(content, options.parentEvent)
const hashtags = extractHashtags(content)
const tags = pubkeys
.map((pubkey) => ['p', pubkey])
.concat(quoteEventIds.map((eventId) => ['q', eventId, client.getEventHint(eventId)]))
.concat(hashtags.map((hashtag) => ['t', hashtag]))
const tags = hashtags.map((hashtag) => ['t', hashtag])
// imeta tags
const { images } = extractImagesFromContent(content)
if (images && images.length) {
tags.push(...generateImetaTags(images, pictureInfos))
}
// q tags
tags.push(...quoteEventIds.map((eventId) => ['q', eventId, client.getEventHint(eventId)]))
// e tags
if (rootEventId) {
tags.push(['e', rootEventId, client.getEventHint(rootEventId), 'root'])
}
@@ -79,10 +87,8 @@ export async function createShortTextNoteDraftEvent(
tags.push(['e', parentEventId, client.getEventHint(parentEventId), 'reply'])
}
const { images } = extractImagesFromContent(content)
if (images && images.length) {
tags.push(...generateImetaTags(images, pictureInfos))
}
// p tags
tags.push(...mentions.map((pubkey) => ['p', pubkey]))
if (options.addClientTag) {
tags.push(['client', 'jumble'])
@@ -117,12 +123,13 @@ export function createRelaySetDraftEvent(relaySet: TRelaySet): TDraftEvent {
export async function createPictureNoteDraftEvent(
content: string,
pictureInfos: { url: string; tags: string[][] }[],
mentions: string[],
options: {
addClientTag?: boolean
protectedEvent?: boolean
} = {}
): Promise<TDraftEvent> {
const { pubkeys, quoteEventIds } = await extractMentions(content)
const { quoteEventIds } = await extractRelatedEventIds(content)
const hashtags = extractHashtags(content)
if (!pictureInfos.length) {
throw new Error('No images found in content')
@@ -130,9 +137,9 @@ export async function createPictureNoteDraftEvent(
const tags = pictureInfos
.map((info) => ['imeta', ...info.tags.map(([n, v]) => `${n} ${v}`)])
.concat(pubkeys.map((pubkey) => ['p', pubkey]))
.concat(quoteEventIds.map((eventId) => ['q', eventId, client.getEventHint(eventId)]))
.concat(hashtags.map((hashtag) => ['t', hashtag]))
.concat(quoteEventIds.map((eventId) => ['q', eventId, client.getEventHint(eventId)]))
.concat(mentions.map((pubkey) => ['p', pubkey]))
if (options.addClientTag) {
tags.push(['client', 'jumble'])
@@ -154,13 +161,13 @@ export async function createCommentDraftEvent(
content: string,
parentEvent: Event,
pictureInfos: { url: string; tags: string[][] }[],
mentions: string[],
options: {
addClientTag?: boolean
protectedEvent?: boolean
} = {}
): Promise<TDraftEvent> {
const {
pubkeys,
quoteEventIds,
rootEventId,
rootEventKind,
@@ -171,23 +178,29 @@ export async function createCommentDraftEvent(
} = await extractCommentMentions(content, parentEvent)
const hashtags = extractHashtags(content)
const tags = [
['E', rootEventId, client.getEventHint(rootEventId), rootEventPubkey],
['K', rootEventKind.toString()],
['P', rootEventPubkey],
['e', parentEventId, client.getEventHint(parentEventId), parentEventPubkey],
['k', parentEventKind.toString()],
['p', parentEventPubkey]
]
.concat(pubkeys.map((pubkey) => ['p', pubkey]))
const tags = hashtags
.map((hashtag) => ['t', hashtag])
.concat(quoteEventIds.map((eventId) => ['q', eventId, client.getEventHint(eventId)]))
.concat(hashtags.map((hashtag) => ['t', hashtag]))
const { images } = extractImagesFromContent(content)
if (images && images.length) {
tags.push(...generateImetaTags(images, pictureInfos))
}
tags.push(
...mentions.filter((pubkey) => pubkey !== parentEventPubkey).map((pubkey) => ['p', pubkey])
)
tags.push(
...[
['E', rootEventId, client.getEventHint(rootEventId), rootEventPubkey],
['K', rootEventKind.toString()],
['P', rootEventPubkey],
['e', parentEventId, client.getEventHint(parentEventId), parentEventPubkey],
['k', parentEventKind.toString()],
['p', parentEventPubkey]
]
)
if (options.addClientTag) {
tags.push(['client', 'jumble'])
}

View File

@@ -171,11 +171,9 @@ export function getProfileFromProfileEvent(event: Event) {
}
export async function extractMentions(content: string, parentEvent?: Event) {
let parentEventPubkey: string | undefined
const pubkeySet = new Set<string>()
const relatedEventIdSet = new Set<string>()
const quoteEventIdSet = new Set<string>()
let rootEventId: string | undefined
let parentEventId: string | undefined
const relatedPubkeySet = new Set<string>()
const matches = content.match(
/nostr:(npub1[a-z0-9]{58}|nprofile1[a-z0-9]+|note1[a-z0-9]{58}|nevent1[a-z0-9]+)/g
)
@@ -192,7 +190,6 @@ export async function extractMentions(content: string, parentEvent?: Event) {
const event = await client.fetchEvent(id)
if (event) {
pubkeySet.add(event.pubkey)
quoteEventIdSet.add(event.id)
}
}
} catch (e) {
@@ -200,13 +197,52 @@ export async function extractMentions(content: string, parentEvent?: Event) {
}
}
if (parentEvent) {
parentEventPubkey = parentEvent.pubkey
parentEvent.tags.forEach(([tagName, tagValue]) => {
if (['p', 'P'].includes(tagName) && !!tagValue) {
relatedPubkeySet.add(tagValue)
}
})
}
if (parentEventPubkey) {
pubkeySet.delete(parentEventPubkey)
relatedPubkeySet.delete(parentEventPubkey)
}
return {
pubkeys: Array.from(pubkeySet),
relatedPubkeys: Array.from(relatedPubkeySet).filter((p) => !pubkeySet.has(p)),
parentEventPubkey
}
}
export async function extractRelatedEventIds(content: string, parentEvent?: Event) {
const relatedEventIdSet = new Set<string>()
const quoteEventIdSet = new Set<string>()
let rootEventId: string | undefined
let parentEventId: string | undefined
const matches = content.match(/nostr:(note1[a-z0-9]{58}|nevent1[a-z0-9]+)/g)
for (const m of matches || []) {
try {
const id = m.split(':')[1]
const { type, data } = nip19.decode(id)
if (type === 'nevent') {
quoteEventIdSet.add(data.id)
} else if (type === 'note') {
quoteEventIdSet.add(data)
}
} catch (e) {
console.error(e)
}
}
if (parentEvent) {
relatedEventIdSet.add(parentEvent.id)
pubkeySet.add(parentEvent.pubkey)
parentEvent.tags.forEach((tag) => {
if (tagNameEquals('p')(tag)) {
pubkeySet.add(tag[1])
} else if (isRootETag(tag)) {
if (isRootETag(tag)) {
rootEventId = tag[1]
} else if (tagNameEquals('e')(tag)) {
relatedEventIdSet.add(tag[1])
@@ -223,7 +259,6 @@ export async function extractMentions(content: string, parentEvent?: Event) {
if (parentEventId) relatedEventIdSet.delete(parentEventId)
return {
pubkeys: Array.from(pubkeySet),
otherRelatedEventIds: Array.from(relatedEventIdSet),
quoteEventIds: Array.from(quoteEventIdSet),
rootEventId,
@@ -232,7 +267,6 @@ export async function extractMentions(content: string, parentEvent?: Event) {
}
export async function extractCommentMentions(content: string, parentEvent: Event) {
const pubkeySet = new Set<string>()
const quoteEventIdSet = new Set<string>()
const rootEventId = parentEvent.tags.find(tagNameEquals('E'))?.[1] ?? parentEvent.id
const rootEventKind = parentEvent.tags.find(tagNameEquals('K'))?.[1] ?? parentEvent.kind
@@ -241,34 +275,23 @@ export async function extractCommentMentions(content: string, parentEvent: Event
const parentEventKind = parentEvent.kind
const parentEventPubkey = parentEvent.pubkey
const matches = content.match(
/nostr:(npub1[a-z0-9]{58}|nprofile1[a-z0-9]+|note1[a-z0-9]{58}|nevent1[a-z0-9]+)/g
)
const matches = content.match(/nostr:(note1[a-z0-9]{58}|nevent1[a-z0-9]+)/g)
for (const m of matches || []) {
try {
const id = m.split(':')[1]
const { type, data } = nip19.decode(id)
if (type === 'nprofile') {
pubkeySet.add(data.pubkey)
} else if (type === 'npub') {
pubkeySet.add(data)
} else if (['nevent', 'note', 'naddr'].includes(type)) {
const event = await client.fetchEvent(id)
if (event) {
pubkeySet.add(event.pubkey)
quoteEventIdSet.add(event.id)
}
if (type === 'nevent') {
quoteEventIdSet.add(data.id)
} else if (type === 'note') {
quoteEventIdSet.add(data)
}
} catch (e) {
console.error(e)
}
}
pubkeySet.add(parentEvent.pubkey)
return {
pubkeys: Array.from(pubkeySet),
quoteEventIds: Array.from(quoteEventIdSet),
rootEventId,
rootEventKind,