friendica/view/theme/frio/js/compose.js
“Raroun” d6067d3466 Modernize compose.js and modal.js with improved architecture and bug fixes
compose.js:
- Add draft autosave to sessionStorage (body, location, scheduled_at)
- Restore draft when returning to compose page
- Clear form after successful post submission
- Fix geolocation operator precedence bug
- Fix button staying disabled after geolocation error
- Add sessionStorage availability check for private browsing
- Wrap in IIFE with 'use strict'
- Add JSDoc documentation
- Use namespaced events (.compose)

modal.js:
- Fix critical double event registration bug in editpost()
- Add isJotResetBound flag to ensure single handler binding
- Add isEditJotClosing guard for race condition protection
- Wrap in IIFE with 'use strict'
- Add feature detection for all external dependencies
- Use namespaced events (.frio, .frio-edit)
- Add JSDoc documentation
2026-02-27 13:23:03 +01:00

376 lines
9.7 KiB
JavaScript

// SPDX-FileCopyrightText: 2010-2024 the Friendica project
//
// SPDX-License-Identifier: AGPL-3.0-or-later
// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPLv3-or-later
/**
* @file view/theme/frio/js/compose.js
* JavaScript for the compose page (/compose).
*
* Handles:
* - Link preview attachment
* - Character counter
* - ACL autocomplete (@-mentions)
* - BBCode autocomplete
* - Location button with geolocation support
*
* @requires jQuery
* @requires linkPreview.js
* @requires autocomplete.js
*/
(function ($, window, document) {
"use strict";
// DOM element references (cached)
let $textarea = null;
let locationButton = null;
let locationInput = null;
/**
* Initialize the compose page functionality.
*/
function init() {
initTextarea();
initLocation();
initFormReset();
}
/**
* Handle form reset after successful submission and draft saving.
* Clears the form when a post was successfully submitted to prevent
* showing old content on the next visit to /compose.
* Also saves/restores scheduling settings as draft.
*/
function initFormReset() {
const $form = $("form.comment-edit-form");
// Check if sessionStorage is available and if a post was submitted
// This flag is set in the submit handler below
if (isStorageAvailable() && sessionStorage.getItem("compose_post_submitted") === "true") {
// Clear the flag
sessionStorage.removeItem("compose_post_submitted");
// Clear saved draft data
sessionStorage.removeItem("compose_draft");
// Clear the form after a short delay to let browser autofill complete
setTimeout(function () {
if ($textarea && $textarea.length) {
$textarea.val("");
updateCharacterCounter(0);
}
// Also clear location if set
if (locationInput) {
locationInput.value = "";
updateLocationButtonDisplay(locationButton, locationInput);
}
// Clear any link preview
if (typeof window.linkPreview === "object" && window.linkPreview !== null) {
window.linkPreview.destroy();
window.linkPreview = null;
}
// Reset scheduled time
$('[name="scheduled_at"]').val("");
}, 50);
} else {
// No submission - try to restore draft if available
restoreDraft();
}
// Save draft when form values change (but not on submit)
$form.on("change.compose-draft input.compose-draft", function (e) {
// Don't save if this is triggered by programmatic changes
if (e.isTrigger) {
return;
}
saveDraft();
});
// Listen for form submission
$form.on("submit.compose", function () {
// Mark that the form was submitted
// This flag will be checked when the page loads next time
if (isStorageAvailable()) {
sessionStorage.setItem("compose_post_submitted", "true");
}
});
}
/**
* Check if sessionStorage is available.
* @returns {boolean}
*/
function isStorageAvailable() {
try {
const test = "__test__";
sessionStorage.setItem(test, test);
sessionStorage.removeItem(test);
return true;
} catch (e) {
return false;
}
}
/**
* Save current form state as draft to sessionStorage.
*/
function saveDraft() {
if (!$textarea || !$textarea.length || !isStorageAvailable()) {
return;
}
const draft = {
body: $textarea.val(),
location: locationInput ? locationInput.value : "",
scheduled_at: $('[name="scheduled_at"]').val(),
};
try {
sessionStorage.setItem("compose_draft", JSON.stringify(draft));
} catch (e) {
// Ignore storage errors (e.g., quota exceeded, private mode)
}
}
/**
* Restore form state from sessionStorage draft.
*/
function restoreDraft() {
if (!isStorageAvailable()) {
return;
}
try {
const draftJson = sessionStorage.getItem("compose_draft");
if (!draftJson) {
return;
}
const draft = JSON.parse(draftJson);
// Restore values after a short delay to override browser autofill
setTimeout(function () {
if (typeof draft.body !== "undefined" && $textarea && $textarea.length) {
$textarea.val(draft.body);
updateCharacterCounter(draft.body.length);
}
// Restore location (check for undefined, not falsy, so empty string works)
if (typeof draft.location !== "undefined" && locationInput) {
locationInput.value = draft.location;
updateLocationButtonDisplay(locationButton, locationInput);
// Trigger change event so other scripts can react
$(locationInput).trigger("change");
}
// Restore scheduled time (check for undefined, not falsy)
const $scheduledAt = $('[name="scheduled_at"]');
if (typeof draft.scheduled_at !== "undefined" && $scheduledAt.length) {
$scheduledAt.val(draft.scheduled_at);
// Trigger change event for datepicker plugins
$scheduledAt.trigger("change");
}
}, 100);
} catch (e) {
// Ignore parse errors
sessionStorage.removeItem("compose_draft");
}
}
/**
* Initialize textarea features: link preview, autocomplete, character counter.
*/
function initTextarea() {
$textarea = $("textarea[name=body]");
if (!$textarea.length) {
return;
}
// Initialize link preview plugin
if (typeof $.fn.linkPreview === "function") {
$textarea.linkPreview();
}
// Initialize ACL autocomplete (@-mentions)
if (typeof $.fn.editor_autocomplete === "function" && typeof baseurl !== "undefined") {
$textarea.editor_autocomplete(baseurl + "/search/acl");
}
// Initialize BBCode autocomplete
if (typeof $.fn.bbco_autocomplete === "function") {
$textarea.bbco_autocomplete("bbcode");
}
// Character counter - use input event to catch all changes (paste, cut, etc.)
$textarea.on("input.compose", function () {
updateCharacterCounter($(this).val().length);
});
// Initial count
updateCharacterCounter($textarea.val().length);
}
/**
* Update the character counter display.
*
* @param {number} count - The character count
*/
function updateCharacterCounter(count) {
$("#character-counter").text(count);
}
/**
* Initialize location button functionality.
*/
function initLocation() {
locationButton = document.getElementById("profile-location");
locationInput = document.getElementById("jot-location");
if (!locationButton || !locationInput) {
return;
}
// Set initial button state
updateLocationButtonDisplay(locationButton, locationInput);
// Bind to both input and change events for robustness
$(locationInput)
.on("input.compose change.compose", function () {
updateLocationButtonDisplay(locationButton, locationInput);
});
// Location button click handler
$(locationButton).on("click.compose", function () {
handleLocationButtonClick();
});
}
/**
* Handle the location button click.
*/
function handleLocationButtonClick() {
if (!locationButton || !locationInput) {
return;
}
// If location is already set, clear it
if (locationInput.value) {
locationInput.value = "";
updateLocationButtonDisplay(locationButton, locationInput);
// Trigger change event to save draft
$(locationInput).trigger("change");
return;
}
// Otherwise, try to get geolocation
if (!("geolocation" in navigator)) {
// Geolocation not supported - button should already be disabled
return;
}
// Temporarily disable button while getting position
locationButton.disabled = true;
navigator.geolocation.getCurrentPosition(
handleGeolocationSuccess,
handleGeolocationError,
{
enableHighAccuracy: false,
timeout: 10000,
maximumAge: 600000, // 10 minutes
}
);
}
/**
* Handle successful geolocation retrieval.
*
* @param {GeolocationPosition} position
*/
function handleGeolocationSuccess(position) {
if (!locationInput || !locationButton) {
return;
}
const coords = position.coords;
locationInput.value = coords.latitude + ", " + coords.longitude;
locationButton.disabled = false;
updateLocationButtonDisplay(locationButton, locationInput);
// Trigger change event to save draft
$(locationInput).trigger("change");
}
/**
* Handle geolocation error.
*
* @param {GeolocationPositionError} error
*/
function handleGeolocationError(error) {
if (!locationButton) {
return;
}
// Re-enable button so user can try again (unless geolocation is unsupported)
if ("geolocation" in navigator) {
locationButton.disabled = false;
}
updateLocationButtonDisplay(locationButton, locationInput);
// Log error for debugging (non-critical)
if (typeof console !== "undefined" && console.warn) {
console.warn("Geolocation error:", error.message);
}
}
/**
* Update the location button display based on current state.
*
* @param {HTMLButtonElement} button - The location button element
* @param {HTMLInputElement} input - The location input element
*/
function updateLocationButtonDisplay(button, input) {
if (!button || !input) {
return;
}
const hasValue = !!input.value;
const hasGeolocation = "geolocation" in navigator;
// Remove primary class first (will be re-added if needed)
button.classList.remove("btn-primary");
if (hasValue) {
// Location is set - button clears it
button.disabled = false;
button.classList.add("btn-primary");
button.title = button.dataset.titleClear || "Clear location";
} else if (!hasGeolocation) {
// Geolocation not supported
button.disabled = true;
button.title = button.dataset.titleUnavailable || "Geolocation not available";
} else if (button.disabled) {
// Geolocation supported but button disabled (error state)
button.title = button.dataset.titleDisabled || "Location unavailable";
} else {
// Ready to get location
button.title = button.dataset.titleSet || "Set location";
}
}
// Initialize on DOM ready
$(function () {
init();
});
// Expose public API
window.updateLocationButtonDisplay = updateLocationButtonDisplay;
})(jQuery, window, document);
// @license-end