Fossil

Check-in [6d676f6eb5]
Login

Check-in [6d676f6eb5]

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

Overview
Comment:Initial impl of buttons to load older chat messages. The UI code is a bit more involved than might seem necessary, but is so largely because it needs to avoid UI/ajax race conditions.
Downloads: Tarball | ZIP archive
Timelines: family | ancestors | descendants | both | trunk
Files: files | file ages | folders
SHA3-256: 6d676f6eb5d78f4dd1db55caf589d8ea360159a1b497348b5a25313b6284dffb
User & Date: stephan 2020-12-24 20:18:49.278
Context
2020-12-24
22:07
chat message deletion: admins now have both delete local and delete global options, in case they want to remove something from local view without deleting it for all users. ... (check-in: b12d69d9f4 user: stephan tags: trunk)
20:18
Initial impl of buttons to load older chat messages. The UI code is a bit more involved than might seem necessary, but is so largely because it needs to avoid UI/ajax race conditions. ... (check-in: 6d676f6eb5 user: stephan tags: trunk)
19:28
A valid /chat-ping request should set the Access-Control-Allow-Origin in the reply header, to avoid client-side errors. ... (check-in: ffb40fd894 user: drh tags: trunk)
Changes
Unified Diff Ignore Whitespace Patch
Changes to src/chat.c.
395
396
397
398
399
400
401










402
403
404
405
406
407
408
** If N is negative, then the return value is the N most recent messages.
** Hence a request like /chat-poll/-100 can be used to initialize a new
** chat session to just the most recent messages.
**
** Some webservers (althttpd) do not allow a term of the URL path to
** begin with "-".  Then /chat-poll/-100 cannot be used.  Instead you
** have to say "/chat-poll?name=-100".










**
** The reply from this webpage is JSON that describes the new content.
** Format of the json:
**
** |    {
** |      "msg":[
** |        {







>
>
>
>
>
>
>
>
>
>







395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
** If N is negative, then the return value is the N most recent messages.
** Hence a request like /chat-poll/-100 can be used to initialize a new
** chat session to just the most recent messages.
**
** Some webservers (althttpd) do not allow a term of the URL path to
** begin with "-".  Then /chat-poll/-100 cannot be used.  Instead you
** have to say "/chat-poll?name=-100".
**
** If the integer parameter "before" is passed in, it is assumed that
** the client is requesting older messages, up to (but not including)
** that message ID, in which case the next-oldest "n" messages
** (default=chat-initial-history setting, equivalent to n=0) are
** returned (negative n fetches all older entries). The client then
** needs to take care to inject them at the end of the history rather
** than the same place new messages go.
**
** If "before" is provided, "name" is ignored.
**
** The reply from this webpage is JSON that describes the new content.
** Format of the json:
**
** |    {
** |      "msg":[
** |        {
421
422
423
424
425
426
427



428
429
430
431
432
433
434



435
436
437
438
439
440





441
442
443
444
445











446
447
448
449
450
451
452
453
454
455
456
457
458



459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474

475
476
477
478
479
480
481
**
** The "fname" and "fmime" fields are only present if "fsize" is greater
** than zero.  The "xmsg" field may be an empty string if "fsize" is zero.
**
** The "msgid" values will be in increasing order.
**
** The "mdel" will only exist if "xmsg" is an empty string and "fsize" is zero.



*/
void chat_poll_webpage(void){
  Blob json;                  /* The json to be constructed and returned */
  sqlite3_int64 dataVersion;  /* Data version.  Used for polling. */
  int iDelay = 1000;          /* Delay until next poll (milliseconds) */
  const char *zSep = "{\"msgs\":[\n";   /* List separator */
  int msgid = atoi(PD("name","0"));



  Stmt q1;
  login_check_credentials();
  if( !g.perm.Chat ) return;
  chat_create_tables();
  cgi_set_content_type("text/json");
  dataVersion = db_int64(0, "PRAGMA data_version");





  if( msgid<=0 ){
    db_begin_write();
    chat_purge();
    db_commit_transaction();
  }











  if( msgid<0 ){
    msgid = db_int(0,
        "SELECT msgid FROM chat WHERE mdel IS NOT true"
        " ORDER BY msgid DESC LIMIT 1 OFFSET %d", -msgid);
  }
  db_prepare(&q1,
    "SELECT msgid, datetime(mtime), xfrom, xmsg, length(file),"
    "       fname, fmime, mdel"
    "  FROM chat"
    " WHERE msgid>%d"
    " ORDER BY msgid",
    msgid
  );



  blob_init(&json, 0, 0);
  while(1){
    int cnt = 0;
    while( db_step(&q1)==SQLITE_ROW ){
      int id = db_column_int(&q1, 0);
      const char *zDate = db_column_text(&q1, 1);
      const char *zFrom = db_column_text(&q1, 2);
      const char *zRawMsg = db_column_text(&q1, 3);
      int nByte = db_column_int(&q1, 4);
      const char *zFName = db_column_text(&q1, 5);
      const char *zFMime = db_column_text(&q1, 6);
      int iToDel = db_column_int(&q1, 7);
      char *zMsg;
      cnt++;
      blob_append(&json, zSep, -1);
      zSep = ",\n";

      blob_appendf(&json, "{\"msgid\":%d,\"mtime\":%!j,", id, zDate);
      blob_appendf(&json, "\"xfrom\":%!j,", zFrom);
      blob_appendf(&json, "\"uclr\":%!j,", hash_color(zFrom));

      zMsg = chat_format_to_html(zRawMsg ? zRawMsg : "");
      blob_appendf(&json, "\"xmsg\":%!j,", zMsg);
      fossil_free(zMsg);







>
>
>





<

>
>
>






>
>
>
>
>
|




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












|
|
<
>







431
432
433
434
435
436
437
438
439
440
441
442
443
444
445

446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482



483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504

505
506
507
508
509
510
511
512
**
** The "fname" and "fmime" fields are only present if "fsize" is greater
** than zero.  The "xmsg" field may be an empty string if "fsize" is zero.
**
** The "msgid" values will be in increasing order.
**
** The "mdel" will only exist if "xmsg" is an empty string and "fsize" is zero.
**
** The messages are ordered oldest first unless "before" is provided, in which
** case they are sorted newest first (to facilitate the client-side UI update).
*/
void chat_poll_webpage(void){
  Blob json;                  /* The json to be constructed and returned */
  sqlite3_int64 dataVersion;  /* Data version.  Used for polling. */
  int iDelay = 1000;          /* Delay until next poll (milliseconds) */

  int msgid = atoi(PD("name","0"));
  const int msgBefore = atoi(PD("before","0"));
  int nLimit = msgBefore>0 ? atoi(PD("n","0")) : 0;
  Blob sql = empty_blob;
  Stmt q1;
  login_check_credentials();
  if( !g.perm.Chat ) return;
  chat_create_tables();
  cgi_set_content_type("text/json");
  dataVersion = db_int64(0, "PRAGMA data_version");
  blob_append_sql(&sql,
    "SELECT msgid, datetime(mtime), xfrom, xmsg, length(file),"
    "       fname, fmime, %s"
    "  FROM chat ",
    msgBefore>0 ? "0 as mdel" : "mdel");
  if( msgid<=0 || msgBefore>0 ){
    db_begin_write();
    chat_purge();
    db_commit_transaction();
  }
  if(msgBefore>0){
    if(0==nLimit){
      nLimit = db_get_int("chat-initial-history",50);
    }
    blob_append_sql(&sql,
      " WHERE msgid<%d"
      " ORDER BY msgid DESC "
      "LIMIT %d",
      msgBefore, nLimit>0 ? nLimit : -1
    );
  }else{
    if( msgid<0 ){
      msgid = db_int(0,
            "SELECT msgid FROM chat WHERE mdel IS NOT true"
            " ORDER BY msgid DESC LIMIT 1 OFFSET %d", -msgid);
    }
    blob_append_sql(&sql,



      " WHERE msgid>%d"
      " ORDER BY msgid",
      msgid
    );
  }
  db_prepare(&q1, "%s", blob_sql_text(&sql));
  blob_reset(&sql);
  blob_init(&json, "{\"msgs\":[\n", -1);
  while(1){
    int cnt = 0;
    while( db_step(&q1)==SQLITE_ROW ){
      int id = db_column_int(&q1, 0);
      const char *zDate = db_column_text(&q1, 1);
      const char *zFrom = db_column_text(&q1, 2);
      const char *zRawMsg = db_column_text(&q1, 3);
      int nByte = db_column_int(&q1, 4);
      const char *zFName = db_column_text(&q1, 5);
      const char *zFMime = db_column_text(&q1, 6);
      int iToDel = db_column_int(&q1, 7);
      char *zMsg;
      if(cnt++){
        blob_append(&json, ",\n", 2);

      }
      blob_appendf(&json, "{\"msgid\":%d,\"mtime\":%!j,", id, zDate);
      blob_appendf(&json, "\"xfrom\":%!j,", zFrom);
      blob_appendf(&json, "\"uclr\":%!j,", hash_color(zFrom));

      zMsg = chat_format_to_html(zRawMsg ? zRawMsg : "");
      blob_appendf(&json, "\"xmsg\":%!j,", zMsg);
      fossil_free(zMsg);
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
      if( iToDel ){
        blob_appendf(&json, ",\"mdel\":%d}", iToDel);
      }else{
        blob_append(&json, "}", 1);
      }
    }
    db_reset(&q1);
    if( cnt ){
      blob_append(&json, "\n]}", 3);
      cgi_set_content(&json);
      break;
    }
    sqlite3_sleep(iDelay);
    while( 1 ){
      sqlite3_int64 newDataVers = db_int64(0,"PRAGMA repository.data_version");







|







520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
      if( iToDel ){
        blob_appendf(&json, ",\"mdel\":%d}", iToDel);
      }else{
        blob_append(&json, "}", 1);
      }
    }
    db_reset(&q1);
    if( cnt || msgBefore>0 ){
      blob_append(&json, "\n]}", 3);
      cgi_set_content(&json);
      break;
    }
    sqlite3_sleep(iDelay);
    while( 1 ){
      sqlite3_int64 newDataVers = db_int64(0,"PRAGMA repository.data_version");
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
/**
   This file contains the client-side implementation of fossil's /chat
   application. 
*/
(function(){
  const form = document.querySelector('#chat-form');
  const F = window.fossil, D = F.dom;
  const Chat = (function(){
    const cs = {





      me: F.user.name,
      mxMsg: F.config.chatInitSize ? -F.config.chatInitSize : -50,

      pageIsActive: 'visible'===document.visibilityState,
      changesSincePageHidden: 0,
      notificationBubbleColor: 'white',

















      pageTitle: document.querySelector('head title')








































    };
    cs.pageTitleOrig = cs.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);









>
>
>
>
>


>



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

|







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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
/**
   This file contains the client-side implementation of fossil's /chat
   application. 
*/
(function(){
  const form = document.querySelector('#chat-form');
  const F = window.fossil, D = F.dom;
  const Chat = (function(){
    const cs = {
      e:{/*map of certain DOM elements.*/
        messageInjectPoint: document.querySelector('#message-inject-point'),
        pageTitle: document.querySelector('head title'),
        loadToolbar: undefined /* the load-posts toolbar (dynamically created) */
      },
      me: F.user.name,
      mxMsg: F.config.chatInitSize ? -F.config.chatInitSize : -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.chatInitSize || 20),
      ajaxInflight: 0,
      /** 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. */
      ],
      /* Injects element e as a new row in the chat, at the top of the
         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);
          }
        }
      }
    };
    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);
73
74
75
76
77
78
79

80
81
82

83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
        e = id;
        id = e.dataset.msgid;
      }else{
        e = this.getMessageElemById(id);
      }
      if(!(e instanceof HTMLElement)) return;
      if(this.userMayDelete(e)){

        fetch("chat-delete?name=" + id)
          .then(()=>this.deleteMessageElem(e))
          .catch(err=>this.reportError(err))

      }else{
        this.deleteMessageElem(id);
      }
    };
    document.addEventListener('visibilitychange', function(ev){
      cs.pageIsActive = !document.hidden;
      if(cs.pageIsActive){
        cs.pageTitle.innerText = cs.pageTitleOrig;
      }
    }, true);
    return cs;
  })()/*Chat initialization*/;
  /* State for paste and drag/drop */
  const BlobXferState = {
    dropDetails: document.querySelector('#chat-drop-details'),







>



>







|







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
        e = id;
        id = e.dataset.msgid;
      }else{
        e = this.getMessageElemById(id);
      }
      if(!(e instanceof HTMLElement)) return;
      if(this.userMayDelete(e)){
        this.ajaxStart();
        fetch("chat-delete?name=" + id)
          .then(()=>this.deleteMessageElem(e))
          .catch(err=>this.reportError(err))
          .finally(()=>this.ajaxEnd());
      }else{
        this.deleteMessageElem(id);
      }
    };
    document.addEventListener('visibilitychange', function(ev){
      cs.pageIsActive = !document.hidden;
      if(cs.pageIsActive){
        cs.e.pageTitle.innerText = cs.pageTitleOrig;
      }
    }, true);
    return cs;
  })()/*Chat initialization*/;
  /* State for paste and drag/drop */
  const BlobXferState = {
    dropDetails: document.querySelector('#chat-drop-details'),
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
      D.removeClass(dropHighlight, 'dragover');
    }
  };
  Object.keys(dropEvents).forEach(
    (k)=>form.file.addEventListener(k, dropEvents[k], true)
  );

  /* Injects element e as a new row in the chat, at the top of the list */
  const injectMessage = function f(e){
    if(!f.injectPoint){
      f.injectPoint = document.querySelector('#message-inject-point');
    }
    if(f.injectPoint.nextSibling){
      f.injectPoint.parentNode.insertBefore(e, f.injectPoint.nextSibling);
    }else{
      f.injectPoint.parentNode.appendChild(e);
    }
  };
  /* Returns a new TEXT node with the given text content. */
  /** Returns the local time string of Date object d, defaulting
      to the current time. */
  const localTimeString = function ff(d){
    if(!ff.pad){
      ff.pad = (x)=>(''+x).length>1 ? x : '0'+x;
    }







<
<
<
<
<
<
<
<
<
<
<







258
259
260
261
262
263
264











265
266
267
268
269
270
271
      D.removeClass(dropHighlight, 'dragover');
    }
  };
  Object.keys(dropEvents).forEach(
    (k)=>form.file.addEventListener(k, dropEvents[k], true)
  );












  /* Returns a new TEXT node with the given text content. */
  /** Returns the local time string of Date object d, defaulting
      to the current time. */
  const localTimeString = function ff(d){
    if(!ff.pad){
      ff.pad = (x)=>(''+x).length>1 ? x : '0'+x;
    }
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
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367

368























































369
370
371

372
373
374
375


376

377

378
379
380
381
      });
      f.popup.installClickToHide();
      f.popup.hide = function(){
        delete this._eMsg;
        D.clearElement(this.e);
        return this.show(false);
      };
    }
    const rect = ev.target.getBoundingClientRect();
    const eMsg = ev.target.parentNode/*the owning fieldset element*/;
    f.popup._eMsg = eMsg;
    let x = rect.left, y = rect.top - 10;
    f.popup.show(ev.target)/*so we can get its computed size*/;
    if('right'===ev.target.getAttribute('align')){
      // Shift popup to the left for right-aligned messages to avoid
      // truncation off the right edge of the page.
      const pRect = f.popup.e.getBoundingClientRect();
      x -= pRect.width/3*2;
    }
    f.popup.show(x, y);
  };

  /** Callback for poll() to inject new content into the page. */



  function newcontent(jx){





























    var i;










































    if('visible'===document.visibilityState){
      if(Chat.changesSincePageHidden){
        Chat.changesSincePageHidden = 0;
        Chat.pageTitle.innerText = Chat.pageTitleOrig;
      }
    }else{
      Chat.changesSincePageHidden += jx.msgs.length;
      Chat.pageTitle.innerText = '('+Chat.changesSincePageHidden+') '+
        Chat.pageTitleOrig;
    }
    for(i=0; i<jx.msgs.length; ++i){
      const m = jx.msgs[i];
      if( m.msgid>Chat.mxMsg ) Chat.mxMsg = m.msgid;
      if( m.mdel ){
        /* A record deletion notice. */
        Chat.deleteMessageElem(m.mdel);
        continue;
      }
      const eWho = D.create('legend'),
            row = D.addClass(D.fieldset(eWho), 'message-row');
      row.dataset.msgid = m.msgid;
      row.dataset.xfrom = m.xfrom;
      row.dataset.timestamp = m.mtime;
      injectMessage(row);
      eWho.addEventListener('click', handleLegendClicked, false);
      if( m.xfrom==Chat.me && window.outerWidth<1000 ){
        eWho.setAttribute('align', 'right');
        row.style.justifyContent = "flex-end";
      }else{
        eWho.setAttribute('align', 'left');
      }
      eWho.style.backgroundColor = m.uclr;
      eWho.classList.add('message-user');
      let whoName = m.xfrom;
      var d = new Date(m.mtime + "Z");
      if( d.getMinutes().toString()!="NaN" ){
        /* Show local time when we can compute it */
        eWho.append(D.text(whoName+' @ '+
          d.getHours()+":"+(d.getMinutes()+100).toString().slice(1,3)
        ))
      }else{
        /* Show UTC on systems where Date() does not work */
        eWho.append(D.text(whoName+' @ '+m.mtime.slice(11,16)))
      }
      let eContent = D.addClass(D.div(),'message-content','chat-message');
      eContent.style.backgroundColor = m.uclr;
      row.appendChild(eContent);
      if( m.fsize>0 ){
        if( m.fmime && m.fmime.startsWith("image/") ){
          eContent.appendChild(D.img("chat-download/" + m.msgid));
        }else{
          eContent.appendChild(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)"
          ));
        }
        const br = D.br();
        br.style.clear = "both";
        eContent.appendChild(br);
      }
      if(m.xmsg){
        // 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.
        eContent.innerHTML += m.xmsg
      }
      eContent.classList.add('chat-message');
    }
    if(i && window.fossil.config.pingTcp){
      fetch("http:/"+"/localhost:"+window.fossil.config.pingTcp+"/chat-ping");
    }

  }























































  async function poll(){
    if(poll.running) return;
    poll.running = true;

    fetch("chat-poll?name=" + Chat.mxMsg)
    .then(x=>x.json())
    .then(y=>newcontent(y))
    .catch(e=>console.error(e))


    .finally(()=>poll.running=false)

  }

  poll();
  setInterval(poll, 1000);
  F.page.chat = Chat;
})();







|












|

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



|



|


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


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


>
|
|
|
|
>
>
|
>

>
|

|

318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
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
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426


































































427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
      });
      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();
    const eMsg = ev.target.parentNode/*the owning fieldset element*/;
    f.popup._eMsg = eMsg;
    let x = rect.left, y = rect.top - 10;
    f.popup.show(ev.target)/*so we can get its computed size*/;
    if('right'===ev.target.getAttribute('align')){
      // Shift popup to the left for right-aligned messages to avoid
      // truncation off the right edge of the page.
      const pRect = f.popup.e.getBoundingClientRect();
      x -= pRect.width/3*2;
    }
    f.popup.show(x, y);
  }/*handleLegendClicked()*/;

  /** 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
      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.mdel ){
          /* A record deletion notice. */
          Chat.deleteMessageElem(m.mdel);
          return;
        }
        const eWho = D.create('legend'),
              row = D.addClass(D.fieldset(eWho), 'message-row');
        row.dataset.msgid = m.msgid;
        row.dataset.xfrom = m.xfrom;
        row.dataset.timestamp = m.mtime;
        Chat.injectMessageElem(row,atEnd);
        eWho.addEventListener('click', handleLegendClicked, false);
        if( m.xfrom==Chat.me && window.outerWidth<1000 ){
          eWho.setAttribute('align', 'right');
          row.style.justifyContent = "flex-end";
        }else{
          eWho.setAttribute('align', 'left');
        }
        eWho.style.backgroundColor = m.uclr;
        eWho.classList.add('message-user');
        let whoName = m.xfrom;
        var d = new Date(m.mtime + "Z");
        if( d.getMinutes().toString()!="NaN" ){
          /* Show local time when we can compute it */
          eWho.append(D.text(whoName+' @ '+
                             d.getHours()+":"+(d.getMinutes()+100).toString().slice(1,3)
                            ))
        }else{
          /* Show UTC on systems where Date() does not work */
          eWho.append(D.text(whoName+' @ '+m.mtime.slice(11,16)))
        }
        let eContent = D.addClass(D.div(),'message-content','chat-message');
        eContent.style.backgroundColor = m.uclr;
        row.appendChild(eContent);
        if( m.fsize>0 ){
          if( m.fmime && m.fmime.startsWith("image/") ){
            eContent.appendChild(D.img("chat-download/" + m.msgid));
          }else{
            eContent.appendChild(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)"
            ));
          }
          const br = D.br();
          br.style.clear = "both";
          eContent.appendChild(br);
        }
        if(m.xmsg){
          // 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.
          eContent.innerHTML += m.xmsg
        }
      }/*processPost()*/;
    }/*end static init*/
    jx.msgs.forEach((m)=>f.processPost(m,atEnd));
    if('visible'===document.visibilityState){
      if(Chat.changesSincePageHidden){
        Chat.changesSincePageHidden = 0;
        Chat.e.pageTitle.innerText = Chat.pageTitleOrig;
      }
    }else{
      Chat.changesSincePageHidden += jx.msgs.length;
      Chat.e.pageTitle.innerText = '('+Chat.changesSincePageHidden+') '+
        Chat.pageTitleOrig;
    }


































































    if(jx.msgs.length && F.config.pingTcp){
      fetch("http:/"+"/localhost:"+window.fossil.config.pingTcp+"/chat-ping");
    }
  }/*newcontent()*/;

  if(true){
    /** 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.loadToolbar = D.addClass(
      D.fieldset(loadLegend), "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.ajaxStart();
      var gotMessages = false;
      fetch("chat-poll?before="+Chat.mnMsg+"&n="+n)
        .then(x=>x.json())
        .then(function(x){
          gotMessages = x.msgs.length;
          newcontent(x,true);
        })
        .catch(e=>Chat.reportError(e))
        .finally(function(){
          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*/)
             || (false!==gotMessages && 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.loadToolbar.querySelector('div');
            D.append(D.clearElement(div), "All history has been loaded.");
            D.addClass(Chat.e.loadToolbar, 'all-done');
            const ndx = Chat.disableDuringAjax.indexOf(Chat.e.loadToolbar);
            if(ndx>=0) Chat.disableDuringAjax.splice(ndx,1);
            Chat.e.loadToolbar.disabled = true;
          }
          if(gotMessages > 0){
            F.toast.message("Loaded "+gotMessages+" older messages.");
          }
          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(document.querySelector('body.chat > div.content'), toolbar);
      toolbar.disabled = true /*will be enabled when msg load finishes */;
  }/*end history loading widget setup*/

  async function poll(isFirstCall){
    if(poll.running) return;
    poll.running = true;
    if(isFirstCall) Chat.ajaxStart();
    var p = fetch("chat-poll?name=" + Chat.mxMsg);
    p.then(x=>x.json())
      .then(y=>newcontent(y))
      .catch(e=>Chat.reportError(e))
      .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 */;
})();
Changes to src/default.css.
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524

















  font-size: 0.8em;
  text-align: left;
  opacity: 0.8;
  display: flex;
  flex-direction: column;
  align-items: stretch;
}
.chat-message-popup > span { white-space: nowrap; }
.chat-message-popup > .toolbar {
  padding: 0.2em;
  margin: 0;
  border: 2px inset rgba(0,0,0,0.3);
  border-radius: 0.25em;
}
























|
|





>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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
  font-size: 0.8em;
  text-align: left;
  opacity: 0.8;
  display: flex;
  flex-direction: column;
  align-items: stretch;
}
body.chat .chat-message-popup > span { white-space: nowrap; }
body.chat .chat-message-popup > .toolbar {
  padding: 0.2em;
  margin: 0;
  border: 2px inset rgba(0,0,0,0.3);
  border-radius: 0.25em;
}

body.chat .load-msg-toolbar  {
  border-radius: 0.25em;
  padding: 0.1em 0.2em;
}
body.chat .load-msg-toolbar.all-done {
  opacity: 0.5;
}
body.chat .load-msg-toolbar > div {
  display: flex;
  flex-direction: row;
  justify-content: stretch;
  flex-wrap: wrap;
}
body.chat .load-msg-toolbar > div > button {
  flex: 1 1 auto;
}
Changes to src/fossil.dom.js.
244
245
246
247
248
249
250
251
252

253
254
255
256
257
258
259
260
261
262
263
264
265
266










267
268
269
270
271
272
273
  dom.tfoot = dom.createElemFactoryWithOptionalParent('tfoot');
  dom.tr = dom.createElemFactoryWithOptionalParent('tr');
  dom.td = dom.createElemFactoryWithOptionalParent('td');
  dom.th = dom.createElemFactoryWithOptionalParent('th');

  /**
     Creates and returns a FIELDSET element, optionaly with a LEGEND
     element added to it. If legendText is an HTMLElement then it is
     appended as-is, else it is assume (if truthy) to be a value

     suitable for passing to dom.append(aLegendElement,...).
  */
  dom.fieldset = function(legendText){
    const fs = this.create('fieldset');
    if(legendText){
      this.append(
        fs,
        (legendText instanceof HTMLElement)
          ? legendText
          : this.append(this.create('legend'),legendText)
      );
    }
    return fs;
  };











  /**
     Appends each argument after the first to the first argument
     (a DOM node) and returns the first argument.

     - If an argument is a string or number, it is transformed
     into a text node.







|
|
>
|








|




>
>
>
>
>
>
>
>
>
>







244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
  dom.tfoot = dom.createElemFactoryWithOptionalParent('tfoot');
  dom.tr = dom.createElemFactoryWithOptionalParent('tr');
  dom.td = dom.createElemFactoryWithOptionalParent('td');
  dom.th = dom.createElemFactoryWithOptionalParent('th');

  /**
     Creates and returns a FIELDSET element, optionaly with a LEGEND
     element added to it. If legendText is an HTMLElement then is is
     assumed to be a LEGEND and is appended as-is, else it is assumed
     (if truthy) to be a value suitable for passing to
     dom.append(aLegendElement,...).
  */
  dom.fieldset = function(legendText){
    const fs = this.create('fieldset');
    if(legendText){
      this.append(
        fs,
        (legendText instanceof HTMLElement)
          ? legendText
          : this.append(this.legend(legendText))
      );
    }
    return fs;
  };
  /**
     Returns a new LEGEND legend element. The given argument, if
     not falsy, is append()ed to the element (so it may be a string
     or DOM element.
  */
  dom.legend = function(legendText){
    const rc = this.create('legend');
    if(legendText) this.append(rc, legendText);
    return rc;
  };

  /**
     Appends each argument after the first to the first argument
     (a DOM node) and returns the first argument.

     - If an argument is a string or number, it is transformed
     into a text node.