Fix topic reservation icon in nav bar

This commit is contained in:
binwiederhier
2026-06-24 06:48:51 -04:00
parent b0411e5c92
commit 6a4f68897a
5 changed files with 135 additions and 8 deletions
+1
View File
@@ -1974,6 +1974,7 @@ since I do have to reset emails on a regular basis.
* Update web app dependencies, including major-version upgrades to Vite (6 -> 8, now Rolldown-based), Material UI (5 -> 9), and Dexie (3 -> 4) ([#1800](https://github.com/binwiederhier/ntfy/pull/1800), [#1764](https://github.com/binwiederhier/ntfy/pull/1764), [#1767](https://github.com/binwiederhier/ntfy/pull/1767), [#1762](https://github.com/binwiederhier/ntfy/pull/1762), [#1766](https://github.com/binwiederhier/ntfy/pull/1766), [#1765](https://github.com/binwiederhier/ntfy/pull/1765), thanks Dependabot) * Update web app dependencies, including major-version upgrades to Vite (6 -> 8, now Rolldown-based), Material UI (5 -> 9), and Dexie (3 -> 4) ([#1800](https://github.com/binwiederhier/ntfy/pull/1800), [#1764](https://github.com/binwiederhier/ntfy/pull/1764), [#1767](https://github.com/binwiederhier/ntfy/pull/1767), [#1762](https://github.com/binwiederhier/ntfy/pull/1762), [#1766](https://github.com/binwiederhier/ntfy/pull/1766), [#1765](https://github.com/binwiederhier/ntfy/pull/1765), thanks Dependabot)
* Play notification sounds in the web app even when the Notification API is unavailable, e.g. over plain HTTP or in browsers without notification support ([#1772](https://github.com/binwiederhier/ntfy/pull/1772), thanks to [@mitya12342](https://github.com/mitya12342) for the contribution) * Play notification sounds in the web app even when the Notification API is unavailable, e.g. over plain HTTP or in browsers without notification support ([#1772](https://github.com/binwiederhier/ntfy/pull/1772), thanks to [@mitya12342](https://github.com/mitya12342) for the contribution)
* Stop escaping `<`, `>`, and `&` as `\u003c`/`\u003e`/`\u0026` in JSON responses ([#1511](https://github.com/binwiederhier/ntfy/issues/1511), [#1512](https://github.com/binwiederhier/ntfy/pull/1512), thanks to [@wunter8](https://github.com/wunter8) for the contribution) * Stop escaping `<`, `>`, and `&` as `\u003c`/`\u003e`/`\u0026` in JSON responses ([#1511](https://github.com/binwiederhier/ntfy/issues/1511), [#1512](https://github.com/binwiederhier/ntfy/pull/1512), thanks to [@wunter8](https://github.com/wunter8) for the contribution)
* Fix the web app navbar not reflecting a topic reservation (lock icon, and "Reserve topic" -> "Change reservation"/"Remove reservation" menu) until a page reload, by persisting reservation and display-name changes onto already-subscribed topics during account sync
### ntfy Android v1.25.x (UNRELEASED) ### ntfy Android v1.25.x (UNRELEASED)
+20 -5
View File
@@ -6,7 +6,7 @@ import { topicUrl } from "./utils";
import { messageWithSequenceId } from "./notificationUtils"; import { messageWithSequenceId } from "./notificationUtils";
import { EVENT_MESSAGE, EVENT_MESSAGE_CLEAR, EVENT_MESSAGE_DELETE } from "./events"; import { EVENT_MESSAGE, EVENT_MESSAGE_CLEAR, EVENT_MESSAGE_DELETE } from "./events";
class SubscriptionManager { export class SubscriptionManager {
constructor(dbImpl) { constructor(dbImpl) {
this.db = dbImpl; this.db = dbImpl;
} }
@@ -65,23 +65,36 @@ class SubscriptionManager {
} }
/** /**
* Upsert a subscription: create it if it doesn't exist yet, or merge the given fields into the
* existing one. Merging matters for account sync, which passes the remote display name and
* reservation -- without it, reserving/unreserving a topic you're already subscribed to would
* never be reflected locally until the database is recreated (e.g. a fresh login). Local-only
* state such as mutedUntil and last is preserved on merge.
*
* @param {string} baseUrl * @param {string} baseUrl
* @param {string} topic * @param {string} topic
* @param {object} opts * @param {object} opts
* @param {boolean} opts.internal * @param {boolean} opts.internal
* @returns * @returns
*/ */
async add(baseUrl, topic, opts = {}) { async upsert(baseUrl, topic, opts = {}) {
const id = topicUrl(baseUrl, topic); const id = topicUrl(baseUrl, topic);
const existingSubscription = await this.get(id); const existingSubscription = await this.get(id);
if (existingSubscription) { if (existingSubscription) {
return existingSubscription; // Avoid a needless write (and the resulting Dexie live-query churn) when nothing changed.
const changed = Object.keys(opts).some((key) => existingSubscription[key] !== opts[key]);
if (!changed) {
return existingSubscription;
}
const updatedSubscription = { ...existingSubscription, ...opts };
await this.db.subscriptions.put(updatedSubscription);
return updatedSubscription;
} }
const subscription = { const subscription = {
...opts, ...opts,
id: topicUrl(baseUrl, topic), id,
baseUrl, baseUrl,
topic, topic,
mutedUntil: 0, mutedUntil: 0,
@@ -101,7 +114,9 @@ class SubscriptionManager {
remoteSubscriptions.map(async (remote) => { remoteSubscriptions.map(async (remote) => {
const reservation = remoteReservations?.find((r) => remote.base_url === config.base_url && remote.topic === r.topic) || null; const reservation = remoteReservations?.find((r) => remote.base_url === config.base_url && remote.topic === r.topic) || null;
const local = await this.add(remote.base_url, remote.topic, { // upsert(): for topics that already exist locally this merges in the latest remote
// display name and reservation (see upsert() for why this matters).
const local = await this.upsert(remote.base_url, remote.topic, {
displayName: remote.display_name, // May be undefined displayName: remote.display_name, // May be undefined
reservation, // May be null! reservation, // May be null!
}); });
+111
View File
@@ -0,0 +1,111 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
// SubscriptionManager pulls in a handful of browser/Dexie-heavy singletons at import time. Mock
// them so the module imports cleanly under the node test environment; the tests construct their
// own SubscriptionManager with an in-memory fake db, so the real db singleton is never used.
vi.mock("./Api", () => ({ default: {} }));
vi.mock("./Notifier", () => ({ default: {} }));
vi.mock("./Prefs", () => ({ default: {} }));
vi.mock("./db", () => ({ default: () => ({}) }));
const { SubscriptionManager } = await import("./SubscriptionManager");
// Minimal in-memory stand-in for the Dexie "subscriptions" table, implementing just the surface
// that syncFromRemote() (and the upsert/remove/update helpers it calls) touches.
const fakeDb = () => {
const rows = new Map();
return {
rows,
subscriptions: {
get: async (id) => rows.get(id),
put: async (sub) => {
rows.set(sub.id, sub);
},
update: async (id, changes) => {
const existing = rows.get(id);
if (existing) {
rows.set(id, { ...existing, ...changes });
}
},
delete: async (id) => {
rows.delete(id);
},
toArray: async () => Array.from(rows.values()),
},
};
};
const baseUrl = "https://ntfy.sh";
beforeEach(() => {
vi.spyOn(console, "log").mockImplementation(() => {});
});
describe("SubscriptionManager.upsert", () => {
it("merges fields into an existing subscription without clobbering local-only state", async () => {
const db = fakeDb();
const manager = new SubscriptionManager(db);
await manager.upsert(baseUrl, "mytopic");
await manager.setMutedUntil("https://ntfy.sh/mytopic", 123);
const reservation = { topic: "mytopic", everyone: "deny-all" };
await manager.upsert(baseUrl, "mytopic", { displayName: "My Topic", reservation });
const stored = db.rows.get("https://ntfy.sh/mytopic");
expect(stored.reservation).toEqual(reservation);
expect(stored.displayName).toBe("My Topic");
expect(stored.mutedUntil).toBe(123); // local-only state preserved
});
it("does not write when an existing subscription would not change", async () => {
const db = fakeDb();
const manager = new SubscriptionManager(db);
await manager.upsert(baseUrl, "mytopic", { internal: true });
const putSpy = vi.spyOn(db.subscriptions, "put");
const result = await manager.upsert(baseUrl, "mytopic", { internal: true });
expect(putSpy).not.toHaveBeenCalled();
expect(result.topic).toBe("mytopic");
});
});
describe("SubscriptionManager.syncFromRemote", () => {
it("persists a reservation onto a subscription that already exists locally", async () => {
const db = fakeDb();
const manager = new SubscriptionManager(db);
// Topic was subscribed to before it was reserved, so it already exists locally without a
// reservation -- exactly the state when a user clicks "Reserve topic" in the navbar.
await manager.upsert(baseUrl, "mytopic");
expect(db.rows.get("https://ntfy.sh/mytopic").reservation).toBeFalsy();
const reservation = { topic: "mytopic", everyone: "deny-all" };
await manager.syncFromRemote([{ base_url: baseUrl, topic: "mytopic" }], [reservation]);
expect(db.rows.get("https://ntfy.sh/mytopic").reservation).toEqual(reservation);
});
it("clears the reservation when the remote no longer reports one", async () => {
const db = fakeDb();
const manager = new SubscriptionManager(db);
await manager.upsert(baseUrl, "mytopic", { reservation: { topic: "mytopic", everyone: "deny-all" } });
await manager.syncFromRemote([{ base_url: baseUrl, topic: "mytopic" }], []);
expect(db.rows.get("https://ntfy.sh/mytopic").reservation).toBeNull();
});
it("updates the display name on an existing subscription", async () => {
const db = fakeDb();
const manager = new SubscriptionManager(db);
await manager.upsert(baseUrl, "mytopic");
await manager.syncFromRemote([{ base_url: baseUrl, topic: "mytopic", display_name: "My Topic" }], []);
expect(db.rows.get("https://ntfy.sh/mytopic").displayName).toBe("My Topic");
});
});
+1 -1
View File
@@ -34,7 +34,7 @@ import prefs from "../app/Prefs";
const publicBaseUrl = "https://ntfy.sh"; const publicBaseUrl = "https://ntfy.sh";
export const subscribeTopic = async (baseUrl, topic, opts) => { export const subscribeTopic = async (baseUrl, topic, opts) => {
const subscription = await subscriptionManager.add(baseUrl, topic, opts); const subscription = await subscriptionManager.upsert(baseUrl, topic, opts);
if (session.exists()) { if (session.exists()) {
try { try {
await accountApi.addSubscription(baseUrl, topic); await accountApi.addSubscription(baseUrl, topic);
+2 -2
View File
@@ -111,7 +111,7 @@ export const useConnectionListeners = (account, subscriptions, users, webPushTop
if (!account || !account.sync_topic) { if (!account || !account.sync_topic) {
return; return;
} }
subscriptionManager.add(config.base_url, account.sync_topic, { internal: true }); // Dangle! subscriptionManager.upsert(config.base_url, account.sync_topic, { internal: true }); // Dangle!
}, [account]); }, [account]);
// When subscriptions or users change, refresh the connections // When subscriptions or users change, refresh the connections
@@ -139,7 +139,7 @@ export const useAutoSubscribe = (subscriptions, selected) => {
const baseUrl = params.baseUrl ? expandSecureUrl(params.baseUrl) : config.base_url; const baseUrl = params.baseUrl ? expandSecureUrl(params.baseUrl) : config.base_url;
console.log(`[Hooks] Auto-subscribing to ${topicUrl(baseUrl, params.topic)}`); console.log(`[Hooks] Auto-subscribing to ${topicUrl(baseUrl, params.topic)}`);
(async () => { (async () => {
const subscription = await subscriptionManager.add(baseUrl, params.topic); const subscription = await subscriptionManager.upsert(baseUrl, params.topic);
if (session.exists()) { if (session.exists()) {
try { try {
await accountApi.addSubscription(baseUrl, params.topic); await accountApi.addSubscription(baseUrl, params.topic);