Fossil SCM

chat: auto-scrolling of other peoples' posts into view works based on a heuristic of whether the *previous* post is in view or not (else we assume the user is back in the history), with the notable caveat that posts with inlined images play havok with this, in part because loading of images is async and we race against it. Moved the #debugMsg element out of div.content to keep it from unduly influencing our layout.

stephan 2020-12-27 07:45 trunk
Commit 6c28d7d6cb593463c2203a16c028a64f304cd8bb23337d79a21d18901073e23c
1 file changed +78 -17
+78 -17
--- src/chat.js
+++ src/chat.js
@@ -17,10 +17,18 @@
1717
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
1818
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
1919
);
2020
};
2121
22
+ (function(){
23
+ let dbg = document.querySelector('#debugMsg');
24
+ if(dbg){
25
+ /* This can inadvertently influence our flexbox layouts, so move
26
+ it out of the way. */
27
+ D.append(document.body,dbg);
28
+ }
29
+ })();
2230
const ForceResizeKludge = 0 ? function(){} : (function(){
2331
/* Workaround for Safari mayhem regarding use of vh CSS units....
2432
We tried to use vh units to set the content area size for the
2533
chat layout, but Safari chokes on that, so we calculate that
2634
height here: 85% when in "normal" mode and 95% in chat-only
@@ -141,33 +149,82 @@
141149
/* List of DOM elements disable while ajax traffic is in
142150
transit. Must be populated before ajax starts. We do this
143151
to avoid various race conditions in the UI and long-running
144152
network requests. */
145153
],
154
+ /** Either scrolls .message-widget element eMsg into view
155
+ immediately or, if it represents an inlined image, delays
156
+ the scroll until the image is loaded, at which point it will
157
+ scroll to either the newest message, if one is set or to
158
+ eMsg (the liklihood is good, at least on initial page load,
159
+ that the the image won't be loaded until other messages have
160
+ been injected). */
161
+ scheduleScrollOfMsg: function(eMsg){
162
+ if(1===+eMsg.dataset.hasImage){
163
+ console.debug("Delaying scroll for IMG.");
164
+ eMsg.querySelector('img').addEventListener(
165
+ 'load', ()=>(this.e.newestMessage || eMsg).scrollIntoView()
166
+ );
167
+ }else{
168
+ eMsg.scrollIntoView();
169
+ }
170
+ return this;
171
+ },
146172
/* Injects element e as a new row in the chat, at the top of the
147173
list if atEnd is falsy, else at the end of the list, before
148174
the load-history widget. */
149175
injectMessageElem: function f(e, atEnd){
150176
const mip = atEnd ? this.e.loadOlderToolbar : this.e.messageInjectPoint,
151
- holder = this.e.messagesWrapper;
177
+ holder = this.e.messagesWrapper,
178
+ prevMessage = this.e.newestMessage;
152179
if(atEnd){
153180
const fe = mip.nextElementSibling;
154181
if(fe) mip.parentNode.insertBefore(e, fe);
155182
else D.append(mip.parentNode, e);
156183
}else{
157184
D.append(holder,e);
158
- Chat.newestMessageElem = e;
185
+ this.e.newestMessage = e;
159186
}
160187
if(!atEnd && !this.isMassLoading
161
- && e.dataset.xfrom!==Chat.me && !isInViewport(e)){
188
+ && e.dataset.xfrom!==this.me && !isInViewport(e)){
162189
/* If a new non-history message arrives while the user is
163190
scrolled elsewhere, do not scroll to the latest
164191
message, but gently alert the user that a new message
165192
has arrived. */
166193
F.toast.message("New message has arrived.");
167
- }else if(e.dataset.xfrom===Chat.me){
168
- e.scrollIntoView();
194
+ }else if(!this.isMassLoading && e.dataset.xfrom===Chat.me){
195
+ this.scheduleScrollOfMsg(e);
196
+ }else if(!this.isMassLoading){
197
+ /* When a message from someone else arrives, we have to
198
+ figure out whether or not to scroll it into view. Ideally
199
+ we'd just stuff it in the UI and let the flexbox layout
200
+ DTRT, but Safari has expressed, in no uncertain terms,
201
+ some disappointment with that approach, so we'll
202
+ heuristicize it: if the previous last message is in view,
203
+ assume the user is at or near the input element and
204
+ scroll this one into view. If that message is NOT in
205
+ view, assume the user is up reading history somewhere and
206
+ do NOT scroll because doing so would interrupt
207
+ them. There are middle grounds here where the user will
208
+ experience a slight UI jolt, but this heuristic mostly
209
+ seems to work out okay. If there was no previous message,
210
+ assume we don't have any messages yet and go ahead and
211
+ scroll this message into view (noting that that scrolling
212
+ is hypothetically a no-op in such cases).
213
+
214
+ The wrench in these works are posts with IMG tags, as
215
+ those images are loaded async and the element does not
216
+ yet have enough information to know how far to scroll!
217
+ For such cases we have to delay the scroll until the
218
+ image loads (and we hope it does so before another
219
+ message arrives).
220
+ */
221
+ if(1===+e.dataset.hasImage){
222
+ e.querySelector('img').addEventListener('load',()=>e.scrollIntoView());
223
+ }else if(!prevMessage || (prevMessage && isInViewport(prevMessage))){
224
+ e.scrollIntoView();
225
+ }
169226
}
170227
},
171228
/** Returns true if chat-only mode is enabled. */
172229
isChatOnlyMode: ()=>document.body.classList.contains('chat-only-mode'),
173230
/**
@@ -178,11 +235,15 @@
178235
*/
179236
chatOnlyMode: function f(yes){
180237
if(undefined === f.elemsToToggle){
181238
f.elemsToToggle = [];
182239
document.querySelectorAll(
183
- "body > div.header, body > div.mainmenu, body > div.footer"
240
+ ["body > div.header",
241
+ "body > div.mainmenu",
242
+ "body > div.footer",
243
+ "#debugMsg"
244
+ ].join(',')
184245
).forEach((e)=>f.elemsToToggle.push(e));
185246
}
186247
if(!arguments.length) yes = true;
187248
if(yes === this.isChatOnlyMode()) return this;
188249
if(yes){
@@ -261,11 +322,15 @@
261322
262323
/** Finds the last .message-widget element and returns it or
263324
the undefined value if none are found. */
264325
cs.fetchLastMessageElem = function(){
265326
const msgs = document.querySelectorAll('.message-widget');
266
- return msgs.length ? msgs[msgs.length-1] : undefined;
327
+ var rc;
328
+ if(msgs.length){
329
+ rc = this.e.newestMessage = msgs[msgs.length-1];
330
+ }
331
+ return rc;
267332
};
268333
269334
/**
270335
LOCALLY deletes a message element by the message ID or passing
271336
the .message-row element. Returns true if it removes an element,
@@ -279,12 +344,12 @@
279344
}else{
280345
e = this.getMessageElemById(id);
281346
}
282347
if(e && id){
283348
D.remove(e);
284
- if(e===this.newestMessageElem){
285
- Chat.newestMessageElem = Chat.fetchLastMessageElem();
349
+ if(e===this.e.newestMessage){
350
+ this.fetchLastMessageElem();
286351
}
287352
F.toast.message("Deleted message "+id+".");
288353
}
289354
return !!e;
290355
};
@@ -388,10 +453,11 @@
388453
if( m.fmime
389454
&& m.fmime.startsWith("image/")
390455
&& Chat.settings.getBool('images-inline',true)
391456
){
392457
contentTarget.appendChild(D.img("chat-download/" + m.msgid));
458
+ ds.hasImage = 1;
393459
}else{
394460
const a = D.a(
395461
window.fossil.rootPath+
396462
'chat-download/' + m.msgid+'/'+encodeURIComponent(m.fname),
397463
// ^^^ add m.fname to URL to cause downloaded file to have that name.
@@ -861,22 +927,17 @@
861927
resumed, and reportError() produces a loud error message. */
862928
.finally(function(){
863929
if(isFirstCall){
864930
Chat.isMassLoading = false;
865931
Chat.ajaxEnd();
866
- setTimeout(function(){
867
- const m = Chat.newestMessageElem;
868
- if(m){
869
- m.scrollIntoView();
870
- //console.debug("Scrolling into view...",msgs[msgs.length-1]);
871
- }
872
- Chat.e.inputWrapper.scrollIntoView()
873
- }, 0);
932
+ const m = Chat.e.newestMessage;
933
+ if(m) Chat.scheduleScrollOfMsg(m);
934
+ setTimeout(()=>Chat.e.inputWrapper.scrollIntoView(), 0);
874935
}
875936
poll.running=false;
876937
});
877938
}
878939
poll.running = false;
879940
poll(true);
880941
setInterval(poll, 1000);
881942
F.page.chat = Chat/* enables testing the APIs via the dev tools */;
882943
})();
883944
--- src/chat.js
+++ src/chat.js
@@ -17,10 +17,18 @@
17 rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
18 rect.right <= (window.innerWidth || document.documentElement.clientWidth)
19 );
20 };
21
 
 
 
 
 
 
 
 
22 const ForceResizeKludge = 0 ? function(){} : (function(){
23 /* Workaround for Safari mayhem regarding use of vh CSS units....
24 We tried to use vh units to set the content area size for the
25 chat layout, but Safari chokes on that, so we calculate that
26 height here: 85% when in "normal" mode and 95% in chat-only
@@ -141,33 +149,82 @@
141 /* List of DOM elements disable while ajax traffic is in
142 transit. Must be populated before ajax starts. We do this
143 to avoid various race conditions in the UI and long-running
144 network requests. */
145 ],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146 /* Injects element e as a new row in the chat, at the top of the
147 list if atEnd is falsy, else at the end of the list, before
148 the load-history widget. */
149 injectMessageElem: function f(e, atEnd){
150 const mip = atEnd ? this.e.loadOlderToolbar : this.e.messageInjectPoint,
151 holder = this.e.messagesWrapper;
 
152 if(atEnd){
153 const fe = mip.nextElementSibling;
154 if(fe) mip.parentNode.insertBefore(e, fe);
155 else D.append(mip.parentNode, e);
156 }else{
157 D.append(holder,e);
158 Chat.newestMessageElem = e;
159 }
160 if(!atEnd && !this.isMassLoading
161 && e.dataset.xfrom!==Chat.me && !isInViewport(e)){
162 /* If a new non-history message arrives while the user is
163 scrolled elsewhere, do not scroll to the latest
164 message, but gently alert the user that a new message
165 has arrived. */
166 F.toast.message("New message has arrived.");
167 }else if(e.dataset.xfrom===Chat.me){
168 e.scrollIntoView();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169 }
170 },
171 /** Returns true if chat-only mode is enabled. */
172 isChatOnlyMode: ()=>document.body.classList.contains('chat-only-mode'),
173 /**
@@ -178,11 +235,15 @@
178 */
179 chatOnlyMode: function f(yes){
180 if(undefined === f.elemsToToggle){
181 f.elemsToToggle = [];
182 document.querySelectorAll(
183 "body > div.header, body > div.mainmenu, body > div.footer"
 
 
 
 
184 ).forEach((e)=>f.elemsToToggle.push(e));
185 }
186 if(!arguments.length) yes = true;
187 if(yes === this.isChatOnlyMode()) return this;
188 if(yes){
@@ -261,11 +322,15 @@
261
262 /** Finds the last .message-widget element and returns it or
263 the undefined value if none are found. */
264 cs.fetchLastMessageElem = function(){
265 const msgs = document.querySelectorAll('.message-widget');
266 return msgs.length ? msgs[msgs.length-1] : undefined;
 
 
 
 
267 };
268
269 /**
270 LOCALLY deletes a message element by the message ID or passing
271 the .message-row element. Returns true if it removes an element,
@@ -279,12 +344,12 @@
279 }else{
280 e = this.getMessageElemById(id);
281 }
282 if(e && id){
283 D.remove(e);
284 if(e===this.newestMessageElem){
285 Chat.newestMessageElem = Chat.fetchLastMessageElem();
286 }
287 F.toast.message("Deleted message "+id+".");
288 }
289 return !!e;
290 };
@@ -388,10 +453,11 @@
388 if( m.fmime
389 && m.fmime.startsWith("image/")
390 && Chat.settings.getBool('images-inline',true)
391 ){
392 contentTarget.appendChild(D.img("chat-download/" + m.msgid));
 
393 }else{
394 const a = D.a(
395 window.fossil.rootPath+
396 'chat-download/' + m.msgid+'/'+encodeURIComponent(m.fname),
397 // ^^^ add m.fname to URL to cause downloaded file to have that name.
@@ -861,22 +927,17 @@
861 resumed, and reportError() produces a loud error message. */
862 .finally(function(){
863 if(isFirstCall){
864 Chat.isMassLoading = false;
865 Chat.ajaxEnd();
866 setTimeout(function(){
867 const m = Chat.newestMessageElem;
868 if(m){
869 m.scrollIntoView();
870 //console.debug("Scrolling into view...",msgs[msgs.length-1]);
871 }
872 Chat.e.inputWrapper.scrollIntoView()
873 }, 0);
874 }
875 poll.running=false;
876 });
877 }
878 poll.running = false;
879 poll(true);
880 setInterval(poll, 1000);
881 F.page.chat = Chat/* enables testing the APIs via the dev tools */;
882 })();
883
--- src/chat.js
+++ src/chat.js
@@ -17,10 +17,18 @@
17 rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
18 rect.right <= (window.innerWidth || document.documentElement.clientWidth)
19 );
20 };
21
22 (function(){
23 let dbg = document.querySelector('#debugMsg');
24 if(dbg){
25 /* This can inadvertently influence our flexbox layouts, so move
26 it out of the way. */
27 D.append(document.body,dbg);
28 }
29 })();
30 const ForceResizeKludge = 0 ? function(){} : (function(){
31 /* Workaround for Safari mayhem regarding use of vh CSS units....
32 We tried to use vh units to set the content area size for the
33 chat layout, but Safari chokes on that, so we calculate that
34 height here: 85% when in "normal" mode and 95% in chat-only
@@ -141,33 +149,82 @@
149 /* List of DOM elements disable while ajax traffic is in
150 transit. Must be populated before ajax starts. We do this
151 to avoid various race conditions in the UI and long-running
152 network requests. */
153 ],
154 /** Either scrolls .message-widget element eMsg into view
155 immediately or, if it represents an inlined image, delays
156 the scroll until the image is loaded, at which point it will
157 scroll to either the newest message, if one is set or to
158 eMsg (the liklihood is good, at least on initial page load,
159 that the the image won't be loaded until other messages have
160 been injected). */
161 scheduleScrollOfMsg: function(eMsg){
162 if(1===+eMsg.dataset.hasImage){
163 console.debug("Delaying scroll for IMG.");
164 eMsg.querySelector('img').addEventListener(
165 'load', ()=>(this.e.newestMessage || eMsg).scrollIntoView()
166 );
167 }else{
168 eMsg.scrollIntoView();
169 }
170 return this;
171 },
172 /* Injects element e as a new row in the chat, at the top of the
173 list if atEnd is falsy, else at the end of the list, before
174 the load-history widget. */
175 injectMessageElem: function f(e, atEnd){
176 const mip = atEnd ? this.e.loadOlderToolbar : this.e.messageInjectPoint,
177 holder = this.e.messagesWrapper,
178 prevMessage = this.e.newestMessage;
179 if(atEnd){
180 const fe = mip.nextElementSibling;
181 if(fe) mip.parentNode.insertBefore(e, fe);
182 else D.append(mip.parentNode, e);
183 }else{
184 D.append(holder,e);
185 this.e.newestMessage = e;
186 }
187 if(!atEnd && !this.isMassLoading
188 && e.dataset.xfrom!==this.me && !isInViewport(e)){
189 /* If a new non-history message arrives while the user is
190 scrolled elsewhere, do not scroll to the latest
191 message, but gently alert the user that a new message
192 has arrived. */
193 F.toast.message("New message has arrived.");
194 }else if(!this.isMassLoading && e.dataset.xfrom===Chat.me){
195 this.scheduleScrollOfMsg(e);
196 }else if(!this.isMassLoading){
197 /* When a message from someone else arrives, we have to
198 figure out whether or not to scroll it into view. Ideally
199 we'd just stuff it in the UI and let the flexbox layout
200 DTRT, but Safari has expressed, in no uncertain terms,
201 some disappointment with that approach, so we'll
202 heuristicize it: if the previous last message is in view,
203 assume the user is at or near the input element and
204 scroll this one into view. If that message is NOT in
205 view, assume the user is up reading history somewhere and
206 do NOT scroll because doing so would interrupt
207 them. There are middle grounds here where the user will
208 experience a slight UI jolt, but this heuristic mostly
209 seems to work out okay. If there was no previous message,
210 assume we don't have any messages yet and go ahead and
211 scroll this message into view (noting that that scrolling
212 is hypothetically a no-op in such cases).
213
214 The wrench in these works are posts with IMG tags, as
215 those images are loaded async and the element does not
216 yet have enough information to know how far to scroll!
217 For such cases we have to delay the scroll until the
218 image loads (and we hope it does so before another
219 message arrives).
220 */
221 if(1===+e.dataset.hasImage){
222 e.querySelector('img').addEventListener('load',()=>e.scrollIntoView());
223 }else if(!prevMessage || (prevMessage && isInViewport(prevMessage))){
224 e.scrollIntoView();
225 }
226 }
227 },
228 /** Returns true if chat-only mode is enabled. */
229 isChatOnlyMode: ()=>document.body.classList.contains('chat-only-mode'),
230 /**
@@ -178,11 +235,15 @@
235 */
236 chatOnlyMode: function f(yes){
237 if(undefined === f.elemsToToggle){
238 f.elemsToToggle = [];
239 document.querySelectorAll(
240 ["body > div.header",
241 "body > div.mainmenu",
242 "body > div.footer",
243 "#debugMsg"
244 ].join(',')
245 ).forEach((e)=>f.elemsToToggle.push(e));
246 }
247 if(!arguments.length) yes = true;
248 if(yes === this.isChatOnlyMode()) return this;
249 if(yes){
@@ -261,11 +322,15 @@
322
323 /** Finds the last .message-widget element and returns it or
324 the undefined value if none are found. */
325 cs.fetchLastMessageElem = function(){
326 const msgs = document.querySelectorAll('.message-widget');
327 var rc;
328 if(msgs.length){
329 rc = this.e.newestMessage = msgs[msgs.length-1];
330 }
331 return rc;
332 };
333
334 /**
335 LOCALLY deletes a message element by the message ID or passing
336 the .message-row element. Returns true if it removes an element,
@@ -279,12 +344,12 @@
344 }else{
345 e = this.getMessageElemById(id);
346 }
347 if(e && id){
348 D.remove(e);
349 if(e===this.e.newestMessage){
350 this.fetchLastMessageElem();
351 }
352 F.toast.message("Deleted message "+id+".");
353 }
354 return !!e;
355 };
@@ -388,10 +453,11 @@
453 if( m.fmime
454 && m.fmime.startsWith("image/")
455 && Chat.settings.getBool('images-inline',true)
456 ){
457 contentTarget.appendChild(D.img("chat-download/" + m.msgid));
458 ds.hasImage = 1;
459 }else{
460 const a = D.a(
461 window.fossil.rootPath+
462 'chat-download/' + m.msgid+'/'+encodeURIComponent(m.fname),
463 // ^^^ add m.fname to URL to cause downloaded file to have that name.
@@ -861,22 +927,17 @@
927 resumed, and reportError() produces a loud error message. */
928 .finally(function(){
929 if(isFirstCall){
930 Chat.isMassLoading = false;
931 Chat.ajaxEnd();
932 const m = Chat.e.newestMessage;
933 if(m) Chat.scheduleScrollOfMsg(m);
934 setTimeout(()=>Chat.e.inputWrapper.scrollIntoView(), 0);
 
 
 
 
 
935 }
936 poll.running=false;
937 });
938 }
939 poll.running = false;
940 poll(true);
941 setInterval(poll, 1000);
942 F.page.chat = Chat/* enables testing the APIs via the dev tools */;
943 })();
944

Keyboard Shortcuts

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