diff --git a/.env.template b/.env.template index 4e2b334a..b1a7a2e5 100644 --- a/.env.template +++ b/.env.template @@ -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 diff --git a/package.json b/package.json index 4f04cf6c..1fe6acf8 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "flotilla", + "name": "flotilla", "version": "1.6.3", "private": true, "scripts": { diff --git a/src/app/core/requests.ts b/src/app/core/requests.ts index e90929a5..b32d9c5a 100644 --- a/src/app/core/requests.ts +++ b/src/app/core/requests.ts @@ -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))) diff --git a/src/app/core/state.ts b/src/app/core/state.ts index df7d9ed7..e1a2aca5 100644 --- a/src/app/core/state.ts +++ b/src/app/core/state.ts @@ -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, } diff --git a/src/app/core/sync.ts b/src/app/core/sync.ts index e0beeef3..7e6435d1 100644 --- a/src/app/core/sync.ts +++ b/src/app/core/sync.ts @@ -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[] = [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) diff --git a/src/app/util/notifications.ts b/src/app/util/notifications.ts index 90d8196d..c22aa502 100644 --- a/src/app/util/notifications.ts +++ b/src/app/util/notifications.ts @@ -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,168 +54,175 @@ 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( - throttled( - 1000, - derived( - [ - pubkey, - checked, - chatsById, - userGroupList, - relaysByUrl, - deriveEventsByIdByUrl({tracker, repository, filters: goalCommentFilters}), - deriveEventsByIdByUrl({tracker, repository, filters: threadCommentFilters}), - deriveEventsByIdByUrl({tracker, repository, filters: calendarCommentFilters}), - deriveEventsByIdByUrl({tracker, repository, filters: messageFilters}), - ], - identity, - ), +export const latestNotification = deriveDeduplicated( + deriveEventsDesc(deriveEventsById({repository, filters: allFilters})), + first +) + +export const notifications = derived( + throttled( + 1000, + derived( + [ + pubkey, + checked, + chatsById, + userGroupList, + relaysByUrl, + deriveEventsByIdByUrl({tracker, repository, filters: goalCommentFilters}), + deriveEventsByIdByUrl({tracker, repository, filters: threadCommentFilters}), + deriveEventsByIdByUrl({tracker, repository, filters: calendarCommentFilters}), + deriveEventsByIdByUrl({tracker, repository, filters: messageFilters}), + ], + identity, ), - ([ - $pubkey, - $checked, - $chatsById, - $userGroupList, - $relaysByUrl, - goalCommentsByUrl, - threadCommentsByUrl, - calendarCommentsByUrl, - messagesByUrl, - ]) => { - const hasNotification = (path: string, latestEvent: TrustedEvent | undefined) => { - if (!latestEvent || latestEvent.pubkey === $pubkey) { + ), + ([ + $pubkey, + $checked, + $chatsById, + $userGroupList, + $relaysByUrl, + goalCommentsByUrl, + threadCommentsByUrl, + calendarCommentsByUrl, + messagesByUrl, + ]) => { + 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 } - - 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() + return true + } - for (const {pubkeys, messages} of $chatsById.values()) { - const chatPath = makeChatPath(pubkeys) + const paths = new Set() - if (hasNotification(chatPath, messages[0])) { - paths.add("/chat") - paths.add(chatPath) + for (const {pubkeys, messages} of $chatsById.values()) { + const chatPath = makeChatPath(pubkeys) + + 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 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 commentsByThreadId = groupBy( + e => getTagValue("E", e.tags), + threadComments.filter(spec({kind: COMMENT})), + ) - const commentsByGoalId = groupBy( - e => getTagValue("E", e.tags), - goalComments.filter(spec({kind: COMMENT})), - ) + for (const [threadId, [comment]] of commentsByThreadId.entries()) { + const threadItemPath = makeThreadPath(url, threadId) - 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) - } + if (hasNotification(threadPath, comment)) { + paths.add(spacePathMobile) + paths.add(threadPath) } - const commentsByThreadId = groupBy( - e => getTagValue("E", e.tags), - threadComments.filter(spec({kind: COMMENT})), - ) + if (hasNotification(threadItemPath, comment)) { + paths.add(threadItemPath) + } + } - for (const [threadId, [comment]] of commentsByThreadId.entries()) { - const threadItemPath = makeThreadPath(url, threadId) + const commentsByEventId = groupBy( + e => getTagValue("E", e.tags), + calendarComments.filter(spec({kind: COMMENT})), + ) - if (hasNotification(threadPath, comment)) { - paths.add(spacePathMobile) - paths.add(threadPath) - } + for (const [eventId, [comment]] of commentsByEventId.entries()) { + const calendarItemPath = makeCalendarPath(url, eventId) - if (hasNotification(threadItemPath, comment)) { - paths.add(threadItemPath) - } + if (hasNotification(calendarPath, comment)) { + paths.add(spacePathMobile) + paths.add(calendarPath) } - const commentsByEventId = groupBy( - e => getTagValue("E", e.tags), - 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 (hasNotification(calendarItemPath, comment)) { + paths.add(calendarItemPath) } + } - if (hasNip29($relaysByUrl.get(url))) { - for (const h of getSpaceRoomsFromGroupList(url, $userGroupList)) { - const roomPath = makeRoomPath(url, h) - const latestEvent = find(e => e.tags.some(spec(["h", h])), messages) + if (hasNip29($relaysByUrl.get(url))) { + for (const h of getSpaceRoomsFromGroupList(url, $userGroupList)) { + const roomPath = makeRoomPath(url, h) + const latestEvent = find(e => e.tags.some(spec(["h", h])), messages) - if (hasNotification(roomPath, latestEvent)) { - paths.add(spacePathMobile) - paths.add(spacePath) - paths.add(roomPath) - } - } - } else { - if (hasNotification(messagesPath, first(messages))) { + if (hasNotification(roomPath, latestEvent)) { paths.add(spacePathMobile) 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 => { return notifications.size }) export const handleBadgeCountChanges = async (count: number) => { - if (get(userSettingsValues).alerts_badge) { + if (getSetting('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 + 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('alerts_messages') && matchFilters(dmFilters, event)) { + this.notify(event, "New direct message", "Someone sent you a direct message.") + } else if (getSetting('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('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('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 + } +} diff --git a/src/app/util/policies.ts b/src/app/util/policies.ts index 5328b757..0feb450a 100644 --- a/src/app/util/policies.ts +++ b/src/app/util/policies.ts @@ -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("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 () => { diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index fec86292..384f5992 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -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 => { diff --git a/src/routes/settings/alerts/+page.svelte b/src/routes/settings/alerts/+page.svelte index 540e5472..24932a84 100644 --- a/src/routes/settings/alerts/+page.svelte +++ b/src/routes/settings/alerts/+page.svelte @@ -1,11 +1,13 @@
@@ -33,39 +62,52 @@ Alert Settings

Show badge for unread alerts

- +
-

Play sound for new messages

+

Play sound for new activity

-
- - - Space Activity - -
-

Notify me about new messages

- +

Enable push notifications

+
-
-

Always notify me when mentioned

- -
- -
- - - Direct Messages - -
-
-

Notify me about new messages

- +
+ Push Notifications +
+ + + Space Activity + +
+
+

Notify me about new activity

+ +
+
+

Always notify me when mentioned

+ +
+ +
+ + + Direct Messages + +
+
+

Notify me about new messages

+ +