Browse Source

support bot tag

imwald
Silberengel 1 month ago
parent
commit
020f685f14
  1. 59
      src/components/Profile/index.tsx
  2. 52
      src/components/ProfileBotBadge/index.tsx
  3. 163
      src/components/UserAvatar/index.tsx
  4. 23
      src/lib/event-metadata.ts
  5. 26
      src/pages/secondary/ProfileEditorPage/index.tsx
  6. 2
      src/types/index.d.ts

59
src/components/Profile/index.tsx

@ -4,6 +4,7 @@ import Nip05List from '@/components/Nip05List'
import NpubQrCode from '@/components/NpubQrCode' import NpubQrCode from '@/components/NpubQrCode'
import ProfileAbout from '@/components/ProfileAbout' import ProfileAbout from '@/components/ProfileAbout'
import ProfileBanner from '@/components/ProfileBanner' import ProfileBanner from '@/components/ProfileBanner'
import { ProfileBotBadge } from '@/components/ProfileBotBadge'
import ProfileOptions from '@/components/ProfileOptions' import ProfileOptions from '@/components/ProfileOptions'
import ProfileZapButton from '@/components/ProfileZapButton' import ProfileZapButton from '@/components/ProfileZapButton'
import PubkeyCopy from '@/components/PubkeyCopy' 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 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 ( return (
<> <>
@ -467,29 +468,43 @@ export default function Profile({
imageFetchPriority="low" imageFetchPriority="low"
/> />
{isVideo(avatar ?? '') ? ( {isVideo(avatar ?? '') ? (
<div className="absolute bottom-0 left-3 z-20 h-24 w-24 translate-y-1/2 overflow-hidden rounded-full border-4 border-background bg-muted md:h-48 md:w-48"> <div className="absolute bottom-0 left-3 z-20 h-24 w-24 translate-y-1/2 md:h-48 md:w-48">
<video <div className="relative h-full w-full">
src={avatar} <div className="h-full w-full overflow-hidden rounded-full border-4 border-background bg-muted">
className="h-full w-full object-cover object-center" <video
autoPlay src={avatar}
muted className="h-full w-full object-cover object-center"
loop autoPlay
playsInline muted
fetchPriority="high" loop
/> playsInline
fetchPriority="high"
/>
</div>
{isBot ? (
<ProfileBotBadge size="lg" className="bottom-1 right-1 md:bottom-2 md:right-2" />
) : null}
</div>
</div> </div>
) : ( ) : (
<Avatar className="absolute bottom-0 left-3 z-20 h-24 w-24 translate-y-1/2 border-4 border-background md:h-48 md:w-48"> <div className="absolute bottom-0 left-3 z-20 h-24 w-24 translate-y-1/2 md:h-48 md:w-48">
<AvatarImage <div className="relative h-full w-full">
src={avatar} <Avatar className="h-full w-full border-4 border-background">
className="object-cover object-center" <AvatarImage
fetchPriority="high" src={avatar}
loading="eager" className="object-cover object-center"
/> fetchPriority="high"
<AvatarFallback> loading="eager"
<img src={defaultImage} alt="" /> />
</AvatarFallback> <AvatarFallback>
</Avatar> <img src={defaultImage} alt="" />
</AvatarFallback>
</Avatar>
{isBot ? (
<ProfileBotBadge size="lg" className="bottom-1 right-1 md:bottom-2 md:right-2" />
) : null}
</div>
</div>
)} )}
</div> </div>
<div className="px-4"> <div className="px-4">

52
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 (
<span
role="img"
aria-label="Bot"
title="Bot"
className={cn(
'pointer-events-none absolute bottom-0 right-0 z-[25] flex items-center justify-center rounded-full bg-background/95 p-[1px] text-muted-foreground shadow-sm ring-1 ring-border',
SIZE_CLASS[size],
className
)}
>
<svg
viewBox="0 0 24 24"
className="h-full w-full shrink-0"
fill="none"
stroke="currentColor"
strokeWidth="1.35"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M12 2.5v2" />
<circle cx="12" cy="2" r="0.85" fill="currentColor" stroke="none" />
<rect x="6" y="5" width="12" height="10" rx="2" />
<circle cx="9.5" cy="10" r="0.95" fill="currentColor" stroke="none" />
<circle cx="14.5" cy="10" r="0.95" fill="currentColor" stroke="none" />
<path d="M9 14.5h6" />
<path d="M10 18v2.5M14 18v2.5" />
<path d="M8 20.5h8" />
</svg>
</span>
)
}

163
src/components/UserAvatar/index.tsx

@ -1,3 +1,4 @@
import { ProfileBotBadge } from '@/components/ProfileBotBadge'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { useFetchProfile } from '@/hooks' import { useFetchProfile } from '@/hooks'
import { toNostrBuildThumbUrl } from '@/lib/nostr-build' import { toNostrBuildThumbUrl } from '@/lib/nostr-build'
@ -204,6 +205,21 @@ const UserAvatarSizeCnMap = {
tiny: 'w-4 h-4' 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({ export default function UserAvatar({
userId, userId,
className, className,
@ -314,11 +330,14 @@ export default function UserAvatar({
// Render image directly instead of using Radix UI Avatar for better reliability // Render image directly instead of using Radix UI Avatar for better reliability
return ( return (
<div <div
ref={containerRef} ref={containerRef}
data-user-avatar data-user-avatar
className={cn('shrink-0 cursor-pointer block overflow-hidden rounded-full bg-muted', UserAvatarSizeCnMap[size], className)} className={cn(
style={{ position: 'relative', zIndex: 10, isolation: 'isolate', display: 'block' }} 'relative isolate z-10 block shrink-0 cursor-pointer rounded-full bg-muted',
UserAvatarSizeCnMap[size],
className
)}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
if (!profileNavTarget) return if (!profileNavTarget) return
@ -326,39 +345,42 @@ export default function UserAvatar({
navigateToProfile(toProfile(profileNavTarget)) navigateToProfile(toProfile(profileNavTarget))
}} }}
> >
{!imgError && currentSrc ? ( <div className="h-full w-full overflow-hidden rounded-full">
isVideoAvatar ? ( {!imgError && currentSrc ? (
<video isVideoAvatar ? (
src={currentSrc} <video
className="block w-full h-full object-cover object-center" src={currentSrc}
autoPlay className="block h-full w-full object-cover object-center"
muted autoPlay
loop muted
playsInline loop
onCanPlay={() => { playsInline
if (currentSrc && isHttpOrHttpsUrl(currentSrc)) loadedAvatarUrls.add(currentSrc) onCanPlay={() => {
}} if (currentSrc && isHttpOrHttpsUrl(currentSrc)) loadedAvatarUrls.add(currentSrc)
onError={handleImageError} }}
/> onError={handleImageError}
/>
) : (
<img
src={currentSrc}
alt=""
className="block h-full w-full object-cover object-center"
style={{ display: 'block', position: 'static', margin: 0, padding: 0, top: 0, left: 0, right: 0, bottom: 0 }}
onError={handleImageError}
onLoad={handleImageLoad}
loading={isHttpOrHttpsUrl(currentSrc) ? 'eager' : 'lazy'}
decoding="async"
fetchpriority={isHttpOrHttpsUrl(currentSrc) ? 'high' : undefined}
/>
)
) : ( ) : (
<img // Show initials or placeholder when image fails
src={currentSrc} <div className="flex h-full w-full items-center justify-center text-xs font-medium text-muted-foreground">
alt="" {(displayPubkey || userId).slice(0, 2).toUpperCase()}
className="block w-full h-full object-cover object-center" </div>
style={{ display: 'block', position: 'static', margin: 0, padding: 0, top: 0, left: 0, right: 0, bottom: 0 }} )}
onError={handleImageError} </div>
onLoad={handleImageLoad} {profile?.isBot ? <ProfileBotBadge size={botBadgeSizeForAvatar(size)} /> : null}
loading={isHttpOrHttpsUrl(currentSrc) ? 'eager' : 'lazy'}
decoding="async"
fetchpriority={isHttpOrHttpsUrl(currentSrc) ? 'high' : undefined}
/>
)
) : (
// Show initials or placeholder when image fails
<div className="h-full w-full flex items-center justify-center text-xs font-medium text-muted-foreground">
{(displayPubkey || userId).slice(0, 2).toUpperCase()}
</div>
)}
</div> </div>
) )
} }
@ -457,43 +479,46 @@ export function SimpleUserAvatar({
// Render image directly instead of using Radix UI Avatar for better reliability // Render image directly instead of using Radix UI Avatar for better reliability
return ( return (
<div <div
ref={containerRef} ref={containerRef}
className={cn('shrink-0 relative overflow-hidden rounded-full bg-muted', UserAvatarSizeCnMap[size], className)} className={cn('relative shrink-0 rounded-full bg-muted', UserAvatarSizeCnMap[size], className)}
> >
{!imgError && currentSrc ? ( <div className="h-full w-full overflow-hidden rounded-full">
isVideoAvatar ? ( {!imgError && currentSrc ? (
<video isVideoAvatar ? (
src={currentSrc} <video
className="block w-full h-full object-cover object-center" src={currentSrc}
autoPlay className="block h-full w-full object-cover object-center"
muted autoPlay
loop muted
playsInline loop
onCanPlay={() => { playsInline
if (currentSrc && isHttpOrHttpsUrl(currentSrc)) loadedAvatarUrls.add(currentSrc) onCanPlay={() => {
}} if (currentSrc && isHttpOrHttpsUrl(currentSrc)) loadedAvatarUrls.add(currentSrc)
onError={handleImageError} }}
/> onError={handleImageError}
/>
) : (
<img
src={currentSrc}
alt=""
className="block h-full w-full object-cover object-center"
style={{ display: 'block', position: 'static', margin: 0, padding: 0, top: 0, left: 0, right: 0, bottom: 0 }}
onError={handleImageError}
onLoad={handleImageLoad}
loading={isHttpOrHttpsUrl(currentSrc) ? 'eager' : 'lazy'}
decoding="async"
fetchpriority={isHttpOrHttpsUrl(currentSrc) ? 'high' : undefined}
/>
)
) : ( ) : (
<img // Show initials or placeholder when image fails
src={currentSrc} <div className="flex h-full w-full items-center justify-center text-xs font-medium text-muted-foreground">
alt="" {(displayPubkey || userId).slice(0, 2).toUpperCase()}
className="block w-full h-full object-cover object-center" </div>
style={{ display: 'block', position: 'static', margin: 0, padding: 0, top: 0, left: 0, right: 0, bottom: 0 }} )}
onError={handleImageError} </div>
onLoad={handleImageLoad} {profile?.isBot ? <ProfileBotBadge size={botBadgeSizeForAvatar(size)} /> : null}
loading={isHttpOrHttpsUrl(currentSrc) ? 'eager' : 'lazy'}
decoding="async"
fetchpriority={isHttpOrHttpsUrl(currentSrc) ? 'high' : undefined}
/>
)
) : (
// Show initials or placeholder when image fails
<div className="h-full w-full flex items-center justify-center text-xs font-medium text-muted-foreground">
{(displayPubkey || userId).slice(0, 2).toUpperCase()}
</div>
)}
</div> </div>
) )
} }

23
src/lib/event-metadata.ts

@ -149,6 +149,28 @@ function nip05ListFromJson(raw: unknown): string[] | undefined {
return out.length > 0 ? out : 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) { export function getProfileFromEvent(event: Event) {
// Parse JSON content as fallback // Parse JSON content as fallback
let profileObj: any = {} let profileObj: any = {}
@ -223,6 +245,7 @@ export function getProfileFromEvent(event: Event) {
banner: profileObj.banner, banner: profileObj.banner,
avatar: avatarUrl, avatar: avatarUrl,
pictureSize, pictureSize,
isBot: event.kind === 0 ? profileIsBotFromKind0Tags(event.tags as string[][]) : undefined,
username: username || formatPubkey(event.pubkey), username: username || formatPubkey(event.pubkey),
original_username: username, original_username: username,
nip05, nip05,

26
src/pages/secondary/ProfileEditorPage/index.tsx

@ -366,8 +366,24 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
try { try {
// Strip empty/incomplete rows, trim whitespace. // Strip empty/incomplete rows, trim whitespace.
const validTags = profileTags const validTags = profileTags
.filter((t) => Array.isArray(t) && t.length >= 2 && (t[0] ?? '').trim() && (t[1] ?? '').trim()) .filter((t) => {
.map((t) => [t[0].trim(), t[1].trim(), ...t.slice(2)]) 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). // Sort alphabetically by tag name (stable: same-name tags keep their relative order).
const sortedTags = [...validTags] const sortedTags = [...validTags]
@ -388,6 +404,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
const seenContent = new Set<string>() const seenContent = new Set<string>()
for (const tag of sortedTags) { for (const tag of sortedTags) {
const name = tag[0] const name = tag[0]
if (name === 'bot') continue
if (DISPLAY_ORDER.includes(name) && !seenContent.has(name)) { if (DISPLAY_ORDER.includes(name) && !seenContent.has(name)) {
content[name] = tag[1] content[name] = tag[1]
seenContent.add(name) seenContent.add(name)
@ -653,7 +670,10 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
variant="outline" variant="outline"
size="icon" size="icon"
className="h-8 w-8 shrink-0" 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')} aria-label={t('Add tag')}
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />

2
src/types/index.d.ts vendored

@ -39,6 +39,8 @@ export type TProfile = {
lightningAddress?: string lightningAddress?: string
lightningAddressList?: string[] lightningAddressList?: string[]
created_at?: number created_at?: number
/** Kind 0: `bot` / `bot,true` tags without `bot,false` — see Nostr profile conventions. */
isBot?: boolean
} }
export type TPaymentInfo = { export type TPaymentInfo = {

Loading…
Cancel
Save