summaryrefslogtreecommitdiff
path: root/src/fossil.page.forumpost.js
diff options
context:
space:
mode:
authorcashmere <cashmere@autistici.org>2026-09-04 09:05:24 +0200
committercashmere <cashmere@autistici.org>2026-09-04 09:05:24 +0200
commit4f49b16079b27d7ce633619e4cddfdf2bffbc952 (patch)
treec296c1e7f3c3e2f6859c6d3a1b1b208779b89679 /src/fossil.page.forumpost.js
downloadrelic-4f49b16079b27d7ce633619e4cddfdf2bffbc952.tar.gz
initial commitHEADmaster
Diffstat (limited to 'src/fossil.page.forumpost.js')
-rw-r--r--src/fossil.page.forumpost.js1486
1 files changed, 1486 insertions, 0 deletions
diff --git a/src/fossil.page.forumpost.js b/src/fossil.page.forumpost.js
new file mode 100644
index 0000000..79f667e
--- /dev/null
+++ b/src/fossil.page.forumpost.js
@@ -0,0 +1,1486 @@
+/**
+ 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);