Fossil SCM

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.

stephan 2020-12-24 20:18 trunk
Commit 6d676f6eb5d78f4dd1db55caf589d8ea360159a1b497348b5a25313b6284dffb
+51 -20
--- src/chat.c
+++ src/chat.c
@@ -397,10 +397,20 @@
397397
** chat session to just the most recent messages.
398398
**
399399
** Some webservers (althttpd) do not allow a term of the URL path to
400400
** begin with "-". Then /chat-poll/-100 cannot be used. Instead you
401401
** have to say "/chat-poll?name=-100".
402
+**
403
+** If the integer parameter "before" is passed in, it is assumed that
404
+** the client is requesting older messages, up to (but not including)
405
+** that message ID, in which case the next-oldest "n" messages
406
+** (default=chat-initial-history setting, equivalent to n=0) are
407
+** returned (negative n fetches all older entries). The client then
408
+** needs to take care to inject them at the end of the history rather
409
+** than the same place new messages go.
410
+**
411
+** If "before" is provided, "name" is ignored.
402412
**
403413
** The reply from this webpage is JSON that describes the new content.
404414
** Format of the json:
405415
**
406416
** | {
@@ -423,42 +433,63 @@
423433
** than zero. The "xmsg" field may be an empty string if "fsize" is zero.
424434
**
425435
** The "msgid" values will be in increasing order.
426436
**
427437
** The "mdel" will only exist if "xmsg" is an empty string and "fsize" is zero.
438
+**
439
+** The messages are ordered oldest first unless "before" is provided, in which
440
+** case they are sorted newest first (to facilitate the client-side UI update).
428441
*/
429442
void chat_poll_webpage(void){
430443
Blob json; /* The json to be constructed and returned */
431444
sqlite3_int64 dataVersion; /* Data version. Used for polling. */
432445
int iDelay = 1000; /* Delay until next poll (milliseconds) */
433
- const char *zSep = "{\"msgs\":[\n"; /* List separator */
434446
int msgid = atoi(PD("name","0"));
447
+ const int msgBefore = atoi(PD("before","0"));
448
+ int nLimit = msgBefore>0 ? atoi(PD("n","0")) : 0;
449
+ Blob sql = empty_blob;
435450
Stmt q1;
436451
login_check_credentials();
437452
if( !g.perm.Chat ) return;
438453
chat_create_tables();
439454
cgi_set_content_type("text/json");
440455
dataVersion = db_int64(0, "PRAGMA data_version");
441
- if( msgid<=0 ){
456
+ blob_append_sql(&sql,
457
+ "SELECT msgid, datetime(mtime), xfrom, xmsg, length(file),"
458
+ " fname, fmime, %s"
459
+ " FROM chat ",
460
+ msgBefore>0 ? "0 as mdel" : "mdel");
461
+ if( msgid<=0 || msgBefore>0 ){
442462
db_begin_write();
443463
chat_purge();
444464
db_commit_transaction();
445465
}
446
- if( msgid<0 ){
447
- msgid = db_int(0,
448
- "SELECT msgid FROM chat WHERE mdel IS NOT true"
449
- " ORDER BY msgid DESC LIMIT 1 OFFSET %d", -msgid);
450
- }
451
- db_prepare(&q1,
452
- "SELECT msgid, datetime(mtime), xfrom, xmsg, length(file),"
453
- " fname, fmime, mdel"
454
- " FROM chat"
455
- " WHERE msgid>%d"
456
- " ORDER BY msgid",
457
- msgid
458
- );
459
- blob_init(&json, 0, 0);
466
+ if(msgBefore>0){
467
+ if(0==nLimit){
468
+ nLimit = db_get_int("chat-initial-history",50);
469
+ }
470
+ blob_append_sql(&sql,
471
+ " WHERE msgid<%d"
472
+ " ORDER BY msgid DESC "
473
+ "LIMIT %d",
474
+ msgBefore, nLimit>0 ? nLimit : -1
475
+ );
476
+ }else{
477
+ if( msgid<0 ){
478
+ msgid = db_int(0,
479
+ "SELECT msgid FROM chat WHERE mdel IS NOT true"
480
+ " ORDER BY msgid DESC LIMIT 1 OFFSET %d", -msgid);
481
+ }
482
+ blob_append_sql(&sql,
483
+ " WHERE msgid>%d"
484
+ " ORDER BY msgid",
485
+ msgid
486
+ );
487
+ }
488
+ db_prepare(&q1, "%s", blob_sql_text(&sql));
489
+ blob_reset(&sql);
490
+ blob_init(&json, "{\"msgs\":[\n", -1);
460491
while(1){
461492
int cnt = 0;
462493
while( db_step(&q1)==SQLITE_ROW ){
463494
int id = db_column_int(&q1, 0);
464495
const char *zDate = db_column_text(&q1, 1);
@@ -467,13 +498,13 @@
467498
int nByte = db_column_int(&q1, 4);
468499
const char *zFName = db_column_text(&q1, 5);
469500
const char *zFMime = db_column_text(&q1, 6);
470501
int iToDel = db_column_int(&q1, 7);
471502
char *zMsg;
472
- cnt++;
473
- blob_append(&json, zSep, -1);
474
- zSep = ",\n";
503
+ if(cnt++){
504
+ blob_append(&json, ",\n", 2);
505
+ }
475506
blob_appendf(&json, "{\"msgid\":%d,\"mtime\":%!j,", id, zDate);
476507
blob_appendf(&json, "\"xfrom\":%!j,", zFrom);
477508
blob_appendf(&json, "\"uclr\":%!j,", hash_color(zFrom));
478509
479510
zMsg = chat_format_to_html(zRawMsg ? zRawMsg : "");
@@ -491,11 +522,11 @@
491522
}else{
492523
blob_append(&json, "}", 1);
493524
}
494525
}
495526
db_reset(&q1);
496
- if( cnt ){
527
+ if( cnt || msgBefore>0 ){
497528
blob_append(&json, "\n]}", 3);
498529
cgi_set_content(&json);
499530
break;
500531
}
501532
sqlite3_sleep(iDelay);
502533
--- src/chat.c
+++ src/chat.c
@@ -397,10 +397,20 @@
397 ** chat session to just the most recent messages.
398 **
399 ** Some webservers (althttpd) do not allow a term of the URL path to
400 ** begin with "-". Then /chat-poll/-100 cannot be used. Instead you
401 ** have to say "/chat-poll?name=-100".
 
 
 
 
 
 
 
 
 
 
402 **
403 ** The reply from this webpage is JSON that describes the new content.
404 ** Format of the json:
405 **
406 ** | {
@@ -423,42 +433,63 @@
423 ** than zero. The "xmsg" field may be an empty string if "fsize" is zero.
424 **
425 ** The "msgid" values will be in increasing order.
426 **
427 ** The "mdel" will only exist if "xmsg" is an empty string and "fsize" is zero.
 
 
 
428 */
429 void chat_poll_webpage(void){
430 Blob json; /* The json to be constructed and returned */
431 sqlite3_int64 dataVersion; /* Data version. Used for polling. */
432 int iDelay = 1000; /* Delay until next poll (milliseconds) */
433 const char *zSep = "{\"msgs\":[\n"; /* List separator */
434 int msgid = atoi(PD("name","0"));
 
 
 
435 Stmt q1;
436 login_check_credentials();
437 if( !g.perm.Chat ) return;
438 chat_create_tables();
439 cgi_set_content_type("text/json");
440 dataVersion = db_int64(0, "PRAGMA data_version");
441 if( msgid<=0 ){
 
 
 
 
 
442 db_begin_write();
443 chat_purge();
444 db_commit_transaction();
445 }
446 if( msgid<0 ){
447 msgid = db_int(0,
448 "SELECT msgid FROM chat WHERE mdel IS NOT true"
449 " ORDER BY msgid DESC LIMIT 1 OFFSET %d", -msgid);
450 }
451 db_prepare(&q1,
452 "SELECT msgid, datetime(mtime), xfrom, xmsg, length(file),"
453 " fname, fmime, mdel"
454 " FROM chat"
455 " WHERE msgid>%d"
456 " ORDER BY msgid",
457 msgid
458 );
459 blob_init(&json, 0, 0);
 
 
 
 
 
 
 
 
 
 
 
460 while(1){
461 int cnt = 0;
462 while( db_step(&q1)==SQLITE_ROW ){
463 int id = db_column_int(&q1, 0);
464 const char *zDate = db_column_text(&q1, 1);
@@ -467,13 +498,13 @@
467 int nByte = db_column_int(&q1, 4);
468 const char *zFName = db_column_text(&q1, 5);
469 const char *zFMime = db_column_text(&q1, 6);
470 int iToDel = db_column_int(&q1, 7);
471 char *zMsg;
472 cnt++;
473 blob_append(&json, zSep, -1);
474 zSep = ",\n";
475 blob_appendf(&json, "{\"msgid\":%d,\"mtime\":%!j,", id, zDate);
476 blob_appendf(&json, "\"xfrom\":%!j,", zFrom);
477 blob_appendf(&json, "\"uclr\":%!j,", hash_color(zFrom));
478
479 zMsg = chat_format_to_html(zRawMsg ? zRawMsg : "");
@@ -491,11 +522,11 @@
491 }else{
492 blob_append(&json, "}", 1);
493 }
494 }
495 db_reset(&q1);
496 if( cnt ){
497 blob_append(&json, "\n]}", 3);
498 cgi_set_content(&json);
499 break;
500 }
501 sqlite3_sleep(iDelay);
502
--- src/chat.c
+++ src/chat.c
@@ -397,10 +397,20 @@
397 ** chat session to just the most recent messages.
398 **
399 ** Some webservers (althttpd) do not allow a term of the URL path to
400 ** begin with "-". Then /chat-poll/-100 cannot be used. Instead you
401 ** have to say "/chat-poll?name=-100".
402 **
403 ** If the integer parameter "before" is passed in, it is assumed that
404 ** the client is requesting older messages, up to (but not including)
405 ** that message ID, in which case the next-oldest "n" messages
406 ** (default=chat-initial-history setting, equivalent to n=0) are
407 ** returned (negative n fetches all older entries). The client then
408 ** needs to take care to inject them at the end of the history rather
409 ** than the same place new messages go.
410 **
411 ** If "before" is provided, "name" is ignored.
412 **
413 ** The reply from this webpage is JSON that describes the new content.
414 ** Format of the json:
415 **
416 ** | {
@@ -423,42 +433,63 @@
433 ** than zero. The "xmsg" field may be an empty string if "fsize" is zero.
434 **
435 ** The "msgid" values will be in increasing order.
436 **
437 ** The "mdel" will only exist if "xmsg" is an empty string and "fsize" is zero.
438 **
439 ** The messages are ordered oldest first unless "before" is provided, in which
440 ** case they are sorted newest first (to facilitate the client-side UI update).
441 */
442 void chat_poll_webpage(void){
443 Blob json; /* The json to be constructed and returned */
444 sqlite3_int64 dataVersion; /* Data version. Used for polling. */
445 int iDelay = 1000; /* Delay until next poll (milliseconds) */
 
446 int msgid = atoi(PD("name","0"));
447 const int msgBefore = atoi(PD("before","0"));
448 int nLimit = msgBefore>0 ? atoi(PD("n","0")) : 0;
449 Blob sql = empty_blob;
450 Stmt q1;
451 login_check_credentials();
452 if( !g.perm.Chat ) return;
453 chat_create_tables();
454 cgi_set_content_type("text/json");
455 dataVersion = db_int64(0, "PRAGMA data_version");
456 blob_append_sql(&sql,
457 "SELECT msgid, datetime(mtime), xfrom, xmsg, length(file),"
458 " fname, fmime, %s"
459 " FROM chat ",
460 msgBefore>0 ? "0 as mdel" : "mdel");
461 if( msgid<=0 || msgBefore>0 ){
462 db_begin_write();
463 chat_purge();
464 db_commit_transaction();
465 }
466 if(msgBefore>0){
467 if(0==nLimit){
468 nLimit = db_get_int("chat-initial-history",50);
469 }
470 blob_append_sql(&sql,
471 " WHERE msgid<%d"
472 " ORDER BY msgid DESC "
473 "LIMIT %d",
474 msgBefore, nLimit>0 ? nLimit : -1
475 );
476 }else{
477 if( msgid<0 ){
478 msgid = db_int(0,
479 "SELECT msgid FROM chat WHERE mdel IS NOT true"
480 " ORDER BY msgid DESC LIMIT 1 OFFSET %d", -msgid);
481 }
482 blob_append_sql(&sql,
483 " WHERE msgid>%d"
484 " ORDER BY msgid",
485 msgid
486 );
487 }
488 db_prepare(&q1, "%s", blob_sql_text(&sql));
489 blob_reset(&sql);
490 blob_init(&json, "{\"msgs\":[\n", -1);
491 while(1){
492 int cnt = 0;
493 while( db_step(&q1)==SQLITE_ROW ){
494 int id = db_column_int(&q1, 0);
495 const char *zDate = db_column_text(&q1, 1);
@@ -467,13 +498,13 @@
498 int nByte = db_column_int(&q1, 4);
499 const char *zFName = db_column_text(&q1, 5);
500 const char *zFMime = db_column_text(&q1, 6);
501 int iToDel = db_column_int(&q1, 7);
502 char *zMsg;
503 if(cnt++){
504 blob_append(&json, ",\n", 2);
505 }
506 blob_appendf(&json, "{\"msgid\":%d,\"mtime\":%!j,", id, zDate);
507 blob_appendf(&json, "\"xfrom\":%!j,", zFrom);
508 blob_appendf(&json, "\"uclr\":%!j,", hash_color(zFrom));
509
510 zMsg = chat_format_to_html(zRawMsg ? zRawMsg : "");
@@ -491,11 +522,11 @@
522 }else{
523 blob_append(&json, "}", 1);
524 }
525 }
526 db_reset(&q1);
527 if( cnt || msgBefore>0 ){
528 blob_append(&json, "\n]}", 3);
529 cgi_set_content(&json);
530 break;
531 }
532 sqlite3_sleep(iDelay);
533
+222 -99
--- src/chat.js
+++ src/chat.js
@@ -5,18 +5,81 @@
55
(function(){
66
const form = document.querySelector('#chat-form');
77
const F = window.fossil, D = F.dom;
88
const Chat = (function(){
99
const cs = {
10
+ e:{/*map of certain DOM elements.*/
11
+ messageInjectPoint: document.querySelector('#message-inject-point'),
12
+ pageTitle: document.querySelector('head title'),
13
+ loadToolbar: undefined /* the load-posts toolbar (dynamically created) */
14
+ },
1015
me: F.user.name,
1116
mxMsg: F.config.chatInitSize ? -F.config.chatInitSize : -50,
17
+ mnMsg: undefined/*lowest message ID we've seen so far (for history loading)*/,
1218
pageIsActive: 'visible'===document.visibilityState,
1319
changesSincePageHidden: 0,
1420
notificationBubbleColor: 'white',
15
- pageTitle: document.querySelector('head title')
21
+ totalMessageCount: 0, // total # of inbound messages
22
+ //! Number of messages to load for the history buttons
23
+ loadMessageCount: Math.abs(F.config.chatInitSize || 20),
24
+ ajaxInflight: 0,
25
+ /** Enables (if yes is truthy) or disables all elements in
26
+ * this.disableDuringAjax. */
27
+ enableAjaxComponents: function(yes){
28
+ D[yes ? 'enable' : 'disable'](this.disableDuringAjax);
29
+ return this;
30
+ },
31
+ /* Must be called before any API is used which starts ajax traffic.
32
+ If this call represents the currently only in-flight ajax request,
33
+ all DOM elements in this.disableDuringAjax are disabled.
34
+ We cannot do this via a central API because (1) window.fetch()'s
35
+ Promise-based API seemingly makes that impossible and (2) the polling
36
+ technique holds ajax requests open for as long as possible. A call
37
+ to this method obligates the caller to also call ajaxEnd().
38
+
39
+ This must NOT be called for the chat-polling API except, as a
40
+ special exception, the very first one which fetches the
41
+ initial message list.
42
+ */
43
+ ajaxStart: function(){
44
+ if(1===++this.ajaxInflight){
45
+ this.enableAjaxComponents(false);
46
+ }
47
+ },
48
+ /* Must be called after any ajax-related call for which
49
+ ajaxStart() was called, regardless of success or failure. If
50
+ it was the last such call (as measured by calls to
51
+ ajaxStart() and ajaxEnd()), elements disabled by a prior call
52
+ to ajaxStart() will be re-enabled. */
53
+ ajaxEnd: function(){
54
+ if(0===--this.ajaxInflight){
55
+ this.enableAjaxComponents(true);
56
+ }
57
+ },
58
+ disableDuringAjax: [
59
+ /* List of DOM elements disable while ajax traffic is in
60
+ transit. Must be populated before ajax starts. We do this
61
+ to avoid various race conditions in the UI and long-running
62
+ network requests. */
63
+ ],
64
+ /* Injects element e as a new row in the chat, at the top of the
65
+ list if atEnd is falsy, else at the end of the list, before
66
+ the load-history widget. */
67
+ injectMessageElem: function f(e, atEnd){
68
+ const mip = atEnd ? this.e.loadToolbar : this.e.messageInjectPoint;
69
+ if(atEnd){
70
+ mip.parentNode.insertBefore(e, mip);
71
+ }else{
72
+ if(mip.nextSibling){
73
+ mip.parentNode.insertBefore(e, mip.nextSibling);
74
+ }else{
75
+ mip.parentNode.appendChild(e);
76
+ }
77
+ }
78
+ }
1679
};
17
- cs.pageTitleOrig = cs.pageTitle.innerText;
80
+ cs.pageTitleOrig = cs.e.pageTitle.innerText;
1881
const qs = (e)=>document.querySelector(e);
1982
const argsToArray = function(args){
2083
return Array.prototype.slice.call(args,0);
2184
};
2285
cs.reportError = function(/*msg args*/){
@@ -75,21 +138,23 @@
75138
}else{
76139
e = this.getMessageElemById(id);
77140
}
78141
if(!(e instanceof HTMLElement)) return;
79142
if(this.userMayDelete(e)){
143
+ this.ajaxStart();
80144
fetch("chat-delete?name=" + id)
81145
.then(()=>this.deleteMessageElem(e))
82146
.catch(err=>this.reportError(err))
147
+ .finally(()=>this.ajaxEnd());
83148
}else{
84149
this.deleteMessageElem(id);
85150
}
86151
};
87152
document.addEventListener('visibilitychange', function(ev){
88153
cs.pageIsActive = !document.hidden;
89154
if(cs.pageIsActive){
90
- cs.pageTitle.innerText = cs.pageTitleOrig;
155
+ cs.e.pageTitle.innerText = cs.pageTitleOrig;
91156
}
92157
}, true);
93158
return cs;
94159
})()/*Chat initialization*/;
95160
/* State for paste and drag/drop */
@@ -195,21 +260,10 @@
195260
};
196261
Object.keys(dropEvents).forEach(
197262
(k)=>form.file.addEventListener(k, dropEvents[k], true)
198263
);
199264
200
- /* Injects element e as a new row in the chat, at the top of the list */
201
- const injectMessage = function f(e){
202
- if(!f.injectPoint){
203
- f.injectPoint = document.querySelector('#message-inject-point');
204
- }
205
- if(f.injectPoint.nextSibling){
206
- f.injectPoint.parentNode.insertBefore(e, f.injectPoint.nextSibling);
207
- }else{
208
- f.injectPoint.parentNode.appendChild(e);
209
- }
210
- };
211265
/* Returns a new TEXT node with the given text content. */
212266
/** Returns the local time string of Date object d, defaulting
213267
to the current time. */
214268
const localTimeString = function ff(d){
215269
if(!ff.pad){
@@ -266,11 +320,11 @@
266320
f.popup.hide = function(){
267321
delete this._eMsg;
268322
D.clearElement(this.e);
269323
return this.show(false);
270324
};
271
- }
325
+ }/*end static init*/
272326
const rect = ev.target.getBoundingClientRect();
273327
const eMsg = ev.target.parentNode/*the owning fieldset element*/;
274328
f.popup._eMsg = eMsg;
275329
let x = rect.left, y = rect.top - 10;
276330
f.popup.show(ev.target)/*so we can get its computed size*/;
@@ -279,103 +333,172 @@
279333
// truncation off the right edge of the page.
280334
const pRect = f.popup.e.getBoundingClientRect();
281335
x -= pRect.width/3*2;
282336
}
283337
f.popup.show(x, y);
284
- };
338
+ }/*handleLegendClicked()*/;
285339
286
- /** Callback for poll() to inject new content into the page. */
287
- function newcontent(jx){
288
- var i;
340
+ /** Callback for poll() to inject new content into the page. jx ==
341
+ the response from /chat-poll. If atEnd is true, the message is
342
+ appended to the end of the chat list, else the beginning (the
343
+ default). */
344
+ const newcontent = function f(jx,atEnd){
345
+ if(!f.processPost){
346
+ /** Processes chat message m, placing it either the start (if atEnd
347
+ is falsy) or end (if atEnd is truthy) of the chat history. atEnd
348
+ should only be true when loading older messages. */
349
+ f.processPost = function(m,atEnd){
350
+ ++Chat.totalMessageCount;
351
+ if( m.msgid>Chat.mxMsg ) Chat.mxMsg = m.msgid;
352
+ if( !Chat.mnMsg || m.msgid<Chat.mnMsg) Chat.mnMsg = m.msgid;
353
+ if( m.mdel ){
354
+ /* A record deletion notice. */
355
+ Chat.deleteMessageElem(m.mdel);
356
+ return;
357
+ }
358
+ const eWho = D.create('legend'),
359
+ row = D.addClass(D.fieldset(eWho), 'message-row');
360
+ row.dataset.msgid = m.msgid;
361
+ row.dataset.xfrom = m.xfrom;
362
+ row.dataset.timestamp = m.mtime;
363
+ Chat.injectMessageElem(row,atEnd);
364
+ eWho.addEventListener('click', handleLegendClicked, false);
365
+ if( m.xfrom==Chat.me && window.outerWidth<1000 ){
366
+ eWho.setAttribute('align', 'right');
367
+ row.style.justifyContent = "flex-end";
368
+ }else{
369
+ eWho.setAttribute('align', 'left');
370
+ }
371
+ eWho.style.backgroundColor = m.uclr;
372
+ eWho.classList.add('message-user');
373
+ let whoName = m.xfrom;
374
+ var d = new Date(m.mtime + "Z");
375
+ if( d.getMinutes().toString()!="NaN" ){
376
+ /* Show local time when we can compute it */
377
+ eWho.append(D.text(whoName+' @ '+
378
+ d.getHours()+":"+(d.getMinutes()+100).toString().slice(1,3)
379
+ ))
380
+ }else{
381
+ /* Show UTC on systems where Date() does not work */
382
+ eWho.append(D.text(whoName+' @ '+m.mtime.slice(11,16)))
383
+ }
384
+ let eContent = D.addClass(D.div(),'message-content','chat-message');
385
+ eContent.style.backgroundColor = m.uclr;
386
+ row.appendChild(eContent);
387
+ if( m.fsize>0 ){
388
+ if( m.fmime && m.fmime.startsWith("image/") ){
389
+ eContent.appendChild(D.img("chat-download/" + m.msgid));
390
+ }else{
391
+ eContent.appendChild(D.a(
392
+ window.fossil.rootPath+
393
+ 'chat-download/' + m.msgid+'/'+encodeURIComponent(m.fname),
394
+ // ^^^ add m.fname to URL to cause downloaded file to have that name.
395
+ "(" + m.fname + " " + m.fsize + " bytes)"
396
+ ));
397
+ }
398
+ const br = D.br();
399
+ br.style.clear = "both";
400
+ eContent.appendChild(br);
401
+ }
402
+ if(m.xmsg){
403
+ // The m.xmsg text comes from the same server as this script and
404
+ // is guaranteed by that server to be "safe" HTML - safe in the
405
+ // sense that it is not possible for a malefactor to inject HTML
406
+ // or javascript or CSS. The m.xmsg content might contain
407
+ // hyperlinks, but otherwise it will be markup-free. See the
408
+ // chat_format_to_html() routine in the server for details.
409
+ //
410
+ // Hence, even though innerHTML is normally frowned upon, it is
411
+ // perfectly safe to use in this context.
412
+ eContent.innerHTML += m.xmsg
413
+ }
414
+ }/*processPost()*/;
415
+ }/*end static init*/
416
+ jx.msgs.forEach((m)=>f.processPost(m,atEnd));
289417
if('visible'===document.visibilityState){
290418
if(Chat.changesSincePageHidden){
291419
Chat.changesSincePageHidden = 0;
292
- Chat.pageTitle.innerText = Chat.pageTitleOrig;
420
+ Chat.e.pageTitle.innerText = Chat.pageTitleOrig;
293421
}
294422
}else{
295423
Chat.changesSincePageHidden += jx.msgs.length;
296
- Chat.pageTitle.innerText = '('+Chat.changesSincePageHidden+') '+
424
+ Chat.e.pageTitle.innerText = '('+Chat.changesSincePageHidden+') '+
297425
Chat.pageTitleOrig;
298426
}
299
- for(i=0; i<jx.msgs.length; ++i){
300
- const m = jx.msgs[i];
301
- if( m.msgid>Chat.mxMsg ) Chat.mxMsg = m.msgid;
302
- if( m.mdel ){
303
- /* A record deletion notice. */
304
- Chat.deleteMessageElem(m.mdel);
305
- continue;
306
- }
307
- const eWho = D.create('legend'),
308
- row = D.addClass(D.fieldset(eWho), 'message-row');
309
- row.dataset.msgid = m.msgid;
310
- row.dataset.xfrom = m.xfrom;
311
- row.dataset.timestamp = m.mtime;
312
- injectMessage(row);
313
- eWho.addEventListener('click', handleLegendClicked, false);
314
- if( m.xfrom==Chat.me && window.outerWidth<1000 ){
315
- eWho.setAttribute('align', 'right');
316
- row.style.justifyContent = "flex-end";
317
- }else{
318
- eWho.setAttribute('align', 'left');
319
- }
320
- eWho.style.backgroundColor = m.uclr;
321
- eWho.classList.add('message-user');
322
- let whoName = m.xfrom;
323
- var d = new Date(m.mtime + "Z");
324
- if( d.getMinutes().toString()!="NaN" ){
325
- /* Show local time when we can compute it */
326
- eWho.append(D.text(whoName+' @ '+
327
- d.getHours()+":"+(d.getMinutes()+100).toString().slice(1,3)
328
- ))
329
- }else{
330
- /* Show UTC on systems where Date() does not work */
331
- eWho.append(D.text(whoName+' @ '+m.mtime.slice(11,16)))
332
- }
333
- let eContent = D.addClass(D.div(),'message-content','chat-message');
334
- eContent.style.backgroundColor = m.uclr;
335
- row.appendChild(eContent);
336
- if( m.fsize>0 ){
337
- if( m.fmime && m.fmime.startsWith("image/") ){
338
- eContent.appendChild(D.img("chat-download/" + m.msgid));
339
- }else{
340
- eContent.appendChild(D.a(
341
- window.fossil.rootPath+
342
- 'chat-download/' + m.msgid+'/'+encodeURIComponent(m.fname),
343
- // ^^^ add m.fname to URL to cause downloaded file to have that name.
344
- "(" + m.fname + " " + m.fsize + " bytes)"
345
- ));
346
- }
347
- const br = D.br();
348
- br.style.clear = "both";
349
- eContent.appendChild(br);
350
- }
351
- if(m.xmsg){
352
- // The m.xmsg text comes from the same server as this script and
353
- // is guaranteed by that server to be "safe" HTML - safe in the
354
- // sense that it is not possible for a malefactor to inject HTML
355
- // or javascript or CSS. The m.xmsg content might contain
356
- // hyperlinks, but otherwise it will be markup-free. See the
357
- // chat_format_to_html() routine in the server for details.
358
- //
359
- // Hence, even though innerHTML is normally frowned upon, it is
360
- // perfectly safe to use in this context.
361
- eContent.innerHTML += m.xmsg
362
- }
363
- eContent.classList.add('chat-message');
364
- }
365
- if(i && window.fossil.config.pingTcp){
427
+ if(jx.msgs.length && F.config.pingTcp){
366428
fetch("http:/"+"/localhost:"+window.fossil.config.pingTcp+"/chat-ping");
367429
}
368
- }
369
- async function poll(){
430
+ }/*newcontent()*/;
431
+
432
+ if(true){
433
+ /** Add toolbar for loading older messages. We use a FIELDSET here
434
+ because a fieldset is the only parent element type which can
435
+ automatically enable/disable its children by
436
+ enabling/disabling the parent element. */
437
+ const loadLegend = D.legend("Load...");
438
+ const toolbar = Chat.e.loadToolbar = D.addClass(
439
+ D.fieldset(loadLegend), "load-msg-toolbar"
440
+ );
441
+ Chat.disableDuringAjax.push(toolbar);
442
+ /* Loads the next n oldest messages, or all previous history if n is negative. */
443
+ const loadOldMessages = function(n){
444
+ Chat.ajaxStart();
445
+ var gotMessages = false;
446
+ fetch("chat-poll?before="+Chat.mnMsg+"&n="+n)
447
+ .then(x=>x.json())
448
+ .then(function(x){
449
+ gotMessages = x.msgs.length;
450
+ newcontent(x,true);
451
+ })
452
+ .catch(e=>Chat.reportError(e))
453
+ .finally(function(){
454
+ if(n<0/*we asked for all history*/
455
+ || 0===gotMessages/*we found no history*/
456
+ || (n>0 && gotMessages<n /*we got fewer history entries than requested*/)
457
+ || (false!==gotMessages && n<0 && gotMessages<Chat.loadMessageCount
458
+ /*we asked for default amount and got fewer than that.*/)){
459
+ /* We've loaded all history. Permanently disable the
460
+ history-load toolbar and keep it from being re-enabled
461
+ via the ajaxStart()/ajaxEnd() mechanism... */
462
+ const div = Chat.e.loadToolbar.querySelector('div');
463
+ D.append(D.clearElement(div), "All history has been loaded.");
464
+ D.addClass(Chat.e.loadToolbar, 'all-done');
465
+ const ndx = Chat.disableDuringAjax.indexOf(Chat.e.loadToolbar);
466
+ if(ndx>=0) Chat.disableDuringAjax.splice(ndx,1);
467
+ Chat.e.loadToolbar.disabled = true;
468
+ }
469
+ if(gotMessages > 0){
470
+ F.toast.message("Loaded "+gotMessages+" older messages.");
471
+ }
472
+ Chat.ajaxEnd();
473
+ });
474
+ };
475
+ const wrapper = D.div(); /* browsers don't all properly handle >1 child in a fieldset */;
476
+ D.append(toolbar, wrapper);
477
+ var btn = D.button("Previous "+Chat.loadMessageCount+" messages");
478
+ D.append(wrapper, btn);
479
+ btn.addEventListener('click',()=>loadOldMessages(Chat.loadMessageCount));
480
+ btn = D.button("All previous messages");
481
+ D.append(wrapper, btn);
482
+ btn.addEventListener('click',()=>loadOldMessages(-1));
483
+ D.append(document.querySelector('body.chat > div.content'), toolbar);
484
+ toolbar.disabled = true /*will be enabled when msg load finishes */;
485
+ }/*end history loading widget setup*/
486
+
487
+ async function poll(isFirstCall){
370488
if(poll.running) return;
371489
poll.running = true;
372
- fetch("chat-poll?name=" + Chat.mxMsg)
373
- .then(x=>x.json())
374
- .then(y=>newcontent(y))
375
- .catch(e=>console.error(e))
376
- .finally(()=>poll.running=false)
377
- }
378
- poll();
379
- setInterval(poll, 1000);
380
- F.page.chat = Chat;
490
+ if(isFirstCall) Chat.ajaxStart();
491
+ var p = fetch("chat-poll?name=" + Chat.mxMsg);
492
+ p.then(x=>x.json())
493
+ .then(y=>newcontent(y))
494
+ .catch(e=>Chat.reportError(e))
495
+ .finally(function(x){
496
+ if(isFirstCall) Chat.ajaxEnd();
497
+ poll.running=false;
498
+ });
499
+ }
500
+ poll.running = false;
501
+ poll(true);
502
+ setInterval(poll, 1000);
503
+ F.page.chat = Chat/* enables testing the APIs via the dev tools */;
381504
})();
382505
--- src/chat.js
+++ src/chat.js
@@ -5,18 +5,81 @@
5 (function(){
6 const form = document.querySelector('#chat-form');
7 const F = window.fossil, D = F.dom;
8 const Chat = (function(){
9 const cs = {
 
 
 
 
 
10 me: F.user.name,
11 mxMsg: F.config.chatInitSize ? -F.config.chatInitSize : -50,
 
12 pageIsActive: 'visible'===document.visibilityState,
13 changesSincePageHidden: 0,
14 notificationBubbleColor: 'white',
15 pageTitle: document.querySelector('head title')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16 };
17 cs.pageTitleOrig = cs.pageTitle.innerText;
18 const qs = (e)=>document.querySelector(e);
19 const argsToArray = function(args){
20 return Array.prototype.slice.call(args,0);
21 };
22 cs.reportError = function(/*msg args*/){
@@ -75,21 +138,23 @@
75 }else{
76 e = this.getMessageElemById(id);
77 }
78 if(!(e instanceof HTMLElement)) return;
79 if(this.userMayDelete(e)){
 
80 fetch("chat-delete?name=" + id)
81 .then(()=>this.deleteMessageElem(e))
82 .catch(err=>this.reportError(err))
 
83 }else{
84 this.deleteMessageElem(id);
85 }
86 };
87 document.addEventListener('visibilitychange', function(ev){
88 cs.pageIsActive = !document.hidden;
89 if(cs.pageIsActive){
90 cs.pageTitle.innerText = cs.pageTitleOrig;
91 }
92 }, true);
93 return cs;
94 })()/*Chat initialization*/;
95 /* State for paste and drag/drop */
@@ -195,21 +260,10 @@
195 };
196 Object.keys(dropEvents).forEach(
197 (k)=>form.file.addEventListener(k, dropEvents[k], true)
198 );
199
200 /* Injects element e as a new row in the chat, at the top of the list */
201 const injectMessage = function f(e){
202 if(!f.injectPoint){
203 f.injectPoint = document.querySelector('#message-inject-point');
204 }
205 if(f.injectPoint.nextSibling){
206 f.injectPoint.parentNode.insertBefore(e, f.injectPoint.nextSibling);
207 }else{
208 f.injectPoint.parentNode.appendChild(e);
209 }
210 };
211 /* Returns a new TEXT node with the given text content. */
212 /** Returns the local time string of Date object d, defaulting
213 to the current time. */
214 const localTimeString = function ff(d){
215 if(!ff.pad){
@@ -266,11 +320,11 @@
266 f.popup.hide = function(){
267 delete this._eMsg;
268 D.clearElement(this.e);
269 return this.show(false);
270 };
271 }
272 const rect = ev.target.getBoundingClientRect();
273 const eMsg = ev.target.parentNode/*the owning fieldset element*/;
274 f.popup._eMsg = eMsg;
275 let x = rect.left, y = rect.top - 10;
276 f.popup.show(ev.target)/*so we can get its computed size*/;
@@ -279,103 +333,172 @@
279 // truncation off the right edge of the page.
280 const pRect = f.popup.e.getBoundingClientRect();
281 x -= pRect.width/3*2;
282 }
283 f.popup.show(x, y);
284 };
285
286 /** Callback for poll() to inject new content into the page. */
287 function newcontent(jx){
288 var i;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
289 if('visible'===document.visibilityState){
290 if(Chat.changesSincePageHidden){
291 Chat.changesSincePageHidden = 0;
292 Chat.pageTitle.innerText = Chat.pageTitleOrig;
293 }
294 }else{
295 Chat.changesSincePageHidden += jx.msgs.length;
296 Chat.pageTitle.innerText = '('+Chat.changesSincePageHidden+') '+
297 Chat.pageTitleOrig;
298 }
299 for(i=0; i<jx.msgs.length; ++i){
300 const m = jx.msgs[i];
301 if( m.msgid>Chat.mxMsg ) Chat.mxMsg = m.msgid;
302 if( m.mdel ){
303 /* A record deletion notice. */
304 Chat.deleteMessageElem(m.mdel);
305 continue;
306 }
307 const eWho = D.create('legend'),
308 row = D.addClass(D.fieldset(eWho), 'message-row');
309 row.dataset.msgid = m.msgid;
310 row.dataset.xfrom = m.xfrom;
311 row.dataset.timestamp = m.mtime;
312 injectMessage(row);
313 eWho.addEventListener('click', handleLegendClicked, false);
314 if( m.xfrom==Chat.me && window.outerWidth<1000 ){
315 eWho.setAttribute('align', 'right');
316 row.style.justifyContent = "flex-end";
317 }else{
318 eWho.setAttribute('align', 'left');
319 }
320 eWho.style.backgroundColor = m.uclr;
321 eWho.classList.add('message-user');
322 let whoName = m.xfrom;
323 var d = new Date(m.mtime + "Z");
324 if( d.getMinutes().toString()!="NaN" ){
325 /* Show local time when we can compute it */
326 eWho.append(D.text(whoName+' @ '+
327 d.getHours()+":"+(d.getMinutes()+100).toString().slice(1,3)
328 ))
329 }else{
330 /* Show UTC on systems where Date() does not work */
331 eWho.append(D.text(whoName+' @ '+m.mtime.slice(11,16)))
332 }
333 let eContent = D.addClass(D.div(),'message-content','chat-message');
334 eContent.style.backgroundColor = m.uclr;
335 row.appendChild(eContent);
336 if( m.fsize>0 ){
337 if( m.fmime && m.fmime.startsWith("image/") ){
338 eContent.appendChild(D.img("chat-download/" + m.msgid));
339 }else{
340 eContent.appendChild(D.a(
341 window.fossil.rootPath+
342 'chat-download/' + m.msgid+'/'+encodeURIComponent(m.fname),
343 // ^^^ add m.fname to URL to cause downloaded file to have that name.
344 "(" + m.fname + " " + m.fsize + " bytes)"
345 ));
346 }
347 const br = D.br();
348 br.style.clear = "both";
349 eContent.appendChild(br);
350 }
351 if(m.xmsg){
352 // The m.xmsg text comes from the same server as this script and
353 // is guaranteed by that server to be "safe" HTML - safe in the
354 // sense that it is not possible for a malefactor to inject HTML
355 // or javascript or CSS. The m.xmsg content might contain
356 // hyperlinks, but otherwise it will be markup-free. See the
357 // chat_format_to_html() routine in the server for details.
358 //
359 // Hence, even though innerHTML is normally frowned upon, it is
360 // perfectly safe to use in this context.
361 eContent.innerHTML += m.xmsg
362 }
363 eContent.classList.add('chat-message');
364 }
365 if(i && window.fossil.config.pingTcp){
366 fetch("http:/"+"/localhost:"+window.fossil.config.pingTcp+"/chat-ping");
367 }
368 }
369 async function poll(){
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
370 if(poll.running) return;
371 poll.running = true;
372 fetch("chat-poll?name=" + Chat.mxMsg)
373 .then(x=>x.json())
374 .then(y=>newcontent(y))
375 .catch(e=>console.error(e))
376 .finally(()=>poll.running=false)
377 }
378 poll();
379 setInterval(poll, 1000);
380 F.page.chat = Chat;
 
 
 
 
 
381 })();
382
--- src/chat.js
+++ src/chat.js
@@ -5,18 +5,81 @@
5 (function(){
6 const form = document.querySelector('#chat-form');
7 const F = window.fossil, D = F.dom;
8 const Chat = (function(){
9 const cs = {
10 e:{/*map of certain DOM elements.*/
11 messageInjectPoint: document.querySelector('#message-inject-point'),
12 pageTitle: document.querySelector('head title'),
13 loadToolbar: undefined /* the load-posts toolbar (dynamically created) */
14 },
15 me: F.user.name,
16 mxMsg: F.config.chatInitSize ? -F.config.chatInitSize : -50,
17 mnMsg: undefined/*lowest message ID we've seen so far (for history loading)*/,
18 pageIsActive: 'visible'===document.visibilityState,
19 changesSincePageHidden: 0,
20 notificationBubbleColor: 'white',
21 totalMessageCount: 0, // total # of inbound messages
22 //! Number of messages to load for the history buttons
23 loadMessageCount: Math.abs(F.config.chatInitSize || 20),
24 ajaxInflight: 0,
25 /** Enables (if yes is truthy) or disables all elements in
26 * this.disableDuringAjax. */
27 enableAjaxComponents: function(yes){
28 D[yes ? 'enable' : 'disable'](this.disableDuringAjax);
29 return this;
30 },
31 /* Must be called before any API is used which starts ajax traffic.
32 If this call represents the currently only in-flight ajax request,
33 all DOM elements in this.disableDuringAjax are disabled.
34 We cannot do this via a central API because (1) window.fetch()'s
35 Promise-based API seemingly makes that impossible and (2) the polling
36 technique holds ajax requests open for as long as possible. A call
37 to this method obligates the caller to also call ajaxEnd().
38
39 This must NOT be called for the chat-polling API except, as a
40 special exception, the very first one which fetches the
41 initial message list.
42 */
43 ajaxStart: function(){
44 if(1===++this.ajaxInflight){
45 this.enableAjaxComponents(false);
46 }
47 },
48 /* Must be called after any ajax-related call for which
49 ajaxStart() was called, regardless of success or failure. If
50 it was the last such call (as measured by calls to
51 ajaxStart() and ajaxEnd()), elements disabled by a prior call
52 to ajaxStart() will be re-enabled. */
53 ajaxEnd: function(){
54 if(0===--this.ajaxInflight){
55 this.enableAjaxComponents(true);
56 }
57 },
58 disableDuringAjax: [
59 /* List of DOM elements disable while ajax traffic is in
60 transit. Must be populated before ajax starts. We do this
61 to avoid various race conditions in the UI and long-running
62 network requests. */
63 ],
64 /* Injects element e as a new row in the chat, at the top of the
65 list if atEnd is falsy, else at the end of the list, before
66 the load-history widget. */
67 injectMessageElem: function f(e, atEnd){
68 const mip = atEnd ? this.e.loadToolbar : this.e.messageInjectPoint;
69 if(atEnd){
70 mip.parentNode.insertBefore(e, mip);
71 }else{
72 if(mip.nextSibling){
73 mip.parentNode.insertBefore(e, mip.nextSibling);
74 }else{
75 mip.parentNode.appendChild(e);
76 }
77 }
78 }
79 };
80 cs.pageTitleOrig = cs.e.pageTitle.innerText;
81 const qs = (e)=>document.querySelector(e);
82 const argsToArray = function(args){
83 return Array.prototype.slice.call(args,0);
84 };
85 cs.reportError = function(/*msg args*/){
@@ -75,21 +138,23 @@
138 }else{
139 e = this.getMessageElemById(id);
140 }
141 if(!(e instanceof HTMLElement)) return;
142 if(this.userMayDelete(e)){
143 this.ajaxStart();
144 fetch("chat-delete?name=" + id)
145 .then(()=>this.deleteMessageElem(e))
146 .catch(err=>this.reportError(err))
147 .finally(()=>this.ajaxEnd());
148 }else{
149 this.deleteMessageElem(id);
150 }
151 };
152 document.addEventListener('visibilitychange', function(ev){
153 cs.pageIsActive = !document.hidden;
154 if(cs.pageIsActive){
155 cs.e.pageTitle.innerText = cs.pageTitleOrig;
156 }
157 }, true);
158 return cs;
159 })()/*Chat initialization*/;
160 /* State for paste and drag/drop */
@@ -195,21 +260,10 @@
260 };
261 Object.keys(dropEvents).forEach(
262 (k)=>form.file.addEventListener(k, dropEvents[k], true)
263 );
264
 
 
 
 
 
 
 
 
 
 
 
265 /* Returns a new TEXT node with the given text content. */
266 /** Returns the local time string of Date object d, defaulting
267 to the current time. */
268 const localTimeString = function ff(d){
269 if(!ff.pad){
@@ -266,11 +320,11 @@
320 f.popup.hide = function(){
321 delete this._eMsg;
322 D.clearElement(this.e);
323 return this.show(false);
324 };
325 }/*end static init*/
326 const rect = ev.target.getBoundingClientRect();
327 const eMsg = ev.target.parentNode/*the owning fieldset element*/;
328 f.popup._eMsg = eMsg;
329 let x = rect.left, y = rect.top - 10;
330 f.popup.show(ev.target)/*so we can get its computed size*/;
@@ -279,103 +333,172 @@
333 // truncation off the right edge of the page.
334 const pRect = f.popup.e.getBoundingClientRect();
335 x -= pRect.width/3*2;
336 }
337 f.popup.show(x, y);
338 }/*handleLegendClicked()*/;
339
340 /** Callback for poll() to inject new content into the page. jx ==
341 the response from /chat-poll. If atEnd is true, the message is
342 appended to the end of the chat list, else the beginning (the
343 default). */
344 const newcontent = function f(jx,atEnd){
345 if(!f.processPost){
346 /** Processes chat message m, placing it either the start (if atEnd
347 is falsy) or end (if atEnd is truthy) of the chat history. atEnd
348 should only be true when loading older messages. */
349 f.processPost = function(m,atEnd){
350 ++Chat.totalMessageCount;
351 if( m.msgid>Chat.mxMsg ) Chat.mxMsg = m.msgid;
352 if( !Chat.mnMsg || m.msgid<Chat.mnMsg) Chat.mnMsg = m.msgid;
353 if( m.mdel ){
354 /* A record deletion notice. */
355 Chat.deleteMessageElem(m.mdel);
356 return;
357 }
358 const eWho = D.create('legend'),
359 row = D.addClass(D.fieldset(eWho), 'message-row');
360 row.dataset.msgid = m.msgid;
361 row.dataset.xfrom = m.xfrom;
362 row.dataset.timestamp = m.mtime;
363 Chat.injectMessageElem(row,atEnd);
364 eWho.addEventListener('click', handleLegendClicked, false);
365 if( m.xfrom==Chat.me && window.outerWidth<1000 ){
366 eWho.setAttribute('align', 'right');
367 row.style.justifyContent = "flex-end";
368 }else{
369 eWho.setAttribute('align', 'left');
370 }
371 eWho.style.backgroundColor = m.uclr;
372 eWho.classList.add('message-user');
373 let whoName = m.xfrom;
374 var d = new Date(m.mtime + "Z");
375 if( d.getMinutes().toString()!="NaN" ){
376 /* Show local time when we can compute it */
377 eWho.append(D.text(whoName+' @ '+
378 d.getHours()+":"+(d.getMinutes()+100).toString().slice(1,3)
379 ))
380 }else{
381 /* Show UTC on systems where Date() does not work */
382 eWho.append(D.text(whoName+' @ '+m.mtime.slice(11,16)))
383 }
384 let eContent = D.addClass(D.div(),'message-content','chat-message');
385 eContent.style.backgroundColor = m.uclr;
386 row.appendChild(eContent);
387 if( m.fsize>0 ){
388 if( m.fmime && m.fmime.startsWith("image/") ){
389 eContent.appendChild(D.img("chat-download/" + m.msgid));
390 }else{
391 eContent.appendChild(D.a(
392 window.fossil.rootPath+
393 'chat-download/' + m.msgid+'/'+encodeURIComponent(m.fname),
394 // ^^^ add m.fname to URL to cause downloaded file to have that name.
395 "(" + m.fname + " " + m.fsize + " bytes)"
396 ));
397 }
398 const br = D.br();
399 br.style.clear = "both";
400 eContent.appendChild(br);
401 }
402 if(m.xmsg){
403 // The m.xmsg text comes from the same server as this script and
404 // is guaranteed by that server to be "safe" HTML - safe in the
405 // sense that it is not possible for a malefactor to inject HTML
406 // or javascript or CSS. The m.xmsg content might contain
407 // hyperlinks, but otherwise it will be markup-free. See the
408 // chat_format_to_html() routine in the server for details.
409 //
410 // Hence, even though innerHTML is normally frowned upon, it is
411 // perfectly safe to use in this context.
412 eContent.innerHTML += m.xmsg
413 }
414 }/*processPost()*/;
415 }/*end static init*/
416 jx.msgs.forEach((m)=>f.processPost(m,atEnd));
417 if('visible'===document.visibilityState){
418 if(Chat.changesSincePageHidden){
419 Chat.changesSincePageHidden = 0;
420 Chat.e.pageTitle.innerText = Chat.pageTitleOrig;
421 }
422 }else{
423 Chat.changesSincePageHidden += jx.msgs.length;
424 Chat.e.pageTitle.innerText = '('+Chat.changesSincePageHidden+') '+
425 Chat.pageTitleOrig;
426 }
427 if(jx.msgs.length && F.config.pingTcp){
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
428 fetch("http:/"+"/localhost:"+window.fossil.config.pingTcp+"/chat-ping");
429 }
430 }/*newcontent()*/;
431
432 if(true){
433 /** Add toolbar for loading older messages. We use a FIELDSET here
434 because a fieldset is the only parent element type which can
435 automatically enable/disable its children by
436 enabling/disabling the parent element. */
437 const loadLegend = D.legend("Load...");
438 const toolbar = Chat.e.loadToolbar = D.addClass(
439 D.fieldset(loadLegend), "load-msg-toolbar"
440 );
441 Chat.disableDuringAjax.push(toolbar);
442 /* Loads the next n oldest messages, or all previous history if n is negative. */
443 const loadOldMessages = function(n){
444 Chat.ajaxStart();
445 var gotMessages = false;
446 fetch("chat-poll?before="+Chat.mnMsg+"&n="+n)
447 .then(x=>x.json())
448 .then(function(x){
449 gotMessages = x.msgs.length;
450 newcontent(x,true);
451 })
452 .catch(e=>Chat.reportError(e))
453 .finally(function(){
454 if(n<0/*we asked for all history*/
455 || 0===gotMessages/*we found no history*/
456 || (n>0 && gotMessages<n /*we got fewer history entries than requested*/)
457 || (false!==gotMessages && n<0 && gotMessages<Chat.loadMessageCount
458 /*we asked for default amount and got fewer than that.*/)){
459 /* We've loaded all history. Permanently disable the
460 history-load toolbar and keep it from being re-enabled
461 via the ajaxStart()/ajaxEnd() mechanism... */
462 const div = Chat.e.loadToolbar.querySelector('div');
463 D.append(D.clearElement(div), "All history has been loaded.");
464 D.addClass(Chat.e.loadToolbar, 'all-done');
465 const ndx = Chat.disableDuringAjax.indexOf(Chat.e.loadToolbar);
466 if(ndx>=0) Chat.disableDuringAjax.splice(ndx,1);
467 Chat.e.loadToolbar.disabled = true;
468 }
469 if(gotMessages > 0){
470 F.toast.message("Loaded "+gotMessages+" older messages.");
471 }
472 Chat.ajaxEnd();
473 });
474 };
475 const wrapper = D.div(); /* browsers don't all properly handle >1 child in a fieldset */;
476 D.append(toolbar, wrapper);
477 var btn = D.button("Previous "+Chat.loadMessageCount+" messages");
478 D.append(wrapper, btn);
479 btn.addEventListener('click',()=>loadOldMessages(Chat.loadMessageCount));
480 btn = D.button("All previous messages");
481 D.append(wrapper, btn);
482 btn.addEventListener('click',()=>loadOldMessages(-1));
483 D.append(document.querySelector('body.chat > div.content'), toolbar);
484 toolbar.disabled = true /*will be enabled when msg load finishes */;
485 }/*end history loading widget setup*/
486
487 async function poll(isFirstCall){
488 if(poll.running) return;
489 poll.running = true;
490 if(isFirstCall) Chat.ajaxStart();
491 var p = fetch("chat-poll?name=" + Chat.mxMsg);
492 p.then(x=>x.json())
493 .then(y=>newcontent(y))
494 .catch(e=>Chat.reportError(e))
495 .finally(function(x){
496 if(isFirstCall) Chat.ajaxEnd();
497 poll.running=false;
498 });
499 }
500 poll.running = false;
501 poll(true);
502 setInterval(poll, 1000);
503 F.page.chat = Chat/* enables testing the APIs via the dev tools */;
504 })();
505
+19 -2
--- src/default.css
+++ src/default.css
@@ -1513,12 +1513,29 @@
15131513
opacity: 0.8;
15141514
display: flex;
15151515
flex-direction: column;
15161516
align-items: stretch;
15171517
}
1518
-.chat-message-popup > span { white-space: nowrap; }
1519
-.chat-message-popup > .toolbar {
1518
+body.chat .chat-message-popup > span { white-space: nowrap; }
1519
+body.chat .chat-message-popup > .toolbar {
15201520
padding: 0.2em;
15211521
margin: 0;
15221522
border: 2px inset rgba(0,0,0,0.3);
15231523
border-radius: 0.25em;
15241524
}
1525
+
1526
+body.chat .load-msg-toolbar {
1527
+ border-radius: 0.25em;
1528
+ padding: 0.1em 0.2em;
1529
+}
1530
+body.chat .load-msg-toolbar.all-done {
1531
+ opacity: 0.5;
1532
+}
1533
+body.chat .load-msg-toolbar > div {
1534
+ display: flex;
1535
+ flex-direction: row;
1536
+ justify-content: stretch;
1537
+ flex-wrap: wrap;
1538
+}
1539
+body.chat .load-msg-toolbar > div > button {
1540
+ flex: 1 1 auto;
1541
+}
15251542
--- src/default.css
+++ src/default.css
@@ -1513,12 +1513,29 @@
1513 opacity: 0.8;
1514 display: flex;
1515 flex-direction: column;
1516 align-items: stretch;
1517 }
1518 .chat-message-popup > span { white-space: nowrap; }
1519 .chat-message-popup > .toolbar {
1520 padding: 0.2em;
1521 margin: 0;
1522 border: 2px inset rgba(0,0,0,0.3);
1523 border-radius: 0.25em;
1524 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1525
--- src/default.css
+++ src/default.css
@@ -1513,12 +1513,29 @@
1513 opacity: 0.8;
1514 display: flex;
1515 flex-direction: column;
1516 align-items: stretch;
1517 }
1518 body.chat .chat-message-popup > span { white-space: nowrap; }
1519 body.chat .chat-message-popup > .toolbar {
1520 padding: 0.2em;
1521 margin: 0;
1522 border: 2px inset rgba(0,0,0,0.3);
1523 border-radius: 0.25em;
1524 }
1525
1526 body.chat .load-msg-toolbar {
1527 border-radius: 0.25em;
1528 padding: 0.1em 0.2em;
1529 }
1530 body.chat .load-msg-toolbar.all-done {
1531 opacity: 0.5;
1532 }
1533 body.chat .load-msg-toolbar > div {
1534 display: flex;
1535 flex-direction: row;
1536 justify-content: stretch;
1537 flex-wrap: wrap;
1538 }
1539 body.chat .load-msg-toolbar > div > button {
1540 flex: 1 1 auto;
1541 }
1542
--- src/fossil.dom.js
+++ src/fossil.dom.js
@@ -246,26 +246,37 @@
246246
dom.td = dom.createElemFactoryWithOptionalParent('td');
247247
dom.th = dom.createElemFactoryWithOptionalParent('th');
248248
249249
/**
250250
Creates and returns a FIELDSET element, optionaly with a LEGEND
251
- element added to it. If legendText is an HTMLElement then it is
252
- appended as-is, else it is assume (if truthy) to be a value
253
- suitable for passing to dom.append(aLegendElement,...).
251
+ element added to it. If legendText is an HTMLElement then is is
252
+ assumed to be a LEGEND and is appended as-is, else it is assumed
253
+ (if truthy) to be a value suitable for passing to
254
+ dom.append(aLegendElement,...).
254255
*/
255256
dom.fieldset = function(legendText){
256257
const fs = this.create('fieldset');
257258
if(legendText){
258259
this.append(
259260
fs,
260261
(legendText instanceof HTMLElement)
261262
? legendText
262
- : this.append(this.create('legend'),legendText)
263
+ : this.append(this.legend(legendText))
263264
);
264265
}
265266
return fs;
266267
};
268
+ /**
269
+ Returns a new LEGEND legend element. The given argument, if
270
+ not falsy, is append()ed to the element (so it may be a string
271
+ or DOM element.
272
+ */
273
+ dom.legend = function(legendText){
274
+ const rc = this.create('legend');
275
+ if(legendText) this.append(rc, legendText);
276
+ return rc;
277
+ };
267278
268279
/**
269280
Appends each argument after the first to the first argument
270281
(a DOM node) and returns the first argument.
271282
272283
--- src/fossil.dom.js
+++ src/fossil.dom.js
@@ -246,26 +246,37 @@
246 dom.td = dom.createElemFactoryWithOptionalParent('td');
247 dom.th = dom.createElemFactoryWithOptionalParent('th');
248
249 /**
250 Creates and returns a FIELDSET element, optionaly with a LEGEND
251 element added to it. If legendText is an HTMLElement then it is
252 appended as-is, else it is assume (if truthy) to be a value
253 suitable for passing to dom.append(aLegendElement,...).
 
254 */
255 dom.fieldset = function(legendText){
256 const fs = this.create('fieldset');
257 if(legendText){
258 this.append(
259 fs,
260 (legendText instanceof HTMLElement)
261 ? legendText
262 : this.append(this.create('legend'),legendText)
263 );
264 }
265 return fs;
266 };
 
 
 
 
 
 
 
 
 
 
267
268 /**
269 Appends each argument after the first to the first argument
270 (a DOM node) and returns the first argument.
271
272
--- src/fossil.dom.js
+++ src/fossil.dom.js
@@ -246,26 +246,37 @@
246 dom.td = dom.createElemFactoryWithOptionalParent('td');
247 dom.th = dom.createElemFactoryWithOptionalParent('th');
248
249 /**
250 Creates and returns a FIELDSET element, optionaly with a LEGEND
251 element added to it. If legendText is an HTMLElement then is is
252 assumed to be a LEGEND and is appended as-is, else it is assumed
253 (if truthy) to be a value suitable for passing to
254 dom.append(aLegendElement,...).
255 */
256 dom.fieldset = function(legendText){
257 const fs = this.create('fieldset');
258 if(legendText){
259 this.append(
260 fs,
261 (legendText instanceof HTMLElement)
262 ? legendText
263 : this.append(this.legend(legendText))
264 );
265 }
266 return fs;
267 };
268 /**
269 Returns a new LEGEND legend element. The given argument, if
270 not falsy, is append()ed to the element (so it may be a string
271 or DOM element.
272 */
273 dom.legend = function(legendText){
274 const rc = this.create('legend');
275 if(legendText) this.append(rc, legendText);
276 return rc;
277 };
278
279 /**
280 Appends each argument after the first to the first argument
281 (a DOM node) and returns the first argument.
282
283

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button