forked from coracle/flotilla
Add fcm push notifications
This commit is contained in:
+11
-6
@@ -1,6 +1,5 @@
|
||||
import * as nip19 from "nostr-tools/nip19"
|
||||
import {get} from "svelte/store"
|
||||
import {Capacitor} from "@capacitor/core"
|
||||
import {randomId, flatten, poll, uniq, equals, TIMEZONE, LOCALE} from "@welshman/lib"
|
||||
import type {Feed} from "@welshman/feeds"
|
||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
ALERT_ANDROID,
|
||||
isSignedEvent,
|
||||
makeEvent,
|
||||
getAddress,
|
||||
displayProfile,
|
||||
normalizeRelayUrl,
|
||||
makeList,
|
||||
@@ -62,8 +62,8 @@ import {
|
||||
NOTIFIER_PUBKEY,
|
||||
NOTIFIER_RELAY,
|
||||
userRoomsByUrl,
|
||||
deviceAlertAddresses,
|
||||
} from "@app/state"
|
||||
import {getPushInfo} from "@app/push"
|
||||
|
||||
// Utils
|
||||
|
||||
@@ -388,10 +388,10 @@ export type AlertParams = {
|
||||
auth: string
|
||||
}
|
||||
ios?: {
|
||||
nothing: string
|
||||
device_token: string
|
||||
}
|
||||
android?: {
|
||||
nothing: string
|
||||
device_token: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -433,5 +433,10 @@ export const makeAlert = async (params: AlertParams) => {
|
||||
})
|
||||
}
|
||||
|
||||
export const publishAlert = async (params: AlertParams) =>
|
||||
publishThunk({event: await makeAlert(params), relays: [NOTIFIER_RELAY]})
|
||||
export const publishAlert = async (params: AlertParams) => {
|
||||
const event = await signer.get().sign(await makeAlert(params))
|
||||
|
||||
deviceAlertAddresses.update($addresses => [...$addresses, getAddress(event)])
|
||||
|
||||
return publishThunk({event, relays: [NOTIFIER_RELAY]})
|
||||
}
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import {preventDefault} from "@lib/html"
|
||||
import {randomInt, displayList, TIMEZONE, identity} from "@welshman/lib"
|
||||
import {displayRelayUrl, getTagValue, THREAD, MESSAGE, EVENT_TIME, COMMENT} from "@welshman/util"
|
||||
import {ucFirst} from "@lib/util"
|
||||
import {decrypt} from "@welshman/signer"
|
||||
import {randomInt, parseJson, fromPairs, displayList, TIMEZONE, identity} from "@welshman/lib"
|
||||
import {
|
||||
displayRelayUrl,
|
||||
getTagValue,
|
||||
getAddress,
|
||||
THREAD,
|
||||
MESSAGE,
|
||||
EVENT_TIME,
|
||||
COMMENT,
|
||||
} from "@welshman/util"
|
||||
import type {Filter} from "@welshman/util"
|
||||
import {makeIntersectionFeed, makeRelayFeed, feedFromFilters} from "@welshman/feeds"
|
||||
import {pubkey} from "@welshman/app"
|
||||
import {pubkey, signer, getThunkError} from "@welshman/app"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import FieldInline from "@lib/components/FieldInline.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import {alerts, getMembershipUrls, getMembershipRoomsByUrl, userMembership} from "@app/state"
|
||||
import {alerts, getMembershipUrls, userMembership, NOTIFIER_PUBKEY} from "@app/state"
|
||||
import {loadAlertStatuses, requestRelayClaims} from "@app/requests"
|
||||
import {publishAlert} from "@app/commands"
|
||||
import type {AlertParams} from "@app/commands"
|
||||
@@ -114,20 +124,41 @@
|
||||
try {
|
||||
// @ts-ignore
|
||||
params[platform] = await getPushInfo()
|
||||
params.description = `${ucFirst(platform)} push notification ${description}.`
|
||||
} catch (e: any) {
|
||||
return pushToast({
|
||||
theme: "error",
|
||||
message: String(e),
|
||||
})
|
||||
}
|
||||
|
||||
params.description = `Push notification alert ${description}.`
|
||||
}
|
||||
|
||||
const thunk = await publishAlert(params)
|
||||
// If we don't do this we'll get an event rejection
|
||||
await Pool.get().get(NOTIFIER_RELAY).auth.attemptAuth()
|
||||
|
||||
await thunk.result
|
||||
await loadAlertStatuses($pubkey!)
|
||||
const thunk = await publishAlert(params)
|
||||
const error = await getThunkError(thunk)
|
||||
|
||||
if (error) {
|
||||
return pushToast({
|
||||
theme: "error",
|
||||
message: `Failed to send your alert to the notification server (${error}).`,
|
||||
})
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script lang="ts">
|
||||
import {parseJson, nthEq} from "@welshman/lib"
|
||||
import {parseJson} from "@welshman/lib"
|
||||
import {displayFeeds} from "@welshman/feeds"
|
||||
import {getAddress, getTagValue, getTagValues} from "@welshman/util"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import AlertDelete from "@app/components/AlertDelete.svelte"
|
||||
import type {Alert} from "@app/state"
|
||||
import {alertStatuses} from "@app/state"
|
||||
import {deriveAlertStatus} from "@app/state"
|
||||
import {pushModal} from "@app/modal"
|
||||
|
||||
type Props = {
|
||||
@@ -15,8 +15,7 @@
|
||||
|
||||
const {alert}: Props = $props()
|
||||
|
||||
const address = $derived(getAddress(alert.event))
|
||||
const status = $derived($alertStatuses.find(s => s.event.tags.some(nthEq(1, address))))
|
||||
const status = deriveAlertStatus(getAddress(alert.event))
|
||||
const cron = $derived(getTagValue("cron", alert.tags))
|
||||
const channel = $derived(getTagValue("channel", alert.tags))
|
||||
const feeds = $derived(getTagValues("feed", alert.tags))
|
||||
@@ -39,24 +38,24 @@
|
||||
</Button>
|
||||
<div class="flex-inline gap-1">{description}</div>
|
||||
</div>
|
||||
{#if status}
|
||||
{@const statusText = getTagValue("status", status.tags) || "error"}
|
||||
{#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)}>
|
||||
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)}>
|
||||
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)}>
|
||||
data-tip={getTagValue("message", $status.tags)}>
|
||||
{statusText.replace("-", " ").replace(/^(.)/, x => x.toUpperCase())}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
memberships,
|
||||
deriveUserRooms,
|
||||
deriveOtherRooms,
|
||||
deviceAlerts,
|
||||
hasNip29,
|
||||
alerts,
|
||||
} from "@app/state"
|
||||
import {loadAlerts} from "@app/requests"
|
||||
import {notifications} from "@app/notifications"
|
||||
@@ -40,7 +40,7 @@
|
||||
const calendarPath = makeSpacePath(url, "calendar")
|
||||
const userRooms = deriveUserRooms(url)
|
||||
const otherRooms = deriveOtherRooms(url)
|
||||
const alert = $derived($alerts.find(a => getTagValue("feed", a.tags)?.includes(url)))
|
||||
const alert = $derived($deviceAlerts.find(a => getTagValue("feed", a.tags)?.includes(url)))
|
||||
|
||||
const openMenu = () => {
|
||||
showMenu = true
|
||||
|
||||
+51
-8
@@ -1,8 +1,28 @@
|
||||
import * as nip19 from "nostr-tools/nip19"
|
||||
import {Capacitor} from "@capacitor/core"
|
||||
import type {ActionPerformed, RegistrationError, Token} from "@capacitor/push-notifications"
|
||||
import {PushNotifications} from "@capacitor/push-notifications"
|
||||
import {sleep, parseJson} from "@welshman/lib"
|
||||
import {isSignedEvent} from "@welshman/util"
|
||||
import {goto} from "$app/navigation"
|
||||
import {VAPID_PUBLIC_KEY} from "@app/state"
|
||||
|
||||
export const platform = Capacitor.getPlatform()
|
||||
|
||||
export const initializePushNotifications = () => {
|
||||
if (platform === "web") return
|
||||
|
||||
PushNotifications.addListener("pushNotificationActionPerformed", (action: ActionPerformed) => {
|
||||
const event = parseJson(action.notification.data.event)
|
||||
const parsedRelays = parseJson(action.notification.data.relays)
|
||||
const relays = Array.isArray(parsedRelays) ? parsedRelays : []
|
||||
|
||||
if (isSignedEvent(event)) {
|
||||
goto("/" + nip19.neventEncode({id: event.id, relays}))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const canSendPushNotifications = () => ["web", "android", "ios"].includes(platform)
|
||||
|
||||
export const getWebPushInfo = async () => {
|
||||
@@ -36,7 +56,7 @@ export const getWebPushInfo = async () => {
|
||||
})
|
||||
}
|
||||
|
||||
const {endpoint, keys} = subscription.toJSON()
|
||||
const {keys} = subscription.toJSON()
|
||||
|
||||
if (!keys) {
|
||||
throw new Error(`Failed to get push info: no keys were returned`)
|
||||
@@ -49,12 +69,36 @@ export const getWebPushInfo = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
export const getIosPushInfo = async () => {
|
||||
return {}
|
||||
}
|
||||
export const getCapacitorPushInfo = async () => {
|
||||
let status = await PushNotifications.checkPermissions()
|
||||
|
||||
export const getAndroidPushInfo = async () => {
|
||||
return {}
|
||||
if (status.receive === "prompt") {
|
||||
status = await PushNotifications.requestPermissions()
|
||||
}
|
||||
|
||||
if (status.receive !== "granted") {
|
||||
throw new Error("Failed to register for push notifications")
|
||||
}
|
||||
|
||||
let device_token = ""
|
||||
let error = "Failed to register for push notifications"
|
||||
|
||||
PushNotifications.addListener("registration", (token: Token) => {
|
||||
device_token = token.value
|
||||
})
|
||||
|
||||
PushNotifications.addListener("registrationError", (_error: RegistrationError) => {
|
||||
error = _error.error
|
||||
})
|
||||
|
||||
await PushNotifications.register()
|
||||
await sleep(100)
|
||||
|
||||
if (!device_token) {
|
||||
throw new Error(error)
|
||||
}
|
||||
|
||||
return {device_token}
|
||||
}
|
||||
|
||||
export const getPushInfo = (): Promise<Record<string, string>> => {
|
||||
@@ -62,9 +106,8 @@ export const getPushInfo = (): Promise<Record<string, string>> => {
|
||||
case "web":
|
||||
return getWebPushInfo()
|
||||
case "ios":
|
||||
return getIosPushInfo()
|
||||
case "android":
|
||||
return getAndroidPushInfo()
|
||||
return getCapacitorPushInfo()
|
||||
default:
|
||||
throw new Error(`Invalid push platform: ${platform}`)
|
||||
}
|
||||
|
||||
+3
-1
@@ -367,7 +367,7 @@ export const listenForNotifications = () => {
|
||||
|
||||
for (const [url, allRooms] of userRoomsByUrl.get()) {
|
||||
// Limit how many rooms we load at a time, since we have to send a separate filter
|
||||
// for each one due to nip 29 breaking postel's law
|
||||
// for each one due to relay29 being picky
|
||||
const rooms = shuffle(Array.from(allRooms)).slice(0, 30)
|
||||
|
||||
load({
|
||||
@@ -375,6 +375,7 @@ export const listenForNotifications = () => {
|
||||
relays: [url],
|
||||
filters: [
|
||||
{kinds: [THREAD], limit: 1},
|
||||
{kinds: [MESSAGE], limit: 1},
|
||||
{kinds: [COMMENT], "#K": [String(THREAD)], limit: 1},
|
||||
...rooms.map(room => ({kinds: [MESSAGE], "#h": [room], limit: 1})),
|
||||
],
|
||||
@@ -385,6 +386,7 @@ export const listenForNotifications = () => {
|
||||
relays: [url],
|
||||
filters: [
|
||||
{kinds: [THREAD], since: now()},
|
||||
{kinds: [MESSAGE], since: now()},
|
||||
{kinds: [COMMENT], "#K": [String(THREAD)], since: now()},
|
||||
...rooms.map(room => ({kinds: [MESSAGE], "#h": [room], since: now()})),
|
||||
],
|
||||
|
||||
+3
-3
@@ -69,7 +69,7 @@ export const goToEvent = async (event: TrustedEvent, options: Record<string, any
|
||||
const urls = Array.from(tracker.getRelays(event.id))
|
||||
const path = await getEventPath(event, urls)
|
||||
|
||||
if (path.includes('://')) {
|
||||
if (path.includes("://")) {
|
||||
window.open(path)
|
||||
} else {
|
||||
goto(path, options)
|
||||
@@ -94,7 +94,7 @@ export const getEventPath = async (event: TrustedEvent, urls: string[]) => {
|
||||
}
|
||||
|
||||
if (event.kind === MESSAGE) {
|
||||
return room ? makeRoomPath(url, room) : makeSpacePath(url, 'chat')
|
||||
return room ? makeRoomPath(url, room) : makeSpacePath(url, "chat")
|
||||
}
|
||||
|
||||
const kind = event.tags.find(nthEq(0, "K"))?.[1]
|
||||
@@ -110,7 +110,7 @@ export const getEventPath = async (event: TrustedEvent, urls: string[]) => {
|
||||
}
|
||||
|
||||
if (parseInt(kind) === MESSAGE) {
|
||||
return room ? makeRoomPath(url, room) : makeSpacePath(url, 'chat')
|
||||
return room ? makeRoomPath(url, room) : makeSpacePath(url, "chat")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+21
-2
@@ -23,7 +23,14 @@ import {
|
||||
} from "@welshman/lib"
|
||||
import type {Socket} from "@welshman/net"
|
||||
import {Pool, load, AuthStateEvent, SocketEvent} from "@welshman/net"
|
||||
import {collection, custom} from "@welshman/store"
|
||||
import {
|
||||
collection,
|
||||
custom,
|
||||
deriveEvents,
|
||||
deriveEventsMapped,
|
||||
withGetter,
|
||||
synced,
|
||||
} from "@welshman/store"
|
||||
import {
|
||||
getIdFilters,
|
||||
WRAP,
|
||||
@@ -56,7 +63,9 @@ import {
|
||||
asDecryptedEvent,
|
||||
normalizeRelayUrl,
|
||||
getTag,
|
||||
getTagValue,
|
||||
getTagValues,
|
||||
getAddress,
|
||||
} from "@welshman/util"
|
||||
import type {TrustedEvent, SignedEvent, PublishedList, List, Filter} from "@welshman/util"
|
||||
import {Nip59, decrypt} from "@welshman/signer"
|
||||
@@ -81,7 +90,6 @@ import {
|
||||
appContext,
|
||||
} from "@welshman/app"
|
||||
import type {Thunk, Relay} from "@welshman/app"
|
||||
import {deriveEvents, deriveEventsMapped, withGetter, synced} from "@welshman/store"
|
||||
|
||||
export const fromCsv = (s: string) => (s || "").split(",").filter(identity)
|
||||
|
||||
@@ -341,6 +349,8 @@ export const {
|
||||
|
||||
// Alerts
|
||||
|
||||
export const deviceAlertAddresses = synced<string[]>("deviceAlertAddresses", [])
|
||||
|
||||
export type Alert = {
|
||||
event: TrustedEvent
|
||||
tags: string[][]
|
||||
@@ -356,6 +366,12 @@ export const alerts = deriveEventsMapped<Alert>(repository, {
|
||||
},
|
||||
})
|
||||
|
||||
export const deviceAlerts = derived(
|
||||
[deviceAlertAddresses, alerts],
|
||||
([$deviceAlertAddresses, $alerts]) =>
|
||||
$alerts.filter(a => $deviceAlertAddresses.includes(getAddress(a.event))),
|
||||
)
|
||||
|
||||
// Alert Statuses
|
||||
|
||||
export type AlertStatus = {
|
||||
@@ -373,6 +389,9 @@ export const alertStatuses = deriveEventsMapped<AlertStatus>(repository, {
|
||||
},
|
||||
})
|
||||
|
||||
export const deriveAlertStatus = (address: string) =>
|
||||
derived(alertStatuses, statuses => statuses.find(s => getTagValue("d", s.event.tags) === address))
|
||||
|
||||
// Membership
|
||||
|
||||
export const hasMembershipUrl = (list: List | undefined, url: string) =>
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
} from "@app/state"
|
||||
import {loadUserData, listenForNotifications} from "@app/requests"
|
||||
import {theme} from "@app/theme"
|
||||
import {initializePushNotifications} from "@app/push"
|
||||
import * as commands from "@app/commands"
|
||||
import * as requests from "@app/requests"
|
||||
import * as notifications from "@app/notifications"
|
||||
@@ -73,6 +74,9 @@
|
||||
dropSession($session.pubkey)
|
||||
}
|
||||
|
||||
// Initialize push notification handler asap
|
||||
initializePushNotifications()
|
||||
|
||||
const {children} = $props()
|
||||
|
||||
const ready = $state(defer<void>())
|
||||
@@ -95,8 +99,8 @@
|
||||
})
|
||||
|
||||
// Listen for navigation messages from service worker
|
||||
navigator.serviceWorker?.addEventListener('message', (event) => {
|
||||
if (event.data && event.data.type === 'NAVIGATE') {
|
||||
navigator.serviceWorker?.addEventListener("message", event => {
|
||||
if (event.data && event.data.type === "NAVIGATE") {
|
||||
goto(event.data.url)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import {onMount} from "svelte"
|
||||
import * as nip19 from "nostr-tools/nip19"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {Address, getIdFilters, getTagValue} from "@welshman/util"
|
||||
import {Address, getIdFilters} from "@welshman/util"
|
||||
import {LOCAL_RELAY_URL} from "@welshman/relay"
|
||||
import {load} from "@welshman/net"
|
||||
import {page} from "$app/stores"
|
||||
|
||||
+23
-24
@@ -1,24 +1,12 @@
|
||||
import * as nip19 from 'nostr-tools/nip19'
|
||||
import {parse, renderAsText} from '@welshman/content'
|
||||
import {MESSAGE, THREAD, COMMENT, EVENT_TIME} from '@welshman/util'
|
||||
/* global clients */
|
||||
|
||||
const renderOptions = {
|
||||
createElement: tag => ({
|
||||
_text: "",
|
||||
set innerText(text) {
|
||||
this._text = text
|
||||
},
|
||||
get innerHTML() {
|
||||
return this._text
|
||||
},
|
||||
})
|
||||
}
|
||||
import * as nip19 from "nostr-tools/nip19"
|
||||
|
||||
self.addEventListener('install', event => {
|
||||
self.addEventListener("install", event => {
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
self.addEventListener('activate', event => {
|
||||
self.addEventListener("activate", event => {
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
|
||||
@@ -26,26 +14,37 @@ self.addEventListener("push", e => {
|
||||
console.log("Service Worker: Push event received", e)
|
||||
|
||||
let url = "/"
|
||||
let title = "New activity"
|
||||
let body = "You have a new message"
|
||||
|
||||
try {
|
||||
const {event, relays = []} = e.data?.json() || {}
|
||||
const data = e.data?.json()
|
||||
|
||||
if (event) {
|
||||
url += nip19.neventEncode({id: event.id, relays})
|
||||
body = renderAsText(parse(event), renderOptions).toString()
|
||||
if (data?.event) {
|
||||
url += nip19.neventEncode({
|
||||
id: data.event.id,
|
||||
relays: data.relays || []
|
||||
})
|
||||
}
|
||||
|
||||
if (data?.title) {
|
||||
title = data.title
|
||||
}
|
||||
|
||||
if (data?.body) {
|
||||
body = data.body
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("Service Worker: Failed to parse push data", e)
|
||||
}
|
||||
|
||||
e.waitUntil(
|
||||
self.registration.showNotification("New message", {
|
||||
self.registration.showNotification(title, {
|
||||
body,
|
||||
data: {url},
|
||||
icon: "/pwa-192x192.png",
|
||||
badge: "/pwa-64x64.png",
|
||||
tag: 'flotilla-notification',
|
||||
tag: "flotilla-notification",
|
||||
requireInteraction: false,
|
||||
}),
|
||||
)
|
||||
@@ -74,8 +73,8 @@ self.addEventListener("notificationclick", e => {
|
||||
for (const client of clientList) {
|
||||
if (client.url.includes(location.origin)) {
|
||||
client.postMessage({
|
||||
type: 'NAVIGATE',
|
||||
url: url
|
||||
type: "NAVIGATE",
|
||||
url: url,
|
||||
})
|
||||
|
||||
return client.focus()
|
||||
|
||||
Reference in New Issue
Block a user