Merge pull request #903 from PR0M3TH3AN/codex/update-profile-modal-for-async-hashtag-publishing

Ensure hashtag preference publishing runs from modal and menu
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-10-25 18:52:25 -04:00
commit 357c88afd0
4 files changed
+337 -7

No files matched your search

+67 -2
View File
@@ -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 });
}
+108 -5
View File
@@ -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);
+48
View File
@@ -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();
+114
View File
@@ -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("<!DOCTYPE html>").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 });
});