diff --git a/src/app/core/requests.ts b/src/app/core/requests.ts index 8588fcfe..32c4859c 100644 --- a/src/app/core/requests.ts +++ b/src/app/core/requests.ts @@ -113,7 +113,7 @@ export const makeFeed = ({ onScroll: async () => { const $buffer = get(buffer) - events.update($events => [...$events, ...$buffer.splice(0, 100)]) + events.update($events => [...$events, ...$buffer.splice(0, 30)]) if ($buffer.length < 100) { ctrl.load(100) diff --git a/src/app/core/state.ts b/src/app/core/state.ts index 4672b4d3..f7af640d 100644 --- a/src/app/core/state.ts +++ b/src/app/core/state.ts @@ -349,7 +349,7 @@ export const { // Relays sending events with empty signatures that the user has to choose to trust -export const relaysPendingTrust = writable([]) +export const relaysPendingTrust = withGetter(writable([])) // Relays that mostly send restricted responses to requests and events diff --git a/src/app/util/analytics.ts b/src/app/util/analytics.ts index 40762752..7bdae02a 100644 --- a/src/app/util/analytics.ts +++ b/src/app/util/analytics.ts @@ -11,10 +11,9 @@ w.plausible = ;(w.plausible.q = w.plausible.q || []).push(arguments) } -export const setupAnalytics = () => { +export const setupAnalytics = () => page.subscribe($page => { if ($page.route && getSetting("report_usage")) { w.plausible("pageview", {u: $page.route.id}) } }) -} diff --git a/src/app/util/history.ts b/src/app/util/history.ts index b08d34a3..9739157d 100644 --- a/src/app/util/history.ts +++ b/src/app/util/history.ts @@ -2,10 +2,9 @@ import {page} from "$app/stores" export const lastPageBySpaceUrl = new Map() -export const setupHistory = () => { +export const setupHistory = () => page.subscribe($page => { if ($page.params.relay) { lastPageBySpaceUrl.set($page.params.relay, $page.url.pathname) } }) -} diff --git a/src/app/util/policies.ts b/src/app/util/policies.ts new file mode 100644 index 00000000..2d7b73b5 --- /dev/null +++ b/src/app/util/policies.ts @@ -0,0 +1,123 @@ +import {on, call, dissoc, assoc, uniq} from "@welshman/lib" +import type {StampedEvent} from "@welshman/util" +import type {Socket, RelayMessage, ClientMessage} from "@welshman/net" +import { + makeSocketPolicyAuth, + SocketEvent, + isRelayEvent, + isRelayOk, + isRelayClosed, + isClientReq, + isClientEvent, + isClientClose, +} from "@welshman/net" +import {signer} from "@welshman/app" +import { + userSettingsValues, + getSetting, + relaysPendingTrust, + relaysMostlyRestricted, +} from "@app/core/state" + +export const authPolicy = makeSocketPolicyAuth({ + sign: (event: StampedEvent) => signer.get()?.sign(event), + shouldAuth: (socket: Socket) => true, +}) + +export const trustPolicy = (socket: Socket) => { + const buffer: RelayMessage[] = [] + + const unsubscribers = [ + // When the socket goes from untrusted to trusted, receive all buffered messages + userSettingsValues.subscribe($settings => { + if ($settings.trusted_relays.includes(socket.url)) { + for (const message of buffer.splice(0)) { + socket._recvQueue.push(message) + } + } + }), + // When we get an event with no signature from an untrusted relay, remove it from + // the receive queue. If trust status is undefined, buffer it for later. + on(socket, SocketEvent.Receiving, (message: RelayMessage) => { + if (isRelayEvent(message) && !message[2]?.sig) { + const isTrusted = getSetting("trusted_relays").includes(socket.url) + + if (!isTrusted) { + buffer.push(message) + socket._recvQueue.remove(message) + relaysPendingTrust.update($r => uniq([...$r, socket.url])) + } + } + }), + ] + + return () => { + unsubscribers.forEach(call) + } +} + +export const mostlyRestrictedPolicy = (socket: Socket) => { + let total = 0 + let restricted = 0 + let error = "" + + const pending = new Set() + + const updateStatus = () => + relaysMostlyRestricted.update( + restricted > total / 2 ? assoc(socket.url, error) : dissoc(socket.url), + ) + + const unsubscribers = [ + on(socket, SocketEvent.Receive, (message: RelayMessage) => { + if (isRelayOk(message)) { + const [_, id, ok, details = ""] = message + + if (pending.has(id)) { + pending.delete(id) + + if (!ok && details.startsWith("restricted: ")) { + restricted++ + error = details + updateStatus() + } + } + } + + if (isRelayClosed(message)) { + const [_, id, details = ""] = message + + if (pending.has(id)) { + pending.delete(id) + + if (details.startsWith("restricted: ")) { + restricted++ + error = details + updateStatus() + } + } + } + }), + on(socket, SocketEvent.Send, (message: ClientMessage) => { + if (isClientReq(message)) { + total++ + pending.add(message[1]) + updateStatus() + } + + if (isClientEvent(message)) { + total++ + pending.add(message[1].id) + updateStatus() + } + + if (isClientClose(message)) { + pending.delete(message[1]) + } + }), + ] + + return () => { + unsubscribers.forEach(call) + } +} diff --git a/src/app/util/storage.ts b/src/app/util/storage.ts index 9499db3e..7f081858 100644 --- a/src/app/util/storage.ts +++ b/src/app/util/storage.ts @@ -1,5 +1,6 @@ import { always, + call, on, hash, last, @@ -281,8 +282,8 @@ const syncWrapManager = async () => { } } -export const syncDataStores = () => - Promise.all([ +export const syncDataStores = async () => { + const unsubscribers = await Promise.all([ syncEvents(), syncTracker(), syncRelays(), @@ -292,3 +293,6 @@ export const syncDataStores = () => syncPlaintext(), syncWrapManager(), ]) + + return () => unsubscribers.forEach(call) +} diff --git a/src/app/util/tracking.ts b/src/app/util/tracking.ts index 2b1371fd..1fe479c5 100644 --- a/src/app/util/tracking.ts +++ b/src/app/util/tracking.ts @@ -1,3 +1,4 @@ +import {noop} from "@welshman/lib" import * as Sentry from "@sentry/browser" import {getSetting} from "@app/core/state" @@ -17,4 +18,6 @@ export const setupTracking = () => { }, }) } + + return noop } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 059df604..d86a119e 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -2,39 +2,20 @@ import "@src/app.css" import "@capacitor-community/safe-area" import {throttle} from "throttle-debounce" - import {onMount} from "svelte" import * as nip19 from "nostr-tools/nip19" import {get} from "svelte/store" import {App, type URLOpenListenerEvent} from "@capacitor/app" import {dev} from "$app/environment" import {goto} from "$app/navigation" import {sync} from "@welshman/store" - import {assoc, call, defer, dissoc, on, sleep, spec} from "@welshman/lib" - import type {StampedEvent} from "@welshman/util" - import {Nip46Broker, makeSecret} from "@welshman/signer" - import type {Socket, RelayMessage, ClientMessage} from "@welshman/net" - import { - defaultSocketPolicies, - makeSocketPolicyAuth, - SocketEvent, - isRelayEvent, - isRelayOk, - isRelayClosed, - isClientReq, - isClientEvent, - isClientClose, - } from "@welshman/net" + import {call, on, spec} from "@welshman/lib" + import {defaultSocketPolicies} from "@welshman/net" import { repository, pubkey, - session, sessions, - signer, signerLog, - dropSession, shouldUnwrap, - loginWithNip01, - loginWithNip46, loadRelaySelections, SignerLogEntryStatus, } from "@welshman/app" @@ -45,19 +26,14 @@ import * as welshmanSigner from "@welshman/signer" import * as net from "@welshman/net" import * as app from "@welshman/app" - import {nsecDecode} from "@lib/util" import {preferencesStorageProvider} from "@lib/storage" import AppContainer from "@app/components/AppContainer.svelte" import ModalContainer from "@app/components/ModalContainer.svelte" import {setupHistory} from "@app/util/history" import {setupTracking} from "@app/util/tracking" import {setupAnalytics} from "@app/util/analytics" - import { - userSettingsValues, - relaysPendingTrust, - getSetting, - relaysMostlyRestricted, - } from "@app/core/state" + import {authPolicy, trustPolicy, mostlyRestrictedPolicy} from "@app/util/policies" + import {userSettingsValues} from "@app/core/state" import {syncApplicationData} from "@app/core/sync" import {theme} from "@app/util/theme" import {toast, pushToast} from "@app/util/toast" @@ -69,280 +45,153 @@ import * as storage from "@app/util/storage" import NewNotificationSound from "@src/app/components/NewNotificationSound.svelte" - // Migration: delete old indexeddb database - indexedDB?.deleteDatabase("flotilla") + const {children} = $props() - // Migration: old nostrtalk instance used different sessions - if ($session && !$signer) { - dropSession($session.pubkey) - } + // Add stuff to window for convenience + Object.assign(window, { + get, + nip19, + theme, + ...lib, + ...welshmanSigner, + ...router, + ...util, + ...feeds, + ...net, + ...app, + ...appState, + ...commands, + ...requests, + ...notifications, + }) // Initialize push notification handler asap initializePushNotifications() - const {children} = $props() - - const ready = $state(defer()) - - let initialized = false - - onMount(async () => { - Object.assign(window, { - get, - nip19, - theme, - ...lib, - ...welshmanSigner, - ...router, - ...util, - ...feeds, - ...net, - ...app, - ...appState, - ...commands, - ...requests, - ...notifications, - }) - - // Listen for navigation messages from service worker - navigator.serviceWorker?.addEventListener("message", event => { - if (event.data && event.data.type === "NAVIGATE") { - goto(event.data.url) - } - }) - - // Listen for deep link events - App.addListener("appUrlOpen", (event: URLOpenListenerEvent) => { - const url = new URL(event.url) - const target = `${url.pathname}${url.search}${url.hash}` - goto(target, {replaceState: false, noScroll: false}) - }) - - // Nstart login - if (window.location.hash?.startsWith("#nostr-login")) { - const params = new URLSearchParams(window.location.hash.slice(1)) - const login = params.get("nostr-login") - - let success = false - - try { - if (login?.startsWith("bunker://")) { - const clientSecret = makeSecret() - const {signerPubkey, connectSecret, relays} = Nip46Broker.parseBunkerUrl(login) - const broker = new Nip46Broker({relays, clientSecret, signerPubkey}) - const result = await broker.connect(connectSecret, appState.NIP46_PERMS) - const pubkey = await broker.getPublicKey() - - // TODO: remove ack result - if (pubkey && ["ack", connectSecret].includes(result)) { - loginWithNip46(pubkey, clientSecret, signerPubkey, relays) - broker.cleanup() - success = true - } - } else if (login) { - loginWithNip01(nsecDecode(login)) - success = true - } - } catch (e) { - console.error(e) - } - - if (success) { - goto("/home") - } + // Listen for navigation messages from service worker + navigator.serviceWorker?.addEventListener("message", event => { + if (event.data && event.data.type === "NAVIGATE") { + goto(event.data.url) } + }) - // Sync theme - theme.subscribe($theme => { - document.body.setAttribute("data-theme", $theme) - }) + // Listen for deep link events + App.addListener("appUrlOpen", (event: URLOpenListenerEvent) => { + const url = new URL(event.url) + const target = `${url.pathname}${url.search}${url.hash}` + goto(target, {replaceState: false, noScroll: false}) + }) - // Sync font size - userSettingsValues.subscribe($userSettingsValues => { - // @ts-ignore - document.documentElement.style["font-size"] = `${$userSettingsValues.font_size}rem` - }) + // Handle back button on mobile + App.addListener("backButton", () => { + if (window.history.length > 1) { + window.history.back() + } else { + App.exitApp() + } + }) - if (!initialized) { - initialized = true - setupHistory() - setupTracking() - setupAnalytics() + // Listen to navigation changes + const unsubscribeHistory = setupHistory() - App.addListener("backButton", () => { - if (window.history.length > 1) { - window.history.back() - } else { - App.exitApp() - } - }) + // Report usage on navigation change + const unsubscribeAnalytics = setupAnalytics() - repository.on("update", ({added}) => { - for (const event of added) { - loadRelaySelections(event.pubkey) - } - }) + // Bug tracking + const unsubscribeTracking = setupTracking() - // Sync current pubkey - await sync({ + // Load user data, listen for messages, etc + const unsubscribeApplicationData = syncApplicationData() + + // Whenever we see a new pubkey, load their outbox event + const unsubscribeRepository = on(repository, "update", ({added}) => { + for (const event of added) { + loadRelaySelections(event.pubkey) + } + }) + + // Subscribe to badge count for changes + const unsubscribeBadgeCount = notifications.badgeCount.subscribe( + notifications.handleBadgeCountChanges, + ) + + // Listen for signer errors, report to user via toast + const unsubscribeSignerLog = signerLog.subscribe( + throttle(10_000, $log => { + const recent = $log.slice(-10) + const success = recent.filter(spec({status: SignerLogEntryStatus.Success})) + const failure = recent.filter(spec({status: SignerLogEntryStatus.Failure})) + + if (!$toast && failure.length > 5 && success.length === 0) { + pushToast({ + theme: "error", + timeout: 60_000, + message: "Your signer appears to be unresponsive.", + action: { + message: "Details", + onclick: () => goto("/settings/profile"), + }, + }) + } + }), + ) + + // Sync theme + const unsubscribeTheme = theme.subscribe($theme => { + document.body.setAttribute("data-theme", $theme) + }) + + // Sync font size + const unsubscribeSettings = userSettingsValues.subscribe($userSettingsValues => { + // @ts-ignore + document.documentElement.style["font-size"] = `${$userSettingsValues.font_size}rem` + }) + + let unsubscribeStorage: () => void + + const ready = call(async () => { + // Sync stuff to localstorage + await Promise.all([ + sync({ key: "pubkey", store: pubkey, storage: preferencesStorageProvider, - }) - - // Sync user sessions - await sync({ + }), + sync({ key: "sessions", store: sessions, storage: preferencesStorageProvider, - }) - - // Sync shouldUnwrap - await sync({ + }), + sync({ key: "shouldUnwrap", store: shouldUnwrap, storage: preferencesStorageProvider, - }) + }), + ]) - // Sync application data (relay, events, etc) - await storage.syncDataStores() + // Sync stuff to indexeddb + unsubscribeStorage = await storage.syncDataStores() + }) - // Wait 300 ms for any throttled stores to finish - sleep(300).then(() => ready.resolve()) + // Default socket policies + const additionalPolicies = [authPolicy, trustPolicy, mostlyRestrictedPolicy] - defaultSocketPolicies.push( - makeSocketPolicyAuth({ - sign: (event: StampedEvent) => signer.get()?.sign(event), - shouldAuth: (socket: Socket) => true, - }), - (socket: Socket) => { - const buffer: RelayMessage[] = [] + defaultSocketPolicies.push(...additionalPolicies) - const unsubscribers = [ - // When the socket goes from untrusted to trusted, receive all buffered messages - userSettingsValues.subscribe($settings => { - if ($settings.trusted_relays.includes(socket.url)) { - for (const message of buffer.splice(0)) { - socket._recvQueue.push(message) - } - } - }), - // When we get an event with no signature from an untrusted relay, remove it from - // the receive queue. If trust status is undefined, buffer it for later. - on(socket, SocketEvent.Receiving, (message: RelayMessage) => { - if (isRelayEvent(message) && !message[2]?.sig) { - const isTrusted = getSetting("trusted_relays").includes(socket.url) - - if (!isTrusted) { - socket._recvQueue.remove(message) - buffer.push(message) - - if (!$relaysPendingTrust.includes(socket.url)) { - relaysPendingTrust.update($r => [...$r, socket.url]) - } - } - } - }), - ] - - return () => { - unsubscribers.forEach(call) - } - }, - function monitorRestrictedResponses(socket: Socket) { - let total = 0 - let restricted = 0 - let error = "" - - const pending = new Set() - - const updateStatus = () => - relaysMostlyRestricted.update( - restricted > total / 2 ? assoc(socket.url, error) : dissoc(socket.url), - ) - - const unsubscribers = [ - on(socket, SocketEvent.Receive, (message: RelayMessage) => { - if (isRelayOk(message)) { - const [_, id, ok, details = ""] = message - - if (pending.has(id)) { - pending.delete(id) - - if (!ok && details.startsWith("restricted: ")) { - restricted++ - error = details - updateStatus() - } - } - } - - if (isRelayClosed(message)) { - const [_, id, details = ""] = message - - if (pending.has(id)) { - pending.delete(id) - - if (details.startsWith("restricted: ")) { - restricted++ - error = details - updateStatus() - } - } - } - }), - on(socket, SocketEvent.Send, (message: ClientMessage) => { - if (isClientReq(message)) { - total++ - pending.add(message[1]) - updateStatus() - } - - if (isClientEvent(message)) { - total++ - pending.add(message[1].id) - updateStatus() - } - - if (isClientClose(message)) { - pending.delete(message[1]) - } - }), - ] - - return () => { - unsubscribers.forEach(call) - } - }, - ) - - // Load user data, listen for messages, etc - syncApplicationData() - - // subscribe to badge count for changes - notifications.badgeCount.subscribe(notifications.handleBadgeCountChanges) - - // Listen for signer errors, report to user via toast - signerLog.subscribe( - throttle(10_000, $log => { - const recent = $log.slice(-10) - const success = recent.filter(spec({status: SignerLogEntryStatus.Success})) - const failure = recent.filter(spec({status: SignerLogEntryStatus.Failure})) - - if (!$toast && failure.length > 5 && success.length === 0) { - pushToast({ - theme: "error", - timeout: 60_000, - message: "Your signer appears to be unresponsive.", - action: { - message: "Details", - onclick: () => goto("/settings/profile"), - }, - }) - } - }), - ) - } + // Cleanup on hot reload + import.meta.hot?.dispose(() => { + App.removeAllListeners() + unsubscribeHistory() + unsubscribeAnalytics() + unsubscribeTracking() + unsubscribeApplicationData() + unsubscribeRepository() + unsubscribeBadgeCount() + unsubscribeSignerLog() + unsubscribeTheme() + unsubscribeSettings() + unsubscribeStorage?.() + defaultSocketPolicies.splice(-additionalPolicies.length) })