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. 54
      src/components/NoteOptions/useMenuActions.tsx
  4. 3
      src/components/PostEditor/index.tsx
  5. 53
      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({ @@ -74,16 +74,27 @@ export default function Note({
const [highlightDefaultContent, setHighlightDefaultContent] = useState<string>('')
const [postEditorOpen, setPostEditorOpen] = useState(false)
const [publicMessageTo, setPublicMessageTo] = useState<string | null>(null)
const [callInviteContent, setCallInviteContent] = useState<string | null>(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({ @@ -284,9 +295,12 @@ export default function Note({
setHighlightData(undefined)
setHighlightDefaultContent('')
setPublicMessageTo(null)
setCallInviteContent(null)
}}
onOpenPublicMessage={openPublicMessage}
initialPublicMessageTo={publicMessageTo}
onOpenCallInvite={openCallInvite}
initialDefaultContent={callInviteContent}
/>
)}
</div>

13
src/components/NoteOptions/index.tsx

@ -18,7 +18,9 @@ export default function NoteOptions({ @@ -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({ @@ -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({ @@ -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({ @@ -110,7 +117,7 @@ export default function NoteOptions({
setOpen={(open) => {
if (!open) onPostEditorClose()
}}
defaultContent={highlightDefaultContent ?? ''}
defaultContent={initialDefaultContent ?? highlightDefaultContent ?? ''}
initialHighlightData={initialHighlightData}
initialPublicMessageTo={initialPublicMessageTo ?? undefined}
/>

54
src/components/NoteOptions/useMenuActions.tsx

@ -1,9 +1,10 @@ @@ -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' @@ -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 { @@ -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({ @@ -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({ @@ -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({ @@ -830,7 +873,10 @@ export function useMenuActions({
isArticleType,
articleMetadata,
dTag,
naddr
naddr,
onOpenPublicMessage,
onOpenCallInvite,
profile
])
return menuActions

3
src/components/PostEditor/index.tsx

@ -43,7 +43,8 @@ export default function PostEditor({ @@ -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])

53
src/components/Profile/index.tsx

@ -164,6 +164,7 @@ export default function Profile({ id }: { id?: string }) { @@ -164,6 +164,7 @@ export default function Profile({ id }: { id?: string }) {
const [paymentInfo, setPaymentInfo] = useState<ReturnType<typeof getPaymentInfoFromEvent> | null>(null)
const [openZapDialog, setOpenZapDialog] = useState(false)
const [openPublicMessageTo, setOpenPublicMessageTo] = useState<string | null>(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 }) { @@ -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<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
useEffect(() => {
if (!profile?.pubkey) {
@ -431,6 +451,11 @@ export default function Profile({ id }: { id?: string }) { @@ -431,6 +451,11 @@ export default function Profile({ id }: { id?: string }) {
<ProfileOptions
pubkey={pubkey}
onSendPublicMessage={!isSelf ? () => setOpenPublicMessageTo(pubkey) : undefined}
onSendCallInvite={
!isSelf
? (url) => setOpenCallInviteTo({ pubkey, url })
: undefined
}
/>
{isSelf ? (
<div className="flex gap-2">
@ -514,15 +539,18 @@ export default function Profile({ id }: { id?: string }) { @@ -514,15 +539,18 @@ export default function Profile({ id }: { id?: string }) {
</div>
)}
{/* 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="text-xs font-semibold text-muted-foreground mb-2">Payment Methods</div>
<div className="space-y-2 min-w-0">
{mergedPaymentMethods.map((method, idx) => (
<div key={idx} className="text-sm min-w-0">
<div className="font-medium">{method.displayType}</div>
<div className="space-y-3 min-w-0">
{paymentMethodsByType.map((group, groupIdx) => (
<div key={groupIdx} className="text-sm min-w-0">
<div className="font-medium">{group.displayType}</div>
<div className="space-y-1.5 mt-1">
{group.methods.map((method, idx) => (
<div key={idx} className="min-w-0">
{method.authority && (
<div className="text-muted-foreground mt-1 flex items-center gap-1 min-w-0">
<div className="text-muted-foreground flex items-center gap-1 min-w-0">
<PaytoLink
type={method.type}
authority={method.authority}
@ -549,7 +577,7 @@ export default function Profile({ id }: { id?: string }) { @@ -549,7 +577,7 @@ export default function Profile({ id }: { id?: string }) {
</div>
)}
{(method.currency || (method.minAmount !== undefined && method.maxAmount !== undefined)) && (
<div className="text-muted-foreground text-xs mt-1">
<div className="text-muted-foreground text-xs mt-0.5">
{method.currency && <span>({method.currency})</span>}
{method.minAmount !== undefined && method.maxAmount !== undefined && (
<span className="ml-2">
@ -562,6 +590,9 @@ export default function Profile({ id }: { id?: string }) { @@ -562,6 +590,9 @@ export default function Profile({ id }: { id?: string }) {
))}
</div>
</div>
))}
</div>
</div>
)}
<ZapDialog
open={openZapDialog}
@ -772,6 +803,14 @@ export default function Profile({ id }: { id?: string }) { @@ -772,6 +803,14 @@ export default function Profile({ id }: { id?: string }) {
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 { @@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@ -41,6 +55,33 @@ export default function ProfileOptions({ @@ -41,6 +55,33 @@ export default function ProfileOptions({
{t('Send public message')}
</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
onClick={() => navigator.clipboard.writeText('nostr:' + pubkeyToNpub(pubkey))}
>

4
src/constants.ts

@ -4,6 +4,10 @@ import { kinds } from 'nostr-tools' @@ -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',

5
src/i18n/locales/de.ts

@ -66,6 +66,11 @@ export default { @@ -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',

5
src/i18n/locales/en.ts

@ -120,6 +120,11 @@ export default { @@ -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',

38
src/lib/hivetalk.ts

@ -0,0 +1,38 @@ @@ -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 { @@ -22,14 +22,24 @@ class PostEditorCacheService {
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({
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(

Loading…
Cancel
Save