From 3da281c197ef823cd079cb9775f459e741799689 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 25 Oct 2025 18:48:15 -0400 Subject: [PATCH] Handle hashtag preference publishing from profile modal and menu --- js/app.js | 69 +++++++++++++- js/ui/profileModalController.js | 113 +++++++++++++++++++++-- tests/profile-modal-controller.test.mjs | 48 ++++++++++ tests/ui/tag-preference-menu.test.mjs | 114 ++++++++++++++++++++++++ 4 files changed, 337 insertions(+), 7 deletions(-) diff --git a/js/app.js b/js/app.js index fdd979ea..364ed124 100644 --- a/js/app.js +++ b/js/app.js @@ -315,6 +315,8 @@ class Application { this.computeHashtagPreferencesSignature( this.hashtagPreferencesSnapshot, ); + this.hashtagPreferencesPublishInFlight = false; + this.hashtagPreferencesPublishPromise = null; this.boundHashtagPreferencesChangeHandler = null; this.unsubscribeFromHashtagPreferencesChange = null; if ( @@ -7919,7 +7921,7 @@ class Application { membership: this.getTagPreferenceMembership(entry.tag), designSystem: this.designSystemContext, onAction: (action, actionDetail = {}) => { - this.handleTagPreferenceMenuAction(action, { + void this.handleTagPreferenceMenuAction(action, { tag: entry.tag, trigger, video: entry.video || null, @@ -8047,7 +8049,61 @@ class Application { return closed; } - handleTagPreferenceMenuAction(action, detail = {}) { + async persistHashtagPreferencesFromMenu() { + const service = this.hashtagPreferences; + const publish = + service && typeof service.publish === "function" ? service.publish : null; + + if (!publish) { + const message = this.describeHashtagPreferencesError(null, { + fallbackMessage: "Hashtag preferences are unavailable right now.", + }); + if (message) { + this.showError(message); + } + const error = new Error( + message || "Hashtag preferences are unavailable right now.", + ); + error.code = "service-unavailable"; + throw error; + } + + if (this.hashtagPreferencesPublishInFlight) { + return this.hashtagPreferencesPublishPromise; + } + + const normalizedPubkey = this.normalizeHexPubkey(this.pubkey); + const payload = normalizedPubkey ? { pubkey: normalizedPubkey } : {}; + + this.hashtagPreferencesPublishInFlight = true; + + const publishPromise = (async () => { + try { + return await publish.call(service, payload); + } catch (error) { + const failure = + error instanceof Error ? error : new Error(String(error || "")); + if (!failure.code) { + failure.code = "hashtag-preferences-publish-failed"; + } + const message = this.describeHashtagPreferencesError(failure, { + operation: "update", + }); + if (message) { + this.showError(message); + } + throw failure; + } finally { + this.hashtagPreferencesPublishInFlight = false; + this.hashtagPreferencesPublishPromise = null; + } + })(); + + this.hashtagPreferencesPublishPromise = publishPromise; + return publishPromise; + } + + async handleTagPreferenceMenuAction(action, detail = {}) { const tag = typeof detail?.tag === "string" ? detail.tag : ""; if (!tag) { return; @@ -8098,6 +8154,15 @@ class Application { this.updateCachedHashtagPreferences(); this.refreshTagPreferenceUi(); + try { + await this.persistHashtagPreferencesFromMenu(); + } catch (error) { + return; + } + + this.updateCachedHashtagPreferences(); + this.refreshTagPreferenceUi(); + if (typeof detail?.closePopover === "function") { detail.closePopover({ restoreFocus: false }); } diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index 05cbb3bb..079f5817 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -193,6 +193,7 @@ const SERVICE_CONTRACT = [ removeInterest: () => false, addDisinterest: () => false, removeDisinterest: () => false, + publish: async () => false, on: () => () => {}, }), }, @@ -856,6 +857,8 @@ export class ProfileModalController { this.services.describeHashtagPreferencesError; this.getHashtagPreferencesSnapshotService = this.services.getHashtagPreferences; + this.hashtagPreferencesPublishInFlight = false; + this.hashtagPreferencesPublishPromise = null; if ( this.hashtagPreferencesService && @@ -3209,6 +3212,83 @@ export class ProfileModalController { return item; } + async persistHashtagPreferences(options = {}) { + const service = this.hashtagPreferencesService; + const publish = + service && typeof service.publish === "function" ? service.publish : null; + + if (!publish) { + const message = this.describeHashtagPreferencesError(null, { + fallbackMessage: "Hashtag preferences are unavailable right now.", + }); + if (message) { + this.showError(message); + this.setHashtagStatus(message, "warning"); + } + const error = new Error( + message || "Hashtag preferences are unavailable right now.", + ); + error.code = "service-unavailable"; + throw error; + } + + if (this.hashtagPreferencesPublishInFlight) { + return this.hashtagPreferencesPublishPromise; + } + + const { successMessage, pubkey, progressMessage } = + options && typeof options === "object" ? options : {}; + + const resolvedPubkeyCandidate = + typeof pubkey === "string" && pubkey.trim() + ? pubkey + : this.getActivePubkey(); + const normalizedPubkey = this.normalizeHexPubkey(resolvedPubkeyCandidate); + + const payload = normalizedPubkey ? { pubkey: normalizedPubkey } : {}; + + const pendingMessage = + typeof progressMessage === "string" && progressMessage.trim() + ? progressMessage.trim() + : "Saving hashtag preferences…"; + const finalMessage = + typeof successMessage === "string" && successMessage.trim() + ? successMessage.trim() + : "Hashtag preferences saved."; + + this.hashtagPreferencesPublishInFlight = true; + this.setHashtagStatus(pendingMessage, "info"); + + const publishPromise = (async () => { + try { + const result = await publish.call(service, payload); + this.setHashtagStatus(finalMessage, "success"); + return result; + } catch (error) { + const failure = + error instanceof Error ? error : new Error(String(error || "")); + if (!failure.code) { + failure.code = "hashtag-preferences-publish-failed"; + } + const message = this.describeHashtagPreferencesError(failure, { + fallbackMessage: + "Failed to update hashtag preferences. Please try again.", + }); + if (message) { + this.showError(message); + this.setHashtagStatus(message, "warning"); + } + throw failure; + } finally { + this.hashtagPreferencesPublishInFlight = false; + this.hashtagPreferencesPublishPromise = null; + } + })(); + + this.hashtagPreferencesPublishPromise = publishPromise; + return publishPromise; + } + async handleAddHashtagPreference(type) { const isInterest = type === "interest"; const input = isInterest @@ -3276,10 +3356,22 @@ export class ProfileModalController { : `${this.formatHashtagTag(normalized)} added to ${ isInterest ? "interests" : "disinterests" }.`; - this.showSuccess(actionMessage); - this.setHashtagStatus(actionMessage, "success"); this.populateHashtagPreferences(); - return { success: true, reason: inOpposite ? "moved" : "added" }; + try { + await this.persistHashtagPreferences({ + successMessage: actionMessage, + }); + this.showSuccess(actionMessage); + return { success: true, reason: inOpposite ? "moved" : "added" }; + } catch (error) { + return { + success: false, + reason: error?.code || "publish-failed", + error, + }; + } finally { + this.populateHashtagPreferences(); + } } if (alreadyInTarget) { @@ -3341,8 +3433,19 @@ export class ProfileModalController { const message = `${this.formatHashtagTag(normalized)} removed from ${ type === "interest" ? "interests" : "disinterests" }.`; - this.showSuccess(message); - this.setHashtagStatus(message, "success"); + this.populateHashtagPreferences(); + try { + await this.persistHashtagPreferences({ successMessage: message }); + this.showSuccess(message); + } catch (error) { + return { + success: false, + reason: error?.code || "publish-failed", + error, + }; + } finally { + this.populateHashtagPreferences(); + } } else { const message = `${this.formatHashtagTag(normalized)} is already removed.`; this.showStatus(message); diff --git a/tests/profile-modal-controller.test.mjs b/tests/profile-modal-controller.test.mjs index caf16de7..da81dd44 100644 --- a/tests/profile-modal-controller.test.mjs +++ b/tests/profile-modal-controller.test.mjs @@ -259,6 +259,7 @@ function createController(options = {}) { } return hashtagStore.disinterests.delete(normalized); }, + publish: async () => ({ ok: true }), on: () => () => {}, }; @@ -777,6 +778,53 @@ for (const _ of [0]) { }); }); + test('handleAddHashtagPreference publishes updates', async (t) => { + const publishCalls = []; + const controller = createController({ + services: { + hashtagPreferences: { + publish: async (payload) => { + publishCalls.push(payload); + return { ok: true }; + }, + }, + }, + }); + await controller.load(); + applyDesignSystemAttributes(document); + + controller.state.setActivePubkey(defaultActorHex); + + let cleanupRan = false; + const cleanup = () => { + try { + controller.hide({ silent: true }); + } catch {} + cleanupRan = true; + }; + + try { + await controller.show('hashtags'); + await waitForAnimationFrame(window, 2); + + controller.hashtagInterestInput.value = '#nostr'; + const result = await controller.handleAddHashtagPreference('interest'); + + assert.equal(result.success, true); + assert.equal(publishCalls.length, 1); + assert.deepEqual(publishCalls[0], { pubkey: defaultActorHex }); + } finally { + cleanup(); + } + + t.after(() => { + if (!cleanupRan) { + cleanup(); + } + resetRuntimeFlags(); + }); + }); + test('Hashtag pane resets after logout when service clears tags', async (t) => { const controller = createController(); await controller.load(); diff --git a/tests/ui/tag-preference-menu.test.mjs b/tests/ui/tag-preference-menu.test.mjs index 3ce49ca0..4647f404 100644 --- a/tests/ui/tag-preference-menu.test.mjs +++ b/tests/ui/tag-preference-menu.test.mjs @@ -6,6 +6,7 @@ import createTagPreferenceMenu, { TAG_PREFERENCE_ACTIONS, applyTagPreferenceMenuState, } from "../../js/ui/components/tagPreferenceMenu.js"; +import Application from "../../js/app.js"; test("createTagPreferenceMenu renders heading and actions", () => { const { document } = new JSDOM("").window; @@ -84,3 +85,116 @@ test("createTagPreferenceMenu forwards actions to callback", () => { assert.equal(events[1].action, TAG_PREFERENCE_ACTIONS.REMOVE_DISINTEREST); assert.equal(events[1].detail.normalizedTag, "art"); }); + +test("handleTagPreferenceMenuAction publishes hashtag updates", async () => { + const store = { + interests: new Set(), + disinterests: new Set(), + }; + + const publishCalls = []; + const service = { + addInterest(tag) { + if (typeof tag !== "string") { + return false; + } + const normalized = tag.trim().toLowerCase(); + if (!normalized) { + return false; + } + store.disinterests.delete(normalized); + const had = store.interests.has(normalized); + store.interests.add(normalized); + return !had; + }, + removeInterest(tag) { + if (typeof tag !== "string") { + return false; + } + const normalized = tag.trim().toLowerCase(); + if (!normalized) { + return false; + } + return store.interests.delete(normalized); + }, + addDisinterest(tag) { + if (typeof tag !== "string") { + return false; + } + const normalized = tag.trim().toLowerCase(); + if (!normalized) { + return false; + } + store.interests.delete(normalized); + const had = store.disinterests.has(normalized); + store.disinterests.add(normalized); + return !had; + }, + removeDisinterest(tag) { + if (typeof tag !== "string") { + return false; + } + const normalized = tag.trim().toLowerCase(); + if (!normalized) { + return false; + } + return store.disinterests.delete(normalized); + }, + getInterests() { + return Array.from(store.interests).sort(); + }, + getDisinterests() { + return Array.from(store.disinterests).sort(); + }, + async publish(payload) { + publishCalls.push(payload); + return { ok: true }; + }, + on() { + return () => {}; + }, + }; + + const app = Object.create(Application.prototype); + app.hashtagPreferences = service; + app.pubkey = "f".repeat(64); + app.safeDecodeNpub = () => null; + app.describeHashtagPreferencesError = (_error, fallback = "") => fallback; + app.showError = () => {}; + app.refreshTagPreferenceUi = () => {}; + app.normalizeHexPubkey = Application.prototype.normalizeHexPubkey; + app.normalizeHashtagPreferenceList = + Application.prototype.normalizeHashtagPreferenceList; + app.createHashtagPreferencesSnapshot = + Application.prototype.createHashtagPreferencesSnapshot; + app.computeHashtagPreferencesSignature = + Application.prototype.computeHashtagPreferencesSignature; + app.getHashtagPreferences = Application.prototype.getHashtagPreferences; + app.updateCachedHashtagPreferences = + Application.prototype.updateCachedHashtagPreferences; + app.hashtagPreferencesSnapshot = app.createHashtagPreferencesSnapshot(); + app.hashtagPreferencesSnapshotSignature = + app.computeHashtagPreferencesSignature(app.hashtagPreferencesSnapshot); + app.hashtagPreferencesPublishInFlight = false; + app.hashtagPreferencesPublishPromise = null; + app.persistHashtagPreferencesFromMenu = + Application.prototype.persistHashtagPreferencesFromMenu; + app.handleTagPreferenceMenuAction = + Application.prototype.handleTagPreferenceMenuAction; + + await app.handleTagPreferenceMenuAction( + TAG_PREFERENCE_ACTIONS.ADD_INTEREST, + { tag: "nostr" }, + ); + + assert.equal(publishCalls.length, 1); + assert.deepEqual(publishCalls[0], { pubkey: app.pubkey }); + + await app.handleTagPreferenceMenuAction( + TAG_PREFERENCE_ACTIONS.REMOVE_INTEREST, + { tag: "nostr" }, + ); + + assert.equal(publishCalls.length, 2); + assert.deepEqual(publishCalls[1], { pubkey: app.pubkey }); +});