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_ACCENT="#7161FF"
VITE_PLATFORM_SECONDARY="#EB5E28" VITE_PLATFORM_SECONDARY="#EB5E28"
VITE_PLATFORM_DESCRIPTION="Flotilla is nostr — for communities." 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_INDEXER_RELAYS=purplepag.es,relay.damus.io,indexer.coracle.social
VITE_DEFAULT_RELAYS=relay.damus.io,relay.primal.net,nostr.mom VITE_DEFAULT_RELAYS=relay.damus.io,relay.primal.net,nostr.mom
VITE_DEFAULT_MESSAGING_RELAYS=auth.nostr1.com VITE_DEFAULT_MESSAGING_RELAYS=auth.nostr1.com
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"name": "flotilla", "name": "flotilla",
"version": "1.6.3", "version": "1.6.3",
"private": true, "private": true,
"scripts": { "scripts": {
+1 -15
View File
@@ -32,7 +32,7 @@ import {load, request} from "@welshman/net"
import {repository, makeFeedController, loadRelay, tracker} from "@welshman/app" import {repository, makeFeedController, loadRelay, tracker} from "@welshman/app"
import {createScroller} from "@lib/html" import {createScroller} from "@lib/html"
import {daysBetween} from "@lib/util" import {daysBetween} from "@lib/util"
import {NOTIFIER_RELAY, getEventsForUrl} from "@app/core/state" import {getEventsForUrl} from "@app/core/state"
// Utils // Utils
@@ -249,20 +249,6 @@ export const makeCalendarFeed = ({
// Domain specific // 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[]) => export const discoverRelays = (lists: List[]) =>
Promise.all( Promise.all(
uniq(lists.flatMap($l => getRelaysFromList($l))) 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 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 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 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 INDEXER_RELAYS = fromCsv(import.meta.env.VITE_INDEXER_RELAYS).map(normalizeRelayUrl)
export const DEFAULT_RELAYS = fromCsv(import.meta.env.VITE_DEFAULT_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 relay_auth: RelayAuthMode
send_delay: number send_delay: number
font_size: number font_size: number
alerts_push: boolean
alerts_spaces: boolean alerts_spaces: boolean
alerts_mentions: boolean alerts_mentions: boolean
alerts_messages: boolean alerts_messages: boolean
@@ -295,9 +294,10 @@ export const defaultSettings: SettingsValues = {
relay_auth: RelayAuthMode.Conservative, relay_auth: RelayAuthMode.Conservative,
send_delay: 0, send_delay: 0,
font_size: 1.1, font_size: 1.1,
alerts_spaces: true, alerts_push: true,
alerts_mentions: true, alerts_spaces: false,
alerts_messages: true, alerts_mentions: false,
alerts_messages: false,
alerts_sound: true, alerts_sound: true,
alerts_badge: true, alerts_badge: true,
} }
+1 -4
View File
@@ -65,7 +65,6 @@ import {
getSpaceRoomsFromGroupList, getSpaceRoomsFromGroupList,
makeCommentFilter, makeCommentFilter,
} from "@app/core/state" } from "@app/core/state"
import {loadAlerts, loadAlertStatuses} from "@app/core/requests"
import {hasBlossomSupport} from "@app/core/commands" import {hasBlossomSupport} from "@app/core/commands"
// Utils // Utils
@@ -76,7 +75,7 @@ type PullOpts = {
signal: AbortSignal signal: AbortSignal
} }
const pullWithFallback = ({relays, filters, signal}: PullOpts) => { export const pullWithFallback = ({relays, filters, signal}: PullOpts) => {
const [smart, dumb] = partition(hasNegentropy, relays) const [smart, dumb] = partition(hasNegentropy, relays)
const events = repository.query(filters, {shouldSort: false}).filter(isSignedEvent) const events = repository.query(filters, {shouldSort: false}).filter(isSignedEvent)
const promises: Promise<TrustedEvent[]>[] = [pull({relays: smart, filters, signal, events})] const promises: Promise<TrustedEvent[]>[] = [pull({relays: smart, filters, signal, events})]
@@ -213,8 +212,6 @@ const syncUserData = () => {
const unsubscribeRelayList = userRelayList.subscribe($userRelayList => { const unsubscribeRelayList = userRelayList.subscribe($userRelayList => {
if ($userRelayList) { if ($userRelayList) {
loadAlerts($userRelayList.event.pubkey)
loadAlertStatuses($userRelayList.event.pubkey)
loadBlossomServerList($userRelayList.event.pubkey) loadBlossomServerList($userRelayList.event.pubkey)
loadBlockedRelayList($userRelayList.event.pubkey) loadBlockedRelayList($userRelayList.event.pubkey)
loadFollowList($userRelayList.event.pubkey) loadFollowList($userRelayList.event.pubkey)
+255 -130
View File
@@ -1,17 +1,23 @@
import type {Unsubscriber} from 'svelte/store'
import {derived, get} from "svelte/store" import {derived, get} from "svelte/store"
import {Capacitor} from "@capacitor/core"
import {Badge} from "@capawesome/capacitor-badge" import {Badge} from "@capawesome/capacitor-badge"
import {synced, throttled} from "@welshman/store" import {synced, throttled} from "@welshman/store"
import {pubkey, tracker, repository, relaysByUrl} from "@welshman/app" 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 type {TrustedEvent} from "@welshman/util"
import {deriveEventsByIdByUrl} from "@welshman/store" import {deriveEventsByIdByUrl, deriveEventsById, deriveEventsDesc, deriveDeduplicated} from "@welshman/store"
import { import {
DIRECT_MESSAGE,
DIRECT_MESSAGE_FILE,
ZAP_GOAL, ZAP_GOAL,
EVENT_TIME, EVENT_TIME,
MESSAGE, MESSAGE,
THREAD, THREAD,
COMMENT, COMMENT,
getTagValue, getTagValue,
getPubkeyTagValues,
matchFilters,
sortEventsDesc, sortEventsDesc,
} from "@welshman/util" } from "@welshman/util"
import { import {
@@ -22,11 +28,12 @@ import {
makeCalendarPath, makeCalendarPath,
makeSpaceChatPath, makeSpaceChatPath,
makeRoomPath, makeRoomPath,
goToEvent,
} from "@app/util/routes" } from "@app/util/routes"
import { import {
chatsById, chatsById,
hasNip29, hasNip29,
userSettingsValues, getSetting,
userGroupList, userGroupList,
getSpaceUrlsFromGroupList, getSpaceUrlsFromGroupList,
getSpaceRoomsFromGroupList, getSpaceRoomsFromGroupList,
@@ -47,168 +54,175 @@ export const setChecked = (key: string) => checked.update(state => ({...state, [
// Derived notifications state // Derived notifications state
export const notifications = call(() => { const goalCommentFilters = [{kinds: [COMMENT], "#K": [String(ZAP_GOAL)]}]
const goalCommentFilters = [{kinds: [COMMENT], "#K": [String(ZAP_GOAL)]}] const threadCommentFilters = [{kinds: [COMMENT], "#K": [String(THREAD)]}]
const threadCommentFilters = [{kinds: [COMMENT], "#K": [String(THREAD)]}] const calendarCommentFilters = [{kinds: [COMMENT], "#K": [String(EVENT_TIME)]}]
const calendarCommentFilters = [{kinds: [COMMENT], "#K": [String(EVENT_TIME)]}] const messageFilters = [{kinds: [MESSAGE, THREAD, ZAP_GOAL, 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(
throttled( deriveEventsDesc(deriveEventsById({repository, filters: allFilters})),
1000, first
derived( )
[
pubkey, export const notifications = derived(
checked, throttled(
chatsById, 1000,
userGroupList, derived(
relaysByUrl, [
deriveEventsByIdByUrl({tracker, repository, filters: goalCommentFilters}), pubkey,
deriveEventsByIdByUrl({tracker, repository, filters: threadCommentFilters}), checked,
deriveEventsByIdByUrl({tracker, repository, filters: calendarCommentFilters}), chatsById,
deriveEventsByIdByUrl({tracker, repository, filters: messageFilters}), userGroupList,
], relaysByUrl,
identity, deriveEventsByIdByUrl({tracker, repository, filters: goalCommentFilters}),
), deriveEventsByIdByUrl({tracker, repository, filters: threadCommentFilters}),
deriveEventsByIdByUrl({tracker, repository, filters: calendarCommentFilters}),
deriveEventsByIdByUrl({tracker, repository, filters: messageFilters}),
],
identity,
), ),
([ ),
$pubkey, ([
$checked, $pubkey,
$chatsById, $checked,
$userGroupList, $chatsById,
$relaysByUrl, $userGroupList,
goalCommentsByUrl, $relaysByUrl,
threadCommentsByUrl, goalCommentsByUrl,
calendarCommentsByUrl, threadCommentsByUrl,
messagesByUrl, calendarCommentsByUrl,
]) => { messagesByUrl,
const hasNotification = (path: string, latestEvent: TrustedEvent | undefined) => { ]) => {
if (!latestEvent || latestEvent.pubkey === $pubkey) { const hasNotification = (path: string, latestEvent: TrustedEvent | undefined) => {
if (!latestEvent || latestEvent.pubkey === $pubkey) {
return false
}
for (const [entryPath, ts] of Object.entries($checked)) {
const isMatch =
entryPath === "*" ||
entryPath.startsWith(path) ||
(entryPath === "/chat/*" && path.startsWith("/chat/"))
if (isMatch && ts > latestEvent.created_at) {
return false return false
} }
for (const [entryPath, ts] of Object.entries($checked)) {
const isMatch =
entryPath === "*" ||
entryPath.startsWith(path) ||
(entryPath === "/chat/*" && path.startsWith("/chat/"))
if (isMatch && ts > latestEvent.created_at) {
return false
}
}
return true
} }
const paths = new Set<string>() return true
}
for (const {pubkeys, messages} of $chatsById.values()) { const paths = new Set<string>()
const chatPath = makeChatPath(pubkeys)
if (hasNotification(chatPath, messages[0])) { for (const {pubkeys, messages} of $chatsById.values()) {
paths.add("/chat") const chatPath = makeChatPath(pubkeys)
paths.add(chatPath)
if (hasNotification(chatPath, messages[0])) {
paths.add("/chat")
paths.add(chatPath)
}
}
for (const url of getSpaceUrlsFromGroupList($userGroupList)) {
const spacePath = makeSpacePath(url)
const spacePathMobile = spacePath + ":mobile"
const goalPath = makeGoalPath(url)
const threadPath = makeThreadPath(url)
const calendarPath = makeCalendarPath(url)
const messagesPath = makeSpaceChatPath(url)
const goalComments = sortEventsDesc(goalCommentsByUrl.get(url)?.values() || [])
const threadComments = sortEventsDesc(threadCommentsByUrl.get(url)?.values() || [])
const calendarComments = sortEventsDesc(calendarCommentsByUrl.get(url)?.values() || [])
const messages = sortEventsDesc(messagesByUrl.get(url)?.values() || [])
const commentsByGoalId = groupBy(
e => getTagValue("E", e.tags),
goalComments.filter(spec({kind: COMMENT})),
)
for (const [goalId, [comment]] of commentsByGoalId.entries()) {
const goalItemPath = makeGoalPath(url, goalId)
if (hasNotification(goalPath, comment)) {
paths.add(spacePathMobile)
paths.add(goalPath)
}
if (hasNotification(goalItemPath, comment)) {
paths.add(goalItemPath)
} }
} }
for (const url of getSpaceUrlsFromGroupList($userGroupList)) { const commentsByThreadId = groupBy(
const spacePath = makeSpacePath(url) e => getTagValue("E", e.tags),
const spacePathMobile = spacePath + ":mobile" threadComments.filter(spec({kind: COMMENT})),
const goalPath = makeGoalPath(url) )
const threadPath = makeThreadPath(url)
const calendarPath = makeCalendarPath(url)
const messagesPath = makeSpaceChatPath(url)
const goalComments = sortEventsDesc(goalCommentsByUrl.get(url)?.values() || [])
const threadComments = sortEventsDesc(threadCommentsByUrl.get(url)?.values() || [])
const calendarComments = sortEventsDesc(calendarCommentsByUrl.get(url)?.values() || [])
const messages = sortEventsDesc(messagesByUrl.get(url)?.values() || [])
const commentsByGoalId = groupBy( for (const [threadId, [comment]] of commentsByThreadId.entries()) {
e => getTagValue("E", e.tags), const threadItemPath = makeThreadPath(url, threadId)
goalComments.filter(spec({kind: COMMENT})),
)
for (const [goalId, [comment]] of commentsByGoalId.entries()) { if (hasNotification(threadPath, comment)) {
const goalItemPath = makeGoalPath(url, goalId) paths.add(spacePathMobile)
paths.add(threadPath)
if (hasNotification(goalPath, comment)) {
paths.add(spacePathMobile)
paths.add(goalPath)
}
if (hasNotification(goalItemPath, comment)) {
paths.add(goalItemPath)
}
} }
const commentsByThreadId = groupBy( if (hasNotification(threadItemPath, comment)) {
e => getTagValue("E", e.tags), paths.add(threadItemPath)
threadComments.filter(spec({kind: COMMENT})), }
) }
for (const [threadId, [comment]] of commentsByThreadId.entries()) { const commentsByEventId = groupBy(
const threadItemPath = makeThreadPath(url, threadId) e => getTagValue("E", e.tags),
calendarComments.filter(spec({kind: COMMENT})),
)
if (hasNotification(threadPath, comment)) { for (const [eventId, [comment]] of commentsByEventId.entries()) {
paths.add(spacePathMobile) const calendarItemPath = makeCalendarPath(url, eventId)
paths.add(threadPath)
}
if (hasNotification(threadItemPath, comment)) { if (hasNotification(calendarPath, comment)) {
paths.add(threadItemPath) paths.add(spacePathMobile)
} paths.add(calendarPath)
} }
const commentsByEventId = groupBy( if (hasNotification(calendarItemPath, comment)) {
e => getTagValue("E", e.tags), paths.add(calendarItemPath)
calendarComments.filter(spec({kind: COMMENT})),
)
for (const [eventId, [comment]] of commentsByEventId.entries()) {
const calendarItemPath = makeCalendarPath(url, eventId)
if (hasNotification(calendarPath, comment)) {
paths.add(spacePathMobile)
paths.add(calendarPath)
}
if (hasNotification(calendarItemPath, comment)) {
paths.add(calendarItemPath)
}
} }
}
if (hasNip29($relaysByUrl.get(url))) { if (hasNip29($relaysByUrl.get(url))) {
for (const h of getSpaceRoomsFromGroupList(url, $userGroupList)) { for (const h of getSpaceRoomsFromGroupList(url, $userGroupList)) {
const roomPath = makeRoomPath(url, h) const roomPath = makeRoomPath(url, h)
const latestEvent = find(e => e.tags.some(spec(["h", h])), messages) const latestEvent = find(e => e.tags.some(spec(["h", h])), messages)
if (hasNotification(roomPath, latestEvent)) { if (hasNotification(roomPath, latestEvent)) {
paths.add(spacePathMobile)
paths.add(spacePath)
paths.add(roomPath)
}
}
} else {
if (hasNotification(messagesPath, first(messages))) {
paths.add(spacePathMobile) paths.add(spacePathMobile)
paths.add(spacePath) paths.add(spacePath)
paths.add(messagesPath) paths.add(roomPath)
} }
} }
} else {
if (hasNotification(messagesPath, first(messages))) {
paths.add(spacePathMobile)
paths.add(spacePath)
paths.add(messagesPath)
}
} }
}
return paths return paths
}, },
) )
})
// Badges
export const badgeCount = derived(notifications, notifications => { export const badgeCount = derived(notifications, notifications => {
return notifications.size return notifications.size
}) })
export const handleBadgeCountChanges = async (count: number) => { export const handleBadgeCountChanges = async (count: number) => {
if (get(userSettingsValues).alerts_badge) { if (getSetting<boolean>('alerts_badge')) {
try { try {
await Badge.set({count}) await Badge.set({count})
} catch (err) { } catch (err) {
@@ -222,3 +236,114 @@ export const handleBadgeCountChanges = async (count: number) => {
export const clearBadges = async () => { export const clearBadges = async () => {
await Badge.clear() 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" } from "@welshman/net"
import {sign, pubkey, getPubkeyRelays} from "@welshman/app" import {sign, pubkey, getPubkeyRelays} from "@welshman/app"
import { import {
BLOCKED_RELAYS,
userSettingsValues, userSettingsValues,
getSetting, getSetting,
relaysPendingTrust, relaysPendingTrust,
relaysMostlyRestricted, relaysMostlyRestricted,
RelayAuthMode, RelayAuthMode,
NOTIFIER_RELAY,
userSpaceUrls, userSpaceUrls,
} from "@app/core/state" } from "@app/core/state"
@@ -33,7 +33,6 @@ export const authPolicy = makeSocketPolicyAuth({
const mode = getSetting<RelayAuthMode>("relay_auth") const mode = getSetting<RelayAuthMode>("relay_auth")
if (!$pubkey) return false if (!$pubkey) return false
if (socket.url === NOTIFIER_RELAY) return true
if (mode === RelayAuthMode.Aggressive) return true if (mode === RelayAuthMode.Aggressive) return true
if (get(userSpaceUrls).includes(socket.url)) return true if (get(userSpaceUrls).includes(socket.url)) return true
if (getPubkeyRelays($pubkey).includes(socket.url)) return true if (getPubkeyRelays($pubkey).includes(socket.url)) return true
@@ -49,9 +48,10 @@ export const blockPolicy = (socket: Socket) => {
socket.open = () => { socket.open = () => {
const $pubkey = pubkey.get() const $pubkey = pubkey.get()
if (!$pubkey || !getPubkeyRelays($pubkey, RelayMode.Blocked).includes(socket.url)) { if (BLOCKED_RELAYS.includes(socket.url)) return
return previousOpen() if ($pubkey && getPubkeyRelays($pubkey, RelayMode.Blocked).includes(socket.url)) return
}
previousOpen()
} }
return () => { return () => {
+3 -7
View File
@@ -63,13 +63,6 @@
...notifications, ...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 // Listen for deep link events
App.addListener("appUrlOpen", (event: URLOpenListenerEvent) => { App.addListener("appUrlOpen", (event: URLOpenListenerEvent) => {
const url = new URL(event.url) const url = new URL(event.url)
@@ -135,6 +128,9 @@
// Initialize keyboard state tracking // Initialize keyboard state tracking
unsubscribers.push(syncKeyboard()) unsubscribers.push(syncKeyboard())
// Initialize background notifications
unsubscribers.push(notifications.Alerts.resume())
// Listen for signer errors, report to user via toast // Listen for signer errors, report to user via toast
unsubscribers.push( unsubscribers.push(
throttled(10_000, signerLog).subscribe($log => { throttled(10_000, signerLog).subscribe($log => {
+76 -34
View File
@@ -1,11 +1,13 @@
<script lang="ts"> <script lang="ts">
import cx from "classnames"
import {sleep} from '@welshman/lib'
import Inbox from "@assets/icons/inbox.svg?dataurl" import Inbox from "@assets/icons/inbox.svg?dataurl"
import Bell from "@assets/icons/bell.svg?dataurl" import Bell from "@assets/icons/bell.svg?dataurl"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
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 {pushToast} from "@app/util/toast" 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 {userSettingsValues} from "@app/core/state"
import {publishSettings} from "@app/core/commands" import {publishSettings} from "@app/core/commands"
@@ -13,19 +15,46 @@
settings = {...$userSettingsValues} 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 () => { const onsubmit = preventDefault(async () => {
await publishSettings($state.snapshot(settings)) await publishSettings($state.snapshot(settings))
if (settings.alerts_push) {
await Alerts.start()
} else {
await Alerts.stop()
}
pushToast({message: "Your settings have been saved!"}) pushToast({message: "Your settings have been saved!"})
}) })
let settings = $state({...$userSettingsValues}) let settings = $state({...$userSettingsValues})
$effect(() => {
if (!$userSettingsValues.alerts_badge) {
clearBadges()
}
})
</script> </script>
<form class="content column gap-4" {onsubmit}> <form class="content column gap-4" {onsubmit}>
@@ -33,39 +62,52 @@
<strong class="text-lg">Alert Settings</strong> <strong class="text-lg">Alert Settings</strong>
<div class="flex justify-between"> <div class="flex justify-between">
<p>Show badge for unread alerts</p> <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>
<div class="flex justify-between"> <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} /> <input type="checkbox" class="toggle toggle-primary" bind:checked={settings.alerts_sound} />
</div> </div>
<div class="flex items-center justify-between">
<strong class="flex items-center gap-3">
<Icon icon={Inbox} />
Space Activity
</strong>
</div>
<div class="flex justify-between"> <div class="flex justify-between">
<p>Notify me about new messages</p> <p>Enable push notifications</p>
<input type="checkbox" class="toggle toggle-primary" bind:checked={settings.alerts_spaces} /> <input type="checkbox" class="toggle toggle-primary" bind:checked={settings.alerts_push} onchange={onAlertsPushChange} />
</div> </div>
<div class="flex justify-between"> <div
<p>Always notify me when mentioned</p> class={cx("card2 bg-alt col-4 shadow-md", {
<input type="checkbox" class="toggle toggle-primary" checked={settings.alerts_mentions} /> "pointer-events-none opacity-50": !settings.alerts_push,
</div> })}>
<!-- todo: add list of muted spaces --> <strong class="text-lg">Push Notifications</strong>
<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">
<Icon icon={Bell} /> <Icon icon={Inbox} />
Direct Messages Space Activity
</strong> </strong>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<p>Notify me about new messages</p> <p>Notify me about new activity</p>
<input <input
type="checkbox" type="checkbox"
class="toggle toggle-primary" class="toggle toggle-primary"
bind:checked={settings.alerts_messages} /> bind:checked={settings.alerts_spaces} />
</div>
<div class="flex justify-between">
<p>Always notify me when mentioned</p>
<input type="checkbox" class="toggle toggle-primary" checked={settings.alerts_mentions} />
</div>
<!-- todo: add list of muted spaces -->
<div class="flex items-center justify-between">
<strong class="flex items-center gap-3">
<Icon icon={Bell} />
Direct Messages
</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_messages} />
</div>
</div> </div>
<div class="mt-4 flex flex-row items-center justify-between gap-4"> <div class="mt-4 flex flex-row items-center justify-between gap-4">
<Button class="btn btn-neutral" onclick={reset}>Discard Changes</Button> <Button class="btn btn-neutral" onclick={reset}>Discard Changes</Button>