feat: broadcast

This commit is contained in:
codytseng
2025-08-02 16:04:27 +08:00
parent 5714fae7bd
commit 3f8a9e8efa
20 changed files with 683 additions and 232 deletions

View File

@@ -0,0 +1,62 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
import { MenuAction } from './useMenuActions'
interface DesktopMenuProps {
menuActions: MenuAction[]
trigger: React.ReactNode
}
export function DesktopMenu({ menuActions, trigger }: DesktopMenuProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent className="max-h-screen overflow-y-auto">
{menuActions.map((action, index) => {
const Icon = action.icon
return (
<div key={index}>
{action.separator && index > 0 && <DropdownMenuSeparator />}
{action.subMenu ? (
<DropdownMenuSub>
<DropdownMenuSubTrigger className={action.className}>
<Icon />
{action.label}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="max-h-screen overflow-y-auto">
{action.subMenu.map((subAction, subIndex) => (
<>
{subAction.separator && subIndex > 0 && <DropdownMenuSeparator />}
<DropdownMenuItem
key={subIndex}
onClick={subAction.onClick}
className={cn('w-64', subAction.className)}
>
{subAction.label}
</DropdownMenuItem>
</>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
) : (
<DropdownMenuItem onClick={action.onClick} className={action.className}>
<Icon />
{action.label}
</DropdownMenuItem>
)}
</div>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,79 @@
import { Button } from '@/components/ui/button'
import { Drawer, DrawerContent, DrawerOverlay } from '@/components/ui/drawer'
import { ArrowLeft } from 'lucide-react'
import { MenuAction, SubMenuAction } from './useMenuActions'
interface MobileMenuProps {
menuActions: MenuAction[]
trigger: React.ReactNode
isDrawerOpen: boolean
setIsDrawerOpen: (open: boolean) => void
showSubMenu: boolean
activeSubMenu: SubMenuAction[]
subMenuTitle: string
closeDrawer: () => void
goBackToMainMenu: () => void
}
export function MobileMenu({
menuActions,
trigger,
isDrawerOpen,
setIsDrawerOpen,
showSubMenu,
activeSubMenu,
subMenuTitle,
closeDrawer,
goBackToMainMenu
}: MobileMenuProps) {
return (
<>
{trigger}
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
<DrawerOverlay onClick={closeDrawer} />
<DrawerContent hideOverlay className="max-h-screen">
<div className="overflow-y-auto overscroll-contain py-2" style={{ touchAction: 'pan-y' }}>
{!showSubMenu ? (
menuActions.map((action, index) => {
const Icon = action.icon
return (
<Button
key={index}
onClick={action.onClick}
className={`w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5 ${action.className || ''}`}
variant="ghost"
>
<Icon />
{action.label}
</Button>
)
})
) : (
<>
<Button
onClick={goBackToMainMenu}
className="w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5 mb-2"
variant="ghost"
>
<ArrowLeft />
{subMenuTitle}
</Button>
<div className="border-t border-border mb-2" />
{activeSubMenu.map((subAction, index) => (
<Button
key={index}
onClick={subAction.onClick}
className={`w-full p-6 justify-start text-lg gap-4 ${subAction.className || ''}`}
variant="ghost"
>
{subAction.label}
</Button>
))}
</>
)}
</div>
</DrawerContent>
</Drawer>
</>
)
}

View File

@@ -1,32 +1,42 @@
import { Button } from '@/components/ui/button'
import { Drawer, DrawerContent, DrawerOverlay } from '@/components/ui/drawer'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { getNoteBech32Id } from '@/lib/event'
import { toNjump } from '@/lib/link'
import { pubkeyToNpub } from '@/lib/pubkey'
import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Bell, BellOff, Code, Copy, Ellipsis, Link } from 'lucide-react' import { Ellipsis } from 'lucide-react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useMemo, useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { DesktopMenu } from './DesktopMenu'
import { MobileMenu } from './MobileMenu'
import RawEventDialog from './RawEventDialog' import RawEventDialog from './RawEventDialog'
import { SubMenuAction, useMenuActions } from './useMenuActions'
export default function NoteOptions({ event, className }: { event: Event; className?: string }) { export default function NoteOptions({ event, className }: { event: Event; className?: string }) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { pubkey } = useNostr()
const [isRawEventDialogOpen, setIsRawEventDialogOpen] = useState(false) const [isRawEventDialogOpen, setIsRawEventDialogOpen] = useState(false)
const [isDrawerOpen, setIsDrawerOpen] = useState(false) const [isDrawerOpen, setIsDrawerOpen] = useState(false)
const { mutePubkeyPublicly, mutePubkeyPrivately, unmutePubkey, mutePubkeys } = useMuteList() const [showSubMenu, setShowSubMenu] = useState(false)
const isMuted = useMemo(() => mutePubkeys.includes(event.pubkey), [mutePubkeys, event]) const [activeSubMenu, setActiveSubMenu] = useState<SubMenuAction[]>([])
const [subMenuTitle, setSubMenuTitle] = useState('')
const closeDrawer = () => {
setIsDrawerOpen(false)
setShowSubMenu(false)
}
const goBackToMainMenu = () => {
setShowSubMenu(false)
}
const showSubMenuActions = (subMenu: SubMenuAction[], title: string) => {
setActiveSubMenu(subMenu)
setSubMenuTitle(title)
setShowSubMenu(true)
}
const menuActions = useMenuActions({
event,
closeDrawer,
showSubMenuActions,
setIsRawEventDialogOpen,
isSmallScreen
})
const trigger = ( const trigger = (
<button <button
@@ -37,175 +47,29 @@ export default function NoteOptions({ event, className }: { event: Event; classN
</button> </button>
) )
const rawEventDialog = (
<RawEventDialog
event={event}
isOpen={isRawEventDialogOpen}
onClose={() => setIsRawEventDialogOpen(false)}
/>
)
if (isSmallScreen) {
return (
<div className={className} onClick={(e) => e.stopPropagation()}>
{trigger}
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
<DrawerOverlay onClick={() => setIsDrawerOpen(false)} />
<DrawerContent hideOverlay>
<div className="py-2">
<Button
onClick={() => {
setIsDrawerOpen(false)
navigator.clipboard.writeText(getNoteBech32Id(event))
}}
className="w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5"
variant="ghost"
>
<Copy />
{t('Copy event ID')}
</Button>
<Button
onClick={() => {
navigator.clipboard.writeText(pubkeyToNpub(event.pubkey) ?? '')
setIsDrawerOpen(false)
}}
className="w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5"
variant="ghost"
>
<Copy />
{t('Copy user ID')}
</Button>
<Button
onClick={() => {
setIsDrawerOpen(false)
navigator.clipboard.writeText(toNjump(getNoteBech32Id(event)))
}}
className="w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5"
variant="ghost"
>
<Link />
{t('Copy share link')}
</Button>
<Button
onClick={() => {
setIsDrawerOpen(false)
setIsRawEventDialogOpen(true)
}}
className="w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5"
variant="ghost"
>
<Code />
{t('View raw event')}
</Button>
{pubkey &&
(isMuted ? (
<Button
onClick={() => {
setIsDrawerOpen(false)
unmutePubkey(event.pubkey)
}}
className="w-full p-6 justify-start text-destructive text-lg gap-4 [&_svg]:size-5 focus:text-destructive"
variant="ghost"
>
<Bell />
{t('Unmute user')}
</Button>
) : (
<>
<Button
onClick={() => {
setIsDrawerOpen(false)
mutePubkeyPrivately(event.pubkey)
}}
className="w-full p-6 justify-start text-destructive text-lg gap-4 [&_svg]:size-5 focus:text-destructive"
variant="ghost"
>
<BellOff />
{t('Mute user privately')}
</Button>
<Button
onClick={() => {
setIsDrawerOpen(false)
mutePubkeyPublicly(event.pubkey)
}}
className="w-full p-6 justify-start text-destructive text-lg gap-4 [&_svg]:size-5 focus:text-destructive"
variant="ghost"
>
<BellOff />
{t('Mute user publicly')}
</Button>
</>
))}
</div>
</DrawerContent>
</Drawer>
{rawEventDialog}
</div>
)
}
return ( return (
<div className={className} onClick={(e) => e.stopPropagation()}> <div className={className} onClick={(e) => e.stopPropagation()}>
<DropdownMenu> {isSmallScreen ? (
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger> <MobileMenu
<DropdownMenuContent> menuActions={menuActions}
<DropdownMenuItem onClick={() => navigator.clipboard.writeText(getNoteBech32Id(event))}> trigger={trigger}
<Copy /> isDrawerOpen={isDrawerOpen}
{t('Copy event ID')} setIsDrawerOpen={setIsDrawerOpen}
</DropdownMenuItem> showSubMenu={showSubMenu}
<DropdownMenuItem activeSubMenu={activeSubMenu}
onClick={() => navigator.clipboard.writeText(pubkeyToNpub(event.pubkey) ?? '')} subMenuTitle={subMenuTitle}
> closeDrawer={closeDrawer}
<Copy /> goBackToMainMenu={goBackToMainMenu}
{t('Copy user ID')} />
</DropdownMenuItem> ) : (
<DropdownMenuItem <DesktopMenu menuActions={menuActions} trigger={trigger} />
onClick={() => navigator.clipboard.writeText(toNjump(getNoteBech32Id(event)))} )}
>
<Link />
{t('Copy share link')}
</DropdownMenuItem>
<DropdownMenuSeparator /> <RawEventDialog
event={event}
<DropdownMenuItem onClick={() => setIsRawEventDialogOpen(true)}> isOpen={isRawEventDialogOpen}
<Code /> onClose={() => setIsRawEventDialogOpen(false)}
{t('View raw event')} />
</DropdownMenuItem>
{pubkey && (
<>
<DropdownMenuSeparator />
{isMuted ? (
<DropdownMenuItem
onClick={() => unmutePubkey(event.pubkey)}
className="text-destructive focus:text-destructive"
>
<Bell />
{t('Unmute user')}
</DropdownMenuItem>
) : (
<>
<DropdownMenuItem
onClick={() => mutePubkeyPrivately(event.pubkey)}
className="text-destructive focus:text-destructive"
>
<BellOff />
{t('Mute user privately')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => mutePubkeyPublicly(event.pubkey)}
className="text-destructive focus:text-destructive"
>
<BellOff />
{t('Mute user publicly')}
</DropdownMenuItem>
</>
)}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
{rawEventDialog}
</div> </div>
) )
} }

View File

@@ -0,0 +1,255 @@
import { getNoteBech32Id, isProtectedEvent } from '@/lib/event'
import { toNjump } from '@/lib/link'
import { pubkeyToNpub } from '@/lib/pubkey'
import { simplifyUrl } from '@/lib/url'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service'
import { Bell, BellOff, Code, Copy, Globe, Link, Mail, Server } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import RelayIcon from '../RelayIcon'
export interface SubMenuAction {
label: React.ReactNode
onClick: () => void
className?: string
separator?: boolean
}
export interface MenuAction {
icon: React.ComponentType
label: string
onClick?: () => void
className?: string
separator?: boolean
subMenu?: SubMenuAction[]
}
interface UseMenuActionsProps {
event: Event
closeDrawer: () => void
showSubMenuActions: (subMenu: SubMenuAction[], title: string) => void
setIsRawEventDialogOpen: (open: boolean) => void
isSmallScreen: boolean
}
export function useMenuActions({
event,
closeDrawer,
showSubMenuActions,
setIsRawEventDialogOpen,
isSmallScreen
}: UseMenuActionsProps) {
const { t } = useTranslation()
const { pubkey, relayList } = useNostr()
const { relaySets, favoriteRelays } = useFavoriteRelays()
const { mutePubkeyPublicly, mutePubkeyPrivately, unmutePubkey, mutePubkeys } = useMuteList()
const isMuted = useMemo(() => mutePubkeys.includes(event.pubkey), [mutePubkeys, event])
const broadcastSubMenu: SubMenuAction[] = useMemo(() => {
const items = []
if (pubkey) {
items.push({
label: (
<div className="flex items-center gap-2 w-full pl-1">
<Mail />
<div className="flex-1 truncate text-left">{t('Write relays')}</div>
</div>
),
onClick: async () => {
closeDrawer()
const relays = relayList?.write.slice(0, 10)
if (relays?.length) {
await client
.publishEvent(relays, event)
.then(() => {
toast.success(t('Successfully broadcasted to your write relays'))
})
.catch((error) => {
toast.error(
t('Failed to broadcast to your write relays: {{error}}', { error: error.message })
)
})
}
}
})
}
if (relaySets.length) {
items.push(
...relaySets
.filter((set) => set.relayUrls.length)
.map((set, index) => ({
label: (
<div className="flex items-center gap-2 w-full pl-1">
<Server />
<div className="flex-1 truncate text-left">{set.name}</div>
</div>
),
onClick: async () => {
closeDrawer()
await client
.publishEvent(set.relayUrls, event)
.then(() => {
toast.success(
t('Successfully broadcasted to relay set: {{name}}', { name: set.name })
)
})
.catch((error) => {
toast.error(
t('Failed to broadcast to relay set: {{name}}. Error: {{error}}', {
name: set.name,
error: error.message
})
)
})
},
separator: index === 0
}))
)
}
if (favoriteRelays.length) {
items.push(
...favoriteRelays.map((relay, index) => ({
label: (
<div className="flex items-center gap-2 w-full">
<RelayIcon url={relay} />
<div className="flex-1 truncate text-left">{simplifyUrl(relay)}</div>
</div>
),
onClick: async () => {
closeDrawer()
await client
.publishEvent([relay], event)
.then(() => {
toast.success(
t('Successfully broadcasted to relay: {{url}}', { url: simplifyUrl(relay) })
)
})
.catch((error) => {
toast.error(
t('Failed to broadcast to relay: {{url}}. Error: {{error}}', {
url: simplifyUrl(relay),
error: error.message
})
)
})
},
separator: index === 0
}))
)
}
return items
}, [pubkey, favoriteRelays, relaySets])
const menuActions: MenuAction[] = useMemo(() => {
const actions: MenuAction[] = [
{
icon: Copy,
label: t('Copy event ID'),
onClick: () => {
navigator.clipboard.writeText(getNoteBech32Id(event))
closeDrawer()
}
},
{
icon: Copy,
label: t('Copy user ID'),
onClick: () => {
navigator.clipboard.writeText(pubkeyToNpub(event.pubkey) ?? '')
closeDrawer()
}
},
{
icon: Link,
label: t('Copy share link'),
onClick: () => {
navigator.clipboard.writeText(toNjump(getNoteBech32Id(event)))
closeDrawer()
}
},
{
icon: Code,
label: t('View raw event'),
onClick: () => {
closeDrawer()
setIsRawEventDialogOpen(true)
},
separator: true
}
]
const isProtected = isProtectedEvent(event)
if (!isProtected || event.pubkey === pubkey) {
actions.push({
icon: Globe,
label: t('Broadcast to ...'),
onClick: isSmallScreen
? () => showSubMenuActions(broadcastSubMenu, t('Broadcast to ...'))
: undefined,
subMenu: isSmallScreen ? undefined : broadcastSubMenu,
separator: true
})
}
if (pubkey) {
if (isMuted) {
actions.push({
icon: Bell,
label: t('Unmute user'),
onClick: () => {
closeDrawer()
unmutePubkey(event.pubkey)
},
className: 'text-destructive focus:text-destructive',
separator: true
})
} else {
actions.push(
{
icon: BellOff,
label: t('Mute user privately'),
onClick: () => {
closeDrawer()
mutePubkeyPrivately(event.pubkey)
},
className: 'text-destructive focus:text-destructive',
separator: true
},
{
icon: BellOff,
label: t('Mute user publicly'),
onClick: () => {
closeDrawer()
mutePubkeyPublicly(event.pubkey)
},
className: 'text-destructive focus:text-destructive'
}
)
}
}
return actions
}, [
t,
event,
pubkey,
isMuted,
isSmallScreen,
broadcastSubMenu,
closeDrawer,
showSubMenuActions,
setIsRawEventDialogOpen,
mutePubkeyPrivately,
mutePubkeyPublicly,
unmutePubkey
])
return menuActions
}

View File

@@ -314,6 +314,18 @@ export default {
'Remove poll': 'إزالة الاستطلاع', 'Remove poll': 'إزالة الاستطلاع',
'Refresh results': 'تحديث النتائج', 'Refresh results': 'تحديث النتائج',
Poll: 'استطلاع', Poll: 'استطلاع',
media: 'الوسائط' media: 'الوسائط',
'Broadcast to ...': 'البث إلى...',
'Successfully broadcasted to your write relays': 'تم البث بنجاح إلى مرحلات الكتابة الخاصة بك',
'Failed to broadcast to your write relays: {{error}}':
'فشل البث إلى مرحلات الكتابة الخاصة بك: {{error}}',
'Successfully broadcasted to relay set: {{name}}':
'تم البث بنجاح إلى مجموعة المرحلات: {{name}}',
'Failed to broadcast to relay set: {{name}}. Error: {{error}}':
'فشل البث إلى مجموعة المرحلات: {{name}}. خطأ: {{error}}',
'Successfully broadcasted to relay: {{url}}': 'تم البث بنجاح إلى المرحل: {{url}}',
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
'فشل البث إلى المرحل: {{url}}. خطأ: {{error}}',
'Write relays': 'مرحلات الكتابة'
} }
} }

View File

@@ -321,6 +321,18 @@ export default {
'Remove poll': 'Umfrage entfernen', 'Remove poll': 'Umfrage entfernen',
'Refresh results': 'Ergebnisse aktualisieren', 'Refresh results': 'Ergebnisse aktualisieren',
Poll: 'Umfrage', Poll: 'Umfrage',
media: 'Medien' media: 'Medien',
'Broadcast to ...': 'Senden an...',
'Successfully broadcasted to your write relays': 'Erfolgreich an Ihre Schreibrelays gesendet',
'Failed to broadcast to your write relays: {{error}}':
'Fehler beim Senden an Ihre Schreibrelays: {{error}}',
'Successfully broadcasted to relay set: {{name}}':
'Erfolgreich an Relay-Set gesendet: {{name}}',
'Failed to broadcast to relay set: {{name}}. Error: {{error}}':
'Fehler beim Senden an Relay-Set: {{name}}. Fehler: {{error}}',
'Successfully broadcasted to relay: {{url}}': 'Erfolgreich an Relay gesendet: {{url}}',
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
'Fehler beim Senden an Relay: {{url}}. Fehler: {{error}}',
'Write relays': 'Schreib-Relays'
} }
} }

View File

@@ -314,6 +314,19 @@ export default {
'Remove poll': 'Remove poll', 'Remove poll': 'Remove poll',
'Refresh results': 'Refresh results', 'Refresh results': 'Refresh results',
Poll: 'Poll', Poll: 'Poll',
media: 'media' media: 'media',
'Broadcast to ...': 'Broadcast to ...',
'Successfully broadcasted to your write relays':
'Successfully broadcasted to your write relays',
'Failed to broadcast to your write relays: {{error}}':
'Failed to broadcast to your write relays: {{error}}',
'Successfully broadcasted to relay set: {{name}}':
'Successfully broadcasted to relay set: {{name}}',
'Failed to broadcast to relay set: {{name}}. Error: {{error}}':
'Failed to broadcast to relay set: {{name}}. Error: {{error}}',
'Successfully broadcasted to relay: {{url}}': 'Successfully broadcasted to relay: {{url}}',
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
'Failed to broadcast to relay: {{url}}. Error: {{error}}',
'Write relays': 'Write relays'
} }
} }

View File

@@ -319,6 +319,19 @@ export default {
'Remove poll': 'Eliminar encuesta', 'Remove poll': 'Eliminar encuesta',
'Refresh results': 'Actualizar resultados', 'Refresh results': 'Actualizar resultados',
Poll: 'Encuesta', Poll: 'Encuesta',
media: 'medios' media: 'medios',
'Broadcast to ...': 'Transmitir a...',
'Successfully broadcasted to your write relays':
'Transmitido exitosamente a sus relés de escritura',
'Failed to broadcast to your write relays: {{error}}':
'Error al transmitir a sus relés de escritura: {{error}}',
'Successfully broadcasted to relay set: {{name}}':
'Transmitido exitosamente al conjunto de relés: {{name}}',
'Failed to broadcast to relay set: {{name}}. Error: {{error}}':
'Error al transmitir al conjunto de relés: {{name}}. Error: {{error}}',
'Successfully broadcasted to relay: {{url}}': 'Transmitido exitosamente al relé: {{url}}',
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
'Error al transmitir al relé: {{url}}. Error: {{error}}',
'Write relays': 'Relés de escritura'
} }
} }

View File

@@ -316,6 +316,17 @@ export default {
'Remove poll': 'حذف نظرسنجی', 'Remove poll': 'حذف نظرسنجی',
'Refresh results': 'بارگیری مجدد نتایج', 'Refresh results': 'بارگیری مجدد نتایج',
Poll: 'نظرسنجی', Poll: 'نظرسنجی',
media: 'رسانه' media: 'رسانه',
'Broadcast to ...': 'پخش به...',
'Successfully broadcasted to your write relays': 'با موفقیت به رله‌های نوشتن شما پخش شد',
'Failed to broadcast to your write relays: {{error}}':
'پخش به رله‌های نوشتن شما ناموفق بود: {{error}}',
'Successfully broadcasted to relay set: {{name}}': 'با موفقیت به مجموعه رله پخش شد: {{name}}',
'Failed to broadcast to relay set: {{name}}. Error: {{error}}':
'پخش به مجموعه رله ناموفق بود: {{name}}. خطا: {{error}}',
'Successfully broadcasted to relay: {{url}}': 'با موفقیت به رله پخش شد: {{url}}',
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
'پخش به رله ناموفق بود: {{url}}. خطا: {{error}}',
'Write relays': 'رله‌های نوشتن'
} }
} }

View File

@@ -320,6 +320,18 @@ export default {
'Remove poll': 'Supprimer le sondage', 'Remove poll': 'Supprimer le sondage',
'Refresh results': 'Rafraîchir les résultats', 'Refresh results': 'Rafraîchir les résultats',
Poll: 'Sondage', Poll: 'Sondage',
media: 'média' media: 'média',
'Broadcast to ...': 'Diffuser vers...',
'Successfully broadcasted to your write relays': "Diffusion réussie vers vos relais d'écriture",
'Failed to broadcast to your write relays: {{error}}':
"Échec de la diffusion vers vos relais d'écriture : {{error}}",
'Successfully broadcasted to relay set: {{name}}':
"Diffusion réussie vers l'ensemble de relais : {{name}}",
'Failed to broadcast to relay set: {{name}}. Error: {{error}}':
"Échec de la diffusion vers l'ensemble de relais : {{name}}. Erreur : {{error}}",
'Successfully broadcasted to relay: {{url}}': 'Diffusion réussie vers le relais : {{url}}',
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
'Échec de la diffusion vers le relais : {{url}}. Erreur : {{error}}',
'Write relays': 'Relais décriture'
} }
} }

View File

@@ -318,6 +318,19 @@ export default {
'Remove poll': 'Rimuovi sondaggio', 'Remove poll': 'Rimuovi sondaggio',
'Refresh results': 'Aggiorna risultati', 'Refresh results': 'Aggiorna risultati',
Poll: 'Sondaggio', Poll: 'Sondaggio',
media: 'media' media: 'media',
'Broadcast to ...': 'Trasmetti a...',
'Successfully broadcasted to your write relays':
'Trasmesso con successo ai tuoi relay di scrittura',
'Failed to broadcast to your write relays: {{error}}':
'Errore nella trasmissione ai tuoi relay di scrittura: {{error}}',
'Successfully broadcasted to relay set: {{name}}':
'Trasmesso con successo al set di relay: {{name}}',
'Failed to broadcast to relay set: {{name}}. Error: {{error}}':
'Errore nella trasmissione al set di relay: {{name}}. Errore: {{error}}',
'Successfully broadcasted to relay: {{url}}': 'Trasmesso con successo al relay: {{url}}',
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
'Errore nella trasmissione al relay: {{url}}. Errore: {{error}}',
'Write relays': 'Relay di scrittura'
} }
} }

View File

@@ -316,6 +316,19 @@ export default {
'Remove poll': '投票を削除', 'Remove poll': '投票を削除',
'Refresh results': '結果を更新', 'Refresh results': '結果を更新',
Poll: '投票', Poll: '投票',
media: 'メディア' media: 'メディア',
'Broadcast to ...': 'ブロードキャスト先...',
'Successfully broadcasted to your write relays': '書きリレイへのブロードキャストが成功しました',
'Failed to broadcast to your write relays: {{error}}':
'書きリレイへのブロードキャストが失敗しました:{{error}}',
'Successfully broadcasted to relay set: {{name}}':
'リレイセットへのブロードキャストが成功しました:{{name}}',
'Failed to broadcast to relay set: {{name}}. Error: {{error}}':
'リレイセットへのブロードキャストが失敗しました:{{name}}。エラー:{{error}}',
'Successfully broadcasted to relay: {{url}}':
'リレイへのブロードキャストが成功しました:{{url}}',
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
'リレイへのブロードキャストが失敗しました:{{url}}。エラー:{{error}}',
'Write relays': '書きリレイ'
} }
} }

View File

@@ -316,6 +316,18 @@ export default {
'Remove poll': '투표 제거', 'Remove poll': '투표 제거',
'Refresh results': '결과 새로 고침', 'Refresh results': '결과 새로 고침',
Poll: '투표', Poll: '투표',
media: '미디어' media: '미디어',
'Broadcast to ...': '브로드캐스트 대상...',
'Successfully broadcasted to your write relays': '쓰기 릴레이로 브로드캐스트에 성공했습니다',
'Failed to broadcast to your write relays: {{error}}':
'쓰기 릴레이로 브로드캐스트에 실패했습니다: {{error}}',
'Successfully broadcasted to relay set: {{name}}':
'릴레이 세트로 브로드캐스트에 성공했습니다: {{name}}',
'Failed to broadcast to relay set: {{name}}. Error: {{error}}':
'릴레이 세트로 브로드캐스트에 실패했습니다: {{name}}. 오류: {{error}}',
'Successfully broadcasted to relay: {{url}}': '릴레이로 브로드캐스트에 성공했습니다: {{url}}',
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
'릴레이로 브로드캐스트에 실패했습니다: {{url}}. 오류: {{error}}',
'Write relays': '쓰기 릴레이'
} }
} }

View File

@@ -318,6 +318,19 @@ export default {
'Remove poll': 'Usuń ankietę', 'Remove poll': 'Usuń ankietę',
'Refresh results': 'Odśwież wyniki', 'Refresh results': 'Odśwież wyniki',
Poll: 'Ankieta', Poll: 'Ankieta',
media: 'media' media: 'media',
'Broadcast to ...': 'Transmituj do...',
'Successfully broadcasted to your write relays':
'Pomyślnie transmitowano do twoich przekaźników zapisu',
'Failed to broadcast to your write relays: {{error}}':
'Nie udało się transmitować do twoich przekaźników zapisu: {{error}}',
'Successfully broadcasted to relay set: {{name}}':
'Pomyślnie transmitowano do zestawu przekaźników: {{name}}',
'Failed to broadcast to relay set: {{name}}. Error: {{error}}':
'Nie udało się transmitować do zestawu przekaźników: {{name}}. Błąd: {{error}}',
'Successfully broadcasted to relay: {{url}}': 'Pomyślnie transmitowano do przekaźnika: {{url}}',
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
'Nie udało się transmitować do przekaźnika: {{url}}. Błąd: {{error}}',
'Write relays': 'Przekaźniki zapisu'
} }
} }

View File

@@ -317,6 +317,19 @@ export default {
'Remove poll': 'Remover enquete', 'Remove poll': 'Remover enquete',
'Refresh results': 'Atualizar resultados', 'Refresh results': 'Atualizar resultados',
Poll: 'Enquete', Poll: 'Enquete',
media: 'Mídia' media: 'Mídia',
'Broadcast to ...': 'Transmitir para...',
'Successfully broadcasted to your write relays':
'Transmitido com sucesso para seus relays de escrita',
'Failed to broadcast to your write relays: {{error}}':
'Falha ao transmitir para seus relays de escrita: {{error}}',
'Successfully broadcasted to relay set: {{name}}':
'Transmitido com sucesso para o conjunto de relays: {{name}}',
'Failed to broadcast to relay set: {{name}}. Error: {{error}}':
'Falha ao transmitir para o conjunto de relays: {{name}}. Erro: {{error}}',
'Successfully broadcasted to relay: {{url}}': 'Transmitido com sucesso para o relay: {{url}}',
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
'Falha ao transmitir para o relay: {{url}}. Erro: {{error}}',
'Write relays': 'Relés de escrita'
} }
} }

View File

@@ -318,6 +318,19 @@ export default {
'Remove poll': 'Remover sondagem', 'Remove poll': 'Remover sondagem',
'Refresh results': 'Atualizar resultados', 'Refresh results': 'Atualizar resultados',
Poll: 'Sondagem', Poll: 'Sondagem',
media: 'mídia' media: 'mídia',
'Broadcast to ...': 'Transmitir para...',
'Successfully broadcasted to your write relays':
'Transmitido com sucesso para os seus relays de escrita',
'Failed to broadcast to your write relays: {{error}}':
'Falha ao transmitir para os seus relays de escrita: {{error}}',
'Successfully broadcasted to relay set: {{name}}':
'Transmitido com sucesso para o conjunto de relays: {{name}}',
'Failed to broadcast to relay set: {{name}}. Error: {{error}}':
'Falha ao transmitir para o conjunto de relays: {{name}}. Erro: {{error}}',
'Successfully broadcasted to relay: {{url}}': 'Transmitido com sucesso para o relay: {{url}}',
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
'Falha ao transmitir para o relay: {{url}}. Erro: {{error}}',
'Write relays': 'Relés de escrita'
} }
} }

View File

@@ -309,7 +309,7 @@ export default {
'Load results': 'Загрузить результаты', 'Load results': 'Загрузить результаты',
'This is a poll note.': 'Это заметка с опросом.', 'This is a poll note.': 'Это заметка с опросом.',
'Unlike regular notes, polls are not widely supported and may not display on other clients.': 'Unlike regular notes, polls are not widely supported and may not display on other clients.':
'В отличие от обычных заметок, опросы не получили широкую поддержку и могут не отображаться в других клиентах.', 'В отличие от обычных заметок, опросы не получили широкой поддержки и могут не отображаться в других клиентах.',
'Option {{number}}': 'Вариант {{number}}', 'Option {{number}}': 'Вариант {{number}}',
'Add Option': 'Добавить вариант', 'Add Option': 'Добавить вариант',
'Allow multiple choices': 'Разрешить множественный выбор', 'Allow multiple choices': 'Разрешить множественный выбор',
@@ -319,6 +319,18 @@ export default {
'Remove poll': 'Удалить опрос', 'Remove poll': 'Удалить опрос',
'Refresh results': 'Обновить результаты', 'Refresh results': 'Обновить результаты',
Poll: 'Опрос', Poll: 'Опрос',
media: 'медиа' media: 'медиа',
'Broadcast to ...': 'Транслировать в...',
'Successfully broadcasted to your write relays': 'Успешно транслировано в ваши релеи записи',
'Failed to broadcast to your write relays: {{error}}':
'Ошибка трансляции в ваши релеи записи: {{error}}',
'Successfully broadcasted to relay set: {{name}}':
'Успешно транслировано в набор релеев: {{name}}',
'Failed to broadcast to relay set: {{name}}. Error: {{error}}':
'Ошибка трансляции в набор релеев: {{name}}. Ошибка: {{error}}',
'Successfully broadcasted to relay: {{url}}': 'Успешно транслировано в релей: {{url}}',
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
'Ошибка трансляции в релей: {{url}}. Ошибка: {{error}}',
'Write relays': 'Ретрансляторы для записи'
} }
} }

View File

@@ -313,6 +313,18 @@ export default {
'Remove poll': 'ลบโพลล์', 'Remove poll': 'ลบโพลล์',
'Refresh results': 'รีเฟรชผลลัพธ์', 'Refresh results': 'รีเฟรชผลลัพธ์',
Poll: 'โพลล์', Poll: 'โพลล์',
media: 'สื่อ' media: 'สื่อ',
'Broadcast to ...': 'ส่งสัญญาณไปยัง...',
'Successfully broadcasted to your write relays': 'ส่งสัญญาณไปยังรีเลย์การเขียนของคุณสำเร็จแล้ว',
'Failed to broadcast to your write relays: {{error}}':
'การส่งสัญญาณไปยังรีเลย์การเขียนของคุณล้มเหลว: {{error}}',
'Successfully broadcasted to relay set: {{name}}':
'ส่งสัญญาณไปยังชุดรีเลย์สำเร็จแล้ว: {{name}}',
'Failed to broadcast to relay set: {{name}}. Error: {{error}}':
'การส่งสัญญาณไปยังชุดรีเลย์ล้มเหลว: {{name}} ข้อผิดพลาด: {{error}}',
'Successfully broadcasted to relay: {{url}}': 'ส่งสัญญาณไปยังรีเลย์สำเร็จแล้ว: {{url}}',
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
'การส่งสัญญาณไปยังรีเลย์ล้มเหลว: {{url}} ข้อผิดพลาด: {{error}}',
'Write relays': 'รีเลย์การเขียน'
} }
} }

View File

@@ -314,6 +314,16 @@ export default {
'Remove poll': '移除投票', 'Remove poll': '移除投票',
'Refresh results': '刷新结果', 'Refresh results': '刷新结果',
Poll: '投票', Poll: '投票',
media: '媒体' media: '媒体',
'Broadcast to ...': '广播到...',
'Successfully broadcasted to your write relays': '成功广播到您的写服务器',
'Failed to broadcast to your write relays: {{error}}': '广播到您的写服务器失败:{{error}}',
'Successfully broadcasted to relay set: {{name}}': '成功广播到服务器组:{{name}}',
'Failed to broadcast to relay set: {{name}}. Error: {{error}}':
'广播到服务器组失败:{{name}}。错误:{{error}}',
'Successfully broadcasted to relay: {{url}}': '成功广播到服务器:{{url}}',
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
'广播到服务器失败:{{url}}。错误:{{error}}',
'Write relays': '写服务器'
} }
} }

View File

@@ -115,35 +115,42 @@ class ClientService extends EventTarget {
} }
async publishEvent(relayUrls: string[], event: NEvent) { async publishEvent(relayUrls: string[], event: NEvent) {
const uniqueRelayUrls = Array.from(new Set(relayUrls)) try {
const result = await Promise.any( const uniqueRelayUrls = Array.from(new Set(relayUrls))
uniqueRelayUrls.map(async (url) => { const result = await Promise.any(
// eslint-disable-next-line @typescript-eslint/no-this-alias uniqueRelayUrls.map(async (url) => {
const that = this // eslint-disable-next-line @typescript-eslint/no-this-alias
const relay = await this.pool.ensureRelay(url) const that = this
return relay const relay = await this.pool.ensureRelay(url)
.publish(event) return relay
.catch((error) => { .publish(event)
if ( .catch((error) => {
error instanceof Error && if (
error.message.startsWith('auth-required') && error instanceof Error &&
!!that.signer error.message.startsWith('auth-required') &&
) { !!that.signer
return relay ) {
.auth((authEvt: EventTemplate) => that.signer!.signEvent(authEvt)) return relay
.then(() => relay.publish(event)) .auth((authEvt: EventTemplate) => that.signer!.signEvent(authEvt))
} else { .then(() => relay.publish(event))
throw error } else {
} throw error
}) }
.then((reason) => { })
this.trackEventSeenOn(event.id, relay) .then((reason) => {
return reason this.trackEventSeenOn(event.id, relay)
}) return reason
}) })
) })
this.dispatchEvent(new CustomEvent('eventPublished', { detail: event })) )
return result this.dispatchEvent(new CustomEvent('eventPublished', { detail: event }))
return result
} catch (error) {
if (error instanceof AggregateError) {
throw error.errors[0]
}
throw error
}
} }
async signHttpAuth(url: string, method: string, description = '') { async signHttpAuth(url: string, method: string, description = '') {