From 488c9275f786aacbed4941f36d7e28d6da8a823a Mon Sep 17 00:00:00 2001 From: Silberengel Date: Tue, 17 Mar 2026 08:26:22 +0100 Subject: [PATCH] add video calls --- src/components/Note/index.tsx | 14 ++ src/components/NoteOptions/index.tsx | 13 +- src/components/NoteOptions/useMenuActions.tsx | 56 +++++++- src/components/PostEditor/index.tsx | 3 +- src/components/Profile/index.tsx | 123 ++++++++++++------ src/components/ProfileOptions/index.tsx | 49 ++++++- src/constants.ts | 4 + src/i18n/locales/de.ts | 5 + src/i18n/locales/en.ts | 5 + src/lib/hivetalk.ts | 38 ++++++ src/services/post-editor-cache.service.ts | 18 ++- 11 files changed, 269 insertions(+), 59 deletions(-) create mode 100644 src/lib/hivetalk.ts diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index 22db0e3f..7044c7c3 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -74,16 +74,27 @@ export default function Note({ const [highlightDefaultContent, setHighlightDefaultContent] = useState('') const [postEditorOpen, setPostEditorOpen] = useState(false) const [publicMessageTo, setPublicMessageTo] = useState(null) + const [callInviteContent, setCallInviteContent] = useState(null) const openHighlight = useCallback((data: HighlightData, eventContent?: string) => { setHighlightData(data) setHighlightDefaultContent(eventContent ?? '') setPublicMessageTo(null) + setCallInviteContent(null) setPostEditorOpen(true) }, []) const openPublicMessage = useCallback((pubkey: string) => { setPublicMessageTo(pubkey) + setCallInviteContent(null) + setPostEditorOpen(true) + }, []) + + const openCallInvite = useCallback((url: string) => { + setCallInviteContent(url) + setPublicMessageTo(null) + setHighlightData(undefined) + setHighlightDefaultContent('') setPostEditorOpen(true) }, []) @@ -284,9 +295,12 @@ export default function Note({ setHighlightData(undefined) setHighlightDefaultContent('') setPublicMessageTo(null) + setCallInviteContent(null) }} onOpenPublicMessage={openPublicMessage} initialPublicMessageTo={publicMessageTo} + onOpenCallInvite={openCallInvite} + initialDefaultContent={callInviteContent} /> )} diff --git a/src/components/NoteOptions/index.tsx b/src/components/NoteOptions/index.tsx index 160946a2..9364b237 100644 --- a/src/components/NoteOptions/index.tsx +++ b/src/components/NoteOptions/index.tsx @@ -18,7 +18,9 @@ export default function NoteOptions({ isPostEditorOpen, onPostEditorClose, onOpenPublicMessage, - initialPublicMessageTo + initialPublicMessageTo, + onOpenCallInvite, + initialDefaultContent }: { event: Event className?: string @@ -30,6 +32,10 @@ export default function NoteOptions({ onOpenPublicMessage?: (pubkey: string) => void /** When set, the post editor is opened in public message mode with this pubkey pre-filled. */ 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 [isRawEventDialogOpen, setIsRawEventDialogOpen] = useState(false) @@ -61,7 +67,8 @@ export default function NoteOptions({ setIsRawEventDialogOpen, setIsReportDialogOpen, isSmallScreen, - onOpenPublicMessage + onOpenPublicMessage, + onOpenCallInvite }) const trigger = useMemo( @@ -110,7 +117,7 @@ export default function NoteOptions({ setOpen={(open) => { if (!open) onPostEditorClose() }} - defaultContent={highlightDefaultContent ?? ''} + defaultContent={initialDefaultContent ?? highlightDefaultContent ?? ''} initialHighlightData={initialHighlightData} initialPublicMessageTo={initialPublicMessageTo ?? undefined} /> diff --git a/src/components/NoteOptions/useMenuActions.tsx b/src/components/NoteOptions/useMenuActions.tsx index c01960ae..7d66854f 100644 --- a/src/components/NoteOptions/useMenuActions.tsx +++ b/src/components/NoteOptions/useMenuActions.tsx @@ -1,9 +1,10 @@ import { ExtendedKind } from '@/constants' import { getNoteBech32Id, isProtectedEvent, getRootEventHexId } from '@/lib/event' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' +import { buildHiveTalkJoinUrl } from '@/lib/hivetalk' import { toAlexandria } from '@/lib/link' import logger from '@/lib/logger' -import { pubkeyToNpub } from '@/lib/pubkey' +import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' import { normalizeUrl, simplifyUrl } from '@/lib/url' import { generateBech32IdFromATag } from '@/lib/tag' 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 client from '@/services/client.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 { nip19 } from 'nostr-tools' import { useMemo, useState, useEffect, useContext } from 'react' @@ -48,6 +49,8 @@ interface UseMenuActionsProps { isSmallScreen: boolean /** When provided, adds "Send public message" to open composer with this pubkey in the mention list. */ 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({ @@ -58,12 +61,13 @@ export function useMenuActions({ setIsReportDialogOpen, isSmallScreen, onOpenPublicMessage, + onOpenCallInvite, }: UseMenuActionsProps) { const { t } = useTranslation() // Use useContext directly to avoid error if provider is not available const primaryPageContext = useContext(PrimaryPageContext) const currentPrimaryPage = primaryPageContext?.current ?? null - const { pubkey, attemptDelete, publish } = useNostr() + const { pubkey, profile, attemptDelete, publish } = useNostr() const { relayUrls: currentBrowsingRelayUrls } = useCurrentRelays() const { relaySets, favoriteRelays } = useFavoriteRelays() const relayUrls = useMemo(() => { @@ -624,7 +628,46 @@ export function useMenuActions({ navigator.clipboard.writeText(toAlexandria(getNoteBech32Id(event))) 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) @@ -830,7 +873,10 @@ export function useMenuActions({ isArticleType, articleMetadata, dTag, - naddr + naddr, + onOpenPublicMessage, + onOpenCallInvite, + profile ]) return menuActions diff --git a/src/components/PostEditor/index.tsx b/src/components/PostEditor/index.tsx index 41be9a05..571df99f 100644 --- a/src/components/PostEditor/index.tsx +++ b/src/components/PostEditor/index.tsx @@ -43,7 +43,8 @@ export default function PostEditor({ const effectiveDefaultContent = useMemo(() => { if (initialPublicMessageTo) { const npub = pubkeyToNpub(initialPublicMessageTo) - return npub ? `nostr:${npub} ` : defaultContent + const suffix = defaultContent ? ` ${defaultContent}` : ' ' + return npub ? `nostr:${npub}${suffix}`.trimEnd() : defaultContent } return defaultContent }, [initialPublicMessageTo, defaultContent]) diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index a6a5e650..1af827b9 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -164,6 +164,7 @@ export default function Profile({ id }: { id?: string }) { const [paymentInfo, setPaymentInfo] = useState | null>(null) const [openZapDialog, setOpenZapDialog] = useState(false) const [openPublicMessageTo, setOpenPublicMessageTo] = useState(null) + const [openCallInviteTo, setOpenCallInviteTo] = useState<{ pubkey: string; url: string } | null>(null) const mergedPaymentMethods = useMemo(() => { const list = mergePaymentMethods(paymentInfo, profile ?? null) @@ -173,6 +174,25 @@ export default function Profile({ id }: { id?: string }) { }) }, [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() + 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 useEffect(() => { if (!profile?.pubkey) { @@ -431,6 +451,11 @@ export default function Profile({ id }: { id?: string }) { setOpenPublicMessageTo(pubkey) : undefined} + onSendCallInvite={ + !isSelf + ? (url) => setOpenCallInviteTo({ pubkey, url }) + : undefined + } /> {isSelf ? (
@@ -514,50 +539,56 @@ export default function Profile({ id }: { id?: string }) {
)} {/* Payment methods: merged from kind 10133 + profile lightning, deduplicated – use PaytoLink for consistent behavior */} - {mergedPaymentMethods.length > 0 && ( + {paymentMethodsByType.length > 0 && (
Payment Methods
-
- {mergedPaymentMethods.map((method, idx) => ( -
-
{method.displayType}
- {method.authority && ( -
- setOpenZapDialog(true) : undefined} - className="hover:underline break-all min-w-0 text-primary flex-1" - > - {method.authority} - - -
- )} - {(method.currency || (method.minAmount !== undefined && method.maxAmount !== undefined)) && ( -
- {method.currency && ({method.currency})} - {method.minAmount !== undefined && method.maxAmount !== undefined && ( - - {method.minAmount}-{method.maxAmount} - - )} -
- )} +
+ {paymentMethodsByType.map((group, groupIdx) => ( +
+
{group.displayType}
+
+ {group.methods.map((method, idx) => ( +
+ {method.authority && ( +
+ setOpenZapDialog(true) : undefined} + className="hover:underline break-all min-w-0 text-primary flex-1" + > + {method.authority} + + +
+ )} + {(method.currency || (method.minAmount !== undefined && method.maxAmount !== undefined)) && ( +
+ {method.currency && ({method.currency})} + {method.minAmount !== undefined && method.maxAmount !== undefined && ( + + {method.minAmount}-{method.maxAmount} + + )} +
+ )} +
+ ))} +
))}
@@ -772,6 +803,14 @@ export default function Profile({ id }: { id?: string }) { initialPublicMessageTo={openPublicMessageTo} /> )} + {openCallInviteTo && ( + !open && setOpenCallInviteTo(null)} + initialPublicMessageTo={openCallInviteTo.pubkey} + defaultContent={`${t('Join the video call')}: ${openCallInviteTo.url}`} + /> + )} ) } diff --git a/src/components/ProfileOptions/index.tsx b/src/components/ProfileOptions/index.tsx index 8510e1fc..8ffeabd3 100644 --- a/src/components/ProfileOptions/index.tsx +++ b/src/components/ProfileOptions/index.tsx @@ -3,30 +3,44 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger } 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 { 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 { useTranslation } from 'react-i18next' +import { toast } from 'sonner' export default function ProfileOptions({ pubkey, - onSendPublicMessage + onSendPublicMessage, + onSendCallInvite }: { pubkey: string /** Opens the post editor in public message mode with this profile's pubkey in the mention list. */ 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 { pubkey: accountPubkey } = useNostr() + const { pubkey: accountPubkey, profile } = useNostr() const { mutePubkeySet, mutePubkeyPrivately, mutePubkeyPublicly, unmutePubkey } = useMuteList() const isMuted = useMemo(() => mutePubkeySet.has(pubkey), [mutePubkeySet, pubkey]) + const displayName = profile?.username ?? (accountPubkey ? formatPubkey(accountPubkey) : 'jumble') if (pubkey === accountPubkey) return null + const callInviteUrl = + accountPubkey && + buildHiveTalkJoinUrl({ + room: roomIdForPubkeys(accountPubkey, pubkey), + name: displayName + }) + return ( @@ -41,6 +55,33 @@ export default function ProfileOptions({ {t('Send public message')} )} + {callInviteUrl && ( + <> + + window.open(callInviteUrl, '_blank', 'noopener,noreferrer')} + > + + { + navigator.clipboard.writeText(callInviteUrl) + toast.success(t('Copied to clipboard')) + }} + > + + {t('Copy call invite link')} + + {onSendCallInvite && ( + onSendCallInvite(callInviteUrl)}> + + {t('Send call invite')} + + )} + + + )} navigator.clipboard.writeText('nostr:' + pubkeyToNpub(pubkey))} > diff --git a/src/constants.ts b/src/constants.ts index f4dd438e..48bed782 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -4,6 +4,10 @@ import { kinds } from 'nostr-tools' export const JUMBLE_API_BASE_URL = (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 = [ 'wss://theforest.nostr1.com', 'wss://orly-relay.imwald.eu', diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 9af1d876..c3331d21 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -66,6 +66,11 @@ export default { Rename: 'Umbenennen', 'Share with Jumble': 'Mit Jumble 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', 'Relay already exists': 'Relay existiert bereits', 'invalid relay URL': 'Ungültige Relay-URL', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 70e25bb4..0be3684a 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -120,6 +120,11 @@ export default { 'Saving…': 'Saving…', 'Share with Jumble': 'Share with Jumble', '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', 'Relay already exists': 'Relay already exists', 'invalid relay URL': 'invalid relay URL', diff --git a/src/lib/hivetalk.ts b/src/lib/hivetalk.ts new file mode 100644 index 00000000..551ff5f9 --- /dev/null +++ b/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}` +} diff --git a/src/services/post-editor-cache.service.ts b/src/services/post-editor-cache.service.ts index f2ac915e..da754c0e 100644 --- a/src/services/post-editor-cache.service.ts +++ b/src/services/post-editor-cache.service.ts @@ -22,14 +22,24 @@ class PostEditorCacheService { return PostEditorCacheService.instance } + /** + * Escape ampersands so that when TipTap parses initial content as HTML, + * sequences like ¬ify in URLs are not interpreted as the ¬ entity (¬). + */ + private escapeAmpersandsForHtml(text: string): string { + return text.replace(/&/g, '&') + } + getPostContentCache({ defaultContent, parentEvent }: { defaultContent?: string; parentEvent?: Event } = {}) { - return ( - this.postContentCache.get(this.generateCacheKey(defaultContent, parentEvent)) ?? - defaultContent - ) + const cached = this.postContentCache.get(this.generateCacheKey(defaultContent, parentEvent)) + if (cached !== undefined) return cached + if (defaultContent !== undefined && defaultContent !== '') { + return this.escapeAmpersandsForHtml(defaultContent) + } + return defaultContent } setPostContentCache(