Only raise notifications for new messages (#663)

This commit is contained in:
Coracle-Bot 2026-09-26 03:45:58 +00:00 committed by hodlbod
parent 04360b212f
commit 173d996de3
9 changed files with 86 additions and 22 deletions

View file

@ -200,12 +200,10 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
) )
val body = "New activity"
val notification = NotificationCompat.Builder(context, CHANNEL_ID) val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_notify_chat) .setSmallIcon(android.R.drawable.stat_notify_chat)
.setContentTitle("Flotilla") .setContentTitle("Flotilla")
.setContentText(body) .setContentText("New message")
.setAutoCancel(true) .setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT) .setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(pendingIntent) .setContentIntent(pendingIntent)

View file

@ -156,7 +156,7 @@ the story catalog in <code>e2e/USER_STORIES.md</code>.</p>
<tr> <tr>
<td>Push notifications</td> <td>Push notifications</td>
<td><span class="status s-ok">Implemented</span></td> <td><span class="status s-ok">Implemented</span></td>
<td><code>app/push/</code>, <code>settings/alerts</code>; web, FCM/APNs and Android fallback. US-086, US-120</td> <td><code>app/push/</code>, <code>settings/alerts</code>; web, FCM/APNs and Android fallback. US-086, US-120, US-136</td>
</tr> </tr>
<tr> <tr>
<td>Threads</td> <td>Threads</td>

View file

@ -1675,6 +1675,18 @@ Acceptance:
- Its body is the words bob wrote rather than the quote his reply is prepended - Its body is the words bob wrote rather than the quote his reply is prepended
with, and a url in it is named by its host instead of spelled out. with, and a url in it is named by its host instead of spelled out.
### US-136 — Be notified about messages and nothing else
As alice, I want notifications to be about what people say to me, so that a
space with busy boards does not buzz my phone all day.
Acceptance:
- With push notifications on and the tab in the background, a message from bob
in a room alice is in raises one.
- A thread bob starts in the same room raises none, however much of it reaches
her client.
### US-105 — Land on the home page ### US-105 — Land on the home page
As a new user, I want the home page to route me somewhere useful, so that I'm As a new user, I want the home page to route me somewhere useful, so that I'm

View file

@ -9,6 +9,8 @@ import {
expect, expect,
message, message,
messageActions, messageActions,
modalForm,
noteEditor,
openRoomDetail, openRoomDetail,
pageBar, pageBar,
pathPattern, pathPattern,
@ -867,3 +869,59 @@ test("US-120 read what a notification says", async ({seed, as}) => {
body: "sunday, the notice is at a link to harbor.example", body: "sunday, the notice is at a link to harbor.example",
}) })
}) })
test("US-136 hear about a new message and not about a new thread", async ({seed, as}) => {
const scenario = await seed(({relay, user}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
space.profile(user.alice, {name: "Alice Anchor"})
space.profile(user.bob, {name: "Bob Barnacle"})
})
const space = scenario.space("space")
// The tab has to be in the background before a notification is raised at all.
const alice = await as(users.alice, "/settings/alerts", {
context: {permissions: ["notifications"]},
})
await settingRow(alice, "Enable push notifications").getByRole("checkbox").check()
await alice.getByRole("button", {name: "Save Changes"}).click()
await expect(alice.getByRole("alert")).toContainText("Your settings have been saved!")
await spaceNavItem(alice, space.name).click()
await roomLink(alice, "General").click()
await expect(alice).toHaveURL(pathPattern(roomPath(space.url, "general")))
const notifications = await captureNotifications(alice)
const bob = await as(users.bob, `${spacePath(space.url)}/threads`)
const board = bob
.locator("section")
.filter({has: bob.getByRole("heading", {name: "General", exact: true})})
await board.getByRole("button", {name: "Create", exact: true}).click()
const threadForm = modalForm(bob, "Create a Thread")
await threadForm.getByPlaceholder("What is this thread about?").fill("the dock is closing")
await noteEditor(threadForm).pressSequentially("sunday, apparently")
await threadForm.getByRole("button", {name: "Create Thread"}).click()
await expect(board.getByRole("row").filter({hasText: "the dock is closing"})).toBeVisible()
// Alice holds the thread before bob says anything, so a notification for it would be here by now.
await contentNavItem(alice, "Threads").click()
await expect(alice.getByRole("row").filter({hasText: "the dock is closing"})).toBeVisible()
await postTo(bob, "General", "when does the dock close?")
await expect
.poll(() => notifications)
.toEqual([{title: "New message", body: "when does the dock close?"}])
})

View file

@ -65,7 +65,7 @@ test("US-086 configure alerts", async ({seed, as}) => {
const page = await as(users.alice, "/settings/alerts") const page = await as(users.alice, "/settings/alerts")
const sound = settingToggle(page, "Play sound for new activity") const sound = settingToggle(page, "Play sound for new messages")
const alertTypes = page.locator("div.card").filter({has: page.getByText("Alert Types")}) const alertTypes = page.locator("div.card").filter({has: page.getByText("Alert Types")})
await expect(sound).toBeChecked() await expect(sound).toBeChecked()
@ -98,7 +98,7 @@ test("US-086 configure alerts", async ({seed, as}) => {
await waitForToastToClear(page) await waitForToastToClear(page)
await page.reload() await page.reload()
await expect(settingToggle(page, "Play sound for new activity")).not.toBeChecked() await expect(settingToggle(page, "Play sound for new messages")).not.toBeChecked()
await expect(reminders).toHaveCount(2) await expect(reminders).toHaveCount(2)
await expect(reminders.nth(1).locator("input")).toHaveValue("1440") await expect(reminders.nth(1).locator("input")).toHaveValue("1440")
}) })

View file

@ -11,7 +11,7 @@
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<strong>Enable notifications for this space</strong> <strong>Enable notifications for this space</strong>
<p class="text-xs opacity-75"> <p class="text-xs opacity-75">
Get notified about new activity in this space. You can change this later in settings. Get notified about new messages in this space. You can change this later in settings.
</p> </p>
</div> </div>
<input type="checkbox" class="toggle" bind:checked={notifications} /> <input type="checkbox" class="toggle" bind:checked={notifications} />

View file

@ -1,7 +1,7 @@
import {writable} from "svelte/store" import {writable} from "svelte/store"
import type {Subscriber, Unsubscriber} from "svelte/store" import type {Subscriber, Unsubscriber} from "svelte/store"
import {Capacitor} from "@capacitor/core" import {Capacitor} from "@capacitor/core"
import {assoc, call, ms, now, on, parseJson, poll, spec, throttle, uniq} from "@welshman/lib" import {call, ms, now, on, parseJson, poll, spec, throttle, uniq} from "@welshman/lib"
import {LOCAL_RELAY_URL} from "@welshman/net" import {LOCAL_RELAY_URL} from "@welshman/net"
import type {RepositoryUpdate} from "@welshman/net" import type {RepositoryUpdate} from "@welshman/net"
import { import {
@ -19,7 +19,7 @@ import {merged, withGetter} from "@welshman/store"
import {User} from "@welshman/app" import {User} from "@welshman/app"
import {navigate} from "@app/modal" import {navigate} from "@app/modal"
import {app, messagingRelayLists, network, roomLists} from "@app/core" import {app, messagingRelayLists, network, roomLists} from "@app/core"
import {DM_KINDS, CONTENT_KINDS, makeCommentFilter} from "@app/content" import {DM_KINDS} from "@app/content"
import {getMutedRooms, notificationSettings, shouldNotify, userSettingsValues} from "@app/settings" import {getMutedRooms, notificationSettings, shouldNotify, userSettingsValues} from "@app/settings"
import {makeEventPath, goToChat, goToSpace} from "@app/routes" import {makeEventPath, goToChat, goToSpace} from "@app/routes"
@ -47,10 +47,6 @@ export interface IPushAdapter {
} }
export const onNotification = call(() => { export const onNotification = call(() => {
const allFilters = [
{kinds: [MESSAGE, ...CONTENT_KINDS, ...DM_KINDS]},
makeCommentFilter(CONTENT_KINDS),
]
const subscribers: Subscriber<TrustedEvent>[] = [] const subscribers: Subscriber<TrustedEvent>[] = []
let unsubscribe: Unsubscriber | undefined let unsubscribe: Unsubscriber | undefined
@ -62,7 +58,7 @@ export const onNotification = call(() => {
let unsubscribeRepository: Unsubscriber | undefined let unsubscribeRepository: Unsubscriber | undefined
const unsubscribeApp = app.subscribe($app => { const unsubscribeApp = app.subscribe($app => {
unsubscribeRepository?.() unsubscribeRepository?.()
const filters = allFilters.map(assoc("since", now())) const filters = [{kinds: [MESSAGE, ...DM_KINDS], since: now()}]
unsubscribeRepository = on($app.repository, "update", ({added}: RepositoryUpdate) => { unsubscribeRepository = on($app.repository, "update", ({added}: RepositoryUpdate) => {
const $pubkey = $app.user?.pubkey const $pubkey = $app.user?.pubkey
@ -163,7 +159,7 @@ export const syncRelaySubscriptions = (
userSettingsValues, userSettingsValues,
]).subscribe( ]).subscribe(
throttle(3000, ([$spaceUrls, {spaces, mentions}, $settings]) => { throttle(3000, ([$spaceUrls, {spaces, mentions}, $settings]) => {
const baseFilters = [{kinds: [MESSAGE, ...CONTENT_KINDS]}, makeCommentFilter(CONTENT_KINDS)] const baseFilter = {kinds: [MESSAGE]}
for (const url of $spaceUrls) { for (const url of $spaceUrls) {
const {notify = true, exceptions = []} = $settings.alerts.find(spec({url})) || {} const {notify = true, exceptions = []} = $settings.alerts.find(spec({url})) || {}
@ -178,18 +174,18 @@ export const syncRelaySubscriptions = (
if (skipped.length > 0) { if (skipped.length > 0) {
ignore.push({"#h": skipped}) ignore.push({"#h": skipped})
} }
filters.push(...baseFilters) filters.push(baseFilter)
} else { } else {
const included = exceptions.filter(h => !muted.includes(h)) const included = exceptions.filter(h => !muted.includes(h))
if (included.length > 0) { if (included.length > 0) {
filters.push(...baseFilters.map(f => ({...f, "#h": included}))) filters.push({...baseFilter, "#h": included})
} }
} }
} }
if (mentions) { if (mentions) {
filters.push(...baseFilters.map(f => ({...f, "#p": [$pubkey]}))) filters.push({...baseFilter, "#p": [$pubkey]})
} }
sync(url, "spaces", filters, ignore) sync(url, "spaces", filters, ignore)

View file

@ -68,7 +68,7 @@ export class WebNotifications implements IPushAdapter {
preview || "Someone tagged you in a message.", preview || "Someone tagged you in a message.",
) )
} else if (spaces) { } else if (spaces) {
this._notify(event, "New activity", preview || "Someone posted a new message.") this._notify(event, "New message", preview || "Someone posted a new message.")
} }
} }
}) })

View file

@ -108,7 +108,7 @@
{#if !Capacitor.isNativePlatform()} {#if !Capacitor.isNativePlatform()}
<FieldInline> <FieldInline>
{#snippet label()} {#snippet label()}
<p>Play sound for new activity</p> <p>Play sound for new messages</p>
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<ToggleInput bind:checked={settings.sound} /> <ToggleInput bind:checked={settings.sound} />
@ -131,7 +131,7 @@
<strong class="text-lg">Alert Types</strong> <strong class="text-lg">Alert Types</strong>
<FieldInline> <FieldInline>
{#snippet label()} {#snippet label()}
<p>Notify me about new activity</p> <p>Notify me about new messages in my spaces</p>
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<ToggleInput bind:checked={settings.spaces} /> <ToggleInput bind:checked={settings.spaces} />
@ -147,7 +147,7 @@
</FieldInline> </FieldInline>
<FieldInline> <FieldInline>
{#snippet label()} {#snippet label()}
<p>Notify me about new messages</p> <p>Notify me about new direct messages</p>
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<ToggleInput bind:checked={settings.messages} /> <ToggleInput bind:checked={settings.messages} />