Fossil SCM

Improved chat.js error reporting a bit. Connection errors for send and history-fetch ops are now reported as error-style messags in the chat feed.

stephan 2021-01-02 19:06 trunk
Commit a08dfbabbd62d28d1e32784e1bd114965cfff0316f5d3ecc67b26796e228f0bb
1 file changed +150 -101
+150 -101
--- src/chat.js
+++ src/chat.js
@@ -155,11 +155,11 @@
155155
sTop = m.scrollTop,
156156
mh1 = m.clientHeight;
157157
D.addClass(old, 'hidden');
158158
D.removeClass(this.e.inputCurrent, 'hidden');
159159
const mh2 = m.clientHeight;
160
- m.scrollTo( 0, sTop + (mh1-mh2));
160
+ m.scrollTo(0, sTop + (mh1-mh2));
161161
this.e.inputCurrent.value = old.value;
162162
old.value = '';
163163
return this;
164164
},
165165
/**
@@ -243,23 +243,23 @@
243243
else D.append(mip.parentNode, e);
244244
}else{
245245
D.append(holder,e);
246246
this.e.newestMessage = e;
247247
}
248
- if(!atEnd && !this.isBatchLoading
248
+ if(!atEnd && !this._isBatchLoading
249249
&& e.dataset.xfrom!==this.me
250250
&& (prevMessage
251251
? !this.messageIsInView(prevMessage)
252252
: false)){
253253
/* If a new non-history message arrives while the user is
254254
scrolled elsewhere, do not scroll to the latest
255255
message, but gently alert the user that a new message
256256
has arrived. */
257257
F.toast.message("New message has arrived.");
258
- }else if(!this.isBatchLoading && e.dataset.xfrom===Chat.me){
258
+ }else if(!this._isBatchLoading && e.dataset.xfrom===Chat.me){
259259
this.scheduleScrollOfMsg(e);
260
- }else if(!this.isBatchLoading){
260
+ }else if(!this._isBatchLoading){
261261
/* When a message from someone else arrives, we have to
262262
figure out whether or not to scroll it into view. Ideally
263263
we'd just stuff it in the UI and let the flexbox layout
264264
DTRT, but Safari has expressed, in no uncertain terms,
265265
some disappointment with that approach, so we'll
@@ -384,15 +384,41 @@
384384
385385
const qs = (e)=>document.querySelector(e);
386386
const argsToArray = function(args){
387387
return Array.prototype.slice.call(args,0);
388388
};
389
+ /**
390
+ Reports an error via console.error() and as a toast message.
391
+ Accepts any argument types valid for fossil.toast.error().
392
+ */
389393
cs.reportError = function(/*msg args*/){
390394
const args = argsToArray(arguments);
391395
console.error("chat error:",args);
392396
F.toast.error.apply(F.toast, args);
393397
};
398
+ /**
399
+ Reports an error in the form of a new message in the chat
400
+ feed. All arguments are appended to the message's content area
401
+ using fossil.dom.append(), so may be of any type supported by
402
+ that function.
403
+ */
404
+ cs.reportErrorAsMessage = function(/*msg args*/){
405
+ const args = argsToArray(arguments);
406
+ console.error("chat error:",args);
407
+ const d = new Date().toISOString(),
408
+ msg = {
409
+ isError: true,
410
+ xfrom: "chat.js",
411
+ msgid: -1,
412
+ mtime: d,
413
+ lmtime: d,
414
+ xmsg: args
415
+ }, mw = new this.MessageWidget();
416
+ mw.setMessage(msg);
417
+ this.injectMessageElem(mw.e.body);
418
+ mw.scrollIntoView();
419
+ };
394420
395421
cs.getMessageElemById = function(id){
396422
return qs('[data-msgid="'+id+'"]');
397423
};
398424
@@ -435,14 +461,31 @@
435461
globally. A user may always delete a local copy of a
436462
post. The server may trump this, e.g. if the login has been
437463
cancelled after this page was loaded.
438464
*/
439465
cs.userMayDelete = function(eMsg){
440
- return this.me === eMsg.dataset.xfrom
441
- || F.user.isAdmin/*will be confirmed server-side*/;
466
+ return eMsg.msgid>0
467
+ && (this.me === eMsg.dataset.xfrom
468
+ || F.user.isAdmin/*will be confirmed server-side*/);
442469
};
443470
471
+ /** Returns a new Error() object encapsulating state from the given
472
+ fetch() response promise. */
473
+ cs._newResponseError = function(response){
474
+ return new Error([
475
+ "HTTP status ", response.status,": ",response.url,": ",
476
+ response.statusText].join(''));
477
+ };
478
+
479
+ /** Helper for reporting HTTP-level response errors via fetch().
480
+ If response.ok then response.json() is returned, else an Error
481
+ is thrown. */
482
+ cs._fetchJsonOrError = function(response){
483
+ if(response.ok) return response.json();
484
+ else throw cs._newResponseError(response);
485
+ };
486
+
444487
/**
445488
Removes the given message ID from the local chat record and, if
446489
the message was posted by this user OR this user in an
447490
admin/setup, also submits it for removal on the remote.
448491
@@ -459,12 +502,13 @@
459502
}
460503
if(!(e instanceof HTMLElement)) return;
461504
if(this.userMayDelete(e)){
462505
this.ajaxStart();
463506
fetch("chat-delete?name=" + id)
507
+ .then(this._fetchJsonOrError)
464508
.then(()=>this.deleteMessageElem(e))
465
- .catch(err=>this.reportError(err))
509
+ .catch(err=>this.reportErrorAsMessage(err))
466510
.finally(()=>this.ajaxEnd());
467511
}else{
468512
this.deleteMessageElem(id);
469513
}
470514
};
@@ -483,28 +527,26 @@
483527
don't use FIELDSET because of cross-browser inconsistencies in
484528
features of the FIELDSET/LEGEND combination, e.g. inability to
485529
align legends via CSS in Firefox and clicking-related
486530
deficiencies in Safari.
487531
*/
488
- const MessageWidget = (function(){
532
+ Chat.MessageWidget = (function(){
489533
const cf = function(){
490534
this.e = {
491535
body: D.addClass(D.div(), 'message-widget'),
492536
tab: D.addClass(D.span(), 'message-widget-tab'),
493537
content: D.addClass(D.div(), 'message-widget-content')
494538
};
495
- D.append(this.e.body, this.e.tab);
496
- D.append(this.e.body, this.e.content);
539
+ D.append(this.e.body, this.e.tab, this.e.content);
497540
this.e.tab.setAttribute('role', 'button');
498541
};
499542
cf.prototype = {
500543
setLabel: function(label){
501544
return this;
502545
},
503
- setPopupCallback: function(callback){
504
- this.e.tab.addEventListener('click', callback, false);
505
- return this;
546
+ scrollIntoView: function(){
547
+ this.e.content.scrollIntoView();
506548
},
507549
setMessage: function(m){
508550
const ds = this.e.body.dataset;
509551
ds.timestamp = m.mtime;
510552
ds.lmtime = m.lmtime;
@@ -558,14 +600,88 @@
558600
// hyperlinks, but otherwise it will be markup-free. See the
559601
// chat_format_to_html() routine in the server for details.
560602
//
561603
// Hence, even though innerHTML is normally frowned upon, it is
562604
// perfectly safe to use in this context.
563
- contentTarget.innerHTML = m.xmsg;
605
+ if(m.xmsg instanceof Array){
606
+ // Used by Chat.reportErrorAsMessage()
607
+ D.append(contentTarget, m.xmsg);
608
+ }else{
609
+ contentTarget.innerHTML = m.xmsg;
610
+ }
564611
}
612
+ this.e.tab.addEventListener('click', this._handleLegendClicked, false);
565613
return this;
566
- }
614
+ },
615
+ /* Event handler for clicking .message-user elements to show their
616
+ timestamps. */
617
+ _handleLegendClicked: function f(ev){
618
+ if(!f.popup){
619
+ /* Timestamp popup widget */
620
+ f.popup = new F.PopupWidget({
621
+ cssClass: ['fossil-tooltip', 'chat-message-popup'],
622
+ refresh:function(){
623
+ const eMsg = this._eMsg;
624
+ if(!eMsg) return;
625
+ D.clearElement(this.e);
626
+ const d = new Date(eMsg.dataset.timestamp);
627
+ if(d.getMinutes().toString()!=="NaN"){
628
+ // Date works, render informative timestamps
629
+ const xfrom = eMsg.dataset.xfrom;
630
+ D.append(this.e,
631
+ D.append(D.span(), localTimeString(d)," ",Chat.me," time"),
632
+ D.append(D.span(), iso8601ish(d)));
633
+ if(eMsg.dataset.lmtime && xfrom!==Chat.me){
634
+ D.append(this.e,
635
+ D.append(D.span(), localTime8601(
636
+ new Date(eMsg.dataset.lmtime)
637
+ ).replace('T',' ')," ",xfrom," time"));
638
+ }
639
+ }else{
640
+ // Date doesn't work, so dumb it down...
641
+ D.append(this.e, D.append(D.span(), eMsg.dataset.timestamp," zulu"));
642
+ }
643
+ const toolbar = D.addClass(D.div(), 'toolbar');
644
+ D.append(this.e, toolbar);
645
+ const btnDeleteLocal = D.button("Delete locally");
646
+ D.append(toolbar, btnDeleteLocal);
647
+ const self = this;
648
+ btnDeleteLocal.addEventListener('click', function(){
649
+ self.hide();
650
+ Chat.deleteMessageElem(eMsg);
651
+ });
652
+ if(Chat.userMayDelete(eMsg)){
653
+ const btnDeleteGlobal = D.button("Delete globally");
654
+ D.append(toolbar, btnDeleteGlobal);
655
+ btnDeleteGlobal.addEventListener('click', function(){
656
+ self.hide();
657
+ Chat.deleteMessage(eMsg);
658
+ });
659
+ }
660
+ }/*refresh()*/
661
+ });
662
+ f.popup.installHideHandlers();
663
+ f.popup.hide = function(){
664
+ delete this._eMsg;
665
+ D.clearElement(this.e);
666
+ return this.show(false);
667
+ };
668
+ }/*end static init*/
669
+ const rect = ev.target.getBoundingClientRect();
670
+ const eMsg = ev.target.parentNode/*the owning .message-widget element*/;
671
+ f.popup._eMsg = eMsg;
672
+ let x = rect.left, y = rect.topm;
673
+ f.popup.show(ev.target)/*so we can get its computed size*/;
674
+ if(eMsg.dataset.xfrom===Chat.me
675
+ && document.body.classList.contains('my-messages-right')){
676
+ // Shift popup to the left for right-aligned messages to avoid
677
+ // truncation off the right edge of the page.
678
+ const pRect = f.popup.e.getBoundingClientRect();
679
+ x = rect.right - pRect.width;
680
+ }
681
+ f.popup.show(x, y);
682
+ }/*_handleLegendClicked()*/
567683
};
568684
return cf;
569685
})()/*MessageWidget*/;
570686
571687
const BlobXferState = (function(){/*drag/drop bits...*/
@@ -674,22 +790,24 @@
674790
const self = this;
675791
fd.set("lmtime", localTime8601(new Date()));
676792
fetch("chat-send",{
677793
method: 'POST',
678794
body: fd
679
- }).then((x)=>x.text())
680
- .then(function(txt){
795
+ }).then((x)=>{
796
+ if(x.ok) return x.text();
797
+ else throw Chat._newResponseError(x);
798
+ }).then(function(txt){
681799
if(!txt) return/*success response*/;
682800
try{
683801
const json = JSON.parse(txt);
684802
self.newContent({msgs:[json]});
685803
}catch(e){
686804
self.reportError(e);
687805
return;
688806
}
689807
})
690
- .catch((e)=>this.reportError(e));
808
+ .catch((e)=>this.reportErrorAsMessage(e));
691809
BlobXferState.clear();
692810
Chat.inputValue("").inputFocus();
693811
};
694812
695813
Chat.e.inputSingle.addEventListener('keydown',function(ev){
@@ -732,78 +850,10 @@
732850
/* Returns an almost-ISO8601 form of Date object d. */
733851
const iso8601ish = function(d){
734852
return d.toISOString()
735853
.replace('T',' ').replace(/\.\d+/,'').replace('Z', ' zulu');
736854
};
737
- /* Event handler for clicking .message-user elements to show their
738
- timestamps. */
739
- const handleLegendClicked = function f(ev){
740
- if(!f.popup){
741
- /* Timestamp popup widget */
742
- f.popup = new F.PopupWidget({
743
- cssClass: ['fossil-tooltip', 'chat-message-popup'],
744
- refresh:function(){
745
- const eMsg = this._eMsg;
746
- if(!eMsg) return;
747
- D.clearElement(this.e);
748
- const d = new Date(eMsg.dataset.timestamp);
749
- if(d.getMinutes().toString()!=="NaN"){
750
- // Date works, render informative timestamps
751
- const xfrom = eMsg.dataset.xfrom;
752
- D.append(this.e,
753
- D.append(D.span(), localTimeString(d)," ",Chat.me," time"),
754
- D.append(D.span(), iso8601ish(d)));
755
- if(eMsg.dataset.lmtime && xfrom!==Chat.me){
756
- D.append(this.e,
757
- D.append(D.span(), localTime8601(
758
- new Date(eMsg.dataset.lmtime)
759
- ).replace('T',' ')," ",xfrom," time"));
760
- }
761
- }else{
762
- // Date doesn't work, so dumb it down...
763
- D.append(this.e, D.append(D.span(), eMsg.dataset.timestamp," zulu"));
764
- }
765
- const toolbar = D.addClass(D.div(), 'toolbar');
766
- D.append(this.e, toolbar);
767
- const btnDeleteLocal = D.button("Delete locally");
768
- D.append(toolbar, btnDeleteLocal);
769
- const self = this;
770
- btnDeleteLocal.addEventListener('click', function(){
771
- self.hide();
772
- Chat.deleteMessageElem(eMsg);
773
- });
774
- if(Chat.userMayDelete(eMsg)){
775
- const btnDeleteGlobal = D.button("Delete globally");
776
- D.append(toolbar, btnDeleteGlobal);
777
- btnDeleteGlobal.addEventListener('click', function(){
778
- self.hide();
779
- Chat.deleteMessage(eMsg);
780
- });
781
- }
782
- }/*refresh()*/
783
- });
784
- f.popup.installHideHandlers();
785
- f.popup.hide = function(){
786
- delete this._eMsg;
787
- D.clearElement(this.e);
788
- return this.show(false);
789
- };
790
- }/*end static init*/
791
- const rect = ev.target.getBoundingClientRect();
792
- const eMsg = ev.target.parentNode/*the owning .message-widget element*/;
793
- f.popup._eMsg = eMsg;
794
- let x = rect.left, y = rect.topm;
795
- f.popup.show(ev.target)/*so we can get its computed size*/;
796
- if(eMsg.dataset.xfrom===Chat.me
797
- && document.body.classList.contains('my-messages-right')){
798
- // Shift popup to the left for right-aligned messages to avoid
799
- // truncation off the right edge of the page.
800
- const pRect = f.popup.e.getBoundingClientRect();
801
- x = rect.right - pRect.width;
802
- }
803
- f.popup.show(x, y);
804
- }/*handleLegendClicked()*/;
805855
806856
(function(){/*Set up #chat-settings-button */
807857
const settingsButton = document.querySelector('#chat-settings-button');
808858
var popupSize = undefined/*placement workaround*/;
809859
const settingsPopup = new F.PopupWidget({
@@ -939,16 +989,15 @@
939989
if( m.mdel ){
940990
/* A record deletion notice. */
941991
Chat.deleteMessageElem(m.mdel);
942992
return;
943993
}
944
- const row = new MessageWidget()
994
+ const row = new Chat.MessageWidget()
945995
row.setMessage(m);
946
- row.setPopupCallback(handleLegendClicked);
947996
Chat.injectMessageElem(row.e.body,atEnd);
948997
if(m.isError){
949
- Chat.gotServerError = m;
998
+ Chat._gotServerError = m;
950999
}
9511000
}/*processPost()*/;
9521001
}/*end static init*/
9531002
jx.msgs.forEach((m)=>f.processPost(m,atEnd));
9541003
if('visible'===document.visibilityState){
@@ -980,28 +1029,28 @@
9801029
Chat.disableDuringAjax.push(toolbar);
9811030
/* Loads the next n oldest messages, or all previous history if n is negative. */
9821031
const loadOldMessages = function(n){
9831032
Chat.ajaxStart();
9841033
Chat.e.messagesWrapper.classList.add('loading');
985
- Chat.isBatchLoading = true;
1034
+ Chat._isBatchLoading = true;
9861035
var gotMessages = false;
9871036
const scrollHt = Chat.e.messagesWrapper.scrollHeight,
9881037
scrollTop = Chat.e.messagesWrapper.scrollTop;
9891038
fetch("chat-poll?before="+Chat.mnMsg+"&n="+n)
990
- .then(x=>x.json())
1039
+ .then(Chat._fetchJsonOrError)
9911040
.then(function(x){
9921041
gotMessages = x.msgs.length;
9931042
newcontent(x,true);
9941043
})
995
- .catch(e=>Chat.reportError(e))
1044
+ .catch(e=>Chat.reportErrorAsMessage(e))
9961045
.finally(function(){
997
- Chat.isBatchLoading = false;
1046
+ Chat._isBatchLoading = false;
9981047
Chat.e.messagesWrapper.classList.remove('loading');
9991048
Chat.ajaxEnd();
1000
- if(Chat.gotServerError){
1049
+ if(Chat._gotServerError){
10011050
F.toast.error("Got an error response from the server. ",
1002
- "See message for details");
1051
+ "See message for details.");
10031052
return;
10041053
}else if(n<0/*we asked for all history*/
10051054
|| 0===gotMessages/*we found no history*/
10061055
|| (n>0 && gotMessages<n /*we got fewer history entries than requested*/)
10071056
|| (false!==gotMessages && n===0 && gotMessages<Chat.loadMessageCount
@@ -1043,37 +1092,37 @@
10431092
poll.running = true;
10441093
if(isFirstCall){
10451094
Chat.ajaxStart();
10461095
Chat.e.messagesWrapper.classList.add('loading');
10471096
}
1048
- Chat.isBatchLoading = isFirstCall;
1097
+ Chat._isBatchLoading = isFirstCall;
10491098
var p = fetch("chat-poll?name=" + Chat.mxMsg);
1050
- p.then(x=>x.json())
1099
+ p.then(Chat._fetchJsonOrError)
10511100
.then(y=>newcontent(y))
10521101
.catch(e=>console.error(e))
10531102
/* ^^^ we don't use Chat.reportError(e) here b/c the polling
10541103
fails exepectedly when it times out, but is then immediately
10551104
resumed, and reportError() produces a loud error message. */
10561105
.finally(function(){
10571106
if(isFirstCall){
1058
- Chat.isBatchLoading = false;
1107
+ Chat._isBatchLoading = false;
10591108
Chat.ajaxEnd();
10601109
Chat.e.messagesWrapper.classList.remove('loading');
10611110
setTimeout(function(){
10621111
Chat.scrollMessagesTo(1);
10631112
}, 250);
10641113
}
1065
- if(Chat.gotServerError && Chat.intervalTimer){
1114
+ if(Chat._gotServerError && Chat.intervalTimer){
10661115
clearInterval(Chat.intervalTimer);
10671116
delete Chat.intervalTimer;
10681117
}
10691118
poll.running=false;
10701119
});
10711120
}
1072
- Chat.gotServerError = poll.running = false;
1121
+ Chat._gotServerError = poll.running = false;
10731122
poll(true);
1074
- if(!Chat.gotServerError){
1123
+ if(!Chat._gotServerError){
10751124
Chat.intervalTimer = setInterval(poll, 1000);
10761125
}
10771126
if(/\bping=\d+/.test(window.location.search)){
10781127
/* If we see the 'ping' parameter we're certain this was run via
10791128
the 'fossil chat' CLI command, in which case we start up in
10801129
--- src/chat.js
+++ src/chat.js
@@ -155,11 +155,11 @@
155 sTop = m.scrollTop,
156 mh1 = m.clientHeight;
157 D.addClass(old, 'hidden');
158 D.removeClass(this.e.inputCurrent, 'hidden');
159 const mh2 = m.clientHeight;
160 m.scrollTo( 0, sTop + (mh1-mh2));
161 this.e.inputCurrent.value = old.value;
162 old.value = '';
163 return this;
164 },
165 /**
@@ -243,23 +243,23 @@
243 else D.append(mip.parentNode, e);
244 }else{
245 D.append(holder,e);
246 this.e.newestMessage = e;
247 }
248 if(!atEnd && !this.isBatchLoading
249 && e.dataset.xfrom!==this.me
250 && (prevMessage
251 ? !this.messageIsInView(prevMessage)
252 : false)){
253 /* If a new non-history message arrives while the user is
254 scrolled elsewhere, do not scroll to the latest
255 message, but gently alert the user that a new message
256 has arrived. */
257 F.toast.message("New message has arrived.");
258 }else if(!this.isBatchLoading && e.dataset.xfrom===Chat.me){
259 this.scheduleScrollOfMsg(e);
260 }else if(!this.isBatchLoading){
261 /* When a message from someone else arrives, we have to
262 figure out whether or not to scroll it into view. Ideally
263 we'd just stuff it in the UI and let the flexbox layout
264 DTRT, but Safari has expressed, in no uncertain terms,
265 some disappointment with that approach, so we'll
@@ -384,15 +384,41 @@
384
385 const qs = (e)=>document.querySelector(e);
386 const argsToArray = function(args){
387 return Array.prototype.slice.call(args,0);
388 };
 
 
 
 
389 cs.reportError = function(/*msg args*/){
390 const args = argsToArray(arguments);
391 console.error("chat error:",args);
392 F.toast.error.apply(F.toast, args);
393 };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
394
395 cs.getMessageElemById = function(id){
396 return qs('[data-msgid="'+id+'"]');
397 };
398
@@ -435,14 +461,31 @@
435 globally. A user may always delete a local copy of a
436 post. The server may trump this, e.g. if the login has been
437 cancelled after this page was loaded.
438 */
439 cs.userMayDelete = function(eMsg){
440 return this.me === eMsg.dataset.xfrom
441 || F.user.isAdmin/*will be confirmed server-side*/;
 
442 };
443
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
444 /**
445 Removes the given message ID from the local chat record and, if
446 the message was posted by this user OR this user in an
447 admin/setup, also submits it for removal on the remote.
448
@@ -459,12 +502,13 @@
459 }
460 if(!(e instanceof HTMLElement)) return;
461 if(this.userMayDelete(e)){
462 this.ajaxStart();
463 fetch("chat-delete?name=" + id)
 
464 .then(()=>this.deleteMessageElem(e))
465 .catch(err=>this.reportError(err))
466 .finally(()=>this.ajaxEnd());
467 }else{
468 this.deleteMessageElem(id);
469 }
470 };
@@ -483,28 +527,26 @@
483 don't use FIELDSET because of cross-browser inconsistencies in
484 features of the FIELDSET/LEGEND combination, e.g. inability to
485 align legends via CSS in Firefox and clicking-related
486 deficiencies in Safari.
487 */
488 const MessageWidget = (function(){
489 const cf = function(){
490 this.e = {
491 body: D.addClass(D.div(), 'message-widget'),
492 tab: D.addClass(D.span(), 'message-widget-tab'),
493 content: D.addClass(D.div(), 'message-widget-content')
494 };
495 D.append(this.e.body, this.e.tab);
496 D.append(this.e.body, this.e.content);
497 this.e.tab.setAttribute('role', 'button');
498 };
499 cf.prototype = {
500 setLabel: function(label){
501 return this;
502 },
503 setPopupCallback: function(callback){
504 this.e.tab.addEventListener('click', callback, false);
505 return this;
506 },
507 setMessage: function(m){
508 const ds = this.e.body.dataset;
509 ds.timestamp = m.mtime;
510 ds.lmtime = m.lmtime;
@@ -558,14 +600,88 @@
558 // hyperlinks, but otherwise it will be markup-free. See the
559 // chat_format_to_html() routine in the server for details.
560 //
561 // Hence, even though innerHTML is normally frowned upon, it is
562 // perfectly safe to use in this context.
563 contentTarget.innerHTML = m.xmsg;
 
 
 
 
 
564 }
 
565 return this;
566 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
567 };
568 return cf;
569 })()/*MessageWidget*/;
570
571 const BlobXferState = (function(){/*drag/drop bits...*/
@@ -674,22 +790,24 @@
674 const self = this;
675 fd.set("lmtime", localTime8601(new Date()));
676 fetch("chat-send",{
677 method: 'POST',
678 body: fd
679 }).then((x)=>x.text())
680 .then(function(txt){
 
 
681 if(!txt) return/*success response*/;
682 try{
683 const json = JSON.parse(txt);
684 self.newContent({msgs:[json]});
685 }catch(e){
686 self.reportError(e);
687 return;
688 }
689 })
690 .catch((e)=>this.reportError(e));
691 BlobXferState.clear();
692 Chat.inputValue("").inputFocus();
693 };
694
695 Chat.e.inputSingle.addEventListener('keydown',function(ev){
@@ -732,78 +850,10 @@
732 /* Returns an almost-ISO8601 form of Date object d. */
733 const iso8601ish = function(d){
734 return d.toISOString()
735 .replace('T',' ').replace(/\.\d+/,'').replace('Z', ' zulu');
736 };
737 /* Event handler for clicking .message-user elements to show their
738 timestamps. */
739 const handleLegendClicked = function f(ev){
740 if(!f.popup){
741 /* Timestamp popup widget */
742 f.popup = new F.PopupWidget({
743 cssClass: ['fossil-tooltip', 'chat-message-popup'],
744 refresh:function(){
745 const eMsg = this._eMsg;
746 if(!eMsg) return;
747 D.clearElement(this.e);
748 const d = new Date(eMsg.dataset.timestamp);
749 if(d.getMinutes().toString()!=="NaN"){
750 // Date works, render informative timestamps
751 const xfrom = eMsg.dataset.xfrom;
752 D.append(this.e,
753 D.append(D.span(), localTimeString(d)," ",Chat.me," time"),
754 D.append(D.span(), iso8601ish(d)));
755 if(eMsg.dataset.lmtime && xfrom!==Chat.me){
756 D.append(this.e,
757 D.append(D.span(), localTime8601(
758 new Date(eMsg.dataset.lmtime)
759 ).replace('T',' ')," ",xfrom," time"));
760 }
761 }else{
762 // Date doesn't work, so dumb it down...
763 D.append(this.e, D.append(D.span(), eMsg.dataset.timestamp," zulu"));
764 }
765 const toolbar = D.addClass(D.div(), 'toolbar');
766 D.append(this.e, toolbar);
767 const btnDeleteLocal = D.button("Delete locally");
768 D.append(toolbar, btnDeleteLocal);
769 const self = this;
770 btnDeleteLocal.addEventListener('click', function(){
771 self.hide();
772 Chat.deleteMessageElem(eMsg);
773 });
774 if(Chat.userMayDelete(eMsg)){
775 const btnDeleteGlobal = D.button("Delete globally");
776 D.append(toolbar, btnDeleteGlobal);
777 btnDeleteGlobal.addEventListener('click', function(){
778 self.hide();
779 Chat.deleteMessage(eMsg);
780 });
781 }
782 }/*refresh()*/
783 });
784 f.popup.installHideHandlers();
785 f.popup.hide = function(){
786 delete this._eMsg;
787 D.clearElement(this.e);
788 return this.show(false);
789 };
790 }/*end static init*/
791 const rect = ev.target.getBoundingClientRect();
792 const eMsg = ev.target.parentNode/*the owning .message-widget element*/;
793 f.popup._eMsg = eMsg;
794 let x = rect.left, y = rect.topm;
795 f.popup.show(ev.target)/*so we can get its computed size*/;
796 if(eMsg.dataset.xfrom===Chat.me
797 && document.body.classList.contains('my-messages-right')){
798 // Shift popup to the left for right-aligned messages to avoid
799 // truncation off the right edge of the page.
800 const pRect = f.popup.e.getBoundingClientRect();
801 x = rect.right - pRect.width;
802 }
803 f.popup.show(x, y);
804 }/*handleLegendClicked()*/;
805
806 (function(){/*Set up #chat-settings-button */
807 const settingsButton = document.querySelector('#chat-settings-button');
808 var popupSize = undefined/*placement workaround*/;
809 const settingsPopup = new F.PopupWidget({
@@ -939,16 +989,15 @@
939 if( m.mdel ){
940 /* A record deletion notice. */
941 Chat.deleteMessageElem(m.mdel);
942 return;
943 }
944 const row = new MessageWidget()
945 row.setMessage(m);
946 row.setPopupCallback(handleLegendClicked);
947 Chat.injectMessageElem(row.e.body,atEnd);
948 if(m.isError){
949 Chat.gotServerError = m;
950 }
951 }/*processPost()*/;
952 }/*end static init*/
953 jx.msgs.forEach((m)=>f.processPost(m,atEnd));
954 if('visible'===document.visibilityState){
@@ -980,28 +1029,28 @@
980 Chat.disableDuringAjax.push(toolbar);
981 /* Loads the next n oldest messages, or all previous history if n is negative. */
982 const loadOldMessages = function(n){
983 Chat.ajaxStart();
984 Chat.e.messagesWrapper.classList.add('loading');
985 Chat.isBatchLoading = true;
986 var gotMessages = false;
987 const scrollHt = Chat.e.messagesWrapper.scrollHeight,
988 scrollTop = Chat.e.messagesWrapper.scrollTop;
989 fetch("chat-poll?before="+Chat.mnMsg+"&n="+n)
990 .then(x=>x.json())
991 .then(function(x){
992 gotMessages = x.msgs.length;
993 newcontent(x,true);
994 })
995 .catch(e=>Chat.reportError(e))
996 .finally(function(){
997 Chat.isBatchLoading = false;
998 Chat.e.messagesWrapper.classList.remove('loading');
999 Chat.ajaxEnd();
1000 if(Chat.gotServerError){
1001 F.toast.error("Got an error response from the server. ",
1002 "See message for details");
1003 return;
1004 }else if(n<0/*we asked for all history*/
1005 || 0===gotMessages/*we found no history*/
1006 || (n>0 && gotMessages<n /*we got fewer history entries than requested*/)
1007 || (false!==gotMessages && n===0 && gotMessages<Chat.loadMessageCount
@@ -1043,37 +1092,37 @@
1043 poll.running = true;
1044 if(isFirstCall){
1045 Chat.ajaxStart();
1046 Chat.e.messagesWrapper.classList.add('loading');
1047 }
1048 Chat.isBatchLoading = isFirstCall;
1049 var p = fetch("chat-poll?name=" + Chat.mxMsg);
1050 p.then(x=>x.json())
1051 .then(y=>newcontent(y))
1052 .catch(e=>console.error(e))
1053 /* ^^^ we don't use Chat.reportError(e) here b/c the polling
1054 fails exepectedly when it times out, but is then immediately
1055 resumed, and reportError() produces a loud error message. */
1056 .finally(function(){
1057 if(isFirstCall){
1058 Chat.isBatchLoading = false;
1059 Chat.ajaxEnd();
1060 Chat.e.messagesWrapper.classList.remove('loading');
1061 setTimeout(function(){
1062 Chat.scrollMessagesTo(1);
1063 }, 250);
1064 }
1065 if(Chat.gotServerError && Chat.intervalTimer){
1066 clearInterval(Chat.intervalTimer);
1067 delete Chat.intervalTimer;
1068 }
1069 poll.running=false;
1070 });
1071 }
1072 Chat.gotServerError = poll.running = false;
1073 poll(true);
1074 if(!Chat.gotServerError){
1075 Chat.intervalTimer = setInterval(poll, 1000);
1076 }
1077 if(/\bping=\d+/.test(window.location.search)){
1078 /* If we see the 'ping' parameter we're certain this was run via
1079 the 'fossil chat' CLI command, in which case we start up in
1080
--- src/chat.js
+++ src/chat.js
@@ -155,11 +155,11 @@
155 sTop = m.scrollTop,
156 mh1 = m.clientHeight;
157 D.addClass(old, 'hidden');
158 D.removeClass(this.e.inputCurrent, 'hidden');
159 const mh2 = m.clientHeight;
160 m.scrollTo(0, sTop + (mh1-mh2));
161 this.e.inputCurrent.value = old.value;
162 old.value = '';
163 return this;
164 },
165 /**
@@ -243,23 +243,23 @@
243 else D.append(mip.parentNode, e);
244 }else{
245 D.append(holder,e);
246 this.e.newestMessage = e;
247 }
248 if(!atEnd && !this._isBatchLoading
249 && e.dataset.xfrom!==this.me
250 && (prevMessage
251 ? !this.messageIsInView(prevMessage)
252 : false)){
253 /* If a new non-history message arrives while the user is
254 scrolled elsewhere, do not scroll to the latest
255 message, but gently alert the user that a new message
256 has arrived. */
257 F.toast.message("New message has arrived.");
258 }else if(!this._isBatchLoading && e.dataset.xfrom===Chat.me){
259 this.scheduleScrollOfMsg(e);
260 }else if(!this._isBatchLoading){
261 /* When a message from someone else arrives, we have to
262 figure out whether or not to scroll it into view. Ideally
263 we'd just stuff it in the UI and let the flexbox layout
264 DTRT, but Safari has expressed, in no uncertain terms,
265 some disappointment with that approach, so we'll
@@ -384,15 +384,41 @@
384
385 const qs = (e)=>document.querySelector(e);
386 const argsToArray = function(args){
387 return Array.prototype.slice.call(args,0);
388 };
389 /**
390 Reports an error via console.error() and as a toast message.
391 Accepts any argument types valid for fossil.toast.error().
392 */
393 cs.reportError = function(/*msg args*/){
394 const args = argsToArray(arguments);
395 console.error("chat error:",args);
396 F.toast.error.apply(F.toast, args);
397 };
398 /**
399 Reports an error in the form of a new message in the chat
400 feed. All arguments are appended to the message's content area
401 using fossil.dom.append(), so may be of any type supported by
402 that function.
403 */
404 cs.reportErrorAsMessage = function(/*msg args*/){
405 const args = argsToArray(arguments);
406 console.error("chat error:",args);
407 const d = new Date().toISOString(),
408 msg = {
409 isError: true,
410 xfrom: "chat.js",
411 msgid: -1,
412 mtime: d,
413 lmtime: d,
414 xmsg: args
415 }, mw = new this.MessageWidget();
416 mw.setMessage(msg);
417 this.injectMessageElem(mw.e.body);
418 mw.scrollIntoView();
419 };
420
421 cs.getMessageElemById = function(id){
422 return qs('[data-msgid="'+id+'"]');
423 };
424
@@ -435,14 +461,31 @@
461 globally. A user may always delete a local copy of a
462 post. The server may trump this, e.g. if the login has been
463 cancelled after this page was loaded.
464 */
465 cs.userMayDelete = function(eMsg){
466 return eMsg.msgid>0
467 && (this.me === eMsg.dataset.xfrom
468 || F.user.isAdmin/*will be confirmed server-side*/);
469 };
470
471 /** Returns a new Error() object encapsulating state from the given
472 fetch() response promise. */
473 cs._newResponseError = function(response){
474 return new Error([
475 "HTTP status ", response.status,": ",response.url,": ",
476 response.statusText].join(''));
477 };
478
479 /** Helper for reporting HTTP-level response errors via fetch().
480 If response.ok then response.json() is returned, else an Error
481 is thrown. */
482 cs._fetchJsonOrError = function(response){
483 if(response.ok) return response.json();
484 else throw cs._newResponseError(response);
485 };
486
487 /**
488 Removes the given message ID from the local chat record and, if
489 the message was posted by this user OR this user in an
490 admin/setup, also submits it for removal on the remote.
491
@@ -459,12 +502,13 @@
502 }
503 if(!(e instanceof HTMLElement)) return;
504 if(this.userMayDelete(e)){
505 this.ajaxStart();
506 fetch("chat-delete?name=" + id)
507 .then(this._fetchJsonOrError)
508 .then(()=>this.deleteMessageElem(e))
509 .catch(err=>this.reportErrorAsMessage(err))
510 .finally(()=>this.ajaxEnd());
511 }else{
512 this.deleteMessageElem(id);
513 }
514 };
@@ -483,28 +527,26 @@
527 don't use FIELDSET because of cross-browser inconsistencies in
528 features of the FIELDSET/LEGEND combination, e.g. inability to
529 align legends via CSS in Firefox and clicking-related
530 deficiencies in Safari.
531 */
532 Chat.MessageWidget = (function(){
533 const cf = function(){
534 this.e = {
535 body: D.addClass(D.div(), 'message-widget'),
536 tab: D.addClass(D.span(), 'message-widget-tab'),
537 content: D.addClass(D.div(), 'message-widget-content')
538 };
539 D.append(this.e.body, this.e.tab, this.e.content);
 
540 this.e.tab.setAttribute('role', 'button');
541 };
542 cf.prototype = {
543 setLabel: function(label){
544 return this;
545 },
546 scrollIntoView: function(){
547 this.e.content.scrollIntoView();
 
548 },
549 setMessage: function(m){
550 const ds = this.e.body.dataset;
551 ds.timestamp = m.mtime;
552 ds.lmtime = m.lmtime;
@@ -558,14 +600,88 @@
600 // hyperlinks, but otherwise it will be markup-free. See the
601 // chat_format_to_html() routine in the server for details.
602 //
603 // Hence, even though innerHTML is normally frowned upon, it is
604 // perfectly safe to use in this context.
605 if(m.xmsg instanceof Array){
606 // Used by Chat.reportErrorAsMessage()
607 D.append(contentTarget, m.xmsg);
608 }else{
609 contentTarget.innerHTML = m.xmsg;
610 }
611 }
612 this.e.tab.addEventListener('click', this._handleLegendClicked, false);
613 return this;
614 },
615 /* Event handler for clicking .message-user elements to show their
616 timestamps. */
617 _handleLegendClicked: function f(ev){
618 if(!f.popup){
619 /* Timestamp popup widget */
620 f.popup = new F.PopupWidget({
621 cssClass: ['fossil-tooltip', 'chat-message-popup'],
622 refresh:function(){
623 const eMsg = this._eMsg;
624 if(!eMsg) return;
625 D.clearElement(this.e);
626 const d = new Date(eMsg.dataset.timestamp);
627 if(d.getMinutes().toString()!=="NaN"){
628 // Date works, render informative timestamps
629 const xfrom = eMsg.dataset.xfrom;
630 D.append(this.e,
631 D.append(D.span(), localTimeString(d)," ",Chat.me," time"),
632 D.append(D.span(), iso8601ish(d)));
633 if(eMsg.dataset.lmtime && xfrom!==Chat.me){
634 D.append(this.e,
635 D.append(D.span(), localTime8601(
636 new Date(eMsg.dataset.lmtime)
637 ).replace('T',' ')," ",xfrom," time"));
638 }
639 }else{
640 // Date doesn't work, so dumb it down...
641 D.append(this.e, D.append(D.span(), eMsg.dataset.timestamp," zulu"));
642 }
643 const toolbar = D.addClass(D.div(), 'toolbar');
644 D.append(this.e, toolbar);
645 const btnDeleteLocal = D.button("Delete locally");
646 D.append(toolbar, btnDeleteLocal);
647 const self = this;
648 btnDeleteLocal.addEventListener('click', function(){
649 self.hide();
650 Chat.deleteMessageElem(eMsg);
651 });
652 if(Chat.userMayDelete(eMsg)){
653 const btnDeleteGlobal = D.button("Delete globally");
654 D.append(toolbar, btnDeleteGlobal);
655 btnDeleteGlobal.addEventListener('click', function(){
656 self.hide();
657 Chat.deleteMessage(eMsg);
658 });
659 }
660 }/*refresh()*/
661 });
662 f.popup.installHideHandlers();
663 f.popup.hide = function(){
664 delete this._eMsg;
665 D.clearElement(this.e);
666 return this.show(false);
667 };
668 }/*end static init*/
669 const rect = ev.target.getBoundingClientRect();
670 const eMsg = ev.target.parentNode/*the owning .message-widget element*/;
671 f.popup._eMsg = eMsg;
672 let x = rect.left, y = rect.topm;
673 f.popup.show(ev.target)/*so we can get its computed size*/;
674 if(eMsg.dataset.xfrom===Chat.me
675 && document.body.classList.contains('my-messages-right')){
676 // Shift popup to the left for right-aligned messages to avoid
677 // truncation off the right edge of the page.
678 const pRect = f.popup.e.getBoundingClientRect();
679 x = rect.right - pRect.width;
680 }
681 f.popup.show(x, y);
682 }/*_handleLegendClicked()*/
683 };
684 return cf;
685 })()/*MessageWidget*/;
686
687 const BlobXferState = (function(){/*drag/drop bits...*/
@@ -674,22 +790,24 @@
790 const self = this;
791 fd.set("lmtime", localTime8601(new Date()));
792 fetch("chat-send",{
793 method: 'POST',
794 body: fd
795 }).then((x)=>{
796 if(x.ok) return x.text();
797 else throw Chat._newResponseError(x);
798 }).then(function(txt){
799 if(!txt) return/*success response*/;
800 try{
801 const json = JSON.parse(txt);
802 self.newContent({msgs:[json]});
803 }catch(e){
804 self.reportError(e);
805 return;
806 }
807 })
808 .catch((e)=>this.reportErrorAsMessage(e));
809 BlobXferState.clear();
810 Chat.inputValue("").inputFocus();
811 };
812
813 Chat.e.inputSingle.addEventListener('keydown',function(ev){
@@ -732,78 +850,10 @@
850 /* Returns an almost-ISO8601 form of Date object d. */
851 const iso8601ish = function(d){
852 return d.toISOString()
853 .replace('T',' ').replace(/\.\d+/,'').replace('Z', ' zulu');
854 };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
855
856 (function(){/*Set up #chat-settings-button */
857 const settingsButton = document.querySelector('#chat-settings-button');
858 var popupSize = undefined/*placement workaround*/;
859 const settingsPopup = new F.PopupWidget({
@@ -939,16 +989,15 @@
989 if( m.mdel ){
990 /* A record deletion notice. */
991 Chat.deleteMessageElem(m.mdel);
992 return;
993 }
994 const row = new Chat.MessageWidget()
995 row.setMessage(m);
 
996 Chat.injectMessageElem(row.e.body,atEnd);
997 if(m.isError){
998 Chat._gotServerError = m;
999 }
1000 }/*processPost()*/;
1001 }/*end static init*/
1002 jx.msgs.forEach((m)=>f.processPost(m,atEnd));
1003 if('visible'===document.visibilityState){
@@ -980,28 +1029,28 @@
1029 Chat.disableDuringAjax.push(toolbar);
1030 /* Loads the next n oldest messages, or all previous history if n is negative. */
1031 const loadOldMessages = function(n){
1032 Chat.ajaxStart();
1033 Chat.e.messagesWrapper.classList.add('loading');
1034 Chat._isBatchLoading = true;
1035 var gotMessages = false;
1036 const scrollHt = Chat.e.messagesWrapper.scrollHeight,
1037 scrollTop = Chat.e.messagesWrapper.scrollTop;
1038 fetch("chat-poll?before="+Chat.mnMsg+"&n="+n)
1039 .then(Chat._fetchJsonOrError)
1040 .then(function(x){
1041 gotMessages = x.msgs.length;
1042 newcontent(x,true);
1043 })
1044 .catch(e=>Chat.reportErrorAsMessage(e))
1045 .finally(function(){
1046 Chat._isBatchLoading = false;
1047 Chat.e.messagesWrapper.classList.remove('loading');
1048 Chat.ajaxEnd();
1049 if(Chat._gotServerError){
1050 F.toast.error("Got an error response from the server. ",
1051 "See message for details.");
1052 return;
1053 }else if(n<0/*we asked for all history*/
1054 || 0===gotMessages/*we found no history*/
1055 || (n>0 && gotMessages<n /*we got fewer history entries than requested*/)
1056 || (false!==gotMessages && n===0 && gotMessages<Chat.loadMessageCount
@@ -1043,37 +1092,37 @@
1092 poll.running = true;
1093 if(isFirstCall){
1094 Chat.ajaxStart();
1095 Chat.e.messagesWrapper.classList.add('loading');
1096 }
1097 Chat._isBatchLoading = isFirstCall;
1098 var p = fetch("chat-poll?name=" + Chat.mxMsg);
1099 p.then(Chat._fetchJsonOrError)
1100 .then(y=>newcontent(y))
1101 .catch(e=>console.error(e))
1102 /* ^^^ we don't use Chat.reportError(e) here b/c the polling
1103 fails exepectedly when it times out, but is then immediately
1104 resumed, and reportError() produces a loud error message. */
1105 .finally(function(){
1106 if(isFirstCall){
1107 Chat._isBatchLoading = false;
1108 Chat.ajaxEnd();
1109 Chat.e.messagesWrapper.classList.remove('loading');
1110 setTimeout(function(){
1111 Chat.scrollMessagesTo(1);
1112 }, 250);
1113 }
1114 if(Chat._gotServerError && Chat.intervalTimer){
1115 clearInterval(Chat.intervalTimer);
1116 delete Chat.intervalTimer;
1117 }
1118 poll.running=false;
1119 });
1120 }
1121 Chat._gotServerError = poll.running = false;
1122 poll(true);
1123 if(!Chat._gotServerError){
1124 Chat.intervalTimer = setInterval(poll, 1000);
1125 }
1126 if(/\bping=\d+/.test(window.location.search)){
1127 /* If we see the 'ping' parameter we're certain this was run via
1128 the 'fossil chat' CLI command, in which case we start up in
1129

Keyboard Shortcuts

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