Fossil SCM

Merge markdown-tagrefs branch into trunk. Summary: adds hashtag detection to markdown-to-html. /chat adds several capabilities when seeing a #NNN reference. Discussion in [forum:3774a95d2072a595|forum post 3774a95d].

stephan 2026-08-24 16:48 UTC trunk merge
Commit 7192ce19c0233e0e03f48ab12d2ebd254356d3306d357a48ab6216475d76ea8a
--- src/backlink.c
+++ src/backlink.c
@@ -283,10 +283,12 @@
283283
static int mkdn_noop_r_html_tag(Blob *b1, Blob *b2, void *v){ return 1; }
284284
static int (*mkdn_noop_tri_emphas)(Blob*, Blob*, char,
285285
void*) = mkdn_noop_emphasis;
286286
static int mkdn_noop_footnoteref(Blob *b1, const Blob *b2, const Blob *b3,
287287
int i1, int i2, void *v){ return 1; }
288
+static int mkdn_noop_tagref(Blob *b1,Blob *b2, enum mkd_tagspan t,
289
+ void *p){ return 1; }
288290
289291
/*
290292
** Scan markdown text and add self-hyperlinks to the BACKLINK table.
291293
*/
292294
void markdown_extract_links(
@@ -317,10 +319,11 @@
317319
/* emphasis */ mkdn_noop_emphasis,
318320
/* image */ mkdn_noop_image,
319321
/* linebreak */ mkdn_noop_linebreak,
320322
/* link */ backlink_md_link,
321323
/* r_html_tag */ mkdn_noop_r_html_tag,
324
+ /* #tags */ mkdn_noop_tagref,
322325
/* tri_emphas */ mkdn_noop_tri_emphas,
323326
/* footnoteref*/ mkdn_noop_footnoteref,
324327
325328
0, /* entity */
326329
0, /* normal_text */
327330
--- src/backlink.c
+++ src/backlink.c
@@ -283,10 +283,12 @@
283 static int mkdn_noop_r_html_tag(Blob *b1, Blob *b2, void *v){ return 1; }
284 static int (*mkdn_noop_tri_emphas)(Blob*, Blob*, char,
285 void*) = mkdn_noop_emphasis;
286 static int mkdn_noop_footnoteref(Blob *b1, const Blob *b2, const Blob *b3,
287 int i1, int i2, void *v){ return 1; }
 
 
288
289 /*
290 ** Scan markdown text and add self-hyperlinks to the BACKLINK table.
291 */
292 void markdown_extract_links(
@@ -317,10 +319,11 @@
317 /* emphasis */ mkdn_noop_emphasis,
318 /* image */ mkdn_noop_image,
319 /* linebreak */ mkdn_noop_linebreak,
320 /* link */ backlink_md_link,
321 /* r_html_tag */ mkdn_noop_r_html_tag,
 
322 /* tri_emphas */ mkdn_noop_tri_emphas,
323 /* footnoteref*/ mkdn_noop_footnoteref,
324
325 0, /* entity */
326 0, /* normal_text */
327
--- src/backlink.c
+++ src/backlink.c
@@ -283,10 +283,12 @@
283 static int mkdn_noop_r_html_tag(Blob *b1, Blob *b2, void *v){ return 1; }
284 static int (*mkdn_noop_tri_emphas)(Blob*, Blob*, char,
285 void*) = mkdn_noop_emphasis;
286 static int mkdn_noop_footnoteref(Blob *b1, const Blob *b2, const Blob *b3,
287 int i1, int i2, void *v){ return 1; }
288 static int mkdn_noop_tagref(Blob *b1,Blob *b2, enum mkd_tagspan t,
289 void *p){ return 1; }
290
291 /*
292 ** Scan markdown text and add self-hyperlinks to the BACKLINK table.
293 */
294 void markdown_extract_links(
@@ -317,10 +319,11 @@
319 /* emphasis */ mkdn_noop_emphasis,
320 /* image */ mkdn_noop_image,
321 /* linebreak */ mkdn_noop_linebreak,
322 /* link */ backlink_md_link,
323 /* r_html_tag */ mkdn_noop_r_html_tag,
324 /* #tags */ mkdn_noop_tagref,
325 /* tri_emphas */ mkdn_noop_tri_emphas,
326 /* footnoteref*/ mkdn_noop_footnoteref,
327
328 0, /* entity */
329 0, /* normal_text */
330
+1
--- src/chat.c
+++ src/chat.c
@@ -216,10 +216,11 @@
216216
@ </span>
217217
@ <span>Active users (sorted by last message time)</span>
218218
@ </div>
219219
@ <div id='chat-user-list'></div>
220220
@ </div>
221
+ @ <button id='chat-clear-filter' class='hidden'>Clear filter</button>
221222
@ <div id='chat-preview' class='hidden chat-view'>
222223
@ <header>Preview: (<a href='%R/md_rules' target='_blank'>markdown reference</a>)</header>
223224
@ <div id='chat-preview-content'></div>
224225
@ <div class='button-bar'><button class='action-close'>Close Preview</button></div>
225226
@ </div>
226227
--- src/chat.c
+++ src/chat.c
@@ -216,10 +216,11 @@
216 @ </span>
217 @ <span>Active users (sorted by last message time)</span>
218 @ </div>
219 @ <div id='chat-user-list'></div>
220 @ </div>
 
221 @ <div id='chat-preview' class='hidden chat-view'>
222 @ <header>Preview: (<a href='%R/md_rules' target='_blank'>markdown reference</a>)</header>
223 @ <div id='chat-preview-content'></div>
224 @ <div class='button-bar'><button class='action-close'>Close Preview</button></div>
225 @ </div>
226
--- src/chat.c
+++ src/chat.c
@@ -216,10 +216,11 @@
216 @ </span>
217 @ <span>Active users (sorted by last message time)</span>
218 @ </div>
219 @ <div id='chat-user-list'></div>
220 @ </div>
221 @ <button id='chat-clear-filter' class='hidden'>Clear filter</button>
222 @ <div id='chat-preview' class='hidden chat-view'>
223 @ <header>Preview: (<a href='%R/md_rules' target='_blank'>markdown reference</a>)</header>
224 @ <div id='chat-preview-content'></div>
225 @ <div class='button-bar'><button class='action-close'>Close Preview</button></div>
226 @ </div>
227
--- src/fossil.bootstrap.js
+++ src/fossil.bootstrap.js
@@ -283,19 +283,21 @@
283283
}
284284
return this;
285285
};
286286
287287
/**
288
- Sets the innerText of the page's TITLE tag to
289
- the given text and returns this object.
288
+ Sets the innerText of the page's TITLE tag to the given text and
289
+ returns this object. If passed a falsy value then the title is
290
+ reverted to its page-load-time value.
290291
*/
291
- F.page.setPageTitle = function(title){
292
+ F.page.setPageTitle = function f(title){
292293
const t = document.querySelector('title');
293
- if(t) t.innerText = title;
294
+ if(t) t.innerText = title || f.$orig;
294295
return this;
295296
};
296
-
297
+ F.onPageLoad(()=>F.page.setPageTitle.$orig
298
+ = document.querySelector('title').innerText);
297299
/**
298300
Returns a function, that, as long as it continues to be invoked,
299301
will not be triggered. The function will be called after it stops
300302
being called for N milliseconds. If `immediate` is passed, call
301303
the callback immediately and hinder future invocations until at
302304
--- src/fossil.bootstrap.js
+++ src/fossil.bootstrap.js
@@ -283,19 +283,21 @@
283 }
284 return this;
285 };
286
287 /**
288 Sets the innerText of the page's TITLE tag to
289 the given text and returns this object.
 
290 */
291 F.page.setPageTitle = function(title){
292 const t = document.querySelector('title');
293 if(t) t.innerText = title;
294 return this;
295 };
296
 
297 /**
298 Returns a function, that, as long as it continues to be invoked,
299 will not be triggered. The function will be called after it stops
300 being called for N milliseconds. If `immediate` is passed, call
301 the callback immediately and hinder future invocations until at
302
--- src/fossil.bootstrap.js
+++ src/fossil.bootstrap.js
@@ -283,19 +283,21 @@
283 }
284 return this;
285 };
286
287 /**
288 Sets the innerText of the page's TITLE tag to the given text and
289 returns this object. If passed a falsy value then the title is
290 reverted to its page-load-time value.
291 */
292 F.page.setPageTitle = function f(title){
293 const t = document.querySelector('title');
294 if(t) t.innerText = title || f.$orig;
295 return this;
296 };
297 F.onPageLoad(()=>F.page.setPageTitle.$orig
298 = document.querySelector('title').innerText);
299 /**
300 Returns a function, that, as long as it continues to be invoked,
301 will not be triggered. The function will be called after it stops
302 being called for N milliseconds. If `immediate` is passed, call
303 the callback immediately and hinder future invocations until at
304
--- src/fossil.page.chat.js
+++ src/fossil.page.chat.js
@@ -90,15 +90,14 @@
9090
While we're here, we also use this to cap the max-height
9191
of the input field so that pasting huge text does not scroll
9292
the upper area of the input widget off-screen. */
9393
const elemsToCount = GetFramingElements();
9494
const contentArea = E1('div.content');
95
- const bcl = document.body.classList;
9695
const resized = function f(){
9796
if(f.$disabled) return;
9897
const wh = window.innerHeight,
99
- com = bcl.contains('chat-only-mode');
98
+ com = document.body.classList.contains('chat-only-mode');
10099
var ht;
101100
var extra = 0;
102101
if(com){
103102
ht = wh;
104103
}else{
@@ -161,10 +160,11 @@
161160
searchContent: E1('#chat-search-content'),
162161
btnPreview: E1('#chat-button-preview'),
163162
views: document.querySelectorAll('.chat-view'),
164163
activeUserListWrapper: E1('#chat-user-list-wrapper'),
165164
activeUserList: E1('#chat-user-list'),
165
+ btnClearFilter: E1('#chat-clear-filter'),
166166
eMsgPollError: undefined /* current connection error MessageMidget */,
167167
pollErrorMarker: document.body /* element to toggle 'connection-error' CSS class on */
168168
},
169169
me: F.user.name,
170170
mxMsg: F.config.chat.initSize ? -F.config.chat.initSize : -50,
@@ -181,15 +181,31 @@
181181
(JS Date object). Only messages received by the chat client
182182
are considered. */
183183
/* Reminder: to convert a Julian time J to JS:
184184
new Date((J - 2440587.5) * 86400000) */
185185
},
186
- filterState:{
187
- activeUser: undefined,
188
- match: function(uname){
189
- return this.activeUser===uname || !this.activeUser;
190
- }
186
+ filter: {
187
+ user:{
188
+ activeTag: undefined,
189
+ match: function(uname){
190
+ return !this.activeTag || this.activeTag===uname;
191
+ },
192
+ matchElem: function(e){
193
+ return !this.activeTag || this.activeTag===e.dataset.xfrom;
194
+ }
195
+ },
196
+ hashtag:{
197
+ activeTag: undefined,
198
+ match: function(tag){
199
+ return !this.activeTag || tag===this.activeTag;
200
+ },
201
+ matchElem: function(e){
202
+ return !this.activeTag
203
+ || !!e.querySelector('[data-hashtag="'+this.activeTag+'"]');
204
+ }
205
+ },
206
+ current: undefined/*gets set to current active filter*/
191207
},
192208
/**
193209
The timer object is used to control connection throttling
194210
when connection errors arrise. It starts off with a polling
195211
delay of $initialDelay ms. If there's a connection error,
@@ -376,11 +392,12 @@
376392
the list. */
377393
injectMessageElem: function f(e, atEnd){
378394
const mip = atEnd ? this.e.loadOlderToolbar : this.e.messageInjectPoint,
379395
holder = this.e.viewMessages,
380396
prevMessage = this.e.newestMessage;
381
- if(!this.filterState.match(e.dataset.xfrom)){
397
+ if(this.filter.current
398
+ && !this.filter.current.matchElem(e)){
382399
e.classList.add('hidden');
383400
}
384401
if(atEnd){
385402
const fe = mip.nextElementSibling;
386403
if(fe) mip.parentNode.insertBefore(e, fe);
@@ -622,11 +639,10 @@
622639
}
623640
this.e.views.forEach(function(E){
624641
if(e!==E) D.addClass(E,'hidden');
625642
});
626643
this.e.currentView = e;
627
- if(this.e.currentView.$beforeShow) this.e.currentView.$beforeShow();
628644
D.removeClass(e,'hidden');
629645
this.animate(this.e.currentView, 'anim-fade-in-fast');
630646
return this.e.currentView;
631647
},
632648
@@ -674,11 +690,11 @@
674690
else return 0;
675691
};
676692
callee.addUserElem = function(u){
677693
const uSpan = D.addClass(D.span(), 'chat-user');
678694
const uDate = self.usersLastSeen[u];
679
- if(self.filterState.activeUser===u){
695
+ if(self.filter.user.activeTag===u){
680696
uSpan.classList.add('selected');
681697
}
682698
uSpan.dataset.uname = u;
683699
D.append(uSpan, u, "\n",
684700
D.append(
@@ -696,11 +712,82 @@
696712
Object.keys(this.usersLastSeen).sort(
697713
callee.sortUsersSeen
698714
).forEach(callee.addUserElem);
699715
return this;
700716
},
701
- /** Show or hide the active user list. Returns this object. */
717
+ /**
718
+ For each Chat.MessageWidget element (X.message-widget) for
719
+ which predicate(elem) returns true, the 'hidden' class is
720
+ removed from that message. For all others, 'hidden' is
721
+ added. If predicate is falsy, 'hidden' is removed from all
722
+ elements. After filtering, it will try to scroll the last
723
+ not-filtered-out message into view, but exactly where it
724
+ scrolls into view (top, middle, button) is
725
+ unpredictable. Returns this object.
726
+
727
+ The argument may optionally be an object from this.filter,
728
+ in which case its matchElem() method becomes the predicate.
729
+
730
+ Note that this does not encapsulate certain filter-specific
731
+ logic which applies changes to elements other than the
732
+ main message list or this.e.btnClearFilter.
733
+ */
734
+ applyMessageFilter: function(predicate){
735
+ const self = this;
736
+ let eLast;
737
+ console.debug("applyMessageFilter(",predicate,")");
738
+ if(!predicate){
739
+ D.removeClass(this.e.viewMessages.querySelectorAll('.message-widget.hidden'),
740
+ 'hidden');
741
+ D.addClass(this.e.btnClearFilter, 'hidden');
742
+ }else if('function'!==typeof predicate
743
+ && predicate.matchElem){
744
+ /* assume Chat.filter object */
745
+ const p = predicate;
746
+ predicate = (e)=>p.matchElem(e);
747
+ }
748
+ if(predicate){
749
+ this.e.viewMessages.querySelectorAll('.message-widget').forEach(function(e){
750
+ if(predicate(e)){
751
+ e.classList.remove('hidden');
752
+ eLast = e;
753
+ }else{
754
+ e.classList.add('hidden');
755
+ }
756
+ });
757
+ D.removeClass(this.e.btnClearFilter, 'hidden');
758
+ }
759
+ this.setCurrentView(this.e.viewMessages);
760
+ if(eLast) eLast.scrollIntoView(false);
761
+ else this.scrollMessagesTo(1);
762
+ return this;
763
+ },
764
+ /**
765
+ Clears the current message filter, if any, and clears the
766
+ activeTag property of all members of this.filter. Returns
767
+ this object. This also unfortunately performs some
768
+ filter-type-specific logic which we have not yet managed to
769
+ encapsulate more cleanly.
770
+ */
771
+ clearFilters: function(){
772
+ if(!this.filter.current) return this;
773
+ this.filter.current = undefined;
774
+ this.applyMessageFilter(false);
775
+ const self = this;
776
+ Object.keys(this.filter).forEach(function(k){
777
+ const f = self.filter[k];
778
+ if(f) f.activeTag = undefined;
779
+ });
780
+ this.e.activeUserList.querySelectorAll('.chat-user').forEach(
781
+ /*Unfortante filter-specific logic*/
782
+ (e)=>e.classList.remove('selected')
783
+ );
784
+ return this;
785
+ },
786
+ /**
787
+ Show or hide the active user list. Returns this object.
788
+ */
702789
showActiveUserList: function(yes){
703790
if(0===arguments.length) yes = true;
704791
this.e.activeUserListWrapper.classList[
705792
yes ? 'remove' : 'add'
706793
]('hidden');
@@ -726,32 +813,38 @@
726813
/**
727814
Applies user name filter to all current messages, or clears
728815
the filter if uname is falsy.
729816
*/
730817
setUserFilter: function(uname){
731
- this.filterState.activeUser = uname;
732
- const mw = this.e.viewMessages.querySelectorAll('.message-widget');
818
+ if(!uname || (this.filter.current
819
+ && this.filter.current!==this.filter.user)){
820
+ this.clearFilters();
821
+ }
822
+ this.filter.user.activeTag = uname;
823
+ if(uname) this.applyMessageFilter(this.filter.user);
824
+ this.filter.current = uname ? this.filter.user : undefined;
733825
const self = this;
734
- let eLast;
735
- if(!uname){
736
- D.removeClass(Chat.e.viewMessages.querySelectorAll('.message-widget.hidden'),
737
- 'hidden');
738
- }else{
739
- mw.forEach(function(w){
740
- if(self.filterState.match(w.dataset.xfrom)){
741
- w.classList.remove('hidden');
742
- eLast = w;
743
- }else{
744
- w.classList.add('hidden');
745
- }
746
- });
747
- }
748
- if(eLast) eLast.scrollIntoView(false);
749
- else this.scrollMessagesTo(1);
750
- cs.e.activeUserList.querySelectorAll('.chat-user').forEach(function(e){
751
- e.classList[uname===e.dataset.uname ? 'add' : 'remove']('selected');
752
- });
826
+ this.e.activeUserList.querySelectorAll('.chat-user').forEach(function(e){
827
+ e.classList[
828
+ self.filter.user.activeTag===e.dataset.uname
829
+ ? 'add' : 'remove'
830
+ ]('selected');
831
+ });
832
+ return this;
833
+ },
834
+ /**
835
+ Applies a hashtag filter to all current messages, or clears
836
+ the filter if tag is falsy.
837
+ */
838
+ setHashtagFilter: function(tag){
839
+ if(!tag || (this.filter.current
840
+ && this.filter.current!==this.filter.hashtag)){
841
+ this.clearFilters();
842
+ }
843
+ this.filter.hashtag.activeTag = tag;
844
+ if(tag) this.applyMessageFilter(this.filter.hashtag);
845
+ this.filter.current = tag ? this.filter.hashtag : undefined;
753846
return this;
754847
},
755848
756849
/**
757850
If animations are enabled, passes its arguments
@@ -1073,22 +1166,106 @@
10731166
cs.setCurrentView(cs.e.viewMessages);
10741167
if(eUser.classList.contains('selected')){
10751168
/* If currently selected, toggle filter off */
10761169
eUser.classList.remove('selected');
10771170
cs.setUserFilter(false);
1078
- delete f.$eSelected;
10791171
}else{
1080
- if(f.$eSelected) f.$eSelected.classList.remove('selected');
1081
- f.$eSelected = eUser;
10821172
eUser.classList.add('selected');
10831173
cs.setUserFilter(uname);
10841174
}
10851175
return false;
10861176
}, false);
1177
+
1178
+ cs.e.btnClearFilter.addEventListener('click',function(){
1179
+ D.addClass(this,'hidden');
1180
+ cs.clearFilters();
1181
+ }, false);
10871182
return cs;
10881183
})()/*Chat initialization*/;
10891184
1185
+ /**
1186
+ An experiment in history navigation: when a message numtag is
1187
+ clicked, we push the origin message onto the history and
1188
+ set up the back button to return to that message.
1189
+ */
1190
+ window.onpopstate = function(event){
1191
+ const msgid = Chat.numtagHistoryStack.pop();
1192
+ if(msgid){
1193
+ const e = Chat.setCurrentView(Chat.e.viewMessages).
1194
+ querySelector('.message-widget[data-msgid="'+msgid+'"]');
1195
+ //console.debug("Popping history back to",msgid, e);
1196
+ if(e){
1197
+ Chat.MessageWidget.scrollToMessageElem(e);
1198
+ return;
1199
+ }
1200
+ }
1201
+ Chat.scrollMessagesTo(1);
1202
+ };
1203
+ Chat.numtagHistoryStack = [
1204
+ /* Relying on the pushHistory() state object for holding
1205
+ the message ID is completely misbehaving, not giving
1206
+ us the expected state object when window.onpopstate
1207
+ is triggered (plus, the browser persists it, which
1208
+ introduces its own problems). Thus we use our own
1209
+ stack of message IDs for history navigation purposes. */];
1210
+
1211
+ /** If e or one of its parents has the given CSS class, that element
1212
+ is returned, else falsy is returned. */
1213
+ const findParentWithClass = function(e, className){
1214
+ while(e && !e.classList.contains(className)){
1215
+ e = e.parentNode;
1216
+ }
1217
+ return e;
1218
+ };
1219
+
1220
+ /** To be passed each MessageWidget's top-level DOM element
1221
+ after initial processing of the message, to set up
1222
+ hashtag and numtag references. */
1223
+ const setupHashtags = function f(elem){
1224
+ if(!f.$clickTag){
1225
+ f.$clickTag = function(ev){
1226
+ /* Click handler for hashtags */
1227
+ const tag = ev.target.dataset.hashtag;
1228
+ if(tag){
1229
+ Chat.setHashtagFilter(
1230
+ tag===Chat.filter.hashtag.activeTag
1231
+ ? false : tag
1232
+ );
1233
+ }
1234
+ };
1235
+ f.$clickNum = function(ev){
1236
+ /* Click handler for #NNN references */
1237
+ const tag = ev.target.dataset.numtag;
1238
+ if(tag){
1239
+ const e = Chat.e.viewMessages.querySelector(
1240
+ '.message-widget[data-msgid="'+tag+'"]'
1241
+ );
1242
+ if(e){
1243
+ Chat.MessageWidget.scrollToMessageElem(e);
1244
+ //Set up window.history() state...
1245
+ const p = 0 ? false : findParentWithClass(ev.target, 'message-widget');
1246
+ if(p){
1247
+ const state = {msgId: p.dataset.msgid};
1248
+ Chat.numtagHistoryStack.push(p.dataset.msgid);
1249
+ const rc = window.history.pushState(state, "");
1250
+ //console.debug("Pushing history for msgid", state);
1251
+ //console.debug("Chat.numtagHistoryStack =",Chat.numtagHistoryStack);
1252
+ }
1253
+ }else{
1254
+ Chat.submitSearch('#'+tag);
1255
+ }
1256
+ }
1257
+ };
1258
+ }
1259
+ elem.querySelectorAll('[data-hashtag]').forEach(function(e){
1260
+ e.dataset.hashtag = e.dataset.hashtag.toLowerCase();
1261
+ e.addEventListener('click', f.$clickTag, false);
1262
+ })
1263
+ elem.querySelectorAll('[data-numtag]').forEach(
1264
+ (e)=>e.addEventListener('click', f.$clickNum, false)
1265
+ )
1266
+ }/*setupHashtags()*/;
10901267
10911268
/** Returns the first .message-widget element in DOM element
10921269
e's lineage. */
10931270
const findMessageWidgetParent = function(e){
10941271
while( e && !e.classList.contains('message-widget')){
@@ -1354,10 +1531,11 @@
13541531
// Used by Chat.reportErrorAsMessage()
13551532
D.append(contentTarget, m.xmsg);
13561533
}else{
13571534
contentTarget.innerHTML = m.xmsg;
13581535
contentTarget.querySelectorAll('a').forEach(addAnchorTargetBlank);
1536
+ setupHashtags(contentTarget);
13591537
if(F.pikchr){
13601538
F.pikchr.addSrcView(contentTarget.querySelectorAll('svg.pikchr'));
13611539
}
13621540
}
13631541
}
@@ -1470,31 +1648,23 @@
14701648
y: 'a'
14711649
}), "User's Timeline"),
14721650
'target', '_blank'
14731651
);
14741652
D.append(toolbar2, timelineLink);
1475
- if(Chat.filterState.activeUser &&
1476
- Chat.filterState.match(eMsg.dataset.xfrom)){
1477
- /* Add a button to clear user filter and jump to
1653
+ if(Chat.filter.current){
1654
+ /* Add a button to clear filter and jump to
14781655
this message in its original context. */
14791656
D.append(
14801657
this.e,
14811658
D.append(
14821659
D.addClass(D.div(), 'toolbar'),
14831660
D.button(
14841661
"Message in context",
14851662
function(){
14861663
self.hide();
1487
- Chat.setUserFilter(false);
1488
- eMsg.scrollIntoView(false);
1489
- Chat.animate(
1490
- eMsg.firstElementChild, 'anim-flip-h'
1491
- //eMsg.firstElementChild, 'anim-flip-v'
1492
- //eMsg.childNodes, 'anim-rotate-360'
1493
- //eMsg.childNodes, 'anim-flip-v'
1494
- //eMsg, 'anim-flip-v'
1495
- );
1664
+ Chat.clearFilters();
1665
+ Chat.MessageWidget.scrollToMessageElem(eMsg);
14961666
})
14971667
)
14981668
);
14991669
}/*jump-to button*/
15001670
}
@@ -1524,10 +1694,20 @@
15241694
}/*f.popup*/;
15251695
}/*end static init*/
15261696
const theMsg = findMessageWidgetParent(ev.target);
15271697
if(theMsg) f.popup.show(theMsg);
15281698
}/*_handleLegendClicked()*/
1699
+ }/*MessageWidget.prototype*/;
1700
+ /** Assumes that e is a MessageWidget element, ensures that
1701
+ Chat.e.viewMessages is visible, scrolls the message,
1702
+ and animates it a bit to make it more visible. */
1703
+ ctor.scrollToMessageElem = function(e){
1704
+ if(e.firstElementChild){
1705
+ Chat.setCurrentView(Chat.e.viewMessages);
1706
+ e.scrollIntoView(false);
1707
+ Chat.animate(e, 'anim-fade-out-in');
1708
+ }
15291709
};
15301710
return ctor;
15311711
})()/*MessageWidget*/;
15321712
15331713
/**
@@ -2067,11 +2247,12 @@
20672247
}/*namedOptions.activeUsers additional setup*/
20682248
/* Settings menu entries... they are presented in the order listed
20692249
here, so the most frequently-needed ones "should" (arguably) be
20702250
closer to the start of this list. */
20712251
/**
2072
- Settings ops structure:
2252
+ Settings options structure: an array of Objects with the
2253
+ following properties:
20732254
20742255
label: string for the UI
20752256
20762257
boolValue: string (name of Chat.settings setting) or a function
20772258
which returns true or false. If it is a string, it gets
@@ -2080,10 +2261,11 @@
20802261
to the persistentSetting property of this object.
20812262
20822263
select: SELECT element (instead of boolValue)
20832264
20842265
callback: optional handler to call after setting is modified.
2266
+ It gets passed the setting object: {key:string, value:something}.
20852267
Its "this" is the options object. If this object has a
20862268
boolValue string or a persistentSetting property, the argument
20872269
passed to the callback is a settings object in the form {key:K,
20882270
value:V}. If this object does not have boolValue string or
20892271
persistentSetting then the callback is passed an event object
@@ -2410,10 +2592,11 @@
24102592
const btnPreview = Chat.e.btnPreview;
24112593
Chat.setPreviewText = function(t){
24122594
this.setCurrentView(this.e.viewPreview);
24132595
this.e.previewContent.innerHTML = t;
24142596
this.e.viewPreview.querySelectorAll('a').forEach(addAnchorTargetBlank);
2597
+ setupHashtags(this.e.previewContent)/*arguable, for usability reasons*/;
24152598
this.inputFocus();
24162599
};
24172600
Chat.e.viewPreview.querySelector('button.action-close').
24182601
addEventListener('click', ()=>Chat.setCurrentView(Chat.e.viewMessages), false);
24192602
let previewPending = false;
@@ -2652,15 +2835,16 @@
26522835
}
26532836
return e;
26542837
};
26552838
Chat.clearSearch(true);
26562839
/**
2657
- Submits a history search using the main input field's current
2658
- text. It is assumed that Chat.e.viewSearch===Chat.e.currentView.
2840
+ Submits a history search using either its argument or the the
2841
+ main input field's current text.
26592842
*/
2660
- Chat.submitSearch = function(){
2661
- const term = this.inputValue(true);
2843
+ Chat.submitSearch = function(term){
2844
+ Chat.setCurrentView(Chat.e.viewSearch);
2845
+ if(!arguments.length) term = this.inputValue(true);
26622846
const eMsgTgt = this.clearSearch(true);
26632847
if( !term ) return;
26642848
D.append( eMsgTgt, "Searching for ",term," ...");
26652849
const fd = new FormData();
26662850
fd.set('q', term);
26672851
--- src/fossil.page.chat.js
+++ src/fossil.page.chat.js
@@ -90,15 +90,14 @@
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{
@@ -161,10 +160,11 @@
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,
@@ -181,15 +181,31 @@
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,
@@ -376,11 +392,12 @@
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);
@@ -622,11 +639,10 @@
622 }
623 this.e.views.forEach(function(E){
624 if(e!==E) D.addClass(E,'hidden');
625 });
626 this.e.currentView = e;
627 if(this.e.currentView.$beforeShow) this.e.currentView.$beforeShow();
628 D.removeClass(e,'hidden');
629 this.animate(this.e.currentView, 'anim-fade-in-fast');
630 return this.e.currentView;
631 },
632
@@ -674,11 +690,11 @@
674 else return 0;
675 };
676 callee.addUserElem = function(u){
677 const uSpan = D.addClass(D.span(), 'chat-user');
678 const uDate = self.usersLastSeen[u];
679 if(self.filterState.activeUser===u){
680 uSpan.classList.add('selected');
681 }
682 uSpan.dataset.uname = u;
683 D.append(uSpan, u, "\n",
684 D.append(
@@ -696,11 +712,82 @@
696 Object.keys(this.usersLastSeen).sort(
697 callee.sortUsersSeen
698 ).forEach(callee.addUserElem);
699 return this;
700 },
701 /** Show or hide the active user list. Returns this object. */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
702 showActiveUserList: function(yes){
703 if(0===arguments.length) yes = true;
704 this.e.activeUserListWrapper.classList[
705 yes ? 'remove' : 'add'
706 ]('hidden');
@@ -726,32 +813,38 @@
726 /**
727 Applies user name filter to all current messages, or clears
728 the filter if uname is falsy.
729 */
730 setUserFilter: function(uname){
731 this.filterState.activeUser = uname;
732 const mw = this.e.viewMessages.querySelectorAll('.message-widget');
 
 
 
 
 
733 const self = this;
734 let eLast;
735 if(!uname){
736 D.removeClass(Chat.e.viewMessages.querySelectorAll('.message-widget.hidden'),
737 'hidden');
738 }else{
739 mw.forEach(function(w){
740 if(self.filterState.match(w.dataset.xfrom)){
741 w.classList.remove('hidden');
742 eLast = w;
743 }else{
744 w.classList.add('hidden');
745 }
746 });
747 }
748 if(eLast) eLast.scrollIntoView(false);
749 else this.scrollMessagesTo(1);
750 cs.e.activeUserList.querySelectorAll('.chat-user').forEach(function(e){
751 e.classList[uname===e.dataset.uname ? 'add' : 'remove']('selected');
752 });
 
753 return this;
754 },
755
756 /**
757 If animations are enabled, passes its arguments
@@ -1073,22 +1166,106 @@
1073 cs.setCurrentView(cs.e.viewMessages);
1074 if(eUser.classList.contains('selected')){
1075 /* If currently selected, toggle filter off */
1076 eUser.classList.remove('selected');
1077 cs.setUserFilter(false);
1078 delete f.$eSelected;
1079 }else{
1080 if(f.$eSelected) f.$eSelected.classList.remove('selected');
1081 f.$eSelected = eUser;
1082 eUser.classList.add('selected');
1083 cs.setUserFilter(uname);
1084 }
1085 return false;
1086 }, false);
 
 
 
 
 
1087 return cs;
1088 })()/*Chat initialization*/;
1089
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1090
1091 /** Returns the first .message-widget element in DOM element
1092 e's lineage. */
1093 const findMessageWidgetParent = function(e){
1094 while( e && !e.classList.contains('message-widget')){
@@ -1354,10 +1531,11 @@
1354 // Used by Chat.reportErrorAsMessage()
1355 D.append(contentTarget, m.xmsg);
1356 }else{
1357 contentTarget.innerHTML = m.xmsg;
1358 contentTarget.querySelectorAll('a').forEach(addAnchorTargetBlank);
 
1359 if(F.pikchr){
1360 F.pikchr.addSrcView(contentTarget.querySelectorAll('svg.pikchr'));
1361 }
1362 }
1363 }
@@ -1470,31 +1648,23 @@
1470 y: 'a'
1471 }), "User's Timeline"),
1472 'target', '_blank'
1473 );
1474 D.append(toolbar2, timelineLink);
1475 if(Chat.filterState.activeUser &&
1476 Chat.filterState.match(eMsg.dataset.xfrom)){
1477 /* Add a button to clear user filter and jump to
1478 this message in its original context. */
1479 D.append(
1480 this.e,
1481 D.append(
1482 D.addClass(D.div(), 'toolbar'),
1483 D.button(
1484 "Message in context",
1485 function(){
1486 self.hide();
1487 Chat.setUserFilter(false);
1488 eMsg.scrollIntoView(false);
1489 Chat.animate(
1490 eMsg.firstElementChild, 'anim-flip-h'
1491 //eMsg.firstElementChild, 'anim-flip-v'
1492 //eMsg.childNodes, 'anim-rotate-360'
1493 //eMsg.childNodes, 'anim-flip-v'
1494 //eMsg, 'anim-flip-v'
1495 );
1496 })
1497 )
1498 );
1499 }/*jump-to button*/
1500 }
@@ -1524,10 +1694,20 @@
1524 }/*f.popup*/;
1525 }/*end static init*/
1526 const theMsg = findMessageWidgetParent(ev.target);
1527 if(theMsg) f.popup.show(theMsg);
1528 }/*_handleLegendClicked()*/
 
 
 
 
 
 
 
 
 
 
1529 };
1530 return ctor;
1531 })()/*MessageWidget*/;
1532
1533 /**
@@ -2067,11 +2247,12 @@
2067 }/*namedOptions.activeUsers additional setup*/
2068 /* Settings menu entries... they are presented in the order listed
2069 here, so the most frequently-needed ones "should" (arguably) be
2070 closer to the start of this list. */
2071 /**
2072 Settings ops structure:
 
2073
2074 label: string for the UI
2075
2076 boolValue: string (name of Chat.settings setting) or a function
2077 which returns true or false. If it is a string, it gets
@@ -2080,10 +2261,11 @@
2080 to the persistentSetting property of this object.
2081
2082 select: SELECT element (instead of boolValue)
2083
2084 callback: optional handler to call after setting is modified.
 
2085 Its "this" is the options object. If this object has a
2086 boolValue string or a persistentSetting property, the argument
2087 passed to the callback is a settings object in the form {key:K,
2088 value:V}. If this object does not have boolValue string or
2089 persistentSetting then the callback is passed an event object
@@ -2410,10 +2592,11 @@
2410 const btnPreview = Chat.e.btnPreview;
2411 Chat.setPreviewText = function(t){
2412 this.setCurrentView(this.e.viewPreview);
2413 this.e.previewContent.innerHTML = t;
2414 this.e.viewPreview.querySelectorAll('a').forEach(addAnchorTargetBlank);
 
2415 this.inputFocus();
2416 };
2417 Chat.e.viewPreview.querySelector('button.action-close').
2418 addEventListener('click', ()=>Chat.setCurrentView(Chat.e.viewMessages), false);
2419 let previewPending = false;
@@ -2652,15 +2835,16 @@
2652 }
2653 return e;
2654 };
2655 Chat.clearSearch(true);
2656 /**
2657 Submits a history search using the main input field's current
2658 text. It is assumed that Chat.e.viewSearch===Chat.e.currentView.
2659 */
2660 Chat.submitSearch = function(){
2661 const term = this.inputValue(true);
 
2662 const eMsgTgt = this.clearSearch(true);
2663 if( !term ) return;
2664 D.append( eMsgTgt, "Searching for ",term," ...");
2665 const fd = new FormData();
2666 fd.set('q', term);
2667
--- src/fossil.page.chat.js
+++ src/fossil.page.chat.js
@@ -90,15 +90,14 @@
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 resized = function f(){
96 if(f.$disabled) return;
97 const wh = window.innerHeight,
98 com = document.body.classList.contains('chat-only-mode');
99 var ht;
100 var extra = 0;
101 if(com){
102 ht = wh;
103 }else{
@@ -161,10 +160,11 @@
160 searchContent: E1('#chat-search-content'),
161 btnPreview: E1('#chat-button-preview'),
162 views: document.querySelectorAll('.chat-view'),
163 activeUserListWrapper: E1('#chat-user-list-wrapper'),
164 activeUserList: E1('#chat-user-list'),
165 btnClearFilter: E1('#chat-clear-filter'),
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,
@@ -181,15 +181,31 @@
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 filter: {
187 user:{
188 activeTag: undefined,
189 match: function(uname){
190 return !this.activeTag || this.activeTag===uname;
191 },
192 matchElem: function(e){
193 return !this.activeTag || this.activeTag===e.dataset.xfrom;
194 }
195 },
196 hashtag:{
197 activeTag: undefined,
198 match: function(tag){
199 return !this.activeTag || tag===this.activeTag;
200 },
201 matchElem: function(e){
202 return !this.activeTag
203 || !!e.querySelector('[data-hashtag="'+this.activeTag+'"]');
204 }
205 },
206 current: undefined/*gets set to current active filter*/
207 },
208 /**
209 The timer object is used to control connection throttling
210 when connection errors arrise. It starts off with a polling
211 delay of $initialDelay ms. If there's a connection error,
@@ -376,11 +392,12 @@
392 the list. */
393 injectMessageElem: function f(e, atEnd){
394 const mip = atEnd ? this.e.loadOlderToolbar : this.e.messageInjectPoint,
395 holder = this.e.viewMessages,
396 prevMessage = this.e.newestMessage;
397 if(this.filter.current
398 && !this.filter.current.matchElem(e)){
399 e.classList.add('hidden');
400 }
401 if(atEnd){
402 const fe = mip.nextElementSibling;
403 if(fe) mip.parentNode.insertBefore(e, fe);
@@ -622,11 +639,10 @@
639 }
640 this.e.views.forEach(function(E){
641 if(e!==E) D.addClass(E,'hidden');
642 });
643 this.e.currentView = e;
 
644 D.removeClass(e,'hidden');
645 this.animate(this.e.currentView, 'anim-fade-in-fast');
646 return this.e.currentView;
647 },
648
@@ -674,11 +690,11 @@
690 else return 0;
691 };
692 callee.addUserElem = function(u){
693 const uSpan = D.addClass(D.span(), 'chat-user');
694 const uDate = self.usersLastSeen[u];
695 if(self.filter.user.activeTag===u){
696 uSpan.classList.add('selected');
697 }
698 uSpan.dataset.uname = u;
699 D.append(uSpan, u, "\n",
700 D.append(
@@ -696,11 +712,82 @@
712 Object.keys(this.usersLastSeen).sort(
713 callee.sortUsersSeen
714 ).forEach(callee.addUserElem);
715 return this;
716 },
717 /**
718 For each Chat.MessageWidget element (X.message-widget) for
719 which predicate(elem) returns true, the 'hidden' class is
720 removed from that message. For all others, 'hidden' is
721 added. If predicate is falsy, 'hidden' is removed from all
722 elements. After filtering, it will try to scroll the last
723 not-filtered-out message into view, but exactly where it
724 scrolls into view (top, middle, button) is
725 unpredictable. Returns this object.
726
727 The argument may optionally be an object from this.filter,
728 in which case its matchElem() method becomes the predicate.
729
730 Note that this does not encapsulate certain filter-specific
731 logic which applies changes to elements other than the
732 main message list or this.e.btnClearFilter.
733 */
734 applyMessageFilter: function(predicate){
735 const self = this;
736 let eLast;
737 console.debug("applyMessageFilter(",predicate,")");
738 if(!predicate){
739 D.removeClass(this.e.viewMessages.querySelectorAll('.message-widget.hidden'),
740 'hidden');
741 D.addClass(this.e.btnClearFilter, 'hidden');
742 }else if('function'!==typeof predicate
743 && predicate.matchElem){
744 /* assume Chat.filter object */
745 const p = predicate;
746 predicate = (e)=>p.matchElem(e);
747 }
748 if(predicate){
749 this.e.viewMessages.querySelectorAll('.message-widget').forEach(function(e){
750 if(predicate(e)){
751 e.classList.remove('hidden');
752 eLast = e;
753 }else{
754 e.classList.add('hidden');
755 }
756 });
757 D.removeClass(this.e.btnClearFilter, 'hidden');
758 }
759 this.setCurrentView(this.e.viewMessages);
760 if(eLast) eLast.scrollIntoView(false);
761 else this.scrollMessagesTo(1);
762 return this;
763 },
764 /**
765 Clears the current message filter, if any, and clears the
766 activeTag property of all members of this.filter. Returns
767 this object. This also unfortunately performs some
768 filter-type-specific logic which we have not yet managed to
769 encapsulate more cleanly.
770 */
771 clearFilters: function(){
772 if(!this.filter.current) return this;
773 this.filter.current = undefined;
774 this.applyMessageFilter(false);
775 const self = this;
776 Object.keys(this.filter).forEach(function(k){
777 const f = self.filter[k];
778 if(f) f.activeTag = undefined;
779 });
780 this.e.activeUserList.querySelectorAll('.chat-user').forEach(
781 /*Unfortante filter-specific logic*/
782 (e)=>e.classList.remove('selected')
783 );
784 return this;
785 },
786 /**
787 Show or hide the active user list. Returns this object.
788 */
789 showActiveUserList: function(yes){
790 if(0===arguments.length) yes = true;
791 this.e.activeUserListWrapper.classList[
792 yes ? 'remove' : 'add'
793 ]('hidden');
@@ -726,32 +813,38 @@
813 /**
814 Applies user name filter to all current messages, or clears
815 the filter if uname is falsy.
816 */
817 setUserFilter: function(uname){
818 if(!uname || (this.filter.current
819 && this.filter.current!==this.filter.user)){
820 this.clearFilters();
821 }
822 this.filter.user.activeTag = uname;
823 if(uname) this.applyMessageFilter(this.filter.user);
824 this.filter.current = uname ? this.filter.user : undefined;
825 const self = this;
826 this.e.activeUserList.querySelectorAll('.chat-user').forEach(function(e){
827 e.classList[
828 self.filter.user.activeTag===e.dataset.uname
829 ? 'add' : 'remove'
830 ]('selected');
831 });
832 return this;
833 },
834 /**
835 Applies a hashtag filter to all current messages, or clears
836 the filter if tag is falsy.
837 */
838 setHashtagFilter: function(tag){
839 if(!tag || (this.filter.current
840 && this.filter.current!==this.filter.hashtag)){
841 this.clearFilters();
842 }
843 this.filter.hashtag.activeTag = tag;
844 if(tag) this.applyMessageFilter(this.filter.hashtag);
845 this.filter.current = tag ? this.filter.hashtag : undefined;
846 return this;
847 },
848
849 /**
850 If animations are enabled, passes its arguments
@@ -1073,22 +1166,106 @@
1166 cs.setCurrentView(cs.e.viewMessages);
1167 if(eUser.classList.contains('selected')){
1168 /* If currently selected, toggle filter off */
1169 eUser.classList.remove('selected');
1170 cs.setUserFilter(false);
 
1171 }else{
 
 
1172 eUser.classList.add('selected');
1173 cs.setUserFilter(uname);
1174 }
1175 return false;
1176 }, false);
1177
1178 cs.e.btnClearFilter.addEventListener('click',function(){
1179 D.addClass(this,'hidden');
1180 cs.clearFilters();
1181 }, false);
1182 return cs;
1183 })()/*Chat initialization*/;
1184
1185 /**
1186 An experiment in history navigation: when a message numtag is
1187 clicked, we push the origin message onto the history and
1188 set up the back button to return to that message.
1189 */
1190 window.onpopstate = function(event){
1191 const msgid = Chat.numtagHistoryStack.pop();
1192 if(msgid){
1193 const e = Chat.setCurrentView(Chat.e.viewMessages).
1194 querySelector('.message-widget[data-msgid="'+msgid+'"]');
1195 //console.debug("Popping history back to",msgid, e);
1196 if(e){
1197 Chat.MessageWidget.scrollToMessageElem(e);
1198 return;
1199 }
1200 }
1201 Chat.scrollMessagesTo(1);
1202 };
1203 Chat.numtagHistoryStack = [
1204 /* Relying on the pushHistory() state object for holding
1205 the message ID is completely misbehaving, not giving
1206 us the expected state object when window.onpopstate
1207 is triggered (plus, the browser persists it, which
1208 introduces its own problems). Thus we use our own
1209 stack of message IDs for history navigation purposes. */];
1210
1211 /** If e or one of its parents has the given CSS class, that element
1212 is returned, else falsy is returned. */
1213 const findParentWithClass = function(e, className){
1214 while(e && !e.classList.contains(className)){
1215 e = e.parentNode;
1216 }
1217 return e;
1218 };
1219
1220 /** To be passed each MessageWidget's top-level DOM element
1221 after initial processing of the message, to set up
1222 hashtag and numtag references. */
1223 const setupHashtags = function f(elem){
1224 if(!f.$clickTag){
1225 f.$clickTag = function(ev){
1226 /* Click handler for hashtags */
1227 const tag = ev.target.dataset.hashtag;
1228 if(tag){
1229 Chat.setHashtagFilter(
1230 tag===Chat.filter.hashtag.activeTag
1231 ? false : tag
1232 );
1233 }
1234 };
1235 f.$clickNum = function(ev){
1236 /* Click handler for #NNN references */
1237 const tag = ev.target.dataset.numtag;
1238 if(tag){
1239 const e = Chat.e.viewMessages.querySelector(
1240 '.message-widget[data-msgid="'+tag+'"]'
1241 );
1242 if(e){
1243 Chat.MessageWidget.scrollToMessageElem(e);
1244 //Set up window.history() state...
1245 const p = 0 ? false : findParentWithClass(ev.target, 'message-widget');
1246 if(p){
1247 const state = {msgId: p.dataset.msgid};
1248 Chat.numtagHistoryStack.push(p.dataset.msgid);
1249 const rc = window.history.pushState(state, "");
1250 //console.debug("Pushing history for msgid", state);
1251 //console.debug("Chat.numtagHistoryStack =",Chat.numtagHistoryStack);
1252 }
1253 }else{
1254 Chat.submitSearch('#'+tag);
1255 }
1256 }
1257 };
1258 }
1259 elem.querySelectorAll('[data-hashtag]').forEach(function(e){
1260 e.dataset.hashtag = e.dataset.hashtag.toLowerCase();
1261 e.addEventListener('click', f.$clickTag, false);
1262 })
1263 elem.querySelectorAll('[data-numtag]').forEach(
1264 (e)=>e.addEventListener('click', f.$clickNum, false)
1265 )
1266 }/*setupHashtags()*/;
1267
1268 /** Returns the first .message-widget element in DOM element
1269 e's lineage. */
1270 const findMessageWidgetParent = function(e){
1271 while( e && !e.classList.contains('message-widget')){
@@ -1354,10 +1531,11 @@
1531 // Used by Chat.reportErrorAsMessage()
1532 D.append(contentTarget, m.xmsg);
1533 }else{
1534 contentTarget.innerHTML = m.xmsg;
1535 contentTarget.querySelectorAll('a').forEach(addAnchorTargetBlank);
1536 setupHashtags(contentTarget);
1537 if(F.pikchr){
1538 F.pikchr.addSrcView(contentTarget.querySelectorAll('svg.pikchr'));
1539 }
1540 }
1541 }
@@ -1470,31 +1648,23 @@
1648 y: 'a'
1649 }), "User's Timeline"),
1650 'target', '_blank'
1651 );
1652 D.append(toolbar2, timelineLink);
1653 if(Chat.filter.current){
1654 /* Add a button to clear filter and jump to
 
1655 this message in its original context. */
1656 D.append(
1657 this.e,
1658 D.append(
1659 D.addClass(D.div(), 'toolbar'),
1660 D.button(
1661 "Message in context",
1662 function(){
1663 self.hide();
1664 Chat.clearFilters();
1665 Chat.MessageWidget.scrollToMessageElem(eMsg);
 
 
 
 
 
 
 
1666 })
1667 )
1668 );
1669 }/*jump-to button*/
1670 }
@@ -1524,10 +1694,20 @@
1694 }/*f.popup*/;
1695 }/*end static init*/
1696 const theMsg = findMessageWidgetParent(ev.target);
1697 if(theMsg) f.popup.show(theMsg);
1698 }/*_handleLegendClicked()*/
1699 }/*MessageWidget.prototype*/;
1700 /** Assumes that e is a MessageWidget element, ensures that
1701 Chat.e.viewMessages is visible, scrolls the message,
1702 and animates it a bit to make it more visible. */
1703 ctor.scrollToMessageElem = function(e){
1704 if(e.firstElementChild){
1705 Chat.setCurrentView(Chat.e.viewMessages);
1706 e.scrollIntoView(false);
1707 Chat.animate(e, 'anim-fade-out-in');
1708 }
1709 };
1710 return ctor;
1711 })()/*MessageWidget*/;
1712
1713 /**
@@ -2067,11 +2247,12 @@
2247 }/*namedOptions.activeUsers additional setup*/
2248 /* Settings menu entries... they are presented in the order listed
2249 here, so the most frequently-needed ones "should" (arguably) be
2250 closer to the start of this list. */
2251 /**
2252 Settings options structure: an array of Objects with the
2253 following properties:
2254
2255 label: string for the UI
2256
2257 boolValue: string (name of Chat.settings setting) or a function
2258 which returns true or false. If it is a string, it gets
@@ -2080,10 +2261,11 @@
2261 to the persistentSetting property of this object.
2262
2263 select: SELECT element (instead of boolValue)
2264
2265 callback: optional handler to call after setting is modified.
2266 It gets passed the setting object: {key:string, value:something}.
2267 Its "this" is the options object. If this object has a
2268 boolValue string or a persistentSetting property, the argument
2269 passed to the callback is a settings object in the form {key:K,
2270 value:V}. If this object does not have boolValue string or
2271 persistentSetting then the callback is passed an event object
@@ -2410,10 +2592,11 @@
2592 const btnPreview = Chat.e.btnPreview;
2593 Chat.setPreviewText = function(t){
2594 this.setCurrentView(this.e.viewPreview);
2595 this.e.previewContent.innerHTML = t;
2596 this.e.viewPreview.querySelectorAll('a').forEach(addAnchorTargetBlank);
2597 setupHashtags(this.e.previewContent)/*arguable, for usability reasons*/;
2598 this.inputFocus();
2599 };
2600 Chat.e.viewPreview.querySelector('button.action-close').
2601 addEventListener('click', ()=>Chat.setCurrentView(Chat.e.viewMessages), false);
2602 let previewPending = false;
@@ -2652,15 +2835,16 @@
2835 }
2836 return e;
2837 };
2838 Chat.clearSearch(true);
2839 /**
2840 Submits a history search using either its argument or the the
2841 main input field's current text.
2842 */
2843 Chat.submitSearch = function(term){
2844 Chat.setCurrentView(Chat.e.viewSearch);
2845 if(!arguments.length) term = this.inputValue(true);
2846 const eMsgTgt = this.clearSearch(true);
2847 if( !term ) return;
2848 D.append( eMsgTgt, "Searching for ",term," ...");
2849 const fd = new FormData();
2850 fd.set('q', term);
2851
--- src/fossil.page.chat.js
+++ src/fossil.page.chat.js
@@ -90,15 +90,14 @@
9090
While we're here, we also use this to cap the max-height
9191
of the input field so that pasting huge text does not scroll
9292
the upper area of the input widget off-screen. */
9393
const elemsToCount = GetFramingElements();
9494
const contentArea = E1('div.content');
95
- const bcl = document.body.classList;
9695
const resized = function f(){
9796
if(f.$disabled) return;
9897
const wh = window.innerHeight,
99
- com = bcl.contains('chat-only-mode');
98
+ com = document.body.classList.contains('chat-only-mode');
10099
var ht;
101100
var extra = 0;
102101
if(com){
103102
ht = wh;
104103
}else{
@@ -161,10 +160,11 @@
161160
searchContent: E1('#chat-search-content'),
162161
btnPreview: E1('#chat-button-preview'),
163162
views: document.querySelectorAll('.chat-view'),
164163
activeUserListWrapper: E1('#chat-user-list-wrapper'),
165164
activeUserList: E1('#chat-user-list'),
165
+ btnClearFilter: E1('#chat-clear-filter'),
166166
eMsgPollError: undefined /* current connection error MessageMidget */,
167167
pollErrorMarker: document.body /* element to toggle 'connection-error' CSS class on */
168168
},
169169
me: F.user.name,
170170
mxMsg: F.config.chat.initSize ? -F.config.chat.initSize : -50,
@@ -181,15 +181,31 @@
181181
(JS Date object). Only messages received by the chat client
182182
are considered. */
183183
/* Reminder: to convert a Julian time J to JS:
184184
new Date((J - 2440587.5) * 86400000) */
185185
},
186
- filterState:{
187
- activeUser: undefined,
188
- match: function(uname){
189
- return this.activeUser===uname || !this.activeUser;
190
- }
186
+ filter: {
187
+ user:{
188
+ activeTag: undefined,
189
+ match: function(uname){
190
+ return !this.activeTag || this.activeTag===uname;
191
+ },
192
+ matchElem: function(e){
193
+ return !this.activeTag || this.activeTag===e.dataset.xfrom;
194
+ }
195
+ },
196
+ hashtag:{
197
+ activeTag: undefined,
198
+ match: function(tag){
199
+ return !this.activeTag || tag===this.activeTag;
200
+ },
201
+ matchElem: function(e){
202
+ return !this.activeTag
203
+ || !!e.querySelector('[data-hashtag="'+this.activeTag+'"]');
204
+ }
205
+ },
206
+ current: undefined/*gets set to current active filter*/
191207
},
192208
/**
193209
The timer object is used to control connection throttling
194210
when connection errors arrise. It starts off with a polling
195211
delay of $initialDelay ms. If there's a connection error,
@@ -376,11 +392,12 @@
376392
the list. */
377393
injectMessageElem: function f(e, atEnd){
378394
const mip = atEnd ? this.e.loadOlderToolbar : this.e.messageInjectPoint,
379395
holder = this.e.viewMessages,
380396
prevMessage = this.e.newestMessage;
381
- if(!this.filterState.match(e.dataset.xfrom)){
397
+ if(this.filter.current
398
+ && !this.filter.current.matchElem(e)){
382399
e.classList.add('hidden');
383400
}
384401
if(atEnd){
385402
const fe = mip.nextElementSibling;
386403
if(fe) mip.parentNode.insertBefore(e, fe);
@@ -622,11 +639,10 @@
622639
}
623640
this.e.views.forEach(function(E){
624641
if(e!==E) D.addClass(E,'hidden');
625642
});
626643
this.e.currentView = e;
627
- if(this.e.currentView.$beforeShow) this.e.currentView.$beforeShow();
628644
D.removeClass(e,'hidden');
629645
this.animate(this.e.currentView, 'anim-fade-in-fast');
630646
return this.e.currentView;
631647
},
632648
@@ -674,11 +690,11 @@
674690
else return 0;
675691
};
676692
callee.addUserElem = function(u){
677693
const uSpan = D.addClass(D.span(), 'chat-user');
678694
const uDate = self.usersLastSeen[u];
679
- if(self.filterState.activeUser===u){
695
+ if(self.filter.user.activeTag===u){
680696
uSpan.classList.add('selected');
681697
}
682698
uSpan.dataset.uname = u;
683699
D.append(uSpan, u, "\n",
684700
D.append(
@@ -696,11 +712,82 @@
696712
Object.keys(this.usersLastSeen).sort(
697713
callee.sortUsersSeen
698714
).forEach(callee.addUserElem);
699715
return this;
700716
},
701
- /** Show or hide the active user list. Returns this object. */
717
+ /**
718
+ For each Chat.MessageWidget element (X.message-widget) for
719
+ which predicate(elem) returns true, the 'hidden' class is
720
+ removed from that message. For all others, 'hidden' is
721
+ added. If predicate is falsy, 'hidden' is removed from all
722
+ elements. After filtering, it will try to scroll the last
723
+ not-filtered-out message into view, but exactly where it
724
+ scrolls into view (top, middle, button) is
725
+ unpredictable. Returns this object.
726
+
727
+ The argument may optionally be an object from this.filter,
728
+ in which case its matchElem() method becomes the predicate.
729
+
730
+ Note that this does not encapsulate certain filter-specific
731
+ logic which applies changes to elements other than the
732
+ main message list or this.e.btnClearFilter.
733
+ */
734
+ applyMessageFilter: function(predicate){
735
+ const self = this;
736
+ let eLast;
737
+ console.debug("applyMessageFilter(",predicate,")");
738
+ if(!predicate){
739
+ D.removeClass(this.e.viewMessages.querySelectorAll('.message-widget.hidden'),
740
+ 'hidden');
741
+ D.addClass(this.e.btnClearFilter, 'hidden');
742
+ }else if('function'!==typeof predicate
743
+ && predicate.matchElem){
744
+ /* assume Chat.filter object */
745
+ const p = predicate;
746
+ predicate = (e)=>p.matchElem(e);
747
+ }
748
+ if(predicate){
749
+ this.e.viewMessages.querySelectorAll('.message-widget').forEach(function(e){
750
+ if(predicate(e)){
751
+ e.classList.remove('hidden');
752
+ eLast = e;
753
+ }else{
754
+ e.classList.add('hidden');
755
+ }
756
+ });
757
+ D.removeClass(this.e.btnClearFilter, 'hidden');
758
+ }
759
+ this.setCurrentView(this.e.viewMessages);
760
+ if(eLast) eLast.scrollIntoView(false);
761
+ else this.scrollMessagesTo(1);
762
+ return this;
763
+ },
764
+ /**
765
+ Clears the current message filter, if any, and clears the
766
+ activeTag property of all members of this.filter. Returns
767
+ this object. This also unfortunately performs some
768
+ filter-type-specific logic which we have not yet managed to
769
+ encapsulate more cleanly.
770
+ */
771
+ clearFilters: function(){
772
+ if(!this.filter.current) return this;
773
+ this.filter.current = undefined;
774
+ this.applyMessageFilter(false);
775
+ const self = this;
776
+ Object.keys(this.filter).forEach(function(k){
777
+ const f = self.filter[k];
778
+ if(f) f.activeTag = undefined;
779
+ });
780
+ this.e.activeUserList.querySelectorAll('.chat-user').forEach(
781
+ /*Unfortante filter-specific logic*/
782
+ (e)=>e.classList.remove('selected')
783
+ );
784
+ return this;
785
+ },
786
+ /**
787
+ Show or hide the active user list. Returns this object.
788
+ */
702789
showActiveUserList: function(yes){
703790
if(0===arguments.length) yes = true;
704791
this.e.activeUserListWrapper.classList[
705792
yes ? 'remove' : 'add'
706793
]('hidden');
@@ -726,32 +813,38 @@
726813
/**
727814
Applies user name filter to all current messages, or clears
728815
the filter if uname is falsy.
729816
*/
730817
setUserFilter: function(uname){
731
- this.filterState.activeUser = uname;
732
- const mw = this.e.viewMessages.querySelectorAll('.message-widget');
818
+ if(!uname || (this.filter.current
819
+ && this.filter.current!==this.filter.user)){
820
+ this.clearFilters();
821
+ }
822
+ this.filter.user.activeTag = uname;
823
+ if(uname) this.applyMessageFilter(this.filter.user);
824
+ this.filter.current = uname ? this.filter.user : undefined;
733825
const self = this;
734
- let eLast;
735
- if(!uname){
736
- D.removeClass(Chat.e.viewMessages.querySelectorAll('.message-widget.hidden'),
737
- 'hidden');
738
- }else{
739
- mw.forEach(function(w){
740
- if(self.filterState.match(w.dataset.xfrom)){
741
- w.classList.remove('hidden');
742
- eLast = w;
743
- }else{
744
- w.classList.add('hidden');
745
- }
746
- });
747
- }
748
- if(eLast) eLast.scrollIntoView(false);
749
- else this.scrollMessagesTo(1);
750
- cs.e.activeUserList.querySelectorAll('.chat-user').forEach(function(e){
751
- e.classList[uname===e.dataset.uname ? 'add' : 'remove']('selected');
752
- });
826
+ this.e.activeUserList.querySelectorAll('.chat-user').forEach(function(e){
827
+ e.classList[
828
+ self.filter.user.activeTag===e.dataset.uname
829
+ ? 'add' : 'remove'
830
+ ]('selected');
831
+ });
832
+ return this;
833
+ },
834
+ /**
835
+ Applies a hashtag filter to all current messages, or clears
836
+ the filter if tag is falsy.
837
+ */
838
+ setHashtagFilter: function(tag){
839
+ if(!tag || (this.filter.current
840
+ && this.filter.current!==this.filter.hashtag)){
841
+ this.clearFilters();
842
+ }
843
+ this.filter.hashtag.activeTag = tag;
844
+ if(tag) this.applyMessageFilter(this.filter.hashtag);
845
+ this.filter.current = tag ? this.filter.hashtag : undefined;
753846
return this;
754847
},
755848
756849
/**
757850
If animations are enabled, passes its arguments
@@ -1073,22 +1166,106 @@
10731166
cs.setCurrentView(cs.e.viewMessages);
10741167
if(eUser.classList.contains('selected')){
10751168
/* If currently selected, toggle filter off */
10761169
eUser.classList.remove('selected');
10771170
cs.setUserFilter(false);
1078
- delete f.$eSelected;
10791171
}else{
1080
- if(f.$eSelected) f.$eSelected.classList.remove('selected');
1081
- f.$eSelected = eUser;
10821172
eUser.classList.add('selected');
10831173
cs.setUserFilter(uname);
10841174
}
10851175
return false;
10861176
}, false);
1177
+
1178
+ cs.e.btnClearFilter.addEventListener('click',function(){
1179
+ D.addClass(this,'hidden');
1180
+ cs.clearFilters();
1181
+ }, false);
10871182
return cs;
10881183
})()/*Chat initialization*/;
10891184
1185
+ /**
1186
+ An experiment in history navigation: when a message numtag is
1187
+ clicked, we push the origin message onto the history and
1188
+ set up the back button to return to that message.
1189
+ */
1190
+ window.onpopstate = function(event){
1191
+ const msgid = Chat.numtagHistoryStack.pop();
1192
+ if(msgid){
1193
+ const e = Chat.setCurrentView(Chat.e.viewMessages).
1194
+ querySelector('.message-widget[data-msgid="'+msgid+'"]');
1195
+ //console.debug("Popping history back to",msgid, e);
1196
+ if(e){
1197
+ Chat.MessageWidget.scrollToMessageElem(e);
1198
+ return;
1199
+ }
1200
+ }
1201
+ Chat.scrollMessagesTo(1);
1202
+ };
1203
+ Chat.numtagHistoryStack = [
1204
+ /* Relying on the pushHistory() state object for holding
1205
+ the message ID is completely misbehaving, not giving
1206
+ us the expected state object when window.onpopstate
1207
+ is triggered (plus, the browser persists it, which
1208
+ introduces its own problems). Thus we use our own
1209
+ stack of message IDs for history navigation purposes. */];
1210
+
1211
+ /** If e or one of its parents has the given CSS class, that element
1212
+ is returned, else falsy is returned. */
1213
+ const findParentWithClass = function(e, className){
1214
+ while(e && !e.classList.contains(className)){
1215
+ e = e.parentNode;
1216
+ }
1217
+ return e;
1218
+ };
1219
+
1220
+ /** To be passed each MessageWidget's top-level DOM element
1221
+ after initial processing of the message, to set up
1222
+ hashtag and numtag references. */
1223
+ const setupHashtags = function f(elem){
1224
+ if(!f.$clickTag){
1225
+ f.$clickTag = function(ev){
1226
+ /* Click handler for hashtags */
1227
+ const tag = ev.target.dataset.hashtag;
1228
+ if(tag){
1229
+ Chat.setHashtagFilter(
1230
+ tag===Chat.filter.hashtag.activeTag
1231
+ ? false : tag
1232
+ );
1233
+ }
1234
+ };
1235
+ f.$clickNum = function(ev){
1236
+ /* Click handler for #NNN references */
1237
+ const tag = ev.target.dataset.numtag;
1238
+ if(tag){
1239
+ const e = Chat.e.viewMessages.querySelector(
1240
+ '.message-widget[data-msgid="'+tag+'"]'
1241
+ );
1242
+ if(e){
1243
+ Chat.MessageWidget.scrollToMessageElem(e);
1244
+ //Set up window.history() state...
1245
+ const p = 0 ? false : findParentWithClass(ev.target, 'message-widget');
1246
+ if(p){
1247
+ const state = {msgId: p.dataset.msgid};
1248
+ Chat.numtagHistoryStack.push(p.dataset.msgid);
1249
+ const rc = window.history.pushState(state, "");
1250
+ //console.debug("Pushing history for msgid", state);
1251
+ //console.debug("Chat.numtagHistoryStack =",Chat.numtagHistoryStack);
1252
+ }
1253
+ }else{
1254
+ Chat.submitSearch('#'+tag);
1255
+ }
1256
+ }
1257
+ };
1258
+ }
1259
+ elem.querySelectorAll('[data-hashtag]').forEach(function(e){
1260
+ e.dataset.hashtag = e.dataset.hashtag.toLowerCase();
1261
+ e.addEventListener('click', f.$clickTag, false);
1262
+ })
1263
+ elem.querySelectorAll('[data-numtag]').forEach(
1264
+ (e)=>e.addEventListener('click', f.$clickNum, false)
1265
+ )
1266
+ }/*setupHashtags()*/;
10901267
10911268
/** Returns the first .message-widget element in DOM element
10921269
e's lineage. */
10931270
const findMessageWidgetParent = function(e){
10941271
while( e && !e.classList.contains('message-widget')){
@@ -1354,10 +1531,11 @@
13541531
// Used by Chat.reportErrorAsMessage()
13551532
D.append(contentTarget, m.xmsg);
13561533
}else{
13571534
contentTarget.innerHTML = m.xmsg;
13581535
contentTarget.querySelectorAll('a').forEach(addAnchorTargetBlank);
1536
+ setupHashtags(contentTarget);
13591537
if(F.pikchr){
13601538
F.pikchr.addSrcView(contentTarget.querySelectorAll('svg.pikchr'));
13611539
}
13621540
}
13631541
}
@@ -1470,31 +1648,23 @@
14701648
y: 'a'
14711649
}), "User's Timeline"),
14721650
'target', '_blank'
14731651
);
14741652
D.append(toolbar2, timelineLink);
1475
- if(Chat.filterState.activeUser &&
1476
- Chat.filterState.match(eMsg.dataset.xfrom)){
1477
- /* Add a button to clear user filter and jump to
1653
+ if(Chat.filter.current){
1654
+ /* Add a button to clear filter and jump to
14781655
this message in its original context. */
14791656
D.append(
14801657
this.e,
14811658
D.append(
14821659
D.addClass(D.div(), 'toolbar'),
14831660
D.button(
14841661
"Message in context",
14851662
function(){
14861663
self.hide();
1487
- Chat.setUserFilter(false);
1488
- eMsg.scrollIntoView(false);
1489
- Chat.animate(
1490
- eMsg.firstElementChild, 'anim-flip-h'
1491
- //eMsg.firstElementChild, 'anim-flip-v'
1492
- //eMsg.childNodes, 'anim-rotate-360'
1493
- //eMsg.childNodes, 'anim-flip-v'
1494
- //eMsg, 'anim-flip-v'
1495
- );
1664
+ Chat.clearFilters();
1665
+ Chat.MessageWidget.scrollToMessageElem(eMsg);
14961666
})
14971667
)
14981668
);
14991669
}/*jump-to button*/
15001670
}
@@ -1524,10 +1694,20 @@
15241694
}/*f.popup*/;
15251695
}/*end static init*/
15261696
const theMsg = findMessageWidgetParent(ev.target);
15271697
if(theMsg) f.popup.show(theMsg);
15281698
}/*_handleLegendClicked()*/
1699
+ }/*MessageWidget.prototype*/;
1700
+ /** Assumes that e is a MessageWidget element, ensures that
1701
+ Chat.e.viewMessages is visible, scrolls the message,
1702
+ and animates it a bit to make it more visible. */
1703
+ ctor.scrollToMessageElem = function(e){
1704
+ if(e.firstElementChild){
1705
+ Chat.setCurrentView(Chat.e.viewMessages);
1706
+ e.scrollIntoView(false);
1707
+ Chat.animate(e, 'anim-fade-out-in');
1708
+ }
15291709
};
15301710
return ctor;
15311711
})()/*MessageWidget*/;
15321712
15331713
/**
@@ -2067,11 +2247,12 @@
20672247
}/*namedOptions.activeUsers additional setup*/
20682248
/* Settings menu entries... they are presented in the order listed
20692249
here, so the most frequently-needed ones "should" (arguably) be
20702250
closer to the start of this list. */
20712251
/**
2072
- Settings ops structure:
2252
+ Settings options structure: an array of Objects with the
2253
+ following properties:
20732254
20742255
label: string for the UI
20752256
20762257
boolValue: string (name of Chat.settings setting) or a function
20772258
which returns true or false. If it is a string, it gets
@@ -2080,10 +2261,11 @@
20802261
to the persistentSetting property of this object.
20812262
20822263
select: SELECT element (instead of boolValue)
20832264
20842265
callback: optional handler to call after setting is modified.
2266
+ It gets passed the setting object: {key:string, value:something}.
20852267
Its "this" is the options object. If this object has a
20862268
boolValue string or a persistentSetting property, the argument
20872269
passed to the callback is a settings object in the form {key:K,
20882270
value:V}. If this object does not have boolValue string or
20892271
persistentSetting then the callback is passed an event object
@@ -2410,10 +2592,11 @@
24102592
const btnPreview = Chat.e.btnPreview;
24112593
Chat.setPreviewText = function(t){
24122594
this.setCurrentView(this.e.viewPreview);
24132595
this.e.previewContent.innerHTML = t;
24142596
this.e.viewPreview.querySelectorAll('a').forEach(addAnchorTargetBlank);
2597
+ setupHashtags(this.e.previewContent)/*arguable, for usability reasons*/;
24152598
this.inputFocus();
24162599
};
24172600
Chat.e.viewPreview.querySelector('button.action-close').
24182601
addEventListener('click', ()=>Chat.setCurrentView(Chat.e.viewMessages), false);
24192602
let previewPending = false;
@@ -2652,15 +2835,16 @@
26522835
}
26532836
return e;
26542837
};
26552838
Chat.clearSearch(true);
26562839
/**
2657
- Submits a history search using the main input field's current
2658
- text. It is assumed that Chat.e.viewSearch===Chat.e.currentView.
2840
+ Submits a history search using either its argument or the the
2841
+ main input field's current text.
26592842
*/
2660
- Chat.submitSearch = function(){
2661
- const term = this.inputValue(true);
2843
+ Chat.submitSearch = function(term){
2844
+ Chat.setCurrentView(Chat.e.viewSearch);
2845
+ if(!arguments.length) term = this.inputValue(true);
26622846
const eMsgTgt = this.clearSearch(true);
26632847
if( !term ) return;
26642848
D.append( eMsgTgt, "Searching for ",term," ...");
26652849
const fd = new FormData();
26662850
fd.set('q', term);
26672851
--- src/fossil.page.chat.js
+++ src/fossil.page.chat.js
@@ -90,15 +90,14 @@
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{
@@ -161,10 +160,11 @@
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,
@@ -181,15 +181,31 @@
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,
@@ -376,11 +392,12 @@
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);
@@ -622,11 +639,10 @@
622 }
623 this.e.views.forEach(function(E){
624 if(e!==E) D.addClass(E,'hidden');
625 });
626 this.e.currentView = e;
627 if(this.e.currentView.$beforeShow) this.e.currentView.$beforeShow();
628 D.removeClass(e,'hidden');
629 this.animate(this.e.currentView, 'anim-fade-in-fast');
630 return this.e.currentView;
631 },
632
@@ -674,11 +690,11 @@
674 else return 0;
675 };
676 callee.addUserElem = function(u){
677 const uSpan = D.addClass(D.span(), 'chat-user');
678 const uDate = self.usersLastSeen[u];
679 if(self.filterState.activeUser===u){
680 uSpan.classList.add('selected');
681 }
682 uSpan.dataset.uname = u;
683 D.append(uSpan, u, "\n",
684 D.append(
@@ -696,11 +712,82 @@
696 Object.keys(this.usersLastSeen).sort(
697 callee.sortUsersSeen
698 ).forEach(callee.addUserElem);
699 return this;
700 },
701 /** Show or hide the active user list. Returns this object. */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
702 showActiveUserList: function(yes){
703 if(0===arguments.length) yes = true;
704 this.e.activeUserListWrapper.classList[
705 yes ? 'remove' : 'add'
706 ]('hidden');
@@ -726,32 +813,38 @@
726 /**
727 Applies user name filter to all current messages, or clears
728 the filter if uname is falsy.
729 */
730 setUserFilter: function(uname){
731 this.filterState.activeUser = uname;
732 const mw = this.e.viewMessages.querySelectorAll('.message-widget');
 
 
 
 
 
733 const self = this;
734 let eLast;
735 if(!uname){
736 D.removeClass(Chat.e.viewMessages.querySelectorAll('.message-widget.hidden'),
737 'hidden');
738 }else{
739 mw.forEach(function(w){
740 if(self.filterState.match(w.dataset.xfrom)){
741 w.classList.remove('hidden');
742 eLast = w;
743 }else{
744 w.classList.add('hidden');
745 }
746 });
747 }
748 if(eLast) eLast.scrollIntoView(false);
749 else this.scrollMessagesTo(1);
750 cs.e.activeUserList.querySelectorAll('.chat-user').forEach(function(e){
751 e.classList[uname===e.dataset.uname ? 'add' : 'remove']('selected');
752 });
 
753 return this;
754 },
755
756 /**
757 If animations are enabled, passes its arguments
@@ -1073,22 +1166,106 @@
1073 cs.setCurrentView(cs.e.viewMessages);
1074 if(eUser.classList.contains('selected')){
1075 /* If currently selected, toggle filter off */
1076 eUser.classList.remove('selected');
1077 cs.setUserFilter(false);
1078 delete f.$eSelected;
1079 }else{
1080 if(f.$eSelected) f.$eSelected.classList.remove('selected');
1081 f.$eSelected = eUser;
1082 eUser.classList.add('selected');
1083 cs.setUserFilter(uname);
1084 }
1085 return false;
1086 }, false);
 
 
 
 
 
1087 return cs;
1088 })()/*Chat initialization*/;
1089
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1090
1091 /** Returns the first .message-widget element in DOM element
1092 e's lineage. */
1093 const findMessageWidgetParent = function(e){
1094 while( e && !e.classList.contains('message-widget')){
@@ -1354,10 +1531,11 @@
1354 // Used by Chat.reportErrorAsMessage()
1355 D.append(contentTarget, m.xmsg);
1356 }else{
1357 contentTarget.innerHTML = m.xmsg;
1358 contentTarget.querySelectorAll('a').forEach(addAnchorTargetBlank);
 
1359 if(F.pikchr){
1360 F.pikchr.addSrcView(contentTarget.querySelectorAll('svg.pikchr'));
1361 }
1362 }
1363 }
@@ -1470,31 +1648,23 @@
1470 y: 'a'
1471 }), "User's Timeline"),
1472 'target', '_blank'
1473 );
1474 D.append(toolbar2, timelineLink);
1475 if(Chat.filterState.activeUser &&
1476 Chat.filterState.match(eMsg.dataset.xfrom)){
1477 /* Add a button to clear user filter and jump to
1478 this message in its original context. */
1479 D.append(
1480 this.e,
1481 D.append(
1482 D.addClass(D.div(), 'toolbar'),
1483 D.button(
1484 "Message in context",
1485 function(){
1486 self.hide();
1487 Chat.setUserFilter(false);
1488 eMsg.scrollIntoView(false);
1489 Chat.animate(
1490 eMsg.firstElementChild, 'anim-flip-h'
1491 //eMsg.firstElementChild, 'anim-flip-v'
1492 //eMsg.childNodes, 'anim-rotate-360'
1493 //eMsg.childNodes, 'anim-flip-v'
1494 //eMsg, 'anim-flip-v'
1495 );
1496 })
1497 )
1498 );
1499 }/*jump-to button*/
1500 }
@@ -1524,10 +1694,20 @@
1524 }/*f.popup*/;
1525 }/*end static init*/
1526 const theMsg = findMessageWidgetParent(ev.target);
1527 if(theMsg) f.popup.show(theMsg);
1528 }/*_handleLegendClicked()*/
 
 
 
 
 
 
 
 
 
 
1529 };
1530 return ctor;
1531 })()/*MessageWidget*/;
1532
1533 /**
@@ -2067,11 +2247,12 @@
2067 }/*namedOptions.activeUsers additional setup*/
2068 /* Settings menu entries... they are presented in the order listed
2069 here, so the most frequently-needed ones "should" (arguably) be
2070 closer to the start of this list. */
2071 /**
2072 Settings ops structure:
 
2073
2074 label: string for the UI
2075
2076 boolValue: string (name of Chat.settings setting) or a function
2077 which returns true or false. If it is a string, it gets
@@ -2080,10 +2261,11 @@
2080 to the persistentSetting property of this object.
2081
2082 select: SELECT element (instead of boolValue)
2083
2084 callback: optional handler to call after setting is modified.
 
2085 Its "this" is the options object. If this object has a
2086 boolValue string or a persistentSetting property, the argument
2087 passed to the callback is a settings object in the form {key:K,
2088 value:V}. If this object does not have boolValue string or
2089 persistentSetting then the callback is passed an event object
@@ -2410,10 +2592,11 @@
2410 const btnPreview = Chat.e.btnPreview;
2411 Chat.setPreviewText = function(t){
2412 this.setCurrentView(this.e.viewPreview);
2413 this.e.previewContent.innerHTML = t;
2414 this.e.viewPreview.querySelectorAll('a').forEach(addAnchorTargetBlank);
 
2415 this.inputFocus();
2416 };
2417 Chat.e.viewPreview.querySelector('button.action-close').
2418 addEventListener('click', ()=>Chat.setCurrentView(Chat.e.viewMessages), false);
2419 let previewPending = false;
@@ -2652,15 +2835,16 @@
2652 }
2653 return e;
2654 };
2655 Chat.clearSearch(true);
2656 /**
2657 Submits a history search using the main input field's current
2658 text. It is assumed that Chat.e.viewSearch===Chat.e.currentView.
2659 */
2660 Chat.submitSearch = function(){
2661 const term = this.inputValue(true);
 
2662 const eMsgTgt = this.clearSearch(true);
2663 if( !term ) return;
2664 D.append( eMsgTgt, "Searching for ",term," ...");
2665 const fd = new FormData();
2666 fd.set('q', term);
2667
--- src/fossil.page.chat.js
+++ src/fossil.page.chat.js
@@ -90,15 +90,14 @@
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 resized = function f(){
96 if(f.$disabled) return;
97 const wh = window.innerHeight,
98 com = document.body.classList.contains('chat-only-mode');
99 var ht;
100 var extra = 0;
101 if(com){
102 ht = wh;
103 }else{
@@ -161,10 +160,11 @@
160 searchContent: E1('#chat-search-content'),
161 btnPreview: E1('#chat-button-preview'),
162 views: document.querySelectorAll('.chat-view'),
163 activeUserListWrapper: E1('#chat-user-list-wrapper'),
164 activeUserList: E1('#chat-user-list'),
165 btnClearFilter: E1('#chat-clear-filter'),
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,
@@ -181,15 +181,31 @@
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 filter: {
187 user:{
188 activeTag: undefined,
189 match: function(uname){
190 return !this.activeTag || this.activeTag===uname;
191 },
192 matchElem: function(e){
193 return !this.activeTag || this.activeTag===e.dataset.xfrom;
194 }
195 },
196 hashtag:{
197 activeTag: undefined,
198 match: function(tag){
199 return !this.activeTag || tag===this.activeTag;
200 },
201 matchElem: function(e){
202 return !this.activeTag
203 || !!e.querySelector('[data-hashtag="'+this.activeTag+'"]');
204 }
205 },
206 current: undefined/*gets set to current active filter*/
207 },
208 /**
209 The timer object is used to control connection throttling
210 when connection errors arrise. It starts off with a polling
211 delay of $initialDelay ms. If there's a connection error,
@@ -376,11 +392,12 @@
392 the list. */
393 injectMessageElem: function f(e, atEnd){
394 const mip = atEnd ? this.e.loadOlderToolbar : this.e.messageInjectPoint,
395 holder = this.e.viewMessages,
396 prevMessage = this.e.newestMessage;
397 if(this.filter.current
398 && !this.filter.current.matchElem(e)){
399 e.classList.add('hidden');
400 }
401 if(atEnd){
402 const fe = mip.nextElementSibling;
403 if(fe) mip.parentNode.insertBefore(e, fe);
@@ -622,11 +639,10 @@
639 }
640 this.e.views.forEach(function(E){
641 if(e!==E) D.addClass(E,'hidden');
642 });
643 this.e.currentView = e;
 
644 D.removeClass(e,'hidden');
645 this.animate(this.e.currentView, 'anim-fade-in-fast');
646 return this.e.currentView;
647 },
648
@@ -674,11 +690,11 @@
690 else return 0;
691 };
692 callee.addUserElem = function(u){
693 const uSpan = D.addClass(D.span(), 'chat-user');
694 const uDate = self.usersLastSeen[u];
695 if(self.filter.user.activeTag===u){
696 uSpan.classList.add('selected');
697 }
698 uSpan.dataset.uname = u;
699 D.append(uSpan, u, "\n",
700 D.append(
@@ -696,11 +712,82 @@
712 Object.keys(this.usersLastSeen).sort(
713 callee.sortUsersSeen
714 ).forEach(callee.addUserElem);
715 return this;
716 },
717 /**
718 For each Chat.MessageWidget element (X.message-widget) for
719 which predicate(elem) returns true, the 'hidden' class is
720 removed from that message. For all others, 'hidden' is
721 added. If predicate is falsy, 'hidden' is removed from all
722 elements. After filtering, it will try to scroll the last
723 not-filtered-out message into view, but exactly where it
724 scrolls into view (top, middle, button) is
725 unpredictable. Returns this object.
726
727 The argument may optionally be an object from this.filter,
728 in which case its matchElem() method becomes the predicate.
729
730 Note that this does not encapsulate certain filter-specific
731 logic which applies changes to elements other than the
732 main message list or this.e.btnClearFilter.
733 */
734 applyMessageFilter: function(predicate){
735 const self = this;
736 let eLast;
737 console.debug("applyMessageFilter(",predicate,")");
738 if(!predicate){
739 D.removeClass(this.e.viewMessages.querySelectorAll('.message-widget.hidden'),
740 'hidden');
741 D.addClass(this.e.btnClearFilter, 'hidden');
742 }else if('function'!==typeof predicate
743 && predicate.matchElem){
744 /* assume Chat.filter object */
745 const p = predicate;
746 predicate = (e)=>p.matchElem(e);
747 }
748 if(predicate){
749 this.e.viewMessages.querySelectorAll('.message-widget').forEach(function(e){
750 if(predicate(e)){
751 e.classList.remove('hidden');
752 eLast = e;
753 }else{
754 e.classList.add('hidden');
755 }
756 });
757 D.removeClass(this.e.btnClearFilter, 'hidden');
758 }
759 this.setCurrentView(this.e.viewMessages);
760 if(eLast) eLast.scrollIntoView(false);
761 else this.scrollMessagesTo(1);
762 return this;
763 },
764 /**
765 Clears the current message filter, if any, and clears the
766 activeTag property of all members of this.filter. Returns
767 this object. This also unfortunately performs some
768 filter-type-specific logic which we have not yet managed to
769 encapsulate more cleanly.
770 */
771 clearFilters: function(){
772 if(!this.filter.current) return this;
773 this.filter.current = undefined;
774 this.applyMessageFilter(false);
775 const self = this;
776 Object.keys(this.filter).forEach(function(k){
777 const f = self.filter[k];
778 if(f) f.activeTag = undefined;
779 });
780 this.e.activeUserList.querySelectorAll('.chat-user').forEach(
781 /*Unfortante filter-specific logic*/
782 (e)=>e.classList.remove('selected')
783 );
784 return this;
785 },
786 /**
787 Show or hide the active user list. Returns this object.
788 */
789 showActiveUserList: function(yes){
790 if(0===arguments.length) yes = true;
791 this.e.activeUserListWrapper.classList[
792 yes ? 'remove' : 'add'
793 ]('hidden');
@@ -726,32 +813,38 @@
813 /**
814 Applies user name filter to all current messages, or clears
815 the filter if uname is falsy.
816 */
817 setUserFilter: function(uname){
818 if(!uname || (this.filter.current
819 && this.filter.current!==this.filter.user)){
820 this.clearFilters();
821 }
822 this.filter.user.activeTag = uname;
823 if(uname) this.applyMessageFilter(this.filter.user);
824 this.filter.current = uname ? this.filter.user : undefined;
825 const self = this;
826 this.e.activeUserList.querySelectorAll('.chat-user').forEach(function(e){
827 e.classList[
828 self.filter.user.activeTag===e.dataset.uname
829 ? 'add' : 'remove'
830 ]('selected');
831 });
832 return this;
833 },
834 /**
835 Applies a hashtag filter to all current messages, or clears
836 the filter if tag is falsy.
837 */
838 setHashtagFilter: function(tag){
839 if(!tag || (this.filter.current
840 && this.filter.current!==this.filter.hashtag)){
841 this.clearFilters();
842 }
843 this.filter.hashtag.activeTag = tag;
844 if(tag) this.applyMessageFilter(this.filter.hashtag);
845 this.filter.current = tag ? this.filter.hashtag : undefined;
846 return this;
847 },
848
849 /**
850 If animations are enabled, passes its arguments
@@ -1073,22 +1166,106 @@
1166 cs.setCurrentView(cs.e.viewMessages);
1167 if(eUser.classList.contains('selected')){
1168 /* If currently selected, toggle filter off */
1169 eUser.classList.remove('selected');
1170 cs.setUserFilter(false);
 
1171 }else{
 
 
1172 eUser.classList.add('selected');
1173 cs.setUserFilter(uname);
1174 }
1175 return false;
1176 }, false);
1177
1178 cs.e.btnClearFilter.addEventListener('click',function(){
1179 D.addClass(this,'hidden');
1180 cs.clearFilters();
1181 }, false);
1182 return cs;
1183 })()/*Chat initialization*/;
1184
1185 /**
1186 An experiment in history navigation: when a message numtag is
1187 clicked, we push the origin message onto the history and
1188 set up the back button to return to that message.
1189 */
1190 window.onpopstate = function(event){
1191 const msgid = Chat.numtagHistoryStack.pop();
1192 if(msgid){
1193 const e = Chat.setCurrentView(Chat.e.viewMessages).
1194 querySelector('.message-widget[data-msgid="'+msgid+'"]');
1195 //console.debug("Popping history back to",msgid, e);
1196 if(e){
1197 Chat.MessageWidget.scrollToMessageElem(e);
1198 return;
1199 }
1200 }
1201 Chat.scrollMessagesTo(1);
1202 };
1203 Chat.numtagHistoryStack = [
1204 /* Relying on the pushHistory() state object for holding
1205 the message ID is completely misbehaving, not giving
1206 us the expected state object when window.onpopstate
1207 is triggered (plus, the browser persists it, which
1208 introduces its own problems). Thus we use our own
1209 stack of message IDs for history navigation purposes. */];
1210
1211 /** If e or one of its parents has the given CSS class, that element
1212 is returned, else falsy is returned. */
1213 const findParentWithClass = function(e, className){
1214 while(e && !e.classList.contains(className)){
1215 e = e.parentNode;
1216 }
1217 return e;
1218 };
1219
1220 /** To be passed each MessageWidget's top-level DOM element
1221 after initial processing of the message, to set up
1222 hashtag and numtag references. */
1223 const setupHashtags = function f(elem){
1224 if(!f.$clickTag){
1225 f.$clickTag = function(ev){
1226 /* Click handler for hashtags */
1227 const tag = ev.target.dataset.hashtag;
1228 if(tag){
1229 Chat.setHashtagFilter(
1230 tag===Chat.filter.hashtag.activeTag
1231 ? false : tag
1232 );
1233 }
1234 };
1235 f.$clickNum = function(ev){
1236 /* Click handler for #NNN references */
1237 const tag = ev.target.dataset.numtag;
1238 if(tag){
1239 const e = Chat.e.viewMessages.querySelector(
1240 '.message-widget[data-msgid="'+tag+'"]'
1241 );
1242 if(e){
1243 Chat.MessageWidget.scrollToMessageElem(e);
1244 //Set up window.history() state...
1245 const p = 0 ? false : findParentWithClass(ev.target, 'message-widget');
1246 if(p){
1247 const state = {msgId: p.dataset.msgid};
1248 Chat.numtagHistoryStack.push(p.dataset.msgid);
1249 const rc = window.history.pushState(state, "");
1250 //console.debug("Pushing history for msgid", state);
1251 //console.debug("Chat.numtagHistoryStack =",Chat.numtagHistoryStack);
1252 }
1253 }else{
1254 Chat.submitSearch('#'+tag);
1255 }
1256 }
1257 };
1258 }
1259 elem.querySelectorAll('[data-hashtag]').forEach(function(e){
1260 e.dataset.hashtag = e.dataset.hashtag.toLowerCase();
1261 e.addEventListener('click', f.$clickTag, false);
1262 })
1263 elem.querySelectorAll('[data-numtag]').forEach(
1264 (e)=>e.addEventListener('click', f.$clickNum, false)
1265 )
1266 }/*setupHashtags()*/;
1267
1268 /** Returns the first .message-widget element in DOM element
1269 e's lineage. */
1270 const findMessageWidgetParent = function(e){
1271 while( e && !e.classList.contains('message-widget')){
@@ -1354,10 +1531,11 @@
1531 // Used by Chat.reportErrorAsMessage()
1532 D.append(contentTarget, m.xmsg);
1533 }else{
1534 contentTarget.innerHTML = m.xmsg;
1535 contentTarget.querySelectorAll('a').forEach(addAnchorTargetBlank);
1536 setupHashtags(contentTarget);
1537 if(F.pikchr){
1538 F.pikchr.addSrcView(contentTarget.querySelectorAll('svg.pikchr'));
1539 }
1540 }
1541 }
@@ -1470,31 +1648,23 @@
1648 y: 'a'
1649 }), "User's Timeline"),
1650 'target', '_blank'
1651 );
1652 D.append(toolbar2, timelineLink);
1653 if(Chat.filter.current){
1654 /* Add a button to clear filter and jump to
 
1655 this message in its original context. */
1656 D.append(
1657 this.e,
1658 D.append(
1659 D.addClass(D.div(), 'toolbar'),
1660 D.button(
1661 "Message in context",
1662 function(){
1663 self.hide();
1664 Chat.clearFilters();
1665 Chat.MessageWidget.scrollToMessageElem(eMsg);
 
 
 
 
 
 
 
1666 })
1667 )
1668 );
1669 }/*jump-to button*/
1670 }
@@ -1524,10 +1694,20 @@
1694 }/*f.popup*/;
1695 }/*end static init*/
1696 const theMsg = findMessageWidgetParent(ev.target);
1697 if(theMsg) f.popup.show(theMsg);
1698 }/*_handleLegendClicked()*/
1699 }/*MessageWidget.prototype*/;
1700 /** Assumes that e is a MessageWidget element, ensures that
1701 Chat.e.viewMessages is visible, scrolls the message,
1702 and animates it a bit to make it more visible. */
1703 ctor.scrollToMessageElem = function(e){
1704 if(e.firstElementChild){
1705 Chat.setCurrentView(Chat.e.viewMessages);
1706 e.scrollIntoView(false);
1707 Chat.animate(e, 'anim-fade-out-in');
1708 }
1709 };
1710 return ctor;
1711 })()/*MessageWidget*/;
1712
1713 /**
@@ -2067,11 +2247,12 @@
2247 }/*namedOptions.activeUsers additional setup*/
2248 /* Settings menu entries... they are presented in the order listed
2249 here, so the most frequently-needed ones "should" (arguably) be
2250 closer to the start of this list. */
2251 /**
2252 Settings options structure: an array of Objects with the
2253 following properties:
2254
2255 label: string for the UI
2256
2257 boolValue: string (name of Chat.settings setting) or a function
2258 which returns true or false. If it is a string, it gets
@@ -2080,10 +2261,11 @@
2261 to the persistentSetting property of this object.
2262
2263 select: SELECT element (instead of boolValue)
2264
2265 callback: optional handler to call after setting is modified.
2266 It gets passed the setting object: {key:string, value:something}.
2267 Its "this" is the options object. If this object has a
2268 boolValue string or a persistentSetting property, the argument
2269 passed to the callback is a settings object in the form {key:K,
2270 value:V}. If this object does not have boolValue string or
2271 persistentSetting then the callback is passed an event object
@@ -2410,10 +2592,11 @@
2592 const btnPreview = Chat.e.btnPreview;
2593 Chat.setPreviewText = function(t){
2594 this.setCurrentView(this.e.viewPreview);
2595 this.e.previewContent.innerHTML = t;
2596 this.e.viewPreview.querySelectorAll('a').forEach(addAnchorTargetBlank);
2597 setupHashtags(this.e.previewContent)/*arguable, for usability reasons*/;
2598 this.inputFocus();
2599 };
2600 Chat.e.viewPreview.querySelector('button.action-close').
2601 addEventListener('click', ()=>Chat.setCurrentView(Chat.e.viewMessages), false);
2602 let previewPending = false;
@@ -2652,15 +2835,16 @@
2835 }
2836 return e;
2837 };
2838 Chat.clearSearch(true);
2839 /**
2840 Submits a history search using either its argument or the the
2841 main input field's current text.
2842 */
2843 Chat.submitSearch = function(term){
2844 Chat.setCurrentView(Chat.e.viewSearch);
2845 if(!arguments.length) term = this.inputValue(true);
2846 const eMsgTgt = this.clearSearch(true);
2847 if( !term ) return;
2848 D.append( eMsgTgt, "Searching for ",term," ...");
2849 const fd = new FormData();
2850 fd.set('q', term);
2851
--- src/markdown.c
+++ src/markdown.c
@@ -39,10 +39,16 @@
3939
MKDA_NOT_AUTOLINK, /* used internally when it is not an autolink*/
4040
MKDA_NORMAL, /* normal http/http/ftp link */
4141
MKDA_EXPLICIT_EMAIL, /* e-mail link with explicit mailto: */
4242
MKDA_IMPLICIT_EMAIL /* e-mail link without mailto: */
4343
};
44
+
45
+/* mkd_tagspan -- type of tagged <span> */
46
+enum mkd_tagspan {
47
+ MKDT_HASHTAG, /* #hashtags */
48
+ MKDT_NUMTAG /* #123[.456] /chat or /forum message IDs. */
49
+};
4450
4551
/* mkd_renderer -- functions for rendering parsed data */
4652
struct mkd_renderer {
4753
/* document level callbacks */
4854
void (*prolog)(struct Blob *ob, void *opaque);
@@ -80,10 +86,12 @@
8086
struct Blob *alt, void *opaque);
8187
int (*linebreak)(struct Blob *ob, void *opaque);
8288
int (*link)(struct Blob *ob, struct Blob *link, struct Blob *title,
8389
struct Blob *content, void *opaque);
8490
int (*raw_html_tag)(struct Blob *ob, struct Blob *tag, void *opaque);
91
+ int (*tagspan)(struct Blob *ob, struct Blob *ref, enum mkd_tagspan type,
92
+ void *opaque);
8593
int (*triple_emphasis)(struct Blob *ob, struct Blob *text,
8694
char c, void *opaque);
8795
int (*footnote_ref)(struct Blob *ob, const struct Blob *span,
8896
const struct Blob *upc, int index, int locus, void *opaque);
8997
@@ -950,10 +958,163 @@
950958
}else{
951959
blob_append(ob, data, end);
952960
}
953961
return end;
954962
}
963
+
964
+/* char_hashref_tag -- '#' followed by "word" characters to tag
965
+** post numbers, hashtags, etc.
966
+**
967
+** Basic syntax:
968
+**
969
+** ^[a-zA-Z]X*
970
+**
971
+** Where X is:
972
+**
973
+** - Any number of alphanumeric characters.
974
+**
975
+** - Single underscores. Adjacent underscores are not recognized
976
+** as valid hashtags. That decision is somewhat arbitrary
977
+** and up for debate.
978
+**
979
+** Hashtags must end at the end of input or be followed by whitespace
980
+** or what appears to be the end or separator of a logical
981
+** natural-language construct, e.g. period, colon, etc.
982
+**
983
+** Current limitations of this implementation:
984
+**
985
+** - Currently requires starting alpha and trailing
986
+** alphanumeric or underscores. "Should" be extended to
987
+** handle #X[.Y], where X and optional Y are integer
988
+** values, for forum post references.
989
+*/
990
+static size_t char_hashref_tag(
991
+ struct Blob *ob,
992
+ struct render *rndr,
993
+ char *data,
994
+ size_t offset,
995
+ size_t size
996
+){
997
+ size_t end;
998
+ struct Blob work = BLOB_INITIALIZER;
999
+ int nUscore = 0; /* Consecutive underscore counter */
1000
+ int numberMode = 0 /* 0 for normal, 1 for #NNN numeric,
1001
+ and 2 for #NNN.NNN. */;
1002
+ if(offset>0 && !fossil_isspace(data[-1])){
1003
+ /* Only ever match if the *previous* character is whitespace or
1004
+ ** we're at the start of the input. Note that we rely on fossil
1005
+ ** processing emphasis markup before reaching this function, so
1006
+ ** *#Hash* will Do The Right Thing. Not that this means that
1007
+ ** "#Hash." will match while ".#Hash" won't. That's okay. */
1008
+ return 0;
1009
+ }
1010
+ assert( '#' == data[0] );
1011
+ if(size < 2) return 0;
1012
+ end = 2;
1013
+ if(fossil_isdigit(data[1])){
1014
+ numberMode = 1;
1015
+ }else if(!fossil_isalpha(data[1])){
1016
+ switch(data[1] & 0xF0){
1017
+ /* Reminder: UTF8 char lengths can be determined by
1018
+ ** masking against 0xF0: 0xf0==4, 0xe0==3, 0xc0==2,
1019
+ ** else 1. */
1020
+ case 0xF0: end+=3; break;
1021
+ case 0xE0: end+=2; break;
1022
+ case 0xC0: end+=1; break;
1023
+ default: return 0;
1024
+ }
1025
+ }
1026
+#if 0
1027
+ fprintf(stderr,"HASHREF offset=%d size=%d: %.*s\n",
1028
+ (int)offset, (int)size, (int)size, data);
1029
+#endif
1030
+#define HASHTAG_LEGAL_END \
1031
+ case ' ': case '\t': case '\r': case '\n': \
1032
+ case ':': case ';': case '!': case '?': case ','
1033
+ /* ^^^^ '.' is handled separately */
1034
+ for(; end < size; ++end){
1035
+ char ch = data[end];
1036
+ switch(ch & 0xF0){
1037
+ case 0xF0: end+=3; continue;
1038
+ case 0xE0: end+=2; continue;
1039
+ case 0xC0: end+=1; continue;
1040
+ case 0x80: goto hashref_bailout /*invalid UTF8*/;
1041
+ default: break;
1042
+ }
1043
+#if 0
1044
+ fprintf(stderr,"hashtag? checking... length=%d: %.*s\n",
1045
+ (int)end, (int)end, data);
1046
+#endif
1047
+ switch(ch){
1048
+ case '_':
1049
+ /* Multiple adjacent underscores not permitted. */
1050
+ if(++nUscore>1) goto hashref_bailout;
1051
+ numberMode = 0;
1052
+ break;
1053
+ case '.':
1054
+ if(1==numberMode) ++numberMode;
1055
+ ch = 0;
1056
+ break;
1057
+ HASHTAG_LEGAL_END:
1058
+ ch = 0;
1059
+ break;
1060
+ case '0': case '1': case '2': case '3': case '4':
1061
+ case '5': case '6': case '7': case '8': case '9':
1062
+ nUscore = 0;
1063
+ break;
1064
+ default:
1065
+ if(numberMode || !fossil_isalpha(ch)){
1066
+ goto hashref_bailout;
1067
+ }
1068
+ nUscore = 0;
1069
+ break;
1070
+ }
1071
+ if(ch) continue;
1072
+ break;
1073
+ }
1074
+ if((end<3/* #. or some such */ && !numberMode)
1075
+ || end>size/*from truncated multi-byte char*/){
1076
+ return 0;
1077
+ }
1078
+ if(numberMode>1){
1079
+ /* Check for trailing part of #NNN.nnn... */
1080
+ assert('.'==data[end]);
1081
+ if(end<size-1 && fossil_isdigit(data[end+1])){
1082
+ for(++end; end<size; ++end){
1083
+ if(!fossil_isdigit(data[end])) break;
1084
+ }
1085
+ }
1086
+ }
1087
+#if 0
1088
+ fprintf(stderr,"???HASHREF length=%d: %.*s\n",
1089
+ (int)end, (int)end, data);
1090
+#endif
1091
+ if(end<size){
1092
+ /* Only match if we end at end of input or what "might" be the end
1093
+ of a natural language grammar construct, e.g. period or
1094
+ [semi]colon. */
1095
+ switch(data[end]){
1096
+ case '.':
1097
+ HASHTAG_LEGAL_END:
1098
+ break;
1099
+ default:
1100
+ goto hashref_bailout;
1101
+ }
1102
+ }
1103
+ blob_init(&work, data + 1, end - 1);
1104
+ rndr->make.tagspan(ob, &work,
1105
+ numberMode ? MKDT_NUMTAG : MKDT_HASHTAG,
1106
+ rndr->make.opaque);
1107
+ return end;
1108
+ hashref_bailout:
1109
+#if 0
1110
+ fprintf(stderr,"BAILING HASHREF examined=%d:\n[%.*s] of\n[%.*s]\n",
1111
+ (int)end, (int)end, data, (int)size, data);
1112
+#endif
1113
+#undef HASHTAG_LEGAL_END
1114
+ return 0;
1115
+}
9551116
9561117
/*
9571118
** char_langle_tag -- '<' when tags or autolinks are allowed.
9581119
*/
9591120
static size_t char_langle_tag(
@@ -2686,10 +2847,11 @@
26862847
}
26872848
}
26882849
if( rndr.make.codespan ) rndr.active_char['`'] = char_codespan;
26892850
if( rndr.make.linebreak ) rndr.active_char['\n'] = char_linebreak;
26902851
if( rndr.make.image || rndr.make.link ) rndr.active_char['['] = char_link;
2852
+ rndr.active_char['#'] = char_hashref_tag;
26912853
if( rndr.make.footnote_ref ) rndr.active_char['('] = char_footnote;
26922854
rndr.active_char['<'] = char_langle_tag;
26932855
rndr.active_char['\\'] = char_escape;
26942856
rndr.active_char['&'] = char_entity;
26952857
26962858
--- src/markdown.c
+++ src/markdown.c
@@ -39,10 +39,16 @@
39 MKDA_NOT_AUTOLINK, /* used internally when it is not an autolink*/
40 MKDA_NORMAL, /* normal http/http/ftp link */
41 MKDA_EXPLICIT_EMAIL, /* e-mail link with explicit mailto: */
42 MKDA_IMPLICIT_EMAIL /* e-mail link without mailto: */
43 };
 
 
 
 
 
 
44
45 /* mkd_renderer -- functions for rendering parsed data */
46 struct mkd_renderer {
47 /* document level callbacks */
48 void (*prolog)(struct Blob *ob, void *opaque);
@@ -80,10 +86,12 @@
80 struct Blob *alt, void *opaque);
81 int (*linebreak)(struct Blob *ob, void *opaque);
82 int (*link)(struct Blob *ob, struct Blob *link, struct Blob *title,
83 struct Blob *content, void *opaque);
84 int (*raw_html_tag)(struct Blob *ob, struct Blob *tag, void *opaque);
 
 
85 int (*triple_emphasis)(struct Blob *ob, struct Blob *text,
86 char c, void *opaque);
87 int (*footnote_ref)(struct Blob *ob, const struct Blob *span,
88 const struct Blob *upc, int index, int locus, void *opaque);
89
@@ -950,10 +958,163 @@
950 }else{
951 blob_append(ob, data, end);
952 }
953 return end;
954 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
955
956 /*
957 ** char_langle_tag -- '<' when tags or autolinks are allowed.
958 */
959 static size_t char_langle_tag(
@@ -2686,10 +2847,11 @@
2686 }
2687 }
2688 if( rndr.make.codespan ) rndr.active_char['`'] = char_codespan;
2689 if( rndr.make.linebreak ) rndr.active_char['\n'] = char_linebreak;
2690 if( rndr.make.image || rndr.make.link ) rndr.active_char['['] = char_link;
 
2691 if( rndr.make.footnote_ref ) rndr.active_char['('] = char_footnote;
2692 rndr.active_char['<'] = char_langle_tag;
2693 rndr.active_char['\\'] = char_escape;
2694 rndr.active_char['&'] = char_entity;
2695
2696
--- src/markdown.c
+++ src/markdown.c
@@ -39,10 +39,16 @@
39 MKDA_NOT_AUTOLINK, /* used internally when it is not an autolink*/
40 MKDA_NORMAL, /* normal http/http/ftp link */
41 MKDA_EXPLICIT_EMAIL, /* e-mail link with explicit mailto: */
42 MKDA_IMPLICIT_EMAIL /* e-mail link without mailto: */
43 };
44
45 /* mkd_tagspan -- type of tagged <span> */
46 enum mkd_tagspan {
47 MKDT_HASHTAG, /* #hashtags */
48 MKDT_NUMTAG /* #123[.456] /chat or /forum message IDs. */
49 };
50
51 /* mkd_renderer -- functions for rendering parsed data */
52 struct mkd_renderer {
53 /* document level callbacks */
54 void (*prolog)(struct Blob *ob, void *opaque);
@@ -80,10 +86,12 @@
86 struct Blob *alt, void *opaque);
87 int (*linebreak)(struct Blob *ob, void *opaque);
88 int (*link)(struct Blob *ob, struct Blob *link, struct Blob *title,
89 struct Blob *content, void *opaque);
90 int (*raw_html_tag)(struct Blob *ob, struct Blob *tag, void *opaque);
91 int (*tagspan)(struct Blob *ob, struct Blob *ref, enum mkd_tagspan type,
92 void *opaque);
93 int (*triple_emphasis)(struct Blob *ob, struct Blob *text,
94 char c, void *opaque);
95 int (*footnote_ref)(struct Blob *ob, const struct Blob *span,
96 const struct Blob *upc, int index, int locus, void *opaque);
97
@@ -950,10 +958,163 @@
958 }else{
959 blob_append(ob, data, end);
960 }
961 return end;
962 }
963
964 /* char_hashref_tag -- '#' followed by "word" characters to tag
965 ** post numbers, hashtags, etc.
966 **
967 ** Basic syntax:
968 **
969 ** ^[a-zA-Z]X*
970 **
971 ** Where X is:
972 **
973 ** - Any number of alphanumeric characters.
974 **
975 ** - Single underscores. Adjacent underscores are not recognized
976 ** as valid hashtags. That decision is somewhat arbitrary
977 ** and up for debate.
978 **
979 ** Hashtags must end at the end of input or be followed by whitespace
980 ** or what appears to be the end or separator of a logical
981 ** natural-language construct, e.g. period, colon, etc.
982 **
983 ** Current limitations of this implementation:
984 **
985 ** - Currently requires starting alpha and trailing
986 ** alphanumeric or underscores. "Should" be extended to
987 ** handle #X[.Y], where X and optional Y are integer
988 ** values, for forum post references.
989 */
990 static size_t char_hashref_tag(
991 struct Blob *ob,
992 struct render *rndr,
993 char *data,
994 size_t offset,
995 size_t size
996 ){
997 size_t end;
998 struct Blob work = BLOB_INITIALIZER;
999 int nUscore = 0; /* Consecutive underscore counter */
1000 int numberMode = 0 /* 0 for normal, 1 for #NNN numeric,
1001 and 2 for #NNN.NNN. */;
1002 if(offset>0 && !fossil_isspace(data[-1])){
1003 /* Only ever match if the *previous* character is whitespace or
1004 ** we're at the start of the input. Note that we rely on fossil
1005 ** processing emphasis markup before reaching this function, so
1006 ** *#Hash* will Do The Right Thing. Not that this means that
1007 ** "#Hash." will match while ".#Hash" won't. That's okay. */
1008 return 0;
1009 }
1010 assert( '#' == data[0] );
1011 if(size < 2) return 0;
1012 end = 2;
1013 if(fossil_isdigit(data[1])){
1014 numberMode = 1;
1015 }else if(!fossil_isalpha(data[1])){
1016 switch(data[1] & 0xF0){
1017 /* Reminder: UTF8 char lengths can be determined by
1018 ** masking against 0xF0: 0xf0==4, 0xe0==3, 0xc0==2,
1019 ** else 1. */
1020 case 0xF0: end+=3; break;
1021 case 0xE0: end+=2; break;
1022 case 0xC0: end+=1; break;
1023 default: return 0;
1024 }
1025 }
1026 #if 0
1027 fprintf(stderr,"HASHREF offset=%d size=%d: %.*s\n",
1028 (int)offset, (int)size, (int)size, data);
1029 #endif
1030 #define HASHTAG_LEGAL_END \
1031 case ' ': case '\t': case '\r': case '\n': \
1032 case ':': case ';': case '!': case '?': case ','
1033 /* ^^^^ '.' is handled separately */
1034 for(; end < size; ++end){
1035 char ch = data[end];
1036 switch(ch & 0xF0){
1037 case 0xF0: end+=3; continue;
1038 case 0xE0: end+=2; continue;
1039 case 0xC0: end+=1; continue;
1040 case 0x80: goto hashref_bailout /*invalid UTF8*/;
1041 default: break;
1042 }
1043 #if 0
1044 fprintf(stderr,"hashtag? checking... length=%d: %.*s\n",
1045 (int)end, (int)end, data);
1046 #endif
1047 switch(ch){
1048 case '_':
1049 /* Multiple adjacent underscores not permitted. */
1050 if(++nUscore>1) goto hashref_bailout;
1051 numberMode = 0;
1052 break;
1053 case '.':
1054 if(1==numberMode) ++numberMode;
1055 ch = 0;
1056 break;
1057 HASHTAG_LEGAL_END:
1058 ch = 0;
1059 break;
1060 case '0': case '1': case '2': case '3': case '4':
1061 case '5': case '6': case '7': case '8': case '9':
1062 nUscore = 0;
1063 break;
1064 default:
1065 if(numberMode || !fossil_isalpha(ch)){
1066 goto hashref_bailout;
1067 }
1068 nUscore = 0;
1069 break;
1070 }
1071 if(ch) continue;
1072 break;
1073 }
1074 if((end<3/* #. or some such */ && !numberMode)
1075 || end>size/*from truncated multi-byte char*/){
1076 return 0;
1077 }
1078 if(numberMode>1){
1079 /* Check for trailing part of #NNN.nnn... */
1080 assert('.'==data[end]);
1081 if(end<size-1 && fossil_isdigit(data[end+1])){
1082 for(++end; end<size; ++end){
1083 if(!fossil_isdigit(data[end])) break;
1084 }
1085 }
1086 }
1087 #if 0
1088 fprintf(stderr,"???HASHREF length=%d: %.*s\n",
1089 (int)end, (int)end, data);
1090 #endif
1091 if(end<size){
1092 /* Only match if we end at end of input or what "might" be the end
1093 of a natural language grammar construct, e.g. period or
1094 [semi]colon. */
1095 switch(data[end]){
1096 case '.':
1097 HASHTAG_LEGAL_END:
1098 break;
1099 default:
1100 goto hashref_bailout;
1101 }
1102 }
1103 blob_init(&work, data + 1, end - 1);
1104 rndr->make.tagspan(ob, &work,
1105 numberMode ? MKDT_NUMTAG : MKDT_HASHTAG,
1106 rndr->make.opaque);
1107 return end;
1108 hashref_bailout:
1109 #if 0
1110 fprintf(stderr,"BAILING HASHREF examined=%d:\n[%.*s] of\n[%.*s]\n",
1111 (int)end, (int)end, data, (int)size, data);
1112 #endif
1113 #undef HASHTAG_LEGAL_END
1114 return 0;
1115 }
1116
1117 /*
1118 ** char_langle_tag -- '<' when tags or autolinks are allowed.
1119 */
1120 static size_t char_langle_tag(
@@ -2686,10 +2847,11 @@
2847 }
2848 }
2849 if( rndr.make.codespan ) rndr.active_char['`'] = char_codespan;
2850 if( rndr.make.linebreak ) rndr.active_char['\n'] = char_linebreak;
2851 if( rndr.make.image || rndr.make.link ) rndr.active_char['['] = char_link;
2852 rndr.active_char['#'] = char_hashref_tag;
2853 if( rndr.make.footnote_ref ) rndr.active_char['('] = char_footnote;
2854 rndr.active_char['<'] = char_langle_tag;
2855 rndr.active_char['\\'] = char_escape;
2856 rndr.active_char['&'] = char_entity;
2857
2858
--- src/markdown_html.c
+++ src/markdown_html.c
@@ -868,10 +868,42 @@
868868
blob_appendb(ob, content);
869869
}
870870
blob_append(ob, zClose, -1);
871871
return 1;
872872
}
873
+
874
+/* Invoked for @name and #tag tagged words, marked up in the
875
+** output text in a way that JS and CSS can do something
876
+** interesting with them. This isn't standard Markdown, so
877
+** it's implementation-specific what occurs here. More, each
878
+** Fossil feature using Markdown is free to apply styling and
879
+** behavior to these in feature-specific ways.
880
+*/
881
+static int html_tagspan(
882
+ struct Blob *ob, /* Write the output here */
883
+ struct Blob *text, /* The word after the tag character */
884
+ enum mkd_tagspan type, /* Which type of tagspan we're creating */
885
+ void *opaque
886
+){
887
+ if( text==0 ){
888
+ /* no-op */
889
+ }else{
890
+ char cPrefix = '!';
891
+ blob_append_literal(ob, "<span data-");
892
+ switch (type) {
893
+ case MKDT_HASHTAG:
894
+ cPrefix = '#'; blob_append_literal(ob, "hashtag"); break;
895
+ case MKDT_NUMTAG:
896
+ cPrefix = '#'; blob_append_literal(ob, "numtag"); break;
897
+ }
898
+ blob_append_literal(ob, "=\"");
899
+ html_quote(ob, blob_buffer(text), blob_size(text));
900
+ blob_append_literal(ob, "\"");
901
+ blob_appendf(ob, ">%c%b</span>", cPrefix,text);
902
+ }
903
+ return 1;
904
+}
873905
874906
static int html_triple_emphasis(
875907
struct Blob *ob,
876908
struct Blob *text,
877909
char c,
@@ -926,10 +958,11 @@
926958
html_emphasis,
927959
html_image,
928960
html_linebreak,
929961
html_link,
930962
html_raw_html_tag,
963
+ html_tagspan,
931964
html_triple_emphasis,
932965
html_footnote_ref,
933966
934967
/* low level elements */
935968
0, /* entity */
936969
--- src/markdown_html.c
+++ src/markdown_html.c
@@ -868,10 +868,42 @@
868 blob_appendb(ob, content);
869 }
870 blob_append(ob, zClose, -1);
871 return 1;
872 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
873
874 static int html_triple_emphasis(
875 struct Blob *ob,
876 struct Blob *text,
877 char c,
@@ -926,10 +958,11 @@
926 html_emphasis,
927 html_image,
928 html_linebreak,
929 html_link,
930 html_raw_html_tag,
 
931 html_triple_emphasis,
932 html_footnote_ref,
933
934 /* low level elements */
935 0, /* entity */
936
--- src/markdown_html.c
+++ src/markdown_html.c
@@ -868,10 +868,42 @@
868 blob_appendb(ob, content);
869 }
870 blob_append(ob, zClose, -1);
871 return 1;
872 }
873
874 /* Invoked for @name and #tag tagged words, marked up in the
875 ** output text in a way that JS and CSS can do something
876 ** interesting with them. This isn't standard Markdown, so
877 ** it's implementation-specific what occurs here. More, each
878 ** Fossil feature using Markdown is free to apply styling and
879 ** behavior to these in feature-specific ways.
880 */
881 static int html_tagspan(
882 struct Blob *ob, /* Write the output here */
883 struct Blob *text, /* The word after the tag character */
884 enum mkd_tagspan type, /* Which type of tagspan we're creating */
885 void *opaque
886 ){
887 if( text==0 ){
888 /* no-op */
889 }else{
890 char cPrefix = '!';
891 blob_append_literal(ob, "<span data-");
892 switch (type) {
893 case MKDT_HASHTAG:
894 cPrefix = '#'; blob_append_literal(ob, "hashtag"); break;
895 case MKDT_NUMTAG:
896 cPrefix = '#'; blob_append_literal(ob, "numtag"); break;
897 }
898 blob_append_literal(ob, "=\"");
899 html_quote(ob, blob_buffer(text), blob_size(text));
900 blob_append_literal(ob, "\"");
901 blob_appendf(ob, ">%c%b</span>", cPrefix,text);
902 }
903 return 1;
904 }
905
906 static int html_triple_emphasis(
907 struct Blob *ob,
908 struct Blob *text,
909 char c,
@@ -926,10 +958,11 @@
958 html_emphasis,
959 html_image,
960 html_linebreak,
961 html_link,
962 html_raw_html_tag,
963 html_tagspan,
964 html_triple_emphasis,
965 html_footnote_ref,
966
967 /* low level elements */
968 0, /* entity */
969
--- src/style.chat.css
+++ src/style.chat.css
@@ -655,10 +655,21 @@
655655
}
656656
body.chat #chat-user-list .chat-user.selected {
657657
font-weight: bold;
658658
text-decoration: underline;
659659
}
660
+
661
+body.chat span[data-hashtag],
662
+body.chat span[data-numtag]{
663
+ font-family: monospace;
664
+ text-decoration: underline;
665
+ cursor: pointer;
666
+}
667
+
668
+body.chat #chat-clear-filter {
669
+ margin: 0.25em 0.5em;
670
+}
660671
661672
body.chat .searchForm {
662673
margin-top: 1em;
663674
}
664675
body.chat .spacer-widget button {
@@ -692,19 +703,26 @@
692703
}
693704
body.chat .anim-fade-in {
694705
animation: fade-in 750ms linear;
695706
}
696707
body.chat .anim-fade-in-fast {
697
- animation: fade-in 350ms linear;
708
+ animation: fade-in 300ms linear;
698709
}
699710
@keyframes fade-in {
700711
from { opacity: 0; }
701712
to { opacity: 1; }
702713
}
703714
body.chat .anim-fade-out-fast {
704
- animation: fade-out 250ms linear;
715
+ animation: fade-out 300ms linear;
705716
}
706717
@keyframes fade-out {
707718
from { opacity: 1; }
708719
to { opacity: 0; }
709720
}
710721
722
+body.chat .anim-fade-out-in {
723
+ animation: fade-out-in 1000ms linear;
724
+}
725
+@keyframes fade-out-in {
726
+ 0%,100% { opacity: 0 }
727
+ 50% { opacity: 1 }
728
+}
711729
--- src/style.chat.css
+++ src/style.chat.css
@@ -655,10 +655,21 @@
655 }
656 body.chat #chat-user-list .chat-user.selected {
657 font-weight: bold;
658 text-decoration: underline;
659 }
 
 
 
 
 
 
 
 
 
 
 
660
661 body.chat .searchForm {
662 margin-top: 1em;
663 }
664 body.chat .spacer-widget button {
@@ -692,19 +703,26 @@
692 }
693 body.chat .anim-fade-in {
694 animation: fade-in 750ms linear;
695 }
696 body.chat .anim-fade-in-fast {
697 animation: fade-in 350ms linear;
698 }
699 @keyframes fade-in {
700 from { opacity: 0; }
701 to { opacity: 1; }
702 }
703 body.chat .anim-fade-out-fast {
704 animation: fade-out 250ms linear;
705 }
706 @keyframes fade-out {
707 from { opacity: 1; }
708 to { opacity: 0; }
709 }
710
 
 
 
 
 
 
 
711
--- src/style.chat.css
+++ src/style.chat.css
@@ -655,10 +655,21 @@
655 }
656 body.chat #chat-user-list .chat-user.selected {
657 font-weight: bold;
658 text-decoration: underline;
659 }
660
661 body.chat span[data-hashtag],
662 body.chat span[data-numtag]{
663 font-family: monospace;
664 text-decoration: underline;
665 cursor: pointer;
666 }
667
668 body.chat #chat-clear-filter {
669 margin: 0.25em 0.5em;
670 }
671
672 body.chat .searchForm {
673 margin-top: 1em;
674 }
675 body.chat .spacer-widget button {
@@ -692,19 +703,26 @@
703 }
704 body.chat .anim-fade-in {
705 animation: fade-in 750ms linear;
706 }
707 body.chat .anim-fade-in-fast {
708 animation: fade-in 300ms linear;
709 }
710 @keyframes fade-in {
711 from { opacity: 0; }
712 to { opacity: 1; }
713 }
714 body.chat .anim-fade-out-fast {
715 animation: fade-out 300ms linear;
716 }
717 @keyframes fade-out {
718 from { opacity: 1; }
719 to { opacity: 0; }
720 }
721
722 body.chat .anim-fade-out-in {
723 animation: fade-out-in 1000ms linear;
724 }
725 @keyframes fade-out-in {
726 0%,100% { opacity: 0 }
727 50% { opacity: 1 }
728 }
729
+14 -5
--- src/wikiformat.c
+++ src/wikiformat.c
@@ -2148,13 +2148,15 @@
21482148
}
21492149
21502150
/*
21512151
** COMMAND: test-markdown-render
21522152
**
2153
-** Usage: %fossil test-markdown-render FILE ...
2153
+** Usage: %fossil test-markdown-render TEXT ...
21542154
**
2155
-** Render markdown in FILE as HTML on stdout.
2155
+** Render markdown in TEXT as HTML on stdout, where TEXT
2156
+** may be a file name or a markdown-formatted string.
2157
+**
21562158
** Options:
21572159
**
21582160
** --dark-pikchr Render pikchrs in dark mode
21592161
** --lint-footnotes Print stats for footnotes-related issues
21602162
** --safe Restrict the output to use only "safe" HTML
@@ -2172,13 +2174,20 @@
21722174
}
21732175
bText = find_option("text",0,0)!=0;
21742176
verify_all_options();
21752177
for(i=2; i<g.argc; i++){
21762178
blob_zero(&out);
2177
- blob_read_from_file(&in, g.argv[i], ExtFILE);
2178
- if( g.argc>3 ){
2179
- fossil_print("<!------ %h ------->\n", g.argv[i]);
2179
+ if(file_isfile(g.argv[i], ExtFILE)){
2180
+ blob_read_from_file(&in, g.argv[i], ExtFILE);
2181
+ if( g.argc>3 ){
2182
+ fossil_print("<!------ %h ------->\n", g.argv[i]);
2183
+ }
2184
+ }else{
2185
+ blob_init(&in, g.argv[i], -1);
2186
+ if( g.argc>3 ){
2187
+ fossil_print("<!------ script #%d ------->\n", i-1);
2188
+ }
21802189
}
21812190
markdown_to_html(&in, 0, &out);
21822191
safe_html_context( bSafe ? DOCSRC_UNTRUSTED : DOCSRC_TRUSTED );
21832192
safe_html(&out);
21842193
if( bText ){
21852194
--- src/wikiformat.c
+++ src/wikiformat.c
@@ -2148,13 +2148,15 @@
2148 }
2149
2150 /*
2151 ** COMMAND: test-markdown-render
2152 **
2153 ** Usage: %fossil test-markdown-render FILE ...
2154 **
2155 ** Render markdown in FILE as HTML on stdout.
 
 
2156 ** Options:
2157 **
2158 ** --dark-pikchr Render pikchrs in dark mode
2159 ** --lint-footnotes Print stats for footnotes-related issues
2160 ** --safe Restrict the output to use only "safe" HTML
@@ -2172,13 +2174,20 @@
2172 }
2173 bText = find_option("text",0,0)!=0;
2174 verify_all_options();
2175 for(i=2; i<g.argc; i++){
2176 blob_zero(&out);
2177 blob_read_from_file(&in, g.argv[i], ExtFILE);
2178 if( g.argc>3 ){
2179 fossil_print("<!------ %h ------->\n", g.argv[i]);
 
 
 
 
 
 
 
2180 }
2181 markdown_to_html(&in, 0, &out);
2182 safe_html_context( bSafe ? DOCSRC_UNTRUSTED : DOCSRC_TRUSTED );
2183 safe_html(&out);
2184 if( bText ){
2185
--- src/wikiformat.c
+++ src/wikiformat.c
@@ -2148,13 +2148,15 @@
2148 }
2149
2150 /*
2151 ** COMMAND: test-markdown-render
2152 **
2153 ** Usage: %fossil test-markdown-render TEXT ...
2154 **
2155 ** Render markdown in TEXT as HTML on stdout, where TEXT
2156 ** may be a file name or a markdown-formatted string.
2157 **
2158 ** Options:
2159 **
2160 ** --dark-pikchr Render pikchrs in dark mode
2161 ** --lint-footnotes Print stats for footnotes-related issues
2162 ** --safe Restrict the output to use only "safe" HTML
@@ -2172,13 +2174,20 @@
2174 }
2175 bText = find_option("text",0,0)!=0;
2176 verify_all_options();
2177 for(i=2; i<g.argc; i++){
2178 blob_zero(&out);
2179 if(file_isfile(g.argv[i], ExtFILE)){
2180 blob_read_from_file(&in, g.argv[i], ExtFILE);
2181 if( g.argc>3 ){
2182 fossil_print("<!------ %h ------->\n", g.argv[i]);
2183 }
2184 }else{
2185 blob_init(&in, g.argv[i], -1);
2186 if( g.argc>3 ){
2187 fossil_print("<!------ script #%d ------->\n", i-1);
2188 }
2189 }
2190 markdown_to_html(&in, 0, &out);
2191 safe_html_context( bSafe ? DOCSRC_UNTRUSTED : DOCSRC_TRUSTED );
2192 safe_html(&out);
2193 if( bText ){
2194
--- www/changes.wiki
+++ www/changes.wiki
@@ -35,10 +35,16 @@
3535
<li> The "diff" command (in all its variations) when invoked with --tk
3636
now accepts an extra argument of the form "HOST:DIRECTORY" or
3737
"USER@HOST:DIRECTORY" to specify a directory on a remote host in which
3838
the diff is to be taken. The diff is run using SSH, the results are
3939
captured, then displayed locally using the Tcl/Tk diff GUI.
40
+ <li> Markdown-to-HTML now recognizes hashtag-like constructs. This
41
+ change is usually invisible but it allows specific pages to
42
+ plug in styling and/or JavaScript functionality. In
43
+ [/help/www/chat|/chat], tapping on a reference in the form
44
+ "#1234" will jump to that message if it's loaded or search for
45
+ it if it's not.
4046
</ol>
4147
4248
<h2 id='v2_28'>Changes for version 2.28 (2026-03-11)</h2><ol>
4349
<li> Improvements to [./antibot.wiki|anti-robot defenses]:<ol type="a">
4450
<li> The default configuration now allows robots to download any tarball
4551
--- www/changes.wiki
+++ www/changes.wiki
@@ -35,10 +35,16 @@
35 <li> The "diff" command (in all its variations) when invoked with --tk
36 now accepts an extra argument of the form "HOST:DIRECTORY" or
37 "USER@HOST:DIRECTORY" to specify a directory on a remote host in which
38 the diff is to be taken. The diff is run using SSH, the results are
39 captured, then displayed locally using the Tcl/Tk diff GUI.
 
 
 
 
 
 
40 </ol>
41
42 <h2 id='v2_28'>Changes for version 2.28 (2026-03-11)</h2><ol>
43 <li> Improvements to [./antibot.wiki|anti-robot defenses]:<ol type="a">
44 <li> The default configuration now allows robots to download any tarball
45
--- www/changes.wiki
+++ www/changes.wiki
@@ -35,10 +35,16 @@
35 <li> The "diff" command (in all its variations) when invoked with --tk
36 now accepts an extra argument of the form "HOST:DIRECTORY" or
37 "USER@HOST:DIRECTORY" to specify a directory on a remote host in which
38 the diff is to be taken. The diff is run using SSH, the results are
39 captured, then displayed locally using the Tcl/Tk diff GUI.
40 <li> Markdown-to-HTML now recognizes hashtag-like constructs. This
41 change is usually invisible but it allows specific pages to
42 plug in styling and/or JavaScript functionality. In
43 [/help/www/chat|/chat], tapping on a reference in the form
44 "#1234" will jump to that message if it's loaded or search for
45 it if it's not.
46 </ol>
47
48 <h2 id='v2_28'>Changes for version 2.28 (2026-03-11)</h2><ol>
49 <li> Improvements to [./antibot.wiki|anti-robot defenses]:<ol type="a">
50 <li> The default configuration now allows robots to download any tarball
51

Keyboard Shortcuts

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