Browse Source

add video calls

imwald
Silberengel 2 months ago
parent
commit
488c9275f7
  1. 14
      src/components/Note/index.tsx
  2. 13
      src/components/NoteOptions/index.tsx
  3. 56
      src/components/NoteOptions/useMenuActions.tsx
  4. 3
      src/components/PostEditor/index.tsx
  5. 123
      src/components/Profile/index.tsx
  6. 49
      src/components/ProfileOptions/index.tsx
  7. 4
      src/constants.ts
  8. 5
      src/i18n/locales/de.ts
  9. 5
      src/i18n/locales/en.ts
  10. 38
      src/lib/hivetalk.ts
  11. 18
      src/services/post-editor-cache.service.ts

14
src/components/Note/index.tsx

@ -74,16 +74,27 @@ export default function Note({
const [highlightDefaultContent, setHighlightDefaultContent] = useState<string>('') const [highlightDefaultContent, setHighlightDefaultContent] = useState<string>('')
const [postEditorOpen, setPostEditorOpen] = useState(false) const [postEditorOpen, setPostEditorOpen] = useState(false)
const [publicMessageTo, setPublicMessageTo] = useState<string | null>(null) const [publicMessageTo, setPublicMessageTo] = useState<string | null>(null)
const [callInviteContent, setCallInviteContent] = useState<string | null>(null)
const openHighlight = useCallback((data: HighlightData, eventContent?: string) => { const openHighlight = useCallback((data: HighlightData, eventContent?: string) => {
setHighlightData(data) setHighlightData(data)
setHighlightDefaultContent(eventContent ?? '') setHighlightDefaultContent(eventContent ?? '')
setPublicMessageTo(null) setPublicMessageTo(null)
setCallInviteContent(null)
setPostEditorOpen(true) setPostEditorOpen(true)
}, []) }, [])
const openPublicMessage = useCallback((pubkey: string) => { const openPublicMessage = useCallback((pubkey: string) => {
setPublicMessageTo(pubkey) setPublicMessageTo(pubkey)
setCallInviteContent(null)
setPostEditorOpen(true)
}, [])
const openCallInvite = useCallback((url: string) => {
setCallInviteContent(url)
setPublicMessageTo(null)
setHighlightData(undefined)
setHighlightDefaultContent('')
setPostEditorOpen(true) setPostEditorOpen(true)
}, []) }, [])
@ -284,9 +295,12 @@ export default function Note({
setHighlightData(undefined) setHighlightData(undefined)
setHighlightDefaultContent('') setHighlightDefaultContent('')
setPublicMessageTo(null) setPublicMessageTo(null)
setCallInviteContent(null)
}} }}
onOpenPublicMessage={openPublicMessage} onOpenPublicMessage={openPublicMessage}
initialPublicMessageTo={publicMessageTo} initialPublicMessageTo={publicMessageTo}
onOpenCallInvite={openCallInvite}
initialDefaultContent={callInviteContent}
/> />
)} )}
</div> </div>

13
src/components/NoteOptions/index.tsx

@ -18,7 +18,9 @@ export default function NoteOptions({
isPostEditorOpen, isPostEditorOpen,
onPostEditorClose, onPostEditorClose,
onOpenPublicMessage, onOpenPublicMessage,
initialPublicMessageTo initialPublicMessageTo,
onOpenCallInvite,
initialDefaultContent
}: { }: {
event: Event event: Event
className?: string className?: string
@ -30,6 +32,10 @@ export default function NoteOptions({
onOpenPublicMessage?: (pubkey: string) => void onOpenPublicMessage?: (pubkey: string) => void
/** When set, the post editor is opened in public message mode with this pubkey pre-filled. */ /** When set, the post editor is opened in public message mode with this pubkey pre-filled. */
initialPublicMessageTo?: string | null initialPublicMessageTo?: string | null
/** Opens the post editor with the given content (e.g. call invite URL). */
onOpenCallInvite?: (url: string) => void
/** Default content when opening the editor (e.g. call invite URL). */
initialDefaultContent?: string | null
}) { }) {
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const [isRawEventDialogOpen, setIsRawEventDialogOpen] = useState(false) const [isRawEventDialogOpen, setIsRawEventDialogOpen] = useState(false)
@ -61,7 +67,8 @@ export default function NoteOptions({
setIsRawEventDialogOpen, setIsRawEventDialogOpen,
setIsReportDialogOpen, setIsReportDialogOpen,
isSmallScreen, isSmallScreen,
onOpenPublicMessage onOpenPublicMessage,
onOpenCallInvite
}) })
const trigger = useMemo( const trigger = useMemo(
@ -110,7 +117,7 @@ export default function NoteOptions({
setOpen={(open) => { setOpen={(open) => {
if (!open) onPostEditorClose() if (!open) onPostEditorClose()
}} }}
defaultContent={highlightDefaultContent ?? ''} defaultContent={initialDefaultContent ?? highlightDefaultContent ?? ''}
initialHighlightData={initialHighlightData} initialHighlightData={initialHighlightData}
initialPublicMessageTo={initialPublicMessageTo ?? undefined} initialPublicMessageTo={initialPublicMessageTo ?? undefined}
/> />

56
src/components/NoteOptions/useMenuActions.tsx

@ -1,9 +1,10 @@
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { getNoteBech32Id, isProtectedEvent, getRootEventHexId } from '@/lib/event' import { getNoteBech32Id, isProtectedEvent, getRootEventHexId } from '@/lib/event'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { buildHiveTalkJoinUrl } from '@/lib/hivetalk'
import { toAlexandria } from '@/lib/link' import { toAlexandria } from '@/lib/link'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { pubkeyToNpub } from '@/lib/pubkey' import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey'
import { normalizeUrl, simplifyUrl } from '@/lib/url' import { normalizeUrl, simplifyUrl } from '@/lib/url'
import { generateBech32IdFromATag } from '@/lib/tag' import { generateBech32IdFromATag } from '@/lib/tag'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
@ -13,7 +14,7 @@ import { useNostr } from '@/providers/NostrProvider'
import { BIG_RELAY_URLS, FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants' import { BIG_RELAY_URLS, FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants'
import client from '@/services/client.service' import client from '@/services/client.service'
import { nip66Service } from '@/services/nip66.service' import { nip66Service } from '@/services/nip66.service'
import { Bell, BellOff, Code, Copy, Link, SatelliteDish, Trash2, TriangleAlert, Pin, FileDown, Globe, BookOpen, MessageCircle } from 'lucide-react' import { Bell, BellOff, Code, Copy, Link, SatelliteDish, Trash2, TriangleAlert, Pin, FileDown, Globe, BookOpen, MessageCircle, Send, Video } from 'lucide-react'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { nip19 } from 'nostr-tools' import { nip19 } from 'nostr-tools'
import { useMemo, useState, useEffect, useContext } from 'react' import { useMemo, useState, useEffect, useContext } from 'react'
@ -48,6 +49,8 @@ interface UseMenuActionsProps {
isSmallScreen: boolean isSmallScreen: boolean
/** When provided, adds "Send public message" to open composer with this pubkey in the mention list. */ /** When provided, adds "Send public message" to open composer with this pubkey in the mention list. */
onOpenPublicMessage?: (pubkey: string) => void onOpenPublicMessage?: (pubkey: string) => void
/** When provided, adds "Send call invite" to open composer with the call URL as content. */
onOpenCallInvite?: (url: string) => void
} }
export function useMenuActions({ export function useMenuActions({
@ -58,12 +61,13 @@ export function useMenuActions({
setIsReportDialogOpen, setIsReportDialogOpen,
isSmallScreen, isSmallScreen,
onOpenPublicMessage, onOpenPublicMessage,
onOpenCallInvite,
}: UseMenuActionsProps) { }: UseMenuActionsProps) {
const { t } = useTranslation() const { t } = useTranslation()
// Use useContext directly to avoid error if provider is not available // Use useContext directly to avoid error if provider is not available
const primaryPageContext = useContext(PrimaryPageContext) const primaryPageContext = useContext(PrimaryPageContext)
const currentPrimaryPage = primaryPageContext?.current ?? null const currentPrimaryPage = primaryPageContext?.current ?? null
const { pubkey, attemptDelete, publish } = useNostr() const { pubkey, profile, attemptDelete, publish } = useNostr()
const { relayUrls: currentBrowsingRelayUrls } = useCurrentRelays() const { relayUrls: currentBrowsingRelayUrls } = useCurrentRelays()
const { relaySets, favoriteRelays } = useFavoriteRelays() const { relaySets, favoriteRelays } = useFavoriteRelays()
const relayUrls = useMemo(() => { const relayUrls = useMemo(() => {
@ -624,7 +628,46 @@ export function useMenuActions({
navigator.clipboard.writeText(toAlexandria(getNoteBech32Id(event))) navigator.clipboard.writeText(toAlexandria(getNoteBech32Id(event)))
closeDrawer() closeDrawer()
} }
} },
{
icon: Video,
label: t('Start call about this'),
separator: true,
onClick: () => {
closeDrawer()
const roomId = `jumble-note-${event.id}`
const displayName = pubkey ? (profile?.username ?? formatPubkey(pubkey)) : 'jumble'
const url = buildHiveTalkJoinUrl({ room: roomId, name: displayName })
window.open(url, '_blank', 'noopener,noreferrer')
}
},
{
icon: Copy,
label: t('Copy call invite link'),
onClick: () => {
closeDrawer()
const roomId = `jumble-note-${event.id}`
const displayName = pubkey ? (profile?.username ?? formatPubkey(pubkey)) : 'jumble'
const url = buildHiveTalkJoinUrl({ room: roomId, name: displayName })
navigator.clipboard.writeText(url)
toast.success(t('Copied to clipboard'))
}
},
...(onOpenCallInvite
? [
{
icon: Send,
label: t('Send call invite'),
onClick: () => {
closeDrawer()
const roomId = `jumble-note-${event.id}`
const displayName = pubkey ? (profile?.username ?? formatPubkey(pubkey)) : 'jumble'
const url = buildHiveTalkJoinUrl({ room: roomId, name: displayName })
onOpenCallInvite(`${t('Join the video call')}: ${url}`)
}
} as MenuAction
]
: [])
] ]
// Add "View on Alexandria" menu item for public messages (PMs) // Add "View on Alexandria" menu item for public messages (PMs)
@ -830,7 +873,10 @@ export function useMenuActions({
isArticleType, isArticleType,
articleMetadata, articleMetadata,
dTag, dTag,
naddr naddr,
onOpenPublicMessage,
onOpenCallInvite,
profile
]) ])
return menuActions return menuActions

3
src/components/PostEditor/index.tsx

@ -43,7 +43,8 @@ export default function PostEditor({
const effectiveDefaultContent = useMemo(() => { const effectiveDefaultContent = useMemo(() => {
if (initialPublicMessageTo) { if (initialPublicMessageTo) {
const npub = pubkeyToNpub(initialPublicMessageTo) const npub = pubkeyToNpub(initialPublicMessageTo)
return npub ? `nostr:${npub} ` : defaultContent const suffix = defaultContent ? ` ${defaultContent}` : ' '
return npub ? `nostr:${npub}${suffix}`.trimEnd() : defaultContent
} }
return defaultContent return defaultContent
}, [initialPublicMessageTo, defaultContent]) }, [initialPublicMessageTo, defaultContent])

123
src/components/Profile/index.tsx

@ -164,6 +164,7 @@ export default function Profile({ id }: { id?: string }) {
const [paymentInfo, setPaymentInfo] = useState<ReturnType<typeof getPaymentInfoFromEvent> | null>(null) const [paymentInfo, setPaymentInfo] = useState<ReturnType<typeof getPaymentInfoFromEvent> | null>(null)
const [openZapDialog, setOpenZapDialog] = useState(false) const [openZapDialog, setOpenZapDialog] = useState(false)
const [openPublicMessageTo, setOpenPublicMessageTo] = useState<string | null>(null) const [openPublicMessageTo, setOpenPublicMessageTo] = useState<string | null>(null)
const [openCallInviteTo, setOpenCallInviteTo] = useState<{ pubkey: string; url: string } | null>(null)
const mergedPaymentMethods = useMemo(() => { const mergedPaymentMethods = useMemo(() => {
const list = mergePaymentMethods(paymentInfo, profile ?? null) const list = mergePaymentMethods(paymentInfo, profile ?? null)
@ -173,6 +174,25 @@ export default function Profile({ id }: { id?: string }) {
}) })
}, [paymentInfo, profile]) }, [paymentInfo, profile])
/** Group payment methods by displayType so same-type addresses render under one heading */
const paymentMethodsByType = useMemo(() => {
const rank = (type: string) => (type === 'lightning' ? 0 : type === 'bitcoin' ? 1 : 2)
const groups = new Map<string, MergedPaymentMethod[]>()
for (const method of mergedPaymentMethods) {
const key = method.displayType || method.type
if (!groups.has(key)) groups.set(key, [])
groups.get(key)!.push(method)
}
const order = Array.from(groups.keys()).sort((a, b) => {
const arrA = groups.get(a)
const arrB = groups.get(b)
const typeA = arrA?.[0]?.type ?? ''
const typeB = arrB?.[0]?.type ?? ''
return rank(typeA) - rank(typeB)
})
return order.map((key) => ({ displayType: key, methods: groups.get(key) ?? [] }))
}, [mergedPaymentMethods])
// Fetch payment info (kind 10133) for this profile; uses cached replaceable events and IndexedDB // Fetch payment info (kind 10133) for this profile; uses cached replaceable events and IndexedDB
useEffect(() => { useEffect(() => {
if (!profile?.pubkey) { if (!profile?.pubkey) {
@ -431,6 +451,11 @@ export default function Profile({ id }: { id?: string }) {
<ProfileOptions <ProfileOptions
pubkey={pubkey} pubkey={pubkey}
onSendPublicMessage={!isSelf ? () => setOpenPublicMessageTo(pubkey) : undefined} onSendPublicMessage={!isSelf ? () => setOpenPublicMessageTo(pubkey) : undefined}
onSendCallInvite={
!isSelf
? (url) => setOpenCallInviteTo({ pubkey, url })
: undefined
}
/> />
{isSelf ? ( {isSelf ? (
<div className="flex gap-2"> <div className="flex gap-2">
@ -514,50 +539,56 @@ export default function Profile({ id }: { id?: string }) {
</div> </div>
)} )}
{/* Payment methods: merged from kind 10133 + profile lightning, deduplicated – use PaytoLink for consistent behavior */} {/* Payment methods: merged from kind 10133 + profile lightning, deduplicated – use PaytoLink for consistent behavior */}
{mergedPaymentMethods.length > 0 && ( {paymentMethodsByType.length > 0 && (
<div className="mt-2 p-2 border rounded-lg bg-muted/50 min-w-0 overflow-hidden"> <div className="mt-2 p-2 border rounded-lg bg-muted/50 min-w-0 overflow-hidden">
<div className="text-xs font-semibold text-muted-foreground mb-2">Payment Methods</div> <div className="text-xs font-semibold text-muted-foreground mb-2">Payment Methods</div>
<div className="space-y-2 min-w-0"> <div className="space-y-3 min-w-0">
{mergedPaymentMethods.map((method, idx) => ( {paymentMethodsByType.map((group, groupIdx) => (
<div key={idx} className="text-sm min-w-0"> <div key={groupIdx} className="text-sm min-w-0">
<div className="font-medium">{method.displayType}</div> <div className="font-medium">{group.displayType}</div>
{method.authority && ( <div className="space-y-1.5 mt-1">
<div className="text-muted-foreground mt-1 flex items-center gap-1 min-w-0"> {group.methods.map((method, idx) => (
<PaytoLink <div key={idx} className="min-w-0">
type={method.type} {method.authority && (
authority={method.authority} <div className="text-muted-foreground flex items-center gap-1 min-w-0">
paytoUri={method.payto} <PaytoLink
pubkey={method.type === 'lightning' ? pubkey : undefined} type={method.type}
onOpenZap={method.type === 'lightning' ? () => setOpenZapDialog(true) : undefined} authority={method.authority}
className="hover:underline break-all min-w-0 text-primary flex-1" paytoUri={method.payto}
> pubkey={method.type === 'lightning' ? pubkey : undefined}
{method.authority} onOpenZap={method.type === 'lightning' ? () => setOpenZapDialog(true) : undefined}
</PaytoLink> className="hover:underline break-all min-w-0 text-primary flex-1"
<button >
type="button" {method.authority}
onClick={(e) => { </PaytoLink>
e.preventDefault() <button
e.stopPropagation() type="button"
navigator.clipboard.writeText(method.authority) onClick={(e) => {
toast.success(t('Copied to clipboard')) e.preventDefault()
}} e.stopPropagation()
className="shrink-0 p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted" navigator.clipboard.writeText(method.authority)
title={t('Copy address')} toast.success(t('Copied to clipboard'))
> }}
<Copy className="size-3.5" /> className="shrink-0 p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted"
</button> title={t('Copy address')}
</div> >
)} <Copy className="size-3.5" />
{(method.currency || (method.minAmount !== undefined && method.maxAmount !== undefined)) && ( </button>
<div className="text-muted-foreground text-xs mt-1"> </div>
{method.currency && <span>({method.currency})</span>} )}
{method.minAmount !== undefined && method.maxAmount !== undefined && ( {(method.currency || (method.minAmount !== undefined && method.maxAmount !== undefined)) && (
<span className="ml-2"> <div className="text-muted-foreground text-xs mt-0.5">
{method.minAmount}-{method.maxAmount} {method.currency && <span>({method.currency})</span>}
</span> {method.minAmount !== undefined && method.maxAmount !== undefined && (
)} <span className="ml-2">
</div> {method.minAmount}-{method.maxAmount}
)} </span>
)}
</div>
)}
</div>
))}
</div>
</div> </div>
))} ))}
</div> </div>
@ -772,6 +803,14 @@ export default function Profile({ id }: { id?: string }) {
initialPublicMessageTo={openPublicMessageTo} initialPublicMessageTo={openPublicMessageTo}
/> />
)} )}
{openCallInviteTo && (
<PostEditor
open={!!openCallInviteTo}
setOpen={(open) => !open && setOpenCallInviteTo(null)}
initialPublicMessageTo={openCallInviteTo.pubkey}
defaultContent={`${t('Join the video call')}: ${openCallInviteTo.url}`}
/>
)}
</> </>
) )
} }

49
src/components/ProfileOptions/index.tsx

@ -3,30 +3,44 @@ import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger DropdownMenuTrigger
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { pubkeyToNpub } from '@/lib/pubkey' import { buildHiveTalkJoinUrl, roomIdForPubkeys } from '@/lib/hivetalk'
import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey'
import { useMuteList } from '@/providers/MuteListProvider' import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { Bell, BellOff, Copy, Ellipsis, MessageCircle } from 'lucide-react' import { Bell, BellOff, Copy, Ellipsis, MessageCircle, Send, Video } from 'lucide-react'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
export default function ProfileOptions({ export default function ProfileOptions({
pubkey, pubkey,
onSendPublicMessage onSendPublicMessage,
onSendCallInvite
}: { }: {
pubkey: string pubkey: string
/** Opens the post editor in public message mode with this profile's pubkey in the mention list. */ /** Opens the post editor in public message mode with this profile's pubkey in the mention list. */
onSendPublicMessage?: () => void onSendPublicMessage?: () => void
/** Opens the post editor to send the call invite URL as a public message to this profile. */
onSendCallInvite?: (url: string) => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey: accountPubkey } = useNostr() const { pubkey: accountPubkey, profile } = useNostr()
const { mutePubkeySet, mutePubkeyPrivately, mutePubkeyPublicly, unmutePubkey } = useMuteList() const { mutePubkeySet, mutePubkeyPrivately, mutePubkeyPublicly, unmutePubkey } = useMuteList()
const isMuted = useMemo(() => mutePubkeySet.has(pubkey), [mutePubkeySet, pubkey]) const isMuted = useMemo(() => mutePubkeySet.has(pubkey), [mutePubkeySet, pubkey])
const displayName = profile?.username ?? (accountPubkey ? formatPubkey(accountPubkey) : 'jumble')
if (pubkey === accountPubkey) return null if (pubkey === accountPubkey) return null
const callInviteUrl =
accountPubkey &&
buildHiveTalkJoinUrl({
room: roomIdForPubkeys(accountPubkey, pubkey),
name: displayName
})
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@ -41,6 +55,33 @@ export default function ProfileOptions({
{t('Send public message')} {t('Send public message')}
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{callInviteUrl && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => window.open(callInviteUrl, '_blank', 'noopener,noreferrer')}
>
<Video />
{t('Start video call')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
navigator.clipboard.writeText(callInviteUrl)
toast.success(t('Copied to clipboard'))
}}
>
<Copy />
{t('Copy call invite link')}
</DropdownMenuItem>
{onSendCallInvite && (
<DropdownMenuItem onClick={() => onSendCallInvite(callInviteUrl)}>
<Send />
{t('Send call invite')}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem <DropdownMenuItem
onClick={() => navigator.clipboard.writeText('nostr:' + pubkeyToNpub(pubkey))} onClick={() => navigator.clipboard.writeText('nostr:' + pubkeyToNpub(pubkey))}
> >

4
src/constants.ts

@ -4,6 +4,10 @@ import { kinds } from 'nostr-tools'
export const JUMBLE_API_BASE_URL = export const JUMBLE_API_BASE_URL =
(import.meta.env.VITE_JUMBLE_API_BASE_URL as string | undefined) ?? 'https://api.jumble.imwald.eu' (import.meta.env.VITE_JUMBLE_API_BASE_URL as string | undefined) ?? 'https://api.jumble.imwald.eu'
/** HiveTalk (WebRTC video call) base URL; override with VITE_HIVETALK_BASE_URL for self-hosted instances. */
export const HIVETALK_BASE_URL =
(import.meta.env.VITE_HIVETALK_BASE_URL as string | undefined) ?? 'https://vanilla.hivetalk.org'
export const DEFAULT_FAVORITE_RELAYS = [ export const DEFAULT_FAVORITE_RELAYS = [
'wss://theforest.nostr1.com', 'wss://theforest.nostr1.com',
'wss://orly-relay.imwald.eu', 'wss://orly-relay.imwald.eu',

5
src/i18n/locales/de.ts

@ -66,6 +66,11 @@ export default {
Rename: 'Umbenennen', Rename: 'Umbenennen',
'Share with Jumble': 'Mit Jumble teilen', 'Share with Jumble': 'Mit Jumble teilen',
'Share with Alexandria': 'Mit Alexandria teilen', 'Share with Alexandria': 'Mit Alexandria teilen',
'Start video call': 'Videoanruf starten',
'Copy call invite link': 'Anruf-Einladungslink kopieren',
'Start call about this': 'Anruf zu diesem Beitrag starten',
'Send call invite': 'Anruf-Einladung senden',
'Join the video call': 'Am Videoanruf teilnehmen',
Delete: 'Löschen', Delete: 'Löschen',
'Relay already exists': 'Relay existiert bereits', 'Relay already exists': 'Relay existiert bereits',
'invalid relay URL': 'Ungültige Relay-URL', 'invalid relay URL': 'Ungültige Relay-URL',

5
src/i18n/locales/en.ts

@ -120,6 +120,11 @@ export default {
'Saving…': 'Saving…', 'Saving…': 'Saving…',
'Share with Jumble': 'Share with Jumble', 'Share with Jumble': 'Share with Jumble',
'Share with Alexandria': 'Share with Alexandria', 'Share with Alexandria': 'Share with Alexandria',
'Start video call': 'Start video call',
'Copy call invite link': 'Copy call invite link',
'Start call about this': 'Start call about this',
'Send call invite': 'Send call invite',
'Join the video call': 'Join the video call',
Delete: 'Delete', Delete: 'Delete',
'Relay already exists': 'Relay already exists', 'Relay already exists': 'Relay already exists',
'invalid relay URL': 'invalid relay URL', 'invalid relay URL': 'invalid relay URL',

38
src/lib/hivetalk.ts

@ -0,0 +1,38 @@
import { HIVETALK_BASE_URL } from '@/constants'
export interface HiveTalkJoinParams {
room: string
name: string
roomPassword?: string
audio?: boolean
video?: boolean
screen?: boolean
notify?: boolean
hide?: boolean
token?: string
}
/**
* Build a HiveTalk direct-join URL. See https://github.com/HiveTalk/hivetalksfu#direct-join
*/
export function buildHiveTalkJoinUrl(params: HiveTalkJoinParams): string {
const url = new URL('/join', HIVETALK_BASE_URL)
url.searchParams.set('room', params.room)
url.searchParams.set('name', params.name)
url.searchParams.set('roomPassword', params.roomPassword ?? '0')
url.searchParams.set('audio', params.audio !== false ? '1' : '0')
url.searchParams.set('video', params.video !== false ? '1' : '0')
url.searchParams.set('screen', params.screen ? '1' : '0')
url.searchParams.set('notify', params.notify !== false ? '1' : '0')
if (params.hide !== undefined) url.searchParams.set('hide', params.hide ? '1' : '0')
if (params.token) url.searchParams.set('token', params.token)
return url.toString()
}
/** Deterministic room id for a 1:1 call between two pubkeys (same room from either side). */
export function roomIdForPubkeys(pubkeyA: string, pubkeyB: string): string {
const [a, b] = [pubkeyA, pubkeyB].sort()
const shortA = a.slice(0, 8)
const shortB = b.slice(0, 8)
return `jumble-${shortA}-${shortB}`
}

18
src/services/post-editor-cache.service.ts

@ -22,14 +22,24 @@ class PostEditorCacheService {
return PostEditorCacheService.instance return PostEditorCacheService.instance
} }
/**
* Escape ampersands so that when TipTap parses initial content as HTML,
* sequences like &notify in URLs are not interpreted as the &not; entity (¬).
*/
private escapeAmpersandsForHtml(text: string): string {
return text.replace(/&/g, '&amp;')
}
getPostContentCache({ getPostContentCache({
defaultContent, defaultContent,
parentEvent parentEvent
}: { defaultContent?: string; parentEvent?: Event } = {}) { }: { defaultContent?: string; parentEvent?: Event } = {}) {
return ( const cached = this.postContentCache.get(this.generateCacheKey(defaultContent, parentEvent))
this.postContentCache.get(this.generateCacheKey(defaultContent, parentEvent)) ?? if (cached !== undefined) return cached
defaultContent if (defaultContent !== undefined && defaultContent !== '') {
) return this.escapeAmpersandsForHtml(defaultContent)
}
return defaultContent
} }
setPostContentCache( setPostContentCache(

Loading…
Cancel
Save