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].
Commit
7192ce19c0233e0e03f48ab12d2ebd254356d3306d357a48ab6216475d76ea8a
Parent
40c12262faac8a5…
10 files changed
+3
+1
+7
-5
+236
-52
+236
-52
+162
+33
+20
-2
+14
-5
+6
+3
| --- src/backlink.c | ||
| +++ src/backlink.c | ||
| @@ -283,10 +283,12 @@ | ||
| 283 | 283 | static int mkdn_noop_r_html_tag(Blob *b1, Blob *b2, void *v){ return 1; } |
| 284 | 284 | static int (*mkdn_noop_tri_emphas)(Blob*, Blob*, char, |
| 285 | 285 | void*) = mkdn_noop_emphasis; |
| 286 | 286 | static int mkdn_noop_footnoteref(Blob *b1, const Blob *b2, const Blob *b3, |
| 287 | 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; } | |
| 288 | 290 | |
| 289 | 291 | /* |
| 290 | 292 | ** Scan markdown text and add self-hyperlinks to the BACKLINK table. |
| 291 | 293 | */ |
| 292 | 294 | void markdown_extract_links( |
| @@ -317,10 +319,11 @@ | ||
| 317 | 319 | /* emphasis */ mkdn_noop_emphasis, |
| 318 | 320 | /* image */ mkdn_noop_image, |
| 319 | 321 | /* linebreak */ mkdn_noop_linebreak, |
| 320 | 322 | /* link */ backlink_md_link, |
| 321 | 323 | /* r_html_tag */ mkdn_noop_r_html_tag, |
| 324 | + /* #tags */ mkdn_noop_tagref, | |
| 322 | 325 | /* tri_emphas */ mkdn_noop_tri_emphas, |
| 323 | 326 | /* footnoteref*/ mkdn_noop_footnoteref, |
| 324 | 327 | |
| 325 | 328 | 0, /* entity */ |
| 326 | 329 | 0, /* normal_text */ |
| 327 | 330 |
| --- 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 @@ | ||
| 216 | 216 | @ </span> |
| 217 | 217 | @ <span>Active users (sorted by last message time)</span> |
| 218 | 218 | @ </div> |
| 219 | 219 | @ <div id='chat-user-list'></div> |
| 220 | 220 | @ </div> |
| 221 | + @ <button id='chat-clear-filter' class='hidden'>Clear filter</button> | |
| 221 | 222 | @ <div id='chat-preview' class='hidden chat-view'> |
| 222 | 223 | @ <header>Preview: (<a href='%R/md_rules' target='_blank'>markdown reference</a>)</header> |
| 223 | 224 | @ <div id='chat-preview-content'></div> |
| 224 | 225 | @ <div class='button-bar'><button class='action-close'>Close Preview</button></div> |
| 225 | 226 | @ </div> |
| 226 | 227 |
| --- 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 |
+7
-5
| --- src/fossil.bootstrap.js | ||
| +++ src/fossil.bootstrap.js | ||
| @@ -283,19 +283,21 @@ | ||
| 283 | 283 | } |
| 284 | 284 | return this; |
| 285 | 285 | }; |
| 286 | 286 | |
| 287 | 287 | /** |
| 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. | |
| 290 | 291 | */ |
| 291 | - F.page.setPageTitle = function(title){ | |
| 292 | + F.page.setPageTitle = function f(title){ | |
| 292 | 293 | const t = document.querySelector('title'); |
| 293 | - if(t) t.innerText = title; | |
| 294 | + if(t) t.innerText = title || f.$orig; | |
| 294 | 295 | return this; |
| 295 | 296 | }; |
| 296 | - | |
| 297 | + F.onPageLoad(()=>F.page.setPageTitle.$orig | |
| 298 | + = document.querySelector('title').innerText); | |
| 297 | 299 | /** |
| 298 | 300 | Returns a function, that, as long as it continues to be invoked, |
| 299 | 301 | will not be triggered. The function will be called after it stops |
| 300 | 302 | being called for N milliseconds. If `immediate` is passed, call |
| 301 | 303 | the callback immediately and hinder future invocations until at |
| 302 | 304 |
| --- 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 |
+236
-52
| --- src/fossil.page.chat.js | ||
| +++ src/fossil.page.chat.js | ||
| @@ -90,15 +90,14 @@ | ||
| 90 | 90 | While we're here, we also use this to cap the max-height |
| 91 | 91 | of the input field so that pasting huge text does not scroll |
| 92 | 92 | the upper area of the input widget off-screen. */ |
| 93 | 93 | const elemsToCount = GetFramingElements(); |
| 94 | 94 | const contentArea = E1('div.content'); |
| 95 | - const bcl = document.body.classList; | |
| 96 | 95 | const resized = function f(){ |
| 97 | 96 | if(f.$disabled) return; |
| 98 | 97 | const wh = window.innerHeight, |
| 99 | - com = bcl.contains('chat-only-mode'); | |
| 98 | + com = document.body.classList.contains('chat-only-mode'); | |
| 100 | 99 | var ht; |
| 101 | 100 | var extra = 0; |
| 102 | 101 | if(com){ |
| 103 | 102 | ht = wh; |
| 104 | 103 | }else{ |
| @@ -161,10 +160,11 @@ | ||
| 161 | 160 | searchContent: E1('#chat-search-content'), |
| 162 | 161 | btnPreview: E1('#chat-button-preview'), |
| 163 | 162 | views: document.querySelectorAll('.chat-view'), |
| 164 | 163 | activeUserListWrapper: E1('#chat-user-list-wrapper'), |
| 165 | 164 | activeUserList: E1('#chat-user-list'), |
| 165 | + btnClearFilter: E1('#chat-clear-filter'), | |
| 166 | 166 | eMsgPollError: undefined /* current connection error MessageMidget */, |
| 167 | 167 | pollErrorMarker: document.body /* element to toggle 'connection-error' CSS class on */ |
| 168 | 168 | }, |
| 169 | 169 | me: F.user.name, |
| 170 | 170 | mxMsg: F.config.chat.initSize ? -F.config.chat.initSize : -50, |
| @@ -181,15 +181,31 @@ | ||
| 181 | 181 | (JS Date object). Only messages received by the chat client |
| 182 | 182 | are considered. */ |
| 183 | 183 | /* Reminder: to convert a Julian time J to JS: |
| 184 | 184 | new Date((J - 2440587.5) * 86400000) */ |
| 185 | 185 | }, |
| 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*/ | |
| 191 | 207 | }, |
| 192 | 208 | /** |
| 193 | 209 | The timer object is used to control connection throttling |
| 194 | 210 | when connection errors arrise. It starts off with a polling |
| 195 | 211 | delay of $initialDelay ms. If there's a connection error, |
| @@ -376,11 +392,12 @@ | ||
| 376 | 392 | the list. */ |
| 377 | 393 | injectMessageElem: function f(e, atEnd){ |
| 378 | 394 | const mip = atEnd ? this.e.loadOlderToolbar : this.e.messageInjectPoint, |
| 379 | 395 | holder = this.e.viewMessages, |
| 380 | 396 | prevMessage = this.e.newestMessage; |
| 381 | - if(!this.filterState.match(e.dataset.xfrom)){ | |
| 397 | + if(this.filter.current | |
| 398 | + && !this.filter.current.matchElem(e)){ | |
| 382 | 399 | e.classList.add('hidden'); |
| 383 | 400 | } |
| 384 | 401 | if(atEnd){ |
| 385 | 402 | const fe = mip.nextElementSibling; |
| 386 | 403 | if(fe) mip.parentNode.insertBefore(e, fe); |
| @@ -622,11 +639,10 @@ | ||
| 622 | 639 | } |
| 623 | 640 | this.e.views.forEach(function(E){ |
| 624 | 641 | if(e!==E) D.addClass(E,'hidden'); |
| 625 | 642 | }); |
| 626 | 643 | this.e.currentView = e; |
| 627 | - if(this.e.currentView.$beforeShow) this.e.currentView.$beforeShow(); | |
| 628 | 644 | D.removeClass(e,'hidden'); |
| 629 | 645 | this.animate(this.e.currentView, 'anim-fade-in-fast'); |
| 630 | 646 | return this.e.currentView; |
| 631 | 647 | }, |
| 632 | 648 | |
| @@ -674,11 +690,11 @@ | ||
| 674 | 690 | else return 0; |
| 675 | 691 | }; |
| 676 | 692 | callee.addUserElem = function(u){ |
| 677 | 693 | const uSpan = D.addClass(D.span(), 'chat-user'); |
| 678 | 694 | const uDate = self.usersLastSeen[u]; |
| 679 | - if(self.filterState.activeUser===u){ | |
| 695 | + if(self.filter.user.activeTag===u){ | |
| 680 | 696 | uSpan.classList.add('selected'); |
| 681 | 697 | } |
| 682 | 698 | uSpan.dataset.uname = u; |
| 683 | 699 | D.append(uSpan, u, "\n", |
| 684 | 700 | D.append( |
| @@ -696,11 +712,82 @@ | ||
| 696 | 712 | Object.keys(this.usersLastSeen).sort( |
| 697 | 713 | callee.sortUsersSeen |
| 698 | 714 | ).forEach(callee.addUserElem); |
| 699 | 715 | return this; |
| 700 | 716 | }, |
| 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 | + */ | |
| 702 | 789 | showActiveUserList: function(yes){ |
| 703 | 790 | if(0===arguments.length) yes = true; |
| 704 | 791 | this.e.activeUserListWrapper.classList[ |
| 705 | 792 | yes ? 'remove' : 'add' |
| 706 | 793 | ]('hidden'); |
| @@ -726,32 +813,38 @@ | ||
| 726 | 813 | /** |
| 727 | 814 | Applies user name filter to all current messages, or clears |
| 728 | 815 | the filter if uname is falsy. |
| 729 | 816 | */ |
| 730 | 817 | 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; | |
| 733 | 825 | 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; | |
| 753 | 846 | return this; |
| 754 | 847 | }, |
| 755 | 848 | |
| 756 | 849 | /** |
| 757 | 850 | If animations are enabled, passes its arguments |
| @@ -1073,22 +1166,106 @@ | ||
| 1073 | 1166 | cs.setCurrentView(cs.e.viewMessages); |
| 1074 | 1167 | if(eUser.classList.contains('selected')){ |
| 1075 | 1168 | /* If currently selected, toggle filter off */ |
| 1076 | 1169 | eUser.classList.remove('selected'); |
| 1077 | 1170 | cs.setUserFilter(false); |
| 1078 | - delete f.$eSelected; | |
| 1079 | 1171 | }else{ |
| 1080 | - if(f.$eSelected) f.$eSelected.classList.remove('selected'); | |
| 1081 | - f.$eSelected = eUser; | |
| 1082 | 1172 | eUser.classList.add('selected'); |
| 1083 | 1173 | cs.setUserFilter(uname); |
| 1084 | 1174 | } |
| 1085 | 1175 | return false; |
| 1086 | 1176 | }, false); |
| 1177 | + | |
| 1178 | + cs.e.btnClearFilter.addEventListener('click',function(){ | |
| 1179 | + D.addClass(this,'hidden'); | |
| 1180 | + cs.clearFilters(); | |
| 1181 | + }, false); | |
| 1087 | 1182 | return cs; |
| 1088 | 1183 | })()/*Chat initialization*/; |
| 1089 | 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()*/; | |
| 1090 | 1267 | |
| 1091 | 1268 | /** Returns the first .message-widget element in DOM element |
| 1092 | 1269 | e's lineage. */ |
| 1093 | 1270 | const findMessageWidgetParent = function(e){ |
| 1094 | 1271 | while( e && !e.classList.contains('message-widget')){ |
| @@ -1354,10 +1531,11 @@ | ||
| 1354 | 1531 | // Used by Chat.reportErrorAsMessage() |
| 1355 | 1532 | D.append(contentTarget, m.xmsg); |
| 1356 | 1533 | }else{ |
| 1357 | 1534 | contentTarget.innerHTML = m.xmsg; |
| 1358 | 1535 | contentTarget.querySelectorAll('a').forEach(addAnchorTargetBlank); |
| 1536 | + setupHashtags(contentTarget); | |
| 1359 | 1537 | if(F.pikchr){ |
| 1360 | 1538 | F.pikchr.addSrcView(contentTarget.querySelectorAll('svg.pikchr')); |
| 1361 | 1539 | } |
| 1362 | 1540 | } |
| 1363 | 1541 | } |
| @@ -1470,31 +1648,23 @@ | ||
| 1470 | 1648 | y: 'a' |
| 1471 | 1649 | }), "User's Timeline"), |
| 1472 | 1650 | 'target', '_blank' |
| 1473 | 1651 | ); |
| 1474 | 1652 | 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 | |
| 1478 | 1655 | this message in its original context. */ |
| 1479 | 1656 | D.append( |
| 1480 | 1657 | this.e, |
| 1481 | 1658 | D.append( |
| 1482 | 1659 | D.addClass(D.div(), 'toolbar'), |
| 1483 | 1660 | D.button( |
| 1484 | 1661 | "Message in context", |
| 1485 | 1662 | function(){ |
| 1486 | 1663 | 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); | |
| 1496 | 1666 | }) |
| 1497 | 1667 | ) |
| 1498 | 1668 | ); |
| 1499 | 1669 | }/*jump-to button*/ |
| 1500 | 1670 | } |
| @@ -1524,10 +1694,20 @@ | ||
| 1524 | 1694 | }/*f.popup*/; |
| 1525 | 1695 | }/*end static init*/ |
| 1526 | 1696 | const theMsg = findMessageWidgetParent(ev.target); |
| 1527 | 1697 | if(theMsg) f.popup.show(theMsg); |
| 1528 | 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 | + } | |
| 1529 | 1709 | }; |
| 1530 | 1710 | return ctor; |
| 1531 | 1711 | })()/*MessageWidget*/; |
| 1532 | 1712 | |
| 1533 | 1713 | /** |
| @@ -2067,11 +2247,12 @@ | ||
| 2067 | 2247 | }/*namedOptions.activeUsers additional setup*/ |
| 2068 | 2248 | /* Settings menu entries... they are presented in the order listed |
| 2069 | 2249 | here, so the most frequently-needed ones "should" (arguably) be |
| 2070 | 2250 | closer to the start of this list. */ |
| 2071 | 2251 | /** |
| 2072 | - Settings ops structure: | |
| 2252 | + Settings options structure: an array of Objects with the | |
| 2253 | + following properties: | |
| 2073 | 2254 | |
| 2074 | 2255 | label: string for the UI |
| 2075 | 2256 | |
| 2076 | 2257 | boolValue: string (name of Chat.settings setting) or a function |
| 2077 | 2258 | which returns true or false. If it is a string, it gets |
| @@ -2080,10 +2261,11 @@ | ||
| 2080 | 2261 | to the persistentSetting property of this object. |
| 2081 | 2262 | |
| 2082 | 2263 | select: SELECT element (instead of boolValue) |
| 2083 | 2264 | |
| 2084 | 2265 | callback: optional handler to call after setting is modified. |
| 2266 | + It gets passed the setting object: {key:string, value:something}. | |
| 2085 | 2267 | Its "this" is the options object. If this object has a |
| 2086 | 2268 | boolValue string or a persistentSetting property, the argument |
| 2087 | 2269 | passed to the callback is a settings object in the form {key:K, |
| 2088 | 2270 | value:V}. If this object does not have boolValue string or |
| 2089 | 2271 | persistentSetting then the callback is passed an event object |
| @@ -2410,10 +2592,11 @@ | ||
| 2410 | 2592 | const btnPreview = Chat.e.btnPreview; |
| 2411 | 2593 | Chat.setPreviewText = function(t){ |
| 2412 | 2594 | this.setCurrentView(this.e.viewPreview); |
| 2413 | 2595 | this.e.previewContent.innerHTML = t; |
| 2414 | 2596 | this.e.viewPreview.querySelectorAll('a').forEach(addAnchorTargetBlank); |
| 2597 | + setupHashtags(this.e.previewContent)/*arguable, for usability reasons*/; | |
| 2415 | 2598 | this.inputFocus(); |
| 2416 | 2599 | }; |
| 2417 | 2600 | Chat.e.viewPreview.querySelector('button.action-close'). |
| 2418 | 2601 | addEventListener('click', ()=>Chat.setCurrentView(Chat.e.viewMessages), false); |
| 2419 | 2602 | let previewPending = false; |
| @@ -2652,15 +2835,16 @@ | ||
| 2652 | 2835 | } |
| 2653 | 2836 | return e; |
| 2654 | 2837 | }; |
| 2655 | 2838 | Chat.clearSearch(true); |
| 2656 | 2839 | /** |
| 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. | |
| 2659 | 2842 | */ |
| 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); | |
| 2662 | 2846 | const eMsgTgt = this.clearSearch(true); |
| 2663 | 2847 | if( !term ) return; |
| 2664 | 2848 | D.append( eMsgTgt, "Searching for ",term," ..."); |
| 2665 | 2849 | const fd = new FormData(); |
| 2666 | 2850 | fd.set('q', term); |
| 2667 | 2851 |
| --- 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 |
+236
-52
| --- src/fossil.page.chat.js | ||
| +++ src/fossil.page.chat.js | ||
| @@ -90,15 +90,14 @@ | ||
| 90 | 90 | While we're here, we also use this to cap the max-height |
| 91 | 91 | of the input field so that pasting huge text does not scroll |
| 92 | 92 | the upper area of the input widget off-screen. */ |
| 93 | 93 | const elemsToCount = GetFramingElements(); |
| 94 | 94 | const contentArea = E1('div.content'); |
| 95 | - const bcl = document.body.classList; | |
| 96 | 95 | const resized = function f(){ |
| 97 | 96 | if(f.$disabled) return; |
| 98 | 97 | const wh = window.innerHeight, |
| 99 | - com = bcl.contains('chat-only-mode'); | |
| 98 | + com = document.body.classList.contains('chat-only-mode'); | |
| 100 | 99 | var ht; |
| 101 | 100 | var extra = 0; |
| 102 | 101 | if(com){ |
| 103 | 102 | ht = wh; |
| 104 | 103 | }else{ |
| @@ -161,10 +160,11 @@ | ||
| 161 | 160 | searchContent: E1('#chat-search-content'), |
| 162 | 161 | btnPreview: E1('#chat-button-preview'), |
| 163 | 162 | views: document.querySelectorAll('.chat-view'), |
| 164 | 163 | activeUserListWrapper: E1('#chat-user-list-wrapper'), |
| 165 | 164 | activeUserList: E1('#chat-user-list'), |
| 165 | + btnClearFilter: E1('#chat-clear-filter'), | |
| 166 | 166 | eMsgPollError: undefined /* current connection error MessageMidget */, |
| 167 | 167 | pollErrorMarker: document.body /* element to toggle 'connection-error' CSS class on */ |
| 168 | 168 | }, |
| 169 | 169 | me: F.user.name, |
| 170 | 170 | mxMsg: F.config.chat.initSize ? -F.config.chat.initSize : -50, |
| @@ -181,15 +181,31 @@ | ||
| 181 | 181 | (JS Date object). Only messages received by the chat client |
| 182 | 182 | are considered. */ |
| 183 | 183 | /* Reminder: to convert a Julian time J to JS: |
| 184 | 184 | new Date((J - 2440587.5) * 86400000) */ |
| 185 | 185 | }, |
| 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*/ | |
| 191 | 207 | }, |
| 192 | 208 | /** |
| 193 | 209 | The timer object is used to control connection throttling |
| 194 | 210 | when connection errors arrise. It starts off with a polling |
| 195 | 211 | delay of $initialDelay ms. If there's a connection error, |
| @@ -376,11 +392,12 @@ | ||
| 376 | 392 | the list. */ |
| 377 | 393 | injectMessageElem: function f(e, atEnd){ |
| 378 | 394 | const mip = atEnd ? this.e.loadOlderToolbar : this.e.messageInjectPoint, |
| 379 | 395 | holder = this.e.viewMessages, |
| 380 | 396 | prevMessage = this.e.newestMessage; |
| 381 | - if(!this.filterState.match(e.dataset.xfrom)){ | |
| 397 | + if(this.filter.current | |
| 398 | + && !this.filter.current.matchElem(e)){ | |
| 382 | 399 | e.classList.add('hidden'); |
| 383 | 400 | } |
| 384 | 401 | if(atEnd){ |
| 385 | 402 | const fe = mip.nextElementSibling; |
| 386 | 403 | if(fe) mip.parentNode.insertBefore(e, fe); |
| @@ -622,11 +639,10 @@ | ||
| 622 | 639 | } |
| 623 | 640 | this.e.views.forEach(function(E){ |
| 624 | 641 | if(e!==E) D.addClass(E,'hidden'); |
| 625 | 642 | }); |
| 626 | 643 | this.e.currentView = e; |
| 627 | - if(this.e.currentView.$beforeShow) this.e.currentView.$beforeShow(); | |
| 628 | 644 | D.removeClass(e,'hidden'); |
| 629 | 645 | this.animate(this.e.currentView, 'anim-fade-in-fast'); |
| 630 | 646 | return this.e.currentView; |
| 631 | 647 | }, |
| 632 | 648 | |
| @@ -674,11 +690,11 @@ | ||
| 674 | 690 | else return 0; |
| 675 | 691 | }; |
| 676 | 692 | callee.addUserElem = function(u){ |
| 677 | 693 | const uSpan = D.addClass(D.span(), 'chat-user'); |
| 678 | 694 | const uDate = self.usersLastSeen[u]; |
| 679 | - if(self.filterState.activeUser===u){ | |
| 695 | + if(self.filter.user.activeTag===u){ | |
| 680 | 696 | uSpan.classList.add('selected'); |
| 681 | 697 | } |
| 682 | 698 | uSpan.dataset.uname = u; |
| 683 | 699 | D.append(uSpan, u, "\n", |
| 684 | 700 | D.append( |
| @@ -696,11 +712,82 @@ | ||
| 696 | 712 | Object.keys(this.usersLastSeen).sort( |
| 697 | 713 | callee.sortUsersSeen |
| 698 | 714 | ).forEach(callee.addUserElem); |
| 699 | 715 | return this; |
| 700 | 716 | }, |
| 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 | + */ | |
| 702 | 789 | showActiveUserList: function(yes){ |
| 703 | 790 | if(0===arguments.length) yes = true; |
| 704 | 791 | this.e.activeUserListWrapper.classList[ |
| 705 | 792 | yes ? 'remove' : 'add' |
| 706 | 793 | ]('hidden'); |
| @@ -726,32 +813,38 @@ | ||
| 726 | 813 | /** |
| 727 | 814 | Applies user name filter to all current messages, or clears |
| 728 | 815 | the filter if uname is falsy. |
| 729 | 816 | */ |
| 730 | 817 | 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; | |
| 733 | 825 | 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; | |
| 753 | 846 | return this; |
| 754 | 847 | }, |
| 755 | 848 | |
| 756 | 849 | /** |
| 757 | 850 | If animations are enabled, passes its arguments |
| @@ -1073,22 +1166,106 @@ | ||
| 1073 | 1166 | cs.setCurrentView(cs.e.viewMessages); |
| 1074 | 1167 | if(eUser.classList.contains('selected')){ |
| 1075 | 1168 | /* If currently selected, toggle filter off */ |
| 1076 | 1169 | eUser.classList.remove('selected'); |
| 1077 | 1170 | cs.setUserFilter(false); |
| 1078 | - delete f.$eSelected; | |
| 1079 | 1171 | }else{ |
| 1080 | - if(f.$eSelected) f.$eSelected.classList.remove('selected'); | |
| 1081 | - f.$eSelected = eUser; | |
| 1082 | 1172 | eUser.classList.add('selected'); |
| 1083 | 1173 | cs.setUserFilter(uname); |
| 1084 | 1174 | } |
| 1085 | 1175 | return false; |
| 1086 | 1176 | }, false); |
| 1177 | + | |
| 1178 | + cs.e.btnClearFilter.addEventListener('click',function(){ | |
| 1179 | + D.addClass(this,'hidden'); | |
| 1180 | + cs.clearFilters(); | |
| 1181 | + }, false); | |
| 1087 | 1182 | return cs; |
| 1088 | 1183 | })()/*Chat initialization*/; |
| 1089 | 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()*/; | |
| 1090 | 1267 | |
| 1091 | 1268 | /** Returns the first .message-widget element in DOM element |
| 1092 | 1269 | e's lineage. */ |
| 1093 | 1270 | const findMessageWidgetParent = function(e){ |
| 1094 | 1271 | while( e && !e.classList.contains('message-widget')){ |
| @@ -1354,10 +1531,11 @@ | ||
| 1354 | 1531 | // Used by Chat.reportErrorAsMessage() |
| 1355 | 1532 | D.append(contentTarget, m.xmsg); |
| 1356 | 1533 | }else{ |
| 1357 | 1534 | contentTarget.innerHTML = m.xmsg; |
| 1358 | 1535 | contentTarget.querySelectorAll('a').forEach(addAnchorTargetBlank); |
| 1536 | + setupHashtags(contentTarget); | |
| 1359 | 1537 | if(F.pikchr){ |
| 1360 | 1538 | F.pikchr.addSrcView(contentTarget.querySelectorAll('svg.pikchr')); |
| 1361 | 1539 | } |
| 1362 | 1540 | } |
| 1363 | 1541 | } |
| @@ -1470,31 +1648,23 @@ | ||
| 1470 | 1648 | y: 'a' |
| 1471 | 1649 | }), "User's Timeline"), |
| 1472 | 1650 | 'target', '_blank' |
| 1473 | 1651 | ); |
| 1474 | 1652 | 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 | |
| 1478 | 1655 | this message in its original context. */ |
| 1479 | 1656 | D.append( |
| 1480 | 1657 | this.e, |
| 1481 | 1658 | D.append( |
| 1482 | 1659 | D.addClass(D.div(), 'toolbar'), |
| 1483 | 1660 | D.button( |
| 1484 | 1661 | "Message in context", |
| 1485 | 1662 | function(){ |
| 1486 | 1663 | 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); | |
| 1496 | 1666 | }) |
| 1497 | 1667 | ) |
| 1498 | 1668 | ); |
| 1499 | 1669 | }/*jump-to button*/ |
| 1500 | 1670 | } |
| @@ -1524,10 +1694,20 @@ | ||
| 1524 | 1694 | }/*f.popup*/; |
| 1525 | 1695 | }/*end static init*/ |
| 1526 | 1696 | const theMsg = findMessageWidgetParent(ev.target); |
| 1527 | 1697 | if(theMsg) f.popup.show(theMsg); |
| 1528 | 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 | + } | |
| 1529 | 1709 | }; |
| 1530 | 1710 | return ctor; |
| 1531 | 1711 | })()/*MessageWidget*/; |
| 1532 | 1712 | |
| 1533 | 1713 | /** |
| @@ -2067,11 +2247,12 @@ | ||
| 2067 | 2247 | }/*namedOptions.activeUsers additional setup*/ |
| 2068 | 2248 | /* Settings menu entries... they are presented in the order listed |
| 2069 | 2249 | here, so the most frequently-needed ones "should" (arguably) be |
| 2070 | 2250 | closer to the start of this list. */ |
| 2071 | 2251 | /** |
| 2072 | - Settings ops structure: | |
| 2252 | + Settings options structure: an array of Objects with the | |
| 2253 | + following properties: | |
| 2073 | 2254 | |
| 2074 | 2255 | label: string for the UI |
| 2075 | 2256 | |
| 2076 | 2257 | boolValue: string (name of Chat.settings setting) or a function |
| 2077 | 2258 | which returns true or false. If it is a string, it gets |
| @@ -2080,10 +2261,11 @@ | ||
| 2080 | 2261 | to the persistentSetting property of this object. |
| 2081 | 2262 | |
| 2082 | 2263 | select: SELECT element (instead of boolValue) |
| 2083 | 2264 | |
| 2084 | 2265 | callback: optional handler to call after setting is modified. |
| 2266 | + It gets passed the setting object: {key:string, value:something}. | |
| 2085 | 2267 | Its "this" is the options object. If this object has a |
| 2086 | 2268 | boolValue string or a persistentSetting property, the argument |
| 2087 | 2269 | passed to the callback is a settings object in the form {key:K, |
| 2088 | 2270 | value:V}. If this object does not have boolValue string or |
| 2089 | 2271 | persistentSetting then the callback is passed an event object |
| @@ -2410,10 +2592,11 @@ | ||
| 2410 | 2592 | const btnPreview = Chat.e.btnPreview; |
| 2411 | 2593 | Chat.setPreviewText = function(t){ |
| 2412 | 2594 | this.setCurrentView(this.e.viewPreview); |
| 2413 | 2595 | this.e.previewContent.innerHTML = t; |
| 2414 | 2596 | this.e.viewPreview.querySelectorAll('a').forEach(addAnchorTargetBlank); |
| 2597 | + setupHashtags(this.e.previewContent)/*arguable, for usability reasons*/; | |
| 2415 | 2598 | this.inputFocus(); |
| 2416 | 2599 | }; |
| 2417 | 2600 | Chat.e.viewPreview.querySelector('button.action-close'). |
| 2418 | 2601 | addEventListener('click', ()=>Chat.setCurrentView(Chat.e.viewMessages), false); |
| 2419 | 2602 | let previewPending = false; |
| @@ -2652,15 +2835,16 @@ | ||
| 2652 | 2835 | } |
| 2653 | 2836 | return e; |
| 2654 | 2837 | }; |
| 2655 | 2838 | Chat.clearSearch(true); |
| 2656 | 2839 | /** |
| 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. | |
| 2659 | 2842 | */ |
| 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); | |
| 2662 | 2846 | const eMsgTgt = this.clearSearch(true); |
| 2663 | 2847 | if( !term ) return; |
| 2664 | 2848 | D.append( eMsgTgt, "Searching for ",term," ..."); |
| 2665 | 2849 | const fd = new FormData(); |
| 2666 | 2850 | fd.set('q', term); |
| 2667 | 2851 |
| --- 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 |
+162
| --- src/markdown.c | ||
| +++ src/markdown.c | ||
| @@ -39,10 +39,16 @@ | ||
| 39 | 39 | MKDA_NOT_AUTOLINK, /* used internally when it is not an autolink*/ |
| 40 | 40 | MKDA_NORMAL, /* normal http/http/ftp link */ |
| 41 | 41 | MKDA_EXPLICIT_EMAIL, /* e-mail link with explicit mailto: */ |
| 42 | 42 | MKDA_IMPLICIT_EMAIL /* e-mail link without mailto: */ |
| 43 | 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 | +}; | |
| 44 | 50 | |
| 45 | 51 | /* mkd_renderer -- functions for rendering parsed data */ |
| 46 | 52 | struct mkd_renderer { |
| 47 | 53 | /* document level callbacks */ |
| 48 | 54 | void (*prolog)(struct Blob *ob, void *opaque); |
| @@ -80,10 +86,12 @@ | ||
| 80 | 86 | struct Blob *alt, void *opaque); |
| 81 | 87 | int (*linebreak)(struct Blob *ob, void *opaque); |
| 82 | 88 | int (*link)(struct Blob *ob, struct Blob *link, struct Blob *title, |
| 83 | 89 | struct Blob *content, void *opaque); |
| 84 | 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); | |
| 85 | 93 | int (*triple_emphasis)(struct Blob *ob, struct Blob *text, |
| 86 | 94 | char c, void *opaque); |
| 87 | 95 | int (*footnote_ref)(struct Blob *ob, const struct Blob *span, |
| 88 | 96 | const struct Blob *upc, int index, int locus, void *opaque); |
| 89 | 97 | |
| @@ -950,10 +958,163 @@ | ||
| 950 | 958 | }else{ |
| 951 | 959 | blob_append(ob, data, end); |
| 952 | 960 | } |
| 953 | 961 | return end; |
| 954 | 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 | +} | |
| 955 | 1116 | |
| 956 | 1117 | /* |
| 957 | 1118 | ** char_langle_tag -- '<' when tags or autolinks are allowed. |
| 958 | 1119 | */ |
| 959 | 1120 | static size_t char_langle_tag( |
| @@ -2686,10 +2847,11 @@ | ||
| 2686 | 2847 | } |
| 2687 | 2848 | } |
| 2688 | 2849 | if( rndr.make.codespan ) rndr.active_char['`'] = char_codespan; |
| 2689 | 2850 | if( rndr.make.linebreak ) rndr.active_char['\n'] = char_linebreak; |
| 2690 | 2851 | if( rndr.make.image || rndr.make.link ) rndr.active_char['['] = char_link; |
| 2852 | + rndr.active_char['#'] = char_hashref_tag; | |
| 2691 | 2853 | if( rndr.make.footnote_ref ) rndr.active_char['('] = char_footnote; |
| 2692 | 2854 | rndr.active_char['<'] = char_langle_tag; |
| 2693 | 2855 | rndr.active_char['\\'] = char_escape; |
| 2694 | 2856 | rndr.active_char['&'] = char_entity; |
| 2695 | 2857 | |
| 2696 | 2858 |
| --- 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 |
+33
| --- src/markdown_html.c | ||
| +++ src/markdown_html.c | ||
| @@ -868,10 +868,42 @@ | ||
| 868 | 868 | blob_appendb(ob, content); |
| 869 | 869 | } |
| 870 | 870 | blob_append(ob, zClose, -1); |
| 871 | 871 | return 1; |
| 872 | 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 | +} | |
| 873 | 905 | |
| 874 | 906 | static int html_triple_emphasis( |
| 875 | 907 | struct Blob *ob, |
| 876 | 908 | struct Blob *text, |
| 877 | 909 | char c, |
| @@ -926,10 +958,11 @@ | ||
| 926 | 958 | html_emphasis, |
| 927 | 959 | html_image, |
| 928 | 960 | html_linebreak, |
| 929 | 961 | html_link, |
| 930 | 962 | html_raw_html_tag, |
| 963 | + html_tagspan, | |
| 931 | 964 | html_triple_emphasis, |
| 932 | 965 | html_footnote_ref, |
| 933 | 966 | |
| 934 | 967 | /* low level elements */ |
| 935 | 968 | 0, /* entity */ |
| 936 | 969 |
| --- 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 |
+20
-2
| --- src/style.chat.css | ||
| +++ src/style.chat.css | ||
| @@ -655,10 +655,21 @@ | ||
| 655 | 655 | } |
| 656 | 656 | body.chat #chat-user-list .chat-user.selected { |
| 657 | 657 | font-weight: bold; |
| 658 | 658 | text-decoration: underline; |
| 659 | 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 | +} | |
| 660 | 671 | |
| 661 | 672 | body.chat .searchForm { |
| 662 | 673 | margin-top: 1em; |
| 663 | 674 | } |
| 664 | 675 | body.chat .spacer-widget button { |
| @@ -692,19 +703,26 @@ | ||
| 692 | 703 | } |
| 693 | 704 | body.chat .anim-fade-in { |
| 694 | 705 | animation: fade-in 750ms linear; |
| 695 | 706 | } |
| 696 | 707 | body.chat .anim-fade-in-fast { |
| 697 | - animation: fade-in 350ms linear; | |
| 708 | + animation: fade-in 300ms linear; | |
| 698 | 709 | } |
| 699 | 710 | @keyframes fade-in { |
| 700 | 711 | from { opacity: 0; } |
| 701 | 712 | to { opacity: 1; } |
| 702 | 713 | } |
| 703 | 714 | body.chat .anim-fade-out-fast { |
| 704 | - animation: fade-out 250ms linear; | |
| 715 | + animation: fade-out 300ms linear; | |
| 705 | 716 | } |
| 706 | 717 | @keyframes fade-out { |
| 707 | 718 | from { opacity: 1; } |
| 708 | 719 | to { opacity: 0; } |
| 709 | 720 | } |
| 710 | 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 | +} | |
| 711 | 729 |
| --- 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 @@ | ||
| 2148 | 2148 | } |
| 2149 | 2149 | |
| 2150 | 2150 | /* |
| 2151 | 2151 | ** COMMAND: test-markdown-render |
| 2152 | 2152 | ** |
| 2153 | -** Usage: %fossil test-markdown-render FILE ... | |
| 2153 | +** Usage: %fossil test-markdown-render TEXT ... | |
| 2154 | 2154 | ** |
| 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 | +** | |
| 2156 | 2158 | ** Options: |
| 2157 | 2159 | ** |
| 2158 | 2160 | ** --dark-pikchr Render pikchrs in dark mode |
| 2159 | 2161 | ** --lint-footnotes Print stats for footnotes-related issues |
| 2160 | 2162 | ** --safe Restrict the output to use only "safe" HTML |
| @@ -2172,13 +2174,20 @@ | ||
| 2172 | 2174 | } |
| 2173 | 2175 | bText = find_option("text",0,0)!=0; |
| 2174 | 2176 | verify_all_options(); |
| 2175 | 2177 | for(i=2; i<g.argc; i++){ |
| 2176 | 2178 | 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 | + } | |
| 2180 | 2189 | } |
| 2181 | 2190 | markdown_to_html(&in, 0, &out); |
| 2182 | 2191 | safe_html_context( bSafe ? DOCSRC_UNTRUSTED : DOCSRC_TRUSTED ); |
| 2183 | 2192 | safe_html(&out); |
| 2184 | 2193 | if( bText ){ |
| 2185 | 2194 |
| --- 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 |
+6
| --- www/changes.wiki | ||
| +++ www/changes.wiki | ||
| @@ -35,10 +35,16 @@ | ||
| 35 | 35 | <li> The "diff" command (in all its variations) when invoked with --tk |
| 36 | 36 | now accepts an extra argument of the form "HOST:DIRECTORY" or |
| 37 | 37 | "USER@HOST:DIRECTORY" to specify a directory on a remote host in which |
| 38 | 38 | the diff is to be taken. The diff is run using SSH, the results are |
| 39 | 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. | |
| 40 | 46 | </ol> |
| 41 | 47 | |
| 42 | 48 | <h2 id='v2_28'>Changes for version 2.28 (2026-03-11)</h2><ol> |
| 43 | 49 | <li> Improvements to [./antibot.wiki|anti-robot defenses]:<ol type="a"> |
| 44 | 50 | <li> The default configuration now allows robots to download any tarball |
| 45 | 51 |
| --- 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 |