feat: add badge for suspicious and spam users

This commit is contained in:
codytseng
2025-11-25 23:11:31 +08:00
parent 2b4f673df1
commit c84c479871
23 changed files with 185 additions and 22 deletions

View File

@@ -16,6 +16,7 @@ import Nip05 from '../Nip05'
import NoteOptions from '../NoteOptions' import NoteOptions from '../NoteOptions'
import ParentNotePreview from '../ParentNotePreview' import ParentNotePreview from '../ParentNotePreview'
import TranslateButton from '../TranslateButton' import TranslateButton from '../TranslateButton'
import TrustScoreBadge from '../TrustScoreBadge'
import UserAvatar from '../UserAvatar' import UserAvatar from '../UserAvatar'
import Username from '../Username' import Username from '../Username'
import CommunityDefinition from './CommunityDefinition' import CommunityDefinition from './CommunityDefinition'
@@ -125,6 +126,7 @@ export default function Note({
skeletonClassName={size === 'small' ? 'h-3' : 'h-4'} skeletonClassName={size === 'small' ? 'h-3' : 'h-4'}
/> />
<FollowingBadge pubkey={event.pubkey} /> <FollowingBadge pubkey={event.pubkey} />
<TrustScoreBadge pubkey={event.pubkey} />
<ClientTag event={event} /> <ClientTag event={event} />
</div> </div>
<div className="flex items-center gap-1 text-sm text-muted-foreground"> <div className="flex items-center gap-1 text-sm text-muted-foreground">

View File

@@ -19,13 +19,14 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import NotFound from '../NotFound' import NotFound from '../NotFound'
import SearchInput from '../SearchInput' import SearchInput from '../SearchInput'
import TextWithEmojis from '../TextWithEmojis'
import TrustScoreBadge from '../TrustScoreBadge'
import AvatarWithLightbox from './AvatarWithLightbox'
import BannerWithLightbox from './BannerWithLightbox'
import FollowedBy from './FollowedBy' import FollowedBy from './FollowedBy'
import Followings from './Followings' import Followings from './Followings'
import ProfileFeed from './ProfileFeed' import ProfileFeed from './ProfileFeed'
import Relays from './Relays' import Relays from './Relays'
import TextWithEmojis from '../TextWithEmojis'
import AvatarWithLightbox from './AvatarWithLightbox'
import BannerWithLightbox from './BannerWithLightbox'
export default function Profile({ id }: { id?: string }) { export default function Profile({ id }: { id?: string }) {
const { t } = useTranslation() const { t } = useTranslation()
@@ -143,6 +144,7 @@ export default function Profile({ id }: { id?: string }) {
emojis={emojis} emojis={emojis}
className="text-xl font-semibold truncate select-text" className="text-xl font-semibold truncate select-text"
/> />
<TrustScoreBadge pubkey={pubkey} />
{isFollowingYou && ( {isFollowingYou && (
<div className="text-muted-foreground rounded-full bg-muted text-xs h-fit px-2 shrink-0"> <div className="text-muted-foreground rounded-full bg-muted text-xs h-fit px-2 shrink-0">
{t('Follows you')} {t('Follows you')}

View File

@@ -4,6 +4,8 @@ import { useMemo } from 'react'
import FollowButton from '../FollowButton' import FollowButton from '../FollowButton'
import Nip05 from '../Nip05' import Nip05 from '../Nip05'
import ProfileAbout from '../ProfileAbout' import ProfileAbout from '../ProfileAbout'
import TextWithEmojis from '../TextWithEmojis'
import TrustScoreBadge from '../TrustScoreBadge'
import { SimpleUserAvatar } from '../UserAvatar' import { SimpleUserAvatar } from '../UserAvatar'
export default function ProfileCard({ userId }: { userId: string }) { export default function ProfileCard({ userId }: { userId: string }) {
@@ -18,7 +20,14 @@ export default function ProfileCard({ userId }: { userId: string }) {
<FollowButton pubkey={pubkey} /> <FollowButton pubkey={pubkey} />
</div> </div>
<div> <div>
<div className="text-lg font-semibold truncate">{username}</div> <div className="flex gap-2 items-center">
<TextWithEmojis
text={username || ''}
emojis={emojis}
className="text-lg font-semibold truncate"
/>
<TrustScoreBadge pubkey={pubkey} />
</div>
<Nip05 pubkey={pubkey} /> <Nip05 pubkey={pubkey} />
</div> </div>
{about && ( {about && (

View File

@@ -0,0 +1,64 @@
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import trustScoreService from '@/services/trust-score.service'
import { AlertTriangle, ShieldAlert } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
export default function TrustScoreBadge({
pubkey,
className
}: {
pubkey: string
className?: string
}) {
const { t } = useTranslation()
const { pubkey: currentPubkey } = useNostr()
const [percentile, setPercentile] = useState<number | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
if (currentPubkey === pubkey) {
setLoading(false)
setPercentile(null)
return
}
const fetchScore = async () => {
try {
const data = await trustScoreService.fetchTrustScore(pubkey)
if (data) {
setPercentile(data.percentile)
}
} catch (error) {
console.error('Failed to fetch trust score:', error)
} finally {
setLoading(false)
}
}
fetchScore()
}, [pubkey, currentPubkey])
if (loading || percentile === null) return null
// percentile < 50: likely spam (red alert)
// percentile < 75: suspicious (yellow warning)
if (percentile < 50) {
return (
<div title={t('Likely spam account (Trust score: {{percentile}}%)', { percentile })}>
<ShieldAlert className={cn('!size-4 text-red-500', className)} />
</div>
)
}
if (percentile < 75) {
return (
<div title={t('Suspicious account (Trust score: {{percentile}}%)', { percentile })}>
<AlertTriangle className={cn('!size-4 text-yellow-600 dark:text-yellow-500', className)} />
</div>
)
}
return null
}

View File

@@ -7,6 +7,7 @@ import { userIdToPubkey } from '@/lib/pubkey'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useMemo } from 'react' import { useMemo } from 'react'
import FollowingBadge from '../FollowingBadge' import FollowingBadge from '../FollowingBadge'
import TrustScoreBadge from '../TrustScoreBadge'
export default function UserItem({ export default function UserItem({
userId, userId,
@@ -32,6 +33,7 @@ export default function UserItem({
skeletonClassName="h-4" skeletonClassName="h-4"
/> />
{showFollowingBadge && <FollowingBadge pubkey={pubkey} />} {showFollowingBadge && <FollowingBadge pubkey={pubkey} />}
<TrustScoreBadge pubkey={pubkey} />
</div> </div>
<Nip05 pubkey={userId} /> <Nip05 pubkey={userId} />
</div> </div>

View File

@@ -550,6 +550,8 @@ export default {
'Failed to republish to optimal relays: {{error}}': 'فشل إعادة النشر إلى المرحلات المثلى: {{error}}', 'Failed to republish to optimal relays: {{error}}': 'فشل إعادة النشر إلى المرحلات المثلى: {{error}}',
'External Content': 'محتوى خارجي', 'External Content': 'محتوى خارجي',
Highlight: 'تسليط الضوء', Highlight: 'تسليط الضوء',
'Optimal relays and {{count}} other relays': 'المرحلات المثلى و {{count}} مرحلات أخرى' 'Optimal relays and {{count}} other relays': 'المرحلات المثلى و {{count}} مرحلات أخرى',
'Likely spam account (Trust score: {{percentile}}%)': 'حساب مشبوه للغاية (درجة الثقة: {{percentile}}%)',
'Suspicious account (Trust score: {{percentile}}%)': 'حساب مشبوه (درجة الثقة: {{percentile}}%)'
} }
} }

View File

@@ -566,6 +566,8 @@ export default {
'Fehler beim Neuveröffentlichen auf optimale Relays: {{error}}', 'Fehler beim Neuveröffentlichen auf optimale Relays: {{error}}',
'External Content': 'Externer Inhalt', 'External Content': 'Externer Inhalt',
Highlight: 'Hervorheben', Highlight: 'Hervorheben',
'Optimal relays and {{count}} other relays': 'Optimale Relays und {{count}} andere Relays' 'Optimal relays and {{count}} other relays': 'Optimale Relays und {{count}} andere Relays',
'Likely spam account (Trust score: {{percentile}}%)': 'Wahrscheinlich Spam-Konto (Vertrauenswert: {{percentile}}%)',
'Suspicious account (Trust score: {{percentile}}%)': 'Verdächtiges Konto (Vertrauenswert: {{percentile}}%)'
} }
} }

View File

@@ -547,9 +547,14 @@ export default {
'Optimal relays': 'Optimal relays', 'Optimal relays': 'Optimal relays',
"Successfully republish to optimal relays (your write relays and mentioned users' read relays)": "Successfully republish to optimal relays (your write relays and mentioned users' read relays)":
"Successfully republish to optimal relays (your write relays and mentioned users' read relays)", "Successfully republish to optimal relays (your write relays and mentioned users' read relays)",
'Failed to republish to optimal relays: {{error}}': 'Failed to republish to optimal relays: {{error}}', 'Failed to republish to optimal relays: {{error}}':
'Failed to republish to optimal relays: {{error}}',
'External Content': 'External Content', 'External Content': 'External Content',
Highlight: 'Highlight', Highlight: 'Highlight',
'Optimal relays and {{count}} other relays': 'Optimal relays and {{count}} other relays' 'Optimal relays and {{count}} other relays': 'Optimal relays and {{count}} other relays',
'Likely spam account (Trust score: {{percentile}}%)':
'Likely spam account (Trust score: {{percentile}}%)',
'Suspicious account (Trust score: {{percentile}}%)':
'Suspicious account (Trust score: {{percentile}}%)'
} }
} }

View File

@@ -561,6 +561,8 @@ export default {
'Failed to republish to optimal relays: {{error}}': 'Error al republicar en relays óptimos: {{error}}', 'Failed to republish to optimal relays: {{error}}': 'Error al republicar en relays óptimos: {{error}}',
'External Content': 'Contenido externo', 'External Content': 'Contenido externo',
Highlight: 'Destacado', Highlight: 'Destacado',
'Optimal relays and {{count}} other relays': 'Relays óptimos y {{count}} otros relays' 'Optimal relays and {{count}} other relays': 'Relays óptimos y {{count}} otros relays',
'Likely spam account (Trust score: {{percentile}}%)': 'Probablemente cuenta spam (Puntuación de confianza: {{percentile}}%)',
'Suspicious account (Trust score: {{percentile}}%)': 'Cuenta sospechosa (Puntuación de confianza: {{percentile}}%)'
} }
} }

View File

@@ -555,6 +555,8 @@ export default {
'Failed to republish to optimal relays: {{error}}': 'خطا در انتشار مجدد در رله‌های بهینه: {{error}}', 'Failed to republish to optimal relays: {{error}}': 'خطا در انتشار مجدد در رله‌های بهینه: {{error}}',
'External Content': 'محتوای خارجی', 'External Content': 'محتوای خارجی',
Highlight: 'برجسته‌سازی', Highlight: 'برجسته‌سازی',
'Optimal relays and {{count}} other relays': 'رله‌های بهینه و {{count}} رله دیگر' 'Optimal relays and {{count}} other relays': 'رله‌های بهینه و {{count}} رله دیگر',
'Likely spam account (Trust score: {{percentile}}%)': 'احتمالاً حساب هرزنامه (امتیاز اعتماد: {{percentile}}%)',
'Suspicious account (Trust score: {{percentile}}%)': 'حساب مشکوک (امتیاز اعتماد: {{percentile}}%)'
} }
} }

View File

@@ -564,6 +564,8 @@ export default {
'Failed to republish to optimal relays: {{error}}': 'Échec de la republication sur les relais optimaux : {{error}}', 'Failed to republish to optimal relays: {{error}}': 'Échec de la republication sur les relais optimaux : {{error}}',
'External Content': 'Contenu externe', 'External Content': 'Contenu externe',
Highlight: 'Surligner', Highlight: 'Surligner',
'Optimal relays and {{count}} other relays': 'Relais optimaux et {{count}} autres relais' 'Optimal relays and {{count}} other relays': 'Relais optimaux et {{count}} autres relais',
'Likely spam account (Trust score: {{percentile}}%)': 'Compte probablement spam (Score de confiance: {{percentile}}%)',
'Suspicious account (Trust score: {{percentile}}%)': 'Compte suspect (Score de confiance: {{percentile}}%)'
} }
} }

View File

@@ -556,6 +556,8 @@ export default {
'Failed to republish to optimal relays: {{error}}': 'इष्टतम रिले पर पुनः प्रकाशित करने में विफल: {{error}}', 'Failed to republish to optimal relays: {{error}}': 'इष्टतम रिले पर पुनः प्रकाशित करने में विफल: {{error}}',
'External Content': 'बाहरी सामग्री', 'External Content': 'बाहरी सामग्री',
Highlight: 'हाइलाइट', Highlight: 'हाइलाइट',
'Optimal relays and {{count}} other relays': 'इष्टतम रिले और {{count}} अन्य रिले' 'Optimal relays and {{count}} other relays': 'इष्टतम रिले और {{count}} अन्य रिले',
'Likely spam account (Trust score: {{percentile}}%)': 'संभावित स्पैम खाता (विश्वास स्कोर: {{percentile}}%)',
'Suspicious account (Trust score: {{percentile}}%)': 'संदिग्ध खाता (विश्वास स्कोर: {{percentile}}%)'
} }
} }

View File

@@ -551,6 +551,8 @@ export default {
'Nem sikerült újra közzétenni az optimális relay-eken: {{error}}', 'Nem sikerült újra közzétenni az optimális relay-eken: {{error}}',
'External Content': 'Külső tartalom', 'External Content': 'Külső tartalom',
Highlight: 'Kiemelés', Highlight: 'Kiemelés',
'Optimal relays and {{count}} other relays': 'Optimális relay-ek és {{count}} másik relay' 'Optimal relays and {{count}} other relays': 'Optimális relay-ek és {{count}} másik relay',
'Likely spam account (Trust score: {{percentile}}%)': 'Valószínűleg spam fiók (Megbízhatósági pontszám: {{percentile}}%)',
'Suspicious account (Trust score: {{percentile}}%)': 'Gyanús fiók (Megbízhatósági pontszám: {{percentile}}%)'
} }
} }

View File

@@ -560,6 +560,8 @@ export default {
'Failed to republish to optimal relays: {{error}}': 'Errore nella ripubblicazione sui relay ottimali: {{error}}', 'Failed to republish to optimal relays: {{error}}': 'Errore nella ripubblicazione sui relay ottimali: {{error}}',
'External Content': 'Contenuto esterno', 'External Content': 'Contenuto esterno',
Highlight: 'Evidenzia', Highlight: 'Evidenzia',
'Optimal relays and {{count}} other relays': 'Relay ottimali e {{count}} altri relay' 'Optimal relays and {{count}} other relays': 'Relay ottimali e {{count}} altri relay',
'Likely spam account (Trust score: {{percentile}}%)': 'Probabile account spam (Punteggio di fiducia: {{percentile}}%)',
'Suspicious account (Trust score: {{percentile}}%)': 'Account sospetto (Punteggio di fiducia: {{percentile}}%)'
} }
} }

View File

@@ -555,6 +555,8 @@ export default {
'Failed to republish to optimal relays: {{error}}': '最適なリレーへの再公開に失敗しました:{{error}}', 'Failed to republish to optimal relays: {{error}}': '最適なリレーへの再公開に失敗しました:{{error}}',
'External Content': '外部コンテンツ', 'External Content': '外部コンテンツ',
Highlight: 'ハイライト', Highlight: 'ハイライト',
'Optimal relays and {{count}} other relays': '最適なリレーと他の{{count}}個のリレー' 'Optimal relays and {{count}} other relays': '最適なリレーと他の{{count}}個のリレー',
'Likely spam account (Trust score: {{percentile}}%)': 'スパムの可能性が高いアカウント(信頼スコア:{{percentile}}%',
'Suspicious account (Trust score: {{percentile}}%)': '疑わしいアカウント(信頼スコア:{{percentile}}%'
} }
} }

View File

@@ -555,6 +555,8 @@ export default {
'Failed to republish to optimal relays: {{error}}': '최적 릴레이에 재게시 실패: {{error}}', 'Failed to republish to optimal relays: {{error}}': '최적 릴레이에 재게시 실패: {{error}}',
'External Content': '외부 콘텐츠', 'External Content': '외부 콘텐츠',
Highlight: '하이라이트', Highlight: '하이라이트',
'Optimal relays and {{count}} other relays': '최적 릴레이 및 기타 {{count}}개 릴레이' 'Optimal relays and {{count}} other relays': '최적 릴레이 및 기타 {{count}}개 릴레이',
'Likely spam account (Trust score: {{percentile}}%)': '스팸 계정 가능성 높음 (신뢰 점수: {{percentile}}%)',
'Suspicious account (Trust score: {{percentile}}%)': '의심스러운 계정 (신뢰 점수: {{percentile}}%)'
} }
} }

View File

@@ -561,6 +561,8 @@ export default {
'Nie udało się opublikować ponownie na optymalnych przekaźnikach: {{error}}', 'Nie udało się opublikować ponownie na optymalnych przekaźnikach: {{error}}',
'External Content': 'Treść zewnętrzna', 'External Content': 'Treść zewnętrzna',
Highlight: 'Podświetl', Highlight: 'Podświetl',
'Optimal relays and {{count}} other relays': 'Optymalne przekaźniki i {{count}} innych przekaźników' 'Optimal relays and {{count}} other relays': 'Optymalne przekaźniki i {{count}} innych przekaźników',
'Likely spam account (Trust score: {{percentile}}%)': 'Prawdopodobnie konto spamowe (Wynik zaufania: {{percentile}}%)',
'Suspicious account (Trust score: {{percentile}}%)': 'Podejrzane konto (Wynik zaufania: {{percentile}}%)'
} }
} }

View File

@@ -556,6 +556,8 @@ export default {
'Failed to republish to optimal relays: {{error}}': 'Falha ao republicar nos relays ideais: {{error}}', 'Failed to republish to optimal relays: {{error}}': 'Falha ao republicar nos relays ideais: {{error}}',
'External Content': 'Conteúdo externo', 'External Content': 'Conteúdo externo',
Highlight: 'Marcação', Highlight: 'Marcação',
'Optimal relays and {{count}} other relays': 'Relays ideais e {{count}} outros relays' 'Optimal relays and {{count}} other relays': 'Relays ideais e {{count}} outros relays',
'Likely spam account (Trust score: {{percentile}}%)': 'Provável conta de spam (Pontuação de confiança: {{percentile}}%)',
'Suspicious account (Trust score: {{percentile}}%)': 'Conta suspeita (Pontuação de confiança: {{percentile}}%)'
} }
} }

View File

@@ -559,6 +559,8 @@ export default {
'Failed to republish to optimal relays: {{error}}': 'Falha ao republicar nos relays ideais: {{error}}', 'Failed to republish to optimal relays: {{error}}': 'Falha ao republicar nos relays ideais: {{error}}',
'External Content': 'Conteúdo externo', 'External Content': 'Conteúdo externo',
Highlight: 'Destacar', Highlight: 'Destacar',
'Optimal relays and {{count}} other relays': 'Relays ideais e {{count}} outros relays' 'Optimal relays and {{count}} other relays': 'Relays ideais e {{count}} outros relays',
'Likely spam account (Trust score: {{percentile}}%)': 'Provável conta de spam (Pontuação de confiança: {{percentile}}%)',
'Suspicious account (Trust score: {{percentile}}%)': 'Conta suspeita (Pontuação de confiança: {{percentile}}%)'
} }
} }

View File

@@ -561,6 +561,8 @@ export default {
'Failed to republish to optimal relays: {{error}}': 'Не удалось опубликовать в оптимальные релеи: {{error}}', 'Failed to republish to optimal relays: {{error}}': 'Не удалось опубликовать в оптимальные релеи: {{error}}',
'External Content': 'Внешний контент', 'External Content': 'Внешний контент',
Highlight: 'Выделить', Highlight: 'Выделить',
'Optimal relays and {{count}} other relays': 'Оптимальные релеи и {{count}} других релеев' 'Optimal relays and {{count}} other relays': 'Оптимальные релеи и {{count}} других релеев',
'Likely spam account (Trust score: {{percentile}}%)': 'Вероятно спам-аккаунт (Оценка доверия: {{percentile}}%)',
'Suspicious account (Trust score: {{percentile}}%)': 'Подозрительный аккаунт (Оценка доверия: {{percentile}}%)'
} }
} }

View File

@@ -548,6 +548,8 @@ export default {
'Failed to republish to optimal relays: {{error}}': 'เผยแพร่ซ้ำไปยังรีเลย์ที่เหมาะสมล้มเหลว: {{error}}', 'Failed to republish to optimal relays: {{error}}': 'เผยแพร่ซ้ำไปยังรีเลย์ที่เหมาะสมล้มเหลว: {{error}}',
'External Content': 'เนื้อหาภายนอก', 'External Content': 'เนื้อหาภายนอก',
Highlight: 'ไฮไลต์', Highlight: 'ไฮไลต์',
'Optimal relays and {{count}} other relays': 'รีเลย์ที่เหมาะสมและรีเลย์อื่น {{count}} รายการ' 'Optimal relays and {{count}} other relays': 'รีเลย์ที่เหมาะสมและรีเลย์อื่น {{count}} รายการ',
'Likely spam account (Trust score: {{percentile}}%)': 'น่าจะเป็นบัญชีสแปม (คะแนนความน่าเชื่อถือ: {{percentile}}%)',
'Suspicious account (Trust score: {{percentile}}%)': 'บัญชีที่น่าสงสัย (คะแนนความน่าเชื่อถือ: {{percentile}}%)'
} }
} }

View File

@@ -543,6 +543,8 @@ export default {
'Failed to republish to optimal relays: {{error}}': '重新发布到最佳中继器失败:{{error}}', 'Failed to republish to optimal relays: {{error}}': '重新发布到最佳中继器失败:{{error}}',
'External Content': '外部内容', 'External Content': '外部内容',
Highlight: '高亮', Highlight: '高亮',
'Optimal relays and {{count}} other relays': '最佳中继器和其他 {{count}} 个中继器' 'Optimal relays and {{count}} other relays': '最佳中继器和其他 {{count}} 个中继器',
'Likely spam account (Trust score: {{percentile}}%)': '疑似垃圾账号(信任分数:{{percentile}}%',
'Suspicious account (Trust score: {{percentile}}%)': '可疑账号(信任分数:{{percentile}}%'
} }
} }

View File

@@ -0,0 +1,47 @@
import DataLoader from 'dataloader'
export interface TrustScoreData {
percentile: number
}
class TrustScoreService {
static instance: TrustScoreService
private trustScoreDataLoader = new DataLoader<string, TrustScoreData | null>(async (userIds) => {
return await Promise.all(
userIds.map(async (userId) => {
try {
const res = await fetch(`https://fayan.jumble.social/${userId}`)
if (!res.ok) {
if (res.status === 404) {
return { percentile: 0 }
}
return null
}
const data = await res.json()
if (typeof data.percentile === 'number') {
return { percentile: data.percentile }
}
return null
} catch {
return null
}
})
)
})
constructor() {
if (!TrustScoreService.instance) {
TrustScoreService.instance = this
}
return TrustScoreService.instance
}
async fetchTrustScore(userId: string): Promise<TrustScoreData | null> {
return await this.trustScoreDataLoader.load(userId)
}
}
const instance = new TrustScoreService()
export default instance