mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
[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:
1 parent
5e8b6b0141
commit
d22785bb87
9 files changed
+605
-413
No files matched your search
@@ -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
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`).
|
||||
@@ -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
|
||||
Reference in new issue
Block a user