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

52
src/components/ProfileBotBadge/index.tsx

@ -0,0 +1,52 @@ @@ -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>
)
}

159
src/components/UserAvatar/index.tsx

@ -1,3 +1,4 @@ @@ -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 = { @@ -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,
@ -317,8 +333,11 @@ export default function UserAvatar({ @@ -317,8 +333,11 @@ export default function UserAvatar({
<div
ref={containerRef}
data-user-avatar
className={cn('shrink-0 cursor-pointer block overflow-hidden rounded-full bg-muted', UserAvatarSizeCnMap[size], className)}
style={{ position: 'relative', zIndex: 10, isolation: 'isolate', display: 'block' }}
className={cn(
'relative isolate z-10 block shrink-0 cursor-pointer rounded-full bg-muted',
UserAvatarSizeCnMap[size],
className
)}
onClick={(e) => {
e.stopPropagation()
if (!profileNavTarget) return
@ -326,39 +345,42 @@ export default function UserAvatar({ @@ -326,39 +345,42 @@ export default function UserAvatar({
navigateToProfile(toProfile(profileNavTarget))
}}
>
{!imgError && currentSrc ? (
isVideoAvatar ? (
<video
src={currentSrc}
className="block w-full h-full object-cover object-center"
autoPlay
muted
loop
playsInline
onCanPlay={() => {
if (currentSrc && isHttpOrHttpsUrl(currentSrc)) loadedAvatarUrls.add(currentSrc)
}}
onError={handleImageError}
/>
<div className="h-full w-full overflow-hidden rounded-full">
{!imgError && currentSrc ? (
isVideoAvatar ? (
<video
src={currentSrc}
className="block h-full w-full object-cover object-center"
autoPlay
muted
loop
playsInline
onCanPlay={() => {
if (currentSrc && isHttpOrHttpsUrl(currentSrc)) loadedAvatarUrls.add(currentSrc)
}}
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
src={currentSrc}
alt=""
className="block w-full h-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}
/>
)
) : (
// 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>
)}
// Show initials or placeholder when image fails
<div className="flex h-full w-full items-center justify-center text-xs font-medium text-muted-foreground">
{(displayPubkey || userId).slice(0, 2).toUpperCase()}
</div>
)}
</div>
{profile?.isBot ? <ProfileBotBadge size={botBadgeSizeForAvatar(size)} /> : null}
</div>
)
}
@ -459,41 +481,44 @@ export function SimpleUserAvatar({ @@ -459,41 +481,44 @@ export function SimpleUserAvatar({
return (
<div
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 ? (
isVideoAvatar ? (
<video
src={currentSrc}
className="block w-full h-full object-cover object-center"
autoPlay
muted
loop
playsInline
onCanPlay={() => {
if (currentSrc && isHttpOrHttpsUrl(currentSrc)) loadedAvatarUrls.add(currentSrc)
}}
onError={handleImageError}
/>
<div className="h-full w-full overflow-hidden rounded-full">
{!imgError && currentSrc ? (
isVideoAvatar ? (
<video
src={currentSrc}
className="block h-full w-full object-cover object-center"
autoPlay
muted
loop
playsInline
onCanPlay={() => {
if (currentSrc && isHttpOrHttpsUrl(currentSrc)) loadedAvatarUrls.add(currentSrc)
}}
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
src={currentSrc}
alt=""
className="block w-full h-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}
/>
)
) : (
// 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>
)}
// Show initials or placeholder when image fails
<div className="flex h-full w-full items-center justify-center text-xs font-medium text-muted-foreground">
{(displayPubkey || userId).slice(0, 2).toUpperCase()}
</div>
)}
</div>
{profile?.isBot ? <ProfileBotBadge size={botBadgeSizeForAvatar(size)} /> : null}
</div>
)
}

23
src/lib/event-metadata.ts

@ -149,6 +149,28 @@ function nip05ListFromJson(raw: unknown): string[] | undefined { @@ -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) { @@ -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,

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

@ -366,8 +366,24 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -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) => { @@ -388,6 +404,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
const seenContent = new Set<string>()
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) => { @@ -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')}
>
<Plus className="h-4 w-4" />

2
src/types/index.d.ts vendored

@ -39,6 +39,8 @@ export type TProfile = { @@ -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 = {

Loading…
Cancel
Save