flotilla/src/app/sync.ts

423 lines
10 KiB
TypeScript
Raw Normal View History

2025-10-06 18:23:19 +00:00
import {page} from "$app/stores"
import type {Unsubscriber} from "svelte/store"
import {last, call, assoc, chunk, WEEK, ago} from "@welshman/lib"
import {merged} from "@welshman/store"
2025-10-06 18:23:19 +00:00
import {
getListTags,
getRelayTagValues,
WRAP,
2025-10-06 18:23:19 +00:00
ROOM_META,
2025-10-24 20:36:59 +00:00
ROOM_DELETE,
ROOM_ADMINS,
ROOM_MEMBERS,
ROOM_ADD_MEMBER,
ROOM_REMOVE_MEMBER,
ROOM_JOIN,
ROOM_LEAVE,
2025-10-24 20:36:59 +00:00
ROOM_CREATE_PERMISSION,
RELAY_MEMBERS,
2025-10-28 18:29:59 +00:00
RELAY_ADD_MEMBER,
RELAY_REMOVE_MEMBER,
MESSAGE,
2026-04-16 18:30:59 +00:00
POLL_RESPONSE,
2025-10-06 18:23:19 +00:00
isSignedEvent,
2025-11-04 23:36:20 +00:00
unionFilters,
2025-10-06 18:23:19 +00:00
} from "@welshman/util"
import type {Filter, List, PublishedList, TrustedEvent} from "@welshman/util"
2026-02-26 22:29:13 +00:00
import {request, requestOne, Difference, DifferenceEvent} from "@welshman/net"
2025-10-06 18:23:19 +00:00
import {
pubkey,
loadRelay,
2025-11-20 23:12:16 +00:00
userRelayList,
userMessagingRelayList,
loadRelayList,
loadMessagingRelayList,
2025-11-20 23:54:06 +00:00
loadBlossomServerList,
2026-01-16 21:10:48 +00:00
loadBlockedRelayList,
2025-11-20 23:54:06 +00:00
loadFollowList,
loadMuteList,
2025-10-06 18:23:19 +00:00
loadProfile,
repository,
shouldUnwrap,
2025-10-06 18:23:19 +00:00
hasNegentropy,
2025-10-06 18:23:19 +00:00
} from "@welshman/app"
import {REACTION_KINDS, CONTENT_KINDS, makeCommentFilter} from "@app/content"
import {INDEXER_RELAYS} from "@app/env"
import {loadSettings} from "@app/settings"
2025-10-06 18:23:19 +00:00
import {
2025-11-20 23:12:16 +00:00
loadGroupList,
userSpaceUrls,
2025-11-20 23:12:16 +00:00
userGroupList,
getSpaceUrlsFromGroupList,
getSpaceRoomsFromGroupList,
} from "@app/groups"
import {decodeRelay} from "@app/relays"
import {loadFeedsForPubkey} from "@app/feeds"
2026-06-22 20:36:57 +00:00
import {RELAY_ROLE} from "@app/members"
import {hasBlossomSupport} from "@app/uploads"
Video in calls (#135) #135 This PR adds basic video functionality to our voice rooms. Again I followed the Discord UX for inspiration, so all video calls start as voice-only calls that gracefully upgrade (and downgrade) when someone turns on a video or starts screen sharing. When a video feed is detected the Room page will change to display a grid of feeds. The grid logic is very basic, that's definitely an area to improve in the future. You can open the chat part of the room with a new button on the VoiceWidget - on the desktop layout this creates a split view with video on the left and chat on the right, but on mobile it switches to chat fullscreen. I also added a little pin icon you can use to focus on a single video feed (useful for screen sharing). There is a lot of tailwind I don't understand here, but it seems to work well enough. I moved voice.ts into a new `call` folder and moved some of its stores into `call/stores.ts` which allowed me to keep most of the video logic in `call/video.ts`. It's not a perfect encapsulation as voice.ts does subscribe to some of the hooks for the livekit calls and passes some of the signals onto `video.ts`. This could probably be broken up better but for this PR I'd rather not focus on making it perfect if that's ok. Partly for the sake of time but also because I envision another PR that renames/reorganizes things and I think a larger UX evaluation is necessary and should include real user feedback. I'm not confident tha""t the Voice Room concept as a whole will stick going forward. Maybe all rooms in a livekit enabled server should be able to host a call (like a slack huddle), maybe users want to be able to schedule calls as events, or even have them start with an ad-hoc set of participants completely outside of a NIP-29 group, etc. Co-authored-by: mplorentz <mplorentz@noreply.gitea.coracle.social> Reviewed-on: https://gitea.coracle.social/coracle/flotilla/pulls/135 Co-authored-by: Matt Lorentz <mplorentz@noreply.coracle.social> Co-committed-by: Matt Lorentz <mplorentz@noreply.coracle.social>
2026-04-08 17:10:20 +00:00
import {LIVEKIT_PARTICIPANTS} from "@app/call/voice"
2025-10-06 18:23:19 +00:00
// Utils
type SyncOpts = {
url: string
2025-10-06 18:23:19 +00:00
signal: AbortSignal
filters: Filter[]
2026-03-25 21:36:48 +00:00
onEvent?: (event: TrustedEvent) => void
2025-10-06 18:23:19 +00:00
}
2026-03-25 21:36:48 +00:00
const pullOneWithFallback = async (
url: string,
filter: Filter,
signal: AbortSignal,
onEvent?: (event: TrustedEvent) => void,
) => {
if (signal.aborted) return
2026-02-26 22:29:13 +00:00
const cachedEvents = repository.query([filter]).filter(isSignedEvent)
const since = last(cachedEvents.slice(10))?.created_at || 0
2026-03-25 21:36:48 +00:00
if (onEvent) {
for (const event of cachedEvents) {
onEvent(event)
}
}
2026-02-26 22:29:13 +00:00
const shouldFallback =
!hasNegentropy(url) ||
(await new Promise(resolve => {
if (signal.aborted) {
resolve(false)
return
}
// If teardown wins while the diff is opening, skip the fallback path and let cleanup stay in control.
2026-02-26 22:29:13 +00:00
const diff = new Difference({relay: url, filter, events: cachedEvents, signal})
diff.on(DifferenceEvent.Error, () => {
resolve(true)
})
diff.on(DifferenceEvent.Close, () => {
for (const ids of chunk(100, Array.from(diff.need))) {
2026-03-25 21:36:48 +00:00
requestOne({relay: url, signal, autoClose: true, filters: [{ids}], onEvent})
2026-02-26 22:29:13 +00:00
}
resolve(false)
})
}))
if (shouldFallback && !signal.aborted) {
2026-03-25 21:36:48 +00:00
request({relays: [url], signal, autoClose: true, filters: [{since, ...filter}], onEvent})
2026-02-26 22:29:13 +00:00
}
}
2026-03-25 21:36:48 +00:00
export const pullWithFallback = async ({url, signal, filters, onEvent}: SyncOpts) => {
2026-02-26 22:29:13 +00:00
await loadRelay(url)
if (signal.aborted) return
await Promise.all(filters.map(filter => pullOneWithFallback(url, filter, signal, onEvent)))
2025-10-06 18:23:19 +00:00
}
2026-03-25 21:36:48 +00:00
const listen = ({url, signal, filters, onEvent}: SyncOpts) => {
const relays = [url]
2025-10-28 21:53:44 +00:00
2026-03-25 21:36:48 +00:00
request({relays, signal, filters: unionFilters(filters.map(assoc("limit", 0))), onEvent})
}
2026-03-25 21:36:48 +00:00
const pullAndListen = (options: SyncOpts) => {
if (options.signal.aborted) return
2026-03-25 21:36:48 +00:00
pullWithFallback(options)
listen(options)
2025-10-28 18:29:59 +00:00
}
2025-10-06 18:23:19 +00:00
// Relays
2025-10-06 18:23:19 +00:00
const syncRelays = () => {
for (const url of INDEXER_RELAYS) {
loadRelay(url)
}
const unsubscribePage = page.subscribe($page => {
if ($page.params.relay) {
2025-10-06 18:23:19 +00:00
const url = decodeRelay($page.params.relay)
loadRelay(url)
hasBlossomSupport(url)
2025-10-06 18:23:19 +00:00
}
})
const unsubscribeSpaceUrls = userSpaceUrls.subscribe(urls => {
for (const url of urls) {
2025-10-06 18:23:19 +00:00
loadRelay(url)
}
})
return () => {
unsubscribePage()
unsubscribeSpaceUrls()
2025-10-06 18:23:19 +00:00
}
}
2025-10-06 18:23:19 +00:00
// User data
2025-10-28 18:29:59 +00:00
const syncUserSpaceMembership = (url: string) => {
const $pubkey = pubkey.get()
const controller = new AbortController()
if ($pubkey) {
pullAndListen({
url,
2025-10-28 18:29:59 +00:00
signal: controller.signal,
filters: [
2025-12-01 18:56:37 +00:00
{kinds: [RELAY_ADD_MEMBER], "#p": [$pubkey], limit: 1},
{kinds: [RELAY_REMOVE_MEMBER], "#p": [$pubkey], limit: 1},
{kinds: [ROOM_CREATE_PERMISSION], "#p": [$pubkey], limit: 1},
2025-10-28 18:29:59 +00:00
],
})
}
return () => controller.abort()
}
2025-10-30 22:33:34 +00:00
const syncUserRoomMembership = (url: string, h: string) => {
2025-10-28 18:29:59 +00:00
const $pubkey = pubkey.get()
const controller = new AbortController()
if ($pubkey) {
pullAndListen({
url,
2025-10-28 18:29:59 +00:00
signal: controller.signal,
filters: [
2025-12-01 18:56:37 +00:00
{kinds: [ROOM_ADD_MEMBER], "#p": [$pubkey], "#h": [h], limit: 1},
{kinds: [ROOM_REMOVE_MEMBER], "#p": [$pubkey], "#h": [h], limit: 1},
2025-10-28 18:29:59 +00:00
],
})
}
return () => controller.abort()
}
2025-10-06 18:23:19 +00:00
const syncUserData = () => {
2025-10-28 18:29:59 +00:00
const unsubscribersByKey = new Map<string, Unsubscriber>()
const syncGroupList = ($userGroupList: List | undefined) => {
2025-12-01 18:56:37 +00:00
if ($userGroupList) {
2025-10-28 18:29:59 +00:00
const keys = new Set<string>()
2025-12-01 18:56:37 +00:00
for (const url of getSpaceUrlsFromGroupList($userGroupList)) {
2025-10-28 18:29:59 +00:00
if (!unsubscribersByKey.has(url)) {
unsubscribersByKey.set(url, syncUserSpaceMembership(url))
}
keys.add(url)
2025-12-01 18:56:37 +00:00
for (const h of getSpaceRoomsFromGroupList(url, $userGroupList)) {
2025-10-30 22:33:34 +00:00
const key = `${url}'${h}`
2025-10-28 18:29:59 +00:00
if (!unsubscribersByKey.has(key)) {
2025-10-30 22:33:34 +00:00
unsubscribersByKey.set(key, syncUserRoomMembership(url, h))
2025-10-28 18:29:59 +00:00
}
keys.add(key)
}
}
for (const [key, unsubscribe] of unsubscribersByKey.entries()) {
if (!keys.has(key)) {
unsubscribersByKey.delete(key)
unsubscribe()
}
}
2025-10-06 18:23:19 +00:00
}
}
2025-10-06 18:23:19 +00:00
const syncRelayList = ($userRelayList: PublishedList | undefined) => {
const pubkey = $userRelayList?.event?.pubkey
if (!pubkey) return
loadBlossomServerList(pubkey)
loadBlockedRelayList(pubkey)
loadFollowList(pubkey)
loadGroupList(pubkey)
loadMuteList(pubkey)
loadProfile(pubkey)
loadSettings(pubkey)
loadFeedsForPubkey(pubkey)
}
2025-10-06 18:23:19 +00:00
const unsubscribeGroupList = merged([userGroupList]).subscribe(([$userGroupList]) => {
syncGroupList($userGroupList)
})
const unsubscribeRelayList = merged([userRelayList]).subscribe(([$userRelayList]) => {
syncRelayList($userRelayList)
})
2025-10-06 18:23:19 +00:00
return () => {
2025-10-28 18:29:59 +00:00
unsubscribersByKey.forEach(call)
2025-11-20 23:12:16 +00:00
unsubscribeGroupList()
2025-12-01 18:56:37 +00:00
unsubscribeRelayList()
2025-10-06 18:23:19 +00:00
}
}
2025-10-28 18:29:59 +00:00
// Spaces
2025-10-06 18:23:19 +00:00
2026-04-11 17:20:50 +00:00
const syncSpace = (url: string) => {
2026-03-25 21:36:48 +00:00
const since = ago(WEEK)
2025-10-06 18:23:19 +00:00
const controller = new AbortController()
2026-06-22 20:36:57 +00:00
const relayKinds = [RELAY_MEMBERS, RELAY_ROLE]
2026-03-25 21:36:48 +00:00
const roomMetaKinds = [ROOM_META, ROOM_ADMINS, ROOM_MEMBERS, LIVEKIT_PARTICIPANTS]
const roomDeleteKinds = [ROOM_DELETE, ROOM_JOIN, ROOM_LEAVE]
2026-03-25 21:36:48 +00:00
pullAndListen({
url,
signal: controller.signal,
2026-03-25 21:36:48 +00:00
filters: [
{kinds: [...relayKinds, ...roomMetaKinds, ...roomDeleteKinds, ...CONTENT_KINDS, MESSAGE]},
2026-04-16 18:30:59 +00:00
{kinds: [...REACTION_KINDS, POLL_RESPONSE], since},
2026-06-22 20:36:57 +00:00
makeCommentFilter(CONTENT_KINDS, {since}),
2026-03-25 21:36:48 +00:00
],
})
2025-10-06 18:23:19 +00:00
return () => controller.abort()
}
2025-10-28 18:29:59 +00:00
const syncSpaces = () => {
const store = merged([userGroupList, page])
2025-12-01 18:56:37 +00:00
const unsubscribersByUrl = new Map<string, Unsubscriber>()
2026-02-26 22:29:13 +00:00
const unsubscribe = store.subscribe(([$userGroupList, $page]) => {
const urls = new Set(getSpaceUrlsFromGroupList($userGroupList))
const currentUrl = $page.params.relay ? decodeRelay($page.params.relay) : undefined
2025-12-01 18:56:37 +00:00
if (currentUrl) {
urls.add(currentUrl)
2025-12-01 18:56:37 +00:00
}
2025-10-06 18:23:19 +00:00
2026-02-26 22:29:13 +00:00
// Stop syncing removed spaces
2025-12-01 18:56:37 +00:00
for (const [url, unsubscribe] of unsubscribersByUrl.entries()) {
2026-02-26 22:29:13 +00:00
if (!urls.has(url)) {
2025-12-01 18:56:37 +00:00
unsubscribersByUrl.delete(url)
2025-10-06 18:23:19 +00:00
unsubscribe()
}
}
2025-10-06 18:23:19 +00:00
2026-04-11 17:20:50 +00:00
// Start syncing for new spaces
2025-10-06 18:23:19 +00:00
for (const url of urls) {
2026-04-11 17:20:50 +00:00
if (!unsubscribersByUrl.has(url)) {
unsubscribersByUrl.set(url, syncSpace(url))
}
2025-10-06 18:23:19 +00:00
}
2025-10-06 18:23:19 +00:00
})
2025-12-01 18:56:37 +00:00
return () => {
for (const unsubscriber of unsubscribersByUrl.values()) {
unsubscriber()
2025-10-28 18:29:59 +00:00
}
2025-12-01 18:56:37 +00:00
unsubscribe()
2025-10-06 18:23:19 +00:00
}
}
2025-10-06 18:23:19 +00:00
// DMs
2025-10-06 18:23:19 +00:00
const syncDMRelay = (url: string, pubkey: string) => {
const controller = new AbortController()
pullAndListen({
url,
2025-10-06 18:23:19 +00:00
signal: controller.signal,
filters: [{kinds: [WRAP], "#p": [pubkey]}],
2025-10-06 18:23:19 +00:00
})
return () => controller.abort()
}
const syncDMs = () => {
const unsubscribersByUrl = new Map<string, Unsubscriber>()
let currentPubkey: string | undefined
let currentShouldUnwrap = false
2025-10-06 18:23:19 +00:00
const unsubscribeAll = () => {
for (const [url, unsubscribe] of unsubscribersByUrl.entries()) {
unsubscribersByUrl.delete(url)
unsubscribe()
}
}
const syncPubkey = ($pubkey: string | undefined, $shouldUnwrap: boolean) => {
if ($pubkey !== currentPubkey) {
unsubscribeAll()
}
if ($pubkey && $shouldUnwrap) {
loadRelayList($pubkey)
.then(() => loadMessagingRelayList($pubkey))
.then($l => {
if ($l && currentPubkey === $pubkey && currentShouldUnwrap === $shouldUnwrap) {
subscribeAll($pubkey, getRelayTagValues(getListTags($l)))
}
})
}
currentPubkey = $pubkey
currentShouldUnwrap = $shouldUnwrap
}
const syncList = ($userMessagingRelayList: List | undefined) => {
const $pubkey = pubkey.get()
const $shouldUnwrap = shouldUnwrap.get()
if ($pubkey && $shouldUnwrap) {
subscribeAll($pubkey, getRelayTagValues(getListTags($userMessagingRelayList)))
}
}
2025-10-06 18:23:19 +00:00
const subscribeAll = (pubkey: string, urls: string[]) => {
// Start syncing newly added relays
for (const url of urls) {
if (!unsubscribersByUrl.has(url)) {
unsubscribersByUrl.set(url, syncDMRelay(url, pubkey))
}
}
// Stop syncing removed spaces
for (const [url, unsubscribe] of unsubscribersByUrl.entries()) {
if (!urls.includes(url)) {
unsubscribersByUrl.delete(url)
unsubscribe()
}
}
}
const unsubscribePubkey = merged([pubkey, shouldUnwrap]).subscribe(([$pubkey, $shouldUnwrap]) => {
syncPubkey($pubkey, $shouldUnwrap)
})
2025-10-06 18:23:19 +00:00
// When user messaging relays change, update synchronization
const unsubscribeList = merged([userMessagingRelayList]).subscribe(
([$userMessagingRelayList]) => {
syncList($userMessagingRelayList)
2025-10-06 18:23:19 +00:00
},
)
return () => {
unsubscribeAll()
unsubscribePubkey()
2025-11-20 23:12:16 +00:00
unsubscribeList()
2025-10-06 18:23:19 +00:00
}
}
2025-10-06 18:23:19 +00:00
// Merge all synchronization functions
2025-10-06 18:23:19 +00:00
export const syncApplicationData = () => {
2025-11-11 00:19:50 +00:00
const unsubscribers = [syncRelays(), syncUserData(), syncSpaces(), syncDMs()]
2025-10-06 18:23:19 +00:00
return () => unsubscribers.forEach(call)
}