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 ParentNotePreview from '../ParentNotePreview'
import TranslateButton from '../TranslateButton'
import TrustScoreBadge from '../TrustScoreBadge'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
import CommunityDefinition from './CommunityDefinition'
@@ -125,6 +126,7 @@ export default function Note({
skeletonClassName={size === 'small' ? 'h-3' : 'h-4'}
/>
<FollowingBadge pubkey={event.pubkey} />
<TrustScoreBadge pubkey={event.pubkey} />
<ClientTag event={event} />
</div>
<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 NotFound from '../NotFound'
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 Followings from './Followings'
import ProfileFeed from './ProfileFeed'
import Relays from './Relays'
import TextWithEmojis from '../TextWithEmojis'
import AvatarWithLightbox from './AvatarWithLightbox'
import BannerWithLightbox from './BannerWithLightbox'
export default function Profile({ id }: { id?: string }) {
const { t } = useTranslation()
@@ -143,6 +144,7 @@ export default function Profile({ id }: { id?: string }) {
emojis={emojis}
className="text-xl font-semibold truncate select-text"
/>
<TrustScoreBadge pubkey={pubkey} />
{isFollowingYou && (
<div className="text-muted-foreground rounded-full bg-muted text-xs h-fit px-2 shrink-0">
{t('Follows you')}

View File

@@ -4,6 +4,8 @@ import { useMemo } from 'react'
import FollowButton from '../FollowButton'
import Nip05 from '../Nip05'
import ProfileAbout from '../ProfileAbout'
import TextWithEmojis from '../TextWithEmojis'
import TrustScoreBadge from '../TrustScoreBadge'
import { SimpleUserAvatar } from '../UserAvatar'
export default function ProfileCard({ userId }: { userId: string }) {
@@ -18,7 +20,14 @@ export default function ProfileCard({ userId }: { userId: string }) {
<FollowButton pubkey={pubkey} />
</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} />
</div>
{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 { useMemo } from 'react'
import FollowingBadge from '../FollowingBadge'
import TrustScoreBadge from '../TrustScoreBadge'
export default function UserItem({
userId,
@@ -32,6 +33,7 @@ export default function UserItem({
skeletonClassName="h-4"
/>
{showFollowingBadge && <FollowingBadge pubkey={pubkey} />}
<TrustScoreBadge pubkey={pubkey} />
</div>
<Nip05 pubkey={userId} />
</div>

View File

@@ -550,6 +550,8 @@ export default {
'Failed to republish to optimal relays: {{error}}': 'فشل إعادة النشر إلى المرحلات المثلى: {{error}}',
'External Content': 'محتوى خارجي',
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}}',
'External Content': 'Externer Inhalt',
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',
"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',
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}}',
'External Content': 'Contenido externo',
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}}',
'External Content': 'محتوای خارجی',
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}}',
'External Content': 'Contenu externe',
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}}',
'External Content': 'बाहरी सामग्री',
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}}',
'External Content': 'Külső tartalom',
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}}',
'External Content': 'Contenuto esterno',
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}}',
'External Content': '外部コンテンツ',
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}}',
'External Content': '외부 콘텐츠',
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}}',
'External Content': 'Treść zewnętrzna',
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}}',
'External Content': 'Conteúdo externo',
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}}',
'External Content': 'Conteúdo externo',
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}}',
'External Content': 'Внешний контент',
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}}',
'External Content': 'เนื้อหาภายนอก',
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}}',
'External Content': '外部内容',
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