Add new alerts

This commit is contained in:
Jon Staab
2026-01-20 10:40:33 -08:00
parent f85748fef9
commit 6d1eeacc49
9 changed files with 350 additions and 203 deletions
+1
View File
@@ -10,6 +10,7 @@ VITE_PLATFORM_RELAYS=
VITE_PLATFORM_ACCENT="#7161FF"
VITE_PLATFORM_SECONDARY="#EB5E28"
VITE_PLATFORM_DESCRIPTION="Flotilla is nostr — for communities."
VITE_BLOCKED_RELAYS=brb.io,relay.nostr.band,nostr.mutinywallet.com,feeds.nostr.band,nostr.zbd.gg,wot.utxo.one
VITE_INDEXER_RELAYS=purplepag.es,relay.damus.io,indexer.coracle.social
VITE_DEFAULT_RELAYS=relay.damus.io,relay.primal.net,nostr.mom
VITE_DEFAULT_MESSAGING_RELAYS=auth.nostr1.com
+1 -15
View File
@@ -32,7 +32,7 @@ import {load, request} from "@welshman/net"
import {repository, makeFeedController, loadRelay, tracker} from "@welshman/app"
import {createScroller} from "@lib/html"
import {daysBetween} from "@lib/util"
import {NOTIFIER_RELAY, getEventsForUrl} from "@app/core/state"
import {getEventsForUrl} from "@app/core/state"
// Utils
@@ -249,20 +249,6 @@ export const makeCalendarFeed = ({
// Domain specific
export const loadAlerts = (pubkey: string) =>
request({
autoClose: true,
relays: [NOTIFIER_RELAY],
filters: [{kinds: [ALERT_EMAIL, ALERT_WEB, ALERT_IOS, ALERT_ANDROID], authors: [pubkey]}],
})
export const loadAlertStatuses = (pubkey: string) =>
request({
autoClose: true,
relays: [NOTIFIER_RELAY],
filters: [{kinds: [ALERT_STATUS], "#p": [pubkey]}],
})
export const discoverRelays = (lists: List[]) =>
Promise.all(
uniq(lists.flatMap($l => getRelaysFromList($l)))
+7 -7
View File
@@ -123,14 +123,12 @@ export const PROTECTED = ["-"]
export const ENABLE_ZAPS = Capacitor.getPlatform() != "ios"
export const NOTIFIER_PUBKEY = import.meta.env.VITE_NOTIFIER_PUBKEY
export const VAPID_PUBLIC_KEY = import.meta.env.VITE_VAPID_PUBLIC_KEY
export const NOTIFIER_RELAY = normalizeRelayUrl(import.meta.env.VITE_NOTIFIER_RELAY)
export const SIGNER_RELAYS = fromCsv(import.meta.env.VITE_SIGNER_RELAYS).map(normalizeRelayUrl)
export const BLOCKED_RELAYS = fromCsv(import.meta.env.VITE_BLOCKED_RELAYS).map(normalizeRelayUrl)
export const INDEXER_RELAYS = fromCsv(import.meta.env.VITE_INDEXER_RELAYS).map(normalizeRelayUrl)
export const DEFAULT_RELAYS = fromCsv(import.meta.env.VITE_DEFAULT_RELAYS).map(normalizeRelayUrl)
@@ -274,6 +272,7 @@ export type SettingsValues = {
relay_auth: RelayAuthMode
send_delay: number
font_size: number
alerts_push: boolean
alerts_spaces: boolean
alerts_mentions: boolean
alerts_messages: boolean
@@ -295,9 +294,10 @@ export const defaultSettings: SettingsValues = {
relay_auth: RelayAuthMode.Conservative,
send_delay: 0,
font_size: 1.1,
alerts_spaces: true,
alerts_mentions: true,
alerts_messages: true,
alerts_push: true,
alerts_spaces: false,
alerts_mentions: false,
alerts_messages: false,
alerts_sound: true,
alerts_badge: true,
}
+1 -4
View File
@@ -65,7 +65,6 @@ import {
getSpaceRoomsFromGroupList,
makeCommentFilter,
} from "@app/core/state"
import {loadAlerts, loadAlertStatuses} from "@app/core/requests"
import {hasBlossomSupport} from "@app/core/commands"
// Utils
@@ -76,7 +75,7 @@ type PullOpts = {
signal: AbortSignal
}
const pullWithFallback = ({relays, filters, signal}: PullOpts) => {
export const pullWithFallback = ({relays, filters, signal}: PullOpts) => {
const [smart, dumb] = partition(hasNegentropy, relays)
const events = repository.query(filters, {shouldSort: false}).filter(isSignedEvent)
const promises: Promise<TrustedEvent[]>[] = [pull({relays: smart, filters, signal, events})]
@@ -213,8 +212,6 @@ const syncUserData = () => {
const unsubscribeRelayList = userRelayList.subscribe($userRelayList => {
if ($userRelayList) {
loadAlerts($userRelayList.event.pubkey)
loadAlertStatuses($userRelayList.event.pubkey)
loadBlossomServerList($userRelayList.event.pubkey)
loadBlockedRelayList($userRelayList.event.pubkey)
loadFollowList($userRelayList.event.pubkey)
+137 -12
View File
@@ -1,17 +1,23 @@
import type {Unsubscriber} from 'svelte/store'
import {derived, get} from "svelte/store"
import {Capacitor} from "@capacitor/core"
import {Badge} from "@capawesome/capacitor-badge"
import {synced, throttled} from "@welshman/store"
import {pubkey, tracker, repository, relaysByUrl} from "@welshman/app"
import {prop, find, call, spec, first, identity, now, groupBy} from "@welshman/lib"
import {prop, ms, maybe, int, MINUTE, flatten, find, spec, first, identity, now, groupBy, hash} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {deriveEventsByIdByUrl} from "@welshman/store"
import {deriveEventsByIdByUrl, deriveEventsById, deriveEventsDesc, deriveDeduplicated} from "@welshman/store"
import {
DIRECT_MESSAGE,
DIRECT_MESSAGE_FILE,
ZAP_GOAL,
EVENT_TIME,
MESSAGE,
THREAD,
COMMENT,
getTagValue,
getPubkeyTagValues,
matchFilters,
sortEventsDesc,
} from "@welshman/util"
import {
@@ -22,11 +28,12 @@ import {
makeCalendarPath,
makeSpaceChatPath,
makeRoomPath,
goToEvent,
} from "@app/util/routes"
import {
chatsById,
hasNip29,
userSettingsValues,
getSetting,
userGroupList,
getSpaceUrlsFromGroupList,
getSpaceRoomsFromGroupList,
@@ -47,13 +54,19 @@ export const setChecked = (key: string) => checked.update(state => ({...state, [
// Derived notifications state
export const notifications = call(() => {
const goalCommentFilters = [{kinds: [COMMENT], "#K": [String(ZAP_GOAL)]}]
const threadCommentFilters = [{kinds: [COMMENT], "#K": [String(THREAD)]}]
const calendarCommentFilters = [{kinds: [COMMENT], "#K": [String(EVENT_TIME)]}]
const messageFilters = [{kinds: [MESSAGE, THREAD, ZAP_GOAL, EVENT_TIME]}]
const goalCommentFilters = [{kinds: [COMMENT], "#K": [String(ZAP_GOAL)]}]
const threadCommentFilters = [{kinds: [COMMENT], "#K": [String(THREAD)]}]
const calendarCommentFilters = [{kinds: [COMMENT], "#K": [String(EVENT_TIME)]}]
const messageFilters = [{kinds: [MESSAGE, THREAD, ZAP_GOAL, EVENT_TIME]}]
const dmFilters = [{kinds: [DIRECT_MESSAGE, DIRECT_MESSAGE_FILE]}]
const allFilters = flatten([goalCommentFilters, threadCommentFilters, calendarCommentFilters, messageFilters, dmFilters])
return derived(
export const latestNotification = deriveDeduplicated(
deriveEventsDesc(deriveEventsById({repository, filters: allFilters})),
first
)
export const notifications = derived(
throttled(
1000,
derived(
@@ -200,15 +213,16 @@ export const notifications = call(() => {
return paths
},
)
})
)
// Badges
export const badgeCount = derived(notifications, notifications => {
return notifications.size
})
export const handleBadgeCountChanges = async (count: number) => {
if (get(userSettingsValues).alerts_badge) {
if (getSetting<boolean>('alerts_badge')) {
try {
await Badge.set({count})
} catch (err) {
@@ -222,3 +236,114 @@ export const handleBadgeCountChanges = async (count: number) => {
export const clearBadges = async () => {
await Badge.clear()
}
// Local notifications
interface IAlertsAdapter {
request: () => Promise<string>
start: () => Unsubscriber
}
class CapacitorNotifications implements IAlertsAdapter {
async request() {
return "denied"
}
start() {
return () => undefined
}
}
class WebNotifications implements IAlertsAdapter {
async request() {
if (Notification?.permission === "default") {
await Notification.requestPermission()
}
return Notification?.permission || "denied"
}
notify(event: TrustedEvent, title: string, body: string) {
const notification = new Notification(title, {body, tag: event.id, icon: "/icon.png", badge: "/icon.png"})
notification.onclick = () => {
window.focus()
goToEvent(event)
notification.close()
}
const onVisibilityChange = () => {
if (document.visibilityState === "visible") {
notification.close()
document.removeEventListener("visibilitychange", onVisibilityChange)
}
}
document.addEventListener("visibilitychange", onVisibilityChange)
}
start() {
let initialized = false
return latestNotification.subscribe(event => {
if (!initialized) {
initialized = true
} else if (event && document.hidden && Notification?.permission === "granted") {
if (getSetting<boolean>('alerts_messages') && matchFilters(dmFilters, event)) {
this.notify(event, "New direct message", "Someone sent you a direct message.")
} else if (getSetting<boolean>('alerts_mentions') && event.pubkey !== pubkey.get() && getPubkeyTagValues(event.tags).includes(pubkey.get())) {
this.notify(event, "Someone mentioned you", "Someone tagged you in a message.")
} else if (getSetting<boolean>('alerts_spaces')) {
this.notify(event, "New activity", "Someone posted a new message.")
}
}
})
}
}
export class Alerts {
static _adapter: IAlertsAdapter
static _unsubscriber: Unsubscriber
static _getAdapter() {
if (!Alerts._adapter) {
if (Capacitor.isNativePlatform()) {
Alerts._adapter = new CapacitorNotifications()
} else {
Alerts._adapter = new WebNotifications()
}
}
return Alerts._adapter
}
static start() {
return Alerts._getAdapter().start()
}
static request() {
return Alerts._getAdapter().request()
}
static resume() {
if (getSetting<boolean>('alerts_push')) {
const promise = Alerts.request()
const controller = new AbortController()
promise.then(permissions => {
if (permissions === "granted" && !controller.signal.aborted) {
controller.signal.addEventListener('abort', Alerts.start())
}
})
Alerts._unsubscriber = () => controller.abort()
}
return Alerts.stop
}
static stop() {
Alerts._unsubscriber?.()
Alerts._unsubscriber = undefined
}
}
+5 -5
View File
@@ -17,12 +17,12 @@ import {
} from "@welshman/net"
import {sign, pubkey, getPubkeyRelays} from "@welshman/app"
import {
BLOCKED_RELAYS,
userSettingsValues,
getSetting,
relaysPendingTrust,
relaysMostlyRestricted,
RelayAuthMode,
NOTIFIER_RELAY,
userSpaceUrls,
} from "@app/core/state"
@@ -33,7 +33,6 @@ export const authPolicy = makeSocketPolicyAuth({
const mode = getSetting<RelayAuthMode>("relay_auth")
if (!$pubkey) return false
if (socket.url === NOTIFIER_RELAY) return true
if (mode === RelayAuthMode.Aggressive) return true
if (get(userSpaceUrls).includes(socket.url)) return true
if (getPubkeyRelays($pubkey).includes(socket.url)) return true
@@ -49,9 +48,10 @@ export const blockPolicy = (socket: Socket) => {
socket.open = () => {
const $pubkey = pubkey.get()
if (!$pubkey || !getPubkeyRelays($pubkey, RelayMode.Blocked).includes(socket.url)) {
return previousOpen()
}
if (BLOCKED_RELAYS.includes(socket.url)) return
if ($pubkey && getPubkeyRelays($pubkey, RelayMode.Blocked).includes(socket.url)) return
previousOpen()
}
return () => {
+3 -7
View File
@@ -63,13 +63,6 @@
...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)
@@ -135,6 +128,9 @@
// Initialize keyboard state tracking
unsubscribers.push(syncKeyboard())
// Initialize background notifications
unsubscribers.push(notifications.Alerts.resume())
// Listen for signer errors, report to user via toast
unsubscribers.push(
throttled(10_000, signerLog).subscribe($log => {
+53 -11
View File
@@ -1,11 +1,13 @@
<script lang="ts">
import cx from "classnames"
import {sleep} from '@welshman/lib'
import Inbox from "@assets/icons/inbox.svg?dataurl"
import Bell from "@assets/icons/bell.svg?dataurl"
import {preventDefault} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import {pushToast} from "@app/util/toast"
import {clearBadges} from "@app/util/notifications"
import {Alerts, clearBadges} from "@app/util/notifications"
import {userSettingsValues} from "@app/core/state"
import {publishSettings} from "@app/core/commands"
@@ -13,19 +15,46 @@
settings = {...$userSettingsValues}
}
const onAlertsBadgeChange = () => {
if (!settings.alerts_badge) {
clearBadges()
}
}
const onAlertsPushChange = () => {
settings.alerts_spaces = settings.alerts_push
settings.alerts_mentions = settings.alerts_push
settings.alerts_messages = settings.alerts_push
if (settings.alerts_push) {
Alerts.request().then(permissions => {
if (permissions !== "granted") {
sleep(300).then(() => {
settings.alerts_push = false
pushToast({
theme: 'error',
message: "Failed to request notification permissions.",
})
})
}
})
}
}
const onsubmit = preventDefault(async () => {
await publishSettings($state.snapshot(settings))
if (settings.alerts_push) {
await Alerts.start()
} else {
await Alerts.stop()
}
pushToast({message: "Your settings have been saved!"})
})
let settings = $state({...$userSettingsValues})
$effect(() => {
if (!$userSettingsValues.alerts_badge) {
clearBadges()
}
})
</script>
<form class="content column gap-4" {onsubmit}>
@@ -33,12 +62,21 @@
<strong class="text-lg">Alert Settings</strong>
<div class="flex justify-between">
<p>Show badge for unread alerts</p>
<input type="checkbox" class="toggle toggle-primary" bind:checked={settings.alerts_badge} />
<input type="checkbox" class="toggle toggle-primary" bind:checked={settings.alerts_badge} onchange={onAlertsBadgeChange} />
</div>
<div class="flex justify-between">
<p>Play sound for new messages</p>
<p>Play sound for new activity</p>
<input type="checkbox" class="toggle toggle-primary" bind:checked={settings.alerts_sound} />
</div>
<div class="flex justify-between">
<p>Enable push notifications</p>
<input type="checkbox" class="toggle toggle-primary" bind:checked={settings.alerts_push} onchange={onAlertsPushChange} />
</div>
<div
class={cx("card2 bg-alt col-4 shadow-md", {
"pointer-events-none opacity-50": !settings.alerts_push,
})}>
<strong class="text-lg">Push Notifications</strong>
<div class="flex items-center justify-between">
<strong class="flex items-center gap-3">
<Icon icon={Inbox} />
@@ -46,8 +84,11 @@
</strong>
</div>
<div class="flex justify-between">
<p>Notify me about new messages</p>
<input type="checkbox" class="toggle toggle-primary" bind:checked={settings.alerts_spaces} />
<p>Notify me about new activity</p>
<input
type="checkbox"
class="toggle toggle-primary"
bind:checked={settings.alerts_spaces} />
</div>
<div class="flex justify-between">
<p>Always notify me when mentioned</p>
@@ -67,6 +108,7 @@
class="toggle toggle-primary"
bind:checked={settings.alerts_messages} />
</div>
</div>
<div class="mt-4 flex flex-row items-center justify-between gap-4">
<Button class="btn btn-neutral" onclick={reset}>Discard Changes</Button>
<Button type="submit" class="btn btn-primary">Save Changes</Button>