diff --git a/package.json b/package.json index f35906d..8f0c468 100644 --- a/package.json +++ b/package.json @@ -57,17 +57,17 @@ "@types/throttle-debounce": "^5.0.2", "@vite-pwa/assets-generator": "^0.2.6", "@vite-pwa/sveltekit": "^0.6.6", - "@welshman/app": "^0.4.4", - "@welshman/content": "^0.4.4", - "@welshman/editor": "^0.4.4", - "@welshman/feeds": "^0.4.4", - "@welshman/lib": "^0.4.4", - "@welshman/net": "^0.4.4", - "@welshman/relay": "^0.4.4", - "@welshman/router": "^0.4.4", - "@welshman/signer": "^0.4.4", - "@welshman/store": "^0.4.4", - "@welshman/util": "^0.4.4", + "@welshman/app": "^0.4.6", + "@welshman/content": "^0.4.6", + "@welshman/editor": "^0.4.6", + "@welshman/feeds": "^0.4.6", + "@welshman/lib": "^0.4.6", + "@welshman/net": "^0.4.6", + "@welshman/relay": "^0.4.6", + "@welshman/router": "^0.4.6", + "@welshman/signer": "^0.4.6", + "@welshman/store": "^0.4.6", + "@welshman/util": "^0.4.6", "compressorjs": "^1.2.1", "daisyui": "^4.12.10", "date-picker-svelte": "^2.13.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d523749..2799032 100644 Binary files a/pnpm-lock.yaml and b/pnpm-lock.yaml differ diff --git a/src/app/components/AlertAdd.svelte b/src/app/components/AlertAdd.svelte index 5e52e26..b6dd6da 100644 --- a/src/app/components/AlertAdd.svelte +++ b/src/app/components/AlertAdd.svelte @@ -1,17 +1,8 @@ + +{#if $status} + {@const statusText = getTagValue("status", $status.tags) || "error"} + {#if statusText === "ok"} + + Active + + {:else if statusText === "pending"} + + Pending + + {:else} + + {statusText.replace("-", " ").replace(/^(.)/, x => x.toUpperCase())} + + {/if} +{:else} + + Inactive + +{/if} diff --git a/src/app/components/Alerts.svelte b/src/app/components/Alerts.svelte index 6b9891c..8516f54 100644 --- a/src/app/components/Alerts.svelte +++ b/src/app/components/Alerts.svelte @@ -1,5 +1,7 @@ -
-
- - - Alerts - - +
+
+
+ + + Alerts + + +
+
+ {#each filteredAlerts as alert (alert.event.id)} + + {:else} +

Nothing here yet!

+ {/each} +
-
- {#each filteredAlerts as alert (alert.event.id)} - - {:else} -

Nothing here yet!

- {/each} +
+
+

Notify me about new direct messages

+ +
+ {#if $dmStatus} + {@const status = getTagValue("status", $dmStatus.tags) || "error"} + {#if status !== "ok"} +
+

+ {getTagValue("message", $dmStatus.tags) || + "The notification server did not respond to your request."} +

+
+ {/if} + {/if}
diff --git a/src/app/components/ChatEnable.svelte b/src/app/components/ChatEnable.svelte index cd9ecde..7abc819 100644 --- a/src/app/components/ChatEnable.svelte +++ b/src/app/components/ChatEnable.svelte @@ -1,7 +1,5 @@
@@ -24,4 +65,19 @@ Mark all read + {#if (!enablingAlert && $dmAlert) || disablingAlert} + + {:else} + + {/if}
diff --git a/src/app/components/MenuSettings.svelte b/src/app/components/MenuSettings.svelte index 61722af..553240d 100644 --- a/src/app/components/MenuSettings.svelte +++ b/src/app/components/MenuSettings.svelte @@ -4,6 +4,8 @@ import Settings from "@assets/icons/settings-minimalistic.svg?dataurl" import Code2 from "@assets/icons/code-2.svg?dataurl" import Exit from "@assets/icons/logout-3.svg?dataurl" + import Bell from "@assets/icons/bell.svg?dataurl" + import Wallet from "@assets/icons/wallet.svg?dataurl" import Icon from "@lib/components/Icon.svelte" import Link from "@lib/components/Link.svelte" import Button from "@lib/components/Button.svelte" @@ -29,6 +31,32 @@ {/snippet} + + + {#snippet icon()} +
+ {/snippet} + {#snippet title()} +
Alerts
+ {/snippet} + {#snippet info()} +
Set up email digests and push notifications
+ {/snippet} +
+ + + + {#snippet icon()} +
+ {/snippet} + {#snippet title()} +
Wallet
+ {/snippet} + {#snippet info()} +
Connect a bitcoin wallet for sending social tips
+ {/snippet} +
+ {#snippet icon()} @@ -42,7 +70,7 @@ {/snippet} - + {#snippet icon()}
diff --git a/src/app/components/ProfileDelete.svelte b/src/app/components/ProfileDelete.svelte index 079c7b9..2913662 100644 --- a/src/app/components/ProfileDelete.svelte +++ b/src/app/components/ProfileDelete.svelte @@ -7,9 +7,8 @@ DELETE, isReplaceable, getAddress, - getRelaysFromList, } from "@welshman/util" - import {pubkey, userRelaySelections, publishThunk, repository} from "@welshman/app" + import {pubkey, publishThunk, repository} from "@welshman/app" import {preventDefault} from "@lib/html" import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl" import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl" @@ -20,7 +19,13 @@ import ModalFooter from "@lib/components/ModalFooter.svelte" import {pushToast} from "@app/util/toast" import {logout} from "@app/core/commands" - import {INDEXER_RELAYS, PLATFORM_NAME, userMembership, getMembershipUrls} from "@app/core/state" + import { + INDEXER_RELAYS, + PLATFORM_NAME, + userMembership, + getMembershipUrls, + userWriteRelays, + } from "@app/core/state" let progress: number | undefined = $state(undefined) let confirmText = $state("") @@ -43,7 +48,7 @@ const denominator = chunks.length + 2 const relays = uniq([ ...INDEXER_RELAYS, - ...getRelaysFromList($userRelaySelections), + ...$userWriteRelays, ...getMembershipUrls($userMembership), ]) diff --git a/src/app/components/ThunkPending.svelte b/src/app/components/ThunkPending.svelte index 4223a96..218fbf4 100644 --- a/src/app/components/ThunkPending.svelte +++ b/src/app/components/ThunkPending.svelte @@ -25,6 +25,7 @@ class="underline transition-all" class:link={isSending} class:opacity-25={!isSending} + class:pointer-events-none={!isSending} onclick={stopPropagation(abort)}> Cancel diff --git a/src/app/core/commands.ts b/src/app/core/commands.ts index f806dae..6d256be 100644 --- a/src/app/core/commands.ts +++ b/src/app/core/commands.ts @@ -1,6 +1,7 @@ import {nwc} from "@getalby/sdk" import * as nip19 from "nostr-tools/nip19" import {get} from "svelte/store" +import type {Override, MakeOptional} from "@welshman/lib" import { randomId, append, @@ -11,10 +12,15 @@ import { equals, TIMEZONE, LOCALE, + parseJson, + fromPairs, } from "@welshman/lib" +import {decrypt} from "@welshman/signer" import type {Feed} from "@welshman/feeds" +import {makeIntersectionFeed, feedFromFilters, makeRelayFeed} from "@welshman/feeds" import type {TrustedEvent, EventContent} from "@welshman/util" import { + WRAP, DELETE, REPORT, PROFILE, @@ -44,6 +50,8 @@ import { toNostrURI, getRelaysFromList, RelayMode, + getAddress, + getTagValue, } from "@welshman/util" import {Pool, AuthStatus, SocketStatus} from "@welshman/net" import {Router} from "@welshman/router" @@ -54,7 +62,6 @@ import { repository, publishThunk, profilesByPubkey, - relaySelectionsByPubkey, tagEvent, tagEventForReaction, userRelaySelections, @@ -66,8 +73,9 @@ import { tagEventForComment, tagEventForQuote, waitForThunkError, + getPubkeyRelays, } from "@welshman/app" -import type {SettingsValues} from "@app/core/state" +import type {SettingsValues, Alert} from "@app/core/state" import { SETTINGS, PROTECTED, @@ -77,14 +85,18 @@ import { NOTIFIER_RELAY, userRoomsByUrl, userSettingsValues, + canDecrypt, + ensureUnwrapped, + userInboxRelays, } from "@app/core/state" +import {loadAlertStatuses} from "@app/core/requests" +import {platform, platformName, getPushInfo} from "@app/util/push" import {preferencesStorageProvider} from "@src/lib/storage" // Utils export const getPubkeyHints = (pubkey: string) => { - const selections = relaySelectionsByPubkey.get().get(pubkey) - const relays = selections ? getRelaysFromList(selections, RelayMode.Write) : [] + const relays = getPubkeyRelays(pubkey, RelayMode.Write) const hints = relays.length ? relays : INDEXER_RELAYS return hints @@ -417,27 +429,35 @@ export const publishComment = ({relays, ...params}: CommentParams & {relays: str // Alerts +export type AlertParamsEmail = { + cron: string + email: string + handler: string[] +} + +export type AlertParamsWeb = { + endpoint: string + p256dh: string + auth: string +} + +export type AlertParamsIos = { + device_token: string + bundle_identifier: string +} + +export type AlertParamsAndroid = { + device_token: string +} + export type AlertParams = { feed: Feed description: string - claims: Record - email?: { - cron: string - email: string - handler: string[] - } - web?: { - endpoint: string - p256dh: string - auth: string - } - ios?: { - device_token: string - bundle_identifier: string - } - android?: { - device_token: string - } + claims?: Record + email?: AlertParamsEmail + web?: AlertParamsWeb + ios?: AlertParamsIos + android?: AlertParamsAndroid } export const makeAlert = async (params: AlertParams) => { @@ -448,7 +468,7 @@ export const makeAlert = async (params: AlertParams) => { ["description", params.description], ] - for (const [relay, claim] of Object.entries(params.claims)) { + for (const [relay, claim] of Object.entries(params.claims || [])) { tags.push(["claim", relay, claim]) } @@ -481,6 +501,88 @@ export const makeAlert = async (params: AlertParams) => { export const publishAlert = async (params: AlertParams) => publishThunk({event: await makeAlert(params), relays: [NOTIFIER_RELAY]}) +export const deleteAlert = (alert: Alert) => { + const relays = [NOTIFIER_RELAY] + const tags = [["p", NOTIFIER_PUBKEY]] + + return publishDelete({event: alert.event, relays, tags, protect: false}) +} + +export type CreateAlertParams = Override< + AlertParams, + { + email?: MakeOptional + } +> + +export type CreateAlertResult = { + ok?: true + error?: string +} + +export const createAlert = async (params: CreateAlertParams): Promise => { + if (params.email) { + const cadence = params.email.cron.endsWith("1") ? "Weekly" : "Daily" + const handler = [ + "31990:97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322:1737058597050", + "wss://relay.nostr.band/", + "web", + ] + + params.email = {handler, ...params.email} + params.description = `${cadence} alert ${params.description}, sent via email.` + } else { + try { + // @ts-ignore + params[platform] = await getPushInfo() + params.description = `${platformName} push notification ${params.description}.` + } catch (e: any) { + return {error: String(e)} + } + } + + // If we don't do this we'll get an event rejection + await attemptAuth(NOTIFIER_RELAY) + + const thunk = await publishAlert(params as AlertParams) + const error = await waitForThunkError(thunk) + + if (error) { + return {error} + } + + // Fetch our new status to make sure it's active + const $pubkey = pubkey.get()! + const address = getAddress(thunk.event) + const statusEvents = await loadAlertStatuses($pubkey!) + const statusEvent = statusEvents.find(event => getTagValue("d", event.tags) === address) + const statusTags = statusEvent + ? parseJson(await decrypt(signer.get(), NOTIFIER_PUBKEY, statusEvent.content)) + : [] + const {status = "error", message = "Your alert was not activated"}: Record = + fromPairs(statusTags) + + if (status === "error") { + return {error: message} + } + + return {ok: true} +} + +export const createDmAlert = async () => { + if (!get(canDecrypt)) { + enableGiftWraps() + } + + return createAlert({ + description: `for direct messages.`, + feed: makeIntersectionFeed( + feedFromFilters([{kinds: [WRAP], "#p": [pubkey.get()!]}]), + makeRelayFeed(...get(userInboxRelays)), + ), + }) +} + // Settings export const makeSettings = async (params: Partial) => { @@ -532,3 +634,13 @@ export const payInvoice = async (invoice: string) => { .then(() => getWebLn().sendPayment(invoice)) } } + +// Gift Wraps + +export const enableGiftWraps = () => { + canDecrypt.set(true) + + for (const event of repository.query([{kinds: [WRAP]}])) { + ensureUnwrapped(event) + } +} diff --git a/src/app/core/state.ts b/src/app/core/state.ts index 636487f..475968e 100644 --- a/src/app/core/state.ts +++ b/src/app/core/state.ts @@ -21,6 +21,7 @@ import { identity, groupBy, always, + tryCatch, } from "@welshman/lib" import type {Socket} from "@welshman/net" import {Pool, load, AuthStateEvent, SocketEvent, netContext} from "@welshman/net" @@ -32,6 +33,7 @@ import { withGetter, synced, } from "@welshman/store" +import {isKindFeed, findFeed} from "@welshman/feeds" import { getIdFilters, WRAP, @@ -70,6 +72,8 @@ import { getTagValues, verifyEvent, makeEvent, + RelayMode, + getRelaysFromList, } from "@welshman/util" import type {TrustedEvent, SignedEvent, PublishedList, List, Filter} from "@welshman/util" import {Nip59, decrypt} from "@welshman/signer" @@ -94,6 +98,8 @@ import { appContext, getThunkError, publishThunk, + userRelaySelections, + userInboxRelaySelections, } from "@welshman/app" import type {Thunk, Relay} from "@welshman/app" import {preferencesStorageProvider} from "@src/lib/storage" @@ -375,6 +381,18 @@ export const relaysPendingTrust = writable([]) export const relaysMostlyRestricted = writable>({}) +// Relay selections + +export const userReadRelays = derived(userRelaySelections, $l => + getRelaysFromList($l, RelayMode.Read), +) + +export const userWriteRelays = derived(userRelaySelections, $l => + getRelaysFromList($l, RelayMode.Write), +) + +export const userInboxRelays = derived(userInboxRelaySelections, $l => getRelaysFromList($l)) + // Alerts export type Alert = { @@ -394,6 +412,17 @@ export const alerts = withGetter( }), ) +export const getAlertFeed = (alert: Alert) => + tryCatch(() => JSON.parse(getTagValue("feed", alert.tags)!)) + +export const dmAlert = derived(alerts, $alerts => + $alerts.find(alert => { + const feed = getAlertFeed(alert) + + return findFeed(feed, f => isKindFeed(f) && f.includes(WRAP)) + }), +) + // Alert Statuses export type AlertStatus = { diff --git a/src/app/util/push.ts b/src/app/util/push.ts index 0eae805..6c06940 100644 --- a/src/app/util/push.ts +++ b/src/app/util/push.ts @@ -2,7 +2,7 @@ import * as nip19 from "nostr-tools/nip19" import {Capacitor} from "@capacitor/core" import type {ActionPerformed, RegistrationError, Token} from "@capacitor/push-notifications" import {PushNotifications} from "@capacitor/push-notifications" -import {parseJson, poll} from "@welshman/lib" +import {parseJson, sleep, poll} from "@welshman/lib" import {isSignedEvent} from "@welshman/util" import {goto} from "$app/navigation" import {ucFirst} from "@lib/util" @@ -50,6 +50,8 @@ export const getWebPushInfo = async () => { } const registration = await navigator.serviceWorker.ready + + // This will hang on firefox in development builds, but works in production let subscription = await registration.pushManager.getSubscription() if (!subscription) { @@ -118,14 +120,19 @@ export const getCapacitorPushInfo = async () => { return info } -export const getPushInfo = (): Promise> => { - switch (platform) { - case "web": - return getWebPushInfo() - case "ios": - case "android": - return getCapacitorPushInfo() - default: - throw new Error(`Invalid push platform: ${platform}`) - } -} +export const getPushInfo = (): Promise> => + new Promise((resolve, reject) => { + sleep(3000).then(() => reject("Failed to request notification permissions")) + + switch (platform) { + case "web": + getWebPushInfo().then(resolve, reject) + break + case "ios": + case "android": + getCapacitorPushInfo().then(resolve, reject) + break + default: + reject(`Invalid push platform: ${platform}`) + } + }) diff --git a/src/app/util/routes.ts b/src/app/util/routes.ts index c06328e..117af85 100644 --- a/src/app/util/routes.ts +++ b/src/app/util/routes.ts @@ -14,8 +14,10 @@ import { THREAD, ZAP_GOAL, EVENT_TIME, + getPubkeyTagValues, } from "@welshman/util" import { + ensureUnwrapped, makeChatId, entityLink, decodeRelay, @@ -74,24 +76,28 @@ export const getPrimaryNavItemIndex = ($page: Page) => { } export const goToEvent = async (event: TrustedEvent, options: Record = {}) => { - if (event.kind === DIRECT_MESSAGE || event.kind === DIRECT_MESSAGE_FILE) { - await scrollToEvent(event.id) - } + const unwrapped = await ensureUnwrapped(event) - const urls = Array.from(tracker.getRelays(event.id)) - const path = await getEventPath(event, urls) + if (unwrapped) { + const urls = Array.from(tracker.getRelays(unwrapped.id)) + const path = await getEventPath(unwrapped, urls) - if (path.includes("://")) { - window.open(path) - } else { - goto(path, options) + if (path.includes("://")) { + window.open(path) + } else { + goto(path, options) - await sleep(300) - await scrollToEvent(event.id) + await sleep(300) + await scrollToEvent(unwrapped.id) + } } } export const getEventPath = async (event: TrustedEvent, urls: string[]) => { + if (event.kind === DIRECT_MESSAGE || event.kind === DIRECT_MESSAGE_FILE) { + return makeChatPath([event.pubkey, ...getPubkeyTagValues(event.tags)]) + } + const room = getTagValue(ROOM, event.tags) if (urls.length > 0) { diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index c6ce64a..a3a9e08 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -26,6 +26,11 @@ import type {TrustedEvent, StampedEvent} from "@welshman/util" import { WRAP, + ALERT_STATUS, + ALERT_EMAIL, + ALERT_WEB, + ALERT_IOS, + ALERT_ANDROID, EVENT_TIME, APP_DATA, THREAD, @@ -39,7 +44,6 @@ RELAYS, BLOSSOM_SERVERS, ROOMS, - getRelaysFromList, } from "@welshman/util" import {Nip46Broker, makeSecret} from "@welshman/signer" import type {Socket, RelayMessage, ClientMessage} from "@welshman/net" @@ -67,7 +71,6 @@ signerLog, dropSession, defaultStorageAdapters, - userInboxRelaySelections, loginWithNip01, loginWithNip46, EventsStorageAdapter, @@ -96,6 +99,7 @@ canDecrypt, getSetting, relaysMostlyRestricted, + userInboxRelays, } from "@app/core/state" import {loadUserData, listenForNotifications} from "@app/core/requests" import {theme} from "@app/util/theme" @@ -290,6 +294,11 @@ INBOX_RELAYS, ROOMS, APP_DATA, + ALERT_STATUS, + ALERT_EMAIL, + ALERT_WEB, + ALERT_IOS, + ALERT_ANDROID, ].includes(e.kind) ) { return 1 @@ -437,19 +446,19 @@ // Listen for chats, populate chat-based notifications let controller: AbortController - derived([pubkey, canDecrypt, userInboxRelaySelections], identity).subscribe( - ([$pubkey, $canDecrypt, $userInboxRelaySelections]) => { + derived([pubkey, canDecrypt, userInboxRelays], identity).subscribe( + ([$pubkey, $canDecrypt, $userInboxRelays]) => { controller?.abort() controller = new AbortController() if ($pubkey && $canDecrypt) { request({ signal: controller.signal, + relays: $userInboxRelays, filters: [ {kinds: [WRAP], "#p": [$pubkey], since: ago(WEEK, 2)}, {kinds: [WRAP], "#p": [$pubkey], limit: 100}, ], - relays: getRelaysFromList($userInboxRelaySelections), }) } }, diff --git a/src/routes/chat/[chat]/+page.svelte b/src/routes/chat/[chat]/+page.svelte index b5e71ac..c44568f 100644 --- a/src/routes/chat/[chat]/+page.svelte +++ b/src/routes/chat/[chat]/+page.svelte @@ -1,11 +1,13 @@ diff --git a/src/routes/settings/+layout.svelte b/src/routes/settings/+layout.svelte index ee96332..818ddc3 100644 --- a/src/routes/settings/+layout.svelte +++ b/src/routes/settings/+layout.svelte @@ -8,6 +8,8 @@ import Moon from "@assets/icons/moon.svg?dataurl" import InfoSquare from "@assets/icons/info-square.svg?dataurl" import Exit from "@assets/icons/logout-3.svg?dataurl" + import GalleryMinimalistic from "@assets/icons/gallery-minimalistic.svg?dataurl" + import Bell from "@assets/icons/bell.svg?dataurl" import Icon from "@lib/components/Icon.svelte" import Page from "@lib/components/Page.svelte" import SecondaryNav from "@lib/components/SecondaryNav.svelte" @@ -30,37 +32,45 @@ + + Your Settings +
Profile
-
+
+ + Alerts + +
+
Wallet
-
+
Relays
-
- - Settings +
+ + Content
-
+
Theme
-
+
About
-
+
Log Out diff --git a/src/routes/settings/alerts/+page.svelte b/src/routes/settings/alerts/+page.svelte new file mode 100644 index 0000000..9e3b7c2 --- /dev/null +++ b/src/routes/settings/alerts/+page.svelte @@ -0,0 +1,7 @@ + + +
+ +
diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/content/+page.svelte similarity index 100% rename from src/routes/settings/+page.svelte rename to src/routes/settings/content/+page.svelte diff --git a/src/routes/settings/profile/+page.svelte b/src/routes/settings/profile/+page.svelte index 79c8d6d..b1ce483 100644 --- a/src/routes/settings/profile/+page.svelte +++ b/src/routes/settings/profile/+page.svelte @@ -22,7 +22,6 @@ import ProfileDelete from "@app/components/ProfileDelete.svelte" import SignerStatus from "@app/components/SignerStatus.svelte" import InfoKeys from "@app/components/InfoKeys.svelte" - import Alerts from "@app/components/Alerts.svelte" import {PLATFORM_NAME} from "@app/core/state" import {pushModal} from "@app/util/modal" import {clip} from "@app/util/toast" @@ -141,7 +140,6 @@ {/if}
-