/** Code for the forum family of pages. Requires fossil.X where X is (copybutton, pikchr, confirmer, attach, tabs, storage). */ (function(F/*the fossil object*/){ "use strict"; /* JS code for /forumpost and friends. Requires fossil.dom and can optionally use fossil.pikchr. */ const P = F.page, D = F.dom; let idCounter = 0; /* The problem: when previewing the bottom-most post of a thread, the preview widget's size changes cause the page to scroll unpredictably as the bottom boundary of the page moves. A weird workaround (not invented here) is to add dummy blank padding to the page to allow the preview widget to grow and shrink without (usually) scrolling, but whether it does so really depends on its size. We could maybe get the same effect by adding this size as padding-bottom to document.body instead of as a new element. */ const dummyPadding = D.div(); dummyPadding.style.height = '75em'; /* Keep track of ForumPostEditor instances so we can remove this padding when none are active. */ dummyPadding.refs = new Set(); F.dummyPadding = dummyPadding /* only for debugging */; /** A forum post editor widget for new posts and responses. */ class ForumPostEditor { /* Options */ #opt; /* Dom elements */ #e; /* F.Attacher instance */ #att; /* Is waiting on a pending remote response. */ #isWaiting = false; /* F.TabManager */ #tabs; /* Elements to disable while an XHR is pending. */ #toDisable = []; /* DOM element of the current active tab. */ #activeTab; /* Extra input[type=hidden] fields imported from fossil's static page generation. */ #extraFields; /* Persistent draft message object. */ #draft; /** Options: opt.draftKey[string=undefined]: if set then this object's state will be stored in fossil.storage when the relevant input fields lose focus. If old state is found, the form is pre-populated from it. The state is cleared on a discard() or successful submit. opt.ondiscard[=function]: if set, a Discard button is added which, when activated, clears the current draft and removes this object's widget from the DOM. After doing so, opt.ondiscard() is called and passed this object. Exceptions thrown by ondiscard() are ignored but may be logged. opt.onsubmit[=function]: if set, this function is called immediately after the post has been successfully saved, and passed this object and a JSON-format response object from the save request. It is generally then up to the caller to close() this object and/or redirect to /forumpost/${arguments[1].uuid}. opt.onclose[=function]: like opt.onsubmit, this function is called when this.close() is called, and passed no arguments. onclose() is called before the widget is removed from the dom and _does not_ fire if it is not in the DOM. opt.hiddenFields: an optional list of input elements to incorporate into the form for requests which request the preview or save the post. opt.inReplyTo=uuid: if this is a response to a post, this is the full forum post uuid of the being-replied-to post. opt.edit=artifactObject: if this is an edit of an existing post, this is the full JSON-format artifact of the forum post the being-edited post, as returned by /ajax/artifact.json. opt.status: optional current status tag value for opt.edit, if known. This is used for pre-selecting a status value. opt.hideStash[bool=false]: if true, the "Stash" button does not get added. Intended for use with /forumnew. */ constructor(opt){ opt = this.#opt = F.nu({ draftKey: undefined, hideStash: false }, opt); opt.isNewThread = !opt.inReplyTo && !opt.edit; if( opt.draftKey ){ this.#draft = F.nu(F.storage.getJSON(opt.draftKey, {})); } const e = this.#e = F.nu({ mimetype: F.nu(), button: F.nu() }); //console.debug("Setting up FPE opt =",opt); const wrapper = e.widget = D.addClass(D.div(), 'ForumPostEditor'); D.clearElement(wrapper); if( !opt.inReplyTo ){ /* Title... */ e.titleBar = D.addClass(D.div(),'titlebar'); e.title = D.attr( D.addClass(D.input('text'), 'title'), 'placeholder', 'Thread title (required)' ); e.title.setAttribute('maxlength', 125); e.titleBar.append( D.append(D.span(), "Title:"), e.title ); if( this.#draft ){ e.title.addEventListener('blur', ()=>{ this.#draft.title = e.title.value; this.#storeDraft(); }); e.title.value = this.#draft.title || opt.edit?.H || ''; }else if( opt.edit?.H ){ e.title.value = opt.edit.H; } wrapper.append(e.titleBar); } { /* Mimetype... */ e.mimetype.wrapper = D.addClass(D.div(), 'mimetype-wrapper'); const sel = e.mimetype.select = D.addClass(D.select(), 'mimetype-select'); sel.setAttribute('title', 'Markup format for this post.'); this.#toDisable.push(sel); let i = 0; D.option(sel, '', '- Markup format -').disabled = true; for(const [k,v] of Object.entries({ 'text/x-markdown': 'Markdown', 'text/x-fossil-wiki': 'Fossil Wiki', 'text/plain': 'Plain text' })) { D.option(sel, k, v); } sel.value = opt.mimetype || this.#draft?.mimetype || F.storage.get('forum-mimetype', sel.options[1].value); sel.addEventListener('change',ev=>{ if( this.#draft && this.#draft.mimetype!==ev.target.value ){ this.#draft.mimetype = ev.target.value; this.#storeDraft(); } F.storage.set('forum-mimetype', ev.target.value); }); e.mimetype.wrapper.append(sel); } e.buttons = D.addClass(D.div(), 'buttons'); { /* Preview/submit buttons... */ e.button.preview = D.attr( D.button("Preview", e=>this.#preview()), 'title', 'Preview your edits.' ); e.button.submit = D.attr( D.button("Post as "+F.user.name), 'title', 'Save any edits to the server. Not permitted until Preview has been used.' ); if( this.#draft && !opt.hideStash ){ e.button.stash = D.attr( D.button( "Stash", e=>this.close() /* This could be called Close, but that would semantically collide with the Close [this post] button. All "Stash" does is close the widget. */ ), 'title', "Close this editor and stash any edits locally." ); } if( opt.ondiscard instanceof Function ){ e.button.discard = D.attr( D.button('Discard'), 'title', 'Close the editor and discard all local edits.' ); } if( 1 ){ F.confirmer(e.button.submit, { confirmText: "Confirm submit...", onconfirm: ()=>this.#submit() }); if( e.button.discard ){ F.confirmer(e.button.discard, { confirmText: "Really discard?", onconfirm: ()=>this.discard() }); } }else{ e.button.submit.addEventListener('click', ()=>this.#submit()); if( e.button.discard ){ e.button.submit.addEventListener('click', ()=>this.discard()); } } e.button.submit.setAttribute('disabled', ''); wrapper.append(e.buttons); e.error = D.addClass(D.div(), 'error', 'hidden'); wrapper.append(e.error); e.error.addEventListener('dblclick',()=>this.reportError()); } const idPrefix = 'FormPostEditor'+(++idCounter)/* TabManager requires IDs */; { /* Main tabs... */ e.tabs = D.attr( D.addClass(D.div(), 'tab-container'), 'id', idPrefix+'-tabs' ); this.#tabs = new F.TabManager(e.tabs); this.#tabs.addEventListener('before-switch-to', (ev)=>{ //console.debug("Switching to tab",ev.detail); switch( (this.#activeTab = ev.detail) ){ case e.preview: this.#e.button.preview.click(); break; case e.help: if( e.help.$needsInit ){ delete e.help.$needsInit; this.#initHelpTab(); } break; case e.tabAttach: if( !this.#att ) this.#initAttacherTab(); break; } }); wrapper.append( e.tabs ); e.tabEdit = D.div(); e.tabEdit.classList.add('editor-wrapper'); e.editor = D.attr( D.addClass(D.textarea(), 'editor'), 'placeholder', 'Your message to other forum-goers...' ); if( opt.inReplyTo && !F.user.isIndividual ){ const w = D.addClass(D.div(), 'warning'); w.append( "You are not ", D.a(F.repoUrl( 'login' /*, { // Leads to a confusing 'you need more permissions // to view /forumpost/.... warning g: 'forumpost/'+(opt.inReplyTo || opt.edit?.uuid) }*/ ), 'logged in'), ' so will be posting anonymously. ', "If you log in then return here, your draft edit will ", "still be here." ); e.tabEdit.append(w); } e.tabEdit.append(e.editor); e.tabEdit.dataset.tabLabel = (opt.edit || !opt.inReplyTo) ? 'Edit' : 'Reply'; this.#tabs.addTab( e.tabEdit ); this.#tabs.switchToTab( e.tabEdit ); if( this.#draft ){ this.editorContent = this.#draft.content || opt.edit?.W || ''; e.editor.addEventListener( 'blur', ()=>{ this.#draft.content = this.editorContent; this.#storeDraft(); } ); }else if( opt.edit?.W ){ this.editorContent = opt.artifact.W; } e.preview = D.addClass(D.div(), 'preview'); e.preview.dataset.tabLabel = 'Preview'; this.#toDisable.push(e.button.preview); this.#tabs.addTab( e.preview ); } if( F.user.enableDebug ){ e.debug = D.addClass(D.div(), 'debug'); e.debug.dataset.tabLabel = 'Debug'; e.debug.setAttribute('id', idPrefix+'-debug'); for(const [k,v] of Object.entries({ dryrun: 'Dry run', domod: 'Require moderation approval', //showqp: 'Show query parameters', fpsilent: 'Do not send notification emails' })){ const lbl = D.label(false, v); lbl.prepend(D.checkbox(k)); e.debug.append(lbl); } this.#tabs.addTab(e.debug); } e.buttons.append(e.mimetype.wrapper); if( opt.edit && !opt.inReplyTo && F.config.forumStatuses?.length>0 ){ const sel = e.status = D.select(); sel.setAttribute('title', 'The status tag value for this post.'); D.option(sel, "", "- Status -").disabled = true; for( const status of F.config.forumStatuses ){ D.option(sel, status.value, status.label); } e.buttons.append(sel); if( opt.status ){ sel.value = opt.status; }else if( this.#draft ){ if( this.#draft.status ){ sel.value = this.#draft.status; }else{ this.#draft.status = sel.value = F.config.forumStatuses[0].value; } sel.addEventListener('change',ev=>{ const v = sel.value; if( this.#draft.status !== v ){ this.#draft.status = v; this.#storeDraft(); } }); } }/*e.status*/ if( F.user.mayAttachForum ){ //e.buttons.append( e.button.addAttach = this.#att.takeAddButton() ); e.tabAttach = D.div(); e.tabAttach.setAttribute('id', idPrefix+'-attach'); e.tabAttach.dataset.tabLabel = 'Attachments'; this.#tabs.addTab(e.tabAttach); /* Reminder: we don't currently have a way to disable/enable an Attacher's controls during ajax traffic. */ } e.buttons.append(e.button.preview, e.button.submit); if( e.button.stash ){ e.buttons.append(e.button.stash); this.#toDisable.push(e.button.stash); } if( e.button.discard ){ e.buttons.append(e.button.discard); this.#toDisable.push(e.button.discard); } e.help = D.attr(D.div(), 'id', idPrefix+'-help'); e.help.$needsInit = true; e.help.dataset.tabLabel = 'Help'; this.#tabs.addTab(e.help); if( opt.hiddenFields ){ this.addHiddenFields( opt.hiddenFields ); delete opt.hiddenFields; } { /* Shift-enter pieces... */ const eCb = D.checkbox(1); const eLbl = D.label(); const eHelp = D.append( D.span(), [ 'When checked, shift-enter will toggle between preview ', 'and edit modes, which is generally useful but some ', 'software keyboards misinteract with it. If the preview ', 'starts when tapping Enter, turn this setting off.' ].join('') ); eCb.checked = F.storage.getBool( 'edit-shift-enter-preview', true /* Maintenance reminder: this setting is shared across several apps, like /chat, /wikiedit, and /fileedit. */ ); eCb.addEventListener('change', (ev)=>{ F.storage.set('edit-shift-enter-preview', eCb.checked); }); F.helpButtonlets.setup(eHelp); eLbl.append("Shift-enter toggles preview?", eCb, eHelp); e.tabEdit.append(eLbl); const isShiftEnter = (ev)=>eCb.checked && ev.shiftKey && 13===ev.keyCode; e.editor.addEventListener('keydown',(ev)=>{ /** If eCb.checked is true, a keyboard combo of shift-enter (from the editor) toggles between preview and edit modes. This is normally desired but at least one software keyboard is known to misinteract with this, treating an Enter after automatically-capitalized letters as a shift-enter: https://fossil-scm.org/forum/forumpost/dbd5b68366147ce8 */ if(!isShiftEnter(ev)) return; ev.preventDefault(); ev.stopPropagation(); e.editor.blur(/*force draft update if needed*/); this.#tabs.switchToTab(e.preview); }, false); // If we're in the preview tab, have ctrl-enter switch back to the editor. document.body.addEventListener('keydown',(ev)=>{ if(!isShiftEnter(ev)) return; if(this.#activeTab !== e.tabEdit){ ev.preventDefault(); ev.stopPropagation(); this.#tabs.switchToTab(e.tabEdit); e.editor.focus(/*slow as molasses for long docs, as focus() forces a document reflow. */); return false; } }, true); }/*shift-enter preview bits*/ if(0){ /* Needs to be optional */ const elemsToToggle = document.body.querySelectorAll( ':scope > header, :scope > nav' ); e.button.toggleHeader = D.button('Toggle header', e=>{ for(const et of elemsToToggle){ et.classList.toggle('hidden'); } }); e.buttons.append(e.button.toggleHeader); } if(0){ // Tentatively replaced with the user name in the Submit button const eLbl = D.label(false, "Posting as "+F.user.name) eLbl.classList.add('logged-in-as'); e.buttons.append(eLbl); } }/*constructor*/ /* ** Removes this object from the DOM. It has no side effects if ** it's not in the DOM. */ close(){ const e = this.#e.widget; if( e?.parentNode ){ if( this.#opt.onclose instanceof Function ){ try{this.#opt.onclose();} catch(e){ console.error("ForumPostEditor.onclose() threw:",e); } } //console.debug("FPE discarding", this); e.classList.add('animate-exit'); e.addEventListener('animationend', ()=>e.remove(), {once: true}); dummyPadding.refs.delete(this); if( 0===dummyPadding.refs.size ){ dummyPadding.remove(); } } } /* ** Discards any draft edits then calls close(). If an ondiscard ** callback was provided to the constructor then it is called ** before the drafts are cleared and any exceptions it throws are ** ignored (but may be logged). */ discard(){ if( this.#opt.ondiscard instanceof Function ){ try{this.#opt.ondiscard(this);} catch(e){ console.error("ForumPostEditor.ondiscard() threw:",e); } } this.#clearDraft(); this.close(); } /** This widget's top-most DOM element. */ get widget(){ if( !dummyPadding.parentElement ){ document.body.append(dummyPadding); } dummyPadding.refs.add(this); return this.#e.widget; } get editorContent(){ /* We wrap access to the editor's contents in a getter/setter so that we can eventually add optional use of a contenteditable edit field, as those are generally more comfortable. The code for that is in fossil.page.chat.js. */ return this.#e.editor.value; } set editorContent(v){ this.#e.editor.value = v; } /** Reports an error by appending each argument to the error widget and unhiding it. If passed no arugments, it clears and hides the error widget. */ reportError(...msg){ const e = this.#e.error; D.clearElement(e); if( msg.length ){ console.error('ForumPostEditor:',...msg); e.classList.remove('hidden'); e.append( ...msg, D.br(), D.button("Clear", ()=>this.reportError()) /* Looks horrid in the Blitz skin */ ); }else{ e.classList.add('hidden'); } } /** Adds a list of input[type=hidden] form fields to this object, imported from the server-generated HTML. This is used for collecting, e.g., the CSRF token and an initial page title. */ addHiddenFields(list){ this.#extraFields ??= []; for( const f of list ){ if( !f ) continue; if( 'title'===f.name && this.#e.title ){ if( f.value && this.#opt.isNewThread && !this.#e.title.value ){ this.#e.title.value = f.value; } }else{ this.#extraFields.push(f); } } } get mimetype(){ return this.#e.mimetype.select.value; } get title(){ return this.#e.title?.value || this.#opt.edit?.H; } #initHelpTab(){ const eh = this.#e.help; const list = D.ul(); D.append( D.li(list), D.attr(D.a(F.repoUrl('md_rules'), 'Markdown markup rules'), 'target', '_new') ); D.append( D.li(list), D.attr(D.a(F.repoUrl('wiki_rules'), 'Fossil wiki markup rules'), 'target', '_new') ); D.append( D.li(list), "WARNING: draft edits are keyed on the ID of the message they ", "are editing or responding to. Attempting to edit or reply to ", "the same post from multiple tabs will cause the most-recently-edited ", "one to overwrite the draft slot for that post. In browsers which support ", "Web Locks, a second attempt to edit or reply to a post will be blocked ", "and an error will be shown explaining the problem." ); if( this.#e.status ){ D.append( D.li(list), "Tip: changing just the status in the editor will change only that, ", "not a whole new (but unedited) copy of the post." ); } eh.append(list); } #initAttacherTab(){ this.#att = new F.Attacher({ reverse: true }); if( this.#opt.edit ){ const eNote = D.append( D.div(), "Tip: attachments can be added to posts without editing them ", "by visiting ", D.attr( D.a(F.repoUrl('attachadd?target='+this.#opt.edit.uuid), '/attachadd'), 'target', '_new' ), ".", ); this.#e.tabAttach.append(eNote); } this.#e.tabAttach.append(this.#att.widget); } #newFormData(addThisContent){ const fd = new FormData; for(const f of this.#extraFields){ fd.append(f.name, f.value); } let v; if( this.#opt.inReplyTo ){ fd.append( 'firt', this.#opt.inReplyTo ); }else if( (v = (this.#e.title?.value?.trim?.() || this.#opt.edit?.H)) ){ fd.append('title', v); } fd.append('mimetype', this.mimetype); fd.append('content', addThisContent || this.editorContent.trim()); return fd; } async #fetchPreview(content){ /* TODO: fetch preview */ const e = this.#e; const fd = /*no: this.#newFormData(content); */ new FormData; let ext; switch(this.mimetype){ case 'text/x-markdown': ext = 'md'; break; case 'text/x-fossil-wiki': ext = 'wiki'; break; default: ext = 'txt'; break; } fd.append('filename', 'x.'+ext/*for mimetype determination*/); fd.append('content', this.editorContent.trim()); return window .fetch(F.repoUrl('ajax/preview-text'),{ method: 'POST', body: fd }) .then(r=>r.text()) .then(t=>{ if( /^\{.*}$/.test(t) ){ const o = JSON.parse(t); throw new Error(o.error); } return t; }); } #setPreviewContent(rawHtml){ /** Append the new content then remove the old, to help reduce jumping-around of the UI if the preview is cleared then repopulated. */ const preview = this.#e.preview; const childs = [...preview.childNodes]; D.parseHtml(preview, rawHtml); D.remove(childs); //preview.style.removeProperty('height'); if(F.pikchr && 'text/x-markdown'===this.mimetype){ F.pikchr.addSrcView( preview.querySelectorAll('svg.pikchr') ); } } async #preview(){ if( this.#isWaiting ) return; const e = this.#e; if( e.preview !== this.#activeTab ){ this.#tabs.switchToTab(e.preview); /* Will recurse into here */ return; } const content = this.editorContent.trim(); //console.debug("content to preview", content); if( !content ){ return; } if( 0 && !e.preview.firstElementChild ){ /* On an initial first preview, inherit the editor's height to reduce jumping-around of the UI. */ if( 0 /* does not work: height of the editor is "auto" */ ){ const c = window.getComputedStyle(e.editor/*tabEdit*/); e.preview.style.height = c.height; }else{ e.preview.style.height = '20em'; } } this.#isWaiting = true; D.disable(this.#toDisable, e.button.submit); this.#fetchPreview(content) .then((c)=>{ this.#setPreviewContent(c); D.enable(e.button.submit); }) .catch(err=>{ e.preview.textContent = "Error fetching preview: "+err.message; console.error("Error fetching preview:",err); this.reportError(err.message); }) .finally(()=>{ this.#isWaiting = false; D.enable(this.#toDisable); }); } #validate(tgt){ if( this.#e.title ){ const v = this.#e.title.value.trim(); if( !v ){ this.reportError("A non-empty title is required."); return; } } return true; } #submit(){ if( this.#isWaiting ) return; if( !this.#validate() ) return; this.#isWaiting = true; const e = this.#e; D.disable(e.button.submit); const fd = this.#newFormData(); if( this.#e.status ){ /* Send the status only if it was modified, otherwise we may add a superfluous tag. */ const v = this.#e.status.value; if( this.#e.status.dataset.originalValue !== v ){ fd.append("status", v); } } if( e.debug ){ e.debug.querySelectorAll('input[type=checkbox]').forEach(cb=>{ if( cb.checked ){ fd.append(cb.value, 1); //console.debug("Forum post debug option:",cb); } }); } if( this.#att ){ this.#att.populateFormData(fd); } //console.warn("Ready to submit",fd); if( 0 ){ this.#isWaiting = false; return; } const resp = window.fetch(F.repoUrl('forumajax_save'), { method: 'POST', body: fd }).then(r=>r.json()) .then(j=>{ j = F.nu(j); console.debug("forum post editor response:",j); if( j.error ){ throw new Error(j.error); }else if( j.message ){ /* This is only for use in debugging during * development. */ this.reportError(j.message); return; } if( 1 ){ this.#clearDraft(); if( this.#opt.onsubmit instanceof Function ){ try{this.#opt.onsubmit(this, j);} catch(e){ console.error("ForumPostEditor.onsubmit() threw: ", e); } } /* if( this.#opt.edit?.uuid === j.uuid ) then we know the content did not change, but it's possible that attachments and/or a status tag did. Ergo, we need to unconditionally reload to render those changes (if any). The other option is to tell the user "nothing changed" and leave them in the editor, but that could be a lie because we don't know if any attachments or tags were changed. */ else if( 0 ){ if( this.#opt.edit.uuid === j.uuid && !j.statusModified && 0===j.attachedCount ){ this.reportError("No changes made."); }else{ window.location = F.repoUrl('forumpost/'+j.uuid); setTimeout(()=>this.close(), 500/*just in case not redirected*/); } } }else{ this.reportError( "Saving worked but we're ignoring it and staying here." ); } }) .catch((e)=>this.reportError(e.message)) .finally(()=>this.#isWaiting = false); } #storeDraft(){ if( this.#draft ){ this.#draft.mtime = Date.now(); F.storage.setJSON(this.#opt.draftKey, this.#draft); } } /** Clears any persistent draft state. Does not clear the UI widgets. */ #clearDraft(){ if( this.#draft ){ F.storage.remove(this.#opt.draftKey); this.#draft = F.nu(); } } /** Looks for editing draft keys matching either a fixed key or a regex, and removes each matching one which is older than the given number of days. Pass days=0 to purge all entries immediately. */ static purgeOldDrafts(key, days=10){ const age = (3600 * 24 * days) * 1000/*ms*/; const now = Date.now(); const check = (k)=>{ const o = F.storage.getJSON(k); if( o && o.mtime && (!days || (o.mtime+age <= now)) ){ F.storage.remove(k); } }; if( key instanceof RegExp ){ for(const k of F.storage.keys(false).filter(v=>key.test(v))){ check(k); } }else{ check(key); } } async #fetchPost(){ /* TODO: when editing an existing post, fetch the raw body of the post and populate this.e. */ } }/*ForumPostEditor*/; F.ForumPostEditor = ForumPostEditor; /** When the page is loaded, this handler does the following: 1. Installs expand/collapse UI elements on "long" posts and collapses them. 2. Any pikchr-generated SVGs get a source-toggle button added to them which activates when the mouse is over the image or it is tapped. 3. Plugs in a new edit/reply widget to forum posts. This is a harmless no-op if the current page has neither forum post constructs for (1) and (3) nor any pikchr images for (2), nor will NOT running this code cause any breakage for clients with no JS support: this is all "nice-to-have", not required functionality. */ F.onPageLoad(function(){ const scrollbarIsVisible = (e)=>e.scrollHeight > e.clientHeight; /* Returns an event handler which implements the post expand/collapse toggle on contentElem when the given widget is activated. */ const getWidgetHandler = function(widget, contentElem){ return function(ev){ if(ev) ev.preventDefault(); const wasExpanded = widget.classList.contains('expanded'); widget.classList.toggle('expanded'); contentElem.classList.toggle('expanded'); if(wasExpanded){ contentElem.classList.add('shrunken'); contentElem.parentElement.scrollIntoView({ /* This is non-standard, but !(MSIE, Safari) supposedly support it: https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView#Browser_compatibility */ behavior: 'smooth' }); }else{ contentElem.classList.remove('shrunken'); } return false; }; }; /* Adds an Expand/Collapse toggle to all div.forumPostBody elements which are deemed "too large" (those for which scrolling is currently activated because they are taller than their max-height). */ document.querySelectorAll( 'div.forumTime, div.forumEdit' ).forEach(function f(forumPostWrapper){ const content = forumPostWrapper.querySelector('div.forumPostBody'); if(!content || !scrollbarIsVisible(content)) return; const parent = content.parentElement, widget = D.addClass( D.div(), 'forum-post-collapser','bottom' ), rightTapZone = D.addClass( D.div(), 'forum-post-collapser','right' ); /* Repopulates the rightTapZone with arrow indicators. Because of the wildly varying height of these elements, This has to be done dynamically at init time and upon collapse/expand. Will not work until the rightTapZone has been added to the DOM. */ const refillTapZone = function f(){ if(!f.baseTapIndicatorHeight){ /* To figure out how often to place an arrow in the rightTapZone, we simply grab the first header element from the page and use its hight as our basis for calculation. */ const h1 = document.querySelector('h1, h2'); f.baseTapIndicatorHeight = h1.getBoundingClientRect().height; } D.clearElement(rightTapZone); var rtzHeight = parseInt(window.getComputedStyle(rightTapZone).height); do { D.append(rightTapZone, D.span()); rtzHeight -= f.baseTapIndicatorHeight * 8; }while(rtzHeight>0); }; const handlerStep1 = getWidgetHandler(widget, content); const widgetEventHandler = ()=>{ handlerStep1(); refillTapZone(); }; content.classList.add('with-expander'); widget.addEventListener('click', widgetEventHandler, false); /** Append 3 children, which CSS will evenly space across the widget. This improves visibility over having the label in only the left, right, or center. */ var i = 0; for( ; i < 3; ++i ) D.append(widget, D.span()); if(content.nextSibling){ forumPostWrapper.insertBefore(widget, content.nextSibling); }else{ forumPostWrapper.appendChild(widget); } content.appendChild(rightTapZone); rightTapZone.addEventListener('click', widgetEventHandler, false); refillTapZone(); })/*for-each div.forumTime|div.forumEdit*/; if(F.pikchr){ F.pikchr.addSrcView(); } const eStatus = document.querySelector( 'form div.submenu select.submenuctrl[name="status"]' ); if( eStatus ){ /* Main /forum list. Remove the 'x' form element when eStatus changes, to avoid propagating x when changing the filter. The problem this solves: we're browsed to page 3 of status X. We change the status filter selection to Y. We're redirected to page x, but Y only has 2 posts with that status, so we see an empty list. When changing the filter, we need to ensure that we start back and that beginning. */ const pForm = eStatus.parentElement?.parentElement; if( pForm ){ eStatus.addEventListener('change', ()=>{ pForm.querySelector('input[type="hidden"][name="x"]')?.remove?.(); }, true); } }else{ /* One of the single-post edit/view pages. Handle various UI controls and attempt to keep stray double-clicks from double-posting. https://fossil-scm.org/forum/info/6bd02466533aa131 */ const formSubmitted = function(event){ const form = event.target; if( form.dataset.submitted ){ event.preventDefault(); return; } form.dataset.submitted = '1'; /** If the user is left waiting "a long time," disable the resubmit protection. If we don't do this and they tap the browser's cancel button while waiting, they'll be stuck with an unsubmittable form. It can apparently also happen, via browser-back, that the form gets left in a submitted state. */ setTimeout(()=>{delete form.dataset.submitted}, 7000); return; }; /** The problem: "Approve", "Reject" and their ilk stays active while their form post is in flight, just begging to be clicked again. We don't really have a way to know when the submit finishes, so we'll (A) disable them before submit and (B) reenable them after some long timeout in case the request fails. On a successful submit we're redirected, making this all moot. */ const deactivateButtonInFlight = (elem, form)=>{ if( elem.disabled ) return /* potential double-click protection */; elem.disabled = true; setTimeout( /* This is a stupid workaround for a failed request but none better come to mind except replacing the form.submit() with a fetch() POST, such that we can do this at precisely the right time. That change would not be difficult (we do this in /chat, /wikiedit, etc.) but that exceeds this morning's ambitions. */ ()=>elem.disabled = false, 10000 ); form.submit(); }; document.querySelectorAll("form").forEach(function(form){ /* Set up controls for closing posts and setting thread status. */ form.addEventListener('submit', formSubmitted); form .querySelectorAll("input.action-close, input.action-reopen") .forEach(function(e){ e.classList.remove('hidden'); F.confirmer(e, { confirmText: (e.classList.contains('action-reopen') ? "Confirm re-open" : "Confirm close"), onconfirm: ()=>deactivateButtonInFlight(e, form) }); }); form .querySelectorAll("input.action-approve, input.action-reject") .forEach(function(e){ e.type = 'button'/*do not submit form on click*/; const isApprove = e.classList.contains('action-approve'); F.confirmer(e, { confirmText: (isApprove ? "Confirm approval" : "Confirm rejection"), onconfirm: ()=>{ form.append( /* Workaround for the button element's value not being sent with the form: inject a hidden field corresponding to the button's action. */ D.attr( D.input('hidden'), 'name', isApprove ? 'approve' : 'reject', 'value', 'ignored' ) ); deactivateButtonInFlight(e, form); } }); /* We should also arguably disable the approve/reject button's counterpart while it's counting down. */ }); form .querySelectorAll("input[type='button'].action-status") .forEach(function(btn){ const sel = btn.previousElementSibling; const updateButton = ()=>{ /* Enable btn only when the status has been locally modified. */ if( sel.dataset.initialValue ){ if( sel.dataset.initialValue===sel.value ){ btn.setAttribute('disabled',''); }else{ btn.removeAttribute('disabled'); } }else{ if(sel.selectedIndex===0){ btn.setAttribute('disabled',''); }else{ btn.removeAttribute('disabled'); } } }; sel.addEventListener('change', updateButton, true); updateButton(); F.confirmer(btn, { confirmText: "Confirm status change", onconfirm: ()=>deactivateButtonInFlight(btn, form) }); }); }); } /* Apply page-specific tweaks for ForumPostEditor instance fpe then plug it into the UI at the end of ePost. */ const initFPEWidget = (fpe, ePost)=>{ ePost.eUnhideThenWhenDone = [ /* List of elements to hide while editing/replying and reveal when discarding or saving. */ ]; for( const ee of ePost.querySelectorAll( '.forumpost-single-controls, fieldset.forum-status-selection' ) ){ ee.hidden = true; ePost.eUnhideThenWhenDone.push(ee); } const w = fpe.widget; w.classList.add('animate-entrance'); ePost.append(w); requestAnimationFrame(() => { w.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }); }); }; const plugInEditor = (new URL(window.location).searchParams).get('nojs')===null; const eForumNew = ( plugInEditor && ( document.body.classList.contains('cpage-forumnew') || document.body.classList.contains('cpage-forume1') )) ? document.body.querySelector('#forumnew-placeholder') : null; if( plugInEditor && eForumNew ){ /* /forumnew and /forume1 */ const fpe = new F.ForumPostEditor({ draftKey: 'draft-forumnew', hiddenFields: eForumNew.querySelectorAll('input[type=hidden]'), hideStash: true, ondiscard: ()=>{ window.location = F.repoUrl('forum'); }, onsubmit: (fpe, response)=>{ window.location = F.repoUrl('forumpost/'+response.uuid); } }); eForumNew.parentElement.insertBefore(fpe.widget, eForumNew); eForumNew.remove(); fossil.page.fpe = fpe /* for testing via the console */; }/*eForumNew*/ else if( plugInEditor && (document.body.classList.contains('cpage-forumpost') || document.body.classList.contains('cpage-forumthread')) ){ /* /forumpost and /forumthread. Take over the Edit/Reply buttons to use a ForumPostEditor. */ const fetchPost = async (fpid)=>{ return window.fetch(F.repoUrl('ajax/artifact.json?uuid='+fpid)) .then(r=>r.json()) .then(j=>{ j = F.nu(j); if( j.error ) throw new Error(j.error); return j; }); }; const makeDraftKey = (prefix,uuid)=>{ return prefix+'-'+uuid.substr(0,12); }; /** Perform some init common to both Reply and Edit. ePost = the forum post DOM element. eButton = the Reply or Edit button. */ const setupEditReplyElement = (ePost, eButton)=>{ /* Forum posts are indented in the main forum view to represent their place in the hierarchy. In order to gain some screen space, we shift the post to the left margin and arrange to shift it back when the editor is closed. We also record the original button label so that it can be restored on close. */ ePost.dataset.originalMarginLeft = ePost.style.marginLeft; ePost.style.marginLeft = 'initial'; eButton.dataset.originalLabel = eButton.innerText; }; /** Undoes the damage done by setupEditReplyElement(). */ const restoreEditReplyElement = (ePost, eButton)=>{ if( ePost.dataset.originalMarginLeft ){ ePost.style.marginLeft = ePost.dataset.originalMarginLeft; delete ePost.dataset.originalMarginLeft; } if( eButton.dataset.originalLabel ){ eButton.innerText = eButton.dataset.originalLabel; delete eButton.dataset.originalLabel; } for(const ee of (ePost.eUnhideThenWhenDone || [])){ ee.removeAttribute('hidden'); } ePost.eUnhideThenWhenDone = undefined; }; /** Reports an error regarding the forum post element ePost, appending each entry in msg to a wrapper element with the class */ const reportFPEError = (ePost,...msg)=>{ const e = D.addClass(D.p(), 'error'); e.append( ...msg, D.br(), D.button("Clear error", ()=>e.remove()) ); ePost.append(e); }; /** Plug in an editor widget representing a reply to a post. form = a (.forum-post-single-controls > form) element. The final 3 arguments are as documented for setupEditReplyElement(). */ const replyClicked = async (form, ePost, eBtnReply)=>{ const fpid = ePost.dataset.fpid; const fEditHead = ePost.dataset.fedithead; const draftKey = makeDraftKey( 'draft-reply', fEditHead /* The problem with firt as a key is that firt is not necessarily the root edit of that post, which is what we really want as a draft key so that the draft does not disappear if firt is later edited (giving us a new firt value here). */ || fpid ); let releaseLock; if( window.navigator.locks ){ /* This business with forceReleaseLockOnClose and 'pagehide' event is a workaround for Chrome being overly lazy in releasing Web Locks when a tab is closed. It sometimes waits half a minute or more before release. Sidebar: we cannot use AbortController because it cannot be used with the ifAvailable lock check. */ let forceReleaseLockOnClose = null; const handlePageHide = ()=>{ if( forceReleaseLockOnClose ){ forceReleaseLockOnClose(); } }; window.addEventListener('pagehide', handlePageHide); releaseLock = await new Promise((resolve)=>{ window.navigator.locks.request( 'fossil-'+draftKey, {ifAvailable: true}, async (lock)=>{ if( !lock ){ /*lock contention*/ resolve(null); return; } let release; const lockReleased = new Promise(res=>release=res); forceReleaseLockOnClose = release; const wrappedRelease = ()=>{ window.removeEventListener('pagehide', handlePageHide); if( release ) release(); }; resolve(wrappedRelease); await lockReleased/*hold the lock open*/; }); }); if( !releaseLock ){ window.removeEventListener('pagehide', handlePageHide); reportFPEError( ePost, "This post is actively being replied to ", "in another tab. To avoid losing edits, ", "it cannot be opened here until the locking ", "tab is closed." ); return; } } setupEditReplyElement(ePost, eBtnReply); eBtnReply.innerText = "Replying..."; const ondone = (fpe, response)=>{ /* onsubmit() and ondiscard() callback */ restoreEditReplyElement(ePost, eBtnReply); //console.debug("ondiscard/onsubmit", fpe, artifact); if( response/*onsubmit()*/ ){ window.location = F.repoUrl('forumpost/'+response.uuid); setTimeout(()=>fpe.close(), 500/*just in case not redirected*/); }else{/*ondiscard() or onclose()*/ } }; const fpe = new F.ForumPostEditor(F.nu({ hiddenFields: form.querySelectorAll( 'input[type=hidden][name=csrf]' /* Do not inherit the fpid field, else this will become an edit to that post rather than a response. */ ), ondiscard: ()=>{/*need a noop here. Will call onclose()*/}, onsubmit: ondone, onclose: ()=>{ if( releaseLock ){ releaseLock(); releaseLock = null; } ondone(); }, inReplyTo: fpid, draftKey })); initFPEWidget(fpe, ePost); }/*replyClicked()*/; /** Plug in an editor widget representing an edit to a post. form = a (.forum-post-single-controls > form) element. The final 3 arguments are as documented for setupEditReplyElement(). */ const editClicked = async (form, ePost, eBtnEdit)=>{ const fpid = ePost.dataset.fpid; const firt = ePost.dataset.firt; const fEditHead = ePost.dataset.fedithead; const draftKey = makeDraftKey('draft-forumedit', fEditHead || fpid); let releaseLock; if( navigator.locks ){ releaseLock = await new Promise((resolve) => { navigator.locks.request( 'fossil-'+draftKey, {ifAvailable: true}, async (lock)=>{ if( !lock ){ resolve(null); return; } let release; const lockReleased = new Promise(res=>release=res); resolve(release); await lockReleased; }); }); if( !releaseLock ){ reportFPEError( ePost, "This post is actively being edited ", "in another tab. To avoid losing edits, ", "it cannot be opened here until the locking ", "tab is closed." ); return; } } setupEditReplyElement(ePost, eBtnEdit); eBtnEdit.innerText = "Editing..."; fetchPost(fpid) .then(artifact=>{ const ondone = (fpe, response)=>{ /* onsubmit() and ondiscard() callback */ if( response/*onsubmit()*/ ){ if( fpid === response.uuid && !response.statusModified && 0===response.attachedCount ){ fpe.reportError("No changes made."); }else{ restoreEditReplyElement(ePost, eBtnEdit); window.location = F.repoUrl('forumpost/'+response.uuid); setTimeout(()=>fpe.close(), 500/*just in case not redirected*/); } }else{ /*ondiscard() or onclose()*/ restoreEditReplyElement(ePost, eBtnEdit); } }; const eStatusSelect = ePost.querySelector( ':scope > fieldset.forum-status-selection select[name=status]' ); const fpe = new F.ForumPostEditor(F.nu({ hiddenFields: form.querySelectorAll('input[type=hidden]'), ondiscard: ()=>{/*need a noop here. Will call onclose()*/}, onsubmit: ondone, onclose: ()=>{ if(releaseLock){ releaseLock(); releaseLock = null; } ondone(); }, draftKey, edit: artifact, status: eStatusSelect?.value, inReplyTo: firt })); initFPEWidget(fpe, ePost); }) .catch(err=>{ if( releaseLock ){ releaseLock(); releaseLock = null; } restoreEditReplyElement(ePost, eBtnEdit); console.error("Error fetching post:", err); reportFPEError(ePost, "Error fetching post: ", err.message); }); }/*editClicked()*/; document.body.querySelectorAll( '.forumpost-single-controls > form' ).forEach(form=>{ /* For each forum post... */ const eThePost = form.parentElement.parentElement/*main post DOM element*/; if( !eThePost?.dataset?.fpid ){ /* The server injects these dataset values. */ console.warn("Unexpected missing fpid", eThePost); return; } const checkButtonForDraft = (draftKeyPrefix, eBtn)=>{ /* If a draft is found associated with eThePost, mark eBtn as a draft and set up storage event listeners to update the button as new drafts come and go. */ const fpid = eThePost.dataset.fpid; const fEditHead = eThePost.dataset.fedithead; const draftKey = makeDraftKey(draftKeyPrefix, fEditHead || fpid); if( F.storage.contains(draftKey) ){ eBtn.classList.add('draft'); } F.storage.addEventListener('set', ({detail})=>{ if( draftKey === detail.key ){ eBtn.classList.add('draft'); } }); F.storage.addEventListener('remove', ({detail})=>{ if( draftKey === detail.key ){ eBtn.classList.remove('draft'); } }); }; /* Replace the Reply and Edit buttons with ones which will activate a ForumPostEditor. */ const btnReply = form.querySelector('input[type=submit][name=reply]'); if( btnReply ){ const b = D.button("Reply", ()=>replyClicked(form, eThePost, b)); b.type = 'button'/*keep container form from submitting*/; checkButtonForDraft('draft-reply',b); btnReply.parentElement.insertBefore(b, btnReply); btnReply.remove(); } const btnEdit = form.querySelector('input[type=submit][name=edit]'); if( btnEdit ){ const b = D.button("Edit", ()=>editClicked(form, eThePost, b)); b.type = 'button'/*keep container form from submitting*/; checkButtonForDraft('draft-forumedit',b); btnEdit.parentElement.insertBefore(b, btnEdit); btnEdit.remove(); } })/*for-each form*/; }/* /forumpost and /forumthread */ document.body.querySelectorAll('.remove-on-load').forEach(e=>e.remove()); document.body.querySelectorAll('.initially-hidden').forEach(e=>{ /* This is a workaround for a span.help-buttonlet which we need to start hidden so that it does not show up for no-JS clients. */ e.classList.remove('initially-hidden'); }); if( plugInEditor ){ document.body.querySelectorAll('.remove-if-replaced').forEach( /* Remove remaining legacy UI elements. */ e=>e.remove() ); /* Purge old drafts only every now and then. */ const now = Date.now(); const lastPurge = +F.storage.get('forum-drafts-last-purge', 0); if( now - lastPurge > (24 * 60 * 60 * 1000 /*1 day ms*/) ){ F.storage.set('forum-drafts-last-purge', now); setTimeout(()=>{ /* Don't block the UI while we're doing I/O */ F.ForumPostEditor.purgeOldDrafts( /^draft-(reply|forumedit)-.*/ /* Intentionally leaving draft-forumnew in place. */ ); }, 50); } } })/*F.onPageLoad callback*/; })(window.fossil);