From 020f685f14f196a2a090ca0e4ebe5dd6146f4bc5 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 13 May 2026 09:59:17 +0200 Subject: [PATCH] support bot tag --- src/components/Profile/index.tsx | 59 ++++--- src/components/ProfileBotBadge/index.tsx | 52 ++++++ src/components/UserAvatar/index.tsx | 163 ++++++++++-------- src/lib/event-metadata.ts | 23 +++ .../secondary/ProfileEditorPage/index.tsx | 26 ++- src/types/index.d.ts | 2 + 6 files changed, 231 insertions(+), 94 deletions(-) create mode 100644 src/components/ProfileBotBadge/index.tsx diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index ecf4b5ac..65659a81 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -4,6 +4,7 @@ import Nip05List from '@/components/Nip05List' import NpubQrCode from '@/components/NpubQrCode' import ProfileAbout from '@/components/ProfileAbout' import ProfileBanner from '@/components/ProfileBanner' +import { ProfileBotBadge } from '@/components/ProfileBotBadge' import ProfileOptions from '@/components/ProfileOptions' import ProfileZapButton from '@/components/ProfileZapButton' import PubkeyCopy from '@/components/PubkeyCopy' @@ -453,7 +454,7 @@ export default function Profile({ if (!profile) return null // TypeScript guard - should never reach here but satisfies type checker - const { banner, username, about, avatar, pubkey, website, websiteList, nip05List } = profile + const { banner, username, about, avatar, pubkey, website, websiteList, nip05List, isBot } = profile return ( <> @@ -467,29 +468,43 @@ export default function Profile({ imageFetchPriority="low" /> {isVideo(avatar ?? '') ? ( -
-
diff --git a/src/components/ProfileBotBadge/index.tsx b/src/components/ProfileBotBadge/index.tsx new file mode 100644 index 00000000..9c548066 --- /dev/null +++ b/src/components/ProfileBotBadge/index.tsx @@ -0,0 +1,52 @@ +import { cn } from '@/lib/utils' + +const SIZE_CLASS = { + sm: 'h-3 w-3 min-h-[10px] min-w-[10px]', + md: 'h-4 w-4 min-h-3 min-w-3 max-h-5 max-w-5', + lg: 'h-7 w-7 min-h-5 min-w-5 max-h-9 max-w-9' +} as const + +/** + * Small line-art robot badge for kind-0 profiles marked with a `bot` tag + * (see {@link profileIsBotFromKind0Tags}). + */ +export function ProfileBotBadge({ + size = 'md', + className +}: { + size?: keyof typeof SIZE_CLASS + className?: string +}) { + return ( + + + + + + + + + + + + + ) +} diff --git a/src/components/UserAvatar/index.tsx b/src/components/UserAvatar/index.tsx index a2472e9e..e46216c6 100644 --- a/src/components/UserAvatar/index.tsx +++ b/src/components/UserAvatar/index.tsx @@ -1,3 +1,4 @@ +import { ProfileBotBadge } from '@/components/ProfileBotBadge' import { Skeleton } from '@/components/ui/skeleton' import { useFetchProfile } from '@/hooks' import { toNostrBuildThumbUrl } from '@/lib/nostr-build' @@ -204,6 +205,21 @@ const UserAvatarSizeCnMap = { tiny: 'w-4 h-4' } +function botBadgeSizeForAvatar( + size: keyof typeof UserAvatarSizeCnMap +): 'sm' | 'md' | 'lg' { + switch (size) { + case 'tiny': + case 'xSmall': + return 'sm' + case 'large': + case 'big': + return 'lg' + default: + return 'md' + } +} + export default function UserAvatar({ userId, className, @@ -314,11 +330,14 @@ export default function UserAvatar({ // Render image directly instead of using Radix UI Avatar for better reliability return ( -
{ e.stopPropagation() if (!profileNavTarget) return @@ -326,39 +345,42 @@ export default function UserAvatar({ navigateToProfile(toProfile(profileNavTarget)) }} > - {!imgError && currentSrc ? ( - isVideoAvatar ? ( -
) } @@ -457,43 +479,46 @@ export function SimpleUserAvatar({ // Render image directly instead of using Radix UI Avatar for better reliability return ( -
- {!imgError && currentSrc ? ( - isVideoAvatar ? ( -
) } \ No newline at end of file diff --git a/src/lib/event-metadata.ts b/src/lib/event-metadata.ts index 2ea77eef..dfe8e76b 100644 --- a/src/lib/event-metadata.ts +++ b/src/lib/event-metadata.ts @@ -149,6 +149,28 @@ function nip05ListFromJson(raw: unknown): string[] | undefined { return out.length > 0 ? out : undefined } +/** + * Kind-0 metadata: profile is marked as a bot when there is `["bot"]` or `["bot","true"]` + * (case-insensitive tag name and value) and no `["bot","false"]` tag. + */ +export function profileIsBotFromKind0Tags(tags: string[][]): boolean { + let hasFalse = false + let hasAffirmative = false + for (const raw of tags) { + if (!Array.isArray(raw) || !raw.length) continue + if (String(raw[0]).toLowerCase() !== 'bot') continue + if (raw.length === 1) { + hasAffirmative = true + continue + } + const v = String(raw[1] ?? '').toLowerCase() + if (v === 'false') hasFalse = true + else if (v === 'true') hasAffirmative = true + } + if (hasFalse) return false + return hasAffirmative +} + export function getProfileFromEvent(event: Event) { // Parse JSON content as fallback let profileObj: any = {} @@ -223,6 +245,7 @@ export function getProfileFromEvent(event: Event) { banner: profileObj.banner, avatar: avatarUrl, pictureSize, + isBot: event.kind === 0 ? profileIsBotFromKind0Tags(event.tags as string[][]) : undefined, username: username || formatPubkey(event.pubkey), original_username: username, nip05, diff --git a/src/pages/secondary/ProfileEditorPage/index.tsx b/src/pages/secondary/ProfileEditorPage/index.tsx index 865011bb..b94c49cc 100644 --- a/src/pages/secondary/ProfileEditorPage/index.tsx +++ b/src/pages/secondary/ProfileEditorPage/index.tsx @@ -366,8 +366,24 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { try { // Strip empty/incomplete rows, trim whitespace. const validTags = profileTags - .filter((t) => Array.isArray(t) && t.length >= 2 && (t[0] ?? '').trim() && (t[1] ?? '').trim()) - .map((t) => [t[0].trim(), t[1].trim(), ...t.slice(2)]) + .filter((t) => { + if (!Array.isArray(t) || !(t[0] ?? '').trim()) return false + const name = (t[0] ?? '').trim() + if (name === 'bot') return true + return t.length >= 2 && (t[1] ?? '').trim() + }) + .map((t) => { + const name = (t[0] ?? '').trim() + const v1 = (t[1] ?? '').trim() + if (name === 'bot') { + if (t.length === 1 || !v1) return ['bot'] + const low = v1.toLowerCase() + if (low === 'false') return ['bot', 'false'] + if (low === 'true') return ['bot', 'true'] + return ['bot', v1] + } + return [name, v1, ...t.slice(2)] + }) // Sort alphabetically by tag name (stable: same-name tags keep their relative order). const sortedTags = [...validTags] @@ -388,6 +404,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { const seenContent = new Set() for (const tag of sortedTags) { const name = tag[0] + if (name === 'bot') continue if (DISPLAY_ORDER.includes(name) && !seenContent.has(name)) { content[name] = tag[1] seenContent.add(name) @@ -653,7 +670,10 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { variant="outline" size="icon" className="h-8 w-8 shrink-0" - onClick={() => addTag(tagToAdd === '__custom__' ? '' : tagToAdd)} + onClick={() => { + const name = tagToAdd === '__custom__' ? '' : tagToAdd + addTag(name, name === 'bot' ? 'true' : '') + }} aria-label={t('Add tag')} > diff --git a/src/types/index.d.ts b/src/types/index.d.ts index 035ba92a..a918ec8d 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -39,6 +39,8 @@ export type TProfile = { lightningAddress?: string lightningAddressList?: string[] created_at?: number + /** Kind 0: `bot` / `bot,true` tags without `bot,false` — see Nostr profile conventions. */ + isBot?: boolean } export type TPaymentInfo = {