Fossil SCM

Teach /chat to behave better when the connection to the remote server goes down, using a back-off timer to throttle reconnection attempts instead of blindly sending one reconnection request per second.

stephan 2025-04-10 14:22 trunk merge
Commit 39b4bd9c06fb0d2ef3ab3396824ef13ce5d1ac1a3830d169d5681b4c81ec7698
--- src/fossil.dom.js
+++ src/fossil.dom.js
@@ -17,16 +17,16 @@
1717
return function(){
1818
return document.createElement(eType);
1919
};
2020
},
2121
remove: function(e){
22
- if(e.forEach){
22
+ if(e?.forEach){
2323
e.forEach(
24
- (x)=>x.parentNode.removeChild(x)
24
+ (x)=>x?.parentNode?.removeChild(x)
2525
);
2626
}else{
27
- e.parentNode.removeChild(e);
27
+ e?.parentNode?.removeChild(e);
2828
}
2929
return e;
3030
},
3131
/**
3232
Removes all child DOM elements from the given element
3333
--- src/fossil.dom.js
+++ src/fossil.dom.js
@@ -17,16 +17,16 @@
17 return function(){
18 return document.createElement(eType);
19 };
20 },
21 remove: function(e){
22 if(e.forEach){
23 e.forEach(
24 (x)=>x.parentNode.removeChild(x)
25 );
26 }else{
27 e.parentNode.removeChild(e);
28 }
29 return e;
30 },
31 /**
32 Removes all child DOM elements from the given element
33
--- src/fossil.dom.js
+++ src/fossil.dom.js
@@ -17,16 +17,16 @@
17 return function(){
18 return document.createElement(eType);
19 };
20 },
21 remove: function(e){
22 if(e?.forEach){
23 e.forEach(
24 (x)=>x?.parentNode?.removeChild(x)
25 );
26 }else{
27 e?.parentNode?.removeChild(e);
28 }
29 return e;
30 },
31 /**
32 Removes all child DOM elements from the given element
33
--- src/fossil.fetch.js
+++ src/fossil.fetch.js
@@ -34,11 +34,17 @@
3434
handler throws an exception. In the context of the callback, the
3535
options object is "this". Note that this function is intended to be
3636
used solely for error reporting, not error recovery. Because
3737
onerror() may be called if onload() throws, it is up to the caller
3838
to ensure that their onerror() callback references only state which
39
- is valid in such a case.
39
+ is valid in such a case. Special cases for the Error object: (1) If
40
+ the connection times out, the error object will have its
41
+ (.name='timeout') and its (.status=XHR.status) set. (2) If it gets
42
+ a non 2xx HTTP code then it will have
43
+ (.name='http',.status=XHR.status). (3) If it was proxied through a
44
+ JSON-format exception on the server, it will have
45
+ (.name='json',status=XHR.status).
4046
4147
- method: 'POST' | 'GET' (default = 'GET'). CASE SENSITIVE!
4248
4349
- payload: anything acceptable by XHR2.send(ARG) (DOMString,
4450
Document, FormData, Blob, File, ArrayBuffer), or a plain object or
@@ -166,11 +172,14 @@
166172
}else{
167173
x.responseType = opt.responseType||'text';
168174
}
169175
x.ontimeout = function(){
170176
try{opt.aftersend()}catch(e){/*ignore*/}
171
- opt.onerror(new Error("XHR timeout of "+x.timeout+"ms expired."));
177
+ const err = new Error("XHR timeout of "+x.timeout+"ms expired.");
178
+ err.status = x.status;
179
+ err.name = 'timeout';
180
+ opt.onerror(err);
172181
};
173182
x.onreadystatechange = function(){
174183
if(XMLHttpRequest.DONE !== x.readyState) return;
175184
try{opt.aftersend()}catch(e){/*ignore*/}
176185
if(false && 0===x.status){
@@ -180,20 +189,32 @@
180189
request is actually sent and it appears to have no
181190
side-effects on the app other than to generate an error
182191
(i.e. no requests/responses are missing). This is a silly
183192
workaround which may or may not bite us later. If so, it can
184193
be removed at the cost of an unsightly console error message
185
- in FF. */
194
+ in FF.
195
+
196
+ 2025-04-10: that behavior is now also in Chrome and enabling
197
+ this workaround causes our timeout errors to never arrive.
198
+ */
186199
return;
187200
}
188201
if(200!==x.status){
189202
let err;
190203
try{
191204
const j = JSON.parse(x.response);
192
- if(j.error) err = new Error(j.error);
205
+ if(j.error){
206
+ err = new Error(j.error);
207
+ err.name = 'json.error';
208
+ }
193209
}catch(ex){/*ignore*/}
194
- opt.onerror(err || new Error("HTTP response status "+x.status+"."));
210
+ if( !err ){
211
+ err = new Error("HTTP response status "+x.status+".")
212
+ err.name = 'http';
213
+ }
214
+ err.status = x.status;
215
+ opt.onerror(err);
195216
return;
196217
}
197218
const orh = opt.responseHeaders;
198219
let head;
199220
if(true===orh){
200221
--- src/fossil.fetch.js
+++ src/fossil.fetch.js
@@ -34,11 +34,17 @@
34 handler throws an exception. In the context of the callback, the
35 options object is "this". Note that this function is intended to be
36 used solely for error reporting, not error recovery. Because
37 onerror() may be called if onload() throws, it is up to the caller
38 to ensure that their onerror() callback references only state which
39 is valid in such a case.
 
 
 
 
 
 
40
41 - method: 'POST' | 'GET' (default = 'GET'). CASE SENSITIVE!
42
43 - payload: anything acceptable by XHR2.send(ARG) (DOMString,
44 Document, FormData, Blob, File, ArrayBuffer), or a plain object or
@@ -166,11 +172,14 @@
166 }else{
167 x.responseType = opt.responseType||'text';
168 }
169 x.ontimeout = function(){
170 try{opt.aftersend()}catch(e){/*ignore*/}
171 opt.onerror(new Error("XHR timeout of "+x.timeout+"ms expired."));
 
 
 
172 };
173 x.onreadystatechange = function(){
174 if(XMLHttpRequest.DONE !== x.readyState) return;
175 try{opt.aftersend()}catch(e){/*ignore*/}
176 if(false && 0===x.status){
@@ -180,20 +189,32 @@
180 request is actually sent and it appears to have no
181 side-effects on the app other than to generate an error
182 (i.e. no requests/responses are missing). This is a silly
183 workaround which may or may not bite us later. If so, it can
184 be removed at the cost of an unsightly console error message
185 in FF. */
 
 
 
 
186 return;
187 }
188 if(200!==x.status){
189 let err;
190 try{
191 const j = JSON.parse(x.response);
192 if(j.error) err = new Error(j.error);
 
 
 
193 }catch(ex){/*ignore*/}
194 opt.onerror(err || new Error("HTTP response status "+x.status+"."));
 
 
 
 
 
195 return;
196 }
197 const orh = opt.responseHeaders;
198 let head;
199 if(true===orh){
200
--- src/fossil.fetch.js
+++ src/fossil.fetch.js
@@ -34,11 +34,17 @@
34 handler throws an exception. In the context of the callback, the
35 options object is "this". Note that this function is intended to be
36 used solely for error reporting, not error recovery. Because
37 onerror() may be called if onload() throws, it is up to the caller
38 to ensure that their onerror() callback references only state which
39 is valid in such a case. Special cases for the Error object: (1) If
40 the connection times out, the error object will have its
41 (.name='timeout') and its (.status=XHR.status) set. (2) If it gets
42 a non 2xx HTTP code then it will have
43 (.name='http',.status=XHR.status). (3) If it was proxied through a
44 JSON-format exception on the server, it will have
45 (.name='json',status=XHR.status).
46
47 - method: 'POST' | 'GET' (default = 'GET'). CASE SENSITIVE!
48
49 - payload: anything acceptable by XHR2.send(ARG) (DOMString,
50 Document, FormData, Blob, File, ArrayBuffer), or a plain object or
@@ -166,11 +172,14 @@
172 }else{
173 x.responseType = opt.responseType||'text';
174 }
175 x.ontimeout = function(){
176 try{opt.aftersend()}catch(e){/*ignore*/}
177 const err = new Error("XHR timeout of "+x.timeout+"ms expired.");
178 err.status = x.status;
179 err.name = 'timeout';
180 opt.onerror(err);
181 };
182 x.onreadystatechange = function(){
183 if(XMLHttpRequest.DONE !== x.readyState) return;
184 try{opt.aftersend()}catch(e){/*ignore*/}
185 if(false && 0===x.status){
@@ -180,20 +189,32 @@
189 request is actually sent and it appears to have no
190 side-effects on the app other than to generate an error
191 (i.e. no requests/responses are missing). This is a silly
192 workaround which may or may not bite us later. If so, it can
193 be removed at the cost of an unsightly console error message
194 in FF.
195
196 2025-04-10: that behavior is now also in Chrome and enabling
197 this workaround causes our timeout errors to never arrive.
198 */
199 return;
200 }
201 if(200!==x.status){
202 let err;
203 try{
204 const j = JSON.parse(x.response);
205 if(j.error){
206 err = new Error(j.error);
207 err.name = 'json.error';
208 }
209 }catch(ex){/*ignore*/}
210 if( !err ){
211 err = new Error("HTTP response status "+x.status+".")
212 err.name = 'http';
213 }
214 err.status = x.status;
215 opt.onerror(err);
216 return;
217 }
218 const orh = opt.responseHeaders;
219 let head;
220 if(true===orh){
221
--- src/fossil.page.chat.js
+++ src/fossil.page.chat.js
@@ -129,12 +129,13 @@
129129
return resized;
130130
})();
131131
fossil.FRK = ForceResizeKludge/*for debugging*/;
132132
const Chat = ForceResizeKludge.chat = (function(){
133133
const cs = { // the "Chat" object (result of this function)
134
- verboseErrors: false /* if true then certain, mostly extraneous,
135
- error messages may be sent to the console. */,
134
+ beVerbose: false /* if true then certain, mostly extraneous,
135
+ error messages and log messages may be sent
136
+ to the console. */,
136137
playedBeep: false /* used for the beep-once setting */,
137138
e:{/*map of certain DOM elements.*/
138139
messageInjectPoint: E1('#message-inject-point'),
139140
pageTitle: E1('head title'),
140141
loadOlderToolbar: undefined /* the load-posts toolbar (dynamically created) */,
@@ -155,11 +156,12 @@
155156
viewSearch: E1('#chat-search'),
156157
searchContent: E1('#chat-search-content'),
157158
btnPreview: E1('#chat-button-preview'),
158159
views: document.querySelectorAll('.chat-view'),
159160
activeUserListWrapper: E1('#chat-user-list-wrapper'),
160
- activeUserList: E1('#chat-user-list')
161
+ activeUserList: E1('#chat-user-list'),
162
+ eMsgPollError: undefined /* current connection error MessageMidget */
161163
},
162164
me: F.user.name,
163165
mxMsg: F.config.chat.initSize ? -F.config.chat.initSize : -50,
164166
mnMsg: undefined/*lowest message ID we've seen so far (for history loading)*/,
165167
pageIsActive: 'visible'===document.visibilityState,
@@ -179,10 +181,49 @@
179181
filterState:{
180182
activeUser: undefined,
181183
match: function(uname){
182184
return this.activeUser===uname || !this.activeUser;
183185
}
186
+ },
187
+ /**
188
+ The timer object is used to control connection throttling
189
+ when connection errors arrise. It starts off with a polling
190
+ delay of $initialDelay ms. If there's a connection error,
191
+ that gets bumped by some value for each subsequent error, up
192
+ to some max value.
193
+
194
+ The timeing of resetting the delay when service returns is,
195
+ because of the long-poll connection and our lack of low-level
196
+ insight into the connection at this level, a bit wonky.
197
+ */
198
+ timer:{
199
+ tidPoller: undefined /* poller timer */,
200
+ $initialDelay: 1000 /* initial polling interval (ms) */,
201
+ currentDelay: 1000 /* current polling interval */,
202
+ maxDelay: 60000 * 5 /* max interval when backing off for
203
+ connection errors */,
204
+ minDelay: 5000 /* minimum delay time */,
205
+ tidReconnect: undefined /*timer id for reconnection determination*/,
206
+ randomInterval: function(factor){
207
+ return Math.floor(Math.random() * factor);
208
+ },
209
+ incrDelay: function(){
210
+ if( this.maxDelay > this.currentDelay ){
211
+ if(this.currentDelay < this.minDelay){
212
+ this.currentDelay = this.minDelay + this.randomInterval(this.minDelay);
213
+ }else{
214
+ this.currentDelay = this.currentDelay*2 + this.randomInterval(this.currentDelay);
215
+ }
216
+ }
217
+ return this.currentDelay;
218
+ },
219
+ resetDelay: function(){
220
+ return this.currentDelay = this.$initialDelay;
221
+ },
222
+ isDelayed: function(){
223
+ return (this.currentDelay > this.$initialDelay) ? this.currentDelay : 0;
224
+ }
184225
},
185226
/**
186227
Gets (no args) or sets (1 arg) the current input text field
187228
value, taking into account single- vs multi-line input. The
188229
getter returns a trim()'d string and the setter returns this
@@ -606,11 +647,11 @@
606647
607648
/**
608649
If animations are enabled, passes its arguments
609650
to D.addClassBriefly(), else this is a no-op.
610651
If cb is a function, it is called after the
611
- CSS class is removed. Returns this object;
652
+ CSS class is removed. Returns this object;
612653
*/
613654
animate: function f(e,a,cb){
614655
if(!f.$disabled){
615656
D.addClassBriefly(e, a, 0, cb);
616657
}
@@ -645,33 +686,60 @@
645686
cs.reportError = function(/*msg args*/){
646687
const args = argsToArray(arguments);
647688
console.error("chat error:",args);
648689
F.toast.error.apply(F.toast, args);
649690
};
691
+
692
+ let InternalMsgId = 0;
650693
/**
651694
Reports an error in the form of a new message in the chat
652695
feed. All arguments are appended to the message's content area
653696
using fossil.dom.append(), so may be of any type supported by
654697
that function.
655698
*/
656699
cs.reportErrorAsMessage = function f(/*msg args*/){
657
- if(undefined === f.$msgid) f.$msgid=0;
658700
const args = argsToArray(arguments).map(function(v){
659701
return (v instanceof Error) ? v.message : v;
660702
});
661
- console.error("chat error:",args);
703
+ if(Chat.beVerbose){
704
+ console.error("chat error:",args);
705
+ }
662706
const d = new Date().toISOString(),
663707
mw = new this.MessageWidget({
664708
isError: true,
665
- xfrom: null,
666
- msgid: "error-"+(++f.$msgid),
709
+ xfrom: undefined,
710
+ msgid: "error-"+(++InternalMsgId),
711
+ mtime: d,
712
+ lmtime: d,
713
+ xmsg: args
714
+ });
715
+ this.injectMessageElem(mw.e.body);
716
+ mw.scrollIntoView();
717
+ return mw;
718
+ };
719
+
720
+ /**
721
+ For use by the connection poller to send a "connection
722
+ restored" message.
723
+ */
724
+ cs.reportReconnection = function f(/*msg args*/){
725
+ const args = argsToArray(arguments).map(function(v){
726
+ return (v instanceof Error) ? v.message : v;
727
+ });
728
+ const d = new Date().toISOString(),
729
+ mw = new this.MessageWidget({
730
+ isError: false,
731
+ xfrom: undefined,
732
+ msgid: "reconnect-"+(++InternalMsgId),
667733
mtime: d,
668734
lmtime: d,
669735
xmsg: args
670736
});
671737
this.injectMessageElem(mw.e.body);
672738
mw.scrollIntoView();
739
+ //Chat.playNewMessageSound();// browser complains b/c this wasn't via human interaction
740
+ return mw;
673741
};
674742
675743
cs.getMessageElemById = function(id){
676744
return qs('[data-msgid="'+id+'"]');
677745
};
@@ -690,24 +758,41 @@
690758
/**
691759
LOCALLY deletes a message element by the message ID or passing
692760
the .message-row element. Returns true if it removes an element,
693761
else false.
694762
*/
695
- cs.deleteMessageElem = function(id){
763
+ cs.deleteMessageElem = function(id, silent){
696764
var e;
765
+ //console.warn("Chat.deleteMessageElem",id,silent);
697766
if(id instanceof HTMLElement){
698767
e = id;
699768
id = e.dataset.msgid;
700
- }else{
769
+ delete e.dataset.msgid;
770
+ if( e?.dataset?.alsoRemove ){
771
+ const xId = e.dataset.alsoRemove;
772
+ delete e.dataset.alsoRemove;
773
+ this.deleteMessageElem( xId );
774
+ }
775
+ }else if(e instanceof Chat.MessageWidget) {
776
+ if( this.e.eMsgPollError === e.body ){
777
+ this.e.eMsgPollError = undefined;
778
+ }
779
+ if(e.e.body){
780
+ this.deleteMessageElem(e.e.body);
781
+ }
782
+ return;
783
+ } else{
701784
e = this.getMessageElemById(id);
702785
}
703786
if(e && id){
704787
D.remove(e);
705788
if(e===this.e.newestMessage){
706789
this.fetchLastMessageElem();
707790
}
708
- F.toast.message("Deleted message "+id+".");
791
+ if( !silent ){
792
+ F.toast.message("Deleted message "+id+".");
793
+ }
709794
}
710795
return !!e;
711796
};
712797
713798
/**
@@ -776,10 +861,11 @@
776861
const self = this;
777862
F.fetch('chat-fetch-one',{
778863
urlParams:{ name: id, raw: true},
779864
responseType: 'json',
780865
onload: function(msg){
866
+ reportConnectionReestablished('chat-fetch-one');
781867
content.$elems[1] = D.append(D.pre(),msg.xmsg);
782868
content.$elems[1]._xmsgRaw = msg.xmsg/*used for copy-to-clipboard feature*/;
783869
self.toggleTextMode(e);
784870
},
785871
aftersend:function(){
@@ -837,11 +923,14 @@
837923
if(!(e instanceof HTMLElement)) return;
838924
if(this.userMayDelete(e)){
839925
this.ajaxStart();
840926
F.fetch("chat-delete/" + id, {
841927
responseType: 'json',
842
- onload:(r)=>this.deleteMessageElem(r),
928
+ onload:(r)=>{
929
+ reportConnectionReestablished('chat-delete');
930
+ this.deleteMessageElem(r);
931
+ },
843932
onerror:(err)=>this.reportErrorAsMessage(err)
844933
});
845934
}else{
846935
this.deleteMessageElem(id);
847936
}
@@ -1035,10 +1124,11 @@
10351124
10361125
ctor.prototype = {
10371126
scrollIntoView: function(){
10381127
this.e.content.scrollIntoView();
10391128
},
1129
+ //remove: function(silent){Chat.deleteMessageElem(this, silent);},
10401130
setMessage: function(m){
10411131
const ds = this.e.body.dataset;
10421132
ds.timestamp = m.mtime;
10431133
ds.lmtime = m.lmtime;
10441134
ds.msgid = m.msgid;
@@ -1212,12 +1302,21 @@
12121302
const btnDeleteLocal = D.button("Delete locally");
12131303
D.append(toolbar, btnDeleteLocal);
12141304
const self = this;
12151305
btnDeleteLocal.addEventListener('click', function(){
12161306
self.hide();
1217
- Chat.deleteMessageElem(eMsg);
1307
+ Chat.deleteMessageElem(eMsg)
12181308
});
1309
+ if( eMsg.classList.contains('poller-connection') ){
1310
+ const btnDeletePoll = D.button("Delete poller messages?");
1311
+ D.append(toolbar, btnDeletePoll);
1312
+ btnDeletePoll.addEventListener('click', function(){
1313
+ self.hide();
1314
+ Chat.e.viewMessages.querySelectorAll('.message-widget.poller-connection')
1315
+ .forEach(e=>Chat.deleteMessageElem(e, true));
1316
+ });
1317
+ }
12191318
if(Chat.userMayDelete(eMsg)){
12201319
const btnDeleteGlobal = D.button("Delete globally");
12211320
D.append(toolbar, btnDeleteGlobal);
12221321
F.confirmer(btnDeleteGlobal,{
12231322
pinSize: true,
@@ -1457,10 +1556,11 @@
14571556
n: nFetch,
14581557
i: iFirst
14591558
},
14601559
responseType: "json",
14611560
onload:function(jx){
1561
+ reportConnectionReestablished('chat-query.onload');
14621562
if( bDown ) jx.msgs.reverse();
14631563
jx.msgs.forEach((m) => {
14641564
m.isSearchResult = true;
14651565
var mw = new Chat.MessageWidget(m);
14661566
if( bDown ){
@@ -1624,10 +1724,56 @@
16241724
const theMsg = findMessageWidgetParent(w);
16251725
if(theMsg) Chat.deleteMessageElem(theMsg);
16261726
}));
16271727
Chat.reportErrorAsMessage(w);
16281728
};
1729
+
1730
+ /* Assume the connection has been established, reset the
1731
+ Chat.timer.tidReconnect, and (if showMsg and
1732
+ !!Chat.e.eMsgPollError) alert the user that the outage appears to
1733
+ be over. */
1734
+ const reportConnectionReestablished = function(dbgContext, showMsg = true){
1735
+ if(Chat.beVerbose){
1736
+ console.warn("reportConnectionReestablished()",
1737
+ dbgContext, showMsg, Chat.timer.tidReconnect, Chat.e.eMsgPollError);
1738
+ }
1739
+ if( Chat.timer.tidReconnect ){
1740
+ clearTimeout(Chat.timer.tidReconnect);
1741
+ Chat.timer.tidReconnect = 0;
1742
+ }
1743
+ Chat.timer.resetDelay();
1744
+ if( Chat.e.eMsgPollError ) {
1745
+ const oldErrMsg = Chat.e.eMsgPollError;
1746
+ Chat.e.eMsgPollError = undefined;
1747
+ if( showMsg ){
1748
+ const m = Chat.reportReconnection("Poller connection restored.");
1749
+ m.e.body.dataset.alsoRemove = oldErrMsg?.e?.body?.dataset?.msgid;
1750
+ D.addClass(m.e.body,'poller-connection');
1751
+ }
1752
+ }
1753
+ setTimeout( Chat.poll, 0 );
1754
+ };
1755
+
1756
+ /* To be called from F.fetch('chat-poll') beforesend() handlers. If we're
1757
+ currently in delayed-retry mode and a connection is started, try
1758
+ to reset the delay after N time waiting on that connection. The
1759
+ fact that the connection is waiting to respond, rather than
1760
+ outright failing, is a good hint that the outage is over and we
1761
+ can reset the back-off timer. */
1762
+ const clearPollErrOnWait = function(){
1763
+ if( !Chat.timer.tidReconnect && Chat.timer.isDelayed() ){
1764
+ Chat.timer.tidReconnect = setTimeout(()=>{
1765
+ Chat.timer.tidReconnect = 0;
1766
+ if( poll.running ){
1767
+ /* This chat-poll F.fetch() is still underway, so let's
1768
+ assume the connection is back up until/unless it times
1769
+ out or breaks again. */
1770
+ reportConnectionReestablished('clearPollErrOnWait');
1771
+ }
1772
+ }, Chat.timer.$initialDelay * 3 );
1773
+ }
1774
+ };
16291775
16301776
/**
16311777
Submits the contents of the message input field (if not empty)
16321778
and/or the file attachment field to the server. If both are
16331779
empty, this is a no-op.
@@ -1686,10 +1832,11 @@
16861832
onerror:function(err){
16871833
self.reportErrorAsMessage(err);
16881834
recoverFailedMessage(fallback);
16891835
},
16901836
onload:function(txt){
1837
+ reportConnectionReestablished();
16911838
if(!txt) return/*success response*/;
16921839
try{
16931840
const json = JSON.parse(txt);
16941841
self.newContent({msgs:[json]});
16951842
}catch(e){
@@ -2185,10 +2332,11 @@
21852332
/*filename needed for mimetype determination*/);
21862333
fd.append('render_mode',F.page.previewModes.wiki);
21872334
F.fetch('ajax/preview-text',{
21882335
payload: fd,
21892336
onload: function(html){
2337
+ reportConnectionReestablished();
21902338
Chat.setPreviewText(html);
21912339
F.pikchr.addSrcView(Chat.e.viewPreview.querySelectorAll('svg.pikchr'));
21922340
},
21932341
onerror: function(e){
21942342
F.fetch.onerror(e);
@@ -2322,10 +2470,11 @@
23222470
onerror:function(err){
23232471
Chat.reportErrorAsMessage(err);
23242472
Chat._isBatchLoading = false;
23252473
},
23262474
onload:function(x){
2475
+ reportConnectionReestablished();
23272476
let gotMessages = x.msgs.length;
23282477
newcontent(x,true);
23292478
Chat._isBatchLoading = false;
23302479
Chat.updateActiveUserList();
23312480
if(Chat._gotServerError){
@@ -2411,10 +2560,11 @@
24112560
onerror:function(err){
24122561
Chat.setCurrentView(Chat.e.viewMessages);
24132562
Chat.reportErrorAsMessage(err);
24142563
},
24152564
onload:function(jx){
2565
+ reportConnectionReestablished();
24162566
let previd = 0;
24172567
D.clearElement(eMsgTgt);
24182568
jx.msgs.forEach((m)=>{
24192569
m.isSearchResult = true;
24202570
const mw = new Chat.MessageWidget(m);
@@ -2444,29 +2594,65 @@
24442594
}
24452595
}
24462596
);
24472597
}/*Chat.submitSearch()*/;
24482598
2449
- const afterFetch = function f(){
2599
+ /**
2600
+ Deal with the last poll() response and maybe re-start poll().
2601
+ */
2602
+ const afterPollFetch = function f(err){
24502603
if(true===f.isFirstCall){
24512604
f.isFirstCall = false;
24522605
Chat.ajaxEnd();
24532606
Chat.e.viewMessages.classList.remove('loading');
24542607
setTimeout(function(){
24552608
Chat.scrollMessagesTo(1);
24562609
}, 250);
24572610
}
2458
- if(Chat._gotServerError && Chat.intervalTimer){
2459
- clearInterval(Chat.intervalTimer);
2611
+ if(Chat.timer.tidPoller) {
2612
+ clearTimeout(Chat.timer.tidPoller);
2613
+ Chat.timer.tidPoller = 0;
2614
+ }
2615
+ if(Chat._gotServerError){
24602616
Chat.reportErrorAsMessage(
24612617
"Shutting down chat poller due to server-side error. ",
2462
- "Reload this page to reactivate it.");
2463
- delete Chat.intervalTimer;
2618
+ "Reload this page to reactivate it."
2619
+ );
2620
+ Chat.timer.tidPoller = undefined;
2621
+ } else {
2622
+ if( err && Chat.beVerbose ){
2623
+ console.error("afterPollFetch:",err.name,err.status,err.message);
2624
+ }
2625
+ if( !err || 'timeout'===err.name/*(probably) long-poll expired*/ ){
2626
+ /* Restart the poller immediately. */
2627
+ reportConnectionReestablished('afterPollFetch '+err, false);
2628
+ }else{
2629
+ /* Delay a while before trying again, noting that other Chat
2630
+ APIs may try and succeed at connections before this timer
2631
+ resolves, in which case they'll clear this timeout and the
2632
+ UI message about the outage. */
2633
+ const delay = Chat.timer.incrDelay();
2634
+ //console.warn("afterPollFetch Chat.e.eMsgPollError",Chat.e.eMsgPollError);
2635
+ const msg = "Poller connection error. Retrying in "+delay+ " ms.";
2636
+ if( Chat.e.eMsgPollError ){
2637
+ /* Update the error message on the current error MessageWidget */
2638
+ Chat.e.eMsgPollError.e.content.innerText = msg;
2639
+ }else {
2640
+ /* Set current (new) error MessageWidget */
2641
+ Chat.e.eMsgPollError = Chat.reportErrorAsMessage(msg);
2642
+ //Chat.playNewMessageSound();// browser complains b/c this wasn't via human interaction
2643
+ D.addClass(Chat.e.eMsgPollError.e.body,'poller-connection');
2644
+ }
2645
+ Chat.timer.tidPoller = setTimeout(()=>{
2646
+ poll();
2647
+ }, delay);
2648
+ }
2649
+ //console.log("isOkay =",isOkay,"currentDelay =",Chat.timer.currentDelay);
24642650
}
2465
- poll.running = false;
24662651
};
2467
- afterFetch.isFirstCall = true;
2652
+ afterPollFetch.isFirstCall = true;
2653
+
24682654
/**
24692655
FIXME: when polling fails because the remote server is
24702656
reachable but it's not accepting HTTP requests, we should back
24712657
off on polling for a while. e.g. if the remote web server process
24722658
is killed, the poll fails quickly and immediately retries,
@@ -2478,53 +2664,96 @@
24782664
xhrRequest.status value to do so, with status==0 being a
24792665
connection error. We do not currently have a clean way of passing
24802666
that info back to the fossil.fetch() client, so we'll need to
24812667
hammer on that API a bit to get this working.
24822668
*/
2483
- const poll = async function f(){
2669
+ const poll = Chat.poll = async function f(){
24842670
if(f.running) return;
24852671
f.running = true;
24862672
Chat._isBatchLoading = f.isFirstCall;
24872673
if(true===f.isFirstCall){
24882674
f.isFirstCall = false;
2675
+ Chat.aPollErr = [];
24892676
Chat.ajaxStart();
24902677
Chat.e.viewMessages.classList.add('loading');
2678
+ setInterval(
2679
+ /*
2680
+ We manager onerror() results in poll() using a
2681
+ stack of error objects and we delay their handling by
2682
+ a small amount, rather than immediately when the
2683
+ exception arrives.
2684
+
2685
+ This level of indirection is to work around an
2686
+ inexplicable behavior from the F.fetch() connections:
2687
+ timeouts are always announced in pairs of an HTTP 0 and
2688
+ something we can unambiguously identify as a timeout. When
2689
+ that happens, we ignore the HTTP 0. If, however, an HTTP 0
2690
+ is seen here without an immediately-following timeout, we
2691
+ process it.
2692
+
2693
+ It's kinda like in the curses C API, where you to match
2694
+ ALT-X by first getting an ALT event, then a separate X
2695
+ event, but a lot less explicable.
2696
+ */
2697
+ ()=>{
2698
+ if( Chat.aPollErr.length ){
2699
+ if(Chat.aPollErr.length>1){
2700
+ //console.warn('aPollErr',Chat.aPollErr);
2701
+ if(Chat.aPollErr[1].name='timeout'){
2702
+ /* mysterious pairs of HTTP 0 followed immediately
2703
+ by timeout response; ignore the former in that case. */
2704
+ Chat.aPollErr.shift();
2705
+ }
2706
+ }
2707
+ afterPollFetch(Chat.aPollErr.shift());
2708
+ }
2709
+ },
2710
+ 1000
2711
+ );
24912712
}
2713
+ let nErr = 0;
24922714
F.fetch("chat-poll",{
2493
- timeout: 420 * 1000/*FIXME: get the value from the server*/,
2715
+ timeout: window.location.hostname.match(
2716
+ "localhost" /*presumably local dev mode*/
2717
+ ) ? 15000
2718
+ : 420 * 1000/*FIXME: get the value from the server*/,
24942719
urlParams:{
24952720
name: Chat.mxMsg
24962721
},
24972722
responseType: "json",
24982723
// Disable the ajax start/end handling for this long-polling op:
2499
- beforesend: function(){},
2500
- aftersend: function(){},
2724
+ beforesend: function(){
2725
+ clearPollErrOnWait();
2726
+ },
2727
+ aftersend: function(){
2728
+ poll.running = false;
2729
+ },
25012730
onerror:function(err){
25022731
Chat._isBatchLoading = false;
2503
- if(Chat.verboseErrors) console.error(err);
2504
- /* ^^^ we don't use Chat.reportError() here b/c the polling
2505
- fails exepectedly when it times out, but is then immediately
2506
- resumed, and reportError() produces a loud error message. */
2507
- afterFetch();
2732
+ if(Chat.beVerbose){
2733
+ console.error("poll.onerror:",err.name,err.status,JSON.stringify(err));
2734
+ }
2735
+ Chat.aPollErr.push(err);
25082736
},
25092737
onload:function(y){
2738
+ reportConnectionReestablished('poll.onload', true);
25102739
newcontent(y);
25112740
if(Chat._isBatchLoading){
25122741
Chat._isBatchLoading = false;
25132742
Chat.updateActiveUserList();
25142743
}
2515
- afterFetch();
2744
+ afterPollFetch();
25162745
}
25172746
});
25182747
};
25192748
poll.isFirstCall = true;
25202749
Chat._gotServerError = poll.running = false;
25212750
if( window.fossil.config.chat.fromcli ){
25222751
Chat.chatOnlyMode(true);
25232752
}
2524
- Chat.intervalTimer = setInterval(poll, 1000);
2753
+ Chat.timer.tidPoller = setTimeout(poll, Chat.timer.resetDelay());
25252754
delete ForceResizeKludge.$disabled;
25262755
ForceResizeKludge();
25272756
Chat.animate.$disabled = false;
25282757
setTimeout( ()=>Chat.inputFocus(), 0 );
25292758
F.page.chat = Chat/* enables testing the APIs via the dev tools */;
25302759
});
25312760
--- src/fossil.page.chat.js
+++ src/fossil.page.chat.js
@@ -129,12 +129,13 @@
129 return resized;
130 })();
131 fossil.FRK = ForceResizeKludge/*for debugging*/;
132 const Chat = ForceResizeKludge.chat = (function(){
133 const cs = { // the "Chat" object (result of this function)
134 verboseErrors: false /* if true then certain, mostly extraneous,
135 error messages may be sent to the console. */,
 
136 playedBeep: false /* used for the beep-once setting */,
137 e:{/*map of certain DOM elements.*/
138 messageInjectPoint: E1('#message-inject-point'),
139 pageTitle: E1('head title'),
140 loadOlderToolbar: undefined /* the load-posts toolbar (dynamically created) */,
@@ -155,11 +156,12 @@
155 viewSearch: E1('#chat-search'),
156 searchContent: E1('#chat-search-content'),
157 btnPreview: E1('#chat-button-preview'),
158 views: document.querySelectorAll('.chat-view'),
159 activeUserListWrapper: E1('#chat-user-list-wrapper'),
160 activeUserList: E1('#chat-user-list')
 
161 },
162 me: F.user.name,
163 mxMsg: F.config.chat.initSize ? -F.config.chat.initSize : -50,
164 mnMsg: undefined/*lowest message ID we've seen so far (for history loading)*/,
165 pageIsActive: 'visible'===document.visibilityState,
@@ -179,10 +181,49 @@
179 filterState:{
180 activeUser: undefined,
181 match: function(uname){
182 return this.activeUser===uname || !this.activeUser;
183 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184 },
185 /**
186 Gets (no args) or sets (1 arg) the current input text field
187 value, taking into account single- vs multi-line input. The
188 getter returns a trim()'d string and the setter returns this
@@ -606,11 +647,11 @@
606
607 /**
608 If animations are enabled, passes its arguments
609 to D.addClassBriefly(), else this is a no-op.
610 If cb is a function, it is called after the
611 CSS class is removed. Returns this object;
612 */
613 animate: function f(e,a,cb){
614 if(!f.$disabled){
615 D.addClassBriefly(e, a, 0, cb);
616 }
@@ -645,33 +686,60 @@
645 cs.reportError = function(/*msg args*/){
646 const args = argsToArray(arguments);
647 console.error("chat error:",args);
648 F.toast.error.apply(F.toast, args);
649 };
 
 
650 /**
651 Reports an error in the form of a new message in the chat
652 feed. All arguments are appended to the message's content area
653 using fossil.dom.append(), so may be of any type supported by
654 that function.
655 */
656 cs.reportErrorAsMessage = function f(/*msg args*/){
657 if(undefined === f.$msgid) f.$msgid=0;
658 const args = argsToArray(arguments).map(function(v){
659 return (v instanceof Error) ? v.message : v;
660 });
661 console.error("chat error:",args);
 
 
662 const d = new Date().toISOString(),
663 mw = new this.MessageWidget({
664 isError: true,
665 xfrom: null,
666 msgid: "error-"+(++f.$msgid),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
667 mtime: d,
668 lmtime: d,
669 xmsg: args
670 });
671 this.injectMessageElem(mw.e.body);
672 mw.scrollIntoView();
 
 
673 };
674
675 cs.getMessageElemById = function(id){
676 return qs('[data-msgid="'+id+'"]');
677 };
@@ -690,24 +758,41 @@
690 /**
691 LOCALLY deletes a message element by the message ID or passing
692 the .message-row element. Returns true if it removes an element,
693 else false.
694 */
695 cs.deleteMessageElem = function(id){
696 var e;
 
697 if(id instanceof HTMLElement){
698 e = id;
699 id = e.dataset.msgid;
700 }else{
 
 
 
 
 
 
 
 
 
 
 
 
 
 
701 e = this.getMessageElemById(id);
702 }
703 if(e && id){
704 D.remove(e);
705 if(e===this.e.newestMessage){
706 this.fetchLastMessageElem();
707 }
708 F.toast.message("Deleted message "+id+".");
 
 
709 }
710 return !!e;
711 };
712
713 /**
@@ -776,10 +861,11 @@
776 const self = this;
777 F.fetch('chat-fetch-one',{
778 urlParams:{ name: id, raw: true},
779 responseType: 'json',
780 onload: function(msg){
 
781 content.$elems[1] = D.append(D.pre(),msg.xmsg);
782 content.$elems[1]._xmsgRaw = msg.xmsg/*used for copy-to-clipboard feature*/;
783 self.toggleTextMode(e);
784 },
785 aftersend:function(){
@@ -837,11 +923,14 @@
837 if(!(e instanceof HTMLElement)) return;
838 if(this.userMayDelete(e)){
839 this.ajaxStart();
840 F.fetch("chat-delete/" + id, {
841 responseType: 'json',
842 onload:(r)=>this.deleteMessageElem(r),
 
 
 
843 onerror:(err)=>this.reportErrorAsMessage(err)
844 });
845 }else{
846 this.deleteMessageElem(id);
847 }
@@ -1035,10 +1124,11 @@
1035
1036 ctor.prototype = {
1037 scrollIntoView: function(){
1038 this.e.content.scrollIntoView();
1039 },
 
1040 setMessage: function(m){
1041 const ds = this.e.body.dataset;
1042 ds.timestamp = m.mtime;
1043 ds.lmtime = m.lmtime;
1044 ds.msgid = m.msgid;
@@ -1212,12 +1302,21 @@
1212 const btnDeleteLocal = D.button("Delete locally");
1213 D.append(toolbar, btnDeleteLocal);
1214 const self = this;
1215 btnDeleteLocal.addEventListener('click', function(){
1216 self.hide();
1217 Chat.deleteMessageElem(eMsg);
1218 });
 
 
 
 
 
 
 
 
 
1219 if(Chat.userMayDelete(eMsg)){
1220 const btnDeleteGlobal = D.button("Delete globally");
1221 D.append(toolbar, btnDeleteGlobal);
1222 F.confirmer(btnDeleteGlobal,{
1223 pinSize: true,
@@ -1457,10 +1556,11 @@
1457 n: nFetch,
1458 i: iFirst
1459 },
1460 responseType: "json",
1461 onload:function(jx){
 
1462 if( bDown ) jx.msgs.reverse();
1463 jx.msgs.forEach((m) => {
1464 m.isSearchResult = true;
1465 var mw = new Chat.MessageWidget(m);
1466 if( bDown ){
@@ -1624,10 +1724,56 @@
1624 const theMsg = findMessageWidgetParent(w);
1625 if(theMsg) Chat.deleteMessageElem(theMsg);
1626 }));
1627 Chat.reportErrorAsMessage(w);
1628 };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1629
1630 /**
1631 Submits the contents of the message input field (if not empty)
1632 and/or the file attachment field to the server. If both are
1633 empty, this is a no-op.
@@ -1686,10 +1832,11 @@
1686 onerror:function(err){
1687 self.reportErrorAsMessage(err);
1688 recoverFailedMessage(fallback);
1689 },
1690 onload:function(txt){
 
1691 if(!txt) return/*success response*/;
1692 try{
1693 const json = JSON.parse(txt);
1694 self.newContent({msgs:[json]});
1695 }catch(e){
@@ -2185,10 +2332,11 @@
2185 /*filename needed for mimetype determination*/);
2186 fd.append('render_mode',F.page.previewModes.wiki);
2187 F.fetch('ajax/preview-text',{
2188 payload: fd,
2189 onload: function(html){
 
2190 Chat.setPreviewText(html);
2191 F.pikchr.addSrcView(Chat.e.viewPreview.querySelectorAll('svg.pikchr'));
2192 },
2193 onerror: function(e){
2194 F.fetch.onerror(e);
@@ -2322,10 +2470,11 @@
2322 onerror:function(err){
2323 Chat.reportErrorAsMessage(err);
2324 Chat._isBatchLoading = false;
2325 },
2326 onload:function(x){
 
2327 let gotMessages = x.msgs.length;
2328 newcontent(x,true);
2329 Chat._isBatchLoading = false;
2330 Chat.updateActiveUserList();
2331 if(Chat._gotServerError){
@@ -2411,10 +2560,11 @@
2411 onerror:function(err){
2412 Chat.setCurrentView(Chat.e.viewMessages);
2413 Chat.reportErrorAsMessage(err);
2414 },
2415 onload:function(jx){
 
2416 let previd = 0;
2417 D.clearElement(eMsgTgt);
2418 jx.msgs.forEach((m)=>{
2419 m.isSearchResult = true;
2420 const mw = new Chat.MessageWidget(m);
@@ -2444,29 +2594,65 @@
2444 }
2445 }
2446 );
2447 }/*Chat.submitSearch()*/;
2448
2449 const afterFetch = function f(){
 
 
 
2450 if(true===f.isFirstCall){
2451 f.isFirstCall = false;
2452 Chat.ajaxEnd();
2453 Chat.e.viewMessages.classList.remove('loading');
2454 setTimeout(function(){
2455 Chat.scrollMessagesTo(1);
2456 }, 250);
2457 }
2458 if(Chat._gotServerError && Chat.intervalTimer){
2459 clearInterval(Chat.intervalTimer);
 
 
 
2460 Chat.reportErrorAsMessage(
2461 "Shutting down chat poller due to server-side error. ",
2462 "Reload this page to reactivate it.");
2463 delete Chat.intervalTimer;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2464 }
2465 poll.running = false;
2466 };
2467 afterFetch.isFirstCall = true;
 
2468 /**
2469 FIXME: when polling fails because the remote server is
2470 reachable but it's not accepting HTTP requests, we should back
2471 off on polling for a while. e.g. if the remote web server process
2472 is killed, the poll fails quickly and immediately retries,
@@ -2478,53 +2664,96 @@
2478 xhrRequest.status value to do so, with status==0 being a
2479 connection error. We do not currently have a clean way of passing
2480 that info back to the fossil.fetch() client, so we'll need to
2481 hammer on that API a bit to get this working.
2482 */
2483 const poll = async function f(){
2484 if(f.running) return;
2485 f.running = true;
2486 Chat._isBatchLoading = f.isFirstCall;
2487 if(true===f.isFirstCall){
2488 f.isFirstCall = false;
 
2489 Chat.ajaxStart();
2490 Chat.e.viewMessages.classList.add('loading');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2491 }
 
2492 F.fetch("chat-poll",{
2493 timeout: 420 * 1000/*FIXME: get the value from the server*/,
 
 
 
2494 urlParams:{
2495 name: Chat.mxMsg
2496 },
2497 responseType: "json",
2498 // Disable the ajax start/end handling for this long-polling op:
2499 beforesend: function(){},
2500 aftersend: function(){},
 
 
 
 
2501 onerror:function(err){
2502 Chat._isBatchLoading = false;
2503 if(Chat.verboseErrors) console.error(err);
2504 /* ^^^ we don't use Chat.reportError() here b/c the polling
2505 fails exepectedly when it times out, but is then immediately
2506 resumed, and reportError() produces a loud error message. */
2507 afterFetch();
2508 },
2509 onload:function(y){
 
2510 newcontent(y);
2511 if(Chat._isBatchLoading){
2512 Chat._isBatchLoading = false;
2513 Chat.updateActiveUserList();
2514 }
2515 afterFetch();
2516 }
2517 });
2518 };
2519 poll.isFirstCall = true;
2520 Chat._gotServerError = poll.running = false;
2521 if( window.fossil.config.chat.fromcli ){
2522 Chat.chatOnlyMode(true);
2523 }
2524 Chat.intervalTimer = setInterval(poll, 1000);
2525 delete ForceResizeKludge.$disabled;
2526 ForceResizeKludge();
2527 Chat.animate.$disabled = false;
2528 setTimeout( ()=>Chat.inputFocus(), 0 );
2529 F.page.chat = Chat/* enables testing the APIs via the dev tools */;
2530 });
2531
--- src/fossil.page.chat.js
+++ src/fossil.page.chat.js
@@ -129,12 +129,13 @@
129 return resized;
130 })();
131 fossil.FRK = ForceResizeKludge/*for debugging*/;
132 const Chat = ForceResizeKludge.chat = (function(){
133 const cs = { // the "Chat" object (result of this function)
134 beVerbose: false /* if true then certain, mostly extraneous,
135 error messages and log messages may be sent
136 to the console. */,
137 playedBeep: false /* used for the beep-once setting */,
138 e:{/*map of certain DOM elements.*/
139 messageInjectPoint: E1('#message-inject-point'),
140 pageTitle: E1('head title'),
141 loadOlderToolbar: undefined /* the load-posts toolbar (dynamically created) */,
@@ -155,11 +156,12 @@
156 viewSearch: E1('#chat-search'),
157 searchContent: E1('#chat-search-content'),
158 btnPreview: E1('#chat-button-preview'),
159 views: document.querySelectorAll('.chat-view'),
160 activeUserListWrapper: E1('#chat-user-list-wrapper'),
161 activeUserList: E1('#chat-user-list'),
162 eMsgPollError: undefined /* current connection error MessageMidget */
163 },
164 me: F.user.name,
165 mxMsg: F.config.chat.initSize ? -F.config.chat.initSize : -50,
166 mnMsg: undefined/*lowest message ID we've seen so far (for history loading)*/,
167 pageIsActive: 'visible'===document.visibilityState,
@@ -179,10 +181,49 @@
181 filterState:{
182 activeUser: undefined,
183 match: function(uname){
184 return this.activeUser===uname || !this.activeUser;
185 }
186 },
187 /**
188 The timer object is used to control connection throttling
189 when connection errors arrise. It starts off with a polling
190 delay of $initialDelay ms. If there's a connection error,
191 that gets bumped by some value for each subsequent error, up
192 to some max value.
193
194 The timeing of resetting the delay when service returns is,
195 because of the long-poll connection and our lack of low-level
196 insight into the connection at this level, a bit wonky.
197 */
198 timer:{
199 tidPoller: undefined /* poller timer */,
200 $initialDelay: 1000 /* initial polling interval (ms) */,
201 currentDelay: 1000 /* current polling interval */,
202 maxDelay: 60000 * 5 /* max interval when backing off for
203 connection errors */,
204 minDelay: 5000 /* minimum delay time */,
205 tidReconnect: undefined /*timer id for reconnection determination*/,
206 randomInterval: function(factor){
207 return Math.floor(Math.random() * factor);
208 },
209 incrDelay: function(){
210 if( this.maxDelay > this.currentDelay ){
211 if(this.currentDelay < this.minDelay){
212 this.currentDelay = this.minDelay + this.randomInterval(this.minDelay);
213 }else{
214 this.currentDelay = this.currentDelay*2 + this.randomInterval(this.currentDelay);
215 }
216 }
217 return this.currentDelay;
218 },
219 resetDelay: function(){
220 return this.currentDelay = this.$initialDelay;
221 },
222 isDelayed: function(){
223 return (this.currentDelay > this.$initialDelay) ? this.currentDelay : 0;
224 }
225 },
226 /**
227 Gets (no args) or sets (1 arg) the current input text field
228 value, taking into account single- vs multi-line input. The
229 getter returns a trim()'d string and the setter returns this
@@ -606,11 +647,11 @@
647
648 /**
649 If animations are enabled, passes its arguments
650 to D.addClassBriefly(), else this is a no-op.
651 If cb is a function, it is called after the
652 CSS class is removed. Returns this object;
653 */
654 animate: function f(e,a,cb){
655 if(!f.$disabled){
656 D.addClassBriefly(e, a, 0, cb);
657 }
@@ -645,33 +686,60 @@
686 cs.reportError = function(/*msg args*/){
687 const args = argsToArray(arguments);
688 console.error("chat error:",args);
689 F.toast.error.apply(F.toast, args);
690 };
691
692 let InternalMsgId = 0;
693 /**
694 Reports an error in the form of a new message in the chat
695 feed. All arguments are appended to the message's content area
696 using fossil.dom.append(), so may be of any type supported by
697 that function.
698 */
699 cs.reportErrorAsMessage = function f(/*msg args*/){
 
700 const args = argsToArray(arguments).map(function(v){
701 return (v instanceof Error) ? v.message : v;
702 });
703 if(Chat.beVerbose){
704 console.error("chat error:",args);
705 }
706 const d = new Date().toISOString(),
707 mw = new this.MessageWidget({
708 isError: true,
709 xfrom: undefined,
710 msgid: "error-"+(++InternalMsgId),
711 mtime: d,
712 lmtime: d,
713 xmsg: args
714 });
715 this.injectMessageElem(mw.e.body);
716 mw.scrollIntoView();
717 return mw;
718 };
719
720 /**
721 For use by the connection poller to send a "connection
722 restored" message.
723 */
724 cs.reportReconnection = function f(/*msg args*/){
725 const args = argsToArray(arguments).map(function(v){
726 return (v instanceof Error) ? v.message : v;
727 });
728 const d = new Date().toISOString(),
729 mw = new this.MessageWidget({
730 isError: false,
731 xfrom: undefined,
732 msgid: "reconnect-"+(++InternalMsgId),
733 mtime: d,
734 lmtime: d,
735 xmsg: args
736 });
737 this.injectMessageElem(mw.e.body);
738 mw.scrollIntoView();
739 //Chat.playNewMessageSound();// browser complains b/c this wasn't via human interaction
740 return mw;
741 };
742
743 cs.getMessageElemById = function(id){
744 return qs('[data-msgid="'+id+'"]');
745 };
@@ -690,24 +758,41 @@
758 /**
759 LOCALLY deletes a message element by the message ID or passing
760 the .message-row element. Returns true if it removes an element,
761 else false.
762 */
763 cs.deleteMessageElem = function(id, silent){
764 var e;
765 //console.warn("Chat.deleteMessageElem",id,silent);
766 if(id instanceof HTMLElement){
767 e = id;
768 id = e.dataset.msgid;
769 delete e.dataset.msgid;
770 if( e?.dataset?.alsoRemove ){
771 const xId = e.dataset.alsoRemove;
772 delete e.dataset.alsoRemove;
773 this.deleteMessageElem( xId );
774 }
775 }else if(e instanceof Chat.MessageWidget) {
776 if( this.e.eMsgPollError === e.body ){
777 this.e.eMsgPollError = undefined;
778 }
779 if(e.e.body){
780 this.deleteMessageElem(e.e.body);
781 }
782 return;
783 } else{
784 e = this.getMessageElemById(id);
785 }
786 if(e && id){
787 D.remove(e);
788 if(e===this.e.newestMessage){
789 this.fetchLastMessageElem();
790 }
791 if( !silent ){
792 F.toast.message("Deleted message "+id+".");
793 }
794 }
795 return !!e;
796 };
797
798 /**
@@ -776,10 +861,11 @@
861 const self = this;
862 F.fetch('chat-fetch-one',{
863 urlParams:{ name: id, raw: true},
864 responseType: 'json',
865 onload: function(msg){
866 reportConnectionReestablished('chat-fetch-one');
867 content.$elems[1] = D.append(D.pre(),msg.xmsg);
868 content.$elems[1]._xmsgRaw = msg.xmsg/*used for copy-to-clipboard feature*/;
869 self.toggleTextMode(e);
870 },
871 aftersend:function(){
@@ -837,11 +923,14 @@
923 if(!(e instanceof HTMLElement)) return;
924 if(this.userMayDelete(e)){
925 this.ajaxStart();
926 F.fetch("chat-delete/" + id, {
927 responseType: 'json',
928 onload:(r)=>{
929 reportConnectionReestablished('chat-delete');
930 this.deleteMessageElem(r);
931 },
932 onerror:(err)=>this.reportErrorAsMessage(err)
933 });
934 }else{
935 this.deleteMessageElem(id);
936 }
@@ -1035,10 +1124,11 @@
1124
1125 ctor.prototype = {
1126 scrollIntoView: function(){
1127 this.e.content.scrollIntoView();
1128 },
1129 //remove: function(silent){Chat.deleteMessageElem(this, silent);},
1130 setMessage: function(m){
1131 const ds = this.e.body.dataset;
1132 ds.timestamp = m.mtime;
1133 ds.lmtime = m.lmtime;
1134 ds.msgid = m.msgid;
@@ -1212,12 +1302,21 @@
1302 const btnDeleteLocal = D.button("Delete locally");
1303 D.append(toolbar, btnDeleteLocal);
1304 const self = this;
1305 btnDeleteLocal.addEventListener('click', function(){
1306 self.hide();
1307 Chat.deleteMessageElem(eMsg)
1308 });
1309 if( eMsg.classList.contains('poller-connection') ){
1310 const btnDeletePoll = D.button("Delete poller messages?");
1311 D.append(toolbar, btnDeletePoll);
1312 btnDeletePoll.addEventListener('click', function(){
1313 self.hide();
1314 Chat.e.viewMessages.querySelectorAll('.message-widget.poller-connection')
1315 .forEach(e=>Chat.deleteMessageElem(e, true));
1316 });
1317 }
1318 if(Chat.userMayDelete(eMsg)){
1319 const btnDeleteGlobal = D.button("Delete globally");
1320 D.append(toolbar, btnDeleteGlobal);
1321 F.confirmer(btnDeleteGlobal,{
1322 pinSize: true,
@@ -1457,10 +1556,11 @@
1556 n: nFetch,
1557 i: iFirst
1558 },
1559 responseType: "json",
1560 onload:function(jx){
1561 reportConnectionReestablished('chat-query.onload');
1562 if( bDown ) jx.msgs.reverse();
1563 jx.msgs.forEach((m) => {
1564 m.isSearchResult = true;
1565 var mw = new Chat.MessageWidget(m);
1566 if( bDown ){
@@ -1624,10 +1724,56 @@
1724 const theMsg = findMessageWidgetParent(w);
1725 if(theMsg) Chat.deleteMessageElem(theMsg);
1726 }));
1727 Chat.reportErrorAsMessage(w);
1728 };
1729
1730 /* Assume the connection has been established, reset the
1731 Chat.timer.tidReconnect, and (if showMsg and
1732 !!Chat.e.eMsgPollError) alert the user that the outage appears to
1733 be over. */
1734 const reportConnectionReestablished = function(dbgContext, showMsg = true){
1735 if(Chat.beVerbose){
1736 console.warn("reportConnectionReestablished()",
1737 dbgContext, showMsg, Chat.timer.tidReconnect, Chat.e.eMsgPollError);
1738 }
1739 if( Chat.timer.tidReconnect ){
1740 clearTimeout(Chat.timer.tidReconnect);
1741 Chat.timer.tidReconnect = 0;
1742 }
1743 Chat.timer.resetDelay();
1744 if( Chat.e.eMsgPollError ) {
1745 const oldErrMsg = Chat.e.eMsgPollError;
1746 Chat.e.eMsgPollError = undefined;
1747 if( showMsg ){
1748 const m = Chat.reportReconnection("Poller connection restored.");
1749 m.e.body.dataset.alsoRemove = oldErrMsg?.e?.body?.dataset?.msgid;
1750 D.addClass(m.e.body,'poller-connection');
1751 }
1752 }
1753 setTimeout( Chat.poll, 0 );
1754 };
1755
1756 /* To be called from F.fetch('chat-poll') beforesend() handlers. If we're
1757 currently in delayed-retry mode and a connection is started, try
1758 to reset the delay after N time waiting on that connection. The
1759 fact that the connection is waiting to respond, rather than
1760 outright failing, is a good hint that the outage is over and we
1761 can reset the back-off timer. */
1762 const clearPollErrOnWait = function(){
1763 if( !Chat.timer.tidReconnect && Chat.timer.isDelayed() ){
1764 Chat.timer.tidReconnect = setTimeout(()=>{
1765 Chat.timer.tidReconnect = 0;
1766 if( poll.running ){
1767 /* This chat-poll F.fetch() is still underway, so let's
1768 assume the connection is back up until/unless it times
1769 out or breaks again. */
1770 reportConnectionReestablished('clearPollErrOnWait');
1771 }
1772 }, Chat.timer.$initialDelay * 3 );
1773 }
1774 };
1775
1776 /**
1777 Submits the contents of the message input field (if not empty)
1778 and/or the file attachment field to the server. If both are
1779 empty, this is a no-op.
@@ -1686,10 +1832,11 @@
1832 onerror:function(err){
1833 self.reportErrorAsMessage(err);
1834 recoverFailedMessage(fallback);
1835 },
1836 onload:function(txt){
1837 reportConnectionReestablished();
1838 if(!txt) return/*success response*/;
1839 try{
1840 const json = JSON.parse(txt);
1841 self.newContent({msgs:[json]});
1842 }catch(e){
@@ -2185,10 +2332,11 @@
2332 /*filename needed for mimetype determination*/);
2333 fd.append('render_mode',F.page.previewModes.wiki);
2334 F.fetch('ajax/preview-text',{
2335 payload: fd,
2336 onload: function(html){
2337 reportConnectionReestablished();
2338 Chat.setPreviewText(html);
2339 F.pikchr.addSrcView(Chat.e.viewPreview.querySelectorAll('svg.pikchr'));
2340 },
2341 onerror: function(e){
2342 F.fetch.onerror(e);
@@ -2322,10 +2470,11 @@
2470 onerror:function(err){
2471 Chat.reportErrorAsMessage(err);
2472 Chat._isBatchLoading = false;
2473 },
2474 onload:function(x){
2475 reportConnectionReestablished();
2476 let gotMessages = x.msgs.length;
2477 newcontent(x,true);
2478 Chat._isBatchLoading = false;
2479 Chat.updateActiveUserList();
2480 if(Chat._gotServerError){
@@ -2411,10 +2560,11 @@
2560 onerror:function(err){
2561 Chat.setCurrentView(Chat.e.viewMessages);
2562 Chat.reportErrorAsMessage(err);
2563 },
2564 onload:function(jx){
2565 reportConnectionReestablished();
2566 let previd = 0;
2567 D.clearElement(eMsgTgt);
2568 jx.msgs.forEach((m)=>{
2569 m.isSearchResult = true;
2570 const mw = new Chat.MessageWidget(m);
@@ -2444,29 +2594,65 @@
2594 }
2595 }
2596 );
2597 }/*Chat.submitSearch()*/;
2598
2599 /**
2600 Deal with the last poll() response and maybe re-start poll().
2601 */
2602 const afterPollFetch = function f(err){
2603 if(true===f.isFirstCall){
2604 f.isFirstCall = false;
2605 Chat.ajaxEnd();
2606 Chat.e.viewMessages.classList.remove('loading');
2607 setTimeout(function(){
2608 Chat.scrollMessagesTo(1);
2609 }, 250);
2610 }
2611 if(Chat.timer.tidPoller) {
2612 clearTimeout(Chat.timer.tidPoller);
2613 Chat.timer.tidPoller = 0;
2614 }
2615 if(Chat._gotServerError){
2616 Chat.reportErrorAsMessage(
2617 "Shutting down chat poller due to server-side error. ",
2618 "Reload this page to reactivate it."
2619 );
2620 Chat.timer.tidPoller = undefined;
2621 } else {
2622 if( err && Chat.beVerbose ){
2623 console.error("afterPollFetch:",err.name,err.status,err.message);
2624 }
2625 if( !err || 'timeout'===err.name/*(probably) long-poll expired*/ ){
2626 /* Restart the poller immediately. */
2627 reportConnectionReestablished('afterPollFetch '+err, false);
2628 }else{
2629 /* Delay a while before trying again, noting that other Chat
2630 APIs may try and succeed at connections before this timer
2631 resolves, in which case they'll clear this timeout and the
2632 UI message about the outage. */
2633 const delay = Chat.timer.incrDelay();
2634 //console.warn("afterPollFetch Chat.e.eMsgPollError",Chat.e.eMsgPollError);
2635 const msg = "Poller connection error. Retrying in "+delay+ " ms.";
2636 if( Chat.e.eMsgPollError ){
2637 /* Update the error message on the current error MessageWidget */
2638 Chat.e.eMsgPollError.e.content.innerText = msg;
2639 }else {
2640 /* Set current (new) error MessageWidget */
2641 Chat.e.eMsgPollError = Chat.reportErrorAsMessage(msg);
2642 //Chat.playNewMessageSound();// browser complains b/c this wasn't via human interaction
2643 D.addClass(Chat.e.eMsgPollError.e.body,'poller-connection');
2644 }
2645 Chat.timer.tidPoller = setTimeout(()=>{
2646 poll();
2647 }, delay);
2648 }
2649 //console.log("isOkay =",isOkay,"currentDelay =",Chat.timer.currentDelay);
2650 }
 
2651 };
2652 afterPollFetch.isFirstCall = true;
2653
2654 /**
2655 FIXME: when polling fails because the remote server is
2656 reachable but it's not accepting HTTP requests, we should back
2657 off on polling for a while. e.g. if the remote web server process
2658 is killed, the poll fails quickly and immediately retries,
@@ -2478,53 +2664,96 @@
2664 xhrRequest.status value to do so, with status==0 being a
2665 connection error. We do not currently have a clean way of passing
2666 that info back to the fossil.fetch() client, so we'll need to
2667 hammer on that API a bit to get this working.
2668 */
2669 const poll = Chat.poll = async function f(){
2670 if(f.running) return;
2671 f.running = true;
2672 Chat._isBatchLoading = f.isFirstCall;
2673 if(true===f.isFirstCall){
2674 f.isFirstCall = false;
2675 Chat.aPollErr = [];
2676 Chat.ajaxStart();
2677 Chat.e.viewMessages.classList.add('loading');
2678 setInterval(
2679 /*
2680 We manager onerror() results in poll() using a
2681 stack of error objects and we delay their handling by
2682 a small amount, rather than immediately when the
2683 exception arrives.
2684
2685 This level of indirection is to work around an
2686 inexplicable behavior from the F.fetch() connections:
2687 timeouts are always announced in pairs of an HTTP 0 and
2688 something we can unambiguously identify as a timeout. When
2689 that happens, we ignore the HTTP 0. If, however, an HTTP 0
2690 is seen here without an immediately-following timeout, we
2691 process it.
2692
2693 It's kinda like in the curses C API, where you to match
2694 ALT-X by first getting an ALT event, then a separate X
2695 event, but a lot less explicable.
2696 */
2697 ()=>{
2698 if( Chat.aPollErr.length ){
2699 if(Chat.aPollErr.length>1){
2700 //console.warn('aPollErr',Chat.aPollErr);
2701 if(Chat.aPollErr[1].name='timeout'){
2702 /* mysterious pairs of HTTP 0 followed immediately
2703 by timeout response; ignore the former in that case. */
2704 Chat.aPollErr.shift();
2705 }
2706 }
2707 afterPollFetch(Chat.aPollErr.shift());
2708 }
2709 },
2710 1000
2711 );
2712 }
2713 let nErr = 0;
2714 F.fetch("chat-poll",{
2715 timeout: window.location.hostname.match(
2716 "localhost" /*presumably local dev mode*/
2717 ) ? 15000
2718 : 420 * 1000/*FIXME: get the value from the server*/,
2719 urlParams:{
2720 name: Chat.mxMsg
2721 },
2722 responseType: "json",
2723 // Disable the ajax start/end handling for this long-polling op:
2724 beforesend: function(){
2725 clearPollErrOnWait();
2726 },
2727 aftersend: function(){
2728 poll.running = false;
2729 },
2730 onerror:function(err){
2731 Chat._isBatchLoading = false;
2732 if(Chat.beVerbose){
2733 console.error("poll.onerror:",err.name,err.status,JSON.stringify(err));
2734 }
2735 Chat.aPollErr.push(err);
 
2736 },
2737 onload:function(y){
2738 reportConnectionReestablished('poll.onload', true);
2739 newcontent(y);
2740 if(Chat._isBatchLoading){
2741 Chat._isBatchLoading = false;
2742 Chat.updateActiveUserList();
2743 }
2744 afterPollFetch();
2745 }
2746 });
2747 };
2748 poll.isFirstCall = true;
2749 Chat._gotServerError = poll.running = false;
2750 if( window.fossil.config.chat.fromcli ){
2751 Chat.chatOnlyMode(true);
2752 }
2753 Chat.timer.tidPoller = setTimeout(poll, Chat.timer.resetDelay());
2754 delete ForceResizeKludge.$disabled;
2755 ForceResizeKludge();
2756 Chat.animate.$disabled = false;
2757 setTimeout( ()=>Chat.inputFocus(), 0 );
2758 F.page.chat = Chat/* enables testing the APIs via the dev tools */;
2759 });
2760
--- src/fossil.popupwidget.js
+++ src/fossil.popupwidget.js
@@ -286,11 +286,11 @@
286286
};
287287
288288
F.toast = {
289289
config: {
290290
position: { x: 5, y: 5 /*viewport-relative, pixels*/ },
291
- displayTimeMs: 3000
291
+ displayTimeMs: 5000
292292
},
293293
/**
294294
Convenience wrapper around a PopupWidget which pops up a shared
295295
PopupWidget instance to show toast-style messages (commonly
296296
seen on Android). Its arguments may be anything suitable for
297297
--- src/fossil.popupwidget.js
+++ src/fossil.popupwidget.js
@@ -286,11 +286,11 @@
286 };
287
288 F.toast = {
289 config: {
290 position: { x: 5, y: 5 /*viewport-relative, pixels*/ },
291 displayTimeMs: 3000
292 },
293 /**
294 Convenience wrapper around a PopupWidget which pops up a shared
295 PopupWidget instance to show toast-style messages (commonly
296 seen on Android). Its arguments may be anything suitable for
297
--- src/fossil.popupwidget.js
+++ src/fossil.popupwidget.js
@@ -286,11 +286,11 @@
286 };
287
288 F.toast = {
289 config: {
290 position: { x: 5, y: 5 /*viewport-relative, pixels*/ },
291 displayTimeMs: 5000
292 },
293 /**
294 Convenience wrapper around a PopupWidget which pops up a shared
295 PopupWidget instance to show toast-style messages (commonly
296 seen on Android). Its arguments may be anything suitable for
297

Keyboard Shortcuts

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