Fossil

Check-in [2572872bb0]
Login

Check-in [2572872bb0]

Many hyperlinks are disabled.
Use anonymous login to enable hyperlinks.

Overview
Comment:Bottom-up chat mode.
Downloads: Tarball | ZIP archive
Timelines: family | ancestors | descendants | both | trunk
Files: files | file ages | folders
SHA3-256: 2572872bb0de2179f92bcb7b34b397e8995dcb6a8ce6272315a1fa47de470923
User & Date: drh 2020-12-26 16:24:27.392
Context
2020-12-26
16:39
Worked around a browser-dependent scrolling quirk and fixed a logic bug in the recent PopupWidget.installHideHandlers() API change which caused the timestamp popups not to close. ... (check-in: 322627ed7e user: stephan tags: trunk)
16:24
Bottom-up chat mode. ... (check-in: 2572872bb0 user: drh tags: trunk)
16:21
Disabled a dangling part of the search for a solution for the bottom-scrolling problem. ... (Closed-Leaf check-in: 7f4000a62c user: stephan tags: chat-mode-bottom-up)
13:46
Chat docs: minor typos and additions. ... (check-in: 10085418fe user: stephan tags: trunk)
Changes
Unified Diff Ignore Whitespace Patch
Changes to src/chat.c.
521
522
523
524
525
526
527
528


529
530
531
532
533
534
535
}

/*
** WEBPAGE: chat-download
**
** Download the CHAT.FILE attachment associated with a single chat
** entry.  The "name" query parameter begins with an integer that
** identifies the particular chat message.


*/
void chat_download_webpage(void){
  int msgid;
  Blob r;
  const char *zMime;
  login_check_credentials();
  if( !g.perm.Chat ){







|
>
>







521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
}

/*
** WEBPAGE: chat-download
**
** Download the CHAT.FILE attachment associated with a single chat
** entry.  The "name" query parameter begins with an integer that
** identifies the particular chat message. The integer may be followed
** by a / and a filename, which will indicate to the browser to use
** the indicated name when saving the file.
*/
void chat_download_webpage(void){
  int msgid;
  Blob r;
  const char *zMime;
  login_check_credentials();
  if( !g.perm.Chat ){
Changes to src/chat.js.
1
2
3
4
5
6
7
8
9
10
11

12
13
14
15
16
17
18

19
20
21
22
23
24
25

26
27
28
29
30
31
32
/**
   This file contains the client-side implementation of fossil's /chat
   application. 
*/
(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;
  };

  const Chat = (function(){
    const cs = {
      e:{/*map of certain DOM elements.*/
        messageInjectPoint: E1('#message-inject-point'),
        pageTitle: E1('head title'),
        loadToolbar: undefined /* the load-posts toolbar (dynamically created) */,
        inputWrapper: E1("#chat-input-area"),

        messagesWrapper: E1('#chat-messages-wrapper'),
        inputForm: E1('#chat-form'),
        btnSubmit: E1('#chat-message-submit'),
        inputSingle: E1('#chat-input-single'),
        inputMulti: E1('#chat-input-multi'),
        inputCurrent: undefined/*one of inputSingle or inputMulti*/,
        inputFile: E1('#chat-input-file')

      },
      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',











>







>






|
>







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/**
   This file contains the client-side implementation of fossil's /chat
   application. 
*/
(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;
  };
  //document.body.classList.add('chat-only-mode');
  const Chat = (function(){
    const cs = {
      e:{/*map of certain DOM elements.*/
        messageInjectPoint: E1('#message-inject-point'),
        pageTitle: E1('head title'),
        loadToolbar: undefined /* the load-posts toolbar (dynamically created) */,
        inputWrapper: E1("#chat-input-area"),
        fileSelectWrapper: E1('#chat-input-file-area'),
        messagesWrapper: E1('#chat-messages-wrapper'),
        inputForm: E1('#chat-form'),
        btnSubmit: E1('#chat-message-submit'),
        inputSingle: E1('#chat-input-single'),
        inputMulti: E1('#chat-input-multi'),
        inputCurrent: undefined/*one of inputSingle or inputMulti*/,
        inputFile: E1('#chat-input-file'),
        contentDiv: E1('div.content')
      },
      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',
61
62
63
64
65
66
67

68
69
70
71
72
73
74
          this.e.inputCurrent = this.e.inputMulti;
        }else{
          this.e.inputCurrent = this.e.inputSingle;
        }
        D.addClass(old, 'hidden');
        D.removeClass(this.e.inputCurrent, 'hidden');
        this.e.inputCurrent.value = old.value;

        return this;
      },
      /** Enables (if yes is truthy) or disables all elements in
       * this.disableDuringAjax. */
      enableAjaxComponents: function(yes){
        D[yes ? 'enable' : 'disable'](this.disableDuringAjax);
        return this;







>







64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
          this.e.inputCurrent = this.e.inputMulti;
        }else{
          this.e.inputCurrent = this.e.inputSingle;
        }
        D.addClass(old, 'hidden');
        D.removeClass(this.e.inputCurrent, 'hidden');
        this.e.inputCurrent.value = old.value;
        old.value = '';
        return this;
      },
      /** Enables (if yes is truthy) or disables all elements in
       * this.disableDuringAjax. */
      enableAjaxComponents: function(yes){
        D[yes ? 'enable' : 'disable'](this.disableDuringAjax);
        return this;
110
111
112
113
114
115
116








117











118












































119
120







121



122


123
124
125
126
127
128
129
130

131
132
133

134
135
136
137
138
139
140
141

142
143
144



145
146
147
148
149


















150
151
152
153
154
155
156
         list if atEnd is falsy, else at the end of the list, before
         the load-history widget. */
      injectMessageElem: function f(e, atEnd){
        const mip = atEnd ? this.e.loadToolbar : this.e.messageInjectPoint;
        if(atEnd){
          mip.parentNode.insertBefore(e, mip);
        }else{








          if(mip.nextSibling){











            mip.parentNode.insertBefore(e, mip.nextSibling);












































          }else{
            mip.parentNode.appendChild(e);







          }



        }


      },
      settings:{
        get: (k,dflt)=>F.storage.get(k,dflt),
        getBool: (k,dflt)=>F.storage.getBool(k,dflt),
        set: (k,v)=>F.storage.set(k,v),
        defaults:{
          "images-inline": !!F.config.chat.imagesInline,
          "monospace-messages": false

        }
      }
    };

    Object.keys(cs.settings.defaults).forEach(function f(k){
      const v = cs.settings.get(k,f);
      if(f===v) cs.settings.set(k,cs.settings.defaults[k]);
    });
    if(window.innerWidth<window.innerHeight){
      /* 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). Can be toggled via

         settings popup. */
      document.body.classList.add('my-messages-right');
    }



    if(cs.settings.getBool('monospace-messages',false)){
      document.body.classList.add('monospace-messages');
    }
    cs.e.inputCurrent = cs.e.inputSingle;
    cs.pageTitleOrig = cs.e.pageTitle.innerText;


















    const qs = (e)=>document.querySelector(e);
    const argsToArray = function(args){
      return Array.prototype.slice.call(args,0);
    };
    cs.reportError = function(/*msg args*/){
      const args = argsToArray(arguments);
      console.error("chat error:",args);







>
>
>
>
>
>
>
>
|
>
>
>
>
>
>
>
>
>
>
>
|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|
<
>
>
>
>
>
>
>
|
>
>
>
|
>
>







|
>



>
|
|
|




|
>
|


>
>
>





>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>







114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186

187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
         list if atEnd is falsy, else at the end of the list, before
         the load-history widget. */
      injectMessageElem: function f(e, atEnd){
        const mip = atEnd ? this.e.loadToolbar : this.e.messageInjectPoint;
        if(atEnd){
          mip.parentNode.insertBefore(e, mip);
        }else{
          const self = this;
          if(false && this.isUiFlipped()){
            /* When UI is flipped, new messages start out under the
               text input area because of its position:sticky
               style. We have to scroll them up. When the page footer
               is not hidden but is not on-screen, this causes a
               slight amount of UI jarring as the footer is *also*
               scrolled into view (for whatever reason).

               The remaining problem here is messages with IMG tags.
               At this point in the process their IMG.src has not yet
               been loaded - that's async. We scroll the message into
               view, but then the downstream loading of IMG.src pushes
               the message content back down, sliding the message
               behind the input field. This can be verified by delaying the
               message scroll by a second or so to give the image time
               to load (from a local server instance).
            */
            D.addClass(self.e.inputWrapper,'unsticky');
          }
          if(mip.nextSibling) mip.parentNode.insertBefore(e, mip.nextSibling);
          else mip.parentNode.appendChild(e);
          if(false && this.isUiFlipped()){
            //e.scrollIntoView();
            setTimeout(function(){
              //self.e.inputWrapper.scrollIntoView();
              //self.e.fileSelectWrapper.scrollIntoView();
              //e.scrollIntoView();
              //D.removeClass(self.e.inputWrapper,'unsticky');
              self.e.inputWrapper.scrollIntoView();
            },0);
          }
        }
      },
      /** Returns true if chat-only mode is enabled. */
      isChatOnlyMode: ()=>document.body.classList.contains('chat-only-mode'),
      /** Returns true if the UI seems to be in "bottom-up" mode. */
      isUiFlipped: function(){
        const style = window.getComputedStyle(this.e.contentDiv);
        return style.flexDirection.indexOf("-reverse")>0;
      },
      /**
         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 = [];
          document.body.childNodes.forEach(function(e){
            if(!e.classList) return/*TEXT nodes and such*/;
            else if(!e.classList.contains('content')
                    && !e.classList.contains('fossil-PopupWidget')
                    /*kludge^^^ for settingsPopup click handling!*/){
              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');
          setTimeout(()=>document.body.scrollIntoView(
            /*moves to (0,0), whereas scrollTo(0,0) does not!
             setTimeout() is unfortunately necessary to get the scroll
             placement correct.*/
          ), 0);
        }
        const msg = document.querySelector('.message-widget');
        if(msg) setTimeout(()=>msg.scrollIntoView(),0);
        return this;
      },
      toggleChatOnlyMode: function(){
        return this.chatOnlyMode(!this.isChatOnlyMode());
      },
      settings:{
        get: (k,dflt)=>F.storage.get(k,dflt),
        getBool: (k,dflt)=>F.storage.getBool(k,dflt),
        set: (k,v)=>F.storage.set(k,v),
        defaults:{
          "images-inline": !!F.config.chat.imagesInline,
          "monospace-messages": false,
          "bottom-up": true
        }
      }
    };
    /* Install default settings... */
    Object.keys(cs.settings.defaults).forEach(function(k){
      const v = cs.settings.get(k,cs);
      if(cs===v) cs.settings.set(k,cs.settings.defaults[k]);
    });
    if(window.innerWidth<window.innerHeight){
      /* 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 apparently "orientation" of the window:
         tall vs wide. Can be toggled via settings popup. */
      document.body.classList.add('my-messages-right');
    }
    if(cs.settings.getBool("bottom-up")){
      document.body.classList.add('chat-bottom-up');
    }
    if(cs.settings.getBool('monospace-messages',false)){
      document.body.classList.add('monospace-messages');
    }
    cs.e.inputCurrent = cs.e.inputSingle;
    cs.pageTitleOrig = cs.e.pageTitle.innerText;

    if(true){
      /* In order to make the input area opaque, such that the message
         list scrolls under it without being visible, we have to
         ensure that the input area has a non-transparent background
         color. Ideally we'd select the color of div.content, but that
         is not necessarily set, so we fall back to using the body's
         background color and hope it's been explicitly set
         somewhere. If we rely on the input area having its own color
         specified in CSS then all skins would have to define an
         appropriate color. Thus our selection of the body color,
         while slightly unfortunate, is in the interest of keeping
         skins from being forced to define an opaque bg color.
      */
      const bodyStyle = window.getComputedStyle(document.body);
      cs.e.inputWrapper.style.backgroundColor = bodyStyle.backgroundColor;
    }

    const qs = (e)=>document.querySelector(e);
    const argsToArray = function(args){
      return Array.prototype.slice.call(args,0);
    };
    cs.reportError = function(/*msg args*/){
      const args = argsToArray(arguments);
      console.error("chat error:",args);
263
264
265
266
267
268
269
270


271

272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290

291
292



293
294
295
296
297
298
299
300
          D.addClass(this.e.body, 'mine');
        }
        this.e.content.style.backgroundColor = m.uclr;
        this.e.tab.style.backgroundColor = m.uclr;
          
        const d = new Date(m.mtime);
        D.append(
          D.clearElement(this.e.tab), D.text(


            m.xfrom+' @ '+d.getHours()+":"+(d.getMinutes()+100).toString().slice(1,3))

        );
        var contentTarget = this.e.content;
        if( m.fsize>0 ){
          if( m.fmime
              && m.fmime.startsWith("image/")
              && Chat.settings.getBool('images-inline',true)
            ){
            contentTarget.appendChild(D.img("chat-download/" + m.msgid));
          }else{
            const a = D.a(
              window.fossil.rootPath+
                'chat-download/' + m.msgid+'/'+encodeURIComponent(m.fname),
              // ^^^ add m.fname to URL to cause downloaded file to have that name.
              "(" + m.fname + " " + m.fsize + " bytes)"
            )
            D.attr(a,'target','_blank');
            contentTarget.appendChild(a);
          }
          contentTarget = D.div();

        }
        if(m.xmsg){



          if(contentTarget !== this.e.content){
            D.append(this.e.content, contentTarget);
          }
          // 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







|
>
>
|
>


















<
>


>
>
>
|







365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394

395
396
397
398
399
400
401
402
403
404
405
406
407
408
          D.addClass(this.e.body, 'mine');
        }
        this.e.content.style.backgroundColor = m.uclr;
        this.e.tab.style.backgroundColor = m.uclr;
          
        const d = new Date(m.mtime);
        D.append(
          D.clearElement(this.e.tab),
          D.text(
            m.xfrom," #",m.msgid,' @ ',d.getHours(),":",
            (d.getMinutes()+100).toString().slice(1,3)            
          )
        );
        var contentTarget = this.e.content;
        if( m.fsize>0 ){
          if( m.fmime
              && m.fmime.startsWith("image/")
              && Chat.settings.getBool('images-inline',true)
            ){
            contentTarget.appendChild(D.img("chat-download/" + m.msgid));
          }else{
            const a = D.a(
              window.fossil.rootPath+
                'chat-download/' + m.msgid+'/'+encodeURIComponent(m.fname),
              // ^^^ add m.fname to URL to cause downloaded file to have that name.
              "(" + m.fname + " " + m.fsize + " bytes)"
            )
            D.attr(a,'target','_blank');
            contentTarget.appendChild(a);
          }

          ;
        }
        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);
          }
          // 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
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
        updateDropZoneContent(false/*clear prev state*/);
        updateDropZoneContent(items[0].getAsFile());
      }
    }, false);
    /* Add help button for drag/drop/paste zone */
    Chat.e.inputFile.parentNode.insertBefore(
      F.helpButtonlets.create(
        document.querySelector('#chat-input-file-area .help-buttonlet')
      ), Chat.e.inputFile
    );
    ////////////////////////////////////////////////////////////
    // File drag/drop visual notification.
    const dropHighlight = Chat.e.inputFile /* target zone */;
    const dropEvents = {
      drop: function(ev){







|







467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
        updateDropZoneContent(false/*clear prev state*/);
        updateDropZoneContent(items[0].getAsFile());
      }
    }, false);
    /* Add help button for drag/drop/paste zone */
    Chat.e.inputFile.parentNode.insertBefore(
      F.helpButtonlets.create(
        Chat.e.fileSelectWrapper.querySelector('.help-buttonlet')
      ), Chat.e.inputFile
    );
    ////////////////////////////////////////////////////////////
    // File drag/drop visual notification.
    const dropHighlight = Chat.e.inputFile /* target zone */;
    const dropEvents = {
      drop: function(ev){
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
            btnDeleteGlobal.addEventListener('click', function(){
              self.hide();
              Chat.deleteMessage(eMsg);
            });
          }
        }/*refresh()*/
      });
      f.popup.installClickToHide();
      f.popup.hide = function(){
        delete this._eMsg;
        D.clearElement(this.e);
        return this.show(false);
      };
    }/*end static init*/
    const rect = ev.target.getBoundingClientRect();







|







596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
            btnDeleteGlobal.addEventListener('click', function(){
              self.hide();
              Chat.deleteMessage(eMsg);
            });
          }
        }/*refresh()*/
      });
      f.popup.installHideHandlers();
      f.popup.hide = function(){
        delete this._eMsg;
        D.clearElement(this.e);
        return this.show(false);
      };
    }/*end static init*/
    const rect = ev.target.getBoundingClientRect();
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590








591
592
593
594
595
596
597
    f.popup.show(x, y);
  }/*handleLegendClicked()*/;

  (function(){/*Set up #chat-settings-button */
    const settingsButton = document.querySelector('#chat-settings-button');
    var popupSize = undefined/*placement workaround*/;
    const settingsPopup = new F.PopupWidget({
      cssClass: ['fossil-tooltip', 'chat-settings-popup'],
      adjustY: function(y){
        const rect = settingsButton.getBoundingClientRect();
        return rect.top + rect.height + 2;
      }
    });
    /* Settings menu entries... */
    const settingsOps = [{
      label: "Multi-line input",
      boolValue: ()=>Chat.inputElement()===Chat.e.inputMulti,
      callback: function(){
        Chat.inputToggleSingleMulti();
      }
    },{
      label: "Monospace message font",
      boolValue: ()=>document.body.classList.contains('monospace-messages'),
      callback: function(){
        document.body.classList.toggle('monospace-messages');
        Chat.settings.set('monospace-messages',
                          document.body.classList.contains('monospace-messages'));
      }
    },{
      label: "Chat-only mode",
      boolValue: ()=>!!document.body.classList.contains('chat-only-mode'),
      callback: function f(){
        if(undefined === f.isHidden){
          f.isHidden = false;
          f.elemsToToggle = [];
          document.body.childNodes.forEach(function(e){
            if(!e.classList) return/*TEXT nodes and such*/;
            else if(!e.classList.contains('content')
                    && !e.classList.contains('fossil-PopupWidget')
                    /*kludge^^^ for settingsPopup click handling!*/){
              f.elemsToToggle.push(e);
            }
          });
          /* In order to make the input area opaque, such that the
             message list scrolls under it without being visible, we
             have to ensure that the input area has a non-inherited
             background color. Ideally we'd select the color of
             div.content, but that is not necessarily set, so we fall
             back to using the body's background color. If we rely on
             the input area having its own color specified in CSS then
             all skins would have to define an appropriate color.
             Thus our selection of the body color, while slightly unfortunate,
             is in the interest of keeping skins from being forced to
             define an opaque bg color.
          */
          f.initialBg = Chat.e.messagesWrapper.style.backgroundColor;
          const cs = window.getComputedStyle(document.body);
          f.inheritedBg = cs.backgroundColor;
        }
        const iws = Chat.e.inputWrapper.style;
        if((f.isHidden = !f.isHidden)){
          D.addClass(f.elemsToToggle, 'hidden');
          D.addClass(document.body, 'chat-only-mode');
          iws.backgroundColor = f.inheritedBg;
        }else{
          D.removeClass(f.elemsToToggle, 'hidden');
          D.removeClass(document.body, 'chat-only-mode');
          iws.backgroundColor = f.initialBg;
        }
      }
    },{
      label: "Left-align my posts",
      boolValue: ()=>!document.body.classList.contains('my-messages-right'),
      callback: function f(){
        document.body.classList.toggle('my-messages-right');
      }








    },{
      label: "Images inline",
      boolValue: ()=>Chat.settings.getBool('images-inline'),
      callback: function(){
        const v = Chat.settings.getBool('images-inline',true);
        Chat.settings.set('images-inline', !v);
        F.toast.message("Image mode set to "+(v ? "hyperlink" : "inline")+".");







|
<
<
<
<


















|
|
<
<
|
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<







>
>
>
>
>
>
>
>







623
624
625
626
627
628
629
630




631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650


651


































652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
    f.popup.show(x, y);
  }/*handleLegendClicked()*/;

  (function(){/*Set up #chat-settings-button */
    const settingsButton = document.querySelector('#chat-settings-button');
    var popupSize = undefined/*placement workaround*/;
    const settingsPopup = new F.PopupWidget({
      cssClass: ['fossil-tooltip', 'chat-settings-popup']




    });
    /* Settings menu entries... */
    const settingsOps = [{
      label: "Multi-line input",
      boolValue: ()=>Chat.inputElement()===Chat.e.inputMulti,
      callback: function(){
        Chat.inputToggleSingleMulti();
      }
    },{
      label: "Monospace message font",
      boolValue: ()=>document.body.classList.contains('monospace-messages'),
      callback: function(){
        document.body.classList.toggle('monospace-messages');
        Chat.settings.set('monospace-messages',
                          document.body.classList.contains('monospace-messages'));
      }
    },{
      label: "Chat-only mode",
      boolValue: ()=>Chat.isChatOnlyMode(),
      callback: function(){


        Chat.toggleChatOnlyMode();


































      }
    },{
      label: "Left-align my posts",
      boolValue: ()=>!document.body.classList.contains('my-messages-right'),
      callback: function f(){
        document.body.classList.toggle('my-messages-right');
      }
    },{
      label: "Bottom-up chat",
      boolValue: ()=>document.body.classList.contains('chat-bottom-up'),
      callback: function(){
        document.body.classList.toggle('chat-bottom-up');
        Chat.settings.set('bottom-up',
                          document.body.classList.contains('chat-bottom-up'));
      }
    },{
      label: "Images inline",
      boolValue: ()=>Chat.settings.getBool('images-inline'),
      callback: function(){
        const v = Chat.settings.getBool('images-inline',true);
        Chat.settings.set('images-inline', !v);
        F.toast.message("Image mode set to "+(v ? "hyperlink" : "inline")+".");
618
619
620
621
622
623
624
625
626
627
628
629




630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647








648
649
650
651
652
653
654
          D.append(line, check);
          check.addEventListener('click', callback);
        }
        D.append(settingsPopup.e, line);
        btn.addEventListener('click', callback);
      });
    };
    /**
       Reminder:
       settingsPopup.installClickToHide();
       Don't do this for this popup! It interferes with the embedded
       "?" buttons in the popup, which are also PopupWidget users.




    */
    D.attr(settingsButton, 'role', 'button');
    settingsButton.addEventListener('click',function(ev){
      //ev.preventDefault();
      if(settingsPopup.isShown()) settingsPopup.hide();
      else settingsPopup.show(settingsButton);
      /* Reminder: we cannot toggle the visibility from her
       */
    }, false);

    /* Find an ideal X position for the popup, directly under the settings
       button, based on the size of the popup... */
    settingsPopup.show(document.body);
    popupSize = settingsPopup.e.getBoundingClientRect();
    settingsPopup.hide();
    settingsPopup.options.adjustX = function(x){
      const rect = settingsButton.getBoundingClientRect();
      return rect.right - popupSize.width;








    };
  })()/*#chat-settings-button 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, else the beginning (the







<
<
|
|
|
>
>
>
>
|









|







>
>
>
>
>
>
>
>







694
695
696
697
698
699
700


701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
          D.append(line, check);
          check.addEventListener('click', callback);
        }
        D.append(settingsPopup.e, line);
        btn.addEventListener('click', callback);
      });
    };


    settingsPopup.installHideHandlers(false, true, true)
    /** Reminder: click-to-hide interferes with "?" embedded within
        the popup, so cannot be used together with those. Enabling
        this means, however, that tapping the menu button to toggle
        the menu cannot work because tapping the menu button while the
        menu is opened will, because of the click-to-hide handler,
        hide the menu before the button gets an event saying to toggle
        it.*/;
    D.attr(settingsButton, 'role', 'button');
    settingsButton.addEventListener('click',function(ev){
      //ev.preventDefault();
      if(settingsPopup.isShown()) settingsPopup.hide();
      else settingsPopup.show(settingsButton);
      /* Reminder: we cannot toggle the visibility from her
       */
    }, false);

    /* Find an ideal X/Y position for the popup, directly above the settings
       button, based on the size of the popup... */
    settingsPopup.show(document.body);
    popupSize = settingsPopup.e.getBoundingClientRect();
    settingsPopup.hide();
    settingsPopup.options.adjustX = function(x){
      const rect = settingsButton.getBoundingClientRect();
      return rect.right - popupSize.width;
    };
    settingsPopup.options.adjustY = function(y){
      const rect = settingsButton.getBoundingClientRect();
      if(Chat.isUiFlipped()){
        return rect.top - popupSize.height -2;
      }else{
        return rect.bottom + 2;
      }
    };
  })()/*#chat-settings-button 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, else the beginning (the
754
755
756
757
758
759
760
761



762
763
764
765
766
767
768
769
    p.then(x=>x.json())
      .then(y=>newcontent(y))
      .catch(e=>console.error(e))
    /* ^^^ we don't use Chat.reportError(e) here b/c the polling
       fails exepectedly when it times out, but is then immediately
       resumed, and reportError() produces a loud error message. */
      .finally(function(x){
        if(isFirstCall) Chat.ajaxEnd();



        poll.running=false;
      });
  }
  poll.running = false;
  poll(true);
  setInterval(poll, 1000);
  F.page.chat = Chat/* enables testing the APIs via the dev tools */;
})();







|
>
>
>








840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
    p.then(x=>x.json())
      .then(y=>newcontent(y))
      .catch(e=>console.error(e))
    /* ^^^ we don't use Chat.reportError(e) here b/c the polling
       fails exepectedly when it times out, but is then immediately
       resumed, and reportError() produces a loud error message. */
      .finally(function(x){
        if(isFirstCall){
          Chat.ajaxEnd();
          Chat.e.inputWrapper.scrollIntoView();
        }
        poll.running=false;
      });
  }
  poll.running = false;
  poll(true);
  setInterval(poll, 1000);
  F.page.chat = Chat/* enables testing the APIs via the dev tools */;
})();
Changes to src/default.css.
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533

1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
  white-space: pre-wrap/*needed for multi-line edits*/;
}
body.chat.monospace-messages .message-widget-content,
body.chat.monospace-messages textarea,
body.chat.monospace-messages input[type=text]{
  font-family: monospace;  
}

/* User name and timestamp (a LEGEND-like element) */
body.chat .message-widget .message-widget-tab {
  border-radius: 0.25em 0.25em 0 0;
  padding: 0 0.5em;
  margin: 0 0.25em 0em 0.15em;
  padding: 0 0.5em 0.15em 0.5em;
  cursor: pointer;
}

body.chat .fossil-tooltip.help-buttonlet-content {
  font-size: 80%;
}

/* The popup element for displaying message timestamps
   and deletion controls. */
body.chat .chat-message-popup {
  font-family: monospace;
  font-size: 0.8em;
  text-align: left;
  display: flex;
  flex-direction: column;
  align-items: stretch;
  padding: 0.25em;
  z-index: 200;
}

body.chat .chat-message-popup > span { white-space: nowrap; }
/* Container for the message deletion buttons. */
body.chat .chat-message-popup > .toolbar {
  padding: 0.2em;
  margin: 0;
  border: 2px inset rgba(0,0,0,0.3);
  border-radius: 0.25em;
  display: flex;
  flex-direction: row;
  justify-content: stretch;
  flex-wrap: wrap;
}
body.chat .chat-message-popup > .toolbar > button {
  flex: 1 1 auto;
}

/* The main widget for loading more/older chat messages. */
body.chat #load-msg-toolbar  {
  border-radius: 0.25em;
  padding: 0.1em 0.2em;
  margin-bottom: 1em;
}
/* Set when chat has loaded all of the available historical
   messages */
body.chat #load-msg-toolbar.all-done {
  opacity: 0.5;
}
body.chat #load-msg-toolbar > div {
  display: flex;
  flex-direction: row;
  justify-content: stretch;







<








<



<












>















<
|





|
|







1501
1502
1503
1504
1505
1506
1507

1508
1509
1510
1511
1512
1513
1514
1515

1516
1517
1518

1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546

1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
  white-space: pre-wrap/*needed for multi-line edits*/;
}
body.chat.monospace-messages .message-widget-content,
body.chat.monospace-messages textarea,
body.chat.monospace-messages input[type=text]{
  font-family: monospace;  
}

/* User name and timestamp (a LEGEND-like element) */
body.chat .message-widget .message-widget-tab {
  border-radius: 0.25em 0.25em 0 0;
  padding: 0 0.5em;
  margin: 0 0.25em 0em 0.15em;
  padding: 0 0.5em 0.15em 0.5em;
  cursor: pointer;
}

body.chat .fossil-tooltip.help-buttonlet-content {
  font-size: 80%;
}

/* The popup element for displaying message timestamps
   and deletion controls. */
body.chat .chat-message-popup {
  font-family: monospace;
  font-size: 0.8em;
  text-align: left;
  display: flex;
  flex-direction: column;
  align-items: stretch;
  padding: 0.25em;
  z-index: 200;
}
/* Full message timestamps. */
body.chat .chat-message-popup > span { white-space: nowrap; }
/* Container for the message deletion buttons. */
body.chat .chat-message-popup > .toolbar {
  padding: 0.2em;
  margin: 0;
  border: 2px inset rgba(0,0,0,0.3);
  border-radius: 0.25em;
  display: flex;
  flex-direction: row;
  justify-content: stretch;
  flex-wrap: wrap;
}
body.chat .chat-message-popup > .toolbar > button {
  flex: 1 1 auto;
}

/* The widget for loading more/older chat messages. */
body.chat #load-msg-toolbar  {
  border-radius: 0.25em;
  padding: 0.1em 0.2em;
  margin-bottom: 1em;
}
/* .all-done is set when chat has loaded all of the available
   historical messages */
body.chat #load-msg-toolbar.all-done {
  opacity: 0.5;
}
body.chat #load-msg-toolbar > div {
  display: flex;
  flex-direction: row;
  justify-content: stretch;
1596
1597
1598
1599
1600
1601
1602



1603
1604
1605
1606
1607
1608
1609
1610
1611
}
.settings-icon:hover {
  border: 1px outset rgba(127,127,127,1);
}
body.fossil-dark-style .settings-icon {
  filter: invert(100%);
}



body.chat #chat-settings-button {
}
/** Popup widget for the /chat settings. */
body.chat .chat-settings-popup {
  font-size: 0.8em;
  text-align: left;
  display: flex;
  flex-direction: column;
  align-items: stretch;







>
>
>
|
<







1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603

1604
1605
1606
1607
1608
1609
1610
}
.settings-icon:hover {
  border: 1px outset rgba(127,127,127,1);
}
body.fossil-dark-style .settings-icon {
  filter: invert(100%);
}
/* "Chat-only mode" hides the site header/footer, showing only
   the chat app. */
body.chat.chat-only-mode{}
body.chat #chat-settings-button {}

/** Popup widget for the /chat settings. */
body.chat .chat-settings-popup {
  font-size: 0.8em;
  text-align: left;
  display: flex;
  flex-direction: column;
  align-items: stretch;
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647




1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681

1682




1683















1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698

1699
1700
1701
1702
1703
1704
1705
  cursor: inherit;
}
/** Container for the list of /chat messages. */
body.chat #chat-messages-wrapper {
  display: flex;
  flex-direction: column;
}
/* "Chat-only mode" hides the site header/footer, showing only
   the chat app. */
body.chat.chat-only-mode{




}
body.chat.chat-only-mode > div.content {
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  align-items: stretch;
}
body.chat.chat-only-mode #chat-input-area {
  /* would like to pin this to the top so that it stays in place when
 scrolling, but doing so causes #chat-messages-wrapper to scroll
 behind it visibly, which is really ugly. Only current workaround is
 to force an opaque background color on this element, but that's not
 skin-friendly. */
  position: sticky;
  position: -webkit-sticky /* supposedly some versions of Safari */;
  top: 0;
  padding: 0.5em 1em;
  z-index: 100
    /* see notes in #chat-messages-wrapper. The various popups require a
       z-index higher than this one. */
}
body.chat.chat-only-mode #chat-messages-wrapper {
  position: relative;
  top: 0;
  z-index: 99 /* so that it scrolls under input area. If it's
                 lower than div.content then mouse events to it
                 are blocked!*/;
}
/* Wrapper for /chat user input controls */
body.chat #chat-input-area {
  display: flex;
  flex-direction: column;
  border-bottom: 1px solid black;

  margin-bottom: 0.5em;




}















body.chat #chat-input-line {
  display: flex;
  flex-direction: row;
  margin-bottom: 0.25em;
  align-items: flex-start;
}
body.chat #chat-input-line > input[type=submit] {
  flex: 1 5 auto;
  max-width: 6em;
  margin: 0 1em;
}
body.chat #chat-input-line > input[type=text],
body.chat #chat-input-line > textarea {
  flex: 5 1 auto;
}

body.chat #chat-input-file-area  {
  display: flex;
  flex-direction: row;
  align-items: center;
  flex-wrap: wrap;
}
body.chat #chat-input-file-area > .file-selection-wrapper {







<
<
|
>
>
>
>

|






|
<
<
<
<
<
|
<
<
<
<
<
<
<
<
<
<
<
<
<






>

>
>
>
>

>
>
>
>
>
>
>
>
>
>
>
>
>
>
>















>







1637
1638
1639
1640
1641
1642
1643


1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657





1658













1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
  cursor: inherit;
}
/** Container for the list of /chat messages. */
body.chat #chat-messages-wrapper {
  display: flex;
  flex-direction: column;
}


body.chat.chat-bottom-up #chat-messages-wrapper {
  flex-direction: column-reverse;
  z-index: 99 /* so that it scrolls under input area. If it's
                 lower than div.content then mouse events to it
                 are blocked!*/;
}
body.chat > div.content {
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  align-items: stretch;
}
body.chat.chat-bottom-up > div.content {





  flex-direction: column-reverse;













}
/* Wrapper for /chat user input controls */
body.chat #chat-input-area {
  display: flex;
  flex-direction: column;
  border-bottom: 1px solid black;
  padding: 0.5em 1em;
  margin-bottom: 0.5em;
  position: sticky; top: 0;
  z-index: 100
    /* see notes in #chat-messages-wrapper. The various popups require a
       z-index higher than this one. */;
}
body.chat.chat-bottom-up #chat-input-area {
  border-bottom: none;
  border-top: 1px solid black;
  margin-bottom: 0;
  margin-top: 0.5em;
  position: initial /*sticky currently disabled due to scrolling-related issues*/;
  bottom: 0;
}
/* An internal hack to try to help resolve a message-scrolling quirk
   when #chat-input-area is sticky on the bottom of the screen. */
body.chat.chat-bottom-up #chat-input-area.unsticky {
  position: initial;
}
/* Widget holding the chat message input field, send button, and
   settings button. */
body.chat #chat-input-line {
  display: flex;
  flex-direction: row;
  margin-bottom: 0.25em;
  align-items: flex-start;
}
body.chat #chat-input-line > input[type=submit] {
  flex: 1 5 auto;
  max-width: 6em;
  margin: 0 1em;
}
body.chat #chat-input-line > input[type=text],
body.chat #chat-input-line > textarea {
  flex: 5 1 auto;
}
/* Widget holding the file selection control and preview */
body.chat #chat-input-file-area  {
  display: flex;
  flex-direction: row;
  align-items: center;
  flex-wrap: wrap;
}
body.chat #chat-input-file-area > .file-selection-wrapper {
1716
1717
1718
1719
1720
1721
1722

1723
1724
1725

1726
1727
1728
1729
1730
1731
1732
1733
  border:1px solid rgba(0,0,0,0);/*avoid UI shift during drop-targeting*/
  border-radius: 0.25em;
  padding: 0.25em;
}
body.chat #chat-input-file > input {
  flex: 1 0 auto;
}

body.chat #chat-input-file.dragover {
  border: 1px dashed green;
}

body.chat #chat-drop-details {
  flex: 0 1 auto;
  padding: 0.5em 1em;
  margin-left: 0.5em;
  white-space: pre;
  font-family: monospace;
  max-width: 50%;
}







>



>






<

1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737

1738
  border:1px solid rgba(0,0,0,0);/*avoid UI shift during drop-targeting*/
  border-radius: 0.25em;
  padding: 0.25em;
}
body.chat #chat-input-file > input {
  flex: 1 0 auto;
}
/* Indicator when a drag/drop is in progress */
body.chat #chat-input-file.dragover {
  border: 1px dashed green;
}
/* Widget holding the details of a selected/dropped file/image. */
body.chat #chat-drop-details {
  flex: 0 1 auto;
  padding: 0.5em 1em;
  margin-left: 0.5em;
  white-space: pre;
  font-family: monospace;

}
Changes to src/fossil.dom.js.
110
111
112
113
114
115
116



117

118
119
120
121
122
123
124
    const e = this.create('a');
    if(href) e.setAttribute('href',href);
    if(label) e.appendChild(dom.text(true===label ? href : label));
    return e;
  };
  dom.hr = dom.createElemFactory('hr');
  dom.br = dom.createElemFactory('br');



  dom.text = (t)=>document.createTextNode(t||'');

  dom.button = function(label){
    const b = this.create('button');
    if(label) b.appendChild(this.text(label));
    return b;
  };
  /**
     Returns a TEXTAREA element.







>
>
>
|
>







110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
    const e = this.create('a');
    if(href) e.setAttribute('href',href);
    if(label) e.appendChild(dom.text(true===label ? href : label));
    return e;
  };
  dom.hr = dom.createElemFactory('hr');
  dom.br = dom.createElemFactory('br');
  /** Returns a new TEXT node which contains the text of all of the
      arguments appended together. */
  dom.text = function(/*...*/){
    return document.createTextNode(argsToArray(arguments).join(''));
  };
  dom.button = function(label){
    const b = this.create('button');
    if(label) b.appendChild(this.text(label));
    return b;
  };
  /**
     Returns a TEXTAREA element.
Changes to src/fossil.popupwidget.js.
215
216
217
218
219
220
221









222
223



224
225

226
227
228
229

230
231
232
233
234
235
236
    hide: function(){return this.show(false)},

    /**
       A convenience method which adds click handlers to this popup's
       main element and document.body to hide (via hide()) the popup
       when either element is clicked or the ESC key is pressed. Only
       call this once per instance, if at all. Returns this;









    */
    installClickToHide: function f(){



      this.e.addEventListener('click', ()=>this.hide(), false);
      document.body.addEventListener('click', ()=>this.hide(), true);

      const self = this;
      document.body.addEventListener('keydown', function(ev){
        if(self.isShown() && 27===ev.which) self.hide();
      }, true);

      return this;
    }
  }/*F.PopupWidget.prototype*/;

  /**
     Internal impl for F.toast() and friends.








>
>
>
>
>
>
>
>
>

|
>
>
>
|
|
>
|
|
|
|
>







215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
    hide: function(){return this.show(false)},

    /**
       A convenience method which adds click handlers to this popup's
       main element and document.body to hide (via hide()) the popup
       when either element is clicked or the ESC key is pressed. Only
       call this once per instance, if at all. Returns this;

       The first argument specifies whether a click handler on this
       object is installed. The second specifies whether a click
       outside of this object should close it. The third specifies
       whether an ESC handler is installed.

       Passing no arguments is equivalent to passing (true,true,true),
       and passing fewer arguments defaults the unpassed parameters to
       true.
    */
    installHideHandlers: function f(onClickSelf, onClickOther, onEsc){
      if(!arguments.length) onClick = onClickOther = onEsc = true;
      else if(2===arguments.length) onClickOther = onEsc = true;
      else if(1===arguments.length) onEsc = true;
      if(onClickSelf) this.e.addEventListener('click', ()=>this.hide(), false);
      if(onClickOther) document.body.addEventListener('click', ()=>this.hide(), true);
      if(onEsc){
        const self = this;
        document.body.addEventListener('keydown', function(ev){
          if(self.isShown() && 27===ev.which) self.hide();
        }, true);
      }
      return this;
    }
  }/*F.PopupWidget.prototype*/;

  /**
     Internal impl for F.toast() and friends.

340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
          if(!fch.popup){
            fch.popup = new F.PopupWidget({
              cssClass: ['fossil-tooltip', 'help-buttonlet-content'],
              refresh: function(){
              }
            });
            fch.popup.e.style.maxWidth = '80%'/*of body*/;
            fch.popup.installClickToHide();
          }
          D.append(D.clearElement(fch.popup.e), ev.target.$helpContent);
          /* Shift the help around a bit to "better" fit the
             screen. However, fch.popup.e.getClientRects() is empty
             until the popup is shown, so we have to show it,
             calculate the resulting size, then move and/or resize it.








|







354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
          if(!fch.popup){
            fch.popup = new F.PopupWidget({
              cssClass: ['fossil-tooltip', 'help-buttonlet-content'],
              refresh: function(){
              }
            });
            fch.popup.e.style.maxWidth = '80%'/*of body*/;
            fch.popup.installHideHandlers();
          }
          D.append(D.clearElement(fch.popup.e), ev.target.$helpContent);
          /* Shift the help around a bit to "better" fit the
             screen. However, fch.popup.e.getClientRects() is empty
             until the popup is shown, so we have to show it,
             calculate the resulting size, then move and/or resize it.