diff options
| author | cashmere <cashmere@autistici.org> | 2026-09-04 09:05:24 +0200 |
|---|---|---|
| committer | cashmere <cashmere@autistici.org> | 2026-09-04 09:05:24 +0200 |
| commit | 4f49b16079b27d7ce633619e4cddfdf2bffbc952 (patch) | |
| tree | c296c1e7f3c3e2f6859c6d3a1b1b208779b89679 /src/fossil.page.chat.js | |
| download | relic-4f49b16079b27d7ce633619e4cddfdf2bffbc952.tar.gz | |
Diffstat (limited to 'src/fossil.page.chat.js')
| -rw-r--r-- | src/fossil.page.chat.js | 2894 |
1 files changed, 2894 insertions, 0 deletions
diff --git a/src/fossil.page.chat.js b/src/fossil.page.chat.js new file mode 100644 index 0000000..38d83ff --- /dev/null +++ b/src/fossil.page.chat.js @@ -0,0 +1,2894 @@ +-/** + This file contains the client-side implementation of fossil's /chat + application. +*/ +window.fossil.onPageLoad(function(){ + const F = window.fossil, D = F.dom; + const E1 = function(selector){ + const e = document.querySelector(selector); + if(!e) throw new Error("missing required DOM element: "+selector); + return e; + }; + + /** + Returns true if e is entirely within the bounds of the window's viewport. + */ + const isEntirelyInViewport = function(e) { + const rect = e.getBoundingClientRect(); + return ( + rect.top >= 0 && + rect.left >= 0 && + rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && + rect.right <= (window.innerWidth || document.documentElement.clientWidth) + ); + }; + + /** + Returns true if e's on-screen position vertically overlaps that + of element v's. Horizontal overlap is ignored (we don't need it + for our case). + */ + const overlapsElemView = function(e,v) { + const r1 = e.getBoundingClientRect(), + r2 = v.getBoundingClientRect(); + if(r1.top<=r2.bottom && r1.top>=r2.top) return true; + else if(r1.bottom<=r2.bottom && r1.bottom>=r2.top) return true; + return false; + }; + + const addAnchorTargetBlank = (e)=>D.attr(e, 'target','_blank'); + + /** + Returns an almost-ISO8601 form of Date object d. + */ + const iso8601ish = function(d){ + return d.toISOString() + .replace('T',' ').replace(/\.\d+/,'') + .replace('Z', ' zulu'); + }; + const pad2 = (x)=>('0'+x).substr(-2); + /** Returns the local time string of Date object d, defaulting + to the current time. */ + const localTimeString = function ff(d){ + d || (d = new Date()); + return [ + d.getFullYear(),'-',pad2(d.getMonth()+1/*sigh*/), + '-',pad2(d.getDate()), + ' ',pad2(d.getHours()),':',pad2(d.getMinutes()), + ':',pad2(d.getSeconds()) + ].join(''); + }; + + (function(){ + let dbg = document.querySelector('#debugMsg'); + if(dbg){ + /* This can inadvertently influence our flexbox layouts, so move + it out of the way. */ + D.append(document.body,dbg); + } + })(); + /* Returns a list of DOM elements which "frame" the chat UI. These + elements are considered to _not_ be part of the chat UI and that + info is used for sizing the chat UI. In chat-only mode, these are + the elements that get hidden. */ + const GetFramingElements = function() { + return document.querySelectorAll([ + "body > header", + "body > nav.mainmenu", + "body > footer", + "#debugMsg" + ].join(',')); + }; + const ForceResizeKludge = (function(){ + /* Workaround for Safari mayhem regarding use of vh CSS units.... + We tried to use vh units to set the content area size for the + chat layout, but Safari chokes on that, so we calculate that + height here: 85% when in "normal" mode and 95% in chat-only + mode. Larger than ~95% is too big for Firefox on Android, + causing the input area to move off-screen. + + While we're here, we also use this to cap the max-height + of the input field so that pasting huge text does not scroll + the upper area of the input widget off-screen. */ + const elemsToCount = GetFramingElements(); + const contentArea = E1('div.content'); + const bcl = document.body.classList; + const resized = function f(){ + if(f.$disabled) return; + const wh = window.innerHeight, + com = bcl.contains('chat-only-mode'); + var ht; + var extra = 0; + if(com){ + ht = wh; + }else{ + elemsToCount.forEach((e)=>e ? extra += D.effectiveHeight(e) : false); + ht = wh - extra; + } + f.chat.e.inputX.style.maxHeight = (ht/2)+"px"; + /* ^^^^ this is a middle ground between having no size cap + on the input field and having a fixed arbitrary cap. */; + contentArea.style.height = + contentArea.style.maxHeight = [ + "calc(", (ht>=100 ? ht : 100), "px", + " - 0.65em"/*fudge value*/,")" + /* ^^^^ hypothetically not needed, but both Chrome/FF on + Linux will force scrollbars on the body if this value is + too small; current value is empirically selected. */ + ].join(''); + if(false){ + console.debug("resized.",wh, extra, ht, + window.getComputedStyle(contentArea).maxHeight, + contentArea); + console.debug("Set input max height to: ", + f.chat.e.inputX.style.maxHeight); + } + }; + resized.$disabled = true/*gets deleted when setup is finished*/; + window.addEventListener('resize', F.debounce(resized, 250), false); + return resized; + })(); + fossil.FRK = ForceResizeKludge/*for debugging*/; + const Chat = ForceResizeKludge.chat = (function(){ + const cs = { // the "Chat" object (result of this function) + beVerbose: false + //!!window.location.hostname.match("localhost") + /* if true then certain, mostly extraneous, error messages and + log messages may be sent to the console. */, + playedBeep: false /* used for the beep-once setting */, + e:{/*map of certain DOM elements.*/ + messageInjectPoint: E1('#message-inject-point'), + pageTitle: E1('head title'), + loadOlderToolbar: undefined /* the load-posts toolbar (dynamically created) */, + inputArea: E1("#chat-input-area"), + inputLineWrapper: E1('#chat-input-line-wrapper'), + fileSelectWrapper: E1('#chat-input-file-area'), + viewMessages: E1('#chat-messages-wrapper'), + viewZoom: E1('#chat-zoom'), + zoomContent: E1('#chat-zoom-content'), + zoomMarker: E1('#chat-zoom-marker'), + btnSubmit: E1('#chat-button-submit'), + btnAttach: E1('#chat-button-attach'), + inputX: E1('#chat-input-field-x'), + input1: E1('#chat-input-field-single'), + inputM: E1('#chat-input-field-multi'), + inputFile: E1('#chat-input-file'), + contentDiv: E1('div.content'), + viewConfig: E1('#chat-config'), + viewPreview: E1('#chat-preview'), + previewContent: E1('#chat-preview-content'), + viewSearch: E1('#chat-search'), + searchContent: E1('#chat-search-content'), + btnPreview: E1('#chat-button-preview'), + views: document.querySelectorAll('.chat-view'), + activeUserListWrapper: E1('#chat-user-list-wrapper'), + activeUserList: E1('#chat-user-list'), + eMsgPollError: undefined /* current connection error MessageMidget */, + pollErrorMarker: document.body /* element to toggle 'connection-error' CSS class on */ + }, + me: F.user.name, + mxMsg: F.config.chat.initSize ? -F.config.chat.initSize : -50, + mnMsg: undefined/*lowest message ID we've seen so far (for history loading)*/, + pageIsActive: 'visible'===document.visibilityState, + changesSincePageHidden: 0, + notificationBubbleColor: 'white', + totalMessageCount: 0, // total # of inbound messages + //! Number of messages to load for the history buttons + loadMessageCount: Math.abs(F.config.chat.initSize || 20), + ajaxInflight: 0, + usersLastSeen:{ + /* Map of user names to their most recent message time + (JS Date object). Only messages received by the chat client + are considered. */ + /* Reminder: to convert a Julian time J to JS: + new Date((J - 2440587.5) * 86400000) */ + }, + filterState:{ + activeUser: undefined, + match: function(uname){ + return this.activeUser===uname || !this.activeUser; + } + }, + /** + The timer object is used to control connection throttling + when connection errors arrise. It starts off with a polling + delay of $initialDelay ms. If there's a connection error, + that gets bumped by some value for each subsequent error, up + to some max value. + + The timing of resetting the delay when service returns is, + because of the long-poll connection and our lack of low-level + insight into the connection at this level, a bit wonky. + */ + timer:{ + /* setTimeout() ID for (delayed) starting a Chat.poll(), so + that it runs at controlled intervals (which change when a + connection drops and recovers). */ + tidPendingPoll: undefined, + tidClearPollErr: undefined /*setTimeout() timer id for + reconnection determination. See + clearPollErrOnWait(). */, + $initialDelay: 1000 /* initial polling interval (ms) */, + currentDelay: 1000 /* current polling interval */, + maxDelay: 60000 * 5 /* max interval when backing off for + connection errors */, + minDelay: 5000 /* minimum delay time for a back-off/retry + attempt. */, + errCount: 0 /* Current poller connection error count */, + minErrForNotify: 4 /* Don't warn for connection errors until this + many have occurred */, + pollTimeout: (1 && window.location.hostname.match( + "localhost" /*presumably local dev mode*/ + )) ? 15000 + : (+F.config.chat.pollTimeout>0 + ? (1000 * (F.config.chat.pollTimeout - Math.floor(F.config.chat.pollTimeout * 0.1))) + /* ^^^^^^^^^^^^ we want our timeouts to be slightly shorter + than the server's so that we can distingished timed-out + polls on our end from HTTP errors (if the server times + out). */ + : 30000), + /** Returns a random fudge value for reconnect attempt times, + intended to keep the /chat server from getting hammered if + all clients which were just disconnected all reconnect at + the same instant. */ + randomInterval: function(factor){ + return Math.floor(Math.random() * factor); + }, + /** Increments the reconnection delay, within some min/max range. */ + incrDelay: function(){ + if( this.maxDelay > this.currentDelay ){ + if(this.currentDelay < this.minDelay){ + this.currentDelay = this.minDelay + this.randomInterval(this.minDelay); + }else{ + this.currentDelay = this.currentDelay*2 + this.randomInterval(this.currentDelay); + } + } + return this.currentDelay; + }, + /** Resets the delay counter to v || its initial value. */ + resetDelay: function(ms=0){ + return this.currentDelay = ms || this.$initialDelay; + }, + /** Returns true if the timer is set to delayed mode. */ + isDelayed: function(){ + return (this.currentDelay > this.$initialDelay) ? this.currentDelay : 0; + }, + /** + Cancels any in-progress pending-poll timer and starts a new + one with the given delay, defaulting to this.resetDelay(). + */ + startPendingPollTimer: function(delay){ + this.cancelPendingPollTimer().tidPendingPoll + = setTimeout( Chat.poll, delay || Chat.timer.resetDelay() ); + return this; + }, + /** + Cancels any still-active timer set to trigger the next + Chat.poll(). + */ + cancelPendingPollTimer: function(){ + if( this.tidPendingPoll ){ + clearTimeout(this.tidPendingPoll); + this.tidPendingPoll = 0; + } + return this; + }, + /** + Cancels any pending reconnection attempt back-off timer.. + */ + cancelReconnectCheckTimer: function(){ + if( this.tidClearPollErr ){ + clearTimeout(this.tidClearPollErr); + this.tidClearPollErr = 0; + } + return this; + } + }, + /** + Gets (no args) or sets (1 arg) the current input text field + value, taking into account single- vs multi-line input. The + getter returns a trim()'d string and the setter returns this + object. As a special case, if arguments[0] is a boolean + value, it behaves like a getter and, if arguments[0]===true + it clears the input field before returning. + */ + inputValue: function(/*string newValue | bool clearInputField*/){ + const e = this.inputElement(); + if(arguments.length && 'boolean'!==typeof arguments[0]){ + if(e.isContentEditable) e.innerText = arguments[0]; + else e.value = arguments[0]; + return this; + } + const rc = e.isContentEditable ? e.innerText : e.value; + if( true===arguments[0] ){ + if(e.isContentEditable) e.innerText = ''; + else e.value = ''; + } + return rc && rc.trim(); + }, + /** Asks the current user input field to take focus. Returns this. */ + inputFocus: function(){ + this.inputElement().focus(); + return this; + }, + /** Returns the current message input element. */ + inputElement: function(){ + return this.e.inputFields[this.e.inputFields.$currentIndex]; + }, + /** Enables (if yes is truthy) or disables all elements in + * this.disableDuringAjax. */ + enableAjaxComponents: function(yes){ + D[yes ? 'enable' : 'disable'](this.disableDuringAjax); + return this; + }, + /* Must be called before any API is used which starts ajax traffic. + If this call represents the currently only in-flight ajax request, + all DOM elements in this.disableDuringAjax are disabled. + We cannot do this via a central API because (1) window.fetch()'s + Promise-based API seemingly makes that impossible and (2) the polling + technique holds ajax requests open for as long as possible. A call + to this method obligates the caller to also call ajaxEnd(). + + This must NOT be called for the chat-polling API except, as a + special exception, the very first one which fetches the + initial message list. + */ + ajaxStart: function(){ + if(1===++this.ajaxInflight){ + this.enableAjaxComponents(false); + } + }, + /* Must be called after any ajax-related call for which + ajaxStart() was called, regardless of success or failure. If + it was the last such call (as measured by calls to + ajaxStart() and ajaxEnd()), elements disabled by a prior call + to ajaxStart() will be re-enabled. */ + ajaxEnd: function(){ + if(0===--this.ajaxInflight){ + this.enableAjaxComponents(true); + } + }, + disableDuringAjax: [ + /* List of DOM elements disable while ajax traffic is in + transit. Must be populated before ajax starts. We do this + to avoid various race conditions in the UI and long-running + network requests. */ + ], + /** Either scrolls .message-widget element eMsg into view + immediately or, if it represents an inlined image, delays + the scroll until the image is loaded, at which point it will + scroll to either the newest message, if one is set or to + eMsg (the liklihood is good, at least on initial page load, + that the image won't be loaded until other messages have + been injected). */ + scheduleScrollOfMsg: function(eMsg){ + if(1===+eMsg.dataset.hasImage){ + eMsg.querySelector('img').addEventListener( + 'load', ()=>(this.e.newestMessage || eMsg).scrollIntoView(false) + ); + }else{ + eMsg.scrollIntoView(false); + } + return this; + }, + /* Injects DOM element e as a new row in the chat, at the oldest + end of the list if atEnd is truthy, else at the newest end of + the list. */ + injectMessageElem: function f(e, atEnd){ + const mip = atEnd ? this.e.loadOlderToolbar : this.e.messageInjectPoint, + holder = this.e.viewMessages, + prevMessage = this.e.newestMessage; + if(!this.filterState.match(e.dataset.xfrom)){ + e.classList.add('hidden'); + } + if(atEnd){ + const fe = mip.nextElementSibling; + if(fe) mip.parentNode.insertBefore(e, fe); + else D.append(mip.parentNode, e); + }else{ + D.append(holder,e); + this.e.newestMessage = e; + } + if(!atEnd && !this._isBatchLoading + && e.dataset.xfrom!==this.me + && (prevMessage + ? !this.messageIsInView(prevMessage) + : false)){ + /* If a new non-history message arrives while the user is + scrolled elsewhere, do not scroll to the latest + message, but gently alert the user that a new message + has arrived. */ + if(!f.btnDown){ + f.btnDown = D.button("⇣⇣⇣"); + f.btnDown.addEventListener('click',()=>this.scrollMessagesTo(1),false); + } + F.toast.message(f.btnDown," New message has arrived."); + }else if(!this._isBatchLoading && e.dataset.xfrom===Chat.me){ + this.scheduleScrollOfMsg(e); + }else if(!this._isBatchLoading){ + /* When a message from someone else arrives, we have to + figure out whether or not to scroll it into view. Ideally + we'd just stuff it in the UI and let the flexbox layout + DTRT, but Safari has expressed, in no uncertain terms, + some disappointment with that approach, so we'll + heuristicize it: if the previous last message is in view, + assume the user is at or near the input element and + scroll this one into view. If that message is NOT in + view, assume the user is up reading history somewhere and + do NOT scroll because doing so would interrupt + them. There are middle grounds here where the user will + experience a slight UI jolt, but this heuristic mostly + seems to work out okay. If there was no previous message, + assume we don't have any messages yet and go ahead and + scroll this message into view (noting that that scrolling + is hypothetically a no-op in such cases). + + The wrench in these works are posts with IMG tags, as + those images are loaded async and the element does not + yet have enough information to know how far to scroll! + For such cases we have to delay the scroll until the + image loads (and we hope it does so before another + message arrives). + */ + if(1===+e.dataset.hasImage){ + e.querySelector('img').addEventListener('load',()=>e.scrollIntoView()); + }else if(!prevMessage || (prevMessage && isEntirelyInViewport(prevMessage))){ + e.scrollIntoView(false); + } + } + }, + /** Returns true if chat-only mode is enabled. */ + isChatOnlyMode: ()=>document.body.classList.contains('chat-only-mode'), + /** + Enters (if passed a truthy value or no arguments) or leaves + "chat-only" mode. That mode hides the page's header and + footer, leaving only the chat application visible to the + user. + */ + chatOnlyMode: function f(yes){ + if(undefined === f.elemsToToggle){ + f.elemsToToggle = []; + GetFramingElements().forEach((e)=>f.elemsToToggle.push(e)); + } + if(!arguments.length) yes = true; + if(yes === this.isChatOnlyMode()) return this; + if(yes){ + D.addClass(f.elemsToToggle, 'hidden'); + D.addClass(document.body, 'chat-only-mode'); + document.body.scroll(0,document.body.height); + }else{ + D.removeClass(f.elemsToToggle, 'hidden'); + D.removeClass(document.body, 'chat-only-mode'); + } + ForceResizeKludge(); + return this; + }, + /** Tries to scroll the message area to... + <0 = top of the message list, >0 = bottom of the message list, + 0 == the newest message (normally the same position as >1). + */ + scrollMessagesTo: function(where){ + if(where<0){ + Chat.e.viewMessages.scrollTop = 0; + }else if(where>0){ + Chat.e.viewMessages.scrollTop = Chat.e.viewMessages.scrollHeight; + }else if(Chat.e.newestMessage){ + Chat.e.newestMessage.scrollIntoView(false); + } + }, + toggleChatOnlyMode: function(){ + return this.chatOnlyMode(!this.isChatOnlyMode()); + }, + messageIsInView: function(e){ + return e ? overlapsElemView(e, this.e.viewMessages) : false; + }, + settings:{ + get: (k,dflt)=>F.storage.get(k,dflt), + getBool: (k,dflt)=>F.storage.getBool(k,dflt), + set: function(k,v){ + F.storage.set(k,v); + F.page.dispatchEvent('chat-setting',{key: k, value: v}); + }, + /* Toggles the boolean setting specified by k. Returns the + new value.*/ + toggle: function(k){ + const v = this.getBool(k); + this.set(k, !v); + return !v; + }, + addListener: function(setting, f){ + F.page.addEventListener('chat-setting', function(ev){ + if(ev.detail.key===setting) f(ev.detail); + }, false); + }, + /* Default values of settings. These are used for initializing + the setting event listeners and config view UI. */ + defaults:{ + /* When on, inbound images are displayed inlined, else as a + link to download the image. */ + "images-inline": !!F.config.chat.imagesInline, + /* When on, ctrl-enter sends messages, else enter and + ctrl-enter both send them. */ + "edit-ctrl-send": false, + /* When on, the edit field starts as a single line and + expands as the user types, and the relevant buttons are + laid out in a compact form. When off, the edit field and + buttons are larger. */ + "edit-compact-mode": true, + /* See notes for this setting in fossil.page.wikiedit.js. + Both /wikiedit and /fileedit share this persistent config + option under the same storage key. */ + "edit-shift-enter-preview": + F.storage.getBool('edit-shift-enter-preview', true), + /* When on, sets the font-family on messages and the edit + field to monospace. */ + "monospace-messages": false, + /* When on, non-chat UI elements (page header/footer) are + hidden */ + "chat-only-mode": false, + /* When set to a URI, it is assumed to be an audio file, + which gets played when new messages arrive. When true, + the first entry in the audio file selection list will be + used. */ + "audible-alert": true, + /* + */ + "beep-once": false, + /* When on, show the list of "active" users - those from + whom we have messages in the currently-loaded history + (noting that deletions are also messages). */ + "active-user-list": false, + /* When on, the [active-user-list] setting includes the + timestamp of each user's most recent message. */ + "active-user-list-timestamps": false, + /* When on, the [audible-alert] is played for one's own + messages, else it is only played for other users' + messages. */ + "alert-own-messages": false, + /* "Experimental mode" input: use a contenteditable field + for input. This is generally more comfortable to use, + and more modern, than plain text input fields, but + the list of browser-specific quirks and bugs is... + not short. */ + "edit-widget-x": false + } + }, + /** Plays a new-message notification sound IF the audible-alert + setting is true, else this is a no-op. Returns this. + */ + playNewMessageSound: function f(){ + if(f.uri){ + if(!cs.pageIsActive + /* ^^^ this could also arguably apply when chat is visible */ + && this.playedBeep && this.settings.getBool('beep-once',false)){ + return; + } + try{ + this.playedBeep = true; + if(!f.audio) f.audio = new Audio(f.uri); + f.audio.currentTime = 0; + f.audio.play(); + }catch(e){ + console.error("Audio playblack failed.", f.uri, e); + } + } + return this; + }, + /** + Sets the current new-message audio alert URI (must be a + repository-relative path which responds with an audio + file). Pass a falsy value to disable audio alerts. Returns + this. + */ + setNewMessageSound: function f(uri){ + this.playedBeep = false; + delete this.playNewMessageSound.audio; + this.playNewMessageSound.uri = uri; + this.settings.set('audible-alert', uri); + return this; + }, + /** + Expects e to be one of the elements in this.e.views. + The 'hidden' class is removed from e and added to + all other elements in that list. Returns e. + */ + setCurrentView: function(e){ + if(e===this.e.currentView){ + return e; + } + if( e!==this.e.viewZoom && this.e.zoomedMsg ){ + this.zoomMessage(null, e); + return this.e.currentView; + } + this.e.views.forEach(function(E){ + if(e!==E) D.addClass(E,'hidden'); + }); + this.e.currentView = e; + if(this.e.currentView.$beforeShow) this.e.currentView.$beforeShow(); + D.removeClass(e,'hidden'); + this.animate(this.e.currentView, 'anim-fade-in-fast'); + return this.e.currentView; + }, + + /** + Makes message element eMsg the content of this.e.viewZoom. + */ + zoomMessage: function(eMsg,nextView){ + const marker = this.e.zoomMarker; + if( !eMsg || eMsg===this.e.zoomedMsg ){ + if( this.e.zoomedMsg ){ + marker.parentNode.insertBefore(this.e.zoomedMsg, marker); + delete this.e.zoomedMsg; + } + this.setCurrentView(nextView || this.e.viewMessages); + return; + } + console.log("zoom message",eMsg); + if( this.e.zoomedMsg ){ + marker.parentNode.insertBefore(this.e.zoomedMsg, marker); + } + this.e.viewMessages.insertBefore(marker, eMsg); + this.e.zoomContent.appendChild(eMsg); + this.e.zoomedMsg = eMsg; + this.setCurrentView(this.e.viewZoom); + }, + /** + Updates the "active user list" view if we are not currently + batch-loading messages and if the active user list UI element + is active. + */ + updateActiveUserList: function callee(){ + if(this._isBatchLoading + || this.e.activeUserListWrapper.classList.contains('hidden')){ + return this; + }else if(!callee.sortUsersSeen){ + /** Array.sort() callback. Expects an array of user names and + sorts them in last-received message order (newest first). */ + const self = this; + callee.sortUsersSeen = function(l,r){ + l = self.usersLastSeen[l]; + r = self.usersLastSeen[r]; + if(l && r) return r - l; + else if(l) return -1; + else if(r) return 1; + else return 0; + }; + callee.addUserElem = function(u){ + const uSpan = D.addClass(D.span(), 'chat-user'); + const uDate = self.usersLastSeen[u]; + if(self.filterState.activeUser===u){ + uSpan.classList.add('selected'); + } + uSpan.dataset.uname = u; + D.append(uSpan, u, "\n", + D.append( + D.addClass(D.span(),'timestamp'), + localTimeString(uDate)//.substr(5/*chop off year*/) + )); + if(uDate.$uColor){ + uSpan.style.backgroundColor = uDate.$uColor; + } + D.append(self.e.activeUserList, uSpan); + }; + } + //D.clearElement(this.e.activeUserList); + D.remove(this.e.activeUserList.querySelectorAll('.chat-user')); + Object.keys(this.usersLastSeen).sort( + callee.sortUsersSeen + ).forEach(callee.addUserElem); + return this; + }, + /** Show or hide the active user list. Returns this object. */ + showActiveUserList: function(yes){ + if(0===arguments.length) yes = true; + this.e.activeUserListWrapper.classList[ + yes ? 'remove' : 'add' + ]('hidden'); + D.removeClass(Chat.e.activeUserListWrapper, 'collapsed'); + if(Chat.e.activeUserListWrapper.classList.contains('hidden')){ + /* When hiding this element, undo all filtering */ + Chat.setUserFilter(false); + /*Ideally we'd scroll the final message into view + now, but because viewMessages is currently hidden behind + viewConfig, scrolling is a no-op. */ + Chat.scrollMessagesTo(1); + }else{ + Chat.updateActiveUserList(); + Chat.animate(Chat.e.activeUserListWrapper, 'anim-flip-v'); + } + return this; + }, + showActiveUserTimestamps: function(yes){ + if(0===arguments.length) yes = true; + this.e.activeUserList.classList[yes ? 'add' : 'remove']('timestamps'); + return this; + }, + /** + Applies user name filter to all current messages, or clears + the filter if uname is falsy. + */ + setUserFilter: function(uname){ + this.filterState.activeUser = uname; + const mw = this.e.viewMessages.querySelectorAll('.message-widget'); + const self = this; + let eLast; + if(!uname){ + D.removeClass(Chat.e.viewMessages.querySelectorAll('.message-widget.hidden'), + 'hidden'); + }else{ + mw.forEach(function(w){ + if(self.filterState.match(w.dataset.xfrom)){ + w.classList.remove('hidden'); + eLast = w; + }else{ + w.classList.add('hidden'); + } + }); + } + if(eLast) eLast.scrollIntoView(false); + else this.scrollMessagesTo(1); + cs.e.activeUserList.querySelectorAll('.chat-user').forEach(function(e){ + e.classList[uname===e.dataset.uname ? 'add' : 'remove']('selected'); + }); + return this; + }, + + /** + If animations are enabled, passes its arguments + to D.addClassBriefly(), else this is a no-op. + If cb is a function, it is called after the + CSS class is removed. Returns this object; + */ + animate: function f(e,a,cb){ + if(!f.$disabled){ + D.addClassBriefly(e, a, 0, cb); + } + return this; + } + }/*Chat object*/; + cs.e.inputFields = [ cs.e.input1, cs.e.inputM, cs.e.inputX ]; + cs.e.inputFields.$currentIndex = 0; + cs.e.inputFields.forEach(function(e,ndx){ + if(ndx===cs.e.inputFields.$currentIndex) D.removeClass(e,'hidden'); + else D.addClass(e,'hidden'); + }); + if(D.attr(cs.e.inputX,'contenteditable','plaintext-only').isContentEditable){ + cs.$browserHasPlaintextOnly = true; + }else{ + /* contenteditable="plaintext-only" is a latecomer, not + supported in FF until version 136. */ + cs.$browserHasPlaintextOnly = false; + D.attr(cs.e.inputX,'contenteditable','true'); + } + cs.animate.$disabled = true; + F.fetch.beforesend = ()=>cs.ajaxStart(); + F.fetch.aftersend = ()=>cs.ajaxEnd(); + cs.pageTitleOrig = cs.e.pageTitle.innerText; + const qs = (e)=>document.querySelector(e); + const argsToArray = function(args){ + return Array.prototype.slice.call(args,0); + }; + /** + Reports an error via console.error() and as a toast message. + Accepts any argument types valid for fossil.toast.error(). + */ + cs.reportError = function(/*msg args*/){ + const args = argsToArray(arguments); + console.error("chat error:",args); + F.toast.error.apply(F.toast, args); + }; + + let InternalMsgId = 0; + /** + Reports an error in the form of a new message in the chat + feed. All arguments are appended to the message's content area + using fossil.dom.append(), so may be of any type supported by + that function. + */ + cs.reportErrorAsMessage = function f(/*msg args*/){ + const args = argsToArray(arguments).map(function(v){ + return (v instanceof Error) ? v.message : v; + }); + if(Chat.beVerbose){ + console.error("chat error:",args); + } + const d = new Date().toISOString(), + mw = new this.MessageWidget({ + isError: true, + xfrom: undefined, + msgid: "error-"+(++InternalMsgId), + mtime: d, + lmtime: d, + xmsg: args + }); + this.injectMessageElem(mw.e.body); + mw.scrollIntoView(); + return mw; + }; + + /** + For use by the connection poller to send a "connection + restored" message. + */ + cs.reportReconnection = function f(/*msg args*/){ + const args = argsToArray(arguments).map(function(v){ + return (v instanceof Error) ? v.message : v; + }); + const d = new Date().toISOString(), + mw = new this.MessageWidget({ + isError: false, + xfrom: undefined, + msgid: "reconnect-"+(++InternalMsgId), + mtime: d, + lmtime: d, + xmsg: args + }); + this.injectMessageElem(mw.e.body); + mw.scrollIntoView(); + return mw; + }; + + cs.getMessageElemById = function(id){ + return qs('[data-msgid="'+id+'"]'); + }; + + /** Finds the last .message-widget element and returns it or + the undefined value if none are found. */ + cs.fetchLastMessageElem = function(){ + const msgs = document.querySelectorAll('.message-widget'); + var rc; + if(msgs.length){ + rc = this.e.newestMessage = msgs[msgs.length-1]; + } + return rc; + }; + + /** + LOCALLY deletes a message element by the message ID or passing + the .message-row element. Returns true if it removes an element, + else false. + */ + cs.deleteMessageElem = function(id, silent){ + var e; + if(id instanceof HTMLElement){ + e = id; + id = e.dataset.msgid; + delete e.dataset.msgid; + if( e?.dataset?.alsoRemove ){ + const xId = e.dataset.alsoRemove; + delete e.dataset.alsoRemove; + this.deleteMessageElem( xId ); + } + }else if(id instanceof Chat.MessageWidget) { + if( this.e.eMsgPollError === e ){ + this.e.eMsgPollError = undefined; + } + if(id.e?.body){ + this.deleteMessageElem(id.e.body); + } + return; + } else{ + e = this.getMessageElemById(id); + } + if(e && id){ + D.remove(e); + if(e===this.e.newestMessage){ + this.fetchLastMessageElem(); + } + if( !silent ){ + F.toast.message("Deleted message "+id+"."); + } + } + return !!e; + }; + + /** + Toggles the given message between its parsed and plain-text + representations. It requires a server round-trip to collect the + plain-text form but caches it for subsequent toggles. + + Expects the ID of a currently-loaded message or a + message-widget DOM elment from which it can extract an id. + This is an async operation the first time it's passed a given + message and synchronous on subsequent calls for that + message. It is a no-op if id does not resolve to a loaded + message. + */ + cs.toggleTextMode = function(id){ + var e; + if(id instanceof HTMLElement){ + e = id; + id = e.dataset.msgid; + }else{ + e = this.getMessageElemById(id); + } + if(!e || !id) return false; + else if(e.$isToggling) return; + e.$isToggling = true; + const content = e.querySelector('.content-target'); + if(!content){ + console.warn("Should not be possible: trying to toggle text", + "mode of a message with no .content-target.", e); + return; + } + if(!content.$elems){ + content.$elems = [ + content.firstElementChild, // parsed elem + undefined // plaintext elem + ]; + }else if(content.$elems[1]){ + // We have both content types. Simply toggle them. + const child = ( + content.firstElementChild===content.$elems[0] + ? content.$elems[1] + : content.$elems[0] + ); + D.clearElement(content); + if(child===content.$elems[1]){ + /* When showing the unformatted version, inject a + copy-to-clipboard button. This is a workaround for + mouse-copying from that field collecting twice as many + newlines as it should (for unknown reasons). */ + const cpId = 'copy-to-clipboard-'+id; + /* ^^^ copy button element ID, needed for LABEL element + pairing. Recall that we destroy all child elements of + `content` each time we hit this block, so we can reuse + that element ID on subsequent toggles. */ + const btnCp = D.attr(D.addClass(D.button(),'copy-button'), 'id', cpId); + F.copyButton(btnCp, {extractText: ()=>child._xmsgRaw}); + const lblCp = D.label(cpId, "Copy unformatted text"); + D.append(content, D.append(D.addClass(D.span(), 'nobr'), btnCp, lblCp)); + } + delete e.$isToggling; + D.append(content, child); + return; + } + // We need to fetch the plain-text version... + const self = this; + F.fetch('chat-fetch-one',{ + urlParams:{ name: id, raw: true}, + responseType: 'json', + onload: function(msg){ + reportConnectionOkay('chat-fetch-one'); + content.$elems[1] = D.append(D.pre(),msg.xmsg); + content.$elems[1]._xmsgRaw = msg.xmsg/*used for copy-to-clipboard feature*/; + self.toggleTextMode(e); + }, + aftersend:function(){ + delete e.$isToggling; + Chat.ajaxEnd(); + } + }); + return true; + }; + + /** Given a .message-row element, this function returns whethe the + current user may, at least hypothetically, delete the message + globally. A user may always delete a local copy of a + post. The server may trump this, e.g. if the login has been + cancelled after this page was loaded. + */ + cs.userMayDelete = function(eMsg){ + return +eMsg.dataset.msgid>0 + && (this.me === eMsg.dataset.xfrom + || F.user.isAdmin/*will be confirmed server-side*/); + }; + + /** Returns a new Error() object encapsulating state from the given + fetch() response promise. */ + cs._newResponseError = function(response){ + return new Error([ + "HTTP status ", response.status,": ",response.url,": ", + response.statusText].join('')); + }; + + /** Helper for reporting HTTP-level response errors via fetch(). + If response.ok then response.json() is returned, else an Error + is thrown. */ + cs._fetchJsonOrError = function(response){ + if(response.ok) return response.json(); + else throw cs._newResponseError(response); + }; + + /** + Removes the given message ID from the local chat record and, if + the message was posted by this user OR this user in an + admin/setup, also submits it for removal on the remote. + + id may optionally be a DOM element, in which case it must be a + .message-row element. + */ + cs.deleteMessage = function(id){ + var e; + if(id instanceof HTMLElement){ + e = id; + id = e.dataset.msgid; + }else{ + e = this.getMessageElemById(id); + } + if(!(e instanceof HTMLElement)) return; + if(this.userMayDelete(e)){ + F.fetch("chat-delete/" + id, { + responseType: 'json', + onload:(r)=>{ + reportConnectionOkay('chat-delete'); + this.deleteMessageElem(r); + }, + onerror:(err)=>this.reportErrorAsMessage(err) + }); + }else{ + this.deleteMessageElem(id); + } + }; + document.addEventListener('visibilitychange', function(ev){ + cs.pageIsActive = ('visible' === document.visibilityState); + cs.playedBeep = false; + if(cs.pageIsActive){ + cs.e.pageTitle.innerText = cs.pageTitleOrig; + if(document.activeElement!==cs.inputElement()){ + /* An attempt to resolve usability problem reported by Joe + M. where the Pale Moon browser is giving input focus to + the Preview button. The down-side of this is that it will + deselect any text which was previously selected on this + page. This also, unfortunately, places the focus at the + start of the element, rather than the last cursor position + (like a textarea would). */ + setTimeout(()=>cs.inputFocus(), 0); + } + } + }, true); + cs.setCurrentView(cs.e.viewMessages); + + cs.e.activeUserList.addEventListener('click', function f(ev){ + /* Filter messages on a user clicked in activeUserList */ + ev.stopPropagation(); + ev.preventDefault(); + let eUser = ev.target; + while(eUser!==this && !eUser.classList.contains('chat-user')){ + eUser = eUser.parentNode; + } + if(eUser==this || !eUser) return false; + const uname = eUser.dataset.uname; + let eLast; + cs.setCurrentView(cs.e.viewMessages); + if(eUser.classList.contains('selected')){ + /* If currently selected, toggle filter off */ + eUser.classList.remove('selected'); + cs.setUserFilter(false); + delete f.$eSelected; + }else{ + if(f.$eSelected) f.$eSelected.classList.remove('selected'); + f.$eSelected = eUser; + eUser.classList.add('selected'); + cs.setUserFilter(uname); + } + return false; + }, false); + return cs; + })()/*Chat initialization*/; + + + /** Returns the first .message-widget element in DOM element + e's lineage. */ + const findMessageWidgetParent = function(e){ + while( e && !e.classList.contains('message-widget')){ + e = e.parentNode; + } + return e; + }; + + /** + Custom widget type for rendering messages (one message per + instance). These are modelled after FIELDSET elements but we + don't use FIELDSET because of cross-browser inconsistencies in + features of the FIELDSET/LEGEND combination, e.g. inability to + align legends via CSS in Firefox and clicking-related + deficiencies in Safari. + */ + Chat.MessageWidget = (function(){ + /** + Constructor. If passed an argument, it is passed to + this.setMessage() after initialization. + */ + const ctor = function(){ + this.e = { + body: D.addClass(D.div(), 'message-widget'), + tab: D.addClass(D.div(), 'message-widget-tab'), + content: D.addClass(D.div(), 'message-widget-content') + }; + D.append(this.e.body, this.e.tab, this.e.content); + this.e.tab.setAttribute('role', 'button'); + if(arguments.length){ + this.setMessage(arguments[0]); + } + }; + /* Left-zero-pad a number to at least 2 digits */ + const dowMap = { + /* Map of Date.getDay() values to weekday names. */ + 0: "Sunday", 1: "Monday", 2: "Tuesday", + 3: "Wednesday", 4: "Thursday", 5: "Friday", + 6: "Saturday" + }; + /* Given a Date, returns the timestamp string used in the "tab" + part of message widgets. If longFmt is true then a verbose + format is used, else a brief format is used. The returned string + is in client-local time. */ + const theTime = function(d, longFmt=false){ + const li = []; + if( longFmt ){ + li.push( + d.getFullYear(), + '-', pad2(d.getMonth()+1), + '-', pad2(d.getDate()), + ' ', + d.getHours(), ":", + (d.getMinutes()+100).toString().slice(1,3) + ); + }else{ + li.push( + d.getHours(),":", + (d.getMinutes()+100).toString().slice(1,3), + ' ', dowMap[d.getDay()] + ); + } + return li.join(''); + }; + + /** + Returns true if this page believes it can embed a view of the + file wrapped by the given message object, else returns false. + */ + const canEmbedFile = function f(msg){ + if(!f.$rx){ + f.$rx = /\.((html?)|(txt)|(md)|(wiki)|(pikchr))$/i; + f.$specificTypes = [ + /* Mime types we know we can embed, sans image/... */ + 'text/plain', + 'text/html', + 'text/x-markdown', + /* Firefox sends text/markdown when uploading .md files */ + 'text/markdown', + 'text/x-pikchr', + 'text/x-fossil-wiki' + /* Add more as we discover which ones Firefox won't + force the user to try to download. */ + ]; + } + if(msg.fmime){ + if(msg.fmime.startsWith("image/") + || f.$specificTypes.indexOf(msg.fmime)>=0){ + return true; + } + } + return (msg.fname && f.$rx.test(msg.fname)); + }; + + /** + Returns true if the given message object "should" + be embedded in fossil-rendered form instead of + raw content form. This is only intended to be passed + message objects for which canEmbedFile() returns true. + */ + const shouldFossilRenderEmbed = function f(msg){ + if(!f.$rx){ + f.$rx = /\.((md)|(wiki)|(pikchr))$/i; + f.$specificTypes = [ + 'text/x-markdown', + 'text/markdown' /* Firefox-uploaded md files */, + 'text/x-pikchr', + 'text/x-fossil-wiki' + ]; + } + if(msg.fmime){ + if(f.$specificTypes.indexOf(msg.fmime)>=0) return true; + } + return msg.fname && f.$rx.test(msg.fname); + }; + + const adjustIFrameSize = function(msgObj){ + const iframe = msgObj.e.iframe; + const body = iframe.contentWindow.document.querySelector('body'); + if(body && !body.style.fontSize){ + /** _Attempt_ to force the iframe to inherit the message's text size + if the body has no explicit size set. On desktop systems + the size is apparently being inherited in that case, but on mobile + not. */ + body.style.fontSize = window.getComputedStyle(msgObj.e.content).fontSize; + } + if('' === iframe.style.maxHeight){ + /* Resize iframe height to fit the content. Workaround: if we + adjust the iframe height while it's hidden then its height + is 0, so we must briefly unhide it. */ + const isHidden = iframe.classList.contains('hidden'); + if(isHidden) D.removeClass(iframe, 'hidden'); + iframe.style.maxHeight = iframe.style.height + = iframe.contentWindow.document.documentElement.scrollHeight + 'px'; + if(isHidden) D.addClass(iframe, 'hidden'); + } + }; + + ctor.prototype = { + scrollIntoView: function(){ + this.e.content.scrollIntoView(); + }, + //remove: function(silent){Chat.deleteMessageElem(this, silent);}, + setMessage: function(m){ + const ds = this.e.body.dataset; + ds.timestamp = m.mtime; + ds.lmtime = m.lmtime; + ds.msgid = m.msgid; + ds.xfrom = m.xfrom || ''; + if(m.xfrom === Chat.me){ + D.addClass(this.e.body, 'mine'); + } + if(m.uclr){ + this.e.content.style.backgroundColor = m.uclr; + this.e.tab.style.backgroundColor = m.uclr; + } + const d = new Date(m.mtime); + D.clearElement(this.e.tab); + var contentTarget = this.e.content; + var eXFrom /* element holding xfrom name */; + if(m.xfrom){ + eXFrom = D.append(D.addClass(D.span(), 'xfrom'), m.xfrom); + const wrapper = D.append( + D.span(), eXFrom, + ' ', + D.append(D.addClass(D.span(), 'msgid'), + '#' + (m.msgid||'???')), + (m.isSearchResult ? ' ' : ' @ '), + D.append(D.addClass(D.span(), 'timestamp'), + theTime(d,!!m.isSearchResult)) + ); + D.append(this.e.tab, wrapper); + }else{/*notification*/ + D.addClass(this.e.body, 'notification'); + if(m.isError){ + D.addClass([contentTarget, this.e.tab], 'error'); + } + D.append( + this.e.tab, + D.append(D.code(), 'notification @ ',theTime(d,false)) + ); + } + if( m.xfrom && m.fsize>0 ){ + if( m.fmime + && m.fmime.startsWith("image/") + && Chat.settings.getBool('images-inline',true) + ){ + const extension = m.fname.split('.').pop(); + contentTarget.appendChild(D.img("chat-download/" + m.msgid +( + extension ? ('.'+extension) : ''/*So that IMG tag mimetype guessing works*/ + ))); + ds.hasImage = 1; + }else{ + // Add a download link. + const downloadUri = window.fossil.rootPath+ + 'chat-download/' + m.msgid+'/'+encodeURIComponent(m.fname); + const w = D.addClass(D.div(), 'attachment-link'); + const a = D.a(downloadUri, + // ^^^ add m.fname to URL to cause downloaded file to have that name. + "(" + m.fname + " " + m.fsize + " bytes)" + ) + D.attr(a,'target','_blank'); + D.append(w, a); + if(canEmbedFile(m)){ + /* Add an option to embed HTML attachments in an iframe. The primary + use case is attached diffs. */ + const shouldFossilRender = shouldFossilRenderEmbed(m); + const downloadArgs = shouldFossilRender ? '?render' : ''; + D.addClass(contentTarget, 'wide'); + const embedTarget = this.e.content; + const self = this; + const btnEmbed = D.attr(D.checkbox("1", false), 'id', + 'embed-'+ds.msgid); + const btnLabel = D.label(btnEmbed, shouldFossilRender + ? "Embed (fossil-rendered)" : "Embed"); + /* Maintenance reminder: do not disable the toggle + button while the content is loading because that will + cause it to get stuck in disabled mode if the browser + decides that loading the content should prompt the + user to download it, rather than embed it in the + iframe. */ + btnEmbed.addEventListener('change',function(){ + if(self.e.iframe){ + if(btnEmbed.checked){ + D.removeClass(self.e.iframe, 'hidden'); + if(self.e.$iframeLoaded) adjustIFrameSize(self); + } + else D.addClass(self.e.iframe, 'hidden'); + return; + } + const iframe = self.e.iframe = document.createElement('iframe'); + D.append(embedTarget, iframe); + iframe.addEventListener('load', function(){ + self.e.$iframeLoaded = true; + adjustIFrameSize(self); + }); + iframe.setAttribute('src', downloadUri + downloadArgs); + }); + D.append(w, btnEmbed, btnLabel); + } + contentTarget.appendChild(w); + } + } + if(m.xmsg){ + if(m.fsize>0){ + /* We have file/image content, so need another element for + the message text. */ + contentTarget = D.div(); + D.append(this.e.content, contentTarget); + } + D.addClass(contentTarget, 'content-target' + /*target element for the 'toggle text mode' feature*/); + // The m.xmsg text comes from the same server as this script and + // is guaranteed by that server to be "safe" HTML - safe in the + // sense that it is not possible for a malefactor to inject HTML + // or javascript or CSS. The m.xmsg content might contain + // hyperlinks, but otherwise it will be markup-free. See the + // chat_format_to_html() routine in the server for details. + // + // Hence, even though innerHTML is normally frowned upon, it is + // perfectly safe to use in this context. + if(m.xmsg && 'string' !== typeof m.xmsg){ + // Used by Chat.reportErrorAsMessage() + D.append(contentTarget, m.xmsg); + }else{ + contentTarget.innerHTML = m.xmsg; + contentTarget.querySelectorAll('a').forEach(addAnchorTargetBlank); + if(F.pikchr){ + F.pikchr.addSrcView(contentTarget.querySelectorAll('svg.pikchr')); + } + } + } + //console.debug("tab",this.e.tab); + //console.debug("this.e.tab.firstElementChild",this.e.tab.firstElementChild); + this.e.tab.firstElementChild.addEventListener('click', this._handleLegendClicked, false); + /*if(eXFrom){ + eXFrom.addEventListener('click', ()=>this.e.tab.click(), false); + }*/ + return this; + }, + /* Event handler for clicking .message-user elements to show their + timestamps and a set of actions. */ + _handleLegendClicked: function f(ev){ + if(!f.popup){ + /* "Popup" widget */ + f.popup = { + e: D.addClass(D.div(), 'chat-message-popup'), + refresh:function(){ + const eMsg = this.$eMsg/*.message-widget element*/; + if(!eMsg) return; + D.clearElement(this.e); + const d = new Date(eMsg.dataset.timestamp); + if(d.getMinutes().toString()!=="NaN"){ + // Date works, render informative timestamps + const xfrom = eMsg.dataset.xfrom || 'server'; + D.append(this.e, + D.append(D.span(), localTimeString(d)," ",Chat.me," time"), + D.append(D.span(), iso8601ish(d))); + if(eMsg.dataset.lmtime && xfrom!==Chat.me){ + D.append(this.e, + D.append(D.span(), localTime8601( + new Date(eMsg.dataset.lmtime) + ).replace('T',' ')," ",xfrom," time")); + } + }else{ + /* This might not be necessary any more: it was + initially caused by Safari being stricter than + other browsers on its time string input, and that + has since been resolved by emiting a stricter + format. */ + // Date doesn't work, so dumb it down... + D.append(this.e, D.append(D.span(), eMsg.dataset.timestamp," zulu")); + } + const toolbar = D.addClass(D.div(), 'toolbar', 'hide-in-zoom'); + D.append(this.e, toolbar); + const self = this; + + const btnDeleteLocal = D.button("Delete locally"); + D.append(toolbar, btnDeleteLocal); + btnDeleteLocal.addEventListener('click', function(){ + self.hide(); + Chat.deleteMessageElem(eMsg) + }); + if( eMsg.classList.contains('notification') ){ + const btnDeletePoll = D.button("Delete /chat notifications?"); + D.append(toolbar, btnDeletePoll); + btnDeletePoll.addEventListener('click', function(){ + self.hide(); + Chat.e.viewMessages.querySelectorAll( + '.message-widget.notification:not(.resend-message)' + ).forEach(e=>Chat.deleteMessageElem(e, true)); + }); + } + if(Chat.userMayDelete(eMsg)){ + const btnDeleteGlobal = D.button("Delete globally"); + D.append(toolbar, btnDeleteGlobal); + F.confirmer(btnDeleteGlobal,{ + pinSize: true, + ticks: F.config.confirmerButtonTicks, + confirmText: "Confirm delete?", + onconfirm:function(){ + self.hide(); + Chat.deleteMessage(eMsg); + } + }); + } + const toolbar3 = D.addClass(D.div(), 'toolbar', 'hide-in-zoom'); + D.append(this.e, toolbar3); + D.append(toolbar3, D.button( + "Locally remove all previous messages", + function(){ + self.hide(); + Chat.mnMsg = +eMsg.dataset.msgid; + var e = eMsg.previousElementSibling; + while(e && e.classList.contains('message-widget')){ + const n = e.previousElementSibling; + D.remove(e); + e = n; + } + eMsg.scrollIntoView(); + } + )); + const toolbar2 = D.addClass(D.div(), 'toolbar'); + D.append(this.e, toolbar2); + if(eMsg.querySelector('.content-target')){ + /* ^^^ messages with only an embedded image have no + .content-target area. */ + D.append(toolbar2, D.button( + "Toggle text mode", function(){ + self.hide(); + Chat.toggleTextMode(eMsg); + })); + } + if(eMsg.dataset.xfrom){ + /* Add a link to the /timeline filtered on this user. */ + const timelineLink = D.attr( + D.a(F.repoUrl('timeline',{ + u: eMsg.dataset.xfrom, + y: 'a' + }), "User's Timeline"), + 'target', '_blank' + ); + D.append(toolbar2, timelineLink); + if(Chat.filterState.activeUser && + Chat.filterState.match(eMsg.dataset.xfrom)){ + /* Add a button to clear user filter and jump to + this message in its original context. */ + D.append( + this.e, + D.append( + D.addClass(D.div(), 'toolbar'), + D.button( + "Message in context", + function(){ + self.hide(); + Chat.setUserFilter(false); + eMsg.scrollIntoView(false); + Chat.animate( + eMsg.firstElementChild, 'anim-flip-h' + //eMsg.firstElementChild, 'anim-flip-v' + //eMsg.childNodes, 'anim-rotate-360' + //eMsg.childNodes, 'anim-flip-v' + //eMsg, 'anim-flip-v' + ); + }) + ) + ); + }/*jump-to button*/ + } + const btnZoom = D.button("Zoom"); + D.append(toolbar2, btnZoom); + btnZoom.addEventListener('click', function(){ + Chat.zoomMessage(eMsg); + }); + const tab = eMsg.querySelector('.message-widget-tab'); + D.append(tab, this.e); + D.removeClass(this.e, 'hidden'); + Chat.animate(this.e, 'anim-fade-in-fast'); + }/*refresh()*/, + hide: function(){ + delete this.$eMsg; + D.addClass(this.e, 'hidden'); + D.clearElement(this.e); + }, + show: function(tgtMsg){ + if(tgtMsg === this.$eMsg){ + this.hide(); + return; + } + this.$eMsg = tgtMsg; + this.refresh(); + } + }/*f.popup*/; + }/*end static init*/ + const theMsg = findMessageWidgetParent(ev.target); + if(theMsg) f.popup.show(theMsg); + }/*_handleLegendClicked()*/ + }; + return ctor; + })()/*MessageWidget*/; + + /** + A widget for loading more messages (context) around a /chat-query + result message. + */ + Chat.SearchCtxLoader = (function(){ + const nMsgContext = 5; + const zUpArrow = '\u25B2'; + const zDownArrow = '\u25BC'; + const ctor = function(o){ + + /* iFirstInTable: + ** msgid of first row in chatfts table. + ** + ** iLastInTable: + ** msgid of last row in chatfts table. + ** + ** iPrevId: + ** msgid of message immediately above this spacer. Or 0 if this + ** spacer is above all results. + ** + ** iNextId: + ** msgid of message immediately below this spacer. Or 0 if this + ** spacer is below all results. + ** + ** bIgnoreClick: + ** ignore any clicks if this is true. This is used to ensure there + ** is only ever one request belonging to this widget outstanding + ** at any time. + */ + this.o = { + iFirstInTable: o.first, + iLastInTable: o.last, + iPrevId: o.previd, + iNextId: o.nextid, + bIgnoreClick: false + }; + + this.e = { + body: D.addClass(D.div(), 'spacer-widget'), + up: D.addClass( + D.button(zDownArrow+' Load '+nMsgContext+' more '+zDownArrow), + 'up' + ), + down: D.addClass( + D.button(zUpArrow+' Load '+nMsgContext+' more '+zUpArrow), + 'down' + ), + all: D.addClass(D.button('Load More'), 'all') + }; + D.append( this.e.body, this.e.up, this.e.down, this.e.all ); + const ms = this; + this.e.up.addEventListener('click', ()=>ms.load_messages(false)); + this.e.down.addEventListener('click', ()=>ms.load_messages(true)); + this.e.all.addEventListener('click', ()=>ms.load_messages( (ms.o.iPrevId==0) )); + this.set_button_visibility(); + }; + + ctor.prototype = { + set_button_visibility: function() { + if( !this.e ) return; + const o = this.o; + + const iPrevId = (o.iPrevId!=0) ? o.iPrevId : o.iFirstInTable-1; + const iNextId = (o.iNextId!=0) ? o.iNextId : o.iLastInTable+1; + let nDiff = (iNextId - iPrevId) - 1; + + for( const x of [this.e.up, this.e.down, this.e.all] ){ + if( x ) D.addClass(x, 'hidden'); + } + let nVisible = 0; + if( nDiff>0 ){ + if( nDiff>nMsgContext && (o.iPrevId==0 || o.iNextId==0) ){ + nDiff = nMsgContext; + } + + if( nDiff<=nMsgContext && o.iPrevId!=0 && o.iNextId!=0 ){ + D.removeClass(this.e.all, 'hidden'); + ++nVisible; + this.e.all.innerText = ( + zUpArrow + " Load " + nDiff + " more " + zDownArrow + ); + }else{ + if( o.iPrevId!=0 ){ + ++nVisible; + D.removeClass(this.e.up, 'hidden'); + }else if( this.e.up ){ + if( this.e.up.parentNode ) D.remove(this.e.up); + delete this.e.up; + } + if( o.iNextId!=0 ){ + ++nVisible; + D.removeClass(this.e.down, 'hidden'); + }else if( this.e.down ){ + if( this.e.down.parentNode ) D.remove( this.e.down ); + delete this.e.down; + } + } + } + if( !nVisible ){ + /* The DOM elements can now be disposed of. */ + for( const x of [this.e.up, this.e.down, this.e.all, this.e.body] ){ + if( x?.parentNode ) D.remove(x); + } + delete this.e; + } + }, + + load_messages: function(bDown) { + if( this.bIgnoreClick ) return; + + var iFirst = 0; /* msgid of first message to fetch */ + var nFetch = 0; /* Number of messages to fetch */ + var iEof = 0; /* last msgid in spacers range, plus 1 */ + + const e = this.e, o = this.o; + this.bIgnoreClick = true; + + /* Figure out the required range of messages. */ + if( bDown ){ + iFirst = this.o.iNextId - nMsgContext; + if( iFirst<this.o.iFirstInTable ){ + iFirst = this.o.iFirstInTable; + } + }else{ + iFirst = this.o.iPrevId+1; + } + nFetch = nMsgContext; + iEof = (this.o.iNextId > 0) ? this.o.iNextId : this.o.iLastInTable+1; + if( iFirst+nFetch>iEof ){ + nFetch = iEof - iFirst; + } + const ms = this; + F.fetch("chat-query",{ + urlParams:{ + q: '', + n: nFetch, + i: iFirst + }, + responseType: "json", + onload:function(jx){ + reportConnectionOkay('chat-query'); + if( bDown ) jx.msgs.reverse(); + jx.msgs.forEach((m) => { + m.isSearchResult = true; + var mw = new Chat.MessageWidget(m); + if( bDown ){ + /* Inject the message below this object's body, or + append it to Chat.e.searchContent if this element + is the final one in its parent (Chat.e.searchContent). */ + const eAnchor = e.body.nextElementSibling; + if( eAnchor ) Chat.e.searchContent.insertBefore(mw.e.body, eAnchor); + else D.append(Chat.e.searchContent, mw.e.body); + }else{ + Chat.e.searchContent.insertBefore(mw.e.body, e.body); + } + }); + if( bDown ){ + o.iNextId -= jx.msgs.length; + }else{ + o.iPrevId += jx.msgs.length; + } + ms.set_button_visibility(); + ms.bIgnoreClick = false; + } + }); + } + }; + + return ctor; + })() /*SearchCtxLoader*/; + + const BlobXferState = (function(){ + /* State for paste and drag/drop */ + const bxs = { + dropDetails: document.querySelector('#chat-drop-details'), + blob: undefined, + clear: function(){ + this.blob = undefined; + D.clearElement(this.dropDetails); + Chat.e.inputFile.value = ""; + } + }; + /** Updates the paste/drop zone with details of the pasted/dropped + data. The argument must be a Blob or Blob-like object (File) or + it can be falsy to reset/clear that state.*/ + const updateDropZoneContent = bxs.updateDropZoneContent = function(blob){ + //console.debug("updateDropZoneContent()",blob); + const dd = bxs.dropDetails; + bxs.blob = blob; + D.clearElement(dd); + if(!blob){ + Chat.e.inputFile.value = ''; + return; + } + D.append(dd, "Attached: ", blob.name, + D.br(), "Size: ",blob.size); + const btn = D.button("Cancel"); + D.append(dd, D.br(), btn); + btn.addEventListener('click', ()=>updateDropZoneContent(), false); + if(blob.type && (blob.type.startsWith("image/") || blob.type==='BITMAP')){ + const img = D.img(); + D.append(dd, D.br(), img); + const reader = new FileReader(); + reader.onload = (e)=>img.setAttribute('src', e.target.result); + reader.readAsDataURL(blob); + } + }; + Chat.e.inputFile.addEventListener('change', function(ev){ + updateDropZoneContent(this?.files[0]) + }); + /* Handle image paste from clipboard. TODO: figure out how we can + paste non-image binary data as if it had been selected via the + file selection element. */ + const pasteListener = function(event){ + const items = event.clipboardData.items, + item = items[0]; + //console.debug("paste event",event.target,item,event); + //console.debug("paste event item",item); + if(item && item.type && ('file'===item.kind || 'BITMAP'===item.type)){ + updateDropZoneContent(false/*clear prev state*/); + updateDropZoneContent(item.getAsFile()); + event.stopPropagation(); + event.preventDefault(true); + return false; + } + /* else continue propagating */ + }; + document.addEventListener('paste', pasteListener, true); + if(window.Selection && window.Range && !Chat.$browserHasPlaintextOnly){ + /* Acrobatics to keep *some* installations of Firefox + from pasting formatting into contenteditable fields. + This also works on Chrome, but chrome has the + contenteditable=plaintext-only property which does this + for us. */ + Chat.e.inputX.addEventListener( + 'paste', + function(ev){ + if (ev.clipboardData && ev.clipboardData.getData) { + const pastedText = ev.clipboardData.getData('text/plain'); + const selection = window.getSelection(); + if (!selection.rangeCount) return false; + selection.deleteFromDocument(/*remove selected content*/); + selection.getRangeAt(0).insertNode(document.createTextNode(pastedText)); + selection.collapseToEnd(/*deselect pasted text and set cursor at the end*/); + ev.preventDefault(); + return false; + } + }, false); + } + const noDragDropEvents = function(ev){ + /* contenteditable tries to do its own thing with dropped data, + which is not compatible with how we use it, so... */ + ev.dataTransfer.effectAllowed = 'none'; + ev.dataTransfer.dropEffect = 'none'; + ev.preventDefault(); + ev.stopPropagation(); + return false; + }; + ['drop','dragenter','dragleave','dragend'].forEach( + (k)=>Chat.e.inputX.addEventListener(k, noDragDropEvents, false) + ); + return bxs; + })()/*drag/drop/paste*/; + + const localTime8601 = function(d){ + return [ + d.getYear()+1900, '-', pad2(d.getMonth()+1), '-', pad2(d.getDate()), + 'T', pad2(d.getHours()),':', pad2(d.getMinutes()),':',pad2(d.getSeconds()) + ].join(''); + }; + + /** + Called by Chat.submitMessage() when message sending failed. Injects a fake message + containing the content and attachment of the failed message and gives the user buttons + to discard it or edit and retry. + */ + const recoverFailedMessage = function(state){ + const w = D.addClass(D.div(), 'failed-message'); + D.append(w, D.append( + D.span(),"This message was not successfully sent to the server:" + )); + if(state.msg){ + const ta = D.textarea(); + ta.value = state.msg; + ta.setAttribute('readonly','true'); + D.append(w,ta); + } + if(state.blob){ + D.append(w,D.append(D.span(),"Attachment: ",(state.blob.name||"unnamed"))); + //console.debug("blob = ",state.blob); + } + const buttons = D.addClass(D.div(), 'buttons'); + D.append(w, buttons); + D.append(buttons, D.button("Discard message?", function(){ + const theMsg = findMessageWidgetParent(w); + if(theMsg) Chat.deleteMessageElem(theMsg); + })); + D.append(buttons, D.button("Edit message and try again?", function(){ + if(state.msg) Chat.inputValue(state.msg); + if(state.blob) BlobXferState.updateDropZoneContent(state.blob); + const theMsg = findMessageWidgetParent(w); + if(theMsg) Chat.deleteMessageElem(theMsg); + })); + D.addClass(Chat.reportErrorAsMessage(w).e.body, "resend-message"); + }; + + /* Assume the connection has been established, reset the + Chat.timer.tidClearPollErr, and (if showMsg and + !!Chat.e.eMsgPollError) alert the user that the outage appears to + be over. Also schedule Chat.poll() to run in the very near + future. */ + const reportConnectionOkay = function(dbgContext, showMsg = true){ + if(Chat.beVerbose){ + console.warn('reportConnectionOkay', dbgContext, + 'Chat.e.pollErrorMarker classes =', + Chat.e.pollErrorMarker.classList, + 'Chat.timer.tidClearPollErr =',Chat.timer.tidClearPollErr, + 'Chat.timer =',Chat.timer); + } + if( Chat.timer.errCount ){ + D.removeClass(Chat.e.pollErrorMarker, 'connection-error'); + Chat.timer.errCount = 0; + } + Chat.timer.cancelReconnectCheckTimer().startPendingPollTimer(); + if( Chat.e.eMsgPollError ) { + const oldErrMsg = Chat.e.eMsgPollError; + Chat.e.eMsgPollError = undefined; + if( showMsg ){ + if(Chat.beVerbose){ + console.log("Poller Connection restored."); + } + const m = Chat.reportReconnection("Poller connection restored."); + if( oldErrMsg ){ + D.remove(oldErrMsg.e?.body.querySelector('button.retry-now')); + } + m.e.body.dataset.alsoRemove = oldErrMsg?.e?.body?.dataset?.msgid; + D.addClass(m.e.body,'poller-connection'); + } + } + }; + + /** + Submits the contents of the message input field (if not empty) + and/or the file attachment field to the server. If both are + empty, this is a no-op. + + If the current view is the history search, this instead sends the + input text to that widget. + */ + Chat.submitMessage = function f(){ + if(!f.spaces){ + f.spaces = /\s+$/; + f.markdownContinuation = /\\\s+$/; + f.spaces2 = /\s{3,}$/; + } + switch( this.e.currentView ){ + case this.e.viewSearch: this.submitSearch(); + return; + default: break; + } + this.setCurrentView(this.e.viewMessages); + const fd = new FormData(); + const fallback = {msg: this.inputValue()}; + var msg = fallback.msg; + if(msg && (msg.indexOf('\n')>0 || f.spaces.test(msg))){ + /* Cosmetic: trim most whitespace from the ends of lines to try to + keep copy/paste from terminals, especially wide ones, from + forcing a horizontal scrollbar on all clients. This breaks + markdown's use of blackslash-space-space for paragraph + continuation, but *not* doing this affects all clients every + time someone pastes in console copy/paste from an affected + platform. We seem to have narrowed to the console pasting + problem to users of tmux together with certain apps (vim, at + a minimum). Most consoles don't behave that way. + + We retain two trailing spaces so that markdown conventions + which use end-of-line spacing aren't broken by this + stripping. + */ + const xmsg = msg.split('\n'); + xmsg.forEach(function(line,ndx){ + if(!f.markdownContinuation.test(line)){ + xmsg[ndx] = line.replace(f.spaces2, ' '); + } + }); + msg = xmsg.join('\n'); + } + if(msg) fd.set('msg',msg); + const file = BlobXferState.blob || this.e.inputFile.files[0]; + if(file) fd.set("file", file); + if( !msg && !file ) return; + fallback.blob = file; + const self = this; + fd.set("lmtime", localTime8601(new Date())); + F.fetch("chat-send",{ + payload: fd, + responseType: 'text', + onerror:function(err){ + self.reportErrorAsMessage(err); + recoverFailedMessage(fallback); + }, + onload:function(txt){ + reportConnectionOkay('chat-send'); + if(!txt) return/*success response*/; + try{ + const json = JSON.parse(txt); + self.newContent({msgs:[json]}); + }catch(e){ + self.reportError(e); + } + recoverFailedMessage(fallback); + } + }); + BlobXferState.clear(); + Chat.inputValue("").inputFocus(); + }; + + const inputWidgetKeydown = function f(ev){ + if(!f.$toggleCtrl){ + f.$toggleCtrl = function(currentMode){ + currentMode = !currentMode; + Chat.settings.set('edit-ctrl-send', currentMode); + }; + f.$toggleCompact = function(currentMode){ + currentMode = !currentMode; + Chat.settings.set('edit-compact-mode', currentMode); + }; + } + if(13 !== ev.keyCode) return; + const text = Chat.inputValue().trim(); + const ctrlMode = Chat.settings.getBool('edit-ctrl-send', false); + //console.debug("Enter key event:", ctrlMode, ev.ctrlKey, ev.shiftKey, ev); + if(ev.shiftKey){ + ev.preventDefault(); + ev.stopPropagation(); + /* Shift-enter will run preview mode UNLESS the input field is empty + AND (preview or search mode) is active, in which cases it will + switch back to message view. */ + if(!text && + (Chat.e.currentView===Chat.e.viewPreview + | Chat.e.currentView===Chat.e.viewSearch)){ + Chat.setCurrentView(Chat.e.viewMessages); + }else if(!text){ + const compactMode = Chat.settings.getBool('edit-compact-mode', false); + f.$toggleCompact(compactMode); + }else if(Chat.settings.getBool('edit-shift-enter-preview', true)){ + Chat.e.btnPreview.click(); + } + return false; + } + if(ev.ctrlKey && !text && !BlobXferState.blob){ + /* Ctrl-enter on empty input field(s) toggles Enter/Ctrl-enter mode */ + ev.preventDefault(); + ev.stopPropagation(); + f.$toggleCtrl(ctrlMode); + return false; + } + if(!ctrlMode && ev.ctrlKey && text){ + //console.debug("!ctrlMode && ev.ctrlKey && text."); + /* Ctrl-enter in Enter-sends mode SHOULD, with this logic add a + newline, but that is not happening, for unknown reasons + (possibly related to this element being a contenteditable DIV + instead of a textarea). Forcibly appending a newline do the + input area does not work, also for unknown reasons, and would + only be suitable when we're at the end of the input. + + Strangely, this approach DOES work for shift-enter, but we + need shift-enter as a hotkey for preview mode. + */ + //return; + // return here "should" cause newline to be added, but that doesn't work + } + if((!ctrlMode && !ev.ctrlKey) || (ev.ctrlKey/* && ctrlMode*/)){ + /* Ship it! */ + ev.preventDefault(); + ev.stopPropagation(); + Chat.submitMessage(); + return false; + } + }; + Chat.e.inputFields.forEach( + (e)=>e.addEventListener('keydown', inputWidgetKeydown, false) + ); + Chat.e.btnSubmit.addEventListener('click',(e)=>{ + e.preventDefault(); + Chat.submitMessage(); + return false; + }); + Chat.e.btnAttach.addEventListener( + 'click', ()=>Chat.e.inputFile.click(), false); + + (function(){/*Set up #chat-button-settings and related bits */ + if(window.innerWidth<window.innerHeight){ + // Must be set up before config view is... + /* Alignment of 'my' messages: right alignment is conventional + for mobile chat apps but can be difficult to read in wide + windows (desktop/tablet landscape mode), so we default to a + layout based on the apparent "orientation" of the window: + tall vs wide. Can be toggled via settings. */ + document.body.classList.add('my-messages-right'); + } + const settingsButton = document.querySelector('#chat-button-settings'); + const optionsMenu = E1('#chat-config-options'); + const eToggleView = function(ev){ + ev.preventDefault(); + ev.stopPropagation(); + Chat.setCurrentView(Chat.e.currentView===Chat.e.viewConfig + ? Chat.e.viewMessages : Chat.e.viewConfig); + return false; + }; + D.attr(settingsButton, 'role', 'button').addEventListener('click', eToggleView, false); + Chat.e.viewConfig.querySelector('button.action-close').addEventListener('click', eToggleView, false); + + /** Internal acrobatics to allow certain settings toggles to access + related toggles. */ + const namedOptions = { + activeUsers:{ + label: "Show active users list", + hint: "List users who have messages in the currently-loaded chat history.", + boolValue: 'active-user-list' + } + }; + if(1){ + /* Per user request, toggle the list of users on and off if the + legend element is tapped. */ + const optAu = namedOptions.activeUsers; + optAu.theLegend = Chat.e.activeUserListWrapper.firstElementChild/*LEGEND*/; + optAu.theList = optAu.theLegend.nextElementSibling/*user list container*/; + optAu.theLegend.addEventListener('click',function(){ + D.toggleClass(Chat.e.activeUserListWrapper, 'collapsed'); + if(!Chat.e.activeUserListWrapper.classList.contains('collapsed')){ + Chat.animate(optAu.theList,'anim-flip-v'); + } + }, false); + }/*namedOptions.activeUsers additional setup*/ + /* Settings menu entries... they are presented in the order listed + here, so the most frequently-needed ones "should" (arguably) be + closer to the start of this list. */ + /** + Settings ops structure: + + label: string for the UI + + boolValue: string (name of Chat.settings setting) or a function + which returns true or false. If it is a string, it gets + replaced by a function which returns + Chat.settings.getBool(thatString) and the string gets assigned + to the persistentSetting property of this object. + + select: SELECT element (instead of boolValue) + + callback: optional handler to call after setting is modified. + Its "this" is the options object. If this object has a + boolValue string or a persistentSetting property, the argument + passed to the callback is a settings object in the form {key:K, + value:V}. If this object does not have boolValue string or + persistentSetting then the callback is passed an event object + in response to the config option's UI widget being activated, + normally a 'change' event. + + children: [array of settings objects]. These get listed under + this element and indented slightly for visual grouping. Only + one level of indention is supported. + + Elements which only have a label and maybe a hint and + children can be used as headings. + + If a setting has a boolValue set, that gets rendered as a + checkbox which toggles the given persistent setting (if + boolValue is a string) AND listens for changes to that setting + fired via Chat.settings.set() so that the checkbox can stay in + sync with external changes to that setting. Various Chat UI + elements stay in sync with the config UI via those settings + events. The checkbox element gets added to the options object + so that the callback() can reference it via this.checkbox. + */ + const settingsOps = [{ + label: "Chat Configuration Options", + hint: F.storage.isTransient() + ? "Local store is unavailable. These settings are transient." + : ["Most of these settings are persistent via ", + F.storage.storageImplName(), ": ", + F.storage.storageHelpDescription()].join('') + },{ + label: "Editing Options...", + hint: ["These options are all recommended but some misinteract", + "with specific browsers or software keyboards so they", + "are not enabled by default." + ].join(' '), + children:[{ + label: "Chat-only mode", + hint: "Toggle the page between normal fossil view and chat-only view.", + boolValue: 'chat-only-mode' + },{ + label: "Ctrl-enter to Send", + hint: [ + "When on, only Ctrl-Enter will send messages and Enter adds ", + "blank lines. When off, both Enter and Ctrl-Enter send. ", + "When the input field has focus and is empty ", + "then Ctrl-Enter toggles this setting." + ].join(''), + boolValue: 'edit-ctrl-send' + },{ + label: "Compact mode", + hint: [ + "Toggle between a space-saving or more spacious writing area. ", + "When the input field has focus and is empty ", + "then Shift-Enter may (depending on the current view) toggle this setting." + ].join(''), + boolValue: 'edit-compact-mode' + },{ + label: "Use 'contenteditable' editing mode", + boolValue: 'edit-widget-x', + hint: [ + "When enabled, chat input uses a so-called 'contenteditable' ", + "field. Though generally more comfortable and modern than ", + "plain-text input fields, browser-specific quirks and bugs ", + "may lead to frustration. Ideal for mobile devices." + ].join('') + },{ + label: "Shift-enter to preview", + hint: ["Use shift-enter to preview being-edited messages. ", + "This is normally desirable but some software-mode ", + "keyboards misinteract with this, in which cases it can be ", + "disabled."], + boolValue: 'edit-shift-enter-preview' + }] + },{ + label: "Appearance Options...", + children:[{ + label: "Left-align my posts", + hint: "Default alignment of your own messages is selected " + + "based on the window width/height ratio.", + boolValue: ()=>!document.body.classList.contains('my-messages-right'), + callback: function f(){ + document.body.classList[ + this.checkbox.checked ? 'remove' : 'add' + ]('my-messages-right'); + } + },{ + label: "Monospace message font", + hint: "Use monospace font for message and input text.", + boolValue: 'monospace-messages', + callback: function(setting){ + document.body.classList[ + setting.value ? 'add' : 'remove' + ]('monospace-messages'); + } + },{ + label: "Show images inline", + hint: "When enabled, attached images are shown inline, "+ + "else they appear as a download link.", + boolValue: 'images-inline' + }] + }]; + + /** Set up selection list of notification sounds. */ + if(1){ + const selectSound = D.select(); + D.option(selectSound, "", "(no audio)"); + const firstSoundIndex = selectSound.options.length; + F.config.chat.alerts.forEach((a)=>D.option(selectSound, a)); + if(true===Chat.settings.getBool('audible-alert')){ + /* This setting used to be a plain bool. If we encounter + such a setting, take the first sound in the list. */ + selectSound.selectedIndex = firstSoundIndex; + }else{ + selectSound.value = Chat.settings.get('audible-alert','<none>'); + if(selectSound.selectedIndex<0){ + /* Missing file - removed after this setting was + applied. Fall back to the first sound in the list. */ + selectSound.selectedIndex = firstSoundIndex; + } + } + Chat.setNewMessageSound(selectSound.value); + settingsOps.push({ + label: "Sound Options...", + hint: "How to enable audio playback is browser-specific!", + children:[{ + hint: "Audio alert", + select: selectSound, + callback: function(ev){ + const v = ev.target.value; + Chat.setNewMessageSound(v); + F.toast.message("Audio notifications "+(v ? "enabled" : "disabled")+"."); + if(v) setTimeout(()=>Chat.playNewMessageSound(), 0); + } + },{ + label: "Notify only once when away", + hint: "Notify only for the first message received after chat is hidden from view.", + boolValue: 'beep-once' + },{ + label: "Play notification for your own messages", + hint: "When enabled, the audio notification will be played for all messages, "+ + "including your own. When disabled only messages from other users "+ + "will trigger a notification.", + boolValue: 'alert-own-messages' + }] + }); + }/*audio notification config*/ + settingsOps.push({ + label: "Active User List...", + hint: [ + "/chat cannot track active connections, but it can tell ", + "you who has posted recently..."].join(''), + children:[ + namedOptions.activeUsers,{ + label: "Timestamps in active users list", + indent: true, + hint: "Show most recent message timestamps in the active user list.", + boolValue: 'active-user-list-timestamps' + } + ] + }); + /** + Build UI for config options... + */ + settingsOps.forEach(function f(op,indentOrIndex){ + const menuEntry = D.addClass(D.div(), 'menu-entry'); + if(true===indentOrIndex) D.addClass(menuEntry, 'child'); + const label = op.label + ? D.append(D.label(),op.label) : undefined; + const labelWrapper = D.addClass(D.div(), 'label-wrapper'); + var hint; + if(op.hint){ + hint = D.append(D.addClass(D.label(),'hint'),op.hint); + } + if(op.hasOwnProperty('select')){ + const col0 = D.addClass(D.span(/*empty, but for spacing*/), + 'toggle-wrapper'); + D.append(menuEntry, labelWrapper, col0); + D.append(labelWrapper, op.select); + if(hint) D.append(labelWrapper, hint); + if(label) D.append(label); + if(op.callback){ + op.select.addEventListener('change', (ev)=>op.callback(ev), false); + } + }else if(op.hasOwnProperty('boolValue')){ + if(undefined === f.$id) f.$id = 0; + ++f.$id; + if('string' ===typeof op.boolValue){ + const key = op.boolValue; + op.boolValue = ()=>Chat.settings.getBool(key); + op.persistentSetting = key; + } + const check = op.checkbox + = D.attr(D.checkbox(1, op.boolValue()), + 'aria-label', op.label); + const id = 'cfgopt'+f.$id; + const col0 = D.addClass(D.span(), 'toggle-wrapper'); + check.checked = op.boolValue(); + op.checkbox = check; + D.attr(check, 'id', id); + if(hint) D.attr(hint, 'for', id); + D.append(menuEntry, labelWrapper, col0); + D.append(col0, check); + if(label){ + D.attr(label, 'for', id); + D.append(labelWrapper, label); + } + if(hint) D.append(labelWrapper, hint); + }else{ + if(op.callback){ + menuEntry.addEventListener('click', (ev)=>op.callback(ev)); + } + D.append(menuEntry, labelWrapper); + if(label) D.append(labelWrapper, label); + if(hint) D.append(labelWrapper, hint); + } + D.append(optionsMenu, menuEntry); + if(op.persistentSetting){ + Chat.settings.addListener( + op.persistentSetting, + function(setting){ + if(op.checkbox) op.checkbox.checked = !!setting.value; + else if(op.select) op.select.value = setting.value; + if(op.callback) op.callback(setting); + } + ); + if(op.checkbox){ + op.checkbox.addEventListener( + 'change', function(){ + Chat.settings.set(op.persistentSetting, op.checkbox.checked) + }, false); + } + }else if(op.callback && op.checkbox){ + op.checkbox.addEventListener('change', (ev)=>op.callback(ev), false); + } + if(op.children){ + D.addClass(menuEntry, 'parent'); + op.children.forEach((x)=>f(x,true)); + } + }); + })()/*#chat-button-settings setup*/; + + (function(){ + /* Install default settings... must come after + chat-button-settings setup so that the listeners which that + installs are notified via the properties getting initialized + here. */ + Chat.settings.addListener('monospace-messages',function(s){ + document.body.classList[s.value ? 'add' : 'remove']('monospace-messages'); + }) + Chat.settings.addListener('active-user-list',function(s){ + Chat.showActiveUserList(s.value); + }); + Chat.settings.addListener('active-user-list-timestamps',function(s){ + Chat.showActiveUserTimestamps(s.value); + }); + Chat.settings.addListener('chat-only-mode',function(s){ + Chat.chatOnlyMode(s.value); + }); + Chat.settings.addListener('edit-widget-x',function(s){ + let eSelected; + if(s.value){ + if(Chat.e.inputX===Chat.inputElement()) return; + eSelected = Chat.e.inputX; + }else{ + eSelected = Chat.settings.getBool('edit-compact-mode') + ? Chat.e.input1 : Chat.e.inputM; + } + const v = Chat.inputValue(); + Chat.inputValue(''); + Chat.e.inputFields.forEach(function(e,ndx){ + if(eSelected===e){ + Chat.e.inputFields.$currentIndex = ndx; + D.removeClass(e, 'hidden'); + } + else D.addClass(e,'hidden'); + }); + Chat.inputValue(v); + eSelected.focus(); + }); + Chat.settings.addListener('edit-compact-mode',function(s){ + if(Chat.e.inputX!==Chat.inputElement()){ + /* Text field/textarea mode: swap them if needed. + Compact mode of inputX is toggled via CSS. */ + const a = s.value + ? [Chat.e.input1, Chat.e.inputM, 0] + : [Chat.e.inputM, Chat.e.input1, 1]; + const v = Chat.inputValue(); + Chat.inputValue(''); + Chat.e.inputFields.$currentIndex = a[2]; + Chat.inputValue(v); + D.removeClass(a[0], 'hidden'); + D.addClass(a[1], 'hidden'); + } + Chat.e.inputLineWrapper.classList[ + s.value ? 'add' : 'remove' + ]('compact'); + Chat.e.inputFields[Chat.e.inputFields.$currentIndex].focus(); + }); + Chat.settings.addListener('edit-ctrl-send',function(s){ + const label = (s.value ? "Ctrl-" : "")+"Enter submits message."; + Chat.e.inputFields.forEach((e)=>{ + const v = e.dataset.placeholder0 + " " +label; + if(e.isContentEditable) e.dataset.placeholder = v; + else D.attr(e,'placeholder',v); + }); + Chat.e.btnSubmit.title = label; + }); + const valueKludges = { + /* Convert certain string-format values to other types... */ + "false": false, + "true": true + }; + Object.keys(Chat.settings.defaults).forEach(function(k){ + var v = Chat.settings.get(k,Chat); + if(Chat===v) v = Chat.settings.defaults[k]; + if(valueKludges.hasOwnProperty(v)) v = valueKludges[v]; + Chat.settings.set(k,v) + /* fires event listeners so that the Config area checkboxes + get in sync */; + }); + })(); + + (function(){/*set up message preview*/ + const btnPreview = Chat.e.btnPreview; + Chat.setPreviewText = function(t){ + this.setCurrentView(this.e.viewPreview); + this.e.previewContent.innerHTML = t; + this.e.viewPreview.querySelectorAll('a').forEach(addAnchorTargetBlank); + this.inputFocus(); + }; + Chat.e.viewPreview.querySelector('button.action-close'). + addEventListener('click', ()=>Chat.setCurrentView(Chat.e.viewMessages), false); + let previewPending = false; + const elemsToEnable = [btnPreview, Chat.e.btnSubmit, Chat.e.inputFields]; + const submit = function(ev){ + ev.preventDefault(); + ev.stopPropagation(); + if(previewPending) return false; + const txt = Chat.inputValue(); + if(!txt){ + Chat.setPreviewText(''); + previewPending = false; + return false; + } + const fd = new FormData(); + fd.append('content', txt); + fd.append('filename','chat.md' + /*filename needed for mimetype determination*/); + fd.append('render_mode',F.page.previewModes.wiki); + F.fetch('ajax/preview-text',{ + payload: fd, + onload: function(html){ + reportConnectionOkay('ajax/preview-text'); + Chat.setPreviewText(html); + F.pikchr.addSrcView(Chat.e.viewPreview.querySelectorAll('svg.pikchr')); + }, + onerror: function(e){ + F.fetch.onerror(e); + Chat.setPreviewText("ERROR: "+( + e.message || 'Unknown error fetching preview!' + )); + }, + beforesend: function(){ + D.disable(elemsToEnable); + Chat.ajaxStart(); + previewPending = true; + Chat.setPreviewText("Loading preview..."); + }, + aftersend:function(){ + previewPending = false; + Chat.ajaxEnd(); + D.enable(elemsToEnable); + } + }); + return false; + }; + btnPreview.addEventListener('click', submit, false); + })()/*message preview setup*/; + + (function(){/*Set up #chat-search and related bits */ + const btn = document.querySelector('#chat-button-search'); + D.attr(btn, 'role', 'button').addEventListener('click', function(ev){ + ev.preventDefault(); + ev.stopPropagation(); + const msg = Chat.inputValue(); + if( Chat.e.currentView===Chat.e.viewSearch ){ + if( msg ) Chat.submitSearch(); + else Chat.setCurrentView(Chat.e.viewMessages); + }else{ + Chat.setCurrentView(Chat.e.viewSearch); + if( msg ) Chat.submitSearch(); + } + return false; + }, false); + Chat.e.viewSearch.querySelector('button.action-clear').addEventListener('click', function(ev){ + ev.preventDefault(); + ev.stopPropagation(); + Chat.clearSearch(true); + Chat.setCurrentView(Chat.e.viewMessages); + return false; + }, false); + Chat.e.viewSearch.querySelector('button.action-close').addEventListener('click', function(ev){ + ev.preventDefault(); + ev.stopPropagation(); + Chat.setCurrentView(Chat.e.viewMessages); + return false; + }, false); + })()/*search view setup*/; + + (function(){/*Set up the zoom view */ + Chat.e.viewZoom.querySelector('button.action-close').addEventListener('click', function(ev){ + ev.preventDefault(); + ev.stopPropagation(); + Chat.zoomMessage(null); + return false; + }, false); + })()/*zoom view setup*/; + + /** Callback for poll() to inject new content into the page. jx == + the response from /chat-poll. If atEnd is true, the message is + appended to the end of the chat list (for loading older + messages), else the beginning (the default). */ + const newcontent = function f(jx,atEnd){ + if(!f.processPost){ + /** Processes chat message m, placing it either the start (if atEnd + is falsy) or end (if atEnd is truthy) of the chat history. atEnd + should only be true when loading older messages. */ + f.processPost = function(m,atEnd){ + ++Chat.totalMessageCount; + if( m.msgid>Chat.mxMsg ) Chat.mxMsg = m.msgid; + if( !Chat.mnMsg || m.msgid<Chat.mnMsg) Chat.mnMsg = m.msgid; + if(m.xfrom && m.mtime){ + const d = new Date(m.mtime); + const uls = Chat.usersLastSeen[m.xfrom]; + if(!uls || uls<d){ + d.$uColor = m.uclr; + Chat.usersLastSeen[m.xfrom] = d; + } + } + if( m.mdel ){ + /* A record deletion notice. */ + Chat.deleteMessageElem(m.mdel); + return; + } + if(!Chat._isBatchLoading + && (Chat.me!==m.xfrom + || Chat.settings.getBool('alert-own-messages'))){ + Chat.playNewMessageSound(); + } + const row = new Chat.MessageWidget(m); + Chat.injectMessageElem(row.e.body,atEnd); + if(m.isError){ + Chat._gotServerError = m; + } + }/*processPost()*/; + }/*end static init*/ + jx.msgs.forEach((m)=>f.processPost(m,atEnd)); + Chat.updateActiveUserList(); + if('visible'===document.visibilityState){ + if(Chat.changesSincePageHidden){ + Chat.changesSincePageHidden = 0; + Chat.e.pageTitle.innerText = Chat.pageTitleOrig; + } + }else{ + Chat.changesSincePageHidden += jx.msgs.length; + if(jx.msgs.length){ + Chat.e.pageTitle.innerText = '[*] '+Chat.pageTitleOrig; + } + } + }/*newcontent()*/; + Chat.newContent = newcontent; + + (function(){ + /** Add toolbar for loading older messages. We use a FIELDSET here + because a fieldset is the only parent element type which can + automatically enable/disable its children by + enabling/disabling the parent element. */ + const loadLegend = D.legend("Load..."); + const toolbar = Chat.e.loadOlderToolbar = D.attr( + D.fieldset(loadLegend), "id", "load-msg-toolbar" + ); + Chat.disableDuringAjax.push(toolbar); + /* Loads the next n oldest messages, or all previous history if n is negative. */ + const loadOldMessages = function(n){ + Chat.e.viewMessages.classList.add('loading'); + Chat._isBatchLoading = true; + const scrollHt = Chat.e.viewMessages.scrollHeight, + scrollTop = Chat.e.viewMessages.scrollTop; + F.fetch("chat-poll",{ + urlParams:{ + before: Chat.mnMsg, + n: n + }, + responseType: 'json', + onerror:function(err){ + Chat.reportErrorAsMessage(err); + Chat._isBatchLoading = false; + }, + onload:function(x){ + reportConnectionOkay('loadOldMessages()'); + let gotMessages = x.msgs.length; + newcontent(x,true); + Chat._isBatchLoading = false; + Chat.updateActiveUserList(); + if(Chat._gotServerError){ + Chat._gotServerError = false; + return; + } + if(n<0/*we asked for all history*/ + || 0===gotMessages/*we found no history*/ + || (n>0 && gotMessages<n /*we got fewer history entries than requested*/) + || (n===0 && gotMessages<Chat.loadMessageCount + /*we asked for default amount and got fewer than that.*/)){ + /* We've loaded all history. Permanently disable the + history-load toolbar and keep it from being re-enabled + via the ajaxStart()/ajaxEnd() mechanism... */ + const div = Chat.e.loadOlderToolbar.querySelector('div'); + D.append(D.clearElement(div), "All history has been loaded."); + D.addClass(Chat.e.loadOlderToolbar, 'all-done'); + const ndx = Chat.disableDuringAjax.indexOf(Chat.e.loadOlderToolbar); + if(ndx>=0) Chat.disableDuringAjax.splice(ndx,1); + Chat.e.loadOlderToolbar.disabled = true; + } + if(gotMessages > 0){ + F.toast.message("Loaded "+gotMessages+" older messages."); + /* Return scroll position to where it was when the history load + was requested, per user request */ + Chat.e.viewMessages.scrollTo( + 0, Chat.e.viewMessages.scrollHeight - scrollHt + scrollTop + ); + } + }, + aftersend:function(){ + Chat.e.viewMessages.classList.remove('loading'); + Chat.ajaxEnd(); + } + }); + }; + const wrapper = D.div(); /* browsers don't all properly handle >1 child in a fieldset */; + D.append(toolbar, wrapper); + var btn = D.button("Previous "+Chat.loadMessageCount+" messages"); + D.append(wrapper, btn); + btn.addEventListener('click',()=>loadOldMessages(Chat.loadMessageCount)); + btn = D.button("All previous messages"); + D.append(wrapper, btn); + btn.addEventListener('click',()=>loadOldMessages(-1)); + D.append(Chat.e.viewMessages, toolbar); + toolbar.disabled = true /*will be enabled when msg load finishes */; + })()/*end history loading widget setup*/; + + /** + Clears the search result view. If addInstructions is true it adds + text to that view instructing the user to enter their query into + the message-entry widget (noting that that widget has text + implying that it's only for submitting a message, which isn't + exactly true when the search view is active). + + Returns the DOM element which wraps all of the chat search + result elements. + */ + Chat.clearSearch = function(addInstructions=false){ + const e = D.clearElement( this.e.searchContent ); + if(addInstructions){ + D.append(e, "Enter search terms in the message field. "+ + "Use #NNNNN to search for the message with ID NNNNN."); + } + return e; + }; + Chat.clearSearch(true); + /** + Submits a history search using the main input field's current + text. It is assumed that Chat.e.viewSearch===Chat.e.currentView. + */ + Chat.submitSearch = function(){ + const term = this.inputValue(true); + const eMsgTgt = this.clearSearch(true); + if( !term ) return; + D.append( eMsgTgt, "Searching for ",term," ..."); + const fd = new FormData(); + fd.set('q', term); + F.fetch( + "chat-query", { + payload: fd, + responseType: 'json', + onerror:function(err){ + Chat.setCurrentView(Chat.e.viewMessages); + Chat.reportErrorAsMessage(err); + }, + onload:function(jx){ + reportConnectionOkay('submitSearch()'); + let previd = 0; + D.clearElement(eMsgTgt); + jx.msgs.forEach((m)=>{ + m.isSearchResult = true; + const mw = new Chat.MessageWidget(m); + const spacer = new Chat.SearchCtxLoader({ + first: jx.first, + last: jx.last, + previd: previd, + nextid: m.msgid + }); + if( spacer.e ) D.append( eMsgTgt, spacer.e.body ); + D.append( eMsgTgt, mw.e.body ); + previd = m.msgid; + }); + if( jx.msgs.length ){ + const spacer = new Chat.SearchCtxLoader({ + first: jx.first, + last: jx.last, + previd: previd, + nextid: 0 + }); + if( spacer.e ) D.append( eMsgTgt, spacer.e.body ); + }else{ + D.append( D.clearElement(eMsgTgt), + 'No search results found for: ', + term ); + } + } + } + ); + }/*Chat.submitSearch()*/; + + /* + To be called from F.fetch('chat-poll') beforesend() handler. If + we're currently in delayed-retry mode and a connection is + started, try to reset the delay after N time waiting on that + connection. The fact that the connection is waiting to respond, + rather than outright failing, is a good hint that the outage is + over and we can reset the back-off timer. + + Without this, recovery of a connection error won't be reported + until after the long-poll completes by either receiving new + messages or timing out. Once a long-poll is in progress, though, + we "know" that it's up and running again, so can update the UI and + connection timer to reflect that. That's the job this function + does. + + Only one of these asynchronous checks will ever be active + concurrently and only if Chat.timer.isDelayed() is true. i.e. if + this timer is active or Chat.timer.isDelayed() is false, this is a + no-op. + */ + const chatPollBeforeSend = function(){ + //console.warn('chatPollBeforeSend outer', Chat.timer.tidClearPollErr, Chat.timer.currentDelay); + if( !Chat.timer.tidClearPollErr && Chat.timer.isDelayed() ){ + Chat.timer.tidClearPollErr = setTimeout(()=>{ + //console.warn('chatPollBeforeSend inner'); + Chat.timer.tidClearPollErr = 0; + if( poll.running ){ + /* This chat-poll F.fetch() is still underway, so let's + assume the connection is back up until/unless it times + out or breaks again. */ + reportConnectionOkay('chatPollBeforeSend', true); + } + }, Chat.timer.$initialDelay * 4/*kinda arbitrary: not too long for UI wait and + not too short as to make connection unlikely. */ ); + } + }; + + /** + Deal with the last poll() response and maybe re-start poll(). + */ + const afterPollFetch = function f(err){ + if(true===f.isFirstCall){ + f.isFirstCall = false; + Chat.ajaxEnd(); + Chat.e.viewMessages.classList.remove('loading'); + setTimeout(function(){ + Chat.scrollMessagesTo(1); + }, 250); + } + Chat.timer.cancelPendingPollTimer(); + if(Chat._gotServerError){ + Chat.reportErrorAsMessage( + "Shutting down chat poller due to server-side error. ", + "Reload this page to reactivate it." + ); + } else { + if( err && Chat.beVerbose ){ + console.error("afterPollFetch:",err.name,err.status,err.message); + } + if( !err || 'timeout'===err.name/*(probably) long-poll expired*/ ){ + /* Restart the poller immediately. */ + reportConnectionOkay('afterPollFetch '+err, false); + }else{ + /* Delay a while before trying again, noting that other Chat + APIs may try and succeed at connections before this timer + resolves, in which case they'll clear this timeout and the + UI message about the outage. */ + let delay; + D.addClass(Chat.e.pollErrorMarker, 'connection-error'); + if( ++Chat.timer.errCount < Chat.timer.minErrForNotify ){ + delay = Chat.timer.resetDelay( + (Chat.timer.minDelay * Chat.timer.errCount) + + Chat.timer.randomInterval(Chat.timer.minDelay) + ); + if(Chat.beVerbose){ + console.warn("Ignoring polling error #",Chat.timer.errCount, + "for another",delay,"ms" ); + } + } else { + delay = Chat.timer.incrDelay(); + //console.warn("afterPollFetch Chat.e.eMsgPollError",Chat.e.eMsgPollError); + const msg = "Poller connection error. Retrying in "+delay+ " ms."; + /* Replace the current/newest connection error widget. We could also + just update its body with the new message, but then its timestamp + never updates. OTOH, if we replace the message, we lose the + start time of the outage in the log. It seems more useful to + update the timestamp so that it doesn't look like it's hung. */ + if( Chat.e.eMsgPollError ){ + Chat.deleteMessageElem(Chat.e.eMsgPollError, false); + } + const theMsg = Chat.e.eMsgPollError = Chat.reportErrorAsMessage(msg); + D.addClass(Chat.e.eMsgPollError.e.body,'poller-connection'); + /* Add a "retry now" button */ + const btnDel = D.addClass(D.button("Retry now"), 'retry-now'); + const eParent = Chat.e.eMsgPollError.e.content; + D.append(eParent, " ", btnDel); + btnDel.addEventListener('click', function(){ + D.remove(btnDel); + D.append(eParent, D.text("retrying...")); + Chat.timer.cancelPendingPollTimer().currentDelay = + Chat.timer.resetDelay() + + 1 /*workaround for showing the "connection restored" + message, as the +1 will cause + Chat.timer.isDelayed() to be true.*/; + poll(); + }); + //Chat.playNewMessageSound();// browser complains b/c this wasn't via human interaction + } + Chat.timer.startPendingPollTimer(delay); + } + } + }; + afterPollFetch.isFirstCall = true; + + /** + Initiates, if it's not already running, a single long-poll + request to the /chat-poll endpoint. In the handling of that + response, it end up will psuedo-recursively calling itself via + the response-handling process. Despite being async, the implied + returned Promise is meaningless. + */ + const poll = Chat.poll = async function f(){ + if(f.running) return; + f.running = true; + Chat._isBatchLoading = f.isFirstCall; + if(true===f.isFirstCall){ + f.isFirstCall = false; + f.pendingOnError = undefined; + Chat.ajaxStart(); + Chat.e.viewMessages.classList.add('loading'); + /* + We manager onerror() results in poll() in a roundabout + manner: when an onerror() arrives, we stash it aside + for a moment before processing it. + + This level of indirection is necessary to be able to + unambiguously identify client-timeout-specific polling errors + from other errors. Timeouts are always announced in pairs of + an HTTP 0 and something we can unambiguously identify as a + timeout (in that order). When we receive an HTTP error we put + it into this queue. If an ontimeout() call arrives before + this error is handled, this error is ignored. If, however, an + HTTP error is seen without an accompanying timeout, we handle + it from here. + + It's kinda like in the curses C API, where you to match + ALT-X by first getting an ESC event, then an X event, but + this one is a lot less explicable. (It's almost certainly a + mis-handling bug in F.fetch(), but it has so far eluded my + eyes.) + */ + f.delayPendingOnError = function(err){ + if( f.pendingOnError ){ + const x = f.pendingOnError; + f.pendingOnError = undefined; + afterPollFetch(x); + } + }; + } + F.fetch("chat-poll",{ + timeout: Chat.timer.pollTimeout, + urlParams:{ + name: Chat.mxMsg + }, + responseType: "json", + // Disable the ajax start/end handling for this long-polling op: + beforesend: chatPollBeforeSend, + aftersend: function(){ + poll.running = false; + }, + ontimeout: function(err){ + f.pendingOnError = undefined /*strip preceding non-timeout error, if any*/; + afterPollFetch(err); + }, + onerror:function(err){ + Chat._isBatchLoading = false; + if(Chat.beVerbose){ + console.error("poll.onerror:",err.name,err.status,JSON.stringify(err)); + } + f.pendingOnError = err; + setTimeout(f.delayPendingOnError, 100); + }, + onload:function(y){ + reportConnectionOkay('poll.onload', true); + newcontent(y); + if(Chat._isBatchLoading){ + Chat._isBatchLoading = false; + Chat.updateActiveUserList(); + } + afterPollFetch(); + } + }); + }/*poll()*/; + poll.isFirstCall = true; + Chat._gotServerError = poll.running = false; + if( window.fossil.config.chat.fromcli ){ + Chat.chatOnlyMode(true); + } + Chat.timer.startPendingPollTimer(); + delete ForceResizeKludge.$disabled; + ForceResizeKludge(); + Chat.animate.$disabled = false; + setTimeout( ()=>Chat.inputFocus(), 0 ); + F.page.chat = Chat/* enables testing the APIs via the dev tools */; +}); |
