Fossil SCM

fossil-scm / src / fossil.page.chat.js
Blame History Raw 2895 lines
1
-/**
2
This file contains the client-side implementation of fossil's /chat
3
application.
4
*/
5
window.fossil.onPageLoad(function(){
6
const F = window.fossil, D = F.dom;
7
const E1 = function(selector){
8
const e = document.querySelector(selector);
9
if(!e) throw new Error("missing required DOM element: "+selector);
10
return e;
11
};
12
13
/**
14
Returns true if e is entirely within the bounds of the window's viewport.
15
*/
16
const isEntirelyInViewport = function(e) {
17
const rect = e.getBoundingClientRect();
18
return (
19
rect.top >= 0 &&
20
rect.left >= 0 &&
21
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
22
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
23
);
24
};
25
26
/**
27
Returns true if e's on-screen position vertically overlaps that
28
of element v's. Horizontal overlap is ignored (we don't need it
29
for our case).
30
*/
31
const overlapsElemView = function(e,v) {
32
const r1 = e.getBoundingClientRect(),
33
r2 = v.getBoundingClientRect();
34
if(r1.top<=r2.bottom && r1.top>=r2.top) return true;
35
else if(r1.bottom<=r2.bottom && r1.bottom>=r2.top) return true;
36
return false;
37
};
38
39
const addAnchorTargetBlank = (e)=>D.attr(e, 'target','_blank');
40
41
/**
42
Returns an almost-ISO8601 form of Date object d.
43
*/
44
const iso8601ish = function(d){
45
return d.toISOString()
46
.replace('T',' ').replace(/\.\d+/,'')
47
.replace('Z', ' zulu');
48
};
49
const pad2 = (x)=>('0'+x).substr(-2);
50
/** Returns the local time string of Date object d, defaulting
51
to the current time. */
52
const localTimeString = function ff(d){
53
d || (d = new Date());
54
return [
55
d.getFullYear(),'-',pad2(d.getMonth()+1/*sigh*/),
56
'-',pad2(d.getDate()),
57
' ',pad2(d.getHours()),':',pad2(d.getMinutes()),
58
':',pad2(d.getSeconds())
59
].join('');
60
};
61
62
(function(){
63
let dbg = document.querySelector('#debugMsg');
64
if(dbg){
65
/* This can inadvertently influence our flexbox layouts, so move
66
it out of the way. */
67
D.append(document.body,dbg);
68
}
69
})();
70
/* Returns a list of DOM elements which "frame" the chat UI. These
71
elements are considered to _not_ be part of the chat UI and that
72
info is used for sizing the chat UI. In chat-only mode, these are
73
the elements that get hidden. */
74
const GetFramingElements = function() {
75
return document.querySelectorAll([
76
"body > header",
77
"body > nav.mainmenu",
78
"body > footer",
79
"#debugMsg"
80
].join(','));
81
};
82
const ForceResizeKludge = (function(){
83
/* Workaround for Safari mayhem regarding use of vh CSS units....
84
We tried to use vh units to set the content area size for the
85
chat layout, but Safari chokes on that, so we calculate that
86
height here: 85% when in "normal" mode and 95% in chat-only
87
mode. Larger than ~95% is too big for Firefox on Android,
88
causing the input area to move off-screen.
89
90
While we're here, we also use this to cap the max-height
91
of the input field so that pasting huge text does not scroll
92
the upper area of the input widget off-screen. */
93
const elemsToCount = GetFramingElements();
94
const contentArea = E1('div.content');
95
const bcl = document.body.classList;
96
const resized = function f(){
97
if(f.$disabled) return;
98
const wh = window.innerHeight,
99
com = bcl.contains('chat-only-mode');
100
var ht;
101
var extra = 0;
102
if(com){
103
ht = wh;
104
}else{
105
elemsToCount.forEach((e)=>e ? extra += D.effectiveHeight(e) : false);
106
ht = wh - extra;
107
}
108
f.chat.e.inputX.style.maxHeight = (ht/2)+"px";
109
/* ^^^^ this is a middle ground between having no size cap
110
on the input field and having a fixed arbitrary cap. */;
111
contentArea.style.height =
112
contentArea.style.maxHeight = [
113
"calc(", (ht>=100 ? ht : 100), "px",
114
" - 0.65em"/*fudge value*/,")"
115
/* ^^^^ hypothetically not needed, but both Chrome/FF on
116
Linux will force scrollbars on the body if this value is
117
too small; current value is empirically selected. */
118
].join('');
119
if(false){
120
console.debug("resized.",wh, extra, ht,
121
window.getComputedStyle(contentArea).maxHeight,
122
contentArea);
123
console.debug("Set input max height to: ",
124
f.chat.e.inputX.style.maxHeight);
125
}
126
};
127
resized.$disabled = true/*gets deleted when setup is finished*/;
128
window.addEventListener('resize', F.debounce(resized, 250), false);
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
135
//!!window.location.hostname.match("localhost")
136
/* if true then certain, mostly extraneous, error messages and
137
log messages may be sent to the console. */,
138
playedBeep: false /* used for the beep-once setting */,
139
e:{/*map of certain DOM elements.*/
140
messageInjectPoint: E1('#message-inject-point'),
141
pageTitle: E1('head title'),
142
loadOlderToolbar: undefined /* the load-posts toolbar (dynamically created) */,
143
inputArea: E1("#chat-input-area"),
144
inputLineWrapper: E1('#chat-input-line-wrapper'),
145
fileSelectWrapper: E1('#chat-input-file-area'),
146
viewMessages: E1('#chat-messages-wrapper'),
147
viewZoom: E1('#chat-zoom'),
148
zoomContent: E1('#chat-zoom-content'),
149
zoomMarker: E1('#chat-zoom-marker'),
150
btnSubmit: E1('#chat-button-submit'),
151
btnAttach: E1('#chat-button-attach'),
152
inputX: E1('#chat-input-field-x'),
153
input1: E1('#chat-input-field-single'),
154
inputM: E1('#chat-input-field-multi'),
155
inputFile: E1('#chat-input-file'),
156
contentDiv: E1('div.content'),
157
viewConfig: E1('#chat-config'),
158
viewPreview: E1('#chat-preview'),
159
previewContent: E1('#chat-preview-content'),
160
viewSearch: E1('#chat-search'),
161
searchContent: E1('#chat-search-content'),
162
btnPreview: E1('#chat-button-preview'),
163
views: document.querySelectorAll('.chat-view'),
164
activeUserListWrapper: E1('#chat-user-list-wrapper'),
165
activeUserList: E1('#chat-user-list'),
166
eMsgPollError: undefined /* current connection error MessageMidget */,
167
pollErrorMarker: document.body /* element to toggle 'connection-error' CSS class on */
168
},
169
me: F.user.name,
170
mxMsg: F.config.chat.initSize ? -F.config.chat.initSize : -50,
171
mnMsg: undefined/*lowest message ID we've seen so far (for history loading)*/,
172
pageIsActive: 'visible'===document.visibilityState,
173
changesSincePageHidden: 0,
174
notificationBubbleColor: 'white',
175
totalMessageCount: 0, // total # of inbound messages
176
//! Number of messages to load for the history buttons
177
loadMessageCount: Math.abs(F.config.chat.initSize || 20),
178
ajaxInflight: 0,
179
usersLastSeen:{
180
/* Map of user names to their most recent message time
181
(JS Date object). Only messages received by the chat client
182
are considered. */
183
/* Reminder: to convert a Julian time J to JS:
184
new Date((J - 2440587.5) * 86400000) */
185
},
186
filterState:{
187
activeUser: undefined,
188
match: function(uname){
189
return this.activeUser===uname || !this.activeUser;
190
}
191
},
192
/**
193
The timer object is used to control connection throttling
194
when connection errors arrise. It starts off with a polling
195
delay of $initialDelay ms. If there's a connection error,
196
that gets bumped by some value for each subsequent error, up
197
to some max value.
198
199
The timing of resetting the delay when service returns is,
200
because of the long-poll connection and our lack of low-level
201
insight into the connection at this level, a bit wonky.
202
*/
203
timer:{
204
/* setTimeout() ID for (delayed) starting a Chat.poll(), so
205
that it runs at controlled intervals (which change when a
206
connection drops and recovers). */
207
tidPendingPoll: undefined,
208
tidClearPollErr: undefined /*setTimeout() timer id for
209
reconnection determination. See
210
clearPollErrOnWait(). */,
211
$initialDelay: 1000 /* initial polling interval (ms) */,
212
currentDelay: 1000 /* current polling interval */,
213
maxDelay: 60000 * 5 /* max interval when backing off for
214
connection errors */,
215
minDelay: 5000 /* minimum delay time for a back-off/retry
216
attempt. */,
217
errCount: 0 /* Current poller connection error count */,
218
minErrForNotify: 4 /* Don't warn for connection errors until this
219
many have occurred */,
220
pollTimeout: (1 && window.location.hostname.match(
221
"localhost" /*presumably local dev mode*/
222
)) ? 15000
223
: (+F.config.chat.pollTimeout>0
224
? (1000 * (F.config.chat.pollTimeout - Math.floor(F.config.chat.pollTimeout * 0.1)))
225
/* ^^^^^^^^^^^^ we want our timeouts to be slightly shorter
226
than the server's so that we can distingished timed-out
227
polls on our end from HTTP errors (if the server times
228
out). */
229
: 30000),
230
/** Returns a random fudge value for reconnect attempt times,
231
intended to keep the /chat server from getting hammered if
232
all clients which were just disconnected all reconnect at
233
the same instant. */
234
randomInterval: function(factor){
235
return Math.floor(Math.random() * factor);
236
},
237
/** Increments the reconnection delay, within some min/max range. */
238
incrDelay: function(){
239
if( this.maxDelay > this.currentDelay ){
240
if(this.currentDelay < this.minDelay){
241
this.currentDelay = this.minDelay + this.randomInterval(this.minDelay);
242
}else{
243
this.currentDelay = this.currentDelay*2 + this.randomInterval(this.currentDelay);
244
}
245
}
246
return this.currentDelay;
247
},
248
/** Resets the delay counter to v || its initial value. */
249
resetDelay: function(ms=0){
250
return this.currentDelay = ms || this.$initialDelay;
251
},
252
/** Returns true if the timer is set to delayed mode. */
253
isDelayed: function(){
254
return (this.currentDelay > this.$initialDelay) ? this.currentDelay : 0;
255
},
256
/**
257
Cancels any in-progress pending-poll timer and starts a new
258
one with the given delay, defaulting to this.resetDelay().
259
*/
260
startPendingPollTimer: function(delay){
261
this.cancelPendingPollTimer().tidPendingPoll
262
= setTimeout( Chat.poll, delay || Chat.timer.resetDelay() );
263
return this;
264
},
265
/**
266
Cancels any still-active timer set to trigger the next
267
Chat.poll().
268
*/
269
cancelPendingPollTimer: function(){
270
if( this.tidPendingPoll ){
271
clearTimeout(this.tidPendingPoll);
272
this.tidPendingPoll = 0;
273
}
274
return this;
275
},
276
/**
277
Cancels any pending reconnection attempt back-off timer..
278
*/
279
cancelReconnectCheckTimer: function(){
280
if( this.tidClearPollErr ){
281
clearTimeout(this.tidClearPollErr);
282
this.tidClearPollErr = 0;
283
}
284
return this;
285
}
286
},
287
/**
288
Gets (no args) or sets (1 arg) the current input text field
289
value, taking into account single- vs multi-line input. The
290
getter returns a trim()'d string and the setter returns this
291
object. As a special case, if arguments[0] is a boolean
292
value, it behaves like a getter and, if arguments[0]===true
293
it clears the input field before returning.
294
*/
295
inputValue: function(/*string newValue | bool clearInputField*/){
296
const e = this.inputElement();
297
if(arguments.length && 'boolean'!==typeof arguments[0]){
298
if(e.isContentEditable) e.innerText = arguments[0];
299
else e.value = arguments[0];
300
return this;
301
}
302
const rc = e.isContentEditable ? e.innerText : e.value;
303
if( true===arguments[0] ){
304
if(e.isContentEditable) e.innerText = '';
305
else e.value = '';
306
}
307
return rc && rc.trim();
308
},
309
/** Asks the current user input field to take focus. Returns this. */
310
inputFocus: function(){
311
this.inputElement().focus();
312
return this;
313
},
314
/** Returns the current message input element. */
315
inputElement: function(){
316
return this.e.inputFields[this.e.inputFields.$currentIndex];
317
},
318
/** Enables (if yes is truthy) or disables all elements in
319
* this.disableDuringAjax. */
320
enableAjaxComponents: function(yes){
321
D[yes ? 'enable' : 'disable'](this.disableDuringAjax);
322
return this;
323
},
324
/* Must be called before any API is used which starts ajax traffic.
325
If this call represents the currently only in-flight ajax request,
326
all DOM elements in this.disableDuringAjax are disabled.
327
We cannot do this via a central API because (1) window.fetch()'s
328
Promise-based API seemingly makes that impossible and (2) the polling
329
technique holds ajax requests open for as long as possible. A call
330
to this method obligates the caller to also call ajaxEnd().
331
332
This must NOT be called for the chat-polling API except, as a
333
special exception, the very first one which fetches the
334
initial message list.
335
*/
336
ajaxStart: function(){
337
if(1===++this.ajaxInflight){
338
this.enableAjaxComponents(false);
339
}
340
},
341
/* Must be called after any ajax-related call for which
342
ajaxStart() was called, regardless of success or failure. If
343
it was the last such call (as measured by calls to
344
ajaxStart() and ajaxEnd()), elements disabled by a prior call
345
to ajaxStart() will be re-enabled. */
346
ajaxEnd: function(){
347
if(0===--this.ajaxInflight){
348
this.enableAjaxComponents(true);
349
}
350
},
351
disableDuringAjax: [
352
/* List of DOM elements disable while ajax traffic is in
353
transit. Must be populated before ajax starts. We do this
354
to avoid various race conditions in the UI and long-running
355
network requests. */
356
],
357
/** Either scrolls .message-widget element eMsg into view
358
immediately or, if it represents an inlined image, delays
359
the scroll until the image is loaded, at which point it will
360
scroll to either the newest message, if one is set or to
361
eMsg (the liklihood is good, at least on initial page load,
362
that the image won't be loaded until other messages have
363
been injected). */
364
scheduleScrollOfMsg: function(eMsg){
365
if(1===+eMsg.dataset.hasImage){
366
eMsg.querySelector('img').addEventListener(
367
'load', ()=>(this.e.newestMessage || eMsg).scrollIntoView(false)
368
);
369
}else{
370
eMsg.scrollIntoView(false);
371
}
372
return this;
373
},
374
/* Injects DOM element e as a new row in the chat, at the oldest
375
end of the list if atEnd is truthy, else at the newest end of
376
the list. */
377
injectMessageElem: function f(e, atEnd){
378
const mip = atEnd ? this.e.loadOlderToolbar : this.e.messageInjectPoint,
379
holder = this.e.viewMessages,
380
prevMessage = this.e.newestMessage;
381
if(!this.filterState.match(e.dataset.xfrom)){
382
e.classList.add('hidden');
383
}
384
if(atEnd){
385
const fe = mip.nextElementSibling;
386
if(fe) mip.parentNode.insertBefore(e, fe);
387
else D.append(mip.parentNode, e);
388
}else{
389
D.append(holder,e);
390
this.e.newestMessage = e;
391
}
392
if(!atEnd && !this._isBatchLoading
393
&& e.dataset.xfrom!==this.me
394
&& (prevMessage
395
? !this.messageIsInView(prevMessage)
396
: false)){
397
/* If a new non-history message arrives while the user is
398
scrolled elsewhere, do not scroll to the latest
399
message, but gently alert the user that a new message
400
has arrived. */
401
if(!f.btnDown){
402
f.btnDown = D.button("⇣⇣⇣");
403
f.btnDown.addEventListener('click',()=>this.scrollMessagesTo(1),false);
404
}
405
F.toast.message(f.btnDown," New message has arrived.");
406
}else if(!this._isBatchLoading && e.dataset.xfrom===Chat.me){
407
this.scheduleScrollOfMsg(e);
408
}else if(!this._isBatchLoading){
409
/* When a message from someone else arrives, we have to
410
figure out whether or not to scroll it into view. Ideally
411
we'd just stuff it in the UI and let the flexbox layout
412
DTRT, but Safari has expressed, in no uncertain terms,
413
some disappointment with that approach, so we'll
414
heuristicize it: if the previous last message is in view,
415
assume the user is at or near the input element and
416
scroll this one into view. If that message is NOT in
417
view, assume the user is up reading history somewhere and
418
do NOT scroll because doing so would interrupt
419
them. There are middle grounds here where the user will
420
experience a slight UI jolt, but this heuristic mostly
421
seems to work out okay. If there was no previous message,
422
assume we don't have any messages yet and go ahead and
423
scroll this message into view (noting that that scrolling
424
is hypothetically a no-op in such cases).
425
426
The wrench in these works are posts with IMG tags, as
427
those images are loaded async and the element does not
428
yet have enough information to know how far to scroll!
429
For such cases we have to delay the scroll until the
430
image loads (and we hope it does so before another
431
message arrives).
432
*/
433
if(1===+e.dataset.hasImage){
434
e.querySelector('img').addEventListener('load',()=>e.scrollIntoView());
435
}else if(!prevMessage || (prevMessage && isEntirelyInViewport(prevMessage))){
436
e.scrollIntoView(false);
437
}
438
}
439
},
440
/** Returns true if chat-only mode is enabled. */
441
isChatOnlyMode: ()=>document.body.classList.contains('chat-only-mode'),
442
/**
443
Enters (if passed a truthy value or no arguments) or leaves
444
"chat-only" mode. That mode hides the page's header and
445
footer, leaving only the chat application visible to the
446
user.
447
*/
448
chatOnlyMode: function f(yes){
449
if(undefined === f.elemsToToggle){
450
f.elemsToToggle = [];
451
GetFramingElements().forEach((e)=>f.elemsToToggle.push(e));
452
}
453
if(!arguments.length) yes = true;
454
if(yes === this.isChatOnlyMode()) return this;
455
if(yes){
456
D.addClass(f.elemsToToggle, 'hidden');
457
D.addClass(document.body, 'chat-only-mode');
458
document.body.scroll(0,document.body.height);
459
}else{
460
D.removeClass(f.elemsToToggle, 'hidden');
461
D.removeClass(document.body, 'chat-only-mode');
462
}
463
ForceResizeKludge();
464
return this;
465
},
466
/** Tries to scroll the message area to...
467
<0 = top of the message list, >0 = bottom of the message list,
468
0 == the newest message (normally the same position as >1).
469
*/
470
scrollMessagesTo: function(where){
471
if(where<0){
472
Chat.e.viewMessages.scrollTop = 0;
473
}else if(where>0){
474
Chat.e.viewMessages.scrollTop = Chat.e.viewMessages.scrollHeight;
475
}else if(Chat.e.newestMessage){
476
Chat.e.newestMessage.scrollIntoView(false);
477
}
478
},
479
toggleChatOnlyMode: function(){
480
return this.chatOnlyMode(!this.isChatOnlyMode());
481
},
482
messageIsInView: function(e){
483
return e ? overlapsElemView(e, this.e.viewMessages) : false;
484
},
485
settings:{
486
get: (k,dflt)=>F.storage.get(k,dflt),
487
getBool: (k,dflt)=>F.storage.getBool(k,dflt),
488
set: function(k,v){
489
F.storage.set(k,v);
490
F.page.dispatchEvent('chat-setting',{key: k, value: v});
491
},
492
/* Toggles the boolean setting specified by k. Returns the
493
new value.*/
494
toggle: function(k){
495
const v = this.getBool(k);
496
this.set(k, !v);
497
return !v;
498
},
499
addListener: function(setting, f){
500
F.page.addEventListener('chat-setting', function(ev){
501
if(ev.detail.key===setting) f(ev.detail);
502
}, false);
503
},
504
/* Default values of settings. These are used for initializing
505
the setting event listeners and config view UI. */
506
defaults:{
507
/* When on, inbound images are displayed inlined, else as a
508
link to download the image. */
509
"images-inline": !!F.config.chat.imagesInline,
510
/* When on, ctrl-enter sends messages, else enter and
511
ctrl-enter both send them. */
512
"edit-ctrl-send": false,
513
/* When on, the edit field starts as a single line and
514
expands as the user types, and the relevant buttons are
515
laid out in a compact form. When off, the edit field and
516
buttons are larger. */
517
"edit-compact-mode": true,
518
/* See notes for this setting in fossil.page.wikiedit.js.
519
Both /wikiedit and /fileedit share this persistent config
520
option under the same storage key. */
521
"edit-shift-enter-preview":
522
F.storage.getBool('edit-shift-enter-preview', true),
523
/* When on, sets the font-family on messages and the edit
524
field to monospace. */
525
"monospace-messages": false,
526
/* When on, non-chat UI elements (page header/footer) are
527
hidden */
528
"chat-only-mode": false,
529
/* When set to a URI, it is assumed to be an audio file,
530
which gets played when new messages arrive. When true,
531
the first entry in the audio file selection list will be
532
used. */
533
"audible-alert": true,
534
/*
535
*/
536
"beep-once": false,
537
/* When on, show the list of "active" users - those from
538
whom we have messages in the currently-loaded history
539
(noting that deletions are also messages). */
540
"active-user-list": false,
541
/* When on, the [active-user-list] setting includes the
542
timestamp of each user's most recent message. */
543
"active-user-list-timestamps": false,
544
/* When on, the [audible-alert] is played for one's own
545
messages, else it is only played for other users'
546
messages. */
547
"alert-own-messages": false,
548
/* "Experimental mode" input: use a contenteditable field
549
for input. This is generally more comfortable to use,
550
and more modern, than plain text input fields, but
551
the list of browser-specific quirks and bugs is...
552
not short. */
553
"edit-widget-x": false
554
}
555
},
556
/** Plays a new-message notification sound IF the audible-alert
557
setting is true, else this is a no-op. Returns this.
558
*/
559
playNewMessageSound: function f(){
560
if(f.uri){
561
if(!cs.pageIsActive
562
/* ^^^ this could also arguably apply when chat is visible */
563
&& this.playedBeep && this.settings.getBool('beep-once',false)){
564
return;
565
}
566
try{
567
this.playedBeep = true;
568
if(!f.audio) f.audio = new Audio(f.uri);
569
f.audio.currentTime = 0;
570
f.audio.play();
571
}catch(e){
572
console.error("Audio playblack failed.", f.uri, e);
573
}
574
}
575
return this;
576
},
577
/**
578
Sets the current new-message audio alert URI (must be a
579
repository-relative path which responds with an audio
580
file). Pass a falsy value to disable audio alerts. Returns
581
this.
582
*/
583
setNewMessageSound: function f(uri){
584
this.playedBeep = false;
585
delete this.playNewMessageSound.audio;
586
this.playNewMessageSound.uri = uri;
587
this.settings.set('audible-alert', uri);
588
return this;
589
},
590
/**
591
Expects e to be one of the elements in this.e.views.
592
The 'hidden' class is removed from e and added to
593
all other elements in that list. Returns e.
594
*/
595
setCurrentView: function(e){
596
if(e===this.e.currentView){
597
return e;
598
}
599
if( e!==this.e.viewZoom && this.e.zoomedMsg ){
600
this.zoomMessage(null, e);
601
return this.e.currentView;
602
}
603
this.e.views.forEach(function(E){
604
if(e!==E) D.addClass(E,'hidden');
605
});
606
this.e.currentView = e;
607
if(this.e.currentView.$beforeShow) this.e.currentView.$beforeShow();
608
D.removeClass(e,'hidden');
609
this.animate(this.e.currentView, 'anim-fade-in-fast');
610
return this.e.currentView;
611
},
612
613
/**
614
Makes message element eMsg the content of this.e.viewZoom.
615
*/
616
zoomMessage: function(eMsg,nextView){
617
const marker = this.e.zoomMarker;
618
if( !eMsg || eMsg===this.e.zoomedMsg ){
619
if( this.e.zoomedMsg ){
620
marker.parentNode.insertBefore(this.e.zoomedMsg, marker);
621
delete this.e.zoomedMsg;
622
}
623
this.setCurrentView(nextView || this.e.viewMessages);
624
return;
625
}
626
console.log("zoom message",eMsg);
627
if( this.e.zoomedMsg ){
628
marker.parentNode.insertBefore(this.e.zoomedMsg, marker);
629
}
630
this.e.viewMessages.insertBefore(marker, eMsg);
631
this.e.zoomContent.appendChild(eMsg);
632
this.e.zoomedMsg = eMsg;
633
this.setCurrentView(this.e.viewZoom);
634
},
635
/**
636
Updates the "active user list" view if we are not currently
637
batch-loading messages and if the active user list UI element
638
is active.
639
*/
640
updateActiveUserList: function callee(){
641
if(this._isBatchLoading
642
|| this.e.activeUserListWrapper.classList.contains('hidden')){
643
return this;
644
}else if(!callee.sortUsersSeen){
645
/** Array.sort() callback. Expects an array of user names and
646
sorts them in last-received message order (newest first). */
647
const self = this;
648
callee.sortUsersSeen = function(l,r){
649
l = self.usersLastSeen[l];
650
r = self.usersLastSeen[r];
651
if(l && r) return r - l;
652
else if(l) return -1;
653
else if(r) return 1;
654
else return 0;
655
};
656
callee.addUserElem = function(u){
657
const uSpan = D.addClass(D.span(), 'chat-user');
658
const uDate = self.usersLastSeen[u];
659
if(self.filterState.activeUser===u){
660
uSpan.classList.add('selected');
661
}
662
uSpan.dataset.uname = u;
663
D.append(uSpan, u, "\n",
664
D.append(
665
D.addClass(D.span(),'timestamp'),
666
localTimeString(uDate)//.substr(5/*chop off year*/)
667
));
668
if(uDate.$uColor){
669
uSpan.style.backgroundColor = uDate.$uColor;
670
}
671
D.append(self.e.activeUserList, uSpan);
672
};
673
}
674
//D.clearElement(this.e.activeUserList);
675
D.remove(this.e.activeUserList.querySelectorAll('.chat-user'));
676
Object.keys(this.usersLastSeen).sort(
677
callee.sortUsersSeen
678
).forEach(callee.addUserElem);
679
return this;
680
},
681
/** Show or hide the active user list. Returns this object. */
682
showActiveUserList: function(yes){
683
if(0===arguments.length) yes = true;
684
this.e.activeUserListWrapper.classList[
685
yes ? 'remove' : 'add'
686
]('hidden');
687
D.removeClass(Chat.e.activeUserListWrapper, 'collapsed');
688
if(Chat.e.activeUserListWrapper.classList.contains('hidden')){
689
/* When hiding this element, undo all filtering */
690
Chat.setUserFilter(false);
691
/*Ideally we'd scroll the final message into view
692
now, but because viewMessages is currently hidden behind
693
viewConfig, scrolling is a no-op. */
694
Chat.scrollMessagesTo(1);
695
}else{
696
Chat.updateActiveUserList();
697
Chat.animate(Chat.e.activeUserListWrapper, 'anim-flip-v');
698
}
699
return this;
700
},
701
showActiveUserTimestamps: function(yes){
702
if(0===arguments.length) yes = true;
703
this.e.activeUserList.classList[yes ? 'add' : 'remove']('timestamps');
704
return this;
705
},
706
/**
707
Applies user name filter to all current messages, or clears
708
the filter if uname is falsy.
709
*/
710
setUserFilter: function(uname){
711
this.filterState.activeUser = uname;
712
const mw = this.e.viewMessages.querySelectorAll('.message-widget');
713
const self = this;
714
let eLast;
715
if(!uname){
716
D.removeClass(Chat.e.viewMessages.querySelectorAll('.message-widget.hidden'),
717
'hidden');
718
}else{
719
mw.forEach(function(w){
720
if(self.filterState.match(w.dataset.xfrom)){
721
w.classList.remove('hidden');
722
eLast = w;
723
}else{
724
w.classList.add('hidden');
725
}
726
});
727
}
728
if(eLast) eLast.scrollIntoView(false);
729
else this.scrollMessagesTo(1);
730
cs.e.activeUserList.querySelectorAll('.chat-user').forEach(function(e){
731
e.classList[uname===e.dataset.uname ? 'add' : 'remove']('selected');
732
});
733
return this;
734
},
735
736
/**
737
If animations are enabled, passes its arguments
738
to D.addClassBriefly(), else this is a no-op.
739
If cb is a function, it is called after the
740
CSS class is removed. Returns this object;
741
*/
742
animate: function f(e,a,cb){
743
if(!f.$disabled){
744
D.addClassBriefly(e, a, 0, cb);
745
}
746
return this;
747
}
748
}/*Chat object*/;
749
cs.e.inputFields = [ cs.e.input1, cs.e.inputM, cs.e.inputX ];
750
cs.e.inputFields.$currentIndex = 0;
751
cs.e.inputFields.forEach(function(e,ndx){
752
if(ndx===cs.e.inputFields.$currentIndex) D.removeClass(e,'hidden');
753
else D.addClass(e,'hidden');
754
});
755
if(D.attr(cs.e.inputX,'contenteditable','plaintext-only').isContentEditable){
756
cs.$browserHasPlaintextOnly = true;
757
}else{
758
/* contenteditable="plaintext-only" is a latecomer, not
759
supported in FF until version 136. */
760
cs.$browserHasPlaintextOnly = false;
761
D.attr(cs.e.inputX,'contenteditable','true');
762
}
763
cs.animate.$disabled = true;
764
F.fetch.beforesend = ()=>cs.ajaxStart();
765
F.fetch.aftersend = ()=>cs.ajaxEnd();
766
cs.pageTitleOrig = cs.e.pageTitle.innerText;
767
const qs = (e)=>document.querySelector(e);
768
const argsToArray = function(args){
769
return Array.prototype.slice.call(args,0);
770
};
771
/**
772
Reports an error via console.error() and as a toast message.
773
Accepts any argument types valid for fossil.toast.error().
774
*/
775
cs.reportError = function(/*msg args*/){
776
const args = argsToArray(arguments);
777
console.error("chat error:",args);
778
F.toast.error.apply(F.toast, args);
779
};
780
781
let InternalMsgId = 0;
782
/**
783
Reports an error in the form of a new message in the chat
784
feed. All arguments are appended to the message's content area
785
using fossil.dom.append(), so may be of any type supported by
786
that function.
787
*/
788
cs.reportErrorAsMessage = function f(/*msg args*/){
789
const args = argsToArray(arguments).map(function(v){
790
return (v instanceof Error) ? v.message : v;
791
});
792
if(Chat.beVerbose){
793
console.error("chat error:",args);
794
}
795
const d = new Date().toISOString(),
796
mw = new this.MessageWidget({
797
isError: true,
798
xfrom: undefined,
799
msgid: "error-"+(++InternalMsgId),
800
mtime: d,
801
lmtime: d,
802
xmsg: args
803
});
804
this.injectMessageElem(mw.e.body);
805
mw.scrollIntoView();
806
return mw;
807
};
808
809
/**
810
For use by the connection poller to send a "connection
811
restored" message.
812
*/
813
cs.reportReconnection = function f(/*msg args*/){
814
const args = argsToArray(arguments).map(function(v){
815
return (v instanceof Error) ? v.message : v;
816
});
817
const d = new Date().toISOString(),
818
mw = new this.MessageWidget({
819
isError: false,
820
xfrom: undefined,
821
msgid: "reconnect-"+(++InternalMsgId),
822
mtime: d,
823
lmtime: d,
824
xmsg: args
825
});
826
this.injectMessageElem(mw.e.body);
827
mw.scrollIntoView();
828
return mw;
829
};
830
831
cs.getMessageElemById = function(id){
832
return qs('[data-msgid="'+id+'"]');
833
};
834
835
/** Finds the last .message-widget element and returns it or
836
the undefined value if none are found. */
837
cs.fetchLastMessageElem = function(){
838
const msgs = document.querySelectorAll('.message-widget');
839
var rc;
840
if(msgs.length){
841
rc = this.e.newestMessage = msgs[msgs.length-1];
842
}
843
return rc;
844
};
845
846
/**
847
LOCALLY deletes a message element by the message ID or passing
848
the .message-row element. Returns true if it removes an element,
849
else false.
850
*/
851
cs.deleteMessageElem = function(id, silent){
852
var e;
853
if(id instanceof HTMLElement){
854
e = id;
855
id = e.dataset.msgid;
856
delete e.dataset.msgid;
857
if( e?.dataset?.alsoRemove ){
858
const xId = e.dataset.alsoRemove;
859
delete e.dataset.alsoRemove;
860
this.deleteMessageElem( xId );
861
}
862
}else if(id instanceof Chat.MessageWidget) {
863
if( this.e.eMsgPollError === e ){
864
this.e.eMsgPollError = undefined;
865
}
866
if(id.e?.body){
867
this.deleteMessageElem(id.e.body);
868
}
869
return;
870
} else{
871
e = this.getMessageElemById(id);
872
}
873
if(e && id){
874
D.remove(e);
875
if(e===this.e.newestMessage){
876
this.fetchLastMessageElem();
877
}
878
if( !silent ){
879
F.toast.message("Deleted message "+id+".");
880
}
881
}
882
return !!e;
883
};
884
885
/**
886
Toggles the given message between its parsed and plain-text
887
representations. It requires a server round-trip to collect the
888
plain-text form but caches it for subsequent toggles.
889
890
Expects the ID of a currently-loaded message or a
891
message-widget DOM elment from which it can extract an id.
892
This is an async operation the first time it's passed a given
893
message and synchronous on subsequent calls for that
894
message. It is a no-op if id does not resolve to a loaded
895
message.
896
*/
897
cs.toggleTextMode = function(id){
898
var e;
899
if(id instanceof HTMLElement){
900
e = id;
901
id = e.dataset.msgid;
902
}else{
903
e = this.getMessageElemById(id);
904
}
905
if(!e || !id) return false;
906
else if(e.$isToggling) return;
907
e.$isToggling = true;
908
const content = e.querySelector('.content-target');
909
if(!content){
910
console.warn("Should not be possible: trying to toggle text",
911
"mode of a message with no .content-target.", e);
912
return;
913
}
914
if(!content.$elems){
915
content.$elems = [
916
content.firstElementChild, // parsed elem
917
undefined // plaintext elem
918
];
919
}else if(content.$elems[1]){
920
// We have both content types. Simply toggle them.
921
const child = (
922
content.firstElementChild===content.$elems[0]
923
? content.$elems[1]
924
: content.$elems[0]
925
);
926
D.clearElement(content);
927
if(child===content.$elems[1]){
928
/* When showing the unformatted version, inject a
929
copy-to-clipboard button. This is a workaround for
930
mouse-copying from that field collecting twice as many
931
newlines as it should (for unknown reasons). */
932
const cpId = 'copy-to-clipboard-'+id;
933
/* ^^^ copy button element ID, needed for LABEL element
934
pairing. Recall that we destroy all child elements of
935
`content` each time we hit this block, so we can reuse
936
that element ID on subsequent toggles. */
937
const btnCp = D.attr(D.addClass(D.button(),'copy-button'), 'id', cpId);
938
F.copyButton(btnCp, {extractText: ()=>child._xmsgRaw});
939
const lblCp = D.label(cpId, "Copy unformatted text");
940
D.append(content, D.append(D.addClass(D.span(), 'nobr'), btnCp, lblCp));
941
}
942
delete e.$isToggling;
943
D.append(content, child);
944
return;
945
}
946
// We need to fetch the plain-text version...
947
const self = this;
948
F.fetch('chat-fetch-one',{
949
urlParams:{ name: id, raw: true},
950
responseType: 'json',
951
onload: function(msg){
952
reportConnectionOkay('chat-fetch-one');
953
content.$elems[1] = D.append(D.pre(),msg.xmsg);
954
content.$elems[1]._xmsgRaw = msg.xmsg/*used for copy-to-clipboard feature*/;
955
self.toggleTextMode(e);
956
},
957
aftersend:function(){
958
delete e.$isToggling;
959
Chat.ajaxEnd();
960
}
961
});
962
return true;
963
};
964
965
/** Given a .message-row element, this function returns whethe the
966
current user may, at least hypothetically, delete the message
967
globally. A user may always delete a local copy of a
968
post. The server may trump this, e.g. if the login has been
969
cancelled after this page was loaded.
970
*/
971
cs.userMayDelete = function(eMsg){
972
return +eMsg.dataset.msgid>0
973
&& (this.me === eMsg.dataset.xfrom
974
|| F.user.isAdmin/*will be confirmed server-side*/);
975
};
976
977
/** Returns a new Error() object encapsulating state from the given
978
fetch() response promise. */
979
cs._newResponseError = function(response){
980
return new Error([
981
"HTTP status ", response.status,": ",response.url,": ",
982
response.statusText].join(''));
983
};
984
985
/** Helper for reporting HTTP-level response errors via fetch().
986
If response.ok then response.json() is returned, else an Error
987
is thrown. */
988
cs._fetchJsonOrError = function(response){
989
if(response.ok) return response.json();
990
else throw cs._newResponseError(response);
991
};
992
993
/**
994
Removes the given message ID from the local chat record and, if
995
the message was posted by this user OR this user in an
996
admin/setup, also submits it for removal on the remote.
997
998
id may optionally be a DOM element, in which case it must be a
999
.message-row element.
1000
*/
1001
cs.deleteMessage = function(id){
1002
var e;
1003
if(id instanceof HTMLElement){
1004
e = id;
1005
id = e.dataset.msgid;
1006
}else{
1007
e = this.getMessageElemById(id);
1008
}
1009
if(!(e instanceof HTMLElement)) return;
1010
if(this.userMayDelete(e)){
1011
F.fetch("chat-delete/" + id, {
1012
responseType: 'json',
1013
onload:(r)=>{
1014
reportConnectionOkay('chat-delete');
1015
this.deleteMessageElem(r);
1016
},
1017
onerror:(err)=>this.reportErrorAsMessage(err)
1018
});
1019
}else{
1020
this.deleteMessageElem(id);
1021
}
1022
};
1023
document.addEventListener('visibilitychange', function(ev){
1024
cs.pageIsActive = ('visible' === document.visibilityState);
1025
cs.playedBeep = false;
1026
if(cs.pageIsActive){
1027
cs.e.pageTitle.innerText = cs.pageTitleOrig;
1028
if(document.activeElement!==cs.inputElement()){
1029
/* An attempt to resolve usability problem reported by Joe
1030
M. where the Pale Moon browser is giving input focus to
1031
the Preview button. The down-side of this is that it will
1032
deselect any text which was previously selected on this
1033
page. This also, unfortunately, places the focus at the
1034
start of the element, rather than the last cursor position
1035
(like a textarea would). */
1036
setTimeout(()=>cs.inputFocus(), 0);
1037
}
1038
}
1039
}, true);
1040
cs.setCurrentView(cs.e.viewMessages);
1041
1042
cs.e.activeUserList.addEventListener('click', function f(ev){
1043
/* Filter messages on a user clicked in activeUserList */
1044
ev.stopPropagation();
1045
ev.preventDefault();
1046
let eUser = ev.target;
1047
while(eUser!==this && !eUser.classList.contains('chat-user')){
1048
eUser = eUser.parentNode;
1049
}
1050
if(eUser==this || !eUser) return false;
1051
const uname = eUser.dataset.uname;
1052
let eLast;
1053
cs.setCurrentView(cs.e.viewMessages);
1054
if(eUser.classList.contains('selected')){
1055
/* If currently selected, toggle filter off */
1056
eUser.classList.remove('selected');
1057
cs.setUserFilter(false);
1058
delete f.$eSelected;
1059
}else{
1060
if(f.$eSelected) f.$eSelected.classList.remove('selected');
1061
f.$eSelected = eUser;
1062
eUser.classList.add('selected');
1063
cs.setUserFilter(uname);
1064
}
1065
return false;
1066
}, false);
1067
return cs;
1068
})()/*Chat initialization*/;
1069
1070
1071
/** Returns the first .message-widget element in DOM element
1072
e's lineage. */
1073
const findMessageWidgetParent = function(e){
1074
while( e && !e.classList.contains('message-widget')){
1075
e = e.parentNode;
1076
}
1077
return e;
1078
};
1079
1080
/**
1081
Custom widget type for rendering messages (one message per
1082
instance). These are modelled after FIELDSET elements but we
1083
don't use FIELDSET because of cross-browser inconsistencies in
1084
features of the FIELDSET/LEGEND combination, e.g. inability to
1085
align legends via CSS in Firefox and clicking-related
1086
deficiencies in Safari.
1087
*/
1088
Chat.MessageWidget = (function(){
1089
/**
1090
Constructor. If passed an argument, it is passed to
1091
this.setMessage() after initialization.
1092
*/
1093
const ctor = function(){
1094
this.e = {
1095
body: D.addClass(D.div(), 'message-widget'),
1096
tab: D.addClass(D.div(), 'message-widget-tab'),
1097
content: D.addClass(D.div(), 'message-widget-content')
1098
};
1099
D.append(this.e.body, this.e.tab, this.e.content);
1100
this.e.tab.setAttribute('role', 'button');
1101
if(arguments.length){
1102
this.setMessage(arguments[0]);
1103
}
1104
};
1105
/* Left-zero-pad a number to at least 2 digits */
1106
const dowMap = {
1107
/* Map of Date.getDay() values to weekday names. */
1108
0: "Sunday", 1: "Monday", 2: "Tuesday",
1109
3: "Wednesday", 4: "Thursday", 5: "Friday",
1110
6: "Saturday"
1111
};
1112
/* Given a Date, returns the timestamp string used in the "tab"
1113
part of message widgets. If longFmt is true then a verbose
1114
format is used, else a brief format is used. The returned string
1115
is in client-local time. */
1116
const theTime = function(d, longFmt=false){
1117
const li = [];
1118
if( longFmt ){
1119
li.push(
1120
d.getFullYear(),
1121
'-', pad2(d.getMonth()+1),
1122
'-', pad2(d.getDate()),
1123
' ',
1124
d.getHours(), ":",
1125
(d.getMinutes()+100).toString().slice(1,3)
1126
);
1127
}else{
1128
li.push(
1129
d.getHours(),":",
1130
(d.getMinutes()+100).toString().slice(1,3),
1131
' ', dowMap[d.getDay()]
1132
);
1133
}
1134
return li.join('');
1135
};
1136
1137
/**
1138
Returns true if this page believes it can embed a view of the
1139
file wrapped by the given message object, else returns false.
1140
*/
1141
const canEmbedFile = function f(msg){
1142
if(!f.$rx){
1143
f.$rx = /\.((html?)|(txt)|(md)|(wiki)|(pikchr))$/i;
1144
f.$specificTypes = [
1145
/* Mime types we know we can embed, sans image/... */
1146
'text/plain',
1147
'text/html',
1148
'text/x-markdown',
1149
/* Firefox sends text/markdown when uploading .md files */
1150
'text/markdown',
1151
'text/x-pikchr',
1152
'text/x-fossil-wiki'
1153
/* Add more as we discover which ones Firefox won't
1154
force the user to try to download. */
1155
];
1156
}
1157
if(msg.fmime){
1158
if(msg.fmime.startsWith("image/")
1159
|| f.$specificTypes.indexOf(msg.fmime)>=0){
1160
return true;
1161
}
1162
}
1163
return (msg.fname && f.$rx.test(msg.fname));
1164
};
1165
1166
/**
1167
Returns true if the given message object "should"
1168
be embedded in fossil-rendered form instead of
1169
raw content form. This is only intended to be passed
1170
message objects for which canEmbedFile() returns true.
1171
*/
1172
const shouldFossilRenderEmbed = function f(msg){
1173
if(!f.$rx){
1174
f.$rx = /\.((md)|(wiki)|(pikchr))$/i;
1175
f.$specificTypes = [
1176
'text/x-markdown',
1177
'text/markdown' /* Firefox-uploaded md files */,
1178
'text/x-pikchr',
1179
'text/x-fossil-wiki'
1180
];
1181
}
1182
if(msg.fmime){
1183
if(f.$specificTypes.indexOf(msg.fmime)>=0) return true;
1184
}
1185
return msg.fname && f.$rx.test(msg.fname);
1186
};
1187
1188
const adjustIFrameSize = function(msgObj){
1189
const iframe = msgObj.e.iframe;
1190
const body = iframe.contentWindow.document.querySelector('body');
1191
if(body && !body.style.fontSize){
1192
/** _Attempt_ to force the iframe to inherit the message's text size
1193
if the body has no explicit size set. On desktop systems
1194
the size is apparently being inherited in that case, but on mobile
1195
not. */
1196
body.style.fontSize = window.getComputedStyle(msgObj.e.content).fontSize;
1197
}
1198
if('' === iframe.style.maxHeight){
1199
/* Resize iframe height to fit the content. Workaround: if we
1200
adjust the iframe height while it's hidden then its height
1201
is 0, so we must briefly unhide it. */
1202
const isHidden = iframe.classList.contains('hidden');
1203
if(isHidden) D.removeClass(iframe, 'hidden');
1204
iframe.style.maxHeight = iframe.style.height
1205
= iframe.contentWindow.document.documentElement.scrollHeight + 'px';
1206
if(isHidden) D.addClass(iframe, 'hidden');
1207
}
1208
};
1209
1210
ctor.prototype = {
1211
scrollIntoView: function(){
1212
this.e.content.scrollIntoView();
1213
},
1214
//remove: function(silent){Chat.deleteMessageElem(this, silent);},
1215
setMessage: function(m){
1216
const ds = this.e.body.dataset;
1217
ds.timestamp = m.mtime;
1218
ds.lmtime = m.lmtime;
1219
ds.msgid = m.msgid;
1220
ds.xfrom = m.xfrom || '';
1221
if(m.xfrom === Chat.me){
1222
D.addClass(this.e.body, 'mine');
1223
}
1224
if(m.uclr){
1225
this.e.content.style.backgroundColor = m.uclr;
1226
this.e.tab.style.backgroundColor = m.uclr;
1227
}
1228
const d = new Date(m.mtime);
1229
D.clearElement(this.e.tab);
1230
var contentTarget = this.e.content;
1231
var eXFrom /* element holding xfrom name */;
1232
if(m.xfrom){
1233
eXFrom = D.append(D.addClass(D.span(), 'xfrom'), m.xfrom);
1234
const wrapper = D.append(
1235
D.span(), eXFrom,
1236
' ',
1237
D.append(D.addClass(D.span(), 'msgid'),
1238
'#' + (m.msgid||'???')),
1239
(m.isSearchResult ? ' ' : ' @ '),
1240
D.append(D.addClass(D.span(), 'timestamp'),
1241
theTime(d,!!m.isSearchResult))
1242
);
1243
D.append(this.e.tab, wrapper);
1244
}else{/*notification*/
1245
D.addClass(this.e.body, 'notification');
1246
if(m.isError){
1247
D.addClass([contentTarget, this.e.tab], 'error');
1248
}
1249
D.append(
1250
this.e.tab,
1251
D.append(D.code(), 'notification @ ',theTime(d,false))
1252
);
1253
}
1254
if( m.xfrom && m.fsize>0 ){
1255
if( m.fmime
1256
&& m.fmime.startsWith("image/")
1257
&& Chat.settings.getBool('images-inline',true)
1258
){
1259
const extension = m.fname.split('.').pop();
1260
contentTarget.appendChild(D.img("chat-download/" + m.msgid +(
1261
extension ? ('.'+extension) : ''/*So that IMG tag mimetype guessing works*/
1262
)));
1263
ds.hasImage = 1;
1264
}else{
1265
// Add a download link.
1266
const downloadUri = window.fossil.rootPath+
1267
'chat-download/' + m.msgid+'/'+encodeURIComponent(m.fname);
1268
const w = D.addClass(D.div(), 'attachment-link');
1269
const a = D.a(downloadUri,
1270
// ^^^ add m.fname to URL to cause downloaded file to have that name.
1271
"(" + m.fname + " " + m.fsize + " bytes)"
1272
)
1273
D.attr(a,'target','_blank');
1274
D.append(w, a);
1275
if(canEmbedFile(m)){
1276
/* Add an option to embed HTML attachments in an iframe. The primary
1277
use case is attached diffs. */
1278
const shouldFossilRender = shouldFossilRenderEmbed(m);
1279
const downloadArgs = shouldFossilRender ? '?render' : '';
1280
D.addClass(contentTarget, 'wide');
1281
const embedTarget = this.e.content;
1282
const self = this;
1283
const btnEmbed = D.attr(D.checkbox("1", false), 'id',
1284
'embed-'+ds.msgid);
1285
const btnLabel = D.label(btnEmbed, shouldFossilRender
1286
? "Embed (fossil-rendered)" : "Embed");
1287
/* Maintenance reminder: do not disable the toggle
1288
button while the content is loading because that will
1289
cause it to get stuck in disabled mode if the browser
1290
decides that loading the content should prompt the
1291
user to download it, rather than embed it in the
1292
iframe. */
1293
btnEmbed.addEventListener('change',function(){
1294
if(self.e.iframe){
1295
if(btnEmbed.checked){
1296
D.removeClass(self.e.iframe, 'hidden');
1297
if(self.e.$iframeLoaded) adjustIFrameSize(self);
1298
}
1299
else D.addClass(self.e.iframe, 'hidden');
1300
return;
1301
}
1302
const iframe = self.e.iframe = document.createElement('iframe');
1303
D.append(embedTarget, iframe);
1304
iframe.addEventListener('load', function(){
1305
self.e.$iframeLoaded = true;
1306
adjustIFrameSize(self);
1307
});
1308
iframe.setAttribute('src', downloadUri + downloadArgs);
1309
});
1310
D.append(w, btnEmbed, btnLabel);
1311
}
1312
contentTarget.appendChild(w);
1313
}
1314
}
1315
if(m.xmsg){
1316
if(m.fsize>0){
1317
/* We have file/image content, so need another element for
1318
the message text. */
1319
contentTarget = D.div();
1320
D.append(this.e.content, contentTarget);
1321
}
1322
D.addClass(contentTarget, 'content-target'
1323
/*target element for the 'toggle text mode' feature*/);
1324
// The m.xmsg text comes from the same server as this script and
1325
// is guaranteed by that server to be "safe" HTML - safe in the
1326
// sense that it is not possible for a malefactor to inject HTML
1327
// or javascript or CSS. The m.xmsg content might contain
1328
// hyperlinks, but otherwise it will be markup-free. See the
1329
// chat_format_to_html() routine in the server for details.
1330
//
1331
// Hence, even though innerHTML is normally frowned upon, it is
1332
// perfectly safe to use in this context.
1333
if(m.xmsg && 'string' !== typeof m.xmsg){
1334
// Used by Chat.reportErrorAsMessage()
1335
D.append(contentTarget, m.xmsg);
1336
}else{
1337
contentTarget.innerHTML = m.xmsg;
1338
contentTarget.querySelectorAll('a').forEach(addAnchorTargetBlank);
1339
if(F.pikchr){
1340
F.pikchr.addSrcView(contentTarget.querySelectorAll('svg.pikchr'));
1341
}
1342
}
1343
}
1344
//console.debug("tab",this.e.tab);
1345
//console.debug("this.e.tab.firstElementChild",this.e.tab.firstElementChild);
1346
this.e.tab.firstElementChild.addEventListener('click', this._handleLegendClicked, false);
1347
/*if(eXFrom){
1348
eXFrom.addEventListener('click', ()=>this.e.tab.click(), false);
1349
}*/
1350
return this;
1351
},
1352
/* Event handler for clicking .message-user elements to show their
1353
timestamps and a set of actions. */
1354
_handleLegendClicked: function f(ev){
1355
if(!f.popup){
1356
/* "Popup" widget */
1357
f.popup = {
1358
e: D.addClass(D.div(), 'chat-message-popup'),
1359
refresh:function(){
1360
const eMsg = this.$eMsg/*.message-widget element*/;
1361
if(!eMsg) return;
1362
D.clearElement(this.e);
1363
const d = new Date(eMsg.dataset.timestamp);
1364
if(d.getMinutes().toString()!=="NaN"){
1365
// Date works, render informative timestamps
1366
const xfrom = eMsg.dataset.xfrom || 'server';
1367
D.append(this.e,
1368
D.append(D.span(), localTimeString(d)," ",Chat.me," time"),
1369
D.append(D.span(), iso8601ish(d)));
1370
if(eMsg.dataset.lmtime && xfrom!==Chat.me){
1371
D.append(this.e,
1372
D.append(D.span(), localTime8601(
1373
new Date(eMsg.dataset.lmtime)
1374
).replace('T',' ')," ",xfrom," time"));
1375
}
1376
}else{
1377
/* This might not be necessary any more: it was
1378
initially caused by Safari being stricter than
1379
other browsers on its time string input, and that
1380
has since been resolved by emiting a stricter
1381
format. */
1382
// Date doesn't work, so dumb it down...
1383
D.append(this.e, D.append(D.span(), eMsg.dataset.timestamp," zulu"));
1384
}
1385
const toolbar = D.addClass(D.div(), 'toolbar', 'hide-in-zoom');
1386
D.append(this.e, toolbar);
1387
const self = this;
1388
1389
const btnDeleteLocal = D.button("Delete locally");
1390
D.append(toolbar, btnDeleteLocal);
1391
btnDeleteLocal.addEventListener('click', function(){
1392
self.hide();
1393
Chat.deleteMessageElem(eMsg)
1394
});
1395
if( eMsg.classList.contains('notification') ){
1396
const btnDeletePoll = D.button("Delete /chat notifications?");
1397
D.append(toolbar, btnDeletePoll);
1398
btnDeletePoll.addEventListener('click', function(){
1399
self.hide();
1400
Chat.e.viewMessages.querySelectorAll(
1401
'.message-widget.notification:not(.resend-message)'
1402
).forEach(e=>Chat.deleteMessageElem(e, true));
1403
});
1404
}
1405
if(Chat.userMayDelete(eMsg)){
1406
const btnDeleteGlobal = D.button("Delete globally");
1407
D.append(toolbar, btnDeleteGlobal);
1408
F.confirmer(btnDeleteGlobal,{
1409
pinSize: true,
1410
ticks: F.config.confirmerButtonTicks,
1411
confirmText: "Confirm delete?",
1412
onconfirm:function(){
1413
self.hide();
1414
Chat.deleteMessage(eMsg);
1415
}
1416
});
1417
}
1418
const toolbar3 = D.addClass(D.div(), 'toolbar', 'hide-in-zoom');
1419
D.append(this.e, toolbar3);
1420
D.append(toolbar3, D.button(
1421
"Locally remove all previous messages",
1422
function(){
1423
self.hide();
1424
Chat.mnMsg = +eMsg.dataset.msgid;
1425
var e = eMsg.previousElementSibling;
1426
while(e && e.classList.contains('message-widget')){
1427
const n = e.previousElementSibling;
1428
D.remove(e);
1429
e = n;
1430
}
1431
eMsg.scrollIntoView();
1432
}
1433
));
1434
const toolbar2 = D.addClass(D.div(), 'toolbar');
1435
D.append(this.e, toolbar2);
1436
if(eMsg.querySelector('.content-target')){
1437
/* ^^^ messages with only an embedded image have no
1438
.content-target area. */
1439
D.append(toolbar2, D.button(
1440
"Toggle text mode", function(){
1441
self.hide();
1442
Chat.toggleTextMode(eMsg);
1443
}));
1444
}
1445
if(eMsg.dataset.xfrom){
1446
/* Add a link to the /timeline filtered on this user. */
1447
const timelineLink = D.attr(
1448
D.a(F.repoUrl('timeline',{
1449
u: eMsg.dataset.xfrom,
1450
y: 'a'
1451
}), "User's Timeline"),
1452
'target', '_blank'
1453
);
1454
D.append(toolbar2, timelineLink);
1455
if(Chat.filterState.activeUser &&
1456
Chat.filterState.match(eMsg.dataset.xfrom)){
1457
/* Add a button to clear user filter and jump to
1458
this message in its original context. */
1459
D.append(
1460
this.e,
1461
D.append(
1462
D.addClass(D.div(), 'toolbar'),
1463
D.button(
1464
"Message in context",
1465
function(){
1466
self.hide();
1467
Chat.setUserFilter(false);
1468
eMsg.scrollIntoView(false);
1469
Chat.animate(
1470
eMsg.firstElementChild, 'anim-flip-h'
1471
//eMsg.firstElementChild, 'anim-flip-v'
1472
//eMsg.childNodes, 'anim-rotate-360'
1473
//eMsg.childNodes, 'anim-flip-v'
1474
//eMsg, 'anim-flip-v'
1475
);
1476
})
1477
)
1478
);
1479
}/*jump-to button*/
1480
}
1481
const btnZoom = D.button("Zoom");
1482
D.append(toolbar2, btnZoom);
1483
btnZoom.addEventListener('click', function(){
1484
Chat.zoomMessage(eMsg);
1485
});
1486
const tab = eMsg.querySelector('.message-widget-tab');
1487
D.append(tab, this.e);
1488
D.removeClass(this.e, 'hidden');
1489
Chat.animate(this.e, 'anim-fade-in-fast');
1490
}/*refresh()*/,
1491
hide: function(){
1492
delete this.$eMsg;
1493
D.addClass(this.e, 'hidden');
1494
D.clearElement(this.e);
1495
},
1496
show: function(tgtMsg){
1497
if(tgtMsg === this.$eMsg){
1498
this.hide();
1499
return;
1500
}
1501
this.$eMsg = tgtMsg;
1502
this.refresh();
1503
}
1504
}/*f.popup*/;
1505
}/*end static init*/
1506
const theMsg = findMessageWidgetParent(ev.target);
1507
if(theMsg) f.popup.show(theMsg);
1508
}/*_handleLegendClicked()*/
1509
};
1510
return ctor;
1511
})()/*MessageWidget*/;
1512
1513
/**
1514
A widget for loading more messages (context) around a /chat-query
1515
result message.
1516
*/
1517
Chat.SearchCtxLoader = (function(){
1518
const nMsgContext = 5;
1519
const zUpArrow = '\u25B2';
1520
const zDownArrow = '\u25BC';
1521
const ctor = function(o){
1522
1523
/* iFirstInTable:
1524
** msgid of first row in chatfts table.
1525
**
1526
** iLastInTable:
1527
** msgid of last row in chatfts table.
1528
**
1529
** iPrevId:
1530
** msgid of message immediately above this spacer. Or 0 if this
1531
** spacer is above all results.
1532
**
1533
** iNextId:
1534
** msgid of message immediately below this spacer. Or 0 if this
1535
** spacer is below all results.
1536
**
1537
** bIgnoreClick:
1538
** ignore any clicks if this is true. This is used to ensure there
1539
** is only ever one request belonging to this widget outstanding
1540
** at any time.
1541
*/
1542
this.o = {
1543
iFirstInTable: o.first,
1544
iLastInTable: o.last,
1545
iPrevId: o.previd,
1546
iNextId: o.nextid,
1547
bIgnoreClick: false
1548
};
1549
1550
this.e = {
1551
body: D.addClass(D.div(), 'spacer-widget'),
1552
up: D.addClass(
1553
D.button(zDownArrow+' Load '+nMsgContext+' more '+zDownArrow),
1554
'up'
1555
),
1556
down: D.addClass(
1557
D.button(zUpArrow+' Load '+nMsgContext+' more '+zUpArrow),
1558
'down'
1559
),
1560
all: D.addClass(D.button('Load More'), 'all')
1561
};
1562
D.append( this.e.body, this.e.up, this.e.down, this.e.all );
1563
const ms = this;
1564
this.e.up.addEventListener('click', ()=>ms.load_messages(false));
1565
this.e.down.addEventListener('click', ()=>ms.load_messages(true));
1566
this.e.all.addEventListener('click', ()=>ms.load_messages( (ms.o.iPrevId==0) ));
1567
this.set_button_visibility();
1568
};
1569
1570
ctor.prototype = {
1571
set_button_visibility: function() {
1572
if( !this.e ) return;
1573
const o = this.o;
1574
1575
const iPrevId = (o.iPrevId!=0) ? o.iPrevId : o.iFirstInTable-1;
1576
const iNextId = (o.iNextId!=0) ? o.iNextId : o.iLastInTable+1;
1577
let nDiff = (iNextId - iPrevId) - 1;
1578
1579
for( const x of [this.e.up, this.e.down, this.e.all] ){
1580
if( x ) D.addClass(x, 'hidden');
1581
}
1582
let nVisible = 0;
1583
if( nDiff>0 ){
1584
if( nDiff>nMsgContext && (o.iPrevId==0 || o.iNextId==0) ){
1585
nDiff = nMsgContext;
1586
}
1587
1588
if( nDiff<=nMsgContext && o.iPrevId!=0 && o.iNextId!=0 ){
1589
D.removeClass(this.e.all, 'hidden');
1590
++nVisible;
1591
this.e.all.innerText = (
1592
zUpArrow + " Load " + nDiff + " more " + zDownArrow
1593
);
1594
}else{
1595
if( o.iPrevId!=0 ){
1596
++nVisible;
1597
D.removeClass(this.e.up, 'hidden');
1598
}else if( this.e.up ){
1599
if( this.e.up.parentNode ) D.remove(this.e.up);
1600
delete this.e.up;
1601
}
1602
if( o.iNextId!=0 ){
1603
++nVisible;
1604
D.removeClass(this.e.down, 'hidden');
1605
}else if( this.e.down ){
1606
if( this.e.down.parentNode ) D.remove( this.e.down );
1607
delete this.e.down;
1608
}
1609
}
1610
}
1611
if( !nVisible ){
1612
/* The DOM elements can now be disposed of. */
1613
for( const x of [this.e.up, this.e.down, this.e.all, this.e.body] ){
1614
if( x?.parentNode ) D.remove(x);
1615
}
1616
delete this.e;
1617
}
1618
},
1619
1620
load_messages: function(bDown) {
1621
if( this.bIgnoreClick ) return;
1622
1623
var iFirst = 0; /* msgid of first message to fetch */
1624
var nFetch = 0; /* Number of messages to fetch */
1625
var iEof = 0; /* last msgid in spacers range, plus 1 */
1626
1627
const e = this.e, o = this.o;
1628
this.bIgnoreClick = true;
1629
1630
/* Figure out the required range of messages. */
1631
if( bDown ){
1632
iFirst = this.o.iNextId - nMsgContext;
1633
if( iFirst<this.o.iFirstInTable ){
1634
iFirst = this.o.iFirstInTable;
1635
}
1636
}else{
1637
iFirst = this.o.iPrevId+1;
1638
}
1639
nFetch = nMsgContext;
1640
iEof = (this.o.iNextId > 0) ? this.o.iNextId : this.o.iLastInTable+1;
1641
if( iFirst+nFetch>iEof ){
1642
nFetch = iEof - iFirst;
1643
}
1644
const ms = this;
1645
F.fetch("chat-query",{
1646
urlParams:{
1647
q: '',
1648
n: nFetch,
1649
i: iFirst
1650
},
1651
responseType: "json",
1652
onload:function(jx){
1653
reportConnectionOkay('chat-query');
1654
if( bDown ) jx.msgs.reverse();
1655
jx.msgs.forEach((m) => {
1656
m.isSearchResult = true;
1657
var mw = new Chat.MessageWidget(m);
1658
if( bDown ){
1659
/* Inject the message below this object's body, or
1660
append it to Chat.e.searchContent if this element
1661
is the final one in its parent (Chat.e.searchContent). */
1662
const eAnchor = e.body.nextElementSibling;
1663
if( eAnchor ) Chat.e.searchContent.insertBefore(mw.e.body, eAnchor);
1664
else D.append(Chat.e.searchContent, mw.e.body);
1665
}else{
1666
Chat.e.searchContent.insertBefore(mw.e.body, e.body);
1667
}
1668
});
1669
if( bDown ){
1670
o.iNextId -= jx.msgs.length;
1671
}else{
1672
o.iPrevId += jx.msgs.length;
1673
}
1674
ms.set_button_visibility();
1675
ms.bIgnoreClick = false;
1676
}
1677
});
1678
}
1679
};
1680
1681
return ctor;
1682
})() /*SearchCtxLoader*/;
1683
1684
const BlobXferState = (function(){
1685
/* State for paste and drag/drop */
1686
const bxs = {
1687
dropDetails: document.querySelector('#chat-drop-details'),
1688
blob: undefined,
1689
clear: function(){
1690
this.blob = undefined;
1691
D.clearElement(this.dropDetails);
1692
Chat.e.inputFile.value = "";
1693
}
1694
};
1695
/** Updates the paste/drop zone with details of the pasted/dropped
1696
data. The argument must be a Blob or Blob-like object (File) or
1697
it can be falsy to reset/clear that state.*/
1698
const updateDropZoneContent = bxs.updateDropZoneContent = function(blob){
1699
//console.debug("updateDropZoneContent()",blob);
1700
const dd = bxs.dropDetails;
1701
bxs.blob = blob;
1702
D.clearElement(dd);
1703
if(!blob){
1704
Chat.e.inputFile.value = '';
1705
return;
1706
}
1707
D.append(dd, "Attached: ", blob.name,
1708
D.br(), "Size: ",blob.size);
1709
const btn = D.button("Cancel");
1710
D.append(dd, D.br(), btn);
1711
btn.addEventListener('click', ()=>updateDropZoneContent(), false);
1712
if(blob.type && (blob.type.startsWith("image/") || blob.type==='BITMAP')){
1713
const img = D.img();
1714
D.append(dd, D.br(), img);
1715
const reader = new FileReader();
1716
reader.onload = (e)=>img.setAttribute('src', e.target.result);
1717
reader.readAsDataURL(blob);
1718
}
1719
};
1720
Chat.e.inputFile.addEventListener('change', function(ev){
1721
updateDropZoneContent(this?.files[0])
1722
});
1723
/* Handle image paste from clipboard. TODO: figure out how we can
1724
paste non-image binary data as if it had been selected via the
1725
file selection element. */
1726
const pasteListener = function(event){
1727
const items = event.clipboardData.items,
1728
item = items[0];
1729
//console.debug("paste event",event.target,item,event);
1730
//console.debug("paste event item",item);
1731
if(item && item.type && ('file'===item.kind || 'BITMAP'===item.type)){
1732
updateDropZoneContent(false/*clear prev state*/);
1733
updateDropZoneContent(item.getAsFile());
1734
event.stopPropagation();
1735
event.preventDefault(true);
1736
return false;
1737
}
1738
/* else continue propagating */
1739
};
1740
document.addEventListener('paste', pasteListener, true);
1741
if(window.Selection && window.Range && !Chat.$browserHasPlaintextOnly){
1742
/* Acrobatics to keep *some* installations of Firefox
1743
from pasting formatting into contenteditable fields.
1744
This also works on Chrome, but chrome has the
1745
contenteditable=plaintext-only property which does this
1746
for us. */
1747
Chat.e.inputX.addEventListener(
1748
'paste',
1749
function(ev){
1750
if (ev.clipboardData && ev.clipboardData.getData) {
1751
const pastedText = ev.clipboardData.getData('text/plain');
1752
const selection = window.getSelection();
1753
if (!selection.rangeCount) return false;
1754
selection.deleteFromDocument(/*remove selected content*/);
1755
selection.getRangeAt(0).insertNode(document.createTextNode(pastedText));
1756
selection.collapseToEnd(/*deselect pasted text and set cursor at the end*/);
1757
ev.preventDefault();
1758
return false;
1759
}
1760
}, false);
1761
}
1762
const noDragDropEvents = function(ev){
1763
/* contenteditable tries to do its own thing with dropped data,
1764
which is not compatible with how we use it, so... */
1765
ev.dataTransfer.effectAllowed = 'none';
1766
ev.dataTransfer.dropEffect = 'none';
1767
ev.preventDefault();
1768
ev.stopPropagation();
1769
return false;
1770
};
1771
['drop','dragenter','dragleave','dragend'].forEach(
1772
(k)=>Chat.e.inputX.addEventListener(k, noDragDropEvents, false)
1773
);
1774
return bxs;
1775
})()/*drag/drop/paste*/;
1776
1777
const localTime8601 = function(d){
1778
return [
1779
d.getYear()+1900, '-', pad2(d.getMonth()+1), '-', pad2(d.getDate()),
1780
'T', pad2(d.getHours()),':', pad2(d.getMinutes()),':',pad2(d.getSeconds())
1781
].join('');
1782
};
1783
1784
/**
1785
Called by Chat.submitMessage() when message sending failed. Injects a fake message
1786
containing the content and attachment of the failed message and gives the user buttons
1787
to discard it or edit and retry.
1788
*/
1789
const recoverFailedMessage = function(state){
1790
const w = D.addClass(D.div(), 'failed-message');
1791
D.append(w, D.append(
1792
D.span(),"This message was not successfully sent to the server:"
1793
));
1794
if(state.msg){
1795
const ta = D.textarea();
1796
ta.value = state.msg;
1797
ta.setAttribute('readonly','true');
1798
D.append(w,ta);
1799
}
1800
if(state.blob){
1801
D.append(w,D.append(D.span(),"Attachment: ",(state.blob.name||"unnamed")));
1802
//console.debug("blob = ",state.blob);
1803
}
1804
const buttons = D.addClass(D.div(), 'buttons');
1805
D.append(w, buttons);
1806
D.append(buttons, D.button("Discard message?", function(){
1807
const theMsg = findMessageWidgetParent(w);
1808
if(theMsg) Chat.deleteMessageElem(theMsg);
1809
}));
1810
D.append(buttons, D.button("Edit message and try again?", function(){
1811
if(state.msg) Chat.inputValue(state.msg);
1812
if(state.blob) BlobXferState.updateDropZoneContent(state.blob);
1813
const theMsg = findMessageWidgetParent(w);
1814
if(theMsg) Chat.deleteMessageElem(theMsg);
1815
}));
1816
D.addClass(Chat.reportErrorAsMessage(w).e.body, "resend-message");
1817
};
1818
1819
/* Assume the connection has been established, reset the
1820
Chat.timer.tidClearPollErr, and (if showMsg and
1821
!!Chat.e.eMsgPollError) alert the user that the outage appears to
1822
be over. Also schedule Chat.poll() to run in the very near
1823
future. */
1824
const reportConnectionOkay = function(dbgContext, showMsg = true){
1825
if(Chat.beVerbose){
1826
console.warn('reportConnectionOkay', dbgContext,
1827
'Chat.e.pollErrorMarker classes =',
1828
Chat.e.pollErrorMarker.classList,
1829
'Chat.timer.tidClearPollErr =',Chat.timer.tidClearPollErr,
1830
'Chat.timer =',Chat.timer);
1831
}
1832
if( Chat.timer.errCount ){
1833
D.removeClass(Chat.e.pollErrorMarker, 'connection-error');
1834
Chat.timer.errCount = 0;
1835
}
1836
Chat.timer.cancelReconnectCheckTimer().startPendingPollTimer();
1837
if( Chat.e.eMsgPollError ) {
1838
const oldErrMsg = Chat.e.eMsgPollError;
1839
Chat.e.eMsgPollError = undefined;
1840
if( showMsg ){
1841
if(Chat.beVerbose){
1842
console.log("Poller Connection restored.");
1843
}
1844
const m = Chat.reportReconnection("Poller connection restored.");
1845
if( oldErrMsg ){
1846
D.remove(oldErrMsg.e?.body.querySelector('button.retry-now'));
1847
}
1848
m.e.body.dataset.alsoRemove = oldErrMsg?.e?.body?.dataset?.msgid;
1849
D.addClass(m.e.body,'poller-connection');
1850
}
1851
}
1852
};
1853
1854
/**
1855
Submits the contents of the message input field (if not empty)
1856
and/or the file attachment field to the server. If both are
1857
empty, this is a no-op.
1858
1859
If the current view is the history search, this instead sends the
1860
input text to that widget.
1861
*/
1862
Chat.submitMessage = function f(){
1863
if(!f.spaces){
1864
f.spaces = /\s+$/;
1865
f.markdownContinuation = /\\\s+$/;
1866
f.spaces2 = /\s{3,}$/;
1867
}
1868
switch( this.e.currentView ){
1869
case this.e.viewSearch: this.submitSearch();
1870
return;
1871
default: break;
1872
}
1873
this.setCurrentView(this.e.viewMessages);
1874
const fd = new FormData();
1875
const fallback = {msg: this.inputValue()};
1876
var msg = fallback.msg;
1877
if(msg && (msg.indexOf('\n')>0 || f.spaces.test(msg))){
1878
/* Cosmetic: trim most whitespace from the ends of lines to try to
1879
keep copy/paste from terminals, especially wide ones, from
1880
forcing a horizontal scrollbar on all clients. This breaks
1881
markdown's use of blackslash-space-space for paragraph
1882
continuation, but *not* doing this affects all clients every
1883
time someone pastes in console copy/paste from an affected
1884
platform. We seem to have narrowed to the console pasting
1885
problem to users of tmux together with certain apps (vim, at
1886
a minimum). Most consoles don't behave that way.
1887
1888
We retain two trailing spaces so that markdown conventions
1889
which use end-of-line spacing aren't broken by this
1890
stripping.
1891
*/
1892
const xmsg = msg.split('\n');
1893
xmsg.forEach(function(line,ndx){
1894
if(!f.markdownContinuation.test(line)){
1895
xmsg[ndx] = line.replace(f.spaces2, ' ');
1896
}
1897
});
1898
msg = xmsg.join('\n');
1899
}
1900
if(msg) fd.set('msg',msg);
1901
const file = BlobXferState.blob || this.e.inputFile.files[0];
1902
if(file) fd.set("file", file);
1903
if( !msg && !file ) return;
1904
fallback.blob = file;
1905
const self = this;
1906
fd.set("lmtime", localTime8601(new Date()));
1907
F.fetch("chat-send",{
1908
payload: fd,
1909
responseType: 'text',
1910
onerror:function(err){
1911
self.reportErrorAsMessage(err);
1912
recoverFailedMessage(fallback);
1913
},
1914
onload:function(txt){
1915
reportConnectionOkay('chat-send');
1916
if(!txt) return/*success response*/;
1917
try{
1918
const json = JSON.parse(txt);
1919
self.newContent({msgs:[json]});
1920
}catch(e){
1921
self.reportError(e);
1922
}
1923
recoverFailedMessage(fallback);
1924
}
1925
});
1926
BlobXferState.clear();
1927
Chat.inputValue("").inputFocus();
1928
};
1929
1930
const inputWidgetKeydown = function f(ev){
1931
if(!f.$toggleCtrl){
1932
f.$toggleCtrl = function(currentMode){
1933
currentMode = !currentMode;
1934
Chat.settings.set('edit-ctrl-send', currentMode);
1935
};
1936
f.$toggleCompact = function(currentMode){
1937
currentMode = !currentMode;
1938
Chat.settings.set('edit-compact-mode', currentMode);
1939
};
1940
}
1941
if(13 !== ev.keyCode) return;
1942
const text = Chat.inputValue().trim();
1943
const ctrlMode = Chat.settings.getBool('edit-ctrl-send', false);
1944
//console.debug("Enter key event:", ctrlMode, ev.ctrlKey, ev.shiftKey, ev);
1945
if(ev.shiftKey){
1946
ev.preventDefault();
1947
ev.stopPropagation();
1948
/* Shift-enter will run preview mode UNLESS the input field is empty
1949
AND (preview or search mode) is active, in which cases it will
1950
switch back to message view. */
1951
if(!text &&
1952
(Chat.e.currentView===Chat.e.viewPreview
1953
| Chat.e.currentView===Chat.e.viewSearch)){
1954
Chat.setCurrentView(Chat.e.viewMessages);
1955
}else if(!text){
1956
const compactMode = Chat.settings.getBool('edit-compact-mode', false);
1957
f.$toggleCompact(compactMode);
1958
}else if(Chat.settings.getBool('edit-shift-enter-preview', true)){
1959
Chat.e.btnPreview.click();
1960
}
1961
return false;
1962
}
1963
if(ev.ctrlKey && !text && !BlobXferState.blob){
1964
/* Ctrl-enter on empty input field(s) toggles Enter/Ctrl-enter mode */
1965
ev.preventDefault();
1966
ev.stopPropagation();
1967
f.$toggleCtrl(ctrlMode);
1968
return false;
1969
}
1970
if(!ctrlMode && ev.ctrlKey && text){
1971
//console.debug("!ctrlMode && ev.ctrlKey && text.");
1972
/* Ctrl-enter in Enter-sends mode SHOULD, with this logic add a
1973
newline, but that is not happening, for unknown reasons
1974
(possibly related to this element being a contenteditable DIV
1975
instead of a textarea). Forcibly appending a newline do the
1976
input area does not work, also for unknown reasons, and would
1977
only be suitable when we're at the end of the input.
1978
1979
Strangely, this approach DOES work for shift-enter, but we
1980
need shift-enter as a hotkey for preview mode.
1981
*/
1982
//return;
1983
// return here "should" cause newline to be added, but that doesn't work
1984
}
1985
if((!ctrlMode && !ev.ctrlKey) || (ev.ctrlKey/* && ctrlMode*/)){
1986
/* Ship it! */
1987
ev.preventDefault();
1988
ev.stopPropagation();
1989
Chat.submitMessage();
1990
return false;
1991
}
1992
};
1993
Chat.e.inputFields.forEach(
1994
(e)=>e.addEventListener('keydown', inputWidgetKeydown, false)
1995
);
1996
Chat.e.btnSubmit.addEventListener('click',(e)=>{
1997
e.preventDefault();
1998
Chat.submitMessage();
1999
return false;
2000
});
2001
Chat.e.btnAttach.addEventListener(
2002
'click', ()=>Chat.e.inputFile.click(), false);
2003
2004
(function(){/*Set up #chat-button-settings and related bits */
2005
if(window.innerWidth<window.innerHeight){
2006
// Must be set up before config view is...
2007
/* Alignment of 'my' messages: right alignment is conventional
2008
for mobile chat apps but can be difficult to read in wide
2009
windows (desktop/tablet landscape mode), so we default to a
2010
layout based on the apparent "orientation" of the window:
2011
tall vs wide. Can be toggled via settings. */
2012
document.body.classList.add('my-messages-right');
2013
}
2014
const settingsButton = document.querySelector('#chat-button-settings');
2015
const optionsMenu = E1('#chat-config-options');
2016
const eToggleView = function(ev){
2017
ev.preventDefault();
2018
ev.stopPropagation();
2019
Chat.setCurrentView(Chat.e.currentView===Chat.e.viewConfig
2020
? Chat.e.viewMessages : Chat.e.viewConfig);
2021
return false;
2022
};
2023
D.attr(settingsButton, 'role', 'button').addEventListener('click', eToggleView, false);
2024
Chat.e.viewConfig.querySelector('button.action-close').addEventListener('click', eToggleView, false);
2025
2026
/** Internal acrobatics to allow certain settings toggles to access
2027
related toggles. */
2028
const namedOptions = {
2029
activeUsers:{
2030
label: "Show active users list",
2031
hint: "List users who have messages in the currently-loaded chat history.",
2032
boolValue: 'active-user-list'
2033
}
2034
};
2035
if(1){
2036
/* Per user request, toggle the list of users on and off if the
2037
legend element is tapped. */
2038
const optAu = namedOptions.activeUsers;
2039
optAu.theLegend = Chat.e.activeUserListWrapper.firstElementChild/*LEGEND*/;
2040
optAu.theList = optAu.theLegend.nextElementSibling/*user list container*/;
2041
optAu.theLegend.addEventListener('click',function(){
2042
D.toggleClass(Chat.e.activeUserListWrapper, 'collapsed');
2043
if(!Chat.e.activeUserListWrapper.classList.contains('collapsed')){
2044
Chat.animate(optAu.theList,'anim-flip-v');
2045
}
2046
}, false);
2047
}/*namedOptions.activeUsers additional setup*/
2048
/* Settings menu entries... they are presented in the order listed
2049
here, so the most frequently-needed ones "should" (arguably) be
2050
closer to the start of this list. */
2051
/**
2052
Settings ops structure:
2053
2054
label: string for the UI
2055
2056
boolValue: string (name of Chat.settings setting) or a function
2057
which returns true or false. If it is a string, it gets
2058
replaced by a function which returns
2059
Chat.settings.getBool(thatString) and the string gets assigned
2060
to the persistentSetting property of this object.
2061
2062
select: SELECT element (instead of boolValue)
2063
2064
callback: optional handler to call after setting is modified.
2065
Its "this" is the options object. If this object has a
2066
boolValue string or a persistentSetting property, the argument
2067
passed to the callback is a settings object in the form {key:K,
2068
value:V}. If this object does not have boolValue string or
2069
persistentSetting then the callback is passed an event object
2070
in response to the config option's UI widget being activated,
2071
normally a 'change' event.
2072
2073
children: [array of settings objects]. These get listed under
2074
this element and indented slightly for visual grouping. Only
2075
one level of indention is supported.
2076
2077
Elements which only have a label and maybe a hint and
2078
children can be used as headings.
2079
2080
If a setting has a boolValue set, that gets rendered as a
2081
checkbox which toggles the given persistent setting (if
2082
boolValue is a string) AND listens for changes to that setting
2083
fired via Chat.settings.set() so that the checkbox can stay in
2084
sync with external changes to that setting. Various Chat UI
2085
elements stay in sync with the config UI via those settings
2086
events. The checkbox element gets added to the options object
2087
so that the callback() can reference it via this.checkbox.
2088
*/
2089
const settingsOps = [{
2090
label: "Chat Configuration Options",
2091
hint: F.storage.isTransient()
2092
? "Local store is unavailable. These settings are transient."
2093
: ["Most of these settings are persistent via ",
2094
F.storage.storageImplName(), ": ",
2095
F.storage.storageHelpDescription()].join('')
2096
},{
2097
label: "Editing Options...",
2098
hint: ["These options are all recommended but some misinteract",
2099
"with specific browsers or software keyboards so they",
2100
"are not enabled by default."
2101
].join(' '),
2102
children:[{
2103
label: "Chat-only mode",
2104
hint: "Toggle the page between normal fossil view and chat-only view.",
2105
boolValue: 'chat-only-mode'
2106
},{
2107
label: "Ctrl-enter to Send",
2108
hint: [
2109
"When on, only Ctrl-Enter will send messages and Enter adds ",
2110
"blank lines. When off, both Enter and Ctrl-Enter send. ",
2111
"When the input field has focus and is empty ",
2112
"then Ctrl-Enter toggles this setting."
2113
].join(''),
2114
boolValue: 'edit-ctrl-send'
2115
},{
2116
label: "Compact mode",
2117
hint: [
2118
"Toggle between a space-saving or more spacious writing area. ",
2119
"When the input field has focus and is empty ",
2120
"then Shift-Enter may (depending on the current view) toggle this setting."
2121
].join(''),
2122
boolValue: 'edit-compact-mode'
2123
},{
2124
label: "Use 'contenteditable' editing mode",
2125
boolValue: 'edit-widget-x',
2126
hint: [
2127
"When enabled, chat input uses a so-called 'contenteditable' ",
2128
"field. Though generally more comfortable and modern than ",
2129
"plain-text input fields, browser-specific quirks and bugs ",
2130
"may lead to frustration. Ideal for mobile devices."
2131
].join('')
2132
},{
2133
label: "Shift-enter to preview",
2134
hint: ["Use shift-enter to preview being-edited messages. ",
2135
"This is normally desirable but some software-mode ",
2136
"keyboards misinteract with this, in which cases it can be ",
2137
"disabled."],
2138
boolValue: 'edit-shift-enter-preview'
2139
}]
2140
},{
2141
label: "Appearance Options...",
2142
children:[{
2143
label: "Left-align my posts",
2144
hint: "Default alignment of your own messages is selected "
2145
+ "based on the window width/height ratio.",
2146
boolValue: ()=>!document.body.classList.contains('my-messages-right'),
2147
callback: function f(){
2148
document.body.classList[
2149
this.checkbox.checked ? 'remove' : 'add'
2150
]('my-messages-right');
2151
}
2152
},{
2153
label: "Monospace message font",
2154
hint: "Use monospace font for message and input text.",
2155
boolValue: 'monospace-messages',
2156
callback: function(setting){
2157
document.body.classList[
2158
setting.value ? 'add' : 'remove'
2159
]('monospace-messages');
2160
}
2161
},{
2162
label: "Show images inline",
2163
hint: "When enabled, attached images are shown inline, "+
2164
"else they appear as a download link.",
2165
boolValue: 'images-inline'
2166
}]
2167
}];
2168
2169
/** Set up selection list of notification sounds. */
2170
if(1){
2171
const selectSound = D.select();
2172
D.option(selectSound, "", "(no audio)");
2173
const firstSoundIndex = selectSound.options.length;
2174
F.config.chat.alerts.forEach((a)=>D.option(selectSound, a));
2175
if(true===Chat.settings.getBool('audible-alert')){
2176
/* This setting used to be a plain bool. If we encounter
2177
such a setting, take the first sound in the list. */
2178
selectSound.selectedIndex = firstSoundIndex;
2179
}else{
2180
selectSound.value = Chat.settings.get('audible-alert','<none>');
2181
if(selectSound.selectedIndex<0){
2182
/* Missing file - removed after this setting was
2183
applied. Fall back to the first sound in the list. */
2184
selectSound.selectedIndex = firstSoundIndex;
2185
}
2186
}
2187
Chat.setNewMessageSound(selectSound.value);
2188
settingsOps.push({
2189
label: "Sound Options...",
2190
hint: "How to enable audio playback is browser-specific!",
2191
children:[{
2192
hint: "Audio alert",
2193
select: selectSound,
2194
callback: function(ev){
2195
const v = ev.target.value;
2196
Chat.setNewMessageSound(v);
2197
F.toast.message("Audio notifications "+(v ? "enabled" : "disabled")+".");
2198
if(v) setTimeout(()=>Chat.playNewMessageSound(), 0);
2199
}
2200
},{
2201
label: "Notify only once when away",
2202
hint: "Notify only for the first message received after chat is hidden from view.",
2203
boolValue: 'beep-once'
2204
},{
2205
label: "Play notification for your own messages",
2206
hint: "When enabled, the audio notification will be played for all messages, "+
2207
"including your own. When disabled only messages from other users "+
2208
"will trigger a notification.",
2209
boolValue: 'alert-own-messages'
2210
}]
2211
});
2212
}/*audio notification config*/
2213
settingsOps.push({
2214
label: "Active User List...",
2215
hint: [
2216
"/chat cannot track active connections, but it can tell ",
2217
"you who has posted recently..."].join(''),
2218
children:[
2219
namedOptions.activeUsers,{
2220
label: "Timestamps in active users list",
2221
indent: true,
2222
hint: "Show most recent message timestamps in the active user list.",
2223
boolValue: 'active-user-list-timestamps'
2224
}
2225
]
2226
});
2227
/**
2228
Build UI for config options...
2229
*/
2230
settingsOps.forEach(function f(op,indentOrIndex){
2231
const menuEntry = D.addClass(D.div(), 'menu-entry');
2232
if(true===indentOrIndex) D.addClass(menuEntry, 'child');
2233
const label = op.label
2234
? D.append(D.label(),op.label) : undefined;
2235
const labelWrapper = D.addClass(D.div(), 'label-wrapper');
2236
var hint;
2237
if(op.hint){
2238
hint = D.append(D.addClass(D.label(),'hint'),op.hint);
2239
}
2240
if(op.hasOwnProperty('select')){
2241
const col0 = D.addClass(D.span(/*empty, but for spacing*/),
2242
'toggle-wrapper');
2243
D.append(menuEntry, labelWrapper, col0);
2244
D.append(labelWrapper, op.select);
2245
if(hint) D.append(labelWrapper, hint);
2246
if(label) D.append(label);
2247
if(op.callback){
2248
op.select.addEventListener('change', (ev)=>op.callback(ev), false);
2249
}
2250
}else if(op.hasOwnProperty('boolValue')){
2251
if(undefined === f.$id) f.$id = 0;
2252
++f.$id;
2253
if('string' ===typeof op.boolValue){
2254
const key = op.boolValue;
2255
op.boolValue = ()=>Chat.settings.getBool(key);
2256
op.persistentSetting = key;
2257
}
2258
const check = op.checkbox
2259
= D.attr(D.checkbox(1, op.boolValue()),
2260
'aria-label', op.label);
2261
const id = 'cfgopt'+f.$id;
2262
const col0 = D.addClass(D.span(), 'toggle-wrapper');
2263
check.checked = op.boolValue();
2264
op.checkbox = check;
2265
D.attr(check, 'id', id);
2266
if(hint) D.attr(hint, 'for', id);
2267
D.append(menuEntry, labelWrapper, col0);
2268
D.append(col0, check);
2269
if(label){
2270
D.attr(label, 'for', id);
2271
D.append(labelWrapper, label);
2272
}
2273
if(hint) D.append(labelWrapper, hint);
2274
}else{
2275
if(op.callback){
2276
menuEntry.addEventListener('click', (ev)=>op.callback(ev));
2277
}
2278
D.append(menuEntry, labelWrapper);
2279
if(label) D.append(labelWrapper, label);
2280
if(hint) D.append(labelWrapper, hint);
2281
}
2282
D.append(optionsMenu, menuEntry);
2283
if(op.persistentSetting){
2284
Chat.settings.addListener(
2285
op.persistentSetting,
2286
function(setting){
2287
if(op.checkbox) op.checkbox.checked = !!setting.value;
2288
else if(op.select) op.select.value = setting.value;
2289
if(op.callback) op.callback(setting);
2290
}
2291
);
2292
if(op.checkbox){
2293
op.checkbox.addEventListener(
2294
'change', function(){
2295
Chat.settings.set(op.persistentSetting, op.checkbox.checked)
2296
}, false);
2297
}
2298
}else if(op.callback && op.checkbox){
2299
op.checkbox.addEventListener('change', (ev)=>op.callback(ev), false);
2300
}
2301
if(op.children){
2302
D.addClass(menuEntry, 'parent');
2303
op.children.forEach((x)=>f(x,true));
2304
}
2305
});
2306
})()/*#chat-button-settings setup*/;
2307
2308
(function(){
2309
/* Install default settings... must come after
2310
chat-button-settings setup so that the listeners which that
2311
installs are notified via the properties getting initialized
2312
here. */
2313
Chat.settings.addListener('monospace-messages',function(s){
2314
document.body.classList[s.value ? 'add' : 'remove']('monospace-messages');
2315
})
2316
Chat.settings.addListener('active-user-list',function(s){
2317
Chat.showActiveUserList(s.value);
2318
});
2319
Chat.settings.addListener('active-user-list-timestamps',function(s){
2320
Chat.showActiveUserTimestamps(s.value);
2321
});
2322
Chat.settings.addListener('chat-only-mode',function(s){
2323
Chat.chatOnlyMode(s.value);
2324
});
2325
Chat.settings.addListener('edit-widget-x',function(s){
2326
let eSelected;
2327
if(s.value){
2328
if(Chat.e.inputX===Chat.inputElement()) return;
2329
eSelected = Chat.e.inputX;
2330
}else{
2331
eSelected = Chat.settings.getBool('edit-compact-mode')
2332
? Chat.e.input1 : Chat.e.inputM;
2333
}
2334
const v = Chat.inputValue();
2335
Chat.inputValue('');
2336
Chat.e.inputFields.forEach(function(e,ndx){
2337
if(eSelected===e){
2338
Chat.e.inputFields.$currentIndex = ndx;
2339
D.removeClass(e, 'hidden');
2340
}
2341
else D.addClass(e,'hidden');
2342
});
2343
Chat.inputValue(v);
2344
eSelected.focus();
2345
});
2346
Chat.settings.addListener('edit-compact-mode',function(s){
2347
if(Chat.e.inputX!==Chat.inputElement()){
2348
/* Text field/textarea mode: swap them if needed.
2349
Compact mode of inputX is toggled via CSS. */
2350
const a = s.value
2351
? [Chat.e.input1, Chat.e.inputM, 0]
2352
: [Chat.e.inputM, Chat.e.input1, 1];
2353
const v = Chat.inputValue();
2354
Chat.inputValue('');
2355
Chat.e.inputFields.$currentIndex = a[2];
2356
Chat.inputValue(v);
2357
D.removeClass(a[0], 'hidden');
2358
D.addClass(a[1], 'hidden');
2359
}
2360
Chat.e.inputLineWrapper.classList[
2361
s.value ? 'add' : 'remove'
2362
]('compact');
2363
Chat.e.inputFields[Chat.e.inputFields.$currentIndex].focus();
2364
});
2365
Chat.settings.addListener('edit-ctrl-send',function(s){
2366
const label = (s.value ? "Ctrl-" : "")+"Enter submits message.";
2367
Chat.e.inputFields.forEach((e)=>{
2368
const v = e.dataset.placeholder0 + " " +label;
2369
if(e.isContentEditable) e.dataset.placeholder = v;
2370
else D.attr(e,'placeholder',v);
2371
});
2372
Chat.e.btnSubmit.title = label;
2373
});
2374
const valueKludges = {
2375
/* Convert certain string-format values to other types... */
2376
"false": false,
2377
"true": true
2378
};
2379
Object.keys(Chat.settings.defaults).forEach(function(k){
2380
var v = Chat.settings.get(k,Chat);
2381
if(Chat===v) v = Chat.settings.defaults[k];
2382
if(valueKludges.hasOwnProperty(v)) v = valueKludges[v];
2383
Chat.settings.set(k,v)
2384
/* fires event listeners so that the Config area checkboxes
2385
get in sync */;
2386
});
2387
})();
2388
2389
(function(){/*set up message preview*/
2390
const btnPreview = Chat.e.btnPreview;
2391
Chat.setPreviewText = function(t){
2392
this.setCurrentView(this.e.viewPreview);
2393
this.e.previewContent.innerHTML = t;
2394
this.e.viewPreview.querySelectorAll('a').forEach(addAnchorTargetBlank);
2395
this.inputFocus();
2396
};
2397
Chat.e.viewPreview.querySelector('button.action-close').
2398
addEventListener('click', ()=>Chat.setCurrentView(Chat.e.viewMessages), false);
2399
let previewPending = false;
2400
const elemsToEnable = [btnPreview, Chat.e.btnSubmit, Chat.e.inputFields];
2401
const submit = function(ev){
2402
ev.preventDefault();
2403
ev.stopPropagation();
2404
if(previewPending) return false;
2405
const txt = Chat.inputValue();
2406
if(!txt){
2407
Chat.setPreviewText('');
2408
previewPending = false;
2409
return false;
2410
}
2411
const fd = new FormData();
2412
fd.append('content', txt);
2413
fd.append('filename','chat.md'
2414
/*filename needed for mimetype determination*/);
2415
fd.append('render_mode',F.page.previewModes.wiki);
2416
F.fetch('ajax/preview-text',{
2417
payload: fd,
2418
onload: function(html){
2419
reportConnectionOkay('ajax/preview-text');
2420
Chat.setPreviewText(html);
2421
F.pikchr.addSrcView(Chat.e.viewPreview.querySelectorAll('svg.pikchr'));
2422
},
2423
onerror: function(e){
2424
F.fetch.onerror(e);
2425
Chat.setPreviewText("ERROR: "+(
2426
e.message || 'Unknown error fetching preview!'
2427
));
2428
},
2429
beforesend: function(){
2430
D.disable(elemsToEnable);
2431
Chat.ajaxStart();
2432
previewPending = true;
2433
Chat.setPreviewText("Loading preview...");
2434
},
2435
aftersend:function(){
2436
previewPending = false;
2437
Chat.ajaxEnd();
2438
D.enable(elemsToEnable);
2439
}
2440
});
2441
return false;
2442
};
2443
btnPreview.addEventListener('click', submit, false);
2444
})()/*message preview setup*/;
2445
2446
(function(){/*Set up #chat-search and related bits */
2447
const btn = document.querySelector('#chat-button-search');
2448
D.attr(btn, 'role', 'button').addEventListener('click', function(ev){
2449
ev.preventDefault();
2450
ev.stopPropagation();
2451
const msg = Chat.inputValue();
2452
if( Chat.e.currentView===Chat.e.viewSearch ){
2453
if( msg ) Chat.submitSearch();
2454
else Chat.setCurrentView(Chat.e.viewMessages);
2455
}else{
2456
Chat.setCurrentView(Chat.e.viewSearch);
2457
if( msg ) Chat.submitSearch();
2458
}
2459
return false;
2460
}, false);
2461
Chat.e.viewSearch.querySelector('button.action-clear').addEventListener('click', function(ev){
2462
ev.preventDefault();
2463
ev.stopPropagation();
2464
Chat.clearSearch(true);
2465
Chat.setCurrentView(Chat.e.viewMessages);
2466
return false;
2467
}, false);
2468
Chat.e.viewSearch.querySelector('button.action-close').addEventListener('click', function(ev){
2469
ev.preventDefault();
2470
ev.stopPropagation();
2471
Chat.setCurrentView(Chat.e.viewMessages);
2472
return false;
2473
}, false);
2474
})()/*search view setup*/;
2475
2476
(function(){/*Set up the zoom view */
2477
Chat.e.viewZoom.querySelector('button.action-close').addEventListener('click', function(ev){
2478
ev.preventDefault();
2479
ev.stopPropagation();
2480
Chat.zoomMessage(null);
2481
return false;
2482
}, false);
2483
})()/*zoom view setup*/;
2484
2485
/** Callback for poll() to inject new content into the page. jx ==
2486
the response from /chat-poll. If atEnd is true, the message is
2487
appended to the end of the chat list (for loading older
2488
messages), else the beginning (the default). */
2489
const newcontent = function f(jx,atEnd){
2490
if(!f.processPost){
2491
/** Processes chat message m, placing it either the start (if atEnd
2492
is falsy) or end (if atEnd is truthy) of the chat history. atEnd
2493
should only be true when loading older messages. */
2494
f.processPost = function(m,atEnd){
2495
++Chat.totalMessageCount;
2496
if( m.msgid>Chat.mxMsg ) Chat.mxMsg = m.msgid;
2497
if( !Chat.mnMsg || m.msgid<Chat.mnMsg) Chat.mnMsg = m.msgid;
2498
if(m.xfrom && m.mtime){
2499
const d = new Date(m.mtime);
2500
const uls = Chat.usersLastSeen[m.xfrom];
2501
if(!uls || uls<d){
2502
d.$uColor = m.uclr;
2503
Chat.usersLastSeen[m.xfrom] = d;
2504
}
2505
}
2506
if( m.mdel ){
2507
/* A record deletion notice. */
2508
Chat.deleteMessageElem(m.mdel);
2509
return;
2510
}
2511
if(!Chat._isBatchLoading
2512
&& (Chat.me!==m.xfrom
2513
|| Chat.settings.getBool('alert-own-messages'))){
2514
Chat.playNewMessageSound();
2515
}
2516
const row = new Chat.MessageWidget(m);
2517
Chat.injectMessageElem(row.e.body,atEnd);
2518
if(m.isError){
2519
Chat._gotServerError = m;
2520
}
2521
}/*processPost()*/;
2522
}/*end static init*/
2523
jx.msgs.forEach((m)=>f.processPost(m,atEnd));
2524
Chat.updateActiveUserList();
2525
if('visible'===document.visibilityState){
2526
if(Chat.changesSincePageHidden){
2527
Chat.changesSincePageHidden = 0;
2528
Chat.e.pageTitle.innerText = Chat.pageTitleOrig;
2529
}
2530
}else{
2531
Chat.changesSincePageHidden += jx.msgs.length;
2532
if(jx.msgs.length){
2533
Chat.e.pageTitle.innerText = '[*] '+Chat.pageTitleOrig;
2534
}
2535
}
2536
}/*newcontent()*/;
2537
Chat.newContent = newcontent;
2538
2539
(function(){
2540
/** Add toolbar for loading older messages. We use a FIELDSET here
2541
because a fieldset is the only parent element type which can
2542
automatically enable/disable its children by
2543
enabling/disabling the parent element. */
2544
const loadLegend = D.legend("Load...");
2545
const toolbar = Chat.e.loadOlderToolbar = D.attr(
2546
D.fieldset(loadLegend), "id", "load-msg-toolbar"
2547
);
2548
Chat.disableDuringAjax.push(toolbar);
2549
/* Loads the next n oldest messages, or all previous history if n is negative. */
2550
const loadOldMessages = function(n){
2551
Chat.e.viewMessages.classList.add('loading');
2552
Chat._isBatchLoading = true;
2553
const scrollHt = Chat.e.viewMessages.scrollHeight,
2554
scrollTop = Chat.e.viewMessages.scrollTop;
2555
F.fetch("chat-poll",{
2556
urlParams:{
2557
before: Chat.mnMsg,
2558
n: n
2559
},
2560
responseType: 'json',
2561
onerror:function(err){
2562
Chat.reportErrorAsMessage(err);
2563
Chat._isBatchLoading = false;
2564
},
2565
onload:function(x){
2566
reportConnectionOkay('loadOldMessages()');
2567
let gotMessages = x.msgs.length;
2568
newcontent(x,true);
2569
Chat._isBatchLoading = false;
2570
Chat.updateActiveUserList();
2571
if(Chat._gotServerError){
2572
Chat._gotServerError = false;
2573
return;
2574
}
2575
if(n<0/*we asked for all history*/
2576
|| 0===gotMessages/*we found no history*/
2577
|| (n>0 && gotMessages<n /*we got fewer history entries than requested*/)
2578
|| (n===0 && gotMessages<Chat.loadMessageCount
2579
/*we asked for default amount and got fewer than that.*/)){
2580
/* We've loaded all history. Permanently disable the
2581
history-load toolbar and keep it from being re-enabled
2582
via the ajaxStart()/ajaxEnd() mechanism... */
2583
const div = Chat.e.loadOlderToolbar.querySelector('div');
2584
D.append(D.clearElement(div), "All history has been loaded.");
2585
D.addClass(Chat.e.loadOlderToolbar, 'all-done');
2586
const ndx = Chat.disableDuringAjax.indexOf(Chat.e.loadOlderToolbar);
2587
if(ndx>=0) Chat.disableDuringAjax.splice(ndx,1);
2588
Chat.e.loadOlderToolbar.disabled = true;
2589
}
2590
if(gotMessages > 0){
2591
F.toast.message("Loaded "+gotMessages+" older messages.");
2592
/* Return scroll position to where it was when the history load
2593
was requested, per user request */
2594
Chat.e.viewMessages.scrollTo(
2595
0, Chat.e.viewMessages.scrollHeight - scrollHt + scrollTop
2596
);
2597
}
2598
},
2599
aftersend:function(){
2600
Chat.e.viewMessages.classList.remove('loading');
2601
Chat.ajaxEnd();
2602
}
2603
});
2604
};
2605
const wrapper = D.div(); /* browsers don't all properly handle >1 child in a fieldset */;
2606
D.append(toolbar, wrapper);
2607
var btn = D.button("Previous "+Chat.loadMessageCount+" messages");
2608
D.append(wrapper, btn);
2609
btn.addEventListener('click',()=>loadOldMessages(Chat.loadMessageCount));
2610
btn = D.button("All previous messages");
2611
D.append(wrapper, btn);
2612
btn.addEventListener('click',()=>loadOldMessages(-1));
2613
D.append(Chat.e.viewMessages, toolbar);
2614
toolbar.disabled = true /*will be enabled when msg load finishes */;
2615
})()/*end history loading widget setup*/;
2616
2617
/**
2618
Clears the search result view. If addInstructions is true it adds
2619
text to that view instructing the user to enter their query into
2620
the message-entry widget (noting that that widget has text
2621
implying that it's only for submitting a message, which isn't
2622
exactly true when the search view is active).
2623
2624
Returns the DOM element which wraps all of the chat search
2625
result elements.
2626
*/
2627
Chat.clearSearch = function(addInstructions=false){
2628
const e = D.clearElement( this.e.searchContent );
2629
if(addInstructions){
2630
D.append(e, "Enter search terms in the message field. "+
2631
"Use #NNNNN to search for the message with ID NNNNN.");
2632
}
2633
return e;
2634
};
2635
Chat.clearSearch(true);
2636
/**
2637
Submits a history search using the main input field's current
2638
text. It is assumed that Chat.e.viewSearch===Chat.e.currentView.
2639
*/
2640
Chat.submitSearch = function(){
2641
const term = this.inputValue(true);
2642
const eMsgTgt = this.clearSearch(true);
2643
if( !term ) return;
2644
D.append( eMsgTgt, "Searching for ",term," ...");
2645
const fd = new FormData();
2646
fd.set('q', term);
2647
F.fetch(
2648
"chat-query", {
2649
payload: fd,
2650
responseType: 'json',
2651
onerror:function(err){
2652
Chat.setCurrentView(Chat.e.viewMessages);
2653
Chat.reportErrorAsMessage(err);
2654
},
2655
onload:function(jx){
2656
reportConnectionOkay('submitSearch()');
2657
let previd = 0;
2658
D.clearElement(eMsgTgt);
2659
jx.msgs.forEach((m)=>{
2660
m.isSearchResult = true;
2661
const mw = new Chat.MessageWidget(m);
2662
const spacer = new Chat.SearchCtxLoader({
2663
first: jx.first,
2664
last: jx.last,
2665
previd: previd,
2666
nextid: m.msgid
2667
});
2668
if( spacer.e ) D.append( eMsgTgt, spacer.e.body );
2669
D.append( eMsgTgt, mw.e.body );
2670
previd = m.msgid;
2671
});
2672
if( jx.msgs.length ){
2673
const spacer = new Chat.SearchCtxLoader({
2674
first: jx.first,
2675
last: jx.last,
2676
previd: previd,
2677
nextid: 0
2678
});
2679
if( spacer.e ) D.append( eMsgTgt, spacer.e.body );
2680
}else{
2681
D.append( D.clearElement(eMsgTgt),
2682
'No search results found for: ',
2683
term );
2684
}
2685
}
2686
}
2687
);
2688
}/*Chat.submitSearch()*/;
2689
2690
/*
2691
To be called from F.fetch('chat-poll') beforesend() handler. If
2692
we're currently in delayed-retry mode and a connection is
2693
started, try to reset the delay after N time waiting on that
2694
connection. The fact that the connection is waiting to respond,
2695
rather than outright failing, is a good hint that the outage is
2696
over and we can reset the back-off timer.
2697
2698
Without this, recovery of a connection error won't be reported
2699
until after the long-poll completes by either receiving new
2700
messages or timing out. Once a long-poll is in progress, though,
2701
we "know" that it's up and running again, so can update the UI and
2702
connection timer to reflect that. That's the job this function
2703
does.
2704
2705
Only one of these asynchronous checks will ever be active
2706
concurrently and only if Chat.timer.isDelayed() is true. i.e. if
2707
this timer is active or Chat.timer.isDelayed() is false, this is a
2708
no-op.
2709
*/
2710
const chatPollBeforeSend = function(){
2711
//console.warn('chatPollBeforeSend outer', Chat.timer.tidClearPollErr, Chat.timer.currentDelay);
2712
if( !Chat.timer.tidClearPollErr && Chat.timer.isDelayed() ){
2713
Chat.timer.tidClearPollErr = setTimeout(()=>{
2714
//console.warn('chatPollBeforeSend inner');
2715
Chat.timer.tidClearPollErr = 0;
2716
if( poll.running ){
2717
/* This chat-poll F.fetch() is still underway, so let's
2718
assume the connection is back up until/unless it times
2719
out or breaks again. */
2720
reportConnectionOkay('chatPollBeforeSend', true);
2721
}
2722
}, Chat.timer.$initialDelay * 4/*kinda arbitrary: not too long for UI wait and
2723
not too short as to make connection unlikely. */ );
2724
}
2725
};
2726
2727
/**
2728
Deal with the last poll() response and maybe re-start poll().
2729
*/
2730
const afterPollFetch = function f(err){
2731
if(true===f.isFirstCall){
2732
f.isFirstCall = false;
2733
Chat.ajaxEnd();
2734
Chat.e.viewMessages.classList.remove('loading');
2735
setTimeout(function(){
2736
Chat.scrollMessagesTo(1);
2737
}, 250);
2738
}
2739
Chat.timer.cancelPendingPollTimer();
2740
if(Chat._gotServerError){
2741
Chat.reportErrorAsMessage(
2742
"Shutting down chat poller due to server-side error. ",
2743
"Reload this page to reactivate it."
2744
);
2745
} else {
2746
if( err && Chat.beVerbose ){
2747
console.error("afterPollFetch:",err.name,err.status,err.message);
2748
}
2749
if( !err || 'timeout'===err.name/*(probably) long-poll expired*/ ){
2750
/* Restart the poller immediately. */
2751
reportConnectionOkay('afterPollFetch '+err, false);
2752
}else{
2753
/* Delay a while before trying again, noting that other Chat
2754
APIs may try and succeed at connections before this timer
2755
resolves, in which case they'll clear this timeout and the
2756
UI message about the outage. */
2757
let delay;
2758
D.addClass(Chat.e.pollErrorMarker, 'connection-error');
2759
if( ++Chat.timer.errCount < Chat.timer.minErrForNotify ){
2760
delay = Chat.timer.resetDelay(
2761
(Chat.timer.minDelay * Chat.timer.errCount)
2762
+ Chat.timer.randomInterval(Chat.timer.minDelay)
2763
);
2764
if(Chat.beVerbose){
2765
console.warn("Ignoring polling error #",Chat.timer.errCount,
2766
"for another",delay,"ms" );
2767
}
2768
} else {
2769
delay = Chat.timer.incrDelay();
2770
//console.warn("afterPollFetch Chat.e.eMsgPollError",Chat.e.eMsgPollError);
2771
const msg = "Poller connection error. Retrying in "+delay+ " ms.";
2772
/* Replace the current/newest connection error widget. We could also
2773
just update its body with the new message, but then its timestamp
2774
never updates. OTOH, if we replace the message, we lose the
2775
start time of the outage in the log. It seems more useful to
2776
update the timestamp so that it doesn't look like it's hung. */
2777
if( Chat.e.eMsgPollError ){
2778
Chat.deleteMessageElem(Chat.e.eMsgPollError, false);
2779
}
2780
const theMsg = Chat.e.eMsgPollError = Chat.reportErrorAsMessage(msg);
2781
D.addClass(Chat.e.eMsgPollError.e.body,'poller-connection');
2782
/* Add a "retry now" button */
2783
const btnDel = D.addClass(D.button("Retry now"), 'retry-now');
2784
const eParent = Chat.e.eMsgPollError.e.content;
2785
D.append(eParent, " ", btnDel);
2786
btnDel.addEventListener('click', function(){
2787
D.remove(btnDel);
2788
D.append(eParent, D.text("retrying..."));
2789
Chat.timer.cancelPendingPollTimer().currentDelay =
2790
Chat.timer.resetDelay() +
2791
1 /*workaround for showing the "connection restored"
2792
message, as the +1 will cause
2793
Chat.timer.isDelayed() to be true.*/;
2794
poll();
2795
});
2796
//Chat.playNewMessageSound();// browser complains b/c this wasn't via human interaction
2797
}
2798
Chat.timer.startPendingPollTimer(delay);
2799
}
2800
}
2801
};
2802
afterPollFetch.isFirstCall = true;
2803
2804
/**
2805
Initiates, if it's not already running, a single long-poll
2806
request to the /chat-poll endpoint. In the handling of that
2807
response, it end up will psuedo-recursively calling itself via
2808
the response-handling process. Despite being async, the implied
2809
returned Promise is meaningless.
2810
*/
2811
const poll = Chat.poll = async function f(){
2812
if(f.running) return;
2813
f.running = true;
2814
Chat._isBatchLoading = f.isFirstCall;
2815
if(true===f.isFirstCall){
2816
f.isFirstCall = false;
2817
f.pendingOnError = undefined;
2818
Chat.ajaxStart();
2819
Chat.e.viewMessages.classList.add('loading');
2820
/*
2821
We manager onerror() results in poll() in a roundabout
2822
manner: when an onerror() arrives, we stash it aside
2823
for a moment before processing it.
2824
2825
This level of indirection is necessary to be able to
2826
unambiguously identify client-timeout-specific polling errors
2827
from other errors. Timeouts are always announced in pairs of
2828
an HTTP 0 and something we can unambiguously identify as a
2829
timeout (in that order). When we receive an HTTP error we put
2830
it into this queue. If an ontimeout() call arrives before
2831
this error is handled, this error is ignored. If, however, an
2832
HTTP error is seen without an accompanying timeout, we handle
2833
it from here.
2834
2835
It's kinda like in the curses C API, where you to match
2836
ALT-X by first getting an ESC event, then an X event, but
2837
this one is a lot less explicable. (It's almost certainly a
2838
mis-handling bug in F.fetch(), but it has so far eluded my
2839
eyes.)
2840
*/
2841
f.delayPendingOnError = function(err){
2842
if( f.pendingOnError ){
2843
const x = f.pendingOnError;
2844
f.pendingOnError = undefined;
2845
afterPollFetch(x);
2846
}
2847
};
2848
}
2849
F.fetch("chat-poll",{
2850
timeout: Chat.timer.pollTimeout,
2851
urlParams:{
2852
name: Chat.mxMsg
2853
},
2854
responseType: "json",
2855
// Disable the ajax start/end handling for this long-polling op:
2856
beforesend: chatPollBeforeSend,
2857
aftersend: function(){
2858
poll.running = false;
2859
},
2860
ontimeout: function(err){
2861
f.pendingOnError = undefined /*strip preceding non-timeout error, if any*/;
2862
afterPollFetch(err);
2863
},
2864
onerror:function(err){
2865
Chat._isBatchLoading = false;
2866
if(Chat.beVerbose){
2867
console.error("poll.onerror:",err.name,err.status,JSON.stringify(err));
2868
}
2869
f.pendingOnError = err;
2870
setTimeout(f.delayPendingOnError, 100);
2871
},
2872
onload:function(y){
2873
reportConnectionOkay('poll.onload', true);
2874
newcontent(y);
2875
if(Chat._isBatchLoading){
2876
Chat._isBatchLoading = false;
2877
Chat.updateActiveUserList();
2878
}
2879
afterPollFetch();
2880
}
2881
});
2882
}/*poll()*/;
2883
poll.isFirstCall = true;
2884
Chat._gotServerError = poll.running = false;
2885
if( window.fossil.config.chat.fromcli ){
2886
Chat.chatOnlyMode(true);
2887
}
2888
Chat.timer.startPendingPollTimer();
2889
delete ForceResizeKludge.$disabled;
2890
ForceResizeKludge();
2891
Chat.animate.$disabled = false;
2892
setTimeout( ()=>Chat.inputFocus(), 0 );
2893
F.page.chat = Chat/* enables testing the APIs via the dev tools */;
2894
});
2895

Keyboard Shortcuts

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