Move alerts to their own page, add direct message alerts

This commit is contained in:
Jon Staab
2025-09-09 09:54:08 -07:00
parent 69bd6d0e70
commit fc6a1a3819
22 changed files with 506 additions and 224 deletions

View File

@@ -57,17 +57,17 @@
"@types/throttle-debounce": "^5.0.2", "@types/throttle-debounce": "^5.0.2",
"@vite-pwa/assets-generator": "^0.2.6", "@vite-pwa/assets-generator": "^0.2.6",
"@vite-pwa/sveltekit": "^0.6.6", "@vite-pwa/sveltekit": "^0.6.6",
"@welshman/app": "^0.4.4", "@welshman/app": "^0.4.6",
"@welshman/content": "^0.4.4", "@welshman/content": "^0.4.6",
"@welshman/editor": "^0.4.4", "@welshman/editor": "^0.4.6",
"@welshman/feeds": "^0.4.4", "@welshman/feeds": "^0.4.6",
"@welshman/lib": "^0.4.4", "@welshman/lib": "^0.4.6",
"@welshman/net": "^0.4.4", "@welshman/net": "^0.4.6",
"@welshman/relay": "^0.4.4", "@welshman/relay": "^0.4.6",
"@welshman/router": "^0.4.4", "@welshman/router": "^0.4.6",
"@welshman/signer": "^0.4.4", "@welshman/signer": "^0.4.6",
"@welshman/store": "^0.4.4", "@welshman/store": "^0.4.6",
"@welshman/util": "^0.4.4", "@welshman/util": "^0.4.6",
"compressorjs": "^1.2.1", "compressorjs": "^1.2.1",
"daisyui": "^4.12.10", "daisyui": "^4.12.10",
"date-picker-svelte": "^2.13.0", "date-picker-svelte": "^2.13.0",

BIN
pnpm-lock.yaml generated

Binary file not shown.

View File

@@ -1,17 +1,8 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import {decrypt} from "@welshman/signer" import {randomInt, displayList, TIMEZONE, identity} from "@welshman/lib"
import {randomInt, parseJson, fromPairs, displayList, TIMEZONE, identity} from "@welshman/lib" import {displayRelayUrl, getTagValue, THREAD, MESSAGE, EVENT_TIME, COMMENT} from "@welshman/util"
import {
displayRelayUrl,
getTagValue,
getAddress,
THREAD,
MESSAGE,
EVENT_TIME,
COMMENT,
} from "@welshman/util"
import type {Filter} from "@welshman/util" import type {Filter} from "@welshman/util"
import {makeIntersectionFeed, makeRelayFeed, feedFromFilters} from "@welshman/feeds" import {makeIntersectionFeed, makeRelayFeed, feedFromFilters} from "@welshman/feeds"
import {pubkey, signer, waitForThunkError} from "@welshman/app" import {pubkey, signer, waitForThunkError} from "@welshman/app"
@@ -23,17 +14,10 @@
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import { import {alerts, getMembershipUrls, userMembership} from "@app/core/state"
alerts, import {requestRelayClaim} from "@app/core/requests"
getMembershipUrls, import {createAlert} from "@app/core/commands"
userMembership, import {canSendPushNotifications} from "@app/util/push"
NOTIFIER_PUBKEY,
NOTIFIER_RELAY,
} from "@app/core/state"
import {loadAlertStatuses, requestRelayClaim} from "@app/core/requests"
import {publishAlert, attemptAuth} from "@app/core/commands"
import type {AlertParams} from "@app/core/commands"
import {platform, platformName, canSendPushNotifications, getPushInfo} from "@app/util/push"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
type Props = { type Props = {
@@ -112,66 +96,20 @@
try { try {
const claim = url ? await requestRelayClaim(url) : undefined const claim = url ? await requestRelayClaim(url) : undefined
const claims = claim ? {[url]: claim} : {}
const feed = makeIntersectionFeed(feedFromFilters(filters), makeRelayFeed(url))
const description = `for ${displayList(display)} on ${displayRelayUrl(url)}`
const params: AlertParams = {feed, claims, description}
if (channel === "email") { const {error} = await createAlert({
const cadence = cron?.endsWith("1") ? "Weekly" : "Daily" feed: makeIntersectionFeed(feedFromFilters(filters), makeRelayFeed(url)),
claims: claim ? {[url]: claim} : {},
params.description = `${cadence} alert ${description}, sent via email.` description: `for ${displayList(display)} on ${displayRelayUrl(url)}`,
params.email = { email: channel === "email" ? {cron, email} : undefined,
cron, })
email,
handler: [
"31990:97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322:1737058597050",
"wss://relay.nostr.band/",
"web",
],
}
} else {
try {
// @ts-ignore
params[platform] = await getPushInfo()
params.description = `${platformName} push notification ${description}.`
} catch (e: any) {
return pushToast({
theme: "error",
message: String(e),
})
}
}
// If we don't do this we'll get an event rejection
await attemptAuth(NOTIFIER_RELAY)
const thunk = await publishAlert(params)
const error = await waitForThunkError(thunk)
if (error) { if (error) {
return pushToast({ pushToast({theme: "error", message: error})
theme: "error", } else {
message: `Failed to send your alert to the notification server (${error}).`, pushToast({message: "Your alert has been successfully created!"})
}) back()
} }
// Fetch our new status to make sure it's active
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<string, string> =
fromPairs(statusTags)
if (status === "error") {
return pushToast({theme: "error", message})
}
pushToast({message: "Your alert has been successfully created!"})
back()
} finally { } finally {
loading = false loading = false
} }
@@ -189,6 +127,9 @@
{#snippet title()} {#snippet title()}
Add an Alert Add an Alert
{/snippet} {/snippet}
{#snippet info()}
Enable notifications to keep up to date on activity you care about.
{/snippet}
</ModalHeader> </ModalHeader>
{#if canSendPushNotifications()} {#if canSendPushNotifications()}
<FieldInline> <FieldInline>

View File

@@ -1,8 +1,7 @@
<script lang="ts"> <script lang="ts">
import Confirm from "@lib/components/Confirm.svelte" import Confirm from "@lib/components/Confirm.svelte"
import type {Alert} from "@app/core/state" import type {Alert} from "@app/core/state"
import {NOTIFIER_RELAY, NOTIFIER_PUBKEY} from "@app/core/state" import {deleteAlert} from "@app/core/commands"
import {publishDelete} from "@app/core/commands"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
type Props = { type Props = {
@@ -12,10 +11,7 @@
const {alert}: Props = $props() const {alert}: Props = $props()
const confirm = () => { const confirm = () => {
const relays = [NOTIFIER_RELAY] deleteAlert(alert)
const tags = [["p", NOTIFIER_PUBKEY]]
publishDelete({event: alert.event, relays, tags, protect: false})
pushToast({message: "Your alert has been deleted!"}) pushToast({message: "Your alert has been deleted!"})
history.back() history.back()
} }

View File

@@ -6,8 +6,8 @@
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import AlertDelete from "@app/components/AlertDelete.svelte" import AlertDelete from "@app/components/AlertDelete.svelte"
import AlertStatus from "@app/components/AlertStatus.svelte"
import type {Alert} from "@app/core/state" import type {Alert} from "@app/core/state"
import {deriveAlertStatus} from "@app/core/state"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
type Props = { type Props = {
@@ -16,7 +16,6 @@
const {alert}: Props = $props() const {alert}: Props = $props()
const status = deriveAlertStatus(getAddress(alert.event))
const cron = $derived(getTagValue("cron", alert.tags)) const cron = $derived(getTagValue("cron", alert.tags))
const channel = $derived(getTagValue("channel", alert.tags)) const channel = $derived(getTagValue("channel", alert.tags))
const feeds = $derived(getTagValues("feed", alert.tags)) const feeds = $derived(getTagValues("feed", alert.tags))
@@ -39,32 +38,5 @@
</Button> </Button>
<div class="flex-inline gap-1">{description}</div> <div class="flex-inline gap-1">{description}</div>
</div> </div>
{#if $status} <AlertStatus {alert} />
{@const statusText = getTagValue("status", $status.tags) || "error"}
{#if statusText === "ok"}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-base-content px-3 py-1 text-sm"
data-tip={getTagValue("message", $status.tags)}>
Active
</span>
{:else if statusText === "pending"}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-base-content border-yellow-500 px-3 py-1 text-sm text-yellow-500"
data-tip={getTagValue("message", $status.tags)}>
Pending
</span>
{:else}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-error px-3 py-1 text-sm text-error"
data-tip={getTagValue("message", $status.tags)}>
{statusText.replace("-", " ").replace(/^(.)/, x => x.toUpperCase())}
</span>
{/if}
{:else}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-error px-3 py-1 text-sm text-error"
data-tip="The notification server did not respond to your request.">
Inactive
</span>
{/if}
</div> </div>

View File

@@ -0,0 +1,42 @@
<script lang="ts">
import {getAddress, getTagValue} from "@welshman/util"
import type {Alert} from "@app/core/state"
import {deriveAlertStatus} from "@app/core/state"
type Props = {
alert: Alert
}
const {alert}: Props = $props()
const status = deriveAlertStatus(getAddress(alert.event))
</script>
{#if $status}
{@const statusText = getTagValue("status", $status.tags) || "error"}
{#if statusText === "ok"}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-base-content px-3 py-1 text-sm"
data-tip={getTagValue("message", $status.tags)}>
Active
</span>
{:else if statusText === "pending"}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-base-content border-yellow-500 px-3 py-1 text-sm text-yellow-500"
data-tip={getTagValue("message", $status.tags)}>
Pending
</span>
{:else}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-error px-3 py-1 text-sm text-error"
data-tip={getTagValue("message", $status.tags)}>
{statusText.replace("-", " ").replace(/^(.)/, x => x.toUpperCase())}
</span>
{/if}
{:else}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-error px-3 py-1 text-sm text-error"
data-tip="The notification server did not respond to your request.">
Inactive
</span>
{/if}

View File

@@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import {getTagValue} from "@welshman/util" import {sleep} from "@welshman/lib"
import {getTagValue, getAddress} from "@welshman/util"
import {isRelayFeed, findFeed} from "@welshman/feeds"
import Inbox from "@assets/icons/inbox.svg?dataurl" import Inbox from "@assets/icons/inbox.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl" import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
@@ -7,7 +9,9 @@
import AlertAdd from "@app/components/AlertAdd.svelte" import AlertAdd from "@app/components/AlertAdd.svelte"
import AlertItem from "@app/components/AlertItem.svelte" import AlertItem from "@app/components/AlertItem.svelte"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
import {alerts} from "@app/core/state" import {pushToast} from "@app/util/toast"
import {alerts, dmAlert, deriveAlertStatus, userInboxRelays, getAlertFeed} from "@app/core/state"
import {deleteAlert, createDmAlert} from "@app/core/commands"
type Props = { type Props = {
url?: string url?: string
@@ -17,29 +21,92 @@
const {url = "", channel = "push", hideSpaceField = false}: Props = $props() const {url = "", channel = "push", hideSpaceField = false}: Props = $props()
const startAlert = () => pushModal(AlertAdd, {url, channel, hideSpaceField}) const dmStatus = $derived($dmAlert ? deriveAlertStatus(getAddress($dmAlert.event)) : undefined)
const filteredAlerts = $derived( const filteredAlerts = $derived(
url ? $alerts.filter(a => getTagValue("feed", a.tags)?.includes(url)) : $alerts, $alerts.filter(alert => {
const feed = getAlertFeed(alert)
// Skip non-feeds and DM alerts
if (!feed || alert === $dmAlert) return false
// If we have a space url, only match feeds for this space
if (url) return findFeed(feed, f => isRelayFeed(f) && f.includes(url))
return true
}),
) )
const startAlert = () => pushModal(AlertAdd, {url, channel, hideSpaceField})
const uncheckDmAlert = async (message: string) => {
await sleep(100)
toggle.checked = false
pushToast({theme: "error", message})
}
const onToggle = async () => {
if ($dmAlert) {
deleteAlert($dmAlert)
} else {
if ($userInboxRelays.length === 0) {
return uncheckDmAlert("Please set up your messaging relays before enabling alerts.")
}
const {error} = await createDmAlert()
if (error) {
return uncheckDmAlert(error)
}
pushToast({message: "Your alert has been successfully created!"})
}
}
let toggle: HTMLInputElement
</script> </script>
<div class="card2 bg-alt flex flex-col gap-6 shadow-xl"> <div class="col-4">
<div class="flex items-center justify-between"> <div class="card2 bg-alt flex flex-col gap-6 shadow-xl">
<strong class="flex items-center gap-3"> <div class="flex items-center justify-between">
<Icon icon={Inbox} /> <strong class="flex items-center gap-3">
Alerts <Icon icon={Inbox} />
</strong> Alerts
<Button class="btn btn-primary btn-sm" onclick={startAlert}> </strong>
<Icon icon={AddCircle} /> <Button class="btn btn-primary btn-sm" onclick={startAlert}>
Add Alert <Icon icon={AddCircle} />
</Button> Add Alert
</Button>
</div>
<div class="col-4">
{#each filteredAlerts as alert (alert.event.id)}
<AlertItem {alert} />
{:else}
<p class="text-center opacity-75 py-12">Nothing here yet!</p>
{/each}
</div>
</div> </div>
<div class="col-4"> <div class="card2 bg-alt flex flex-col gap-4 shadow-xl">
{#each filteredAlerts as alert (alert.event.id)} <div class="flex justify-between">
<AlertItem {alert} /> <p>Notify me about new direct messages</p>
{:else} <input
<p class="text-center opacity-75 py-12">Nothing here yet!</p> type="checkbox"
{/each} class="toggle toggle-primary"
bind:this={toggle}
checked={Boolean($dmAlert)}
oninput={onToggle} />
</div>
{#if $dmStatus}
{@const status = getTagValue("status", $dmStatus.tags) || "error"}
{#if status !== "ok"}
<div class="alert alert-error border border-solid border-error bg-transparent text-error">
<p>
{getTagValue("message", $dmStatus.tags) ||
"The notification server did not respond to your request."}
</p>
</div>
{/if}
{/if}
</div> </div>
</div> </div>

View File

@@ -1,7 +1,5 @@
<script lang="ts"> <script lang="ts">
import {goto} from "$app/navigation" import {goto} from "$app/navigation"
import {WRAP} from "@welshman/util"
import {repository} from "@welshman/app"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl" import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl" import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
@@ -10,7 +8,8 @@
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import {canDecrypt, PLATFORM_NAME, ensureUnwrapped} from "@app/core/state" import {PLATFORM_NAME} from "@app/core/state"
import {enableGiftWraps} from "@app/core/commands"
import {clearModals} from "@app/util/modal" import {clearModals} from "@app/util/modal"
const {next} = $props() const {next} = $props()
@@ -20,12 +19,7 @@
let loading = $state(false) let loading = $state(false)
const enableChat = async () => { const enableChat = async () => {
canDecrypt.set(true) enableGiftWraps()
for (const event of repository.query([{kinds: [WRAP]}])) {
ensureUnwrapped(event)
}
clearModals() clearModals()
goto(nextUrl) goto(nextUrl)
} }

View File

@@ -1,11 +1,18 @@
<script lang="ts"> <script lang="ts">
import {waitForThunkCompletion} from "@welshman/app"
import ChatSquare from "@assets/icons/chat-square.svg?dataurl" import ChatSquare from "@assets/icons/chat-square.svg?dataurl"
import Check from "@assets/icons/check.svg?dataurl" import Check from "@assets/icons/check.svg?dataurl"
import Bell from "@assets/icons/bell.svg?dataurl"
import BellOff from "@assets/icons/bell-off.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ChatStart from "@app/components/ChatStart.svelte" import ChatStart from "@app/components/ChatStart.svelte"
import {setChecked} from "@app/util/notifications" import {setChecked} from "@app/util/notifications"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast"
import {dmAlert, userInboxRelays} from "@app/core/state"
import {deleteAlert, createDmAlert} from "@app/core/commands"
const startChat = () => pushModal(ChatStart, {}, {replaceState: true}) const startChat = () => pushModal(ChatStart, {}, {replaceState: true})
@@ -13,6 +20,40 @@
setChecked("/chat/*") setChecked("/chat/*")
history.back() history.back()
} }
const enableAlerts = async () => {
if ($userInboxRelays.length === 0) {
return pushToast({
theme: "error",
message: "Please set up your messaging relays before enabling alerts.",
})
}
enablingAlert = true
try {
const {error} = await createDmAlert()
if (error) {
return pushToast({theme: "error", message: error})
}
} finally {
enablingAlert = false
}
}
const disableAlerts = async () => {
disablingAlert = true
try {
await waitForThunkCompletion(deleteAlert($dmAlert!))
} finally {
disablingAlert = false
}
}
let enablingAlert = $state(false)
let disablingAlert = $state(false)
</script> </script>
<div class="col-2"> <div class="col-2">
@@ -24,4 +65,19 @@
<Icon size={5} icon={Check} /> <Icon size={5} icon={Check} />
Mark all read Mark all read
</Button> </Button>
{#if (!enablingAlert && $dmAlert) || disablingAlert}
<Button class="btn btn-neutral" onclick={disableAlerts} disabled={disablingAlert}>
{#if !disablingAlert}
<Icon size={4} icon={BellOff} />
{/if}
<Spinner loading={disablingAlert}>Disable alerts</Spinner>
</Button>
{:else}
<Button class="btn btn-neutral" onclick={enableAlerts} disabled={enablingAlert}>
{#if !enablingAlert}
<Icon size={4} icon={Bell} />
{/if}
<Spinner loading={enablingAlert}>Enable alerts</Spinner>
</Button>
{/if}
</div> </div>

View File

@@ -4,6 +4,8 @@
import Settings from "@assets/icons/settings-minimalistic.svg?dataurl" import Settings from "@assets/icons/settings-minimalistic.svg?dataurl"
import Code2 from "@assets/icons/code-2.svg?dataurl" import Code2 from "@assets/icons/code-2.svg?dataurl"
import Exit from "@assets/icons/logout-3.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 Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@@ -29,6 +31,32 @@
{/snippet} {/snippet}
</CardButton> </CardButton>
</Link> </Link>
<Link replaceState href="/settings/alerts">
<CardButton>
{#snippet icon()}
<div><Icon icon={Bell} size={7} /></div>
{/snippet}
{#snippet title()}
<div>Alerts</div>
{/snippet}
{#snippet info()}
<div>Set up email digests and push notifications</div>
{/snippet}
</CardButton>
</Link>
<Link replaceState href="/settings/wallet">
<CardButton>
{#snippet icon()}
<div><Icon icon={Wallet} size={7} /></div>
{/snippet}
{#snippet title()}
<div>Wallet</div>
{/snippet}
{#snippet info()}
<div>Connect a bitcoin wallet for sending social tips</div>
{/snippet}
</CardButton>
</Link>
<Link replaceState href="/settings/relays"> <Link replaceState href="/settings/relays">
<CardButton> <CardButton>
{#snippet icon()} {#snippet icon()}
@@ -42,7 +70,7 @@
{/snippet} {/snippet}
</CardButton> </CardButton>
</Link> </Link>
<Link replaceState href="/settings"> <Link replaceState href="/settings/content">
<CardButton> <CardButton>
{#snippet icon()} {#snippet icon()}
<div><Icon icon={Settings} size={7} /></div> <div><Icon icon={Settings} size={7} /></div>

View File

@@ -7,9 +7,8 @@
DELETE, DELETE,
isReplaceable, isReplaceable,
getAddress, getAddress,
getRelaysFromList,
} from "@welshman/util" } from "@welshman/util"
import {pubkey, userRelaySelections, publishThunk, repository} from "@welshman/app" import {pubkey, publishThunk, repository} from "@welshman/app"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl" import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl" import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
@@ -20,7 +19,13 @@
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
import {logout} from "@app/core/commands" 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 progress: number | undefined = $state(undefined)
let confirmText = $state("") let confirmText = $state("")
@@ -43,7 +48,7 @@
const denominator = chunks.length + 2 const denominator = chunks.length + 2
const relays = uniq([ const relays = uniq([
...INDEXER_RELAYS, ...INDEXER_RELAYS,
...getRelaysFromList($userRelaySelections), ...$userWriteRelays,
...getMembershipUrls($userMembership), ...getMembershipUrls($userMembership),
]) ])

View File

@@ -25,6 +25,7 @@
class="underline transition-all" class="underline transition-all"
class:link={isSending} class:link={isSending}
class:opacity-25={!isSending} class:opacity-25={!isSending}
class:pointer-events-none={!isSending}
onclick={stopPropagation(abort)}> onclick={stopPropagation(abort)}>
Cancel Cancel
</button> </button>

View File

@@ -1,6 +1,7 @@
import {nwc} from "@getalby/sdk" import {nwc} from "@getalby/sdk"
import * as nip19 from "nostr-tools/nip19" import * as nip19 from "nostr-tools/nip19"
import {get} from "svelte/store" import {get} from "svelte/store"
import type {Override, MakeOptional} from "@welshman/lib"
import { import {
randomId, randomId,
append, append,
@@ -11,10 +12,15 @@ import {
equals, equals,
TIMEZONE, TIMEZONE,
LOCALE, LOCALE,
parseJson,
fromPairs,
} from "@welshman/lib" } from "@welshman/lib"
import {decrypt} from "@welshman/signer"
import type {Feed} from "@welshman/feeds" import type {Feed} from "@welshman/feeds"
import {makeIntersectionFeed, feedFromFilters, makeRelayFeed} from "@welshman/feeds"
import type {TrustedEvent, EventContent} from "@welshman/util" import type {TrustedEvent, EventContent} from "@welshman/util"
import { import {
WRAP,
DELETE, DELETE,
REPORT, REPORT,
PROFILE, PROFILE,
@@ -44,6 +50,8 @@ import {
toNostrURI, toNostrURI,
getRelaysFromList, getRelaysFromList,
RelayMode, RelayMode,
getAddress,
getTagValue,
} from "@welshman/util" } from "@welshman/util"
import {Pool, AuthStatus, SocketStatus} from "@welshman/net" import {Pool, AuthStatus, SocketStatus} from "@welshman/net"
import {Router} from "@welshman/router" import {Router} from "@welshman/router"
@@ -54,7 +62,6 @@ import {
repository, repository,
publishThunk, publishThunk,
profilesByPubkey, profilesByPubkey,
relaySelectionsByPubkey,
tagEvent, tagEvent,
tagEventForReaction, tagEventForReaction,
userRelaySelections, userRelaySelections,
@@ -66,8 +73,9 @@ import {
tagEventForComment, tagEventForComment,
tagEventForQuote, tagEventForQuote,
waitForThunkError, waitForThunkError,
getPubkeyRelays,
} from "@welshman/app" } from "@welshman/app"
import type {SettingsValues} from "@app/core/state" import type {SettingsValues, Alert} from "@app/core/state"
import { import {
SETTINGS, SETTINGS,
PROTECTED, PROTECTED,
@@ -77,14 +85,18 @@ import {
NOTIFIER_RELAY, NOTIFIER_RELAY,
userRoomsByUrl, userRoomsByUrl,
userSettingsValues, userSettingsValues,
canDecrypt,
ensureUnwrapped,
userInboxRelays,
} from "@app/core/state" } from "@app/core/state"
import {loadAlertStatuses} from "@app/core/requests"
import {platform, platformName, getPushInfo} from "@app/util/push"
import {preferencesStorageProvider} from "@src/lib/storage" import {preferencesStorageProvider} from "@src/lib/storage"
// Utils // Utils
export const getPubkeyHints = (pubkey: string) => { export const getPubkeyHints = (pubkey: string) => {
const selections = relaySelectionsByPubkey.get().get(pubkey) const relays = getPubkeyRelays(pubkey, RelayMode.Write)
const relays = selections ? getRelaysFromList(selections, RelayMode.Write) : []
const hints = relays.length ? relays : INDEXER_RELAYS const hints = relays.length ? relays : INDEXER_RELAYS
return hints return hints
@@ -417,27 +429,35 @@ export const publishComment = ({relays, ...params}: CommentParams & {relays: str
// Alerts // 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 = { export type AlertParams = {
feed: Feed feed: Feed
description: string description: string
claims: Record<string, string> claims?: Record<string, string>
email?: { email?: AlertParamsEmail
cron: string web?: AlertParamsWeb
email: string ios?: AlertParamsIos
handler: string[] android?: AlertParamsAndroid
}
web?: {
endpoint: string
p256dh: string
auth: string
}
ios?: {
device_token: string
bundle_identifier: string
}
android?: {
device_token: string
}
} }
export const makeAlert = async (params: AlertParams) => { export const makeAlert = async (params: AlertParams) => {
@@ -448,7 +468,7 @@ export const makeAlert = async (params: AlertParams) => {
["description", params.description], ["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]) tags.push(["claim", relay, claim])
} }
@@ -481,6 +501,88 @@ export const makeAlert = async (params: AlertParams) => {
export const publishAlert = async (params: AlertParams) => export const publishAlert = async (params: AlertParams) =>
publishThunk({event: await makeAlert(params), relays: [NOTIFIER_RELAY]}) 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<AlertParamsEmail, "handler">
}
>
export type CreateAlertResult = {
ok?: true
error?: string
}
export const createAlert = async (params: CreateAlertParams): Promise<CreateAlertResult> => {
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<string, string> =
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 // Settings
export const makeSettings = async (params: Partial<SettingsValues>) => { export const makeSettings = async (params: Partial<SettingsValues>) => {
@@ -532,3 +634,13 @@ export const payInvoice = async (invoice: string) => {
.then(() => getWebLn().sendPayment(invoice)) .then(() => getWebLn().sendPayment(invoice))
} }
} }
// Gift Wraps
export const enableGiftWraps = () => {
canDecrypt.set(true)
for (const event of repository.query([{kinds: [WRAP]}])) {
ensureUnwrapped(event)
}
}

View File

@@ -21,6 +21,7 @@ import {
identity, identity,
groupBy, groupBy,
always, always,
tryCatch,
} from "@welshman/lib" } from "@welshman/lib"
import type {Socket} from "@welshman/net" import type {Socket} from "@welshman/net"
import {Pool, load, AuthStateEvent, SocketEvent, netContext} from "@welshman/net" import {Pool, load, AuthStateEvent, SocketEvent, netContext} from "@welshman/net"
@@ -32,6 +33,7 @@ import {
withGetter, withGetter,
synced, synced,
} from "@welshman/store" } from "@welshman/store"
import {isKindFeed, findFeed} from "@welshman/feeds"
import { import {
getIdFilters, getIdFilters,
WRAP, WRAP,
@@ -70,6 +72,8 @@ import {
getTagValues, getTagValues,
verifyEvent, verifyEvent,
makeEvent, makeEvent,
RelayMode,
getRelaysFromList,
} from "@welshman/util" } from "@welshman/util"
import type {TrustedEvent, SignedEvent, PublishedList, List, Filter} from "@welshman/util" import type {TrustedEvent, SignedEvent, PublishedList, List, Filter} from "@welshman/util"
import {Nip59, decrypt} from "@welshman/signer" import {Nip59, decrypt} from "@welshman/signer"
@@ -94,6 +98,8 @@ import {
appContext, appContext,
getThunkError, getThunkError,
publishThunk, publishThunk,
userRelaySelections,
userInboxRelaySelections,
} from "@welshman/app" } from "@welshman/app"
import type {Thunk, Relay} from "@welshman/app" import type {Thunk, Relay} from "@welshman/app"
import {preferencesStorageProvider} from "@src/lib/storage" import {preferencesStorageProvider} from "@src/lib/storage"
@@ -375,6 +381,18 @@ export const relaysPendingTrust = writable<string[]>([])
export const relaysMostlyRestricted = writable<Record<string, string>>({}) export const relaysMostlyRestricted = writable<Record<string, string>>({})
// 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 // Alerts
export type Alert = { 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 // Alert Statuses
export type AlertStatus = { export type AlertStatus = {

View File

@@ -2,7 +2,7 @@ import * as nip19 from "nostr-tools/nip19"
import {Capacitor} from "@capacitor/core" import {Capacitor} from "@capacitor/core"
import type {ActionPerformed, RegistrationError, Token} from "@capacitor/push-notifications" import type {ActionPerformed, RegistrationError, Token} from "@capacitor/push-notifications"
import {PushNotifications} 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 {isSignedEvent} from "@welshman/util"
import {goto} from "$app/navigation" import {goto} from "$app/navigation"
import {ucFirst} from "@lib/util" import {ucFirst} from "@lib/util"
@@ -50,6 +50,8 @@ export const getWebPushInfo = async () => {
} }
const registration = await navigator.serviceWorker.ready const registration = await navigator.serviceWorker.ready
// This will hang on firefox in development builds, but works in production
let subscription = await registration.pushManager.getSubscription() let subscription = await registration.pushManager.getSubscription()
if (!subscription) { if (!subscription) {
@@ -118,14 +120,19 @@ export const getCapacitorPushInfo = async () => {
return info return info
} }
export const getPushInfo = (): Promise<Record<string, string>> => { export const getPushInfo = (): Promise<Record<string, string>> =>
switch (platform) { new Promise((resolve, reject) => {
case "web": sleep(3000).then(() => reject("Failed to request notification permissions"))
return getWebPushInfo()
case "ios": switch (platform) {
case "android": case "web":
return getCapacitorPushInfo() getWebPushInfo().then(resolve, reject)
default: break
throw new Error(`Invalid push platform: ${platform}`) case "ios":
} case "android":
} getCapacitorPushInfo().then(resolve, reject)
break
default:
reject(`Invalid push platform: ${platform}`)
}
})

View File

@@ -14,8 +14,10 @@ import {
THREAD, THREAD,
ZAP_GOAL, ZAP_GOAL,
EVENT_TIME, EVENT_TIME,
getPubkeyTagValues,
} from "@welshman/util" } from "@welshman/util"
import { import {
ensureUnwrapped,
makeChatId, makeChatId,
entityLink, entityLink,
decodeRelay, decodeRelay,
@@ -74,24 +76,28 @@ export const getPrimaryNavItemIndex = ($page: Page) => {
} }
export const goToEvent = async (event: TrustedEvent, options: Record<string, any> = {}) => { export const goToEvent = async (event: TrustedEvent, options: Record<string, any> = {}) => {
if (event.kind === DIRECT_MESSAGE || event.kind === DIRECT_MESSAGE_FILE) { const unwrapped = await ensureUnwrapped(event)
await scrollToEvent(event.id)
}
const urls = Array.from(tracker.getRelays(event.id)) if (unwrapped) {
const path = await getEventPath(event, urls) const urls = Array.from(tracker.getRelays(unwrapped.id))
const path = await getEventPath(unwrapped, urls)
if (path.includes("://")) { if (path.includes("://")) {
window.open(path) window.open(path)
} else { } else {
goto(path, options) goto(path, options)
await sleep(300) await sleep(300)
await scrollToEvent(event.id) await scrollToEvent(unwrapped.id)
}
} }
} }
export const getEventPath = async (event: TrustedEvent, urls: string[]) => { 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) const room = getTagValue(ROOM, event.tags)
if (urls.length > 0) { if (urls.length > 0) {

View File

@@ -26,6 +26,11 @@
import type {TrustedEvent, StampedEvent} from "@welshman/util" import type {TrustedEvent, StampedEvent} from "@welshman/util"
import { import {
WRAP, WRAP,
ALERT_STATUS,
ALERT_EMAIL,
ALERT_WEB,
ALERT_IOS,
ALERT_ANDROID,
EVENT_TIME, EVENT_TIME,
APP_DATA, APP_DATA,
THREAD, THREAD,
@@ -39,7 +44,6 @@
RELAYS, RELAYS,
BLOSSOM_SERVERS, BLOSSOM_SERVERS,
ROOMS, ROOMS,
getRelaysFromList,
} from "@welshman/util" } from "@welshman/util"
import {Nip46Broker, makeSecret} from "@welshman/signer" import {Nip46Broker, makeSecret} from "@welshman/signer"
import type {Socket, RelayMessage, ClientMessage} from "@welshman/net" import type {Socket, RelayMessage, ClientMessage} from "@welshman/net"
@@ -67,7 +71,6 @@
signerLog, signerLog,
dropSession, dropSession,
defaultStorageAdapters, defaultStorageAdapters,
userInboxRelaySelections,
loginWithNip01, loginWithNip01,
loginWithNip46, loginWithNip46,
EventsStorageAdapter, EventsStorageAdapter,
@@ -96,6 +99,7 @@
canDecrypt, canDecrypt,
getSetting, getSetting,
relaysMostlyRestricted, relaysMostlyRestricted,
userInboxRelays,
} from "@app/core/state" } from "@app/core/state"
import {loadUserData, listenForNotifications} from "@app/core/requests" import {loadUserData, listenForNotifications} from "@app/core/requests"
import {theme} from "@app/util/theme" import {theme} from "@app/util/theme"
@@ -290,6 +294,11 @@
INBOX_RELAYS, INBOX_RELAYS,
ROOMS, ROOMS,
APP_DATA, APP_DATA,
ALERT_STATUS,
ALERT_EMAIL,
ALERT_WEB,
ALERT_IOS,
ALERT_ANDROID,
].includes(e.kind) ].includes(e.kind)
) { ) {
return 1 return 1
@@ -437,19 +446,19 @@
// Listen for chats, populate chat-based notifications // Listen for chats, populate chat-based notifications
let controller: AbortController let controller: AbortController
derived([pubkey, canDecrypt, userInboxRelaySelections], identity).subscribe( derived([pubkey, canDecrypt, userInboxRelays], identity).subscribe(
([$pubkey, $canDecrypt, $userInboxRelaySelections]) => { ([$pubkey, $canDecrypt, $userInboxRelays]) => {
controller?.abort() controller?.abort()
controller = new AbortController() controller = new AbortController()
if ($pubkey && $canDecrypt) { if ($pubkey && $canDecrypt) {
request({ request({
signal: controller.signal, signal: controller.signal,
relays: $userInboxRelays,
filters: [ filters: [
{kinds: [WRAP], "#p": [$pubkey], since: ago(WEEK, 2)}, {kinds: [WRAP], "#p": [$pubkey], since: ago(WEEK, 2)},
{kinds: [WRAP], "#p": [$pubkey], limit: 100}, {kinds: [WRAP], "#p": [$pubkey], limit: 100},
], ],
relays: getRelaysFromList($userInboxRelaySelections),
}) })
} }
}, },

View File

@@ -1,11 +1,13 @@
<script lang="ts"> <script lang="ts">
import {onDestroy} from "svelte"
import {page} from "$app/stores" import {page} from "$app/stores"
import Chat from "@app/components/Chat.svelte" import Chat from "@app/components/Chat.svelte"
import {setChecked} from "@app/util/notifications" import {notifications, setChecked} from "@app/util/notifications"
onDestroy(() => { // We have to watch this one, since on mobile the badge will be visible when active
setChecked($page.url.pathname) $effect(() => {
if ($notifications.has($page.url.pathname)) {
setChecked($page.url.pathname)
}
}) })
</script> </script>

View File

@@ -8,6 +8,8 @@
import Moon from "@assets/icons/moon.svg?dataurl" import Moon from "@assets/icons/moon.svg?dataurl"
import InfoSquare from "@assets/icons/info-square.svg?dataurl" import InfoSquare from "@assets/icons/info-square.svg?dataurl"
import Exit from "@assets/icons/logout-3.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 Icon from "@lib/components/Icon.svelte"
import Page from "@lib/components/Page.svelte" import Page from "@lib/components/Page.svelte"
import SecondaryNav from "@lib/components/SecondaryNav.svelte" import SecondaryNav from "@lib/components/SecondaryNav.svelte"
@@ -30,37 +32,45 @@
<SecondaryNav> <SecondaryNav>
<SecondaryNavSection> <SecondaryNavSection>
<SecondaryNavItem class="w-full !justify-between">
<strong class="ellipsize flex items-center gap-3"> Your Settings </strong>
</SecondaryNavItem>
<div in:fly|local> <div in:fly|local>
<SecondaryNavItem href="/settings/profile"> <SecondaryNavItem href="/settings/profile">
<Icon icon={UserCircle} /> Profile <Icon icon={UserCircle} /> Profile
</SecondaryNavItem> </SecondaryNavItem>
</div> </div>
<div in:fly|local> <div in:fly|local={{delay: 50}}>
<SecondaryNavItem href="/settings/alerts">
<Icon icon={Bell} /> Alerts
</SecondaryNavItem>
</div>
<div in:fly|local={{delay: 100}}>
<SecondaryNavItem href="/settings/wallet"> <SecondaryNavItem href="/settings/wallet">
<Icon icon={Wallet} /> Wallet <Icon icon={Wallet} /> Wallet
</SecondaryNavItem> </SecondaryNavItem>
</div> </div>
<div in:fly|local={{delay: 50}}> <div in:fly|local={{delay: 150}}>
<SecondaryNavItem href="/settings/relays"> <SecondaryNavItem href="/settings/relays">
<Icon icon={Server} /> Relays <Icon icon={Server} /> Relays
</SecondaryNavItem> </SecondaryNavItem>
</div> </div>
<div in:fly|local={{delay: 100}}> <div in:fly|local={{delay: 200}}>
<SecondaryNavItem href="/settings"> <SecondaryNavItem href="/settings/content">
<Icon icon={Settings} /> Settings <Icon icon={GalleryMinimalistic} /> Content
</SecondaryNavItem> </SecondaryNavItem>
</div> </div>
<div in:fly|local={{delay: 150}}> <div in:fly|local={{delay: 250}}>
<SecondaryNavItem onclick={toggleTheme}> <SecondaryNavItem onclick={toggleTheme}>
<Icon icon={Moon} /> Theme <Icon icon={Moon} /> Theme
</SecondaryNavItem> </SecondaryNavItem>
</div> </div>
<div in:fly|local={{delay: 200}}> <div in:fly|local={{delay: 300}}>
<SecondaryNavItem href="/settings/about"> <SecondaryNavItem href="/settings/about">
<Icon icon={InfoSquare} /> About <Icon icon={InfoSquare} /> About
</SecondaryNavItem> </SecondaryNavItem>
</div> </div>
<div in:fly|local={{delay: 250}}> <div in:fly|local={{delay: 350}}>
<SecondaryNavItem class="text-error hover:text-error" onclick={logout}> <SecondaryNavItem class="text-error hover:text-error" onclick={logout}>
<Icon icon={Exit} /> Log Out <Icon icon={Exit} /> Log Out
</SecondaryNavItem> </SecondaryNavItem>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import Alerts from "@app/components/Alerts.svelte"
</script>
<div class="content column">
<Alerts />
</div>

View File

@@ -22,7 +22,6 @@
import ProfileDelete from "@app/components/ProfileDelete.svelte" import ProfileDelete from "@app/components/ProfileDelete.svelte"
import SignerStatus from "@app/components/SignerStatus.svelte" import SignerStatus from "@app/components/SignerStatus.svelte"
import InfoKeys from "@app/components/InfoKeys.svelte" import InfoKeys from "@app/components/InfoKeys.svelte"
import Alerts from "@app/components/Alerts.svelte"
import {PLATFORM_NAME} from "@app/core/state" import {PLATFORM_NAME} from "@app/core/state"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
import {clip} from "@app/util/toast" import {clip} from "@app/util/toast"
@@ -141,7 +140,6 @@
{/if} {/if}
<SignerStatus /> <SignerStatus />
</div> </div>
<Alerts />
<div class="card2 bg-alt shadow-xl"> <div class="card2 bg-alt shadow-xl">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<strong class="flex items-center gap-3"> <strong class="flex items-center gap-3">