[decompose] extract helpers from js/ui/profileModalController.js

Extracted DM Relay UI logic and DM Attachment logic into separate binder modules to reduce the size of ProfileModalController.js. Updated file size baseline.

Co-authored-by: PR0M3TH3AN <[email protected]>
This commit is contained in:
google-labs-jules[bot]andPR0M3TH3AN committed 2026-02-18 01:03:43 +00:00
1 parent 5e8b6b0141
commit d22785bb87
9 files changed
+605 -413

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
# Decompose: js/ui/profileModalController.js
## Selection
Selected `js/ui/profileModalController.js` (6258 lines) because it is the largest grandfathered file not decomposed in the last 8 weeks.
## Plan
Extract two cohesive blocks related to DM functionality that are currently mixed into the main controller:
1. **DM Relay UI Logic** (`ProfileDmRelayBinder.js`)
- `cacheDmRelayElements`
- `bindDmRelayControls`
- `refreshDmRelayPreferences`
- `handlePublishDmRelayPreferences`
- `updateDmPrivacyToggleForRecipient`
- `setPrivacyToggleState`
- `handleActiveDmIdentityChanged`
2. **DM Attachment Logic** (`ProfileDmAttachmentBinder.js`)
- `renderAttachmentQueue`
- `uploadAttachmentQueue`
- `resolveLatestDirectMessageForRecipient`
- `maybePublishTypingIndicator`
These functions heavily rely on `this.dmController` and `this.services`, suggesting they belong in dedicated helpers or the DM controller itself. Extracting them to binder modules allows preserving the `this` context via argument passing while reducing the main file size.
## Stats
- Original size: 6258 lines
- Target reduction: > 200 lines
+13
View File
@@ -0,0 +1,13 @@
# Decisions
## Extraction Strategy
- Extracted logic heavily depends on `this` (the controller instance).
- To avoid modifying `ProfileDirectMessageController.js` (keeping changes confined to `ProfileModalController` decomposition), we will create "Binder" modules that export standalone functions accepting the controller instance.
- This pattern mimics the "Mixin" or "Extension Method" pattern but with pure functions.
## Naming
- `ProfileDmRelayBinder.js`: Handles relay preferences and UI binding.
- `ProfileDmAttachmentBinder.js`: Handles attachment queue rendering and uploading.
## Dependencies
- Binders will import necessary utilities (logger, helpers) but rely on the controller instance for state and services.
+15 -9
View File
@@ -1,17 +1,23 @@
# Content Audit Agent (Daily) - Completed
# Decompose Agent (Daily) - Completed
**Date:** 2026-02-17
**Agent:** content-audit-agent
**Date:** 2026-02-18
**Agent:** decompose-agent
**Status:** Success
## Summary
Audited `/content/docs/guides/upload-content.md` and `/content/docs/community/community-guidelines.md` against the codebase.
Decomposed `js/ui/profileModalController.js` by extracting cohesive blocks of logic into helper modules ("binders").
## Findings
1. **Accepted File Types**: `/content/docs/guides/upload-content.md` listed a subset of supported video extensions.
* **Fix**: Updated the doc to match `js/services/s3UploadService.js` (added `.m4v`, `.avi`, `.ogv`, `.ogg`, `.mpd`, `.flv`, `.3gp`).
2. **Moderation Configuration**: `js/services/moderationService.js` was using hardcoded constants for blur/autoplay thresholds, ignoring the configuration file mentioned in `/content/docs/community/community-guidelines.md`.
* **Fix**: Refactored `js/services/moderationService.js` to import `DEFAULT_BLUR_THRESHOLD` and `DEFAULT_AUTOPLAY_BLOCK_THRESHOLD` from `../config.js`.
1. **Selection**: `js/ui/profileModalController.js` (6258 lines) was the largest grandfathered file.
2. **Extraction**:
- Extracted DM Relay UI logic to `js/ui/profileModal/ProfileDmRelayBinder.js`.
- Extracted DM Attachment & Typing logic to `js/ui/profileModal/ProfileDmAttachmentBinder.js`.
3. **Result**:
- `js/ui/profileModalController.js` reduced to 5884 lines (reduction of 374 lines).
- Updated `scripts/check-file-size.mjs` baseline.
- All tests passed.
## Artifacts
- (Internal artifacts cleaned up)
- `context/CONTEXT_1739837127.md`
- `todo/TODO_1739837127.md`
- `decisions/DECISIONS_1739837127.md`
@@ -0,0 +1,243 @@
import { formatAttachmentSize } from "../../attachments/attachmentUtils.js";
import { uploadAttachment } from "../../services/attachmentService.js";
import { buildPublicUrl, buildR2Key } from "../../r2.js";
import { devLogger } from "../../utils/logger.js";
const TYPING_INDICATOR_TTL_SECONDS = 15;
const TYPING_INDICATOR_COOLDOWN_MS = 4000;
export function renderAttachmentQueue(controller) {
const list = controller.dmController.profileMessageAttachmentList;
if (!(list instanceof HTMLElement)) {
return;
}
list.textContent = "";
if (!controller.dmController.dmAttachmentQueue.length) {
return;
}
controller.dmController.dmAttachmentQueue.forEach((entry) => {
const item = document.createElement("div");
item.className = "card flex flex-col gap-2 p-3";
item.dataset.attachmentId = entry.id;
const header = document.createElement("div");
header.className = "flex items-center justify-between gap-2";
const title = document.createElement("div");
title.className = "text-sm font-semibold text-text";
title.textContent = entry.name || "Attachment";
header.appendChild(title);
const removeButton = document.createElement("button");
removeButton.type = "button";
removeButton.className = "btn-ghost focus-ring inline-flex items-center";
removeButton.dataset.size = "sm";
removeButton.textContent = "Remove";
removeButton.addEventListener("click", () => {
controller.dmController.dmAttachmentQueue =
controller.dmController.dmAttachmentQueue.filter(
(queued) => queued.id !== entry.id,
);
if (entry.previewUrl && typeof URL !== "undefined") {
URL.revokeObjectURL(entry.previewUrl);
}
renderAttachmentQueue(controller);
});
header.appendChild(removeButton);
item.appendChild(header);
const meta = document.createElement("div");
meta.className = "text-xs text-muted";
const sizeLabel = formatAttachmentSize(entry.size);
meta.textContent = sizeLabel
? `${entry.type || "file"} · ${sizeLabel}`
: entry.type || "file";
item.appendChild(meta);
if (entry.previewUrl && entry.type?.startsWith("image/")) {
const img = document.createElement("img");
img.src = entry.previewUrl;
img.alt = entry.name || "Attachment preview";
img.className = "h-24 w-24 rounded-lg object-cover";
img.loading = "lazy";
img.decoding = "async";
item.appendChild(img);
}
const progress = document.createElement("progress");
progress.className = "progress";
progress.value = entry.progress || 0;
progress.max = 1;
progress.dataset.variant = "surface";
item.appendChild(progress);
const status = document.createElement("div");
status.className = "text-xs text-muted";
status.textContent =
entry.status === "uploading"
? "Uploading…"
: entry.status === "error"
? entry.error || "Upload failed."
: "Ready to upload.";
item.appendChild(status);
list.appendChild(item);
});
}
export async function uploadAttachmentQueue(controller, actorPubkey) {
const r2Service = controller.services.r2Service;
if (!r2Service) {
throw new Error("Storage service unavailable.");
}
const encrypt =
controller.dmController.profileMessageAttachmentEncrypt instanceof
HTMLInputElement
? controller.dmController.profileMessageAttachmentEncrypt.checked
: false;
const payloads = [];
for (const entry of controller.dmController.dmAttachmentQueue) {
entry.status = "uploading";
entry.progress = 0;
renderAttachmentQueue(controller);
try {
const payload = await uploadAttachment({
r2Service,
pubkey: actorPubkey,
file: entry.file,
encrypt,
buildKey: buildR2Key,
buildUrl: buildPublicUrl,
onProgress: (fraction) => {
entry.progress = Number.isFinite(fraction)
? fraction
: entry.progress;
renderAttachmentQueue(controller);
},
});
payloads.push(payload);
entry.status = "uploaded";
entry.progress = 1;
} catch (error) {
entry.status = "error";
entry.error =
error && typeof error.message === "string"
? error.message
: "Attachment upload failed.";
renderAttachmentQueue(controller);
throw error;
}
}
return payloads;
}
export function resolveLatestDirectMessageForRecipient(
controller,
recipientPubkey,
actorPubkey = null,
) {
const normalizedRecipient =
typeof recipientPubkey === "string"
? controller.normalizeHexPubkey(recipientPubkey)
: "";
if (
!normalizedRecipient ||
!Array.isArray(controller.dmController.directMessagesCache)
) {
return null;
}
const resolvedActor = actorPubkey
? controller.normalizeHexPubkey(actorPubkey)
: controller.dmController.resolveActiveDmActor();
let latest = null;
let latestTimestamp = 0;
for (const entry of controller.dmController.directMessagesCache) {
if (
controller.dmController.resolveDirectMessageRemote(
entry,
resolvedActor,
) !== normalizedRecipient
) {
continue;
}
const timestamp = Number(entry?.timestamp) || 0;
if (!latest || timestamp > latestTimestamp) {
latest = entry;
latestTimestamp = timestamp;
}
}
return latest;
}
export async function maybePublishTypingIndicator(controller) {
const settings = controller.dmController.getDmPrivacySettingsSnapshot();
if (!settings.typingIndicatorsEnabled) {
return;
}
if (
!controller.services.nostrClient ||
typeof controller.services.nostrClient.publishDmTypingIndicator !==
"function"
) {
return;
}
const input = controller.dmController.profileMessageInput;
const messageText =
input instanceof HTMLTextAreaElement ? input.value.trim() : "";
if (!messageText) {
return;
}
const recipient = controller.dmController.resolveActiveDmRecipient();
if (!recipient) {
return;
}
const now = Date.now();
if (
now - controller.dmController.dmTypingLastSentAt <
TYPING_INDICATOR_COOLDOWN_MS
) {
return;
}
controller.dmController.dmTypingLastSentAt = now;
const relayHints =
controller.dmController.buildDmRecipientContext(recipient)?.relayHints ||
[];
const latestMessage = resolveLatestDirectMessageForRecipient(
controller,
recipient,
controller.dmController.resolveActiveDmActor(),
);
const latestEventId =
controller.dmController.resolveDirectMessageEventId(latestMessage);
try {
await controller.services.nostrClient.publishDmTypingIndicator({
recipientPubkey: recipient,
conversationEventId: latestEventId || null,
relays: relayHints,
expiresInSeconds: TYPING_INDICATOR_TTL_SECONDS,
});
} catch (error) {
devLogger.warn(
"[profileModal] Failed to publish typing indicator:",
error,
);
}
}
+238
View File
@@ -0,0 +1,238 @@
import { devLogger } from "../../utils/logger.js";
const noop = () => {};
export function cacheDmRelayElements(controller) {
controller.dmController.profileMessagesRelayList =
document.getElementById("profileMessagesRelayList") || null;
controller.dmController.profileMessagesRelayInput =
document.getElementById("profileMessagesRelayInput") || null;
controller.dmController.profileMessagesRelayAddButton =
document.getElementById("profileMessagesRelayAdd") || null;
controller.dmController.profileMessagesRelayPublishButton =
document.getElementById("profileMessagesRelayPublish") || null;
controller.dmController.profileMessagesRelayStatus =
document.getElementById("profileMessagesRelayStatus") || null;
}
export function bindDmRelayControls(controller) {
const bindOnce = (element, eventName, handler, key) => {
if (!(element instanceof HTMLElement)) {
return;
}
const datasetKey = key || "dmRelayBound";
if (element.dataset[datasetKey] === "true") {
return;
}
element.dataset[datasetKey] = "true";
element.addEventListener(eventName, handler);
};
bindOnce(
controller.dmController.profileMessagesRelayAddButton,
"click",
() => {
void controller.dmController.handleAddDmRelayPreference();
},
"dmRelayAddBound",
);
bindOnce(
controller.dmController.profileMessagesRelayInput,
"keydown",
(event) => {
if (event.key === "Enter") {
event.preventDefault();
void controller.dmController.handleAddDmRelayPreference();
}
},
"dmRelayInputBound",
);
bindOnce(
controller.dmController.profileMessagesRelayPublishButton,
"click",
() => {
void handlePublishDmRelayPreferences(controller);
},
"dmRelayPublishBound",
);
}
export async function refreshDmRelayPreferences(
controller,
{ force = false } = {},
) {
const owner = controller.dmController.resolveActiveDmRelayOwner();
if (!owner) {
controller.dmController.populateDmRelayPreferences();
return;
}
const existing = controller.dmController.getActiveDmRelayPreferences();
if (!existing.length || force) {
if (typeof controller.services.fetchDmRelayHints === "function") {
try {
const hints = await controller.services.fetchDmRelayHints(owner);
if (typeof controller.state.setDmRelayPreferences === "function") {
controller.state.setDmRelayPreferences(owner, hints);
}
} catch (error) {
devLogger.warn(
"[profileModal] Failed to refresh DM relay hints for profile:",
error,
);
}
}
}
controller.dmController.populateDmRelayPreferences();
}
export async function handlePublishDmRelayPreferences(controller) {
const owner = controller.dmController.resolveActiveDmRelayOwner();
if (!owner) {
controller.showError("Please sign in to publish DM relay hints.");
return;
}
const relays = controller.dmController.getActiveDmRelayPreferences();
if (!relays.length) {
controller.showError("Add at least one DM relay before publishing.");
return;
}
const callback = controller.callbacks.onPublishDmRelayPreferences;
if (!callback || callback === noop) {
controller.showError("DM relay publishing is unavailable right now.");
return;
}
controller.dmController.setDmRelayPreferencesStatus(
"Publishing DM relay hints…",
);
try {
const result = await callback({
pubkey: owner,
relays,
controller: controller,
});
if (result?.ok) {
const acceptedCount = Array.isArray(result.accepted)
? result.accepted.length
: 0;
const summary = acceptedCount
? `Published to ${acceptedCount} relay${acceptedCount === 1 ? "" : "s"}.`
: "DM relay hints published.";
controller.showSuccess("DM relay hints published.");
controller.dmController.setDmRelayPreferencesStatus(summary);
return;
}
controller.showError("Failed to publish DM relay hints.");
controller.dmController.setDmRelayPreferencesStatus(
"DM relay hints publish failed.",
);
} catch (error) {
const message =
error && typeof error.message === "string" && error.message.trim()
? error.message.trim()
: "Failed to publish DM relay hints.";
controller.showError(message);
controller.dmController.setDmRelayPreferencesStatus(message);
}
}
export function updateDmPrivacyToggleForRecipient(
controller,
recipientContext,
{ force = false } = {},
) {
if (!recipientContext) {
return;
}
const relayHints = Array.isArray(recipientContext.relayHints)
? recipientContext.relayHints
: [];
const hasHints = relayHints.length > 0;
if (!controller.dmController.dmPrivacyToggleTouched || force) {
setPrivacyToggleState(controller, hasHints);
}
}
export function setPrivacyToggleState(controller, enabled) {
if (
controller.dmController.profileMessagesPrivacyToggle instanceof
HTMLInputElement
) {
controller.dmController.profileMessagesPrivacyToggle.checked = Boolean(enabled);
}
controller.dmController.updateMessagePrivacyModeDisplay();
}
export function handleActiveDmIdentityChanged(
controller,
actorPubkey = null,
) {
const normalized = actorPubkey
? controller.normalizeHexPubkey(actorPubkey)
: controller.dmController.resolveActiveDmActor();
controller.dmController.hasShownRelayWarning = false;
controller.dmController.setDirectMessageRecipient(null, { reason: "clear" });
controller.resetAttachmentQueue({ clearInput: true });
controller.dmController.dmReadReceiptCache.clear();
controller.dmController.dmTypingLastSentAt = 0;
controller.dmController.syncDmPrivacySettingsUi();
if (
controller.dmController.directMessagesSubscription &&
controller.dmController.directMessagesSubscription.actor &&
normalized !== controller.dmController.directMessagesSubscription.actor
) {
controller.dmController.resetDirectMessageSubscription();
}
controller.dmController.directMessagesLastActor = normalized || null;
controller.dmController.directMessagesCache = [];
controller.dmController.messagesInitialLoadPending = true;
controller.dmController.pendingMessagesRender = null;
if (controller.dmController.profileMessagesList instanceof HTMLElement) {
controller.dmController.profileMessagesList.textContent = "";
controller.dmController.profileMessagesList.classList.add("hidden");
controller.dmController.profileMessagesList.setAttribute("hidden", "");
}
if (controller.dmController.profileMessagesConversation instanceof HTMLElement) {
controller.dmController.profileMessagesConversation.textContent = "";
controller.dmController.profileMessagesConversation.classList.add("hidden");
controller.dmController.profileMessagesConversation.setAttribute("hidden", "");
}
if (controller.dmController.profileMessagesConversationEmpty instanceof HTMLElement) {
controller.dmController.profileMessagesConversationEmpty.classList.remove("hidden");
controller.dmController.profileMessagesConversationEmpty.removeAttribute("hidden");
}
if (!normalized) {
controller.dmController.setMessagesLoadingState("unauthenticated");
controller.dmController.updateMessagesReloadState();
controller.dmController.populateDmRelayPreferences();
controller.dmController.setDmRelayPreferencesStatus("");
return;
}
controller.dmController.setMessagesLoadingState("loading");
void controller.dmController.ensureDirectMessageSubscription(normalized);
controller.dmController.updateMessagesReloadState();
if (controller.getActivePane() === "messages") {
void controller.dmController.populateProfileMessages({
force: true,
reason: "identity-change",
});
}
void refreshDmRelayPreferences(controller, { force: true });
}
+30 -403
View File
@@ -50,6 +50,21 @@ import { ProfileRelayController } from "./profileModal/ProfileRelayController.js
import { ProfileHashtagController } from "./profileModal/ProfileHashtagController.js";
import { ProfileAdminController } from "./profileModal/ProfileAdminController.js";
import { ProfileModerationController } from "./profileModal/ProfileModerationController.js";
import {
cacheDmRelayElements,
bindDmRelayControls,
refreshDmRelayPreferences,
handlePublishDmRelayPreferences,
updateDmPrivacyToggleForRecipient,
setPrivacyToggleState,
handleActiveDmIdentityChanged,
} from "./profileModal/ProfileDmRelayBinder.js";
import {
renderAttachmentQueue,
uploadAttachmentQueue,
resolveLatestDirectMessageForRecipient,
maybePublishTypingIndicator,
} from "./profileModal/ProfileDmAttachmentBinder.js";
const noop = () => {};
@@ -62,8 +77,6 @@ const DEFAULT_MAX_WALLET_DEFAULT_ZAP = 100000000;
const DEFAULT_SAVED_PROFILE_LABEL = "Saved profile";
const TRUSTED_MUTE_HIDE_HELPER_TEXT =
"Reaching this count hides cards (with “Show anyway”); lower signals only blur thumbnails or block autoplay.";
const TYPING_INDICATOR_TTL_SECONDS = 15;
const TYPING_INDICATOR_COOLDOWN_MS = 4000;
const DIRECT_MESSAGES_BATCH_DELAY_MS = 250;
const ADD_PROFILE_CANCELLATION_CODES = new Set([
@@ -1006,233 +1019,40 @@ export class ProfileModalController {
cacheDmRelayElements() {
this.dmController.profileMessagesRelayList =
document.getElementById("profileMessagesRelayList") || null;
this.dmController.profileMessagesRelayInput =
document.getElementById("profileMessagesRelayInput") || null;
this.dmController.profileMessagesRelayAddButton =
document.getElementById("profileMessagesRelayAdd") || null;
this.dmController.profileMessagesRelayPublishButton =
document.getElementById("profileMessagesRelayPublish") || null;
this.dmController.profileMessagesRelayStatus =
document.getElementById("profileMessagesRelayStatus") || null;
cacheDmRelayElements(this);
}
bindDmRelayControls() {
const bindOnce = (element, eventName, handler, key) => {
if (!(element instanceof HTMLElement)) {
return;
}
const datasetKey = key || "dmRelayBound";
if (element.dataset[datasetKey] === "true") {
return;
}
element.dataset[datasetKey] = "true";
element.addEventListener(eventName, handler);
};
bindOnce(
this.dmController.profileMessagesRelayAddButton,
"click",
() => {
void this.dmController.handleAddDmRelayPreference();
},
"dmRelayAddBound",
);
bindOnce(
this.dmController.profileMessagesRelayInput,
"keydown",
(event) => {
if (event.key === "Enter") {
event.preventDefault();
void this.dmController.handleAddDmRelayPreference();
}
},
"dmRelayInputBound",
);
bindOnce(
this.dmController.profileMessagesRelayPublishButton,
"click",
() => {
void this.handlePublishDmRelayPreferences();
},
"dmRelayPublishBound",
);
bindDmRelayControls(this);
}
async refreshDmRelayPreferences({ force = false } = {}) {
const owner = this.dmController.resolveActiveDmRelayOwner();
if (!owner) {
this.dmController.populateDmRelayPreferences();
return;
}
const existing = this.dmController.getActiveDmRelayPreferences();
if (!existing.length || force) {
if (typeof this.services.fetchDmRelayHints === "function") {
try {
const hints = await this.services.fetchDmRelayHints(owner);
if (typeof this.state.setDmRelayPreferences === "function") {
this.state.setDmRelayPreferences(owner, hints);
}
} catch (error) {
devLogger.warn(
"[profileModal] Failed to refresh DM relay hints for profile:",
error,
);
}
}
}
this.dmController.populateDmRelayPreferences();
async refreshDmRelayPreferences(options) {
return refreshDmRelayPreferences(this, options);
}
async handlePublishDmRelayPreferences() {
const owner = this.dmController.resolveActiveDmRelayOwner();
if (!owner) {
this.showError("Please sign in to publish DM relay hints.");
return;
}
const relays = this.dmController.getActiveDmRelayPreferences();
if (!relays.length) {
this.showError("Add at least one DM relay before publishing.");
return;
}
const callback = this.callbacks.onPublishDmRelayPreferences;
if (!callback || callback === noop) {
this.showError("DM relay publishing is unavailable right now.");
return;
}
this.dmController.setDmRelayPreferencesStatus("Publishing DM relay hints…");
try {
const result = await callback({
pubkey: owner,
relays,
controller: this,
});
if (result?.ok) {
const acceptedCount = Array.isArray(result.accepted)
? result.accepted.length
: 0;
const summary = acceptedCount
? `Published to ${acceptedCount} relay${acceptedCount === 1 ? "" : "s"}.`
: "DM relay hints published.";
this.showSuccess("DM relay hints published.");
this.dmController.setDmRelayPreferencesStatus(summary);
return;
}
this.showError("Failed to publish DM relay hints.");
this.dmController.setDmRelayPreferencesStatus("DM relay hints publish failed.");
} catch (error) {
const message =
error && typeof error.message === "string" && error.message.trim()
? error.message.trim()
: "Failed to publish DM relay hints.";
this.showError(message);
this.dmController.setDmRelayPreferencesStatus(message);
}
return handlePublishDmRelayPreferences(this);
}
updateDmPrivacyToggleForRecipient(recipientContext, { force = false } = {}) {
if (!recipientContext) {
return;
}
const relayHints = Array.isArray(recipientContext.relayHints)
? recipientContext.relayHints
: [];
const hasHints = relayHints.length > 0;
if (!this.dmController.dmPrivacyToggleTouched || force) {
this.setPrivacyToggleState(hasHints);
}
updateDmPrivacyToggleForRecipient(recipientContext, options) {
updateDmPrivacyToggleForRecipient(this, recipientContext, options);
}
setPrivacyToggleState(enabled) {
if (this.dmController.profileMessagesPrivacyToggle instanceof HTMLInputElement) {
this.dmController.profileMessagesPrivacyToggle.checked = Boolean(enabled);
}
this.dmController.updateMessagePrivacyModeDisplay();
setPrivacyToggleState(this, enabled);
}
handleActiveDmIdentityChanged(actorPubkey = null) {
const normalized = actorPubkey
? this.normalizeHexPubkey(actorPubkey)
: this.dmController.resolveActiveDmActor();
this.dmController.hasShownRelayWarning = false;
this.dmController.setDirectMessageRecipient(null, { reason: "clear" });
this.resetAttachmentQueue({ clearInput: true });
this.dmController.dmReadReceiptCache.clear();
this.dmController.dmTypingLastSentAt = 0;
this.dmController.syncDmPrivacySettingsUi();
if (
this.dmController.directMessagesSubscription &&
this.dmController.directMessagesSubscription.actor &&
normalized !== this.dmController.directMessagesSubscription.actor
) {
this.dmController.resetDirectMessageSubscription();
}
this.dmController.directMessagesLastActor = normalized || null;
this.dmController.directMessagesCache = [];
this.dmController.messagesInitialLoadPending = true;
this.dmController.pendingMessagesRender = null;
if (this.dmController.profileMessagesList instanceof HTMLElement) {
this.dmController.profileMessagesList.textContent = "";
this.dmController.profileMessagesList.classList.add("hidden");
this.dmController.profileMessagesList.setAttribute("hidden", "");
}
if (this.dmController.profileMessagesConversation instanceof HTMLElement) {
this.dmController.profileMessagesConversation.textContent = "";
this.dmController.profileMessagesConversation.classList.add("hidden");
this.dmController.profileMessagesConversation.setAttribute("hidden", "");
}
if (this.dmController.profileMessagesConversationEmpty instanceof HTMLElement) {
this.dmController.profileMessagesConversationEmpty.classList.remove("hidden");
this.dmController.profileMessagesConversationEmpty.removeAttribute("hidden");
}
if (!normalized) {
this.dmController.setMessagesLoadingState("unauthenticated");
this.dmController.updateMessagesReloadState();
this.dmController.populateDmRelayPreferences();
this.dmController.setDmRelayPreferencesStatus("");
return;
}
this.dmController.setMessagesLoadingState("loading");
void this.dmController.ensureDirectMessageSubscription(normalized);
this.dmController.updateMessagesReloadState();
if (this.getActivePane() === "messages") {
void this.dmController.populateProfileMessages({
force: true,
reason: "identity-change",
});
}
void this.refreshDmRelayPreferences({ force: true });
handleActiveDmIdentityChanged(actorPubkey) {
handleActiveDmIdentityChanged(this, actorPubkey);
}
@@ -1290,216 +1110,23 @@ export class ProfileModalController {
renderAttachmentQueue() {
const list = this.dmController.profileMessageAttachmentList;
if (!(list instanceof HTMLElement)) {
return;
}
list.textContent = "";
if (!this.dmController.dmAttachmentQueue.length) {
return;
}
this.dmController.dmAttachmentQueue.forEach((entry) => {
const item = document.createElement("div");
item.className = "card flex flex-col gap-2 p-3";
item.dataset.attachmentId = entry.id;
const header = document.createElement("div");
header.className = "flex items-center justify-between gap-2";
const title = document.createElement("div");
title.className = "text-sm font-semibold text-text";
title.textContent = entry.name || "Attachment";
header.appendChild(title);
const removeButton = document.createElement("button");
removeButton.type = "button";
removeButton.className = "btn-ghost focus-ring inline-flex items-center";
removeButton.dataset.size = "sm";
removeButton.textContent = "Remove";
removeButton.addEventListener("click", () => {
this.dmController.dmAttachmentQueue = this.dmController.dmAttachmentQueue.filter(
(queued) => queued.id !== entry.id,
);
if (entry.previewUrl && typeof URL !== "undefined") {
URL.revokeObjectURL(entry.previewUrl);
}
this.renderAttachmentQueue();
});
header.appendChild(removeButton);
item.appendChild(header);
const meta = document.createElement("div");
meta.className = "text-xs text-muted";
const sizeLabel = formatAttachmentSize(entry.size);
meta.textContent = sizeLabel
? `${entry.type || "file"} · ${sizeLabel}`
: entry.type || "file";
item.appendChild(meta);
if (entry.previewUrl && entry.type?.startsWith("image/")) {
const img = document.createElement("img");
img.src = entry.previewUrl;
img.alt = entry.name || "Attachment preview";
img.className = "h-24 w-24 rounded-lg object-cover";
img.loading = "lazy";
img.decoding = "async";
item.appendChild(img);
}
const progress = document.createElement("progress");
progress.className = "progress";
progress.value = entry.progress || 0;
progress.max = 1;
progress.dataset.variant = "surface";
item.appendChild(progress);
const status = document.createElement("div");
status.className = "text-xs text-muted";
status.textContent =
entry.status === "uploading"
? "Uploading…"
: entry.status === "error"
? entry.error || "Upload failed."
: "Ready to upload.";
item.appendChild(status);
list.appendChild(item);
});
renderAttachmentQueue(this);
}
async uploadAttachmentQueue(actorPubkey) {
const r2Service = this.services.r2Service;
if (!r2Service) {
throw new Error("Storage service unavailable.");
}
const encrypt =
this.dmController.profileMessageAttachmentEncrypt instanceof HTMLInputElement
? this.dmController.profileMessageAttachmentEncrypt.checked
: false;
const payloads = [];
for (const entry of this.dmController.dmAttachmentQueue) {
entry.status = "uploading";
entry.progress = 0;
this.renderAttachmentQueue();
try {
const payload = await uploadAttachment({
r2Service,
pubkey: actorPubkey,
file: entry.file,
encrypt,
buildKey: buildR2Key,
buildUrl: buildPublicUrl,
onProgress: (fraction) => {
entry.progress = Number.isFinite(fraction) ? fraction : entry.progress;
this.renderAttachmentQueue();
},
});
payloads.push(payload);
entry.status = "uploaded";
entry.progress = 1;
} catch (error) {
entry.status = "error";
entry.error =
error && typeof error.message === "string"
? error.message
: "Attachment upload failed.";
this.renderAttachmentQueue();
throw error;
}
}
return payloads;
return uploadAttachmentQueue(this, actorPubkey);
}
resolveLatestDirectMessageForRecipient(recipientPubkey, actorPubkey = null) {
const normalizedRecipient =
typeof recipientPubkey === "string"
? this.normalizeHexPubkey(recipientPubkey)
: "";
if (!normalizedRecipient || !Array.isArray(this.dmController.directMessagesCache)) {
return null;
}
const resolvedActor = actorPubkey
? this.normalizeHexPubkey(actorPubkey)
: this.dmController.resolveActiveDmActor();
let latest = null;
let latestTimestamp = 0;
for (const entry of this.dmController.directMessagesCache) {
if (this.dmController.resolveDirectMessageRemote(entry, resolvedActor) !== normalizedRecipient) {
continue;
}
const timestamp = Number(entry?.timestamp) || 0;
if (!latest || timestamp > latestTimestamp) {
latest = entry;
latestTimestamp = timestamp;
}
}
return latest;
resolveLatestDirectMessageForRecipient(recipientPubkey, actorPubkey) {
return resolveLatestDirectMessageForRecipient(this, recipientPubkey, actorPubkey);
}
async maybePublishTypingIndicator() {
const settings = this.dmController.getDmPrivacySettingsSnapshot();
if (!settings.typingIndicatorsEnabled) {
return;
}
if (
!this.services.nostrClient ||
typeof this.services.nostrClient.publishDmTypingIndicator !== "function"
) {
return;
}
const input = this.dmController.profileMessageInput;
const messageText =
input instanceof HTMLTextAreaElement ? input.value.trim() : "";
if (!messageText) {
return;
}
const recipient = this.dmController.resolveActiveDmRecipient();
if (!recipient) {
return;
}
const now = Date.now();
if (now - this.dmController.dmTypingLastSentAt < TYPING_INDICATOR_COOLDOWN_MS) {
return;
}
this.dmController.dmTypingLastSentAt = now;
const relayHints = this.dmController.buildDmRecipientContext(recipient)?.relayHints || [];
const latestMessage = this.resolveLatestDirectMessageForRecipient(
recipient,
this.dmController.resolveActiveDmActor(),
);
const latestEventId = this.dmController.resolveDirectMessageEventId(latestMessage);
try {
await this.services.nostrClient.publishDmTypingIndicator({
recipientPubkey: recipient,
conversationEventId: latestEventId || null,
relays: relayHints,
expiresInSeconds: TYPING_INDICATOR_TTL_SECONDS,
});
} catch (error) {
devLogger.warn("[profileModal] Failed to publish typing indicator:", error);
}
return maybePublishTypingIndicator(this);
}
+1 -1
View File
@@ -26,7 +26,7 @@ const GROWTH_MARGIN = 50; // lines — allowlisted files may grow by this much f
// These files are known to be oversized and are decomposition targets.
// They must NOT grow beyond (recorded + GROWTH_MARGIN).
const GRANDFATHERED = {
"js/ui/profileModalController.js": 8130,
"js/ui/profileModalController.js": 5884,
"js/ui/components/VideoModal.js": 6045,
"js/app.js": 5602,
"js/channelProfile.js": 5529,
+33
View File
@@ -0,0 +1,33 @@
# Test Log
## Environment
- Date: 2026-02-18
- Agent: decompose-agent
- File: js/ui/profileModalController.js
## Commands
1. **Lint**
```bash
npm run lint
```
**Result:** Passed (with expected asset warnings in dev).
2. **Unit Tests**
```bash
node scripts/run-unit-tests.mjs tests/profile-modal-controller.test.mjs
```
**Result:** Passed (18 tests).
3. **File Size Check**
```bash
node scripts/check-file-size.mjs --report
node scripts/check-file-size.mjs --update
```
**Result:**
- Reduced `js/ui/profileModalController.js` from 6258 to 5884 lines.
- Updated baseline in `scripts/check-file-size.mjs`.
## Manual QA
- Verified imports in `js/ui/profileModalController.js`.
- Verified logic in new binder files.
- Verified no circular dependencies (binders only import utils, controller passes `this`).
+4
View File
@@ -0,0 +1,4 @@
- [ ] Extract DM Relay UI Logic to `js/ui/profileModal/ProfileDmRelayBinder.js`
- [ ] Extract DM Attachment Logic to `js/ui/profileModal/ProfileDmAttachmentBinder.js`
- [ ] Update `js/ui/profileModalController.js` to import and use the new binders
- [ ] Verify lint and tests