| | @@ -146,10 +146,12 @@ |
| 146 | 146 | inputFile: E1('#chat-input-file'), |
| 147 | 147 | contentDiv: E1('div.content'), |
| 148 | 148 | viewConfig: E1('#chat-config'), |
| 149 | 149 | viewPreview: E1('#chat-preview'), |
| 150 | 150 | previewContent: E1('#chat-preview-content'), |
| 151 | + viewSearch: E1('#chat-search'), |
| 152 | + searchContent: E1('#chat-search-content'), |
| 151 | 153 | btnPreview: E1('#chat-button-preview'), |
| 152 | 154 | views: document.querySelectorAll('.chat-view'), |
| 153 | 155 | activeUserListWrapper: E1('#chat-user-list-wrapper'), |
| 154 | 156 | activeUserList: E1('#chat-user-list') |
| 155 | 157 | }, |
| | @@ -174,21 +176,31 @@ |
| 174 | 176 | activeUser: undefined, |
| 175 | 177 | match: function(uname){ |
| 176 | 178 | return this.activeUser===uname || !this.activeUser; |
| 177 | 179 | } |
| 178 | 180 | }, |
| 179 | | - /** Gets (no args) or sets (1 arg) the current input text field value, |
| 180 | | - taking into account single- vs multi-line input. The getter returns |
| 181 | | - a string and the setter returns this object. */ |
| 182 | | - inputValue: function(){ |
| 181 | + /** |
| 182 | + Gets (no args) or sets (1 arg) the current input text field |
| 183 | + value, taking into account single- vs multi-line input. The |
| 184 | + getter returns a trim()'d string and the setter returns this |
| 185 | + object. As a special case, if arguments[0] is a boolean |
| 186 | + value, it behaves like a getter and, if arguments[0]===true |
| 187 | + it clears the input field before returning. |
| 188 | + */ |
| 189 | + inputValue: function(/*string newValue | bool clearInputField*/){ |
| 183 | 190 | const e = this.inputElement(); |
| 184 | | - if(arguments.length){ |
| 191 | + if(arguments.length && 'boolean'!==typeof arguments[0]){ |
| 185 | 192 | if(e.isContentEditable) e.innerText = arguments[0]; |
| 186 | 193 | else e.value = arguments[0]; |
| 187 | 194 | return this; |
| 188 | 195 | } |
| 189 | | - return e.isContentEditable ? e.innerText : e.value; |
| 196 | + const rc = e.isContentEditable ? e.innerText : e.value; |
| 197 | + if( true===arguments[0] ){ |
| 198 | + if(e.isContentEditable) e.innerText = ''; |
| 199 | + else e.value = ''; |
| 200 | + } |
| 201 | + return rc && rc.trim(); |
| 190 | 202 | }, |
| 191 | 203 | /** Asks the current user input field to take focus. Returns this. */ |
| 192 | 204 | inputFocus: function(){ |
| 193 | 205 | this.inputElement().focus(); |
| 194 | 206 | return this; |
| | @@ -513,11 +525,11 @@ |
| 513 | 525 | const uDate = self.usersLastSeen[u]; |
| 514 | 526 | if(self.filterState.activeUser===u){ |
| 515 | 527 | uSpan.classList.add('selected'); |
| 516 | 528 | } |
| 517 | 529 | uSpan.dataset.uname = u; |
| 518 | | - D.append(uSpan, u, "\n", |
| 530 | + D.append(uSpan, u, "\n", |
| 519 | 531 | D.append( |
| 520 | 532 | D.addClass(D.span(),'timestamp'), |
| 521 | 533 | localTimeString(uDate)//.substr(5/*chop off year*/) |
| 522 | 534 | )); |
| 523 | 535 | if(uDate.$uColor){ |
| | @@ -898,11 +910,11 @@ |
| 898 | 910 | Chat.MessageWidget = (function(){ |
| 899 | 911 | /** |
| 900 | 912 | Constructor. If passed an argument, it is passed to |
| 901 | 913 | this.setMessage() after initialization. |
| 902 | 914 | */ |
| 903 | | - const cf = function(){ |
| 915 | + const ctor = function(){ |
| 904 | 916 | this.e = { |
| 905 | 917 | body: D.addClass(D.div(), 'message-widget'), |
| 906 | 918 | tab: D.addClass(D.div(), 'message-widget-tab'), |
| 907 | 919 | content: D.addClass(D.div(), 'message-widget-content') |
| 908 | 920 | }; |
| | @@ -917,20 +929,33 @@ |
| 917 | 929 | /* Map of Date.getDay() values to weekday names. */ |
| 918 | 930 | 0: "Sunday", 1: "Monday", 2: "Tuesday", |
| 919 | 931 | 3: "Wednesday", 4: "Thursday", 5: "Friday", |
| 920 | 932 | 6: "Saturday" |
| 921 | 933 | }; |
| 922 | | - /* Given a Date, returns the timestamp string used in the |
| 923 | | - "tab" part of message widgets. */ |
| 924 | | - const theTime = function(d){ |
| 925 | | - return [ |
| 926 | | - //d.getFullYear(),'-',pad2(d.getMonth()+1/*sigh*/), |
| 927 | | - //'-',pad2(d.getDate()), ' ', |
| 928 | | - d.getHours(),":", |
| 929 | | - (d.getMinutes()+100).toString().slice(1,3), |
| 930 | | - ' ', dowMap[d.getDay()] |
| 931 | | - ].join(''); |
| 934 | + /* Given a Date, returns the timestamp string used in the "tab" |
| 935 | + part of message widgets. If longFmt is true then a verbose |
| 936 | + format is used, else a brief format is used. The returned string |
| 937 | + is in client-local time. */ |
| 938 | + const theTime = function(d, longFmt=false){ |
| 939 | + const li = []; |
| 940 | + if( longFmt ){ |
| 941 | + li.push( |
| 942 | + d.getFullYear(), |
| 943 | + '-', pad2(d.getMonth()+1), |
| 944 | + '-', pad2(d.getDate()), |
| 945 | + ' ', |
| 946 | + d.getHours(), ":", |
| 947 | + (d.getMinutes()+100).toString().slice(1,3) |
| 948 | + ); |
| 949 | + }else{ |
| 950 | + li.push( |
| 951 | + d.getHours(),":", |
| 952 | + (d.getMinutes()+100).toString().slice(1,3), |
| 953 | + ' ', dowMap[d.getDay()] |
| 954 | + ); |
| 955 | + } |
| 956 | + return li.join(''); |
| 932 | 957 | }; |
| 933 | 958 | |
| 934 | 959 | /** |
| 935 | 960 | Returns true if this page believes it can embed a view of the |
| 936 | 961 | file wrapped by the given message object, else returns false. |
| | @@ -937,19 +962,20 @@ |
| 937 | 962 | */ |
| 938 | 963 | const canEmbedFile = function f(msg){ |
| 939 | 964 | if(!f.$rx){ |
| 940 | 965 | f.$rx = /\.((html?)|(txt)|(md)|(wiki)|(pikchr))$/i; |
| 941 | 966 | f.$specificTypes = [ |
| 967 | + /* Mime types we know we can embed, sans image/... */ |
| 942 | 968 | 'text/plain', |
| 943 | 969 | 'text/html', |
| 944 | 970 | 'text/x-markdown', |
| 945 | 971 | /* Firefox sends text/markdown when uploading .md files */ |
| 946 | 972 | 'text/markdown', |
| 947 | 973 | 'text/x-pikchr', |
| 948 | 974 | 'text/x-fossil-wiki' |
| 949 | | - // add more as we discover which ones Firefox won't |
| 950 | | - // force the user to try to download. |
| 975 | + /* Add more as we discover which ones Firefox won't |
| 976 | + force the user to try to download. */ |
| 951 | 977 | ]; |
| 952 | 978 | } |
| 953 | 979 | if(msg.fmime){ |
| 954 | 980 | if(msg.fmime.startsWith("image/") |
| 955 | 981 | || f.$specificTypes.indexOf(msg.fmime)>=0){ |
| | @@ -963,20 +989,18 @@ |
| 963 | 989 | Returns true if the given message object "should" |
| 964 | 990 | be embedded in fossil-rendered form instead of |
| 965 | 991 | raw content form. This is only intended to be passed |
| 966 | 992 | message objects for which canEmbedFile() returns true. |
| 967 | 993 | */ |
| 968 | | - const shouldWikiRenderEmbed = function f(msg){ |
| 994 | + const shouldFossilRenderEmbed = function f(msg){ |
| 969 | 995 | if(!f.$rx){ |
| 970 | 996 | f.$rx = /\.((md)|(wiki)|(pikchr))$/i; |
| 971 | 997 | f.$specificTypes = [ |
| 972 | 998 | 'text/x-markdown', |
| 973 | 999 | 'text/markdown' /* Firefox-uploaded md files */, |
| 974 | 1000 | 'text/x-pikchr', |
| 975 | 1001 | 'text/x-fossil-wiki' |
| 976 | | - // add more as we discover which ones Firefox won't |
| 977 | | - // force the user to try to download. |
| 978 | 1002 | ]; |
| 979 | 1003 | } |
| 980 | 1004 | if(msg.fmime){ |
| 981 | 1005 | if(f.$specificTypes.indexOf(msg.fmime)>=0) return true; |
| 982 | 1006 | } |
| | @@ -1002,12 +1026,12 @@ |
| 1002 | 1026 | iframe.style.maxHeight = iframe.style.height |
| 1003 | 1027 | = iframe.contentWindow.document.documentElement.scrollHeight + 'px'; |
| 1004 | 1028 | if(isHidden) D.addClass(iframe, 'hidden'); |
| 1005 | 1029 | } |
| 1006 | 1030 | }; |
| 1007 | | - |
| 1008 | | - cf.prototype = { |
| 1031 | + |
| 1032 | + ctor.prototype = { |
| 1009 | 1033 | scrollIntoView: function(){ |
| 1010 | 1034 | this.e.content.scrollIntoView(); |
| 1011 | 1035 | }, |
| 1012 | 1036 | setMessage: function(m){ |
| 1013 | 1037 | const ds = this.e.body.dataset; |
| | @@ -1028,20 +1052,26 @@ |
| 1028 | 1052 | var eXFrom /* element holding xfrom name */; |
| 1029 | 1053 | if(m.xfrom){ |
| 1030 | 1054 | eXFrom = D.append(D.addClass(D.span(), 'xfrom'), m.xfrom); |
| 1031 | 1055 | const wrapper = D.append( |
| 1032 | 1056 | D.span(), eXFrom, |
| 1033 | | - D.text(" #",(m.msgid||'???'),' @ ',theTime(d))) |
| 1057 | + ' ', |
| 1058 | + D.append(D.addClass(D.span(), 'msgid'), |
| 1059 | + '#' + (m.msgid||'???')), |
| 1060 | + (m.isSearchResult ? ' ' : ' @ '), |
| 1061 | + D.append(D.addClass(D.span(), 'timestamp'), |
| 1062 | + theTime(d,!!m.isSearchResult)) |
| 1063 | + ); |
| 1034 | 1064 | D.append(this.e.tab, wrapper); |
| 1035 | 1065 | }else{/*notification*/ |
| 1036 | 1066 | D.addClass(this.e.body, 'notification'); |
| 1037 | 1067 | if(m.isError){ |
| 1038 | 1068 | D.addClass([contentTarget, this.e.tab], 'error'); |
| 1039 | 1069 | } |
| 1040 | 1070 | D.append( |
| 1041 | 1071 | this.e.tab, |
| 1042 | | - D.append(D.code(), 'notification @ ',theTime(d)) |
| 1072 | + D.append(D.code(), 'notification @ ',theTime(d,false)) |
| 1043 | 1073 | ); |
| 1044 | 1074 | } |
| 1045 | 1075 | if( m.xfrom && m.fsize>0 ){ |
| 1046 | 1076 | if( m.fmime |
| 1047 | 1077 | && m.fmime.startsWith("image/") |
| | @@ -1064,18 +1094,18 @@ |
| 1064 | 1094 | D.attr(a,'target','_blank'); |
| 1065 | 1095 | D.append(w, a); |
| 1066 | 1096 | if(canEmbedFile(m)){ |
| 1067 | 1097 | /* Add an option to embed HTML attachments in an iframe. The primary |
| 1068 | 1098 | use case is attached diffs. */ |
| 1069 | | - const shouldWikiRender = shouldWikiRenderEmbed(m); |
| 1070 | | - const downloadArgs = shouldWikiRender ? '?render' : ''; |
| 1099 | + const shouldFossilRender = shouldFossilRenderEmbed(m); |
| 1100 | + const downloadArgs = shouldFossilRender ? '?render' : ''; |
| 1071 | 1101 | D.addClass(contentTarget, 'wide'); |
| 1072 | 1102 | const embedTarget = this.e.content; |
| 1073 | 1103 | const self = this; |
| 1074 | 1104 | const btnEmbed = D.attr(D.checkbox("1", false), 'id', |
| 1075 | 1105 | 'embed-'+ds.msgid); |
| 1076 | | - const btnLabel = D.label(btnEmbed, shouldWikiRender |
| 1106 | + const btnLabel = D.label(btnEmbed, shouldFossilRender |
| 1077 | 1107 | ? "Embed (fossil-rendered)" : "Embed"); |
| 1078 | 1108 | /* Maintenance reminder: do not disable the toggle |
| 1079 | 1109 | button while the content is loading because that will |
| 1080 | 1110 | cause it to get stuck in disabled mode if the browser |
| 1081 | 1111 | decides that loading the content should prompt the |
| | @@ -1280,13 +1310,182 @@ |
| 1280 | 1310 | }/*end static init*/ |
| 1281 | 1311 | const theMsg = findMessageWidgetParent(ev.target); |
| 1282 | 1312 | if(theMsg) f.popup.show(theMsg); |
| 1283 | 1313 | }/*_handleLegendClicked()*/ |
| 1284 | 1314 | }; |
| 1285 | | - return cf; |
| 1315 | + return ctor; |
| 1286 | 1316 | })()/*MessageWidget*/; |
| 1287 | 1317 | |
| 1318 | + /** |
| 1319 | + A widget for loading more messages (context) around a /chat-query |
| 1320 | + result message. |
| 1321 | + */ |
| 1322 | + Chat.SearchCtxLoader = (function(){ |
| 1323 | + const nMsgContext = 5; |
| 1324 | + const zUpArrow = '\u25B2'; |
| 1325 | + const zDownArrow = '\u25BC'; |
| 1326 | + const ctor = function(o){ |
| 1327 | + |
| 1328 | + /* iFirstInTable: |
| 1329 | + ** msgid of first row in chatfts table. |
| 1330 | + ** |
| 1331 | + ** iLastInTable: |
| 1332 | + ** msgid of last row in chatfts table. |
| 1333 | + ** |
| 1334 | + ** iPrevId: |
| 1335 | + ** msgid of message immediately above this spacer. Or 0 if this |
| 1336 | + ** spacer is above all results. |
| 1337 | + ** |
| 1338 | + ** iNextId: |
| 1339 | + ** msgid of message immediately below this spacer. Or 0 if this |
| 1340 | + ** spacer is below all results. |
| 1341 | + ** |
| 1342 | + ** bIgnoreClick: |
| 1343 | + ** ignore any clicks if this is true. This is used to ensure there |
| 1344 | + ** is only ever one request belonging to this widget outstanding |
| 1345 | + ** at any time. |
| 1346 | + */ |
| 1347 | + this.o = { |
| 1348 | + iFirstInTable: o.first, |
| 1349 | + iLastInTable: o.last, |
| 1350 | + iPrevId: o.previd, |
| 1351 | + iNextId: o.nextid, |
| 1352 | + bIgnoreClick: false |
| 1353 | + }; |
| 1354 | + |
| 1355 | + this.e = { |
| 1356 | + body: D.addClass(D.div(), 'spacer-widget'), |
| 1357 | + up: D.addClass( |
| 1358 | + D.button(zDownArrow+' Load '+nMsgContext+' more '+zDownArrow), |
| 1359 | + 'up' |
| 1360 | + ), |
| 1361 | + down: D.addClass( |
| 1362 | + D.button(zUpArrow+' Load '+nMsgContext+' more '+zUpArrow), |
| 1363 | + 'down' |
| 1364 | + ), |
| 1365 | + all: D.addClass(D.button('Load More'), 'all') |
| 1366 | + }; |
| 1367 | + D.append( this.e.body, this.e.up, this.e.down, this.e.all ); |
| 1368 | + const ms = this; |
| 1369 | + this.e.up.addEventListener('click', ()=>ms.load_messages(false)); |
| 1370 | + this.e.down.addEventListener('click', ()=>ms.load_messages(true)); |
| 1371 | + this.e.all.addEventListener('click', ()=>ms.load_messages( (ms.o.iPrevId==0) )); |
| 1372 | + this.set_button_visibility(); |
| 1373 | + }; |
| 1374 | + |
| 1375 | + ctor.prototype = { |
| 1376 | + set_button_visibility: function() { |
| 1377 | + if( !this.e ) return; |
| 1378 | + const o = this.o; |
| 1379 | + |
| 1380 | + const iPrevId = (o.iPrevId!=0) ? o.iPrevId : o.iFirstInTable-1; |
| 1381 | + const iNextId = (o.iNextId!=0) ? o.iNextId : o.iLastInTable+1; |
| 1382 | + let nDiff = (iNextId - iPrevId) - 1; |
| 1383 | + |
| 1384 | + for( const x of [this.e.up, this.e.down, this.e.all] ){ |
| 1385 | + if( x ) D.addClass(x, 'hidden'); |
| 1386 | + } |
| 1387 | + let nVisible = 0; |
| 1388 | + if( nDiff>0 ){ |
| 1389 | + if( nDiff>nMsgContext && (o.iPrevId==0 || o.iNextId==0) ){ |
| 1390 | + nDiff = nMsgContext; |
| 1391 | + } |
| 1392 | + |
| 1393 | + if( nDiff<=nMsgContext && o.iPrevId!=0 && o.iNextId!=0 ){ |
| 1394 | + D.removeClass(this.e.all, 'hidden'); |
| 1395 | + ++nVisible; |
| 1396 | + this.e.all.innerText = ( |
| 1397 | + zUpArrow + " Load " + nDiff + " more " + zDownArrow |
| 1398 | + ); |
| 1399 | + }else{ |
| 1400 | + if( o.iPrevId!=0 ){ |
| 1401 | + ++nVisible; |
| 1402 | + D.removeClass(this.e.up, 'hidden'); |
| 1403 | + }else if( this.e.up ){ |
| 1404 | + if( this.e.up.parentNode ) D.remove(this.e.up); |
| 1405 | + delete this.e.up; |
| 1406 | + } |
| 1407 | + if( o.iNextId!=0 ){ |
| 1408 | + ++nVisible; |
| 1409 | + D.removeClass(this.e.down, 'hidden'); |
| 1410 | + }else if( this.e.down ){ |
| 1411 | + if( this.e.down.parentNode ) D.remove( this.e.down ); |
| 1412 | + delete this.e.down; |
| 1413 | + } |
| 1414 | + } |
| 1415 | + } |
| 1416 | + if( !nVisible ){ |
| 1417 | + /* The DOM elements can now be disposed of. */ |
| 1418 | + for( const x of [this.e.up, this.e.down, this.e.all, this.e.body] ){ |
| 1419 | + if( x?.parentNode ) D.remove(x); |
| 1420 | + } |
| 1421 | + delete this.e; |
| 1422 | + } |
| 1423 | + }, |
| 1424 | + |
| 1425 | + load_messages: function(bDown) { |
| 1426 | + if( this.bIgnoreClick ) return; |
| 1427 | + |
| 1428 | + var iFirst = 0; /* msgid of first message to fetch */ |
| 1429 | + var nFetch = 0; /* Number of messages to fetch */ |
| 1430 | + var iEof = 0; /* last msgid in spacers range, plus 1 */ |
| 1431 | + |
| 1432 | + const e = this.e, o = this.o; |
| 1433 | + this.bIgnoreClick = true; |
| 1434 | + |
| 1435 | + /* Figure out the required range of messages. */ |
| 1436 | + if( bDown ){ |
| 1437 | + iFirst = this.o.iNextId - nMsgContext; |
| 1438 | + if( iFirst<this.o.iFirstInTable ){ |
| 1439 | + iFirst = this.o.iFirstInTable; |
| 1440 | + } |
| 1441 | + }else{ |
| 1442 | + iFirst = this.o.iPrevId+1; |
| 1443 | + } |
| 1444 | + nFetch = nMsgContext; |
| 1445 | + iEof = (this.o.iNextId > 0) ? this.o.iNextId : this.o.iLastInTable+1; |
| 1446 | + if( iFirst+nFetch>iEof ){ |
| 1447 | + nFetch = iEof - iFirst; |
| 1448 | + } |
| 1449 | + const ms = this; |
| 1450 | + F.fetch("chat-query",{ |
| 1451 | + urlParams:{ |
| 1452 | + q: '', |
| 1453 | + n: nFetch, |
| 1454 | + i: iFirst |
| 1455 | + }, |
| 1456 | + responseType: "json", |
| 1457 | + onload:function(jx){ |
| 1458 | + if( bDown ) jx.msgs.reverse(); |
| 1459 | + jx.msgs.forEach((m) => { |
| 1460 | + var mw = new Chat.MessageWidget(m); |
| 1461 | + if( bDown ){ |
| 1462 | + /* Inject the message below this object's body, or |
| 1463 | + append it to Chat.e.searchContent if this element |
| 1464 | + is the final one in its parent (Chat.e.searchContent). */ |
| 1465 | + const eAnchor = e.body.nextElementSibling; |
| 1466 | + if( eAnchor ) Chat.e.searchContent.insertBefore(mw.e.body, eAnchor); |
| 1467 | + else D.append(Chat.e.searchContent, mw.e.body); |
| 1468 | + }else{ |
| 1469 | + Chat.e.searchContent.insertBefore(mw.e.body, e.body); |
| 1470 | + } |
| 1471 | + }); |
| 1472 | + if( bDown ){ |
| 1473 | + o.iNextId -= jx.msgs.length; |
| 1474 | + }else{ |
| 1475 | + o.iPrevId += jx.msgs.length; |
| 1476 | + } |
| 1477 | + ms.set_button_visibility(); |
| 1478 | + ms.bIgnoreClick = false; |
| 1479 | + } |
| 1480 | + }); |
| 1481 | + } |
| 1482 | + }; |
| 1483 | + |
| 1484 | + return ctor; |
| 1485 | + })() /*SearchCtxLoader*/; |
| 1486 | + |
| 1288 | 1487 | const BlobXferState = (function(){ |
| 1289 | 1488 | /* State for paste and drag/drop */ |
| 1290 | 1489 | const bxs = { |
| 1291 | 1490 | dropDetails: document.querySelector('#chat-drop-details'), |
| 1292 | 1491 | blob: undefined, |
| | @@ -1425,16 +1624,26 @@ |
| 1425 | 1624 | |
| 1426 | 1625 | /** |
| 1427 | 1626 | Submits the contents of the message input field (if not empty) |
| 1428 | 1627 | and/or the file attachment field to the server. If both are |
| 1429 | 1628 | empty, this is a no-op. |
| 1629 | + |
| 1630 | + If the current view is the history search, this instead sends the |
| 1631 | + input text to that widget. |
| 1430 | 1632 | */ |
| 1431 | 1633 | Chat.submitMessage = function f(){ |
| 1432 | 1634 | if(!f.spaces){ |
| 1433 | 1635 | f.spaces = /\s+$/; |
| 1434 | 1636 | f.markdownContinuation = /\\\s+$/; |
| 1435 | 1637 | f.spaces2 = /\s{3,}$/; |
| 1638 | + } |
| 1639 | + switch( this.e.currentView ){ |
| 1640 | + case this.e.viewSearch: this.submitSearch(); |
| 1641 | + return; |
| 1642 | + case this.e.viewPreview: this.e.btnPreview.click(); |
| 1643 | + return; |
| 1644 | + default: break; |
| 1436 | 1645 | } |
| 1437 | 1646 | this.setCurrentView(this.e.viewMessages); |
| 1438 | 1647 | const fd = new FormData(); |
| 1439 | 1648 | const fallback = {msg: this.inputValue()}; |
| 1440 | 1649 | var msg = fallback.msg; |
| | @@ -1507,14 +1716,16 @@ |
| 1507 | 1716 | //console.debug("Enter key event:", ctrlMode, ev.ctrlKey, ev.shiftKey, ev); |
| 1508 | 1717 | if(ev.shiftKey){ |
| 1509 | 1718 | const compactMode = Chat.settings.getBool('edit-compact-mode', false); |
| 1510 | 1719 | ev.preventDefault(); |
| 1511 | 1720 | ev.stopPropagation(); |
| 1512 | | - /* Shift-enter will run preview mode UNLESS preview mode is |
| 1513 | | - active AND the input field is empty, in which case it will |
| 1721 | + /* Shift-enter will run preview mode UNLESS the input field is empty |
| 1722 | + AND (preview or search mode) is active, in which cases it will |
| 1514 | 1723 | switch back to message view. */ |
| 1515 | | - if(Chat.e.currentView===Chat.e.viewPreview && !text){ |
| 1724 | + if(!text && |
| 1725 | + (Chat.e.currentView===Chat.e.viewPreview |
| 1726 | + | Chat.e.currentView===Chat.e.viewSearch)){ |
| 1516 | 1727 | Chat.setCurrentView(Chat.e.viewMessages); |
| 1517 | 1728 | }else if(!text){ |
| 1518 | 1729 | f.$toggleCompact(compactMode); |
| 1519 | 1730 | }else if(Chat.settings.getBool('edit-shift-enter-preview', true)){ |
| 1520 | 1731 | Chat.e.btnPreview.click(); |
| | @@ -1572,19 +1783,19 @@ |
| 1572 | 1783 | tall vs wide. Can be toggled via settings. */ |
| 1573 | 1784 | document.body.classList.add('my-messages-right'); |
| 1574 | 1785 | } |
| 1575 | 1786 | const settingsButton = document.querySelector('#chat-button-settings'); |
| 1576 | 1787 | const optionsMenu = E1('#chat-config-options'); |
| 1577 | | - const cbToggle = function(ev){ |
| 1788 | + const eToggleView = function(ev){ |
| 1578 | 1789 | ev.preventDefault(); |
| 1579 | 1790 | ev.stopPropagation(); |
| 1580 | 1791 | Chat.setCurrentView(Chat.e.currentView===Chat.e.viewConfig |
| 1581 | 1792 | ? Chat.e.viewMessages : Chat.e.viewConfig); |
| 1582 | 1793 | return false; |
| 1583 | 1794 | }; |
| 1584 | | - D.attr(settingsButton, 'role', 'button').addEventListener('click', cbToggle, false); |
| 1585 | | - Chat.e.viewConfig.querySelector('button').addEventListener('click', cbToggle, false); |
| 1795 | + D.attr(settingsButton, 'role', 'button').addEventListener('click', eToggleView, false); |
| 1796 | + Chat.e.viewConfig.querySelector('button.action-close').addEventListener('click', eToggleView, false); |
| 1586 | 1797 | |
| 1587 | 1798 | /** Internal acrobatics to allow certain settings toggles to access |
| 1588 | 1799 | related toggles. */ |
| 1589 | 1800 | const namedOptions = { |
| 1590 | 1801 | activeUsers:{ |
| | @@ -1670,12 +1881,13 @@ |
| 1670 | 1881 | boolValue: 'edit-ctrl-send' |
| 1671 | 1882 | },{ |
| 1672 | 1883 | label: "Compact mode", |
| 1673 | 1884 | hint: [ |
| 1674 | 1885 | "Toggle between a space-saving or more spacious writing area. ", |
| 1675 | | - "When the input field has focus, is empty, and preview mode ", |
| 1676 | | - "is NOT active then Shift-Enter toggles this setting."].join(''), |
| 1886 | + "When the input field has focus and is empty ", |
| 1887 | + "then Shift-Enter may (depending on the current view) toggle this setting." |
| 1888 | + ].join(''), |
| 1677 | 1889 | boolValue: 'edit-compact-mode' |
| 1678 | 1890 | },{ |
| 1679 | 1891 | label: "Use 'contenteditable' editing mode", |
| 1680 | 1892 | boolValue: 'edit-widget-x', |
| 1681 | 1893 | hint: [ |
| | @@ -1840,11 +2052,11 @@ |
| 1840 | 2052 | op.persistentSetting, |
| 1841 | 2053 | function(setting){ |
| 1842 | 2054 | if(op.checkbox) op.checkbox.checked = !!setting.value; |
| 1843 | 2055 | else if(op.select) op.select.value = setting.value; |
| 1844 | 2056 | if(op.callback) op.callback(setting); |
| 1845 | | - } |
| 2057 | + } |
| 1846 | 2058 | ); |
| 1847 | 2059 | if(op.checkbox){ |
| 1848 | 2060 | op.checkbox.addEventListener( |
| 1849 | 2061 | 'change', function(){ |
| 1850 | 2062 | Chat.settings.set(op.persistentSetting, op.checkbox.checked) |
| | @@ -1916,11 +2128,11 @@ |
| 1916 | 2128 | s.value ? 'add' : 'remove' |
| 1917 | 2129 | ]('compact'); |
| 1918 | 2130 | Chat.e.inputFields[Chat.e.inputFields.$currentIndex].focus(); |
| 1919 | 2131 | }); |
| 1920 | 2132 | Chat.settings.addListener('edit-ctrl-send',function(s){ |
| 1921 | | - const label = (s.value ? "Ctrl-" : "")+"Enter submits messages."; |
| 2133 | + const label = (s.value ? "Ctrl-" : "")+"Enter submits message"; |
| 1922 | 2134 | Chat.e.inputFields.forEach((e)=>{ |
| 1923 | 2135 | const v = e.dataset.placeholder0 + " " +label; |
| 1924 | 2136 | if(e.isContentEditable) e.dataset.placeholder = v; |
| 1925 | 2137 | else D.attr(e,'placeholder',v); |
| 1926 | 2138 | }); |
| | @@ -1947,11 +2159,11 @@ |
| 1947 | 2159 | this.setCurrentView(this.e.viewPreview); |
| 1948 | 2160 | this.e.previewContent.innerHTML = t; |
| 1949 | 2161 | this.e.viewPreview.querySelectorAll('a').forEach(addAnchorTargetBlank); |
| 1950 | 2162 | this.inputFocus(); |
| 1951 | 2163 | }; |
| 1952 | | - Chat.e.viewPreview.querySelector('#chat-preview-close'). |
| 2164 | + Chat.e.viewPreview.querySelector('button.action-close'). |
| 1953 | 2165 | addEventListener('click', ()=>Chat.setCurrentView(Chat.e.viewMessages), false); |
| 1954 | 2166 | let previewPending = false; |
| 1955 | 2167 | const elemsToEnable = [btnPreview, Chat.e.btnSubmit, Chat.e.inputFields]; |
| 1956 | 2168 | const submit = function(ev){ |
| 1957 | 2169 | ev.preventDefault(); |
| | @@ -1994,10 +2206,40 @@ |
| 1994 | 2206 | }); |
| 1995 | 2207 | return false; |
| 1996 | 2208 | }; |
| 1997 | 2209 | btnPreview.addEventListener('click', submit, false); |
| 1998 | 2210 | })()/*message preview setup*/; |
| 2211 | + |
| 2212 | + (function(){/*Set up #chat-search and related bits */ |
| 2213 | + const btn = document.querySelector('#chat-button-search'); |
| 2214 | + D.attr(btn, 'role', 'button').addEventListener('click', function(ev){ |
| 2215 | + ev.preventDefault(); |
| 2216 | + ev.stopPropagation(); |
| 2217 | + const msg = Chat.inputValue(); |
| 2218 | + if( Chat.e.currentView===Chat.e.viewSearch ){ |
| 2219 | + if( msg ) Chat.submitSearch(); |
| 2220 | + else Chat.setCurrentView(Chat.e.viewMessages); |
| 2221 | + }else{ |
| 2222 | + Chat.setCurrentView(Chat.e.viewSearch); |
| 2223 | + if( msg ) Chat.submitSearch(); |
| 2224 | + } |
| 2225 | + return false; |
| 2226 | + }, false); |
| 2227 | + Chat.e.viewSearch.querySelector('button.action-clear').addEventListener('click', function(ev){ |
| 2228 | + ev.preventDefault(); |
| 2229 | + ev.stopPropagation(); |
| 2230 | + Chat.clearSearch(true); |
| 2231 | + Chat.setCurrentView(Chat.e.viewMessages); |
| 2232 | + return false; |
| 2233 | + }, false); |
| 2234 | + Chat.e.viewSearch.querySelector('button.action-close').addEventListener('click', function(ev){ |
| 2235 | + ev.preventDefault(); |
| 2236 | + ev.stopPropagation(); |
| 2237 | + Chat.setCurrentView(Chat.e.viewMessages); |
| 2238 | + return false; |
| 2239 | + }, false); |
| 2240 | + })()/*search view setup*/; |
| 1999 | 2241 | |
| 2000 | 2242 | /** Callback for poll() to inject new content into the page. jx == |
| 2001 | 2243 | the response from /chat-poll. If atEnd is true, the message is |
| 2002 | 2244 | appended to the end of the chat list (for loading older |
| 2003 | 2245 | messages), else the beginning (the default). */ |
| | @@ -2126,10 +2368,82 @@ |
| 2126 | 2368 | btn.addEventListener('click',()=>loadOldMessages(-1)); |
| 2127 | 2369 | D.append(Chat.e.viewMessages, toolbar); |
| 2128 | 2370 | toolbar.disabled = true /*will be enabled when msg load finishes */; |
| 2129 | 2371 | })()/*end history loading widget setup*/; |
| 2130 | 2372 | |
| 2373 | + /** |
| 2374 | + Clears the search result view. If addInstructions is true it adds |
| 2375 | + text to that view instructing the user to enter their query into |
| 2376 | + the message-entry widget (noting that that widget has text |
| 2377 | + implying that it's only for submitting a message, which isn't |
| 2378 | + exactly true when the search view is active). |
| 2379 | + |
| 2380 | + Returns the DOM element which wraps all of the chat search |
| 2381 | + result elements. |
| 2382 | + */ |
| 2383 | + Chat.clearSearch = function(addInstructions=false){ |
| 2384 | + const e = D.clearElement( this.e.searchContent ); |
| 2385 | + if(addInstructions){ |
| 2386 | + D.append(e, "Enter search terms in the message field. "+ |
| 2387 | + "Use #NNNNN to search for the message with ID NNNNN."); |
| 2388 | + } |
| 2389 | + return e; |
| 2390 | + }; |
| 2391 | + Chat.clearSearch(true); |
| 2392 | + /** |
| 2393 | + Submits a history search using the main input field's current |
| 2394 | + text. It is assumed that Chat.e.viewSearch===Chat.e.currentView. |
| 2395 | + */ |
| 2396 | + Chat.submitSearch = function(){ |
| 2397 | + const term = this.inputValue(true); |
| 2398 | + const eMsgTgt = this.clearSearch(true); |
| 2399 | + if( !term ) return; |
| 2400 | + D.append( eMsgTgt, "Searching for ",term," ..."); |
| 2401 | + const fd = new FormData(); |
| 2402 | + fd.set('q', term); |
| 2403 | + F.fetch( |
| 2404 | + "chat-query", { |
| 2405 | + payload: fd, |
| 2406 | + responseType: 'json', |
| 2407 | + onerror:function(err){ |
| 2408 | + Chat.setCurrentView(Chat.e.viewMessages); |
| 2409 | + Chat.reportErrorAsMessage(err); |
| 2410 | + }, |
| 2411 | + onload:function(jx){ |
| 2412 | + let previd = 0; |
| 2413 | + D.clearElement(eMsgTgt); |
| 2414 | + jx.msgs.forEach((m)=>{ |
| 2415 | + m.isSearchResult = true; |
| 2416 | + const mw = new Chat.MessageWidget(m); |
| 2417 | + const spacer = new Chat.SearchCtxLoader({ |
| 2418 | + first: jx.first, |
| 2419 | + last: jx.last, |
| 2420 | + previd: previd, |
| 2421 | + nextid: m.msgid |
| 2422 | + }); |
| 2423 | + if( spacer.e ) D.append( eMsgTgt, spacer.e.body ); |
| 2424 | + D.append( eMsgTgt, mw.e.body ); |
| 2425 | + previd = m.msgid; |
| 2426 | + }); |
| 2427 | + if( jx.msgs.length ){ |
| 2428 | + const spacer = new Chat.SearchCtxLoader({ |
| 2429 | + first: jx.first, |
| 2430 | + last: jx.last, |
| 2431 | + previd: previd, |
| 2432 | + nextid: 0 |
| 2433 | + }); |
| 2434 | + if( spacer.e ) D.append( eMsgTgt, spacer.e.body ); |
| 2435 | + }else{ |
| 2436 | + D.append( D.clearElement(eMsgTgt), |
| 2437 | + 'No search results found for: ', |
| 2438 | + term ); |
| 2439 | + } |
| 2440 | + } |
| 2441 | + } |
| 2442 | + ); |
| 2443 | + }/*Chat.submitSearch()*/; |
| 2444 | + |
| 2131 | 2445 | const afterFetch = function f(){ |
| 2132 | 2446 | if(true===f.isFirstCall){ |
| 2133 | 2447 | f.isFirstCall = false; |
| 2134 | 2448 | Chat.ajaxEnd(); |
| 2135 | 2449 | Chat.e.viewMessages.classList.remove('loading'); |
| | @@ -2145,10 +2459,25 @@ |
| 2145 | 2459 | delete Chat.intervalTimer; |
| 2146 | 2460 | } |
| 2147 | 2461 | poll.running = false; |
| 2148 | 2462 | }; |
| 2149 | 2463 | afterFetch.isFirstCall = true; |
| 2464 | + /** |
| 2465 | + FIXME: when polling fails because the remote server is |
| 2466 | + reachable but it's not accepting HTTP requests, we should back |
| 2467 | + off on polling for a while. e.g. if the remote web server process |
| 2468 | + is killed, the poll fails quickly and immediately retries, |
| 2469 | + hammering the remote server until the httpd is back up. That |
| 2470 | + happens often during development of this application. |
| 2471 | + |
| 2472 | + XHR does not offer a direct way of distinguishing between |
| 2473 | + HTTP/connection errors, but we can hypothetically use the |
| 2474 | + xhrRequest.status value to do so, with status==0 being a |
| 2475 | + connection error. We do not currently have a clean way of passing |
| 2476 | + that info back to the fossil.fetch() client, so we'll need to |
| 2477 | + hammer on that API a bit to get this working. |
| 2478 | + */ |
| 2150 | 2479 | const poll = async function f(){ |
| 2151 | 2480 | if(f.running) return; |
| 2152 | 2481 | f.running = true; |
| 2153 | 2482 | Chat._isBatchLoading = f.isFirstCall; |
| 2154 | 2483 | if(true===f.isFirstCall){ |
| | @@ -2187,17 +2516,11 @@ |
| 2187 | 2516 | Chat._gotServerError = poll.running = false; |
| 2188 | 2517 | if( window.fossil.config.chat.fromcli ){ |
| 2189 | 2518 | Chat.chatOnlyMode(true); |
| 2190 | 2519 | } |
| 2191 | 2520 | Chat.intervalTimer = setInterval(poll, 1000); |
| 2192 | | - if(0){ |
| 2193 | | - const flip = (ev)=>Chat.animate(ev.target,'anim-flip-h'); |
| 2194 | | - document.querySelectorAll('#chat-buttons-wrapper .cbutton').forEach(function(e){ |
| 2195 | | - e.addEventListener('click',flip, false); |
| 2196 | | - }); |
| 2197 | | - } |
| 2198 | 2521 | delete ForceResizeKludge.$disabled; |
| 2199 | 2522 | ForceResizeKludge(); |
| 2200 | 2523 | Chat.animate.$disabled = false; |
| 2201 | 2524 | setTimeout( ()=>Chat.inputFocus(), 0 ); |
| 2202 | 2525 | F.page.chat = Chat/* enables testing the APIs via the dev tools */; |
| 2203 | 2526 | }); |
| 2204 | 2527 | |