|
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
|
|