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 ? (
-
{
- if (currentSrc && isHttpOrHttpsUrl(currentSrc)) loadedAvatarUrls.add(currentSrc)
- }}
- onError={handleImageError}
- />
+
+ {!imgError && currentSrc ? (
+ isVideoAvatar ? (
+
{
+ if (currentSrc && isHttpOrHttpsUrl(currentSrc)) loadedAvatarUrls.add(currentSrc)
+ }}
+ onError={handleImageError}
+ />
+ ) : (
+
+ )
) : (
-
- )
- ) : (
- // Show initials or placeholder when image fails
-
- {(displayPubkey || userId).slice(0, 2).toUpperCase()}
-
- )}
+ // Show initials or placeholder when image fails
+
+ {(displayPubkey || userId).slice(0, 2).toUpperCase()}
+
+ )}
+
+ {profile?.isBot ? : null}
)
}
\ 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 = {