Fossil SCM
Merge trunk into the markdown-tagrefs branch to begin experimentation with tying chat #NNN references into the new search capabilities.
Commit
5e26fd4c10af87762fbbf9ae74e97401ec5886012fb3e3a55a393016bfbc703b
Parent
4d7c408f529d230…
9 files changed
+204
-84
+204
-84
+371
-48
+371
-48
+47
-19
+3
-2
+49
-25
+49
-25
+3
-2
+204
-84
| --- src/chat.c | ||
| +++ src/chat.c | ||
| @@ -147,36 +147,19 @@ | ||
| 147 | 147 | ** |
| 148 | 148 | ** Start up a browser-based chat session. |
| 149 | 149 | ** |
| 150 | 150 | ** This is the main page that humans use to access the chatroom. Simply |
| 151 | 151 | ** point a web-browser at /chat and the screen fills with the latest |
| 152 | -** chat messages, and waits for new one. | |
| 152 | +** chat messages, and waits for new ones. | |
| 153 | 153 | ** |
| 154 | 154 | ** Other /chat-OP pages are used by XHR requests from this page to |
| 155 | 155 | ** send new chat message, delete older messages, or poll for changes. |
| 156 | 156 | */ |
| 157 | 157 | void chat_webpage(void){ |
| 158 | 158 | char *zAlert; |
| 159 | 159 | char *zProjectName; |
| 160 | 160 | char * zInputPlaceholder0; /* Common text input placeholder value */ |
| 161 | - const char *zPaperclip = | |
| 162 | - "<svg height=\"8.0\" width=\"16.0\"><path " | |
| 163 | - "stroke=\"rgb(100,100,100)\" " | |
| 164 | - "d=\"M 15.93452,3.2530441 " | |
| 165 | - "A 4.1499493,4.1265346 0 0 0 11.804809,6.5256284e-4 H 2.8582923 A " | |
| 166 | - "2.8239899,2.8080565 0 0 0 0.68965668,0.96142476 2.874599,2.8583801 " | |
| 167 | - "0 0 0 0.03119302,3.2388108 2.7632589,2.7476682 0 0 0 " | |
| 168 | - "0.81132923,4.7689293 3.168132,3.1502569 0 0 0 3.0300653,5.66565 l " | |
| 169 | - "7.7297897,-4e-7 a 1.6802234,1.6707433 0 0 0 0.0072,-3.3377933 H " | |
| 170 | - "5.6138192 v 1.0105899 l 5.1460358,-0.00712 a 0.66804062,0.66427143 " | |
| 171 | - "0 0 1 0,1.3237305 l -7.7226325,0.00712 A 2.0243655,2.0129437 0 0 1 " | |
| 172 | - "1.0332029,3.0964741 1.8522944,1.8418435 0 0 1 2.8511351,1.0041257 h " | |
| 173 | - "8.9465169 a 3.1478884,3.1301275 0 0 1 3.134859,2.4339559 3.0365483," | |
| 174 | - "3.0194156 0 0 1 -0.629835,2.4908908 3.0365483,3.0194156 0 0 1 " | |
| 175 | - "-2.31178,1.0746415 l -7.5437026,-0.014233 -0.00716,1.0034736 " | |
| 176 | - "7.5365456,0.00715 a 4.048731,4.0258875 0 0 0 3.957938,-4.7469259 z\"" | |
| 177 | - "/></svg>"; | |
| 178 | 161 | |
| 179 | 162 | login_check_credentials(); |
| 180 | 163 | if( !g.perm.Chat ){ |
| 181 | 164 | login_needed(g.anon.Chat); |
| 182 | 165 | return; |
| @@ -203,12 +186,14 @@ | ||
| 203 | 186 | @ data-placeholder="%h(zInputPlaceholder0)" \ |
| 204 | 187 | @ class="chat-input-field hidden"></div> |
| 205 | 188 | @ <div id='chat-buttons-wrapper'> |
| 206 | 189 | @ <span class='cbutton' id="chat-button-preview" \ |
| 207 | 190 | @ title="Preview message (Shift-Enter)">👁</span> |
| 191 | + @ <span class='cbutton' id="chat-button-search" \ | |
| 192 | + @ title="Search chat history">🔍</span> | |
| 208 | 193 | @ <span class='cbutton' id="chat-button-attach" \ |
| 209 | - @ title="Attach file to message">%s(zPaperclip)</span> | |
| 194 | + @ title="Attach file to message">📎</span> | |
| 210 | 195 | @ <span class='cbutton' id="chat-button-settings" \ |
| 211 | 196 | @ title="Configure chat">⚙</span> |
| 212 | 197 | @ <span class='cbutton' id="chat-button-submit" \ |
| 213 | 198 | @ title="Send message (Ctrl-Enter)">📤</span> |
| 214 | 199 | @ </div> |
| @@ -234,17 +219,25 @@ | ||
| 234 | 219 | @ <div id='chat-user-list'></div> |
| 235 | 220 | @ </div> |
| 236 | 221 | @ <button id='chat-clear-filter' class='hidden'>Clear filter</button> |
| 237 | 222 | @ <div id='chat-preview' class='hidden chat-view'> |
| 238 | 223 | @ <header>Preview: (<a href='%R/md_rules' target='_blank'>markdown reference</a>)</header> |
| 239 | - @ <div id='chat-preview-content' class='message-widget-content'></div> | |
| 240 | - @ <div id='chat-preview-buttons'><button id='chat-preview-close'>Close Preview</button></div> | |
| 224 | + @ <div id='chat-preview-content'></div> | |
| 225 | + @ <div class='button-bar'><button class='action-close'>Close Preview</button></div> | |
| 241 | 226 | @ </div> |
| 242 | 227 | @ <div id='chat-config' class='hidden chat-view'> |
| 243 | 228 | @ <div id='chat-config-options'></div> |
| 244 | 229 | /* ^^^populated client-side */ |
| 245 | - @ <button>Close Settings</button> | |
| 230 | + @ <div class='button-bar'><button class='action-close'>Close Settings</button></div> | |
| 231 | + @ </div> | |
| 232 | + @ <div id='chat-search' class='hidden chat-view'> | |
| 233 | + @ <div id='chat-search-content'></div> | |
| 234 | + /* ^^^populated client-side */ | |
| 235 | + @ <div class='button-bar'> | |
| 236 | + @ <button class='action-clear'>Clear results</button> | |
| 237 | + @ <button class='action-close'>Close Search</button> | |
| 238 | + @ </div> | |
| 246 | 239 | @ </div> |
| 247 | 240 | @ <div id='chat-messages-wrapper' class='chat-view'> |
| 248 | 241 | /* New chat messages get inserted immediately after this element */ |
| 249 | 242 | @ <span id='message-inject-point'></span> |
| 250 | 243 | @ </div> |
| @@ -272,11 +265,12 @@ | ||
| 272 | 265 | @ </script> |
| 273 | 266 | builtin_request_js("fossil.page.chat.js"); |
| 274 | 267 | style_finish_page(); |
| 275 | 268 | } |
| 276 | 269 | |
| 277 | -/* Definition of repository tables used by chat | |
| 270 | +/* | |
| 271 | +** Definition of repository tables used by chat | |
| 278 | 272 | */ |
| 279 | 273 | static const char zChatSchema1[] = |
| 280 | 274 | @ CREATE TABLE repository.chat( |
| 281 | 275 | @ msgid INTEGER PRIMARY KEY AUTOINCREMENT, |
| 282 | 276 | @ mtime JULIANDAY, -- Time for this entry - Julianday Zulu |
| @@ -290,12 +284,42 @@ | ||
| 290 | 284 | @ ); |
| 291 | 285 | ; |
| 292 | 286 | |
| 293 | 287 | |
| 294 | 288 | /* |
| 295 | -** Make sure the repository data tables used by chat exist. Create them | |
| 296 | -** if they do not. | |
| 289 | +** Create or rebuild the /chat search index. Requires that the | |
| 290 | +** repository.chat table exists. If bForce is true, it will drop the | |
| 291 | +** chatfts1 table and recreate/reindex it. If bForce is 0, it will | |
| 292 | +** only index the chat content if the chatfts1 table does not already | |
| 293 | +** exist. | |
| 294 | +*/ | |
| 295 | +void chat_rebuild_index(int bForce){ | |
| 296 | + if( bForce!=0 ){ | |
| 297 | + db_multi_exec("DROP TABLE IF EXISTS chatfts1"); | |
| 298 | + } | |
| 299 | + if( bForce!=0 || !db_table_exists("repository", "chatfts1") ){ | |
| 300 | + const int tokType = search_tokenizer_type(0); | |
| 301 | + const char *zTokenizer = search_tokenize_arg_for_type( | |
| 302 | + tokType==FTS5TOK_NONE ? FTS5TOK_PORTER : tokType | |
| 303 | + /* Special case: if fts search is disabled for the main repo | |
| 304 | + ** content, use a default tokenizer here. */ | |
| 305 | + ); | |
| 306 | + assert( zTokenizer && zTokenizer[0] ); | |
| 307 | + db_multi_exec( | |
| 308 | + "CREATE VIRTUAL TABLE repository.chatfts1 USING fts5(" | |
| 309 | + " xmsg, content=chat, content_rowid=msgid%s" | |
| 310 | + ");" | |
| 311 | + "INSERT INTO repository.chatfts1(chatfts1) VALUES('rebuild');", | |
| 312 | + zTokenizer/*safe-for-%s*/ | |
| 313 | + ); | |
| 314 | + } | |
| 315 | +} | |
| 316 | + | |
| 317 | +/* | |
| 318 | +** Make sure the repository data tables used by chat exist. Create | |
| 319 | +** them if they do not. Set up TEMP triggers (if needed) to update the | |
| 320 | +** chatfts1 table as the chat table is updated. | |
| 297 | 321 | */ |
| 298 | 322 | static void chat_create_tables(void){ |
| 299 | 323 | if( !db_table_exists("repository","chat") ){ |
| 300 | 324 | db_multi_exec(zChatSchema1/*works-like:""*/); |
| 301 | 325 | }else if( !db_table_has_column("repository","chat","lmtime") ){ |
| @@ -302,10 +326,20 @@ | ||
| 302 | 326 | if( !db_table_has_column("repository","chat","mdel") ){ |
| 303 | 327 | db_multi_exec("ALTER TABLE chat ADD COLUMN mdel INT"); |
| 304 | 328 | } |
| 305 | 329 | db_multi_exec("ALTER TABLE chat ADD COLUMN lmtime TEXT"); |
| 306 | 330 | } |
| 331 | + chat_rebuild_index(0); | |
| 332 | + db_multi_exec( | |
| 333 | + "CREATE TEMP TRIGGER IF NOT EXISTS chat_ai AFTER INSERT ON chat BEGIN " | |
| 334 | + " INSERT INTO chatfts1(rowid, xmsg) VALUES(new.msgid, new.xmsg);" | |
| 335 | + "END;" | |
| 336 | + "CREATE TEMP TRIGGER IF NOT EXISTS chat_ad AFTER DELETE ON chat BEGIN " | |
| 337 | + " INSERT INTO chatfts1(chatfts1, rowid, xmsg) " | |
| 338 | + " VALUES('delete', old.msgid, old.xmsg);" | |
| 339 | + "END;" | |
| 340 | + ); | |
| 307 | 341 | } |
| 308 | 342 | |
| 309 | 343 | /* |
| 310 | 344 | ** Delete old content from the chat table. |
| 311 | 345 | */ |
| @@ -453,28 +487,101 @@ | ||
| 453 | 487 | } |
| 454 | 488 | |
| 455 | 489 | /* |
| 456 | 490 | ** COMMAND: test-chat-formatter |
| 457 | 491 | ** |
| 458 | -** Usage: %fossil test-chat-formatter STRING ... | |
| 492 | +** Usage: %fossil test-chat-formatter ?OPTIONS? STRING ... | |
| 459 | 493 | ** |
| 460 | 494 | ** Transform each argument string into HTML that will display the |
| 461 | 495 | ** chat message. This is used to test the formatter and to verify |
| 462 | 496 | ** that a malicious message text will not cause HTML or JS injection |
| 463 | 497 | ** into the chat display in a browser. |
| 498 | +** | |
| 499 | +** Options: | |
| 500 | +** | |
| 501 | +** -w|--wiki Assume fossil wiki format instead of markdown | |
| 464 | 502 | */ |
| 465 | 503 | void chat_test_formatter_cmd(void){ |
| 466 | 504 | int i; |
| 467 | 505 | char *zOut; |
| 506 | + int const isWiki = find_option("w","wiki",0)!=0; | |
| 468 | 507 | db_find_and_open_repository(0,0); |
| 469 | 508 | g.perm.Hyperlink = 1; |
| 470 | - for(i=0; i<g.argc; i++){ | |
| 471 | - zOut = chat_format_to_html(g.argv[i], 0); | |
| 472 | - fossil_print("[%d]: %s\n", i, zOut); | |
| 509 | + for(i=2; i<g.argc; i++){ | |
| 510 | + zOut = chat_format_to_html(g.argv[i], isWiki); | |
| 511 | + fossil_print("[%d]: %s\n", i-1, zOut); | |
| 473 | 512 | fossil_free(zOut); |
| 474 | 513 | } |
| 475 | 514 | } |
| 515 | + | |
| 516 | +/* | |
| 517 | +** | |
| 518 | +*/ | |
| 519 | +static int chat_poll_rowstojson( | |
| 520 | + Stmt *p, /* Statement to read rows from */ | |
| 521 | + const char *zChatUser, /* Current user */ | |
| 522 | + int bRaw, /* True to return raw format xmsg */ | |
| 523 | + Blob *pJson /* Append json array entries here */ | |
| 524 | +){ | |
| 525 | + int cnt = 0; | |
| 526 | + while( db_step(p)==SQLITE_ROW ){ | |
| 527 | + int isWiki = 0; /* True if chat message is x-fossil-wiki */ | |
| 528 | + int id = db_column_int(p, 0); | |
| 529 | + const char *zDate = db_column_text(p, 1); | |
| 530 | + const char *zFrom = db_column_text(p, 2); | |
| 531 | + const char *zRawMsg = db_column_text(p, 3); | |
| 532 | + int nByte = db_column_int(p, 4); | |
| 533 | + const char *zFName = db_column_text(p, 5); | |
| 534 | + const char *zFMime = db_column_text(p, 6); | |
| 535 | + int iToDel = db_column_int(p, 7); | |
| 536 | + const char *zLMtime = db_column_text(p, 8); | |
| 537 | + char *zMsg; | |
| 538 | + if(cnt++){ | |
| 539 | + blob_append(pJson, ",\n", 2); | |
| 540 | + } | |
| 541 | + blob_appendf(pJson, "{\"msgid\":%d,", id); | |
| 542 | + blob_appendf(pJson, "\"mtime\":\"%.10sT%sZ\",", zDate, zDate+11); | |
| 543 | + if( zLMtime && zLMtime[0] ){ | |
| 544 | + blob_appendf(pJson, "\"lmtime\":%!j,", zLMtime); | |
| 545 | + } | |
| 546 | + blob_append(pJson, "\"xfrom\":", -1); | |
| 547 | + if(zFrom){ | |
| 548 | + blob_appendf(pJson, "%!j,", zFrom); | |
| 549 | + isWiki = fossil_strcmp(zFrom,zChatUser)==0; | |
| 550 | + }else{ | |
| 551 | + /* see https://fossil-scm.org/forum/forumpost/e0be0eeb4c */ | |
| 552 | + blob_appendf(pJson, "null,"); | |
| 553 | + isWiki = 0; | |
| 554 | + } | |
| 555 | + blob_appendf(pJson, "\"uclr\":%!j,", | |
| 556 | + isWiki ? "transparent" : user_color(zFrom ? zFrom : "nobody")); | |
| 557 | + | |
| 558 | + if(bRaw){ | |
| 559 | + blob_appendf(pJson, "\"xmsg\":%!j,", zRawMsg); | |
| 560 | + }else{ | |
| 561 | + zMsg = chat_format_to_html(zRawMsg ? zRawMsg : "", isWiki); | |
| 562 | + blob_appendf(pJson, "\"xmsg\":%!j,", zMsg); | |
| 563 | + fossil_free(zMsg); | |
| 564 | + } | |
| 565 | + | |
| 566 | + if( nByte==0 ){ | |
| 567 | + blob_appendf(pJson, "\"fsize\":0"); | |
| 568 | + }else{ | |
| 569 | + blob_appendf(pJson, "\"fsize\":%d,\"fname\":%!j,\"fmime\":%!j", | |
| 570 | + nByte, zFName, zFMime); | |
| 571 | + } | |
| 572 | + | |
| 573 | + if( iToDel ){ | |
| 574 | + blob_appendf(pJson, ",\"mdel\":%d}", iToDel); | |
| 575 | + }else{ | |
| 576 | + blob_append(pJson, "}", 1); | |
| 577 | + } | |
| 578 | + } | |
| 579 | + db_reset(p); | |
| 580 | + | |
| 581 | + return cnt; | |
| 582 | +} | |
| 476 | 583 | |
| 477 | 584 | /* |
| 478 | 585 | ** WEBPAGE: chat-poll hidden loadavg-exempt |
| 479 | 586 | ** |
| 480 | 587 | ** The chat page generated by /chat using an XHR to this page to |
| @@ -570,11 +677,10 @@ | ||
| 570 | 677 | Blob json; /* The json to be constructed and returned */ |
| 571 | 678 | sqlite3_int64 dataVersion; /* Data version. Used for polling. */ |
| 572 | 679 | const int iDelay = 1000; /* Delay until next poll (milliseconds) */ |
| 573 | 680 | int nDelay; /* Maximum delay.*/ |
| 574 | 681 | const char *zChatUser; /* chat-timeline-user */ |
| 575 | - int isWiki = 0; /* True if chat message is x-fossil-wiki */ | |
| 576 | 682 | int msgid = atoi(PD("name","0")); |
| 577 | 683 | const int msgBefore = atoi(PD("before","0")); |
| 578 | 684 | int nLimit = msgBefore>0 ? atoi(PD("n","0")) : 0; |
| 579 | 685 | const int bRaw = P("raw")!=0; |
| 580 | 686 | |
| @@ -624,64 +730,11 @@ | ||
| 624 | 730 | } |
| 625 | 731 | db_prepare(&q1, "%s", blob_sql_text(&sql)); |
| 626 | 732 | blob_reset(&sql); |
| 627 | 733 | blob_init(&json, "{\"msgs\":[\n", -1); |
| 628 | 734 | while( nDelay>0 ){ |
| 629 | - int cnt = 0; | |
| 630 | - while( db_step(&q1)==SQLITE_ROW ){ | |
| 631 | - int id = db_column_int(&q1, 0); | |
| 632 | - const char *zDate = db_column_text(&q1, 1); | |
| 633 | - const char *zFrom = db_column_text(&q1, 2); | |
| 634 | - const char *zRawMsg = db_column_text(&q1, 3); | |
| 635 | - int nByte = db_column_int(&q1, 4); | |
| 636 | - const char *zFName = db_column_text(&q1, 5); | |
| 637 | - const char *zFMime = db_column_text(&q1, 6); | |
| 638 | - int iToDel = db_column_int(&q1, 7); | |
| 639 | - const char *zLMtime = db_column_text(&q1, 8); | |
| 640 | - char *zMsg; | |
| 641 | - if(cnt++){ | |
| 642 | - blob_append(&json, ",\n", 2); | |
| 643 | - } | |
| 644 | - blob_appendf(&json, "{\"msgid\":%d,", id); | |
| 645 | - blob_appendf(&json, "\"mtime\":\"%.10sT%sZ\",", zDate, zDate+11); | |
| 646 | - if( zLMtime && zLMtime[0] ){ | |
| 647 | - blob_appendf(&json, "\"lmtime\":%!j,", zLMtime); | |
| 648 | - } | |
| 649 | - blob_append(&json, "\"xfrom\":", -1); | |
| 650 | - if(zFrom){ | |
| 651 | - blob_appendf(&json, "%!j,", zFrom); | |
| 652 | - isWiki = fossil_strcmp(zFrom,zChatUser)==0; | |
| 653 | - }else{ | |
| 654 | - /* see https://fossil-scm.org/forum/forumpost/e0be0eeb4c */ | |
| 655 | - blob_appendf(&json, "null,"); | |
| 656 | - isWiki = 0; | |
| 657 | - } | |
| 658 | - blob_appendf(&json, "\"uclr\":%!j,", | |
| 659 | - isWiki ? "transparent" : user_color(zFrom ? zFrom : "nobody")); | |
| 660 | - | |
| 661 | - if(bRaw){ | |
| 662 | - blob_appendf(&json, "\"xmsg\":%!j,", zRawMsg); | |
| 663 | - }else{ | |
| 664 | - zMsg = chat_format_to_html(zRawMsg ? zRawMsg : "", isWiki); | |
| 665 | - blob_appendf(&json, "\"xmsg\":%!j,", zMsg); | |
| 666 | - fossil_free(zMsg); | |
| 667 | - } | |
| 668 | - | |
| 669 | - if( nByte==0 ){ | |
| 670 | - blob_appendf(&json, "\"fsize\":0"); | |
| 671 | - }else{ | |
| 672 | - blob_appendf(&json, "\"fsize\":%d,\"fname\":%!j,\"fmime\":%!j", | |
| 673 | - nByte, zFName, zFMime); | |
| 674 | - } | |
| 675 | - | |
| 676 | - if( iToDel ){ | |
| 677 | - blob_appendf(&json, ",\"mdel\":%d}", iToDel); | |
| 678 | - }else{ | |
| 679 | - blob_append(&json, "}", 1); | |
| 680 | - } | |
| 681 | - } | |
| 682 | - db_reset(&q1); | |
| 735 | + int cnt = chat_poll_rowstojson(&q1, zChatUser, bRaw, &json); | |
| 683 | 736 | if( cnt || msgBefore>0 ){ |
| 684 | 737 | break; |
| 685 | 738 | } |
| 686 | 739 | sqlite3_sleep(iDelay); nDelay--; |
| 687 | 740 | while( nDelay>0 ){ |
| @@ -697,10 +750,77 @@ | ||
| 697 | 750 | blob_append(&json, "\n]}", 3); |
| 698 | 751 | cgi_set_content(&json); |
| 699 | 752 | return; |
| 700 | 753 | } |
| 701 | 754 | |
| 755 | + | |
| 756 | +/* | |
| 757 | +** WEBPAGE: chat-query hidden loadavg-exempt | |
| 758 | +*/ | |
| 759 | +void chat_query_webpage(void){ | |
| 760 | + Blob json; /* The json to be constructed and returned */ | |
| 761 | + Blob sql = empty_blob; | |
| 762 | + Stmt q1; | |
| 763 | + int nLimit = atoi(PD("n","500")); | |
| 764 | + int iFirst = atoi(PD("i","0")); | |
| 765 | + const char *zQuery = PD("q", ""); | |
| 766 | + i64 iMin = 0; | |
| 767 | + i64 iMax = 0; | |
| 768 | + | |
| 769 | + login_check_credentials(); | |
| 770 | + if( !g.perm.Chat ) { | |
| 771 | + chat_emit_permissions_error(1); | |
| 772 | + return; | |
| 773 | + } | |
| 774 | + chat_create_tables(); | |
| 775 | + cgi_set_content_type("application/json"); | |
| 776 | + | |
| 777 | + if( zQuery[0] ){ | |
| 778 | + iMax = db_int64(0, "SELECT max(msgid) FROM chat"); | |
| 779 | + iMin = db_int64(0, "SELECT min(msgid) FROM chat"); | |
| 780 | + if( '#'==zQuery[0] ){ | |
| 781 | + /* Assume we're looking for an exact msgid match. */ | |
| 782 | + ++zQuery; | |
| 783 | + blob_append_sql(&sql, | |
| 784 | + "SELECT msgid, datetime(mtime), xfrom, " | |
| 785 | + " xmsg, octet_length(file), fname, fmime, mdel, lmtime " | |
| 786 | + " FROM chat WHERE msgid=+%Q", | |
| 787 | + zQuery | |
| 788 | + ); | |
| 789 | + }else{ | |
| 790 | + char * zPat = search_simplify_pattern(zQuery); | |
| 791 | + blob_append_sql(&sql, | |
| 792 | + "SELECT * FROM (" | |
| 793 | + "SELECT c.msgid, datetime(c.mtime), c.xfrom, " | |
| 794 | + " highlight(chatfts1, 0, '<span class=\"match\">', '</span>'), " | |
| 795 | + " octet_length(c.file), c.fname, c.fmime, c.mdel, c.lmtime " | |
| 796 | + " FROM chatfts1(%Q) f, chat c " | |
| 797 | + " WHERE f.rowid=c.msgid" | |
| 798 | + " ORDER BY f.rowid DESC LIMIT %d" | |
| 799 | + ") ORDER BY 1 ASC", zPat, nLimit | |
| 800 | + ); | |
| 801 | + fossil_free(zPat); | |
| 802 | + } | |
| 803 | + }else{ | |
| 804 | + blob_append_sql(&sql, | |
| 805 | + "SELECT msgid, datetime(mtime), xfrom, " | |
| 806 | + " xmsg, octet_length(file), fname, fmime, mdel, lmtime" | |
| 807 | + " FROM chat WHERE msgid>=%d LIMIT %d", | |
| 808 | + iFirst, nLimit | |
| 809 | + ); | |
| 810 | + } | |
| 811 | + | |
| 812 | + db_prepare(&q1, "%s", blob_sql_text(&sql)); | |
| 813 | + blob_reset(&sql); | |
| 814 | + blob_init(&json, "{\"msgs\":[\n", -1); | |
| 815 | + chat_poll_rowstojson(&q1, "", 0, &json); | |
| 816 | + db_finalize(&q1); | |
| 817 | + blob_appendf(&json, "\n], \"first\":%lld, \"last\":%lld}", iMin, iMax); | |
| 818 | + cgi_set_content(&json); | |
| 819 | + return; | |
| 820 | +} | |
| 821 | + | |
| 702 | 822 | /* |
| 703 | 823 | ** WEBPAGE: chat-fetch-one hidden loadavg-exempt |
| 704 | 824 | ** |
| 705 | 825 | ** /chat-fetch-one/N |
| 706 | 826 | ** |
| 707 | 827 |
| --- src/chat.c | |
| +++ src/chat.c | |
| @@ -147,36 +147,19 @@ | |
| 147 | ** |
| 148 | ** Start up a browser-based chat session. |
| 149 | ** |
| 150 | ** This is the main page that humans use to access the chatroom. Simply |
| 151 | ** point a web-browser at /chat and the screen fills with the latest |
| 152 | ** chat messages, and waits for new one. |
| 153 | ** |
| 154 | ** Other /chat-OP pages are used by XHR requests from this page to |
| 155 | ** send new chat message, delete older messages, or poll for changes. |
| 156 | */ |
| 157 | void chat_webpage(void){ |
| 158 | char *zAlert; |
| 159 | char *zProjectName; |
| 160 | char * zInputPlaceholder0; /* Common text input placeholder value */ |
| 161 | const char *zPaperclip = |
| 162 | "<svg height=\"8.0\" width=\"16.0\"><path " |
| 163 | "stroke=\"rgb(100,100,100)\" " |
| 164 | "d=\"M 15.93452,3.2530441 " |
| 165 | "A 4.1499493,4.1265346 0 0 0 11.804809,6.5256284e-4 H 2.8582923 A " |
| 166 | "2.8239899,2.8080565 0 0 0 0.68965668,0.96142476 2.874599,2.8583801 " |
| 167 | "0 0 0 0.03119302,3.2388108 2.7632589,2.7476682 0 0 0 " |
| 168 | "0.81132923,4.7689293 3.168132,3.1502569 0 0 0 3.0300653,5.66565 l " |
| 169 | "7.7297897,-4e-7 a 1.6802234,1.6707433 0 0 0 0.0072,-3.3377933 H " |
| 170 | "5.6138192 v 1.0105899 l 5.1460358,-0.00712 a 0.66804062,0.66427143 " |
| 171 | "0 0 1 0,1.3237305 l -7.7226325,0.00712 A 2.0243655,2.0129437 0 0 1 " |
| 172 | "1.0332029,3.0964741 1.8522944,1.8418435 0 0 1 2.8511351,1.0041257 h " |
| 173 | "8.9465169 a 3.1478884,3.1301275 0 0 1 3.134859,2.4339559 3.0365483," |
| 174 | "3.0194156 0 0 1 -0.629835,2.4908908 3.0365483,3.0194156 0 0 1 " |
| 175 | "-2.31178,1.0746415 l -7.5437026,-0.014233 -0.00716,1.0034736 " |
| 176 | "7.5365456,0.00715 a 4.048731,4.0258875 0 0 0 3.957938,-4.7469259 z\"" |
| 177 | "/></svg>"; |
| 178 | |
| 179 | login_check_credentials(); |
| 180 | if( !g.perm.Chat ){ |
| 181 | login_needed(g.anon.Chat); |
| 182 | return; |
| @@ -203,12 +186,14 @@ | |
| 203 | @ data-placeholder="%h(zInputPlaceholder0)" \ |
| 204 | @ class="chat-input-field hidden"></div> |
| 205 | @ <div id='chat-buttons-wrapper'> |
| 206 | @ <span class='cbutton' id="chat-button-preview" \ |
| 207 | @ title="Preview message (Shift-Enter)">👁</span> |
| 208 | @ <span class='cbutton' id="chat-button-attach" \ |
| 209 | @ title="Attach file to message">%s(zPaperclip)</span> |
| 210 | @ <span class='cbutton' id="chat-button-settings" \ |
| 211 | @ title="Configure chat">⚙</span> |
| 212 | @ <span class='cbutton' id="chat-button-submit" \ |
| 213 | @ title="Send message (Ctrl-Enter)">📤</span> |
| 214 | @ </div> |
| @@ -234,17 +219,25 @@ | |
| 234 | @ <div id='chat-user-list'></div> |
| 235 | @ </div> |
| 236 | @ <button id='chat-clear-filter' class='hidden'>Clear filter</button> |
| 237 | @ <div id='chat-preview' class='hidden chat-view'> |
| 238 | @ <header>Preview: (<a href='%R/md_rules' target='_blank'>markdown reference</a>)</header> |
| 239 | @ <div id='chat-preview-content' class='message-widget-content'></div> |
| 240 | @ <div id='chat-preview-buttons'><button id='chat-preview-close'>Close Preview</button></div> |
| 241 | @ </div> |
| 242 | @ <div id='chat-config' class='hidden chat-view'> |
| 243 | @ <div id='chat-config-options'></div> |
| 244 | /* ^^^populated client-side */ |
| 245 | @ <button>Close Settings</button> |
| 246 | @ </div> |
| 247 | @ <div id='chat-messages-wrapper' class='chat-view'> |
| 248 | /* New chat messages get inserted immediately after this element */ |
| 249 | @ <span id='message-inject-point'></span> |
| 250 | @ </div> |
| @@ -272,11 +265,12 @@ | |
| 272 | @ </script> |
| 273 | builtin_request_js("fossil.page.chat.js"); |
| 274 | style_finish_page(); |
| 275 | } |
| 276 | |
| 277 | /* Definition of repository tables used by chat |
| 278 | */ |
| 279 | static const char zChatSchema1[] = |
| 280 | @ CREATE TABLE repository.chat( |
| 281 | @ msgid INTEGER PRIMARY KEY AUTOINCREMENT, |
| 282 | @ mtime JULIANDAY, -- Time for this entry - Julianday Zulu |
| @@ -290,12 +284,42 @@ | |
| 290 | @ ); |
| 291 | ; |
| 292 | |
| 293 | |
| 294 | /* |
| 295 | ** Make sure the repository data tables used by chat exist. Create them |
| 296 | ** if they do not. |
| 297 | */ |
| 298 | static void chat_create_tables(void){ |
| 299 | if( !db_table_exists("repository","chat") ){ |
| 300 | db_multi_exec(zChatSchema1/*works-like:""*/); |
| 301 | }else if( !db_table_has_column("repository","chat","lmtime") ){ |
| @@ -302,10 +326,20 @@ | |
| 302 | if( !db_table_has_column("repository","chat","mdel") ){ |
| 303 | db_multi_exec("ALTER TABLE chat ADD COLUMN mdel INT"); |
| 304 | } |
| 305 | db_multi_exec("ALTER TABLE chat ADD COLUMN lmtime TEXT"); |
| 306 | } |
| 307 | } |
| 308 | |
| 309 | /* |
| 310 | ** Delete old content from the chat table. |
| 311 | */ |
| @@ -453,28 +487,101 @@ | |
| 453 | } |
| 454 | |
| 455 | /* |
| 456 | ** COMMAND: test-chat-formatter |
| 457 | ** |
| 458 | ** Usage: %fossil test-chat-formatter STRING ... |
| 459 | ** |
| 460 | ** Transform each argument string into HTML that will display the |
| 461 | ** chat message. This is used to test the formatter and to verify |
| 462 | ** that a malicious message text will not cause HTML or JS injection |
| 463 | ** into the chat display in a browser. |
| 464 | */ |
| 465 | void chat_test_formatter_cmd(void){ |
| 466 | int i; |
| 467 | char *zOut; |
| 468 | db_find_and_open_repository(0,0); |
| 469 | g.perm.Hyperlink = 1; |
| 470 | for(i=0; i<g.argc; i++){ |
| 471 | zOut = chat_format_to_html(g.argv[i], 0); |
| 472 | fossil_print("[%d]: %s\n", i, zOut); |
| 473 | fossil_free(zOut); |
| 474 | } |
| 475 | } |
| 476 | |
| 477 | /* |
| 478 | ** WEBPAGE: chat-poll hidden loadavg-exempt |
| 479 | ** |
| 480 | ** The chat page generated by /chat using an XHR to this page to |
| @@ -570,11 +677,10 @@ | |
| 570 | Blob json; /* The json to be constructed and returned */ |
| 571 | sqlite3_int64 dataVersion; /* Data version. Used for polling. */ |
| 572 | const int iDelay = 1000; /* Delay until next poll (milliseconds) */ |
| 573 | int nDelay; /* Maximum delay.*/ |
| 574 | const char *zChatUser; /* chat-timeline-user */ |
| 575 | int isWiki = 0; /* True if chat message is x-fossil-wiki */ |
| 576 | int msgid = atoi(PD("name","0")); |
| 577 | const int msgBefore = atoi(PD("before","0")); |
| 578 | int nLimit = msgBefore>0 ? atoi(PD("n","0")) : 0; |
| 579 | const int bRaw = P("raw")!=0; |
| 580 | |
| @@ -624,64 +730,11 @@ | |
| 624 | } |
| 625 | db_prepare(&q1, "%s", blob_sql_text(&sql)); |
| 626 | blob_reset(&sql); |
| 627 | blob_init(&json, "{\"msgs\":[\n", -1); |
| 628 | while( nDelay>0 ){ |
| 629 | int cnt = 0; |
| 630 | while( db_step(&q1)==SQLITE_ROW ){ |
| 631 | int id = db_column_int(&q1, 0); |
| 632 | const char *zDate = db_column_text(&q1, 1); |
| 633 | const char *zFrom = db_column_text(&q1, 2); |
| 634 | const char *zRawMsg = db_column_text(&q1, 3); |
| 635 | int nByte = db_column_int(&q1, 4); |
| 636 | const char *zFName = db_column_text(&q1, 5); |
| 637 | const char *zFMime = db_column_text(&q1, 6); |
| 638 | int iToDel = db_column_int(&q1, 7); |
| 639 | const char *zLMtime = db_column_text(&q1, 8); |
| 640 | char *zMsg; |
| 641 | if(cnt++){ |
| 642 | blob_append(&json, ",\n", 2); |
| 643 | } |
| 644 | blob_appendf(&json, "{\"msgid\":%d,", id); |
| 645 | blob_appendf(&json, "\"mtime\":\"%.10sT%sZ\",", zDate, zDate+11); |
| 646 | if( zLMtime && zLMtime[0] ){ |
| 647 | blob_appendf(&json, "\"lmtime\":%!j,", zLMtime); |
| 648 | } |
| 649 | blob_append(&json, "\"xfrom\":", -1); |
| 650 | if(zFrom){ |
| 651 | blob_appendf(&json, "%!j,", zFrom); |
| 652 | isWiki = fossil_strcmp(zFrom,zChatUser)==0; |
| 653 | }else{ |
| 654 | /* see https://fossil-scm.org/forum/forumpost/e0be0eeb4c */ |
| 655 | blob_appendf(&json, "null,"); |
| 656 | isWiki = 0; |
| 657 | } |
| 658 | blob_appendf(&json, "\"uclr\":%!j,", |
| 659 | isWiki ? "transparent" : user_color(zFrom ? zFrom : "nobody")); |
| 660 | |
| 661 | if(bRaw){ |
| 662 | blob_appendf(&json, "\"xmsg\":%!j,", zRawMsg); |
| 663 | }else{ |
| 664 | zMsg = chat_format_to_html(zRawMsg ? zRawMsg : "", isWiki); |
| 665 | blob_appendf(&json, "\"xmsg\":%!j,", zMsg); |
| 666 | fossil_free(zMsg); |
| 667 | } |
| 668 | |
| 669 | if( nByte==0 ){ |
| 670 | blob_appendf(&json, "\"fsize\":0"); |
| 671 | }else{ |
| 672 | blob_appendf(&json, "\"fsize\":%d,\"fname\":%!j,\"fmime\":%!j", |
| 673 | nByte, zFName, zFMime); |
| 674 | } |
| 675 | |
| 676 | if( iToDel ){ |
| 677 | blob_appendf(&json, ",\"mdel\":%d}", iToDel); |
| 678 | }else{ |
| 679 | blob_append(&json, "}", 1); |
| 680 | } |
| 681 | } |
| 682 | db_reset(&q1); |
| 683 | if( cnt || msgBefore>0 ){ |
| 684 | break; |
| 685 | } |
| 686 | sqlite3_sleep(iDelay); nDelay--; |
| 687 | while( nDelay>0 ){ |
| @@ -697,10 +750,77 @@ | |
| 697 | blob_append(&json, "\n]}", 3); |
| 698 | cgi_set_content(&json); |
| 699 | return; |
| 700 | } |
| 701 | |
| 702 | /* |
| 703 | ** WEBPAGE: chat-fetch-one hidden loadavg-exempt |
| 704 | ** |
| 705 | ** /chat-fetch-one/N |
| 706 | ** |
| 707 |
| --- src/chat.c | |
| +++ src/chat.c | |
| @@ -147,36 +147,19 @@ | |
| 147 | ** |
| 148 | ** Start up a browser-based chat session. |
| 149 | ** |
| 150 | ** This is the main page that humans use to access the chatroom. Simply |
| 151 | ** point a web-browser at /chat and the screen fills with the latest |
| 152 | ** chat messages, and waits for new ones. |
| 153 | ** |
| 154 | ** Other /chat-OP pages are used by XHR requests from this page to |
| 155 | ** send new chat message, delete older messages, or poll for changes. |
| 156 | */ |
| 157 | void chat_webpage(void){ |
| 158 | char *zAlert; |
| 159 | char *zProjectName; |
| 160 | char * zInputPlaceholder0; /* Common text input placeholder value */ |
| 161 | |
| 162 | login_check_credentials(); |
| 163 | if( !g.perm.Chat ){ |
| 164 | login_needed(g.anon.Chat); |
| 165 | return; |
| @@ -203,12 +186,14 @@ | |
| 186 | @ data-placeholder="%h(zInputPlaceholder0)" \ |
| 187 | @ class="chat-input-field hidden"></div> |
| 188 | @ <div id='chat-buttons-wrapper'> |
| 189 | @ <span class='cbutton' id="chat-button-preview" \ |
| 190 | @ title="Preview message (Shift-Enter)">👁</span> |
| 191 | @ <span class='cbutton' id="chat-button-search" \ |
| 192 | @ title="Search chat history">🔍</span> |
| 193 | @ <span class='cbutton' id="chat-button-attach" \ |
| 194 | @ title="Attach file to message">📎</span> |
| 195 | @ <span class='cbutton' id="chat-button-settings" \ |
| 196 | @ title="Configure chat">⚙</span> |
| 197 | @ <span class='cbutton' id="chat-button-submit" \ |
| 198 | @ title="Send message (Ctrl-Enter)">📤</span> |
| 199 | @ </div> |
| @@ -234,17 +219,25 @@ | |
| 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 | @ <div id='chat-config' class='hidden chat-view'> |
| 228 | @ <div id='chat-config-options'></div> |
| 229 | /* ^^^populated client-side */ |
| 230 | @ <div class='button-bar'><button class='action-close'>Close Settings</button></div> |
| 231 | @ </div> |
| 232 | @ <div id='chat-search' class='hidden chat-view'> |
| 233 | @ <div id='chat-search-content'></div> |
| 234 | /* ^^^populated client-side */ |
| 235 | @ <div class='button-bar'> |
| 236 | @ <button class='action-clear'>Clear results</button> |
| 237 | @ <button class='action-close'>Close Search</button> |
| 238 | @ </div> |
| 239 | @ </div> |
| 240 | @ <div id='chat-messages-wrapper' class='chat-view'> |
| 241 | /* New chat messages get inserted immediately after this element */ |
| 242 | @ <span id='message-inject-point'></span> |
| 243 | @ </div> |
| @@ -272,11 +265,12 @@ | |
| 265 | @ </script> |
| 266 | builtin_request_js("fossil.page.chat.js"); |
| 267 | style_finish_page(); |
| 268 | } |
| 269 | |
| 270 | /* |
| 271 | ** Definition of repository tables used by chat |
| 272 | */ |
| 273 | static const char zChatSchema1[] = |
| 274 | @ CREATE TABLE repository.chat( |
| 275 | @ msgid INTEGER PRIMARY KEY AUTOINCREMENT, |
| 276 | @ mtime JULIANDAY, -- Time for this entry - Julianday Zulu |
| @@ -290,12 +284,42 @@ | |
| 284 | @ ); |
| 285 | ; |
| 286 | |
| 287 | |
| 288 | /* |
| 289 | ** Create or rebuild the /chat search index. Requires that the |
| 290 | ** repository.chat table exists. If bForce is true, it will drop the |
| 291 | ** chatfts1 table and recreate/reindex it. If bForce is 0, it will |
| 292 | ** only index the chat content if the chatfts1 table does not already |
| 293 | ** exist. |
| 294 | */ |
| 295 | void chat_rebuild_index(int bForce){ |
| 296 | if( bForce!=0 ){ |
| 297 | db_multi_exec("DROP TABLE IF EXISTS chatfts1"); |
| 298 | } |
| 299 | if( bForce!=0 || !db_table_exists("repository", "chatfts1") ){ |
| 300 | const int tokType = search_tokenizer_type(0); |
| 301 | const char *zTokenizer = search_tokenize_arg_for_type( |
| 302 | tokType==FTS5TOK_NONE ? FTS5TOK_PORTER : tokType |
| 303 | /* Special case: if fts search is disabled for the main repo |
| 304 | ** content, use a default tokenizer here. */ |
| 305 | ); |
| 306 | assert( zTokenizer && zTokenizer[0] ); |
| 307 | db_multi_exec( |
| 308 | "CREATE VIRTUAL TABLE repository.chatfts1 USING fts5(" |
| 309 | " xmsg, content=chat, content_rowid=msgid%s" |
| 310 | ");" |
| 311 | "INSERT INTO repository.chatfts1(chatfts1) VALUES('rebuild');", |
| 312 | zTokenizer/*safe-for-%s*/ |
| 313 | ); |
| 314 | } |
| 315 | } |
| 316 | |
| 317 | /* |
| 318 | ** Make sure the repository data tables used by chat exist. Create |
| 319 | ** them if they do not. Set up TEMP triggers (if needed) to update the |
| 320 | ** chatfts1 table as the chat table is updated. |
| 321 | */ |
| 322 | static void chat_create_tables(void){ |
| 323 | if( !db_table_exists("repository","chat") ){ |
| 324 | db_multi_exec(zChatSchema1/*works-like:""*/); |
| 325 | }else if( !db_table_has_column("repository","chat","lmtime") ){ |
| @@ -302,10 +326,20 @@ | |
| 326 | if( !db_table_has_column("repository","chat","mdel") ){ |
| 327 | db_multi_exec("ALTER TABLE chat ADD COLUMN mdel INT"); |
| 328 | } |
| 329 | db_multi_exec("ALTER TABLE chat ADD COLUMN lmtime TEXT"); |
| 330 | } |
| 331 | chat_rebuild_index(0); |
| 332 | db_multi_exec( |
| 333 | "CREATE TEMP TRIGGER IF NOT EXISTS chat_ai AFTER INSERT ON chat BEGIN " |
| 334 | " INSERT INTO chatfts1(rowid, xmsg) VALUES(new.msgid, new.xmsg);" |
| 335 | "END;" |
| 336 | "CREATE TEMP TRIGGER IF NOT EXISTS chat_ad AFTER DELETE ON chat BEGIN " |
| 337 | " INSERT INTO chatfts1(chatfts1, rowid, xmsg) " |
| 338 | " VALUES('delete', old.msgid, old.xmsg);" |
| 339 | "END;" |
| 340 | ); |
| 341 | } |
| 342 | |
| 343 | /* |
| 344 | ** Delete old content from the chat table. |
| 345 | */ |
| @@ -453,28 +487,101 @@ | |
| 487 | } |
| 488 | |
| 489 | /* |
| 490 | ** COMMAND: test-chat-formatter |
| 491 | ** |
| 492 | ** Usage: %fossil test-chat-formatter ?OPTIONS? STRING ... |
| 493 | ** |
| 494 | ** Transform each argument string into HTML that will display the |
| 495 | ** chat message. This is used to test the formatter and to verify |
| 496 | ** that a malicious message text will not cause HTML or JS injection |
| 497 | ** into the chat display in a browser. |
| 498 | ** |
| 499 | ** Options: |
| 500 | ** |
| 501 | ** -w|--wiki Assume fossil wiki format instead of markdown |
| 502 | */ |
| 503 | void chat_test_formatter_cmd(void){ |
| 504 | int i; |
| 505 | char *zOut; |
| 506 | int const isWiki = find_option("w","wiki",0)!=0; |
| 507 | db_find_and_open_repository(0,0); |
| 508 | g.perm.Hyperlink = 1; |
| 509 | for(i=2; i<g.argc; i++){ |
| 510 | zOut = chat_format_to_html(g.argv[i], isWiki); |
| 511 | fossil_print("[%d]: %s\n", i-1, zOut); |
| 512 | fossil_free(zOut); |
| 513 | } |
| 514 | } |
| 515 | |
| 516 | /* |
| 517 | ** |
| 518 | */ |
| 519 | static int chat_poll_rowstojson( |
| 520 | Stmt *p, /* Statement to read rows from */ |
| 521 | const char *zChatUser, /* Current user */ |
| 522 | int bRaw, /* True to return raw format xmsg */ |
| 523 | Blob *pJson /* Append json array entries here */ |
| 524 | ){ |
| 525 | int cnt = 0; |
| 526 | while( db_step(p)==SQLITE_ROW ){ |
| 527 | int isWiki = 0; /* True if chat message is x-fossil-wiki */ |
| 528 | int id = db_column_int(p, 0); |
| 529 | const char *zDate = db_column_text(p, 1); |
| 530 | const char *zFrom = db_column_text(p, 2); |
| 531 | const char *zRawMsg = db_column_text(p, 3); |
| 532 | int nByte = db_column_int(p, 4); |
| 533 | const char *zFName = db_column_text(p, 5); |
| 534 | const char *zFMime = db_column_text(p, 6); |
| 535 | int iToDel = db_column_int(p, 7); |
| 536 | const char *zLMtime = db_column_text(p, 8); |
| 537 | char *zMsg; |
| 538 | if(cnt++){ |
| 539 | blob_append(pJson, ",\n", 2); |
| 540 | } |
| 541 | blob_appendf(pJson, "{\"msgid\":%d,", id); |
| 542 | blob_appendf(pJson, "\"mtime\":\"%.10sT%sZ\",", zDate, zDate+11); |
| 543 | if( zLMtime && zLMtime[0] ){ |
| 544 | blob_appendf(pJson, "\"lmtime\":%!j,", zLMtime); |
| 545 | } |
| 546 | blob_append(pJson, "\"xfrom\":", -1); |
| 547 | if(zFrom){ |
| 548 | blob_appendf(pJson, "%!j,", zFrom); |
| 549 | isWiki = fossil_strcmp(zFrom,zChatUser)==0; |
| 550 | }else{ |
| 551 | /* see https://fossil-scm.org/forum/forumpost/e0be0eeb4c */ |
| 552 | blob_appendf(pJson, "null,"); |
| 553 | isWiki = 0; |
| 554 | } |
| 555 | blob_appendf(pJson, "\"uclr\":%!j,", |
| 556 | isWiki ? "transparent" : user_color(zFrom ? zFrom : "nobody")); |
| 557 | |
| 558 | if(bRaw){ |
| 559 | blob_appendf(pJson, "\"xmsg\":%!j,", zRawMsg); |
| 560 | }else{ |
| 561 | zMsg = chat_format_to_html(zRawMsg ? zRawMsg : "", isWiki); |
| 562 | blob_appendf(pJson, "\"xmsg\":%!j,", zMsg); |
| 563 | fossil_free(zMsg); |
| 564 | } |
| 565 | |
| 566 | if( nByte==0 ){ |
| 567 | blob_appendf(pJson, "\"fsize\":0"); |
| 568 | }else{ |
| 569 | blob_appendf(pJson, "\"fsize\":%d,\"fname\":%!j,\"fmime\":%!j", |
| 570 | nByte, zFName, zFMime); |
| 571 | } |
| 572 | |
| 573 | if( iToDel ){ |
| 574 | blob_appendf(pJson, ",\"mdel\":%d}", iToDel); |
| 575 | }else{ |
| 576 | blob_append(pJson, "}", 1); |
| 577 | } |
| 578 | } |
| 579 | db_reset(p); |
| 580 | |
| 581 | return cnt; |
| 582 | } |
| 583 | |
| 584 | /* |
| 585 | ** WEBPAGE: chat-poll hidden loadavg-exempt |
| 586 | ** |
| 587 | ** The chat page generated by /chat using an XHR to this page to |
| @@ -570,11 +677,10 @@ | |
| 677 | Blob json; /* The json to be constructed and returned */ |
| 678 | sqlite3_int64 dataVersion; /* Data version. Used for polling. */ |
| 679 | const int iDelay = 1000; /* Delay until next poll (milliseconds) */ |
| 680 | int nDelay; /* Maximum delay.*/ |
| 681 | const char *zChatUser; /* chat-timeline-user */ |
| 682 | int msgid = atoi(PD("name","0")); |
| 683 | const int msgBefore = atoi(PD("before","0")); |
| 684 | int nLimit = msgBefore>0 ? atoi(PD("n","0")) : 0; |
| 685 | const int bRaw = P("raw")!=0; |
| 686 | |
| @@ -624,64 +730,11 @@ | |
| 730 | } |
| 731 | db_prepare(&q1, "%s", blob_sql_text(&sql)); |
| 732 | blob_reset(&sql); |
| 733 | blob_init(&json, "{\"msgs\":[\n", -1); |
| 734 | while( nDelay>0 ){ |
| 735 | int cnt = chat_poll_rowstojson(&q1, zChatUser, bRaw, &json); |
| 736 | if( cnt || msgBefore>0 ){ |
| 737 | break; |
| 738 | } |
| 739 | sqlite3_sleep(iDelay); nDelay--; |
| 740 | while( nDelay>0 ){ |
| @@ -697,10 +750,77 @@ | |
| 750 | blob_append(&json, "\n]}", 3); |
| 751 | cgi_set_content(&json); |
| 752 | return; |
| 753 | } |
| 754 | |
| 755 | |
| 756 | /* |
| 757 | ** WEBPAGE: chat-query hidden loadavg-exempt |
| 758 | */ |
| 759 | void chat_query_webpage(void){ |
| 760 | Blob json; /* The json to be constructed and returned */ |
| 761 | Blob sql = empty_blob; |
| 762 | Stmt q1; |
| 763 | int nLimit = atoi(PD("n","500")); |
| 764 | int iFirst = atoi(PD("i","0")); |
| 765 | const char *zQuery = PD("q", ""); |
| 766 | i64 iMin = 0; |
| 767 | i64 iMax = 0; |
| 768 | |
| 769 | login_check_credentials(); |
| 770 | if( !g.perm.Chat ) { |
| 771 | chat_emit_permissions_error(1); |
| 772 | return; |
| 773 | } |
| 774 | chat_create_tables(); |
| 775 | cgi_set_content_type("application/json"); |
| 776 | |
| 777 | if( zQuery[0] ){ |
| 778 | iMax = db_int64(0, "SELECT max(msgid) FROM chat"); |
| 779 | iMin = db_int64(0, "SELECT min(msgid) FROM chat"); |
| 780 | if( '#'==zQuery[0] ){ |
| 781 | /* Assume we're looking for an exact msgid match. */ |
| 782 | ++zQuery; |
| 783 | blob_append_sql(&sql, |
| 784 | "SELECT msgid, datetime(mtime), xfrom, " |
| 785 | " xmsg, octet_length(file), fname, fmime, mdel, lmtime " |
| 786 | " FROM chat WHERE msgid=+%Q", |
| 787 | zQuery |
| 788 | ); |
| 789 | }else{ |
| 790 | char * zPat = search_simplify_pattern(zQuery); |
| 791 | blob_append_sql(&sql, |
| 792 | "SELECT * FROM (" |
| 793 | "SELECT c.msgid, datetime(c.mtime), c.xfrom, " |
| 794 | " highlight(chatfts1, 0, '<span class=\"match\">', '</span>'), " |
| 795 | " octet_length(c.file), c.fname, c.fmime, c.mdel, c.lmtime " |
| 796 | " FROM chatfts1(%Q) f, chat c " |
| 797 | " WHERE f.rowid=c.msgid" |
| 798 | " ORDER BY f.rowid DESC LIMIT %d" |
| 799 | ") ORDER BY 1 ASC", zPat, nLimit |
| 800 | ); |
| 801 | fossil_free(zPat); |
| 802 | } |
| 803 | }else{ |
| 804 | blob_append_sql(&sql, |
| 805 | "SELECT msgid, datetime(mtime), xfrom, " |
| 806 | " xmsg, octet_length(file), fname, fmime, mdel, lmtime" |
| 807 | " FROM chat WHERE msgid>=%d LIMIT %d", |
| 808 | iFirst, nLimit |
| 809 | ); |
| 810 | } |
| 811 | |
| 812 | db_prepare(&q1, "%s", blob_sql_text(&sql)); |
| 813 | blob_reset(&sql); |
| 814 | blob_init(&json, "{\"msgs\":[\n", -1); |
| 815 | chat_poll_rowstojson(&q1, "", 0, &json); |
| 816 | db_finalize(&q1); |
| 817 | blob_appendf(&json, "\n], \"first\":%lld, \"last\":%lld}", iMin, iMax); |
| 818 | cgi_set_content(&json); |
| 819 | return; |
| 820 | } |
| 821 | |
| 822 | /* |
| 823 | ** WEBPAGE: chat-fetch-one hidden loadavg-exempt |
| 824 | ** |
| 825 | ** /chat-fetch-one/N |
| 826 | ** |
| 827 |
+204
-84
| --- src/chat.c | ||
| +++ src/chat.c | ||
| @@ -147,36 +147,19 @@ | ||
| 147 | 147 | ** |
| 148 | 148 | ** Start up a browser-based chat session. |
| 149 | 149 | ** |
| 150 | 150 | ** This is the main page that humans use to access the chatroom. Simply |
| 151 | 151 | ** point a web-browser at /chat and the screen fills with the latest |
| 152 | -** chat messages, and waits for new one. | |
| 152 | +** chat messages, and waits for new ones. | |
| 153 | 153 | ** |
| 154 | 154 | ** Other /chat-OP pages are used by XHR requests from this page to |
| 155 | 155 | ** send new chat message, delete older messages, or poll for changes. |
| 156 | 156 | */ |
| 157 | 157 | void chat_webpage(void){ |
| 158 | 158 | char *zAlert; |
| 159 | 159 | char *zProjectName; |
| 160 | 160 | char * zInputPlaceholder0; /* Common text input placeholder value */ |
| 161 | - const char *zPaperclip = | |
| 162 | - "<svg height=\"8.0\" width=\"16.0\"><path " | |
| 163 | - "stroke=\"rgb(100,100,100)\" " | |
| 164 | - "d=\"M 15.93452,3.2530441 " | |
| 165 | - "A 4.1499493,4.1265346 0 0 0 11.804809,6.5256284e-4 H 2.8582923 A " | |
| 166 | - "2.8239899,2.8080565 0 0 0 0.68965668,0.96142476 2.874599,2.8583801 " | |
| 167 | - "0 0 0 0.03119302,3.2388108 2.7632589,2.7476682 0 0 0 " | |
| 168 | - "0.81132923,4.7689293 3.168132,3.1502569 0 0 0 3.0300653,5.66565 l " | |
| 169 | - "7.7297897,-4e-7 a 1.6802234,1.6707433 0 0 0 0.0072,-3.3377933 H " | |
| 170 | - "5.6138192 v 1.0105899 l 5.1460358,-0.00712 a 0.66804062,0.66427143 " | |
| 171 | - "0 0 1 0,1.3237305 l -7.7226325,0.00712 A 2.0243655,2.0129437 0 0 1 " | |
| 172 | - "1.0332029,3.0964741 1.8522944,1.8418435 0 0 1 2.8511351,1.0041257 h " | |
| 173 | - "8.9465169 a 3.1478884,3.1301275 0 0 1 3.134859,2.4339559 3.0365483," | |
| 174 | - "3.0194156 0 0 1 -0.629835,2.4908908 3.0365483,3.0194156 0 0 1 " | |
| 175 | - "-2.31178,1.0746415 l -7.5437026,-0.014233 -0.00716,1.0034736 " | |
| 176 | - "7.5365456,0.00715 a 4.048731,4.0258875 0 0 0 3.957938,-4.7469259 z\"" | |
| 177 | - "/></svg>"; | |
| 178 | 161 | |
| 179 | 162 | login_check_credentials(); |
| 180 | 163 | if( !g.perm.Chat ){ |
| 181 | 164 | login_needed(g.anon.Chat); |
| 182 | 165 | return; |
| @@ -203,12 +186,14 @@ | ||
| 203 | 186 | @ data-placeholder="%h(zInputPlaceholder0)" \ |
| 204 | 187 | @ class="chat-input-field hidden"></div> |
| 205 | 188 | @ <div id='chat-buttons-wrapper'> |
| 206 | 189 | @ <span class='cbutton' id="chat-button-preview" \ |
| 207 | 190 | @ title="Preview message (Shift-Enter)">👁</span> |
| 191 | + @ <span class='cbutton' id="chat-button-search" \ | |
| 192 | + @ title="Search chat history">🔍</span> | |
| 208 | 193 | @ <span class='cbutton' id="chat-button-attach" \ |
| 209 | - @ title="Attach file to message">%s(zPaperclip)</span> | |
| 194 | + @ title="Attach file to message">📎</span> | |
| 210 | 195 | @ <span class='cbutton' id="chat-button-settings" \ |
| 211 | 196 | @ title="Configure chat">⚙</span> |
| 212 | 197 | @ <span class='cbutton' id="chat-button-submit" \ |
| 213 | 198 | @ title="Send message (Ctrl-Enter)">📤</span> |
| 214 | 199 | @ </div> |
| @@ -234,17 +219,25 @@ | ||
| 234 | 219 | @ <div id='chat-user-list'></div> |
| 235 | 220 | @ </div> |
| 236 | 221 | @ <button id='chat-clear-filter' class='hidden'>Clear filter</button> |
| 237 | 222 | @ <div id='chat-preview' class='hidden chat-view'> |
| 238 | 223 | @ <header>Preview: (<a href='%R/md_rules' target='_blank'>markdown reference</a>)</header> |
| 239 | - @ <div id='chat-preview-content' class='message-widget-content'></div> | |
| 240 | - @ <div id='chat-preview-buttons'><button id='chat-preview-close'>Close Preview</button></div> | |
| 224 | + @ <div id='chat-preview-content'></div> | |
| 225 | + @ <div class='button-bar'><button class='action-close'>Close Preview</button></div> | |
| 241 | 226 | @ </div> |
| 242 | 227 | @ <div id='chat-config' class='hidden chat-view'> |
| 243 | 228 | @ <div id='chat-config-options'></div> |
| 244 | 229 | /* ^^^populated client-side */ |
| 245 | - @ <button>Close Settings</button> | |
| 230 | + @ <div class='button-bar'><button class='action-close'>Close Settings</button></div> | |
| 231 | + @ </div> | |
| 232 | + @ <div id='chat-search' class='hidden chat-view'> | |
| 233 | + @ <div id='chat-search-content'></div> | |
| 234 | + /* ^^^populated client-side */ | |
| 235 | + @ <div class='button-bar'> | |
| 236 | + @ <button class='action-clear'>Clear results</button> | |
| 237 | + @ <button class='action-close'>Close Search</button> | |
| 238 | + @ </div> | |
| 246 | 239 | @ </div> |
| 247 | 240 | @ <div id='chat-messages-wrapper' class='chat-view'> |
| 248 | 241 | /* New chat messages get inserted immediately after this element */ |
| 249 | 242 | @ <span id='message-inject-point'></span> |
| 250 | 243 | @ </div> |
| @@ -272,11 +265,12 @@ | ||
| 272 | 265 | @ </script> |
| 273 | 266 | builtin_request_js("fossil.page.chat.js"); |
| 274 | 267 | style_finish_page(); |
| 275 | 268 | } |
| 276 | 269 | |
| 277 | -/* Definition of repository tables used by chat | |
| 270 | +/* | |
| 271 | +** Definition of repository tables used by chat | |
| 278 | 272 | */ |
| 279 | 273 | static const char zChatSchema1[] = |
| 280 | 274 | @ CREATE TABLE repository.chat( |
| 281 | 275 | @ msgid INTEGER PRIMARY KEY AUTOINCREMENT, |
| 282 | 276 | @ mtime JULIANDAY, -- Time for this entry - Julianday Zulu |
| @@ -290,12 +284,42 @@ | ||
| 290 | 284 | @ ); |
| 291 | 285 | ; |
| 292 | 286 | |
| 293 | 287 | |
| 294 | 288 | /* |
| 295 | -** Make sure the repository data tables used by chat exist. Create them | |
| 296 | -** if they do not. | |
| 289 | +** Create or rebuild the /chat search index. Requires that the | |
| 290 | +** repository.chat table exists. If bForce is true, it will drop the | |
| 291 | +** chatfts1 table and recreate/reindex it. If bForce is 0, it will | |
| 292 | +** only index the chat content if the chatfts1 table does not already | |
| 293 | +** exist. | |
| 294 | +*/ | |
| 295 | +void chat_rebuild_index(int bForce){ | |
| 296 | + if( bForce!=0 ){ | |
| 297 | + db_multi_exec("DROP TABLE IF EXISTS chatfts1"); | |
| 298 | + } | |
| 299 | + if( bForce!=0 || !db_table_exists("repository", "chatfts1") ){ | |
| 300 | + const int tokType = search_tokenizer_type(0); | |
| 301 | + const char *zTokenizer = search_tokenize_arg_for_type( | |
| 302 | + tokType==FTS5TOK_NONE ? FTS5TOK_PORTER : tokType | |
| 303 | + /* Special case: if fts search is disabled for the main repo | |
| 304 | + ** content, use a default tokenizer here. */ | |
| 305 | + ); | |
| 306 | + assert( zTokenizer && zTokenizer[0] ); | |
| 307 | + db_multi_exec( | |
| 308 | + "CREATE VIRTUAL TABLE repository.chatfts1 USING fts5(" | |
| 309 | + " xmsg, content=chat, content_rowid=msgid%s" | |
| 310 | + ");" | |
| 311 | + "INSERT INTO repository.chatfts1(chatfts1) VALUES('rebuild');", | |
| 312 | + zTokenizer/*safe-for-%s*/ | |
| 313 | + ); | |
| 314 | + } | |
| 315 | +} | |
| 316 | + | |
| 317 | +/* | |
| 318 | +** Make sure the repository data tables used by chat exist. Create | |
| 319 | +** them if they do not. Set up TEMP triggers (if needed) to update the | |
| 320 | +** chatfts1 table as the chat table is updated. | |
| 297 | 321 | */ |
| 298 | 322 | static void chat_create_tables(void){ |
| 299 | 323 | if( !db_table_exists("repository","chat") ){ |
| 300 | 324 | db_multi_exec(zChatSchema1/*works-like:""*/); |
| 301 | 325 | }else if( !db_table_has_column("repository","chat","lmtime") ){ |
| @@ -302,10 +326,20 @@ | ||
| 302 | 326 | if( !db_table_has_column("repository","chat","mdel") ){ |
| 303 | 327 | db_multi_exec("ALTER TABLE chat ADD COLUMN mdel INT"); |
| 304 | 328 | } |
| 305 | 329 | db_multi_exec("ALTER TABLE chat ADD COLUMN lmtime TEXT"); |
| 306 | 330 | } |
| 331 | + chat_rebuild_index(0); | |
| 332 | + db_multi_exec( | |
| 333 | + "CREATE TEMP TRIGGER IF NOT EXISTS chat_ai AFTER INSERT ON chat BEGIN " | |
| 334 | + " INSERT INTO chatfts1(rowid, xmsg) VALUES(new.msgid, new.xmsg);" | |
| 335 | + "END;" | |
| 336 | + "CREATE TEMP TRIGGER IF NOT EXISTS chat_ad AFTER DELETE ON chat BEGIN " | |
| 337 | + " INSERT INTO chatfts1(chatfts1, rowid, xmsg) " | |
| 338 | + " VALUES('delete', old.msgid, old.xmsg);" | |
| 339 | + "END;" | |
| 340 | + ); | |
| 307 | 341 | } |
| 308 | 342 | |
| 309 | 343 | /* |
| 310 | 344 | ** Delete old content from the chat table. |
| 311 | 345 | */ |
| @@ -453,28 +487,101 @@ | ||
| 453 | 487 | } |
| 454 | 488 | |
| 455 | 489 | /* |
| 456 | 490 | ** COMMAND: test-chat-formatter |
| 457 | 491 | ** |
| 458 | -** Usage: %fossil test-chat-formatter STRING ... | |
| 492 | +** Usage: %fossil test-chat-formatter ?OPTIONS? STRING ... | |
| 459 | 493 | ** |
| 460 | 494 | ** Transform each argument string into HTML that will display the |
| 461 | 495 | ** chat message. This is used to test the formatter and to verify |
| 462 | 496 | ** that a malicious message text will not cause HTML or JS injection |
| 463 | 497 | ** into the chat display in a browser. |
| 498 | +** | |
| 499 | +** Options: | |
| 500 | +** | |
| 501 | +** -w|--wiki Assume fossil wiki format instead of markdown | |
| 464 | 502 | */ |
| 465 | 503 | void chat_test_formatter_cmd(void){ |
| 466 | 504 | int i; |
| 467 | 505 | char *zOut; |
| 506 | + int const isWiki = find_option("w","wiki",0)!=0; | |
| 468 | 507 | db_find_and_open_repository(0,0); |
| 469 | 508 | g.perm.Hyperlink = 1; |
| 470 | - for(i=0; i<g.argc; i++){ | |
| 471 | - zOut = chat_format_to_html(g.argv[i], 0); | |
| 472 | - fossil_print("[%d]: %s\n", i, zOut); | |
| 509 | + for(i=2; i<g.argc; i++){ | |
| 510 | + zOut = chat_format_to_html(g.argv[i], isWiki); | |
| 511 | + fossil_print("[%d]: %s\n", i-1, zOut); | |
| 473 | 512 | fossil_free(zOut); |
| 474 | 513 | } |
| 475 | 514 | } |
| 515 | + | |
| 516 | +/* | |
| 517 | +** | |
| 518 | +*/ | |
| 519 | +static int chat_poll_rowstojson( | |
| 520 | + Stmt *p, /* Statement to read rows from */ | |
| 521 | + const char *zChatUser, /* Current user */ | |
| 522 | + int bRaw, /* True to return raw format xmsg */ | |
| 523 | + Blob *pJson /* Append json array entries here */ | |
| 524 | +){ | |
| 525 | + int cnt = 0; | |
| 526 | + while( db_step(p)==SQLITE_ROW ){ | |
| 527 | + int isWiki = 0; /* True if chat message is x-fossil-wiki */ | |
| 528 | + int id = db_column_int(p, 0); | |
| 529 | + const char *zDate = db_column_text(p, 1); | |
| 530 | + const char *zFrom = db_column_text(p, 2); | |
| 531 | + const char *zRawMsg = db_column_text(p, 3); | |
| 532 | + int nByte = db_column_int(p, 4); | |
| 533 | + const char *zFName = db_column_text(p, 5); | |
| 534 | + const char *zFMime = db_column_text(p, 6); | |
| 535 | + int iToDel = db_column_int(p, 7); | |
| 536 | + const char *zLMtime = db_column_text(p, 8); | |
| 537 | + char *zMsg; | |
| 538 | + if(cnt++){ | |
| 539 | + blob_append(pJson, ",\n", 2); | |
| 540 | + } | |
| 541 | + blob_appendf(pJson, "{\"msgid\":%d,", id); | |
| 542 | + blob_appendf(pJson, "\"mtime\":\"%.10sT%sZ\",", zDate, zDate+11); | |
| 543 | + if( zLMtime && zLMtime[0] ){ | |
| 544 | + blob_appendf(pJson, "\"lmtime\":%!j,", zLMtime); | |
| 545 | + } | |
| 546 | + blob_append(pJson, "\"xfrom\":", -1); | |
| 547 | + if(zFrom){ | |
| 548 | + blob_appendf(pJson, "%!j,", zFrom); | |
| 549 | + isWiki = fossil_strcmp(zFrom,zChatUser)==0; | |
| 550 | + }else{ | |
| 551 | + /* see https://fossil-scm.org/forum/forumpost/e0be0eeb4c */ | |
| 552 | + blob_appendf(pJson, "null,"); | |
| 553 | + isWiki = 0; | |
| 554 | + } | |
| 555 | + blob_appendf(pJson, "\"uclr\":%!j,", | |
| 556 | + isWiki ? "transparent" : user_color(zFrom ? zFrom : "nobody")); | |
| 557 | + | |
| 558 | + if(bRaw){ | |
| 559 | + blob_appendf(pJson, "\"xmsg\":%!j,", zRawMsg); | |
| 560 | + }else{ | |
| 561 | + zMsg = chat_format_to_html(zRawMsg ? zRawMsg : "", isWiki); | |
| 562 | + blob_appendf(pJson, "\"xmsg\":%!j,", zMsg); | |
| 563 | + fossil_free(zMsg); | |
| 564 | + } | |
| 565 | + | |
| 566 | + if( nByte==0 ){ | |
| 567 | + blob_appendf(pJson, "\"fsize\":0"); | |
| 568 | + }else{ | |
| 569 | + blob_appendf(pJson, "\"fsize\":%d,\"fname\":%!j,\"fmime\":%!j", | |
| 570 | + nByte, zFName, zFMime); | |
| 571 | + } | |
| 572 | + | |
| 573 | + if( iToDel ){ | |
| 574 | + blob_appendf(pJson, ",\"mdel\":%d}", iToDel); | |
| 575 | + }else{ | |
| 576 | + blob_append(pJson, "}", 1); | |
| 577 | + } | |
| 578 | + } | |
| 579 | + db_reset(p); | |
| 580 | + | |
| 581 | + return cnt; | |
| 582 | +} | |
| 476 | 583 | |
| 477 | 584 | /* |
| 478 | 585 | ** WEBPAGE: chat-poll hidden loadavg-exempt |
| 479 | 586 | ** |
| 480 | 587 | ** The chat page generated by /chat using an XHR to this page to |
| @@ -570,11 +677,10 @@ | ||
| 570 | 677 | Blob json; /* The json to be constructed and returned */ |
| 571 | 678 | sqlite3_int64 dataVersion; /* Data version. Used for polling. */ |
| 572 | 679 | const int iDelay = 1000; /* Delay until next poll (milliseconds) */ |
| 573 | 680 | int nDelay; /* Maximum delay.*/ |
| 574 | 681 | const char *zChatUser; /* chat-timeline-user */ |
| 575 | - int isWiki = 0; /* True if chat message is x-fossil-wiki */ | |
| 576 | 682 | int msgid = atoi(PD("name","0")); |
| 577 | 683 | const int msgBefore = atoi(PD("before","0")); |
| 578 | 684 | int nLimit = msgBefore>0 ? atoi(PD("n","0")) : 0; |
| 579 | 685 | const int bRaw = P("raw")!=0; |
| 580 | 686 | |
| @@ -624,64 +730,11 @@ | ||
| 624 | 730 | } |
| 625 | 731 | db_prepare(&q1, "%s", blob_sql_text(&sql)); |
| 626 | 732 | blob_reset(&sql); |
| 627 | 733 | blob_init(&json, "{\"msgs\":[\n", -1); |
| 628 | 734 | while( nDelay>0 ){ |
| 629 | - int cnt = 0; | |
| 630 | - while( db_step(&q1)==SQLITE_ROW ){ | |
| 631 | - int id = db_column_int(&q1, 0); | |
| 632 | - const char *zDate = db_column_text(&q1, 1); | |
| 633 | - const char *zFrom = db_column_text(&q1, 2); | |
| 634 | - const char *zRawMsg = db_column_text(&q1, 3); | |
| 635 | - int nByte = db_column_int(&q1, 4); | |
| 636 | - const char *zFName = db_column_text(&q1, 5); | |
| 637 | - const char *zFMime = db_column_text(&q1, 6); | |
| 638 | - int iToDel = db_column_int(&q1, 7); | |
| 639 | - const char *zLMtime = db_column_text(&q1, 8); | |
| 640 | - char *zMsg; | |
| 641 | - if(cnt++){ | |
| 642 | - blob_append(&json, ",\n", 2); | |
| 643 | - } | |
| 644 | - blob_appendf(&json, "{\"msgid\":%d,", id); | |
| 645 | - blob_appendf(&json, "\"mtime\":\"%.10sT%sZ\",", zDate, zDate+11); | |
| 646 | - if( zLMtime && zLMtime[0] ){ | |
| 647 | - blob_appendf(&json, "\"lmtime\":%!j,", zLMtime); | |
| 648 | - } | |
| 649 | - blob_append(&json, "\"xfrom\":", -1); | |
| 650 | - if(zFrom){ | |
| 651 | - blob_appendf(&json, "%!j,", zFrom); | |
| 652 | - isWiki = fossil_strcmp(zFrom,zChatUser)==0; | |
| 653 | - }else{ | |
| 654 | - /* see https://fossil-scm.org/forum/forumpost/e0be0eeb4c */ | |
| 655 | - blob_appendf(&json, "null,"); | |
| 656 | - isWiki = 0; | |
| 657 | - } | |
| 658 | - blob_appendf(&json, "\"uclr\":%!j,", | |
| 659 | - isWiki ? "transparent" : user_color(zFrom ? zFrom : "nobody")); | |
| 660 | - | |
| 661 | - if(bRaw){ | |
| 662 | - blob_appendf(&json, "\"xmsg\":%!j,", zRawMsg); | |
| 663 | - }else{ | |
| 664 | - zMsg = chat_format_to_html(zRawMsg ? zRawMsg : "", isWiki); | |
| 665 | - blob_appendf(&json, "\"xmsg\":%!j,", zMsg); | |
| 666 | - fossil_free(zMsg); | |
| 667 | - } | |
| 668 | - | |
| 669 | - if( nByte==0 ){ | |
| 670 | - blob_appendf(&json, "\"fsize\":0"); | |
| 671 | - }else{ | |
| 672 | - blob_appendf(&json, "\"fsize\":%d,\"fname\":%!j,\"fmime\":%!j", | |
| 673 | - nByte, zFName, zFMime); | |
| 674 | - } | |
| 675 | - | |
| 676 | - if( iToDel ){ | |
| 677 | - blob_appendf(&json, ",\"mdel\":%d}", iToDel); | |
| 678 | - }else{ | |
| 679 | - blob_append(&json, "}", 1); | |
| 680 | - } | |
| 681 | - } | |
| 682 | - db_reset(&q1); | |
| 735 | + int cnt = chat_poll_rowstojson(&q1, zChatUser, bRaw, &json); | |
| 683 | 736 | if( cnt || msgBefore>0 ){ |
| 684 | 737 | break; |
| 685 | 738 | } |
| 686 | 739 | sqlite3_sleep(iDelay); nDelay--; |
| 687 | 740 | while( nDelay>0 ){ |
| @@ -697,10 +750,77 @@ | ||
| 697 | 750 | blob_append(&json, "\n]}", 3); |
| 698 | 751 | cgi_set_content(&json); |
| 699 | 752 | return; |
| 700 | 753 | } |
| 701 | 754 | |
| 755 | + | |
| 756 | +/* | |
| 757 | +** WEBPAGE: chat-query hidden loadavg-exempt | |
| 758 | +*/ | |
| 759 | +void chat_query_webpage(void){ | |
| 760 | + Blob json; /* The json to be constructed and returned */ | |
| 761 | + Blob sql = empty_blob; | |
| 762 | + Stmt q1; | |
| 763 | + int nLimit = atoi(PD("n","500")); | |
| 764 | + int iFirst = atoi(PD("i","0")); | |
| 765 | + const char *zQuery = PD("q", ""); | |
| 766 | + i64 iMin = 0; | |
| 767 | + i64 iMax = 0; | |
| 768 | + | |
| 769 | + login_check_credentials(); | |
| 770 | + if( !g.perm.Chat ) { | |
| 771 | + chat_emit_permissions_error(1); | |
| 772 | + return; | |
| 773 | + } | |
| 774 | + chat_create_tables(); | |
| 775 | + cgi_set_content_type("application/json"); | |
| 776 | + | |
| 777 | + if( zQuery[0] ){ | |
| 778 | + iMax = db_int64(0, "SELECT max(msgid) FROM chat"); | |
| 779 | + iMin = db_int64(0, "SELECT min(msgid) FROM chat"); | |
| 780 | + if( '#'==zQuery[0] ){ | |
| 781 | + /* Assume we're looking for an exact msgid match. */ | |
| 782 | + ++zQuery; | |
| 783 | + blob_append_sql(&sql, | |
| 784 | + "SELECT msgid, datetime(mtime), xfrom, " | |
| 785 | + " xmsg, octet_length(file), fname, fmime, mdel, lmtime " | |
| 786 | + " FROM chat WHERE msgid=+%Q", | |
| 787 | + zQuery | |
| 788 | + ); | |
| 789 | + }else{ | |
| 790 | + char * zPat = search_simplify_pattern(zQuery); | |
| 791 | + blob_append_sql(&sql, | |
| 792 | + "SELECT * FROM (" | |
| 793 | + "SELECT c.msgid, datetime(c.mtime), c.xfrom, " | |
| 794 | + " highlight(chatfts1, 0, '<span class=\"match\">', '</span>'), " | |
| 795 | + " octet_length(c.file), c.fname, c.fmime, c.mdel, c.lmtime " | |
| 796 | + " FROM chatfts1(%Q) f, chat c " | |
| 797 | + " WHERE f.rowid=c.msgid" | |
| 798 | + " ORDER BY f.rowid DESC LIMIT %d" | |
| 799 | + ") ORDER BY 1 ASC", zPat, nLimit | |
| 800 | + ); | |
| 801 | + fossil_free(zPat); | |
| 802 | + } | |
| 803 | + }else{ | |
| 804 | + blob_append_sql(&sql, | |
| 805 | + "SELECT msgid, datetime(mtime), xfrom, " | |
| 806 | + " xmsg, octet_length(file), fname, fmime, mdel, lmtime" | |
| 807 | + " FROM chat WHERE msgid>=%d LIMIT %d", | |
| 808 | + iFirst, nLimit | |
| 809 | + ); | |
| 810 | + } | |
| 811 | + | |
| 812 | + db_prepare(&q1, "%s", blob_sql_text(&sql)); | |
| 813 | + blob_reset(&sql); | |
| 814 | + blob_init(&json, "{\"msgs\":[\n", -1); | |
| 815 | + chat_poll_rowstojson(&q1, "", 0, &json); | |
| 816 | + db_finalize(&q1); | |
| 817 | + blob_appendf(&json, "\n], \"first\":%lld, \"last\":%lld}", iMin, iMax); | |
| 818 | + cgi_set_content(&json); | |
| 819 | + return; | |
| 820 | +} | |
| 821 | + | |
| 702 | 822 | /* |
| 703 | 823 | ** WEBPAGE: chat-fetch-one hidden loadavg-exempt |
| 704 | 824 | ** |
| 705 | 825 | ** /chat-fetch-one/N |
| 706 | 826 | ** |
| 707 | 827 |
| --- src/chat.c | |
| +++ src/chat.c | |
| @@ -147,36 +147,19 @@ | |
| 147 | ** |
| 148 | ** Start up a browser-based chat session. |
| 149 | ** |
| 150 | ** This is the main page that humans use to access the chatroom. Simply |
| 151 | ** point a web-browser at /chat and the screen fills with the latest |
| 152 | ** chat messages, and waits for new one. |
| 153 | ** |
| 154 | ** Other /chat-OP pages are used by XHR requests from this page to |
| 155 | ** send new chat message, delete older messages, or poll for changes. |
| 156 | */ |
| 157 | void chat_webpage(void){ |
| 158 | char *zAlert; |
| 159 | char *zProjectName; |
| 160 | char * zInputPlaceholder0; /* Common text input placeholder value */ |
| 161 | const char *zPaperclip = |
| 162 | "<svg height=\"8.0\" width=\"16.0\"><path " |
| 163 | "stroke=\"rgb(100,100,100)\" " |
| 164 | "d=\"M 15.93452,3.2530441 " |
| 165 | "A 4.1499493,4.1265346 0 0 0 11.804809,6.5256284e-4 H 2.8582923 A " |
| 166 | "2.8239899,2.8080565 0 0 0 0.68965668,0.96142476 2.874599,2.8583801 " |
| 167 | "0 0 0 0.03119302,3.2388108 2.7632589,2.7476682 0 0 0 " |
| 168 | "0.81132923,4.7689293 3.168132,3.1502569 0 0 0 3.0300653,5.66565 l " |
| 169 | "7.7297897,-4e-7 a 1.6802234,1.6707433 0 0 0 0.0072,-3.3377933 H " |
| 170 | "5.6138192 v 1.0105899 l 5.1460358,-0.00712 a 0.66804062,0.66427143 " |
| 171 | "0 0 1 0,1.3237305 l -7.7226325,0.00712 A 2.0243655,2.0129437 0 0 1 " |
| 172 | "1.0332029,3.0964741 1.8522944,1.8418435 0 0 1 2.8511351,1.0041257 h " |
| 173 | "8.9465169 a 3.1478884,3.1301275 0 0 1 3.134859,2.4339559 3.0365483," |
| 174 | "3.0194156 0 0 1 -0.629835,2.4908908 3.0365483,3.0194156 0 0 1 " |
| 175 | "-2.31178,1.0746415 l -7.5437026,-0.014233 -0.00716,1.0034736 " |
| 176 | "7.5365456,0.00715 a 4.048731,4.0258875 0 0 0 3.957938,-4.7469259 z\"" |
| 177 | "/></svg>"; |
| 178 | |
| 179 | login_check_credentials(); |
| 180 | if( !g.perm.Chat ){ |
| 181 | login_needed(g.anon.Chat); |
| 182 | return; |
| @@ -203,12 +186,14 @@ | |
| 203 | @ data-placeholder="%h(zInputPlaceholder0)" \ |
| 204 | @ class="chat-input-field hidden"></div> |
| 205 | @ <div id='chat-buttons-wrapper'> |
| 206 | @ <span class='cbutton' id="chat-button-preview" \ |
| 207 | @ title="Preview message (Shift-Enter)">👁</span> |
| 208 | @ <span class='cbutton' id="chat-button-attach" \ |
| 209 | @ title="Attach file to message">%s(zPaperclip)</span> |
| 210 | @ <span class='cbutton' id="chat-button-settings" \ |
| 211 | @ title="Configure chat">⚙</span> |
| 212 | @ <span class='cbutton' id="chat-button-submit" \ |
| 213 | @ title="Send message (Ctrl-Enter)">📤</span> |
| 214 | @ </div> |
| @@ -234,17 +219,25 @@ | |
| 234 | @ <div id='chat-user-list'></div> |
| 235 | @ </div> |
| 236 | @ <button id='chat-clear-filter' class='hidden'>Clear filter</button> |
| 237 | @ <div id='chat-preview' class='hidden chat-view'> |
| 238 | @ <header>Preview: (<a href='%R/md_rules' target='_blank'>markdown reference</a>)</header> |
| 239 | @ <div id='chat-preview-content' class='message-widget-content'></div> |
| 240 | @ <div id='chat-preview-buttons'><button id='chat-preview-close'>Close Preview</button></div> |
| 241 | @ </div> |
| 242 | @ <div id='chat-config' class='hidden chat-view'> |
| 243 | @ <div id='chat-config-options'></div> |
| 244 | /* ^^^populated client-side */ |
| 245 | @ <button>Close Settings</button> |
| 246 | @ </div> |
| 247 | @ <div id='chat-messages-wrapper' class='chat-view'> |
| 248 | /* New chat messages get inserted immediately after this element */ |
| 249 | @ <span id='message-inject-point'></span> |
| 250 | @ </div> |
| @@ -272,11 +265,12 @@ | |
| 272 | @ </script> |
| 273 | builtin_request_js("fossil.page.chat.js"); |
| 274 | style_finish_page(); |
| 275 | } |
| 276 | |
| 277 | /* Definition of repository tables used by chat |
| 278 | */ |
| 279 | static const char zChatSchema1[] = |
| 280 | @ CREATE TABLE repository.chat( |
| 281 | @ msgid INTEGER PRIMARY KEY AUTOINCREMENT, |
| 282 | @ mtime JULIANDAY, -- Time for this entry - Julianday Zulu |
| @@ -290,12 +284,42 @@ | |
| 290 | @ ); |
| 291 | ; |
| 292 | |
| 293 | |
| 294 | /* |
| 295 | ** Make sure the repository data tables used by chat exist. Create them |
| 296 | ** if they do not. |
| 297 | */ |
| 298 | static void chat_create_tables(void){ |
| 299 | if( !db_table_exists("repository","chat") ){ |
| 300 | db_multi_exec(zChatSchema1/*works-like:""*/); |
| 301 | }else if( !db_table_has_column("repository","chat","lmtime") ){ |
| @@ -302,10 +326,20 @@ | |
| 302 | if( !db_table_has_column("repository","chat","mdel") ){ |
| 303 | db_multi_exec("ALTER TABLE chat ADD COLUMN mdel INT"); |
| 304 | } |
| 305 | db_multi_exec("ALTER TABLE chat ADD COLUMN lmtime TEXT"); |
| 306 | } |
| 307 | } |
| 308 | |
| 309 | /* |
| 310 | ** Delete old content from the chat table. |
| 311 | */ |
| @@ -453,28 +487,101 @@ | |
| 453 | } |
| 454 | |
| 455 | /* |
| 456 | ** COMMAND: test-chat-formatter |
| 457 | ** |
| 458 | ** Usage: %fossil test-chat-formatter STRING ... |
| 459 | ** |
| 460 | ** Transform each argument string into HTML that will display the |
| 461 | ** chat message. This is used to test the formatter and to verify |
| 462 | ** that a malicious message text will not cause HTML or JS injection |
| 463 | ** into the chat display in a browser. |
| 464 | */ |
| 465 | void chat_test_formatter_cmd(void){ |
| 466 | int i; |
| 467 | char *zOut; |
| 468 | db_find_and_open_repository(0,0); |
| 469 | g.perm.Hyperlink = 1; |
| 470 | for(i=0; i<g.argc; i++){ |
| 471 | zOut = chat_format_to_html(g.argv[i], 0); |
| 472 | fossil_print("[%d]: %s\n", i, zOut); |
| 473 | fossil_free(zOut); |
| 474 | } |
| 475 | } |
| 476 | |
| 477 | /* |
| 478 | ** WEBPAGE: chat-poll hidden loadavg-exempt |
| 479 | ** |
| 480 | ** The chat page generated by /chat using an XHR to this page to |
| @@ -570,11 +677,10 @@ | |
| 570 | Blob json; /* The json to be constructed and returned */ |
| 571 | sqlite3_int64 dataVersion; /* Data version. Used for polling. */ |
| 572 | const int iDelay = 1000; /* Delay until next poll (milliseconds) */ |
| 573 | int nDelay; /* Maximum delay.*/ |
| 574 | const char *zChatUser; /* chat-timeline-user */ |
| 575 | int isWiki = 0; /* True if chat message is x-fossil-wiki */ |
| 576 | int msgid = atoi(PD("name","0")); |
| 577 | const int msgBefore = atoi(PD("before","0")); |
| 578 | int nLimit = msgBefore>0 ? atoi(PD("n","0")) : 0; |
| 579 | const int bRaw = P("raw")!=0; |
| 580 | |
| @@ -624,64 +730,11 @@ | |
| 624 | } |
| 625 | db_prepare(&q1, "%s", blob_sql_text(&sql)); |
| 626 | blob_reset(&sql); |
| 627 | blob_init(&json, "{\"msgs\":[\n", -1); |
| 628 | while( nDelay>0 ){ |
| 629 | int cnt = 0; |
| 630 | while( db_step(&q1)==SQLITE_ROW ){ |
| 631 | int id = db_column_int(&q1, 0); |
| 632 | const char *zDate = db_column_text(&q1, 1); |
| 633 | const char *zFrom = db_column_text(&q1, 2); |
| 634 | const char *zRawMsg = db_column_text(&q1, 3); |
| 635 | int nByte = db_column_int(&q1, 4); |
| 636 | const char *zFName = db_column_text(&q1, 5); |
| 637 | const char *zFMime = db_column_text(&q1, 6); |
| 638 | int iToDel = db_column_int(&q1, 7); |
| 639 | const char *zLMtime = db_column_text(&q1, 8); |
| 640 | char *zMsg; |
| 641 | if(cnt++){ |
| 642 | blob_append(&json, ",\n", 2); |
| 643 | } |
| 644 | blob_appendf(&json, "{\"msgid\":%d,", id); |
| 645 | blob_appendf(&json, "\"mtime\":\"%.10sT%sZ\",", zDate, zDate+11); |
| 646 | if( zLMtime && zLMtime[0] ){ |
| 647 | blob_appendf(&json, "\"lmtime\":%!j,", zLMtime); |
| 648 | } |
| 649 | blob_append(&json, "\"xfrom\":", -1); |
| 650 | if(zFrom){ |
| 651 | blob_appendf(&json, "%!j,", zFrom); |
| 652 | isWiki = fossil_strcmp(zFrom,zChatUser)==0; |
| 653 | }else{ |
| 654 | /* see https://fossil-scm.org/forum/forumpost/e0be0eeb4c */ |
| 655 | blob_appendf(&json, "null,"); |
| 656 | isWiki = 0; |
| 657 | } |
| 658 | blob_appendf(&json, "\"uclr\":%!j,", |
| 659 | isWiki ? "transparent" : user_color(zFrom ? zFrom : "nobody")); |
| 660 | |
| 661 | if(bRaw){ |
| 662 | blob_appendf(&json, "\"xmsg\":%!j,", zRawMsg); |
| 663 | }else{ |
| 664 | zMsg = chat_format_to_html(zRawMsg ? zRawMsg : "", isWiki); |
| 665 | blob_appendf(&json, "\"xmsg\":%!j,", zMsg); |
| 666 | fossil_free(zMsg); |
| 667 | } |
| 668 | |
| 669 | if( nByte==0 ){ |
| 670 | blob_appendf(&json, "\"fsize\":0"); |
| 671 | }else{ |
| 672 | blob_appendf(&json, "\"fsize\":%d,\"fname\":%!j,\"fmime\":%!j", |
| 673 | nByte, zFName, zFMime); |
| 674 | } |
| 675 | |
| 676 | if( iToDel ){ |
| 677 | blob_appendf(&json, ",\"mdel\":%d}", iToDel); |
| 678 | }else{ |
| 679 | blob_append(&json, "}", 1); |
| 680 | } |
| 681 | } |
| 682 | db_reset(&q1); |
| 683 | if( cnt || msgBefore>0 ){ |
| 684 | break; |
| 685 | } |
| 686 | sqlite3_sleep(iDelay); nDelay--; |
| 687 | while( nDelay>0 ){ |
| @@ -697,10 +750,77 @@ | |
| 697 | blob_append(&json, "\n]}", 3); |
| 698 | cgi_set_content(&json); |
| 699 | return; |
| 700 | } |
| 701 | |
| 702 | /* |
| 703 | ** WEBPAGE: chat-fetch-one hidden loadavg-exempt |
| 704 | ** |
| 705 | ** /chat-fetch-one/N |
| 706 | ** |
| 707 |
| --- src/chat.c | |
| +++ src/chat.c | |
| @@ -147,36 +147,19 @@ | |
| 147 | ** |
| 148 | ** Start up a browser-based chat session. |
| 149 | ** |
| 150 | ** This is the main page that humans use to access the chatroom. Simply |
| 151 | ** point a web-browser at /chat and the screen fills with the latest |
| 152 | ** chat messages, and waits for new ones. |
| 153 | ** |
| 154 | ** Other /chat-OP pages are used by XHR requests from this page to |
| 155 | ** send new chat message, delete older messages, or poll for changes. |
| 156 | */ |
| 157 | void chat_webpage(void){ |
| 158 | char *zAlert; |
| 159 | char *zProjectName; |
| 160 | char * zInputPlaceholder0; /* Common text input placeholder value */ |
| 161 | |
| 162 | login_check_credentials(); |
| 163 | if( !g.perm.Chat ){ |
| 164 | login_needed(g.anon.Chat); |
| 165 | return; |
| @@ -203,12 +186,14 @@ | |
| 186 | @ data-placeholder="%h(zInputPlaceholder0)" \ |
| 187 | @ class="chat-input-field hidden"></div> |
| 188 | @ <div id='chat-buttons-wrapper'> |
| 189 | @ <span class='cbutton' id="chat-button-preview" \ |
| 190 | @ title="Preview message (Shift-Enter)">👁</span> |
| 191 | @ <span class='cbutton' id="chat-button-search" \ |
| 192 | @ title="Search chat history">🔍</span> |
| 193 | @ <span class='cbutton' id="chat-button-attach" \ |
| 194 | @ title="Attach file to message">📎</span> |
| 195 | @ <span class='cbutton' id="chat-button-settings" \ |
| 196 | @ title="Configure chat">⚙</span> |
| 197 | @ <span class='cbutton' id="chat-button-submit" \ |
| 198 | @ title="Send message (Ctrl-Enter)">📤</span> |
| 199 | @ </div> |
| @@ -234,17 +219,25 @@ | |
| 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 | @ <div id='chat-config' class='hidden chat-view'> |
| 228 | @ <div id='chat-config-options'></div> |
| 229 | /* ^^^populated client-side */ |
| 230 | @ <div class='button-bar'><button class='action-close'>Close Settings</button></div> |
| 231 | @ </div> |
| 232 | @ <div id='chat-search' class='hidden chat-view'> |
| 233 | @ <div id='chat-search-content'></div> |
| 234 | /* ^^^populated client-side */ |
| 235 | @ <div class='button-bar'> |
| 236 | @ <button class='action-clear'>Clear results</button> |
| 237 | @ <button class='action-close'>Close Search</button> |
| 238 | @ </div> |
| 239 | @ </div> |
| 240 | @ <div id='chat-messages-wrapper' class='chat-view'> |
| 241 | /* New chat messages get inserted immediately after this element */ |
| 242 | @ <span id='message-inject-point'></span> |
| 243 | @ </div> |
| @@ -272,11 +265,12 @@ | |
| 265 | @ </script> |
| 266 | builtin_request_js("fossil.page.chat.js"); |
| 267 | style_finish_page(); |
| 268 | } |
| 269 | |
| 270 | /* |
| 271 | ** Definition of repository tables used by chat |
| 272 | */ |
| 273 | static const char zChatSchema1[] = |
| 274 | @ CREATE TABLE repository.chat( |
| 275 | @ msgid INTEGER PRIMARY KEY AUTOINCREMENT, |
| 276 | @ mtime JULIANDAY, -- Time for this entry - Julianday Zulu |
| @@ -290,12 +284,42 @@ | |
| 284 | @ ); |
| 285 | ; |
| 286 | |
| 287 | |
| 288 | /* |
| 289 | ** Create or rebuild the /chat search index. Requires that the |
| 290 | ** repository.chat table exists. If bForce is true, it will drop the |
| 291 | ** chatfts1 table and recreate/reindex it. If bForce is 0, it will |
| 292 | ** only index the chat content if the chatfts1 table does not already |
| 293 | ** exist. |
| 294 | */ |
| 295 | void chat_rebuild_index(int bForce){ |
| 296 | if( bForce!=0 ){ |
| 297 | db_multi_exec("DROP TABLE IF EXISTS chatfts1"); |
| 298 | } |
| 299 | if( bForce!=0 || !db_table_exists("repository", "chatfts1") ){ |
| 300 | const int tokType = search_tokenizer_type(0); |
| 301 | const char *zTokenizer = search_tokenize_arg_for_type( |
| 302 | tokType==FTS5TOK_NONE ? FTS5TOK_PORTER : tokType |
| 303 | /* Special case: if fts search is disabled for the main repo |
| 304 | ** content, use a default tokenizer here. */ |
| 305 | ); |
| 306 | assert( zTokenizer && zTokenizer[0] ); |
| 307 | db_multi_exec( |
| 308 | "CREATE VIRTUAL TABLE repository.chatfts1 USING fts5(" |
| 309 | " xmsg, content=chat, content_rowid=msgid%s" |
| 310 | ");" |
| 311 | "INSERT INTO repository.chatfts1(chatfts1) VALUES('rebuild');", |
| 312 | zTokenizer/*safe-for-%s*/ |
| 313 | ); |
| 314 | } |
| 315 | } |
| 316 | |
| 317 | /* |
| 318 | ** Make sure the repository data tables used by chat exist. Create |
| 319 | ** them if they do not. Set up TEMP triggers (if needed) to update the |
| 320 | ** chatfts1 table as the chat table is updated. |
| 321 | */ |
| 322 | static void chat_create_tables(void){ |
| 323 | if( !db_table_exists("repository","chat") ){ |
| 324 | db_multi_exec(zChatSchema1/*works-like:""*/); |
| 325 | }else if( !db_table_has_column("repository","chat","lmtime") ){ |
| @@ -302,10 +326,20 @@ | |
| 326 | if( !db_table_has_column("repository","chat","mdel") ){ |
| 327 | db_multi_exec("ALTER TABLE chat ADD COLUMN mdel INT"); |
| 328 | } |
| 329 | db_multi_exec("ALTER TABLE chat ADD COLUMN lmtime TEXT"); |
| 330 | } |
| 331 | chat_rebuild_index(0); |
| 332 | db_multi_exec( |
| 333 | "CREATE TEMP TRIGGER IF NOT EXISTS chat_ai AFTER INSERT ON chat BEGIN " |
| 334 | " INSERT INTO chatfts1(rowid, xmsg) VALUES(new.msgid, new.xmsg);" |
| 335 | "END;" |
| 336 | "CREATE TEMP TRIGGER IF NOT EXISTS chat_ad AFTER DELETE ON chat BEGIN " |
| 337 | " INSERT INTO chatfts1(chatfts1, rowid, xmsg) " |
| 338 | " VALUES('delete', old.msgid, old.xmsg);" |
| 339 | "END;" |
| 340 | ); |
| 341 | } |
| 342 | |
| 343 | /* |
| 344 | ** Delete old content from the chat table. |
| 345 | */ |
| @@ -453,28 +487,101 @@ | |
| 487 | } |
| 488 | |
| 489 | /* |
| 490 | ** COMMAND: test-chat-formatter |
| 491 | ** |
| 492 | ** Usage: %fossil test-chat-formatter ?OPTIONS? STRING ... |
| 493 | ** |
| 494 | ** Transform each argument string into HTML that will display the |
| 495 | ** chat message. This is used to test the formatter and to verify |
| 496 | ** that a malicious message text will not cause HTML or JS injection |
| 497 | ** into the chat display in a browser. |
| 498 | ** |
| 499 | ** Options: |
| 500 | ** |
| 501 | ** -w|--wiki Assume fossil wiki format instead of markdown |
| 502 | */ |
| 503 | void chat_test_formatter_cmd(void){ |
| 504 | int i; |
| 505 | char *zOut; |
| 506 | int const isWiki = find_option("w","wiki",0)!=0; |
| 507 | db_find_and_open_repository(0,0); |
| 508 | g.perm.Hyperlink = 1; |
| 509 | for(i=2; i<g.argc; i++){ |
| 510 | zOut = chat_format_to_html(g.argv[i], isWiki); |
| 511 | fossil_print("[%d]: %s\n", i-1, zOut); |
| 512 | fossil_free(zOut); |
| 513 | } |
| 514 | } |
| 515 | |
| 516 | /* |
| 517 | ** |
| 518 | */ |
| 519 | static int chat_poll_rowstojson( |
| 520 | Stmt *p, /* Statement to read rows from */ |
| 521 | const char *zChatUser, /* Current user */ |
| 522 | int bRaw, /* True to return raw format xmsg */ |
| 523 | Blob *pJson /* Append json array entries here */ |
| 524 | ){ |
| 525 | int cnt = 0; |
| 526 | while( db_step(p)==SQLITE_ROW ){ |
| 527 | int isWiki = 0; /* True if chat message is x-fossil-wiki */ |
| 528 | int id = db_column_int(p, 0); |
| 529 | const char *zDate = db_column_text(p, 1); |
| 530 | const char *zFrom = db_column_text(p, 2); |
| 531 | const char *zRawMsg = db_column_text(p, 3); |
| 532 | int nByte = db_column_int(p, 4); |
| 533 | const char *zFName = db_column_text(p, 5); |
| 534 | const char *zFMime = db_column_text(p, 6); |
| 535 | int iToDel = db_column_int(p, 7); |
| 536 | const char *zLMtime = db_column_text(p, 8); |
| 537 | char *zMsg; |
| 538 | if(cnt++){ |
| 539 | blob_append(pJson, ",\n", 2); |
| 540 | } |
| 541 | blob_appendf(pJson, "{\"msgid\":%d,", id); |
| 542 | blob_appendf(pJson, "\"mtime\":\"%.10sT%sZ\",", zDate, zDate+11); |
| 543 | if( zLMtime && zLMtime[0] ){ |
| 544 | blob_appendf(pJson, "\"lmtime\":%!j,", zLMtime); |
| 545 | } |
| 546 | blob_append(pJson, "\"xfrom\":", -1); |
| 547 | if(zFrom){ |
| 548 | blob_appendf(pJson, "%!j,", zFrom); |
| 549 | isWiki = fossil_strcmp(zFrom,zChatUser)==0; |
| 550 | }else{ |
| 551 | /* see https://fossil-scm.org/forum/forumpost/e0be0eeb4c */ |
| 552 | blob_appendf(pJson, "null,"); |
| 553 | isWiki = 0; |
| 554 | } |
| 555 | blob_appendf(pJson, "\"uclr\":%!j,", |
| 556 | isWiki ? "transparent" : user_color(zFrom ? zFrom : "nobody")); |
| 557 | |
| 558 | if(bRaw){ |
| 559 | blob_appendf(pJson, "\"xmsg\":%!j,", zRawMsg); |
| 560 | }else{ |
| 561 | zMsg = chat_format_to_html(zRawMsg ? zRawMsg : "", isWiki); |
| 562 | blob_appendf(pJson, "\"xmsg\":%!j,", zMsg); |
| 563 | fossil_free(zMsg); |
| 564 | } |
| 565 | |
| 566 | if( nByte==0 ){ |
| 567 | blob_appendf(pJson, "\"fsize\":0"); |
| 568 | }else{ |
| 569 | blob_appendf(pJson, "\"fsize\":%d,\"fname\":%!j,\"fmime\":%!j", |
| 570 | nByte, zFName, zFMime); |
| 571 | } |
| 572 | |
| 573 | if( iToDel ){ |
| 574 | blob_appendf(pJson, ",\"mdel\":%d}", iToDel); |
| 575 | }else{ |
| 576 | blob_append(pJson, "}", 1); |
| 577 | } |
| 578 | } |
| 579 | db_reset(p); |
| 580 | |
| 581 | return cnt; |
| 582 | } |
| 583 | |
| 584 | /* |
| 585 | ** WEBPAGE: chat-poll hidden loadavg-exempt |
| 586 | ** |
| 587 | ** The chat page generated by /chat using an XHR to this page to |
| @@ -570,11 +677,10 @@ | |
| 677 | Blob json; /* The json to be constructed and returned */ |
| 678 | sqlite3_int64 dataVersion; /* Data version. Used for polling. */ |
| 679 | const int iDelay = 1000; /* Delay until next poll (milliseconds) */ |
| 680 | int nDelay; /* Maximum delay.*/ |
| 681 | const char *zChatUser; /* chat-timeline-user */ |
| 682 | int msgid = atoi(PD("name","0")); |
| 683 | const int msgBefore = atoi(PD("before","0")); |
| 684 | int nLimit = msgBefore>0 ? atoi(PD("n","0")) : 0; |
| 685 | const int bRaw = P("raw")!=0; |
| 686 | |
| @@ -624,64 +730,11 @@ | |
| 730 | } |
| 731 | db_prepare(&q1, "%s", blob_sql_text(&sql)); |
| 732 | blob_reset(&sql); |
| 733 | blob_init(&json, "{\"msgs\":[\n", -1); |
| 734 | while( nDelay>0 ){ |
| 735 | int cnt = chat_poll_rowstojson(&q1, zChatUser, bRaw, &json); |
| 736 | if( cnt || msgBefore>0 ){ |
| 737 | break; |
| 738 | } |
| 739 | sqlite3_sleep(iDelay); nDelay--; |
| 740 | while( nDelay>0 ){ |
| @@ -697,10 +750,77 @@ | |
| 750 | blob_append(&json, "\n]}", 3); |
| 751 | cgi_set_content(&json); |
| 752 | return; |
| 753 | } |
| 754 | |
| 755 | |
| 756 | /* |
| 757 | ** WEBPAGE: chat-query hidden loadavg-exempt |
| 758 | */ |
| 759 | void chat_query_webpage(void){ |
| 760 | Blob json; /* The json to be constructed and returned */ |
| 761 | Blob sql = empty_blob; |
| 762 | Stmt q1; |
| 763 | int nLimit = atoi(PD("n","500")); |
| 764 | int iFirst = atoi(PD("i","0")); |
| 765 | const char *zQuery = PD("q", ""); |
| 766 | i64 iMin = 0; |
| 767 | i64 iMax = 0; |
| 768 | |
| 769 | login_check_credentials(); |
| 770 | if( !g.perm.Chat ) { |
| 771 | chat_emit_permissions_error(1); |
| 772 | return; |
| 773 | } |
| 774 | chat_create_tables(); |
| 775 | cgi_set_content_type("application/json"); |
| 776 | |
| 777 | if( zQuery[0] ){ |
| 778 | iMax = db_int64(0, "SELECT max(msgid) FROM chat"); |
| 779 | iMin = db_int64(0, "SELECT min(msgid) FROM chat"); |
| 780 | if( '#'==zQuery[0] ){ |
| 781 | /* Assume we're looking for an exact msgid match. */ |
| 782 | ++zQuery; |
| 783 | blob_append_sql(&sql, |
| 784 | "SELECT msgid, datetime(mtime), xfrom, " |
| 785 | " xmsg, octet_length(file), fname, fmime, mdel, lmtime " |
| 786 | " FROM chat WHERE msgid=+%Q", |
| 787 | zQuery |
| 788 | ); |
| 789 | }else{ |
| 790 | char * zPat = search_simplify_pattern(zQuery); |
| 791 | blob_append_sql(&sql, |
| 792 | "SELECT * FROM (" |
| 793 | "SELECT c.msgid, datetime(c.mtime), c.xfrom, " |
| 794 | " highlight(chatfts1, 0, '<span class=\"match\">', '</span>'), " |
| 795 | " octet_length(c.file), c.fname, c.fmime, c.mdel, c.lmtime " |
| 796 | " FROM chatfts1(%Q) f, chat c " |
| 797 | " WHERE f.rowid=c.msgid" |
| 798 | " ORDER BY f.rowid DESC LIMIT %d" |
| 799 | ") ORDER BY 1 ASC", zPat, nLimit |
| 800 | ); |
| 801 | fossil_free(zPat); |
| 802 | } |
| 803 | }else{ |
| 804 | blob_append_sql(&sql, |
| 805 | "SELECT msgid, datetime(mtime), xfrom, " |
| 806 | " xmsg, octet_length(file), fname, fmime, mdel, lmtime" |
| 807 | " FROM chat WHERE msgid>=%d LIMIT %d", |
| 808 | iFirst, nLimit |
| 809 | ); |
| 810 | } |
| 811 | |
| 812 | db_prepare(&q1, "%s", blob_sql_text(&sql)); |
| 813 | blob_reset(&sql); |
| 814 | blob_init(&json, "{\"msgs\":[\n", -1); |
| 815 | chat_poll_rowstojson(&q1, "", 0, &json); |
| 816 | db_finalize(&q1); |
| 817 | blob_appendf(&json, "\n], \"first\":%lld, \"last\":%lld}", iMin, iMax); |
| 818 | cgi_set_content(&json); |
| 819 | return; |
| 820 | } |
| 821 | |
| 822 | /* |
| 823 | ** WEBPAGE: chat-fetch-one hidden loadavg-exempt |
| 824 | ** |
| 825 | ** /chat-fetch-one/N |
| 826 | ** |
| 827 |
+371
-48
| --- src/fossil.page.chat.js | ||
| +++ src/fossil.page.chat.js | ||
| @@ -145,10 +145,12 @@ | ||
| 145 | 145 | inputFile: E1('#chat-input-file'), |
| 146 | 146 | contentDiv: E1('div.content'), |
| 147 | 147 | viewConfig: E1('#chat-config'), |
| 148 | 148 | viewPreview: E1('#chat-preview'), |
| 149 | 149 | previewContent: E1('#chat-preview-content'), |
| 150 | + viewSearch: E1('#chat-search'), | |
| 151 | + searchContent: E1('#chat-search-content'), | |
| 150 | 152 | btnPreview: E1('#chat-button-preview'), |
| 151 | 153 | views: document.querySelectorAll('.chat-view'), |
| 152 | 154 | activeUserListWrapper: E1('#chat-user-list-wrapper'), |
| 153 | 155 | activeUserList: E1('#chat-user-list'), |
| 154 | 156 | btnClearFilter: E1('#chat-clear-filter') |
| @@ -190,21 +192,31 @@ | ||
| 190 | 192 | || !!e.querySelector('[data-hashtag="'+this.activeTag+'"]'); |
| 191 | 193 | } |
| 192 | 194 | }, |
| 193 | 195 | current: undefined/*gets set to current active filter*/ |
| 194 | 196 | }, |
| 195 | - /** Gets (no args) or sets (1 arg) the current input text field value, | |
| 196 | - taking into account single- vs multi-line input. The getter returns | |
| 197 | - a string and the setter returns this object. */ | |
| 198 | - inputValue: function(){ | |
| 197 | + /** | |
| 198 | + Gets (no args) or sets (1 arg) the current input text field | |
| 199 | + value, taking into account single- vs multi-line input. The | |
| 200 | + getter returns a trim()'d string and the setter returns this | |
| 201 | + object. As a special case, if arguments[0] is a boolean | |
| 202 | + value, it behaves like a getter and, if arguments[0]===true | |
| 203 | + it clears the input field before returning. | |
| 204 | + */ | |
| 205 | + inputValue: function(/*string newValue | bool clearInputField*/){ | |
| 199 | 206 | const e = this.inputElement(); |
| 200 | - if(arguments.length){ | |
| 207 | + if(arguments.length && 'boolean'!==typeof arguments[0]){ | |
| 201 | 208 | if(e.isContentEditable) e.innerText = arguments[0]; |
| 202 | 209 | else e.value = arguments[0]; |
| 203 | 210 | return this; |
| 204 | 211 | } |
| 205 | - return e.isContentEditable ? e.innerText : e.value; | |
| 212 | + const rc = e.isContentEditable ? e.innerText : e.value; | |
| 213 | + if( true===arguments[0] ){ | |
| 214 | + if(e.isContentEditable) e.innerText = ''; | |
| 215 | + else e.value = ''; | |
| 216 | + } | |
| 217 | + return rc && rc.trim(); | |
| 206 | 218 | }, |
| 207 | 219 | /** Asks the current user input field to take focus. Returns this. */ |
| 208 | 220 | inputFocus: function(){ |
| 209 | 221 | this.inputElement().focus(); |
| 210 | 222 | return this; |
| @@ -529,11 +541,11 @@ | ||
| 529 | 541 | const uDate = self.usersLastSeen[u]; |
| 530 | 542 | if(self.filter.user.activeTag===u){ |
| 531 | 543 | uSpan.classList.add('selected'); |
| 532 | 544 | } |
| 533 | 545 | uSpan.dataset.uname = u; |
| 534 | - D.append(uSpan, u, "\n", | |
| 546 | + D.append(uSpan, u, "\n", | |
| 535 | 547 | D.append( |
| 536 | 548 | D.addClass(D.span(),'timestamp'), |
| 537 | 549 | localTimeString(uDate)//.substr(5/*chop off year*/) |
| 538 | 550 | )); |
| 539 | 551 | if(uDate.$uColor){ |
| @@ -1075,11 +1087,11 @@ | ||
| 1075 | 1087 | Chat.MessageWidget = (function(){ |
| 1076 | 1088 | /** |
| 1077 | 1089 | Constructor. If passed an argument, it is passed to |
| 1078 | 1090 | this.setMessage() after initialization. |
| 1079 | 1091 | */ |
| 1080 | - const cf = function(){ | |
| 1092 | + const ctor = function(){ | |
| 1081 | 1093 | this.e = { |
| 1082 | 1094 | body: D.addClass(D.div(), 'message-widget'), |
| 1083 | 1095 | tab: D.addClass(D.div(), 'message-widget-tab'), |
| 1084 | 1096 | content: D.addClass(D.div(), 'message-widget-content') |
| 1085 | 1097 | }; |
| @@ -1094,20 +1106,33 @@ | ||
| 1094 | 1106 | /* Map of Date.getDay() values to weekday names. */ |
| 1095 | 1107 | 0: "Sunday", 1: "Monday", 2: "Tuesday", |
| 1096 | 1108 | 3: "Wednesday", 4: "Thursday", 5: "Friday", |
| 1097 | 1109 | 6: "Saturday" |
| 1098 | 1110 | }; |
| 1099 | - /* Given a Date, returns the timestamp string used in the | |
| 1100 | - "tab" part of message widgets. */ | |
| 1101 | - const theTime = function(d){ | |
| 1102 | - return [ | |
| 1103 | - //d.getFullYear(),'-',pad2(d.getMonth()+1/*sigh*/), | |
| 1104 | - //'-',pad2(d.getDate()), ' ', | |
| 1105 | - d.getHours(),":", | |
| 1106 | - (d.getMinutes()+100).toString().slice(1,3), | |
| 1107 | - ' ', dowMap[d.getDay()] | |
| 1108 | - ].join(''); | |
| 1111 | + /* Given a Date, returns the timestamp string used in the "tab" | |
| 1112 | + part of message widgets. If longFmt is true then a verbose | |
| 1113 | + format is used, else a brief format is used. The returned string | |
| 1114 | + is in client-local time. */ | |
| 1115 | + const theTime = function(d, longFmt=false){ | |
| 1116 | + const li = []; | |
| 1117 | + if( longFmt ){ | |
| 1118 | + li.push( | |
| 1119 | + d.getFullYear(), | |
| 1120 | + '-', pad2(d.getMonth()+1), | |
| 1121 | + '-', pad2(d.getDate()), | |
| 1122 | + ' ', | |
| 1123 | + d.getHours(), ":", | |
| 1124 | + (d.getMinutes()+100).toString().slice(1,3) | |
| 1125 | + ); | |
| 1126 | + }else{ | |
| 1127 | + li.push( | |
| 1128 | + d.getHours(),":", | |
| 1129 | + (d.getMinutes()+100).toString().slice(1,3), | |
| 1130 | + ' ', dowMap[d.getDay()] | |
| 1131 | + ); | |
| 1132 | + } | |
| 1133 | + return li.join(''); | |
| 1109 | 1134 | }; |
| 1110 | 1135 | |
| 1111 | 1136 | /** |
| 1112 | 1137 | Returns true if this page believes it can embed a view of the |
| 1113 | 1138 | file wrapped by the given message object, else returns false. |
| @@ -1114,19 +1139,20 @@ | ||
| 1114 | 1139 | */ |
| 1115 | 1140 | const canEmbedFile = function f(msg){ |
| 1116 | 1141 | if(!f.$rx){ |
| 1117 | 1142 | f.$rx = /\.((html?)|(txt)|(md)|(wiki)|(pikchr))$/i; |
| 1118 | 1143 | f.$specificTypes = [ |
| 1144 | + /* Mime types we know we can embed, sans image/... */ | |
| 1119 | 1145 | 'text/plain', |
| 1120 | 1146 | 'text/html', |
| 1121 | 1147 | 'text/x-markdown', |
| 1122 | 1148 | /* Firefox sends text/markdown when uploading .md files */ |
| 1123 | 1149 | 'text/markdown', |
| 1124 | 1150 | 'text/x-pikchr', |
| 1125 | 1151 | 'text/x-fossil-wiki' |
| 1126 | - // add more as we discover which ones Firefox won't | |
| 1127 | - // force the user to try to download. | |
| 1152 | + /* Add more as we discover which ones Firefox won't | |
| 1153 | + force the user to try to download. */ | |
| 1128 | 1154 | ]; |
| 1129 | 1155 | } |
| 1130 | 1156 | if(msg.fmime){ |
| 1131 | 1157 | if(msg.fmime.startsWith("image/") |
| 1132 | 1158 | || f.$specificTypes.indexOf(msg.fmime)>=0){ |
| @@ -1140,20 +1166,18 @@ | ||
| 1140 | 1166 | Returns true if the given message object "should" |
| 1141 | 1167 | be embedded in fossil-rendered form instead of |
| 1142 | 1168 | raw content form. This is only intended to be passed |
| 1143 | 1169 | message objects for which canEmbedFile() returns true. |
| 1144 | 1170 | */ |
| 1145 | - const shouldWikiRenderEmbed = function f(msg){ | |
| 1171 | + const shouldFossilRenderEmbed = function f(msg){ | |
| 1146 | 1172 | if(!f.$rx){ |
| 1147 | 1173 | f.$rx = /\.((md)|(wiki)|(pikchr))$/i; |
| 1148 | 1174 | f.$specificTypes = [ |
| 1149 | 1175 | 'text/x-markdown', |
| 1150 | 1176 | 'text/markdown' /* Firefox-uploaded md files */, |
| 1151 | 1177 | 'text/x-pikchr', |
| 1152 | 1178 | 'text/x-fossil-wiki' |
| 1153 | - // add more as we discover which ones Firefox won't | |
| 1154 | - // force the user to try to download. | |
| 1155 | 1179 | ]; |
| 1156 | 1180 | } |
| 1157 | 1181 | if(msg.fmime){ |
| 1158 | 1182 | if(f.$specificTypes.indexOf(msg.fmime)>=0) return true; |
| 1159 | 1183 | } |
| @@ -1179,12 +1203,12 @@ | ||
| 1179 | 1203 | iframe.style.maxHeight = iframe.style.height |
| 1180 | 1204 | = iframe.contentWindow.document.documentElement.scrollHeight + 'px'; |
| 1181 | 1205 | if(isHidden) D.addClass(iframe, 'hidden'); |
| 1182 | 1206 | } |
| 1183 | 1207 | }; |
| 1184 | - | |
| 1185 | - cf.prototype = { | |
| 1208 | + | |
| 1209 | + ctor.prototype = { | |
| 1186 | 1210 | scrollIntoView: function(){ |
| 1187 | 1211 | this.e.content.scrollIntoView(); |
| 1188 | 1212 | }, |
| 1189 | 1213 | setMessage: function(m){ |
| 1190 | 1214 | const ds = this.e.body.dataset; |
| @@ -1205,20 +1229,26 @@ | ||
| 1205 | 1229 | var eXFrom /* element holding xfrom name */; |
| 1206 | 1230 | if(m.xfrom){ |
| 1207 | 1231 | eXFrom = D.append(D.addClass(D.span(), 'xfrom'), m.xfrom); |
| 1208 | 1232 | const wrapper = D.append( |
| 1209 | 1233 | D.span(), eXFrom, |
| 1210 | - D.text(" #",(m.msgid||'???'),' @ ',theTime(d))) | |
| 1234 | + ' ', | |
| 1235 | + D.append(D.addClass(D.span(), 'msgid'), | |
| 1236 | + '#' + (m.msgid||'???')), | |
| 1237 | + (m.isSearchResult ? ' ' : ' @ '), | |
| 1238 | + D.append(D.addClass(D.span(), 'timestamp'), | |
| 1239 | + theTime(d,!!m.isSearchResult)) | |
| 1240 | + ); | |
| 1211 | 1241 | D.append(this.e.tab, wrapper); |
| 1212 | 1242 | }else{/*notification*/ |
| 1213 | 1243 | D.addClass(this.e.body, 'notification'); |
| 1214 | 1244 | if(m.isError){ |
| 1215 | 1245 | D.addClass([contentTarget, this.e.tab], 'error'); |
| 1216 | 1246 | } |
| 1217 | 1247 | D.append( |
| 1218 | 1248 | this.e.tab, |
| 1219 | - D.append(D.code(), 'notification @ ',theTime(d)) | |
| 1249 | + D.append(D.code(), 'notification @ ',theTime(d,false)) | |
| 1220 | 1250 | ); |
| 1221 | 1251 | } |
| 1222 | 1252 | if( m.xfrom && m.fsize>0 ){ |
| 1223 | 1253 | if( m.fmime |
| 1224 | 1254 | && m.fmime.startsWith("image/") |
| @@ -1241,18 +1271,18 @@ | ||
| 1241 | 1271 | D.attr(a,'target','_blank'); |
| 1242 | 1272 | D.append(w, a); |
| 1243 | 1273 | if(canEmbedFile(m)){ |
| 1244 | 1274 | /* Add an option to embed HTML attachments in an iframe. The primary |
| 1245 | 1275 | use case is attached diffs. */ |
| 1246 | - const shouldWikiRender = shouldWikiRenderEmbed(m); | |
| 1247 | - const downloadArgs = shouldWikiRender ? '?render' : ''; | |
| 1276 | + const shouldFossilRender = shouldFossilRenderEmbed(m); | |
| 1277 | + const downloadArgs = shouldFossilRender ? '?render' : ''; | |
| 1248 | 1278 | D.addClass(contentTarget, 'wide'); |
| 1249 | 1279 | const embedTarget = this.e.content; |
| 1250 | 1280 | const self = this; |
| 1251 | 1281 | const btnEmbed = D.attr(D.checkbox("1", false), 'id', |
| 1252 | 1282 | 'embed-'+ds.msgid); |
| 1253 | - const btnLabel = D.label(btnEmbed, shouldWikiRender | |
| 1283 | + const btnLabel = D.label(btnEmbed, shouldFossilRender | |
| 1254 | 1284 | ? "Embed (fossil-rendered)" : "Embed"); |
| 1255 | 1285 | /* Maintenance reminder: do not disable the toggle |
| 1256 | 1286 | button while the content is loading because that will |
| 1257 | 1287 | cause it to get stuck in disabled mode if the browser |
| 1258 | 1288 | decides that loading the content should prompt the |
| @@ -1460,13 +1490,182 @@ | ||
| 1460 | 1490 | Chat.setCurrentView(Chat.e.viewMessages); |
| 1461 | 1491 | e.scrollIntoView(false); |
| 1462 | 1492 | Chat.animate(e, 'anim-fade-out-in'); |
| 1463 | 1493 | } |
| 1464 | 1494 | }; |
| 1465 | - return cf; | |
| 1495 | + return ctor; | |
| 1466 | 1496 | })()/*MessageWidget*/; |
| 1467 | 1497 | |
| 1498 | + /** | |
| 1499 | + A widget for loading more messages (context) around a /chat-query | |
| 1500 | + result message. | |
| 1501 | + */ | |
| 1502 | + Chat.SearchCtxLoader = (function(){ | |
| 1503 | + const nMsgContext = 5; | |
| 1504 | + const zUpArrow = '\u25B2'; | |
| 1505 | + const zDownArrow = '\u25BC'; | |
| 1506 | + const ctor = function(o){ | |
| 1507 | + | |
| 1508 | + /* iFirstInTable: | |
| 1509 | + ** msgid of first row in chatfts table. | |
| 1510 | + ** | |
| 1511 | + ** iLastInTable: | |
| 1512 | + ** msgid of last row in chatfts table. | |
| 1513 | + ** | |
| 1514 | + ** iPrevId: | |
| 1515 | + ** msgid of message immediately above this spacer. Or 0 if this | |
| 1516 | + ** spacer is above all results. | |
| 1517 | + ** | |
| 1518 | + ** iNextId: | |
| 1519 | + ** msgid of message immediately below this spacer. Or 0 if this | |
| 1520 | + ** spacer is below all results. | |
| 1521 | + ** | |
| 1522 | + ** bIgnoreClick: | |
| 1523 | + ** ignore any clicks if this is true. This is used to ensure there | |
| 1524 | + ** is only ever one request belonging to this widget outstanding | |
| 1525 | + ** at any time. | |
| 1526 | + */ | |
| 1527 | + this.o = { | |
| 1528 | + iFirstInTable: o.first, | |
| 1529 | + iLastInTable: o.last, | |
| 1530 | + iPrevId: o.previd, | |
| 1531 | + iNextId: o.nextid, | |
| 1532 | + bIgnoreClick: false | |
| 1533 | + }; | |
| 1534 | + | |
| 1535 | + this.e = { | |
| 1536 | + body: D.addClass(D.div(), 'spacer-widget'), | |
| 1537 | + up: D.addClass( | |
| 1538 | + D.button(zDownArrow+' Load '+nMsgContext+' more '+zDownArrow), | |
| 1539 | + 'up' | |
| 1540 | + ), | |
| 1541 | + down: D.addClass( | |
| 1542 | + D.button(zUpArrow+' Load '+nMsgContext+' more '+zUpArrow), | |
| 1543 | + 'down' | |
| 1544 | + ), | |
| 1545 | + all: D.addClass(D.button('Load More'), 'all') | |
| 1546 | + }; | |
| 1547 | + D.append( this.e.body, this.e.up, this.e.down, this.e.all ); | |
| 1548 | + const ms = this; | |
| 1549 | + this.e.up.addEventListener('click', ()=>ms.load_messages(false)); | |
| 1550 | + this.e.down.addEventListener('click', ()=>ms.load_messages(true)); | |
| 1551 | + this.e.all.addEventListener('click', ()=>ms.load_messages( (ms.o.iPrevId==0) )); | |
| 1552 | + this.set_button_visibility(); | |
| 1553 | + }; | |
| 1554 | + | |
| 1555 | + ctor.prototype = { | |
| 1556 | + set_button_visibility: function() { | |
| 1557 | + if( !this.e ) return; | |
| 1558 | + const o = this.o; | |
| 1559 | + | |
| 1560 | + const iPrevId = (o.iPrevId!=0) ? o.iPrevId : o.iFirstInTable-1; | |
| 1561 | + const iNextId = (o.iNextId!=0) ? o.iNextId : o.iLastInTable+1; | |
| 1562 | + let nDiff = (iNextId - iPrevId) - 1; | |
| 1563 | + | |
| 1564 | + for( const x of [this.e.up, this.e.down, this.e.all] ){ | |
| 1565 | + if( x ) D.addClass(x, 'hidden'); | |
| 1566 | + } | |
| 1567 | + let nVisible = 0; | |
| 1568 | + if( nDiff>0 ){ | |
| 1569 | + if( nDiff>nMsgContext && (o.iPrevId==0 || o.iNextId==0) ){ | |
| 1570 | + nDiff = nMsgContext; | |
| 1571 | + } | |
| 1572 | + | |
| 1573 | + if( nDiff<=nMsgContext && o.iPrevId!=0 && o.iNextId!=0 ){ | |
| 1574 | + D.removeClass(this.e.all, 'hidden'); | |
| 1575 | + ++nVisible; | |
| 1576 | + this.e.all.innerText = ( | |
| 1577 | + zUpArrow + " Load " + nDiff + " more " + zDownArrow | |
| 1578 | + ); | |
| 1579 | + }else{ | |
| 1580 | + if( o.iPrevId!=0 ){ | |
| 1581 | + ++nVisible; | |
| 1582 | + D.removeClass(this.e.up, 'hidden'); | |
| 1583 | + }else if( this.e.up ){ | |
| 1584 | + if( this.e.up.parentNode ) D.remove(this.e.up); | |
| 1585 | + delete this.e.up; | |
| 1586 | + } | |
| 1587 | + if( o.iNextId!=0 ){ | |
| 1588 | + ++nVisible; | |
| 1589 | + D.removeClass(this.e.down, 'hidden'); | |
| 1590 | + }else if( this.e.down ){ | |
| 1591 | + if( this.e.down.parentNode ) D.remove( this.e.down ); | |
| 1592 | + delete this.e.down; | |
| 1593 | + } | |
| 1594 | + } | |
| 1595 | + } | |
| 1596 | + if( !nVisible ){ | |
| 1597 | + /* The DOM elements can now be disposed of. */ | |
| 1598 | + for( const x of [this.e.up, this.e.down, this.e.all, this.e.body] ){ | |
| 1599 | + if( x?.parentNode ) D.remove(x); | |
| 1600 | + } | |
| 1601 | + delete this.e; | |
| 1602 | + } | |
| 1603 | + }, | |
| 1604 | + | |
| 1605 | + load_messages: function(bDown) { | |
| 1606 | + if( this.bIgnoreClick ) return; | |
| 1607 | + | |
| 1608 | + var iFirst = 0; /* msgid of first message to fetch */ | |
| 1609 | + var nFetch = 0; /* Number of messages to fetch */ | |
| 1610 | + var iEof = 0; /* last msgid in spacers range, plus 1 */ | |
| 1611 | + | |
| 1612 | + const e = this.e, o = this.o; | |
| 1613 | + this.bIgnoreClick = true; | |
| 1614 | + | |
| 1615 | + /* Figure out the required range of messages. */ | |
| 1616 | + if( bDown ){ | |
| 1617 | + iFirst = this.o.iNextId - nMsgContext; | |
| 1618 | + if( iFirst<this.o.iFirstInTable ){ | |
| 1619 | + iFirst = this.o.iFirstInTable; | |
| 1620 | + } | |
| 1621 | + }else{ | |
| 1622 | + iFirst = this.o.iPrevId+1; | |
| 1623 | + } | |
| 1624 | + nFetch = nMsgContext; | |
| 1625 | + iEof = (this.o.iNextId > 0) ? this.o.iNextId : this.o.iLastInTable+1; | |
| 1626 | + if( iFirst+nFetch>iEof ){ | |
| 1627 | + nFetch = iEof - iFirst; | |
| 1628 | + } | |
| 1629 | + const ms = this; | |
| 1630 | + F.fetch("chat-query",{ | |
| 1631 | + urlParams:{ | |
| 1632 | + q: '', | |
| 1633 | + n: nFetch, | |
| 1634 | + i: iFirst | |
| 1635 | + }, | |
| 1636 | + responseType: "json", | |
| 1637 | + onload:function(jx){ | |
| 1638 | + if( bDown ) jx.msgs.reverse(); | |
| 1639 | + jx.msgs.forEach((m) => { | |
| 1640 | + var mw = new Chat.MessageWidget(m); | |
| 1641 | + if( bDown ){ | |
| 1642 | + /* Inject the message below this object's body, or | |
| 1643 | + append it to Chat.e.searchContent if this element | |
| 1644 | + is the final one in its parent (Chat.e.searchContent). */ | |
| 1645 | + const eAnchor = e.body.nextElementSibling; | |
| 1646 | + if( eAnchor ) Chat.e.searchContent.insertBefore(mw.e.body, eAnchor); | |
| 1647 | + else D.append(Chat.e.searchContent, mw.e.body); | |
| 1648 | + }else{ | |
| 1649 | + Chat.e.searchContent.insertBefore(mw.e.body, e.body); | |
| 1650 | + } | |
| 1651 | + }); | |
| 1652 | + if( bDown ){ | |
| 1653 | + o.iNextId -= jx.msgs.length; | |
| 1654 | + }else{ | |
| 1655 | + o.iPrevId += jx.msgs.length; | |
| 1656 | + } | |
| 1657 | + ms.set_button_visibility(); | |
| 1658 | + ms.bIgnoreClick = false; | |
| 1659 | + } | |
| 1660 | + }); | |
| 1661 | + } | |
| 1662 | + }; | |
| 1663 | + | |
| 1664 | + return ctor; | |
| 1665 | + })() /*SearchCtxLoader*/; | |
| 1666 | + | |
| 1468 | 1667 | const BlobXferState = (function(){ |
| 1469 | 1668 | /* State for paste and drag/drop */ |
| 1470 | 1669 | const bxs = { |
| 1471 | 1670 | dropDetails: document.querySelector('#chat-drop-details'), |
| 1472 | 1671 | blob: undefined, |
| @@ -1605,16 +1804,26 @@ | ||
| 1605 | 1804 | |
| 1606 | 1805 | /** |
| 1607 | 1806 | Submits the contents of the message input field (if not empty) |
| 1608 | 1807 | and/or the file attachment field to the server. If both are |
| 1609 | 1808 | empty, this is a no-op. |
| 1809 | + | |
| 1810 | + If the current view is the history search, this instead sends the | |
| 1811 | + input text to that widget. | |
| 1610 | 1812 | */ |
| 1611 | 1813 | Chat.submitMessage = function f(){ |
| 1612 | 1814 | if(!f.spaces){ |
| 1613 | 1815 | f.spaces = /\s+$/; |
| 1614 | 1816 | f.markdownContinuation = /\\\s+$/; |
| 1615 | 1817 | f.spaces2 = /\s{3,}$/; |
| 1818 | + } | |
| 1819 | + switch( this.e.currentView ){ | |
| 1820 | + case this.e.viewSearch: this.submitSearch(); | |
| 1821 | + return; | |
| 1822 | + case this.e.viewPreview: this.e.btnPreview.click(); | |
| 1823 | + return; | |
| 1824 | + default: break; | |
| 1616 | 1825 | } |
| 1617 | 1826 | this.setCurrentView(this.e.viewMessages); |
| 1618 | 1827 | const fd = new FormData(); |
| 1619 | 1828 | const fallback = {msg: this.inputValue()}; |
| 1620 | 1829 | var msg = fallback.msg; |
| @@ -1687,14 +1896,16 @@ | ||
| 1687 | 1896 | //console.debug("Enter key event:", ctrlMode, ev.ctrlKey, ev.shiftKey, ev); |
| 1688 | 1897 | if(ev.shiftKey){ |
| 1689 | 1898 | const compactMode = Chat.settings.getBool('edit-compact-mode', false); |
| 1690 | 1899 | ev.preventDefault(); |
| 1691 | 1900 | ev.stopPropagation(); |
| 1692 | - /* Shift-enter will run preview mode UNLESS preview mode is | |
| 1693 | - active AND the input field is empty, in which case it will | |
| 1901 | + /* Shift-enter will run preview mode UNLESS the input field is empty | |
| 1902 | + AND (preview or search mode) is active, in which cases it will | |
| 1694 | 1903 | switch back to message view. */ |
| 1695 | - if(Chat.e.currentView===Chat.e.viewPreview && !text){ | |
| 1904 | + if(!text && | |
| 1905 | + (Chat.e.currentView===Chat.e.viewPreview | |
| 1906 | + | Chat.e.currentView===Chat.e.viewSearch)){ | |
| 1696 | 1907 | Chat.setCurrentView(Chat.e.viewMessages); |
| 1697 | 1908 | }else if(!text){ |
| 1698 | 1909 | f.$toggleCompact(compactMode); |
| 1699 | 1910 | }else if(Chat.settings.getBool('edit-shift-enter-preview', true)){ |
| 1700 | 1911 | Chat.e.btnPreview.click(); |
| @@ -1752,19 +1963,19 @@ | ||
| 1752 | 1963 | tall vs wide. Can be toggled via settings. */ |
| 1753 | 1964 | document.body.classList.add('my-messages-right'); |
| 1754 | 1965 | } |
| 1755 | 1966 | const settingsButton = document.querySelector('#chat-button-settings'); |
| 1756 | 1967 | const optionsMenu = E1('#chat-config-options'); |
| 1757 | - const cbToggle = function(ev){ | |
| 1968 | + const eToggleView = function(ev){ | |
| 1758 | 1969 | ev.preventDefault(); |
| 1759 | 1970 | ev.stopPropagation(); |
| 1760 | 1971 | Chat.setCurrentView(Chat.e.currentView===Chat.e.viewConfig |
| 1761 | 1972 | ? Chat.e.viewMessages : Chat.e.viewConfig); |
| 1762 | 1973 | return false; |
| 1763 | 1974 | }; |
| 1764 | - D.attr(settingsButton, 'role', 'button').addEventListener('click', cbToggle, false); | |
| 1765 | - Chat.e.viewConfig.querySelector('button').addEventListener('click', cbToggle, false); | |
| 1975 | + D.attr(settingsButton, 'role', 'button').addEventListener('click', eToggleView, false); | |
| 1976 | + Chat.e.viewConfig.querySelector('button.action-close').addEventListener('click', eToggleView, false); | |
| 1766 | 1977 | |
| 1767 | 1978 | /** Internal acrobatics to allow certain settings toggles to access |
| 1768 | 1979 | related toggles. */ |
| 1769 | 1980 | const namedOptions = { |
| 1770 | 1981 | activeUsers:{ |
| @@ -1850,12 +2061,13 @@ | ||
| 1850 | 2061 | boolValue: 'edit-ctrl-send' |
| 1851 | 2062 | },{ |
| 1852 | 2063 | label: "Compact mode", |
| 1853 | 2064 | hint: [ |
| 1854 | 2065 | "Toggle between a space-saving or more spacious writing area. ", |
| 1855 | - "When the input field has focus, is empty, and preview mode ", | |
| 1856 | - "is NOT active then Shift-Enter toggles this setting."].join(''), | |
| 2066 | + "When the input field has focus and is empty ", | |
| 2067 | + "then Shift-Enter may (depending on the current view) toggle this setting." | |
| 2068 | + ].join(''), | |
| 1857 | 2069 | boolValue: 'edit-compact-mode' |
| 1858 | 2070 | },{ |
| 1859 | 2071 | label: "Use 'contenteditable' editing mode", |
| 1860 | 2072 | boolValue: 'edit-widget-x', |
| 1861 | 2073 | hint: [ |
| @@ -2020,11 +2232,11 @@ | ||
| 2020 | 2232 | op.persistentSetting, |
| 2021 | 2233 | function(setting){ |
| 2022 | 2234 | if(op.checkbox) op.checkbox.checked = !!setting.value; |
| 2023 | 2235 | else if(op.select) op.select.value = setting.value; |
| 2024 | 2236 | if(op.callback) op.callback(setting); |
| 2025 | - } | |
| 2237 | + } | |
| 2026 | 2238 | ); |
| 2027 | 2239 | if(op.checkbox){ |
| 2028 | 2240 | op.checkbox.addEventListener( |
| 2029 | 2241 | 'change', function(){ |
| 2030 | 2242 | Chat.settings.set(op.persistentSetting, op.checkbox.checked) |
| @@ -2096,11 +2308,11 @@ | ||
| 2096 | 2308 | s.value ? 'add' : 'remove' |
| 2097 | 2309 | ]('compact'); |
| 2098 | 2310 | Chat.e.inputFields[Chat.e.inputFields.$currentIndex].focus(); |
| 2099 | 2311 | }); |
| 2100 | 2312 | Chat.settings.addListener('edit-ctrl-send',function(s){ |
| 2101 | - const label = (s.value ? "Ctrl-" : "")+"Enter submits messages."; | |
| 2313 | + const label = (s.value ? "Ctrl-" : "")+"Enter submits message"; | |
| 2102 | 2314 | Chat.e.inputFields.forEach((e)=>{ |
| 2103 | 2315 | const v = e.dataset.placeholder0 + " " +label; |
| 2104 | 2316 | if(e.isContentEditable) e.dataset.placeholder = v; |
| 2105 | 2317 | else D.attr(e,'placeholder',v); |
| 2106 | 2318 | }); |
| @@ -2128,11 +2340,11 @@ | ||
| 2128 | 2340 | this.e.previewContent.innerHTML = t; |
| 2129 | 2341 | this.e.viewPreview.querySelectorAll('a').forEach(addAnchorTargetBlank); |
| 2130 | 2342 | setupHashtags(this.e.previewContent)/*arguable, for usability reasons*/; |
| 2131 | 2343 | this.inputFocus(); |
| 2132 | 2344 | }; |
| 2133 | - Chat.e.viewPreview.querySelector('#chat-preview-close'). | |
| 2345 | + Chat.e.viewPreview.querySelector('button.action-close'). | |
| 2134 | 2346 | addEventListener('click', ()=>Chat.setCurrentView(Chat.e.viewMessages), false); |
| 2135 | 2347 | let previewPending = false; |
| 2136 | 2348 | const elemsToEnable = [btnPreview, Chat.e.btnSubmit, Chat.e.inputFields]; |
| 2137 | 2349 | const submit = function(ev){ |
| 2138 | 2350 | ev.preventDefault(); |
| @@ -2175,10 +2387,40 @@ | ||
| 2175 | 2387 | }); |
| 2176 | 2388 | return false; |
| 2177 | 2389 | }; |
| 2178 | 2390 | btnPreview.addEventListener('click', submit, false); |
| 2179 | 2391 | })()/*message preview setup*/; |
| 2392 | + | |
| 2393 | + (function(){/*Set up #chat-search and related bits */ | |
| 2394 | + const btn = document.querySelector('#chat-button-search'); | |
| 2395 | + D.attr(btn, 'role', 'button').addEventListener('click', function(ev){ | |
| 2396 | + ev.preventDefault(); | |
| 2397 | + ev.stopPropagation(); | |
| 2398 | + const msg = Chat.inputValue(); | |
| 2399 | + if( Chat.e.currentView===Chat.e.viewSearch ){ | |
| 2400 | + if( msg ) Chat.submitSearch(); | |
| 2401 | + else Chat.setCurrentView(Chat.e.viewMessages); | |
| 2402 | + }else{ | |
| 2403 | + Chat.setCurrentView(Chat.e.viewSearch); | |
| 2404 | + if( msg ) Chat.submitSearch(); | |
| 2405 | + } | |
| 2406 | + return false; | |
| 2407 | + }, false); | |
| 2408 | + Chat.e.viewSearch.querySelector('button.action-clear').addEventListener('click', function(ev){ | |
| 2409 | + ev.preventDefault(); | |
| 2410 | + ev.stopPropagation(); | |
| 2411 | + Chat.clearSearch(true); | |
| 2412 | + Chat.setCurrentView(Chat.e.viewMessages); | |
| 2413 | + return false; | |
| 2414 | + }, false); | |
| 2415 | + Chat.e.viewSearch.querySelector('button.action-close').addEventListener('click', function(ev){ | |
| 2416 | + ev.preventDefault(); | |
| 2417 | + ev.stopPropagation(); | |
| 2418 | + Chat.setCurrentView(Chat.e.viewMessages); | |
| 2419 | + return false; | |
| 2420 | + }, false); | |
| 2421 | + })()/*search view setup*/; | |
| 2180 | 2422 | |
| 2181 | 2423 | /** Callback for poll() to inject new content into the page. jx == |
| 2182 | 2424 | the response from /chat-poll. If atEnd is true, the message is |
| 2183 | 2425 | appended to the end of the chat list (for loading older |
| 2184 | 2426 | messages), else the beginning (the default). */ |
| @@ -2307,10 +2549,82 @@ | ||
| 2307 | 2549 | btn.addEventListener('click',()=>loadOldMessages(-1)); |
| 2308 | 2550 | D.append(Chat.e.viewMessages, toolbar); |
| 2309 | 2551 | toolbar.disabled = true /*will be enabled when msg load finishes */; |
| 2310 | 2552 | })()/*end history loading widget setup*/; |
| 2311 | 2553 | |
| 2554 | + /** | |
| 2555 | + Clears the search result view. If addInstructions is true it adds | |
| 2556 | + text to that view instructing the user to enter their query into | |
| 2557 | + the message-entry widget (noting that that widget has text | |
| 2558 | + implying that it's only for submitting a message, which isn't | |
| 2559 | + exactly true when the search view is active). | |
| 2560 | + | |
| 2561 | + Returns the DOM element which wraps all of the chat search | |
| 2562 | + result elements. | |
| 2563 | + */ | |
| 2564 | + Chat.clearSearch = function(addInstructions=false){ | |
| 2565 | + const e = D.clearElement( this.e.searchContent ); | |
| 2566 | + if(addInstructions){ | |
| 2567 | + D.append(e, "Enter search terms in the message field. "+ | |
| 2568 | + "Use #NNNNN to search for the message with ID NNNNN."); | |
| 2569 | + } | |
| 2570 | + return e; | |
| 2571 | + }; | |
| 2572 | + Chat.clearSearch(true); | |
| 2573 | + /** | |
| 2574 | + Submits a history search using the main input field's current | |
| 2575 | + text. It is assumed that Chat.e.viewSearch===Chat.e.currentView. | |
| 2576 | + */ | |
| 2577 | + Chat.submitSearch = function(){ | |
| 2578 | + const term = this.inputValue(true); | |
| 2579 | + const eMsgTgt = this.clearSearch(true); | |
| 2580 | + if( !term ) return; | |
| 2581 | + D.append( eMsgTgt, "Searching for ",term," ..."); | |
| 2582 | + const fd = new FormData(); | |
| 2583 | + fd.set('q', term); | |
| 2584 | + F.fetch( | |
| 2585 | + "chat-query", { | |
| 2586 | + payload: fd, | |
| 2587 | + responseType: 'json', | |
| 2588 | + onerror:function(err){ | |
| 2589 | + Chat.setCurrentView(Chat.e.viewMessages); | |
| 2590 | + Chat.reportErrorAsMessage(err); | |
| 2591 | + }, | |
| 2592 | + onload:function(jx){ | |
| 2593 | + let previd = 0; | |
| 2594 | + D.clearElement(eMsgTgt); | |
| 2595 | + jx.msgs.forEach((m)=>{ | |
| 2596 | + m.isSearchResult = true; | |
| 2597 | + const mw = new Chat.MessageWidget(m); | |
| 2598 | + const spacer = new Chat.SearchCtxLoader({ | |
| 2599 | + first: jx.first, | |
| 2600 | + last: jx.last, | |
| 2601 | + previd: previd, | |
| 2602 | + nextid: m.msgid | |
| 2603 | + }); | |
| 2604 | + if( spacer.e ) D.append( eMsgTgt, spacer.e.body ); | |
| 2605 | + D.append( eMsgTgt, mw.e.body ); | |
| 2606 | + previd = m.msgid; | |
| 2607 | + }); | |
| 2608 | + if( jx.msgs.length ){ | |
| 2609 | + const spacer = new Chat.SearchCtxLoader({ | |
| 2610 | + first: jx.first, | |
| 2611 | + last: jx.last, | |
| 2612 | + previd: previd, | |
| 2613 | + nextid: 0 | |
| 2614 | + }); | |
| 2615 | + if( spacer.e ) D.append( eMsgTgt, spacer.e.body ); | |
| 2616 | + }else{ | |
| 2617 | + D.append( D.clearElement(eMsgTgt), | |
| 2618 | + 'No search results found for: ', | |
| 2619 | + term ); | |
| 2620 | + } | |
| 2621 | + } | |
| 2622 | + } | |
| 2623 | + ); | |
| 2624 | + }/*Chat.submitSearch()*/; | |
| 2625 | + | |
| 2312 | 2626 | const afterFetch = function f(){ |
| 2313 | 2627 | if(true===f.isFirstCall){ |
| 2314 | 2628 | f.isFirstCall = false; |
| 2315 | 2629 | Chat.ajaxEnd(); |
| 2316 | 2630 | Chat.e.viewMessages.classList.remove('loading'); |
| @@ -2326,10 +2640,25 @@ | ||
| 2326 | 2640 | delete Chat.intervalTimer; |
| 2327 | 2641 | } |
| 2328 | 2642 | poll.running = false; |
| 2329 | 2643 | }; |
| 2330 | 2644 | afterFetch.isFirstCall = true; |
| 2645 | + /** | |
| 2646 | + FIXME: when polling fails because the remote server is | |
| 2647 | + reachable but it's not accepting HTTP requests, we should back | |
| 2648 | + off on polling for a while. e.g. if the remote web server process | |
| 2649 | + is killed, the poll fails quickly and immediately retries, | |
| 2650 | + hammering the remote server until the httpd is back up. That | |
| 2651 | + happens often during development of this application. | |
| 2652 | + | |
| 2653 | + XHR does not offer a direct way of distinguishing between | |
| 2654 | + HTTP/connection errors, but we can hypothetically use the | |
| 2655 | + xhrRequest.status value to do so, with status==0 being a | |
| 2656 | + connection error. We do not currently have a clean way of passing | |
| 2657 | + that info back to the fossil.fetch() client, so we'll need to | |
| 2658 | + hammer on that API a bit to get this working. | |
| 2659 | + */ | |
| 2331 | 2660 | const poll = async function f(){ |
| 2332 | 2661 | if(f.running) return; |
| 2333 | 2662 | f.running = true; |
| 2334 | 2663 | Chat._isBatchLoading = f.isFirstCall; |
| 2335 | 2664 | if(true===f.isFirstCall){ |
| @@ -2368,17 +2697,11 @@ | ||
| 2368 | 2697 | Chat._gotServerError = poll.running = false; |
| 2369 | 2698 | if( window.fossil.config.chat.fromcli ){ |
| 2370 | 2699 | Chat.chatOnlyMode(true); |
| 2371 | 2700 | } |
| 2372 | 2701 | Chat.intervalTimer = setInterval(poll, 1000); |
| 2373 | - if(0){ | |
| 2374 | - const flip = (ev)=>Chat.animate(ev.target,'anim-flip-h'); | |
| 2375 | - document.querySelectorAll('#chat-buttons-wrapper .cbutton').forEach(function(e){ | |
| 2376 | - e.addEventListener('click',flip, false); | |
| 2377 | - }); | |
| 2378 | - } | |
| 2379 | 2702 | delete ForceResizeKludge.$disabled; |
| 2380 | 2703 | ForceResizeKludge(); |
| 2381 | 2704 | Chat.animate.$disabled = false; |
| 2382 | 2705 | setTimeout( ()=>Chat.inputFocus(), 0 ); |
| 2383 | 2706 | F.page.chat = Chat/* enables testing the APIs via the dev tools */; |
| 2384 | 2707 | }); |
| 2385 | 2708 |
| --- src/fossil.page.chat.js | |
| +++ src/fossil.page.chat.js | |
| @@ -145,10 +145,12 @@ | |
| 145 | inputFile: E1('#chat-input-file'), |
| 146 | contentDiv: E1('div.content'), |
| 147 | viewConfig: E1('#chat-config'), |
| 148 | viewPreview: E1('#chat-preview'), |
| 149 | previewContent: E1('#chat-preview-content'), |
| 150 | btnPreview: E1('#chat-button-preview'), |
| 151 | views: document.querySelectorAll('.chat-view'), |
| 152 | activeUserListWrapper: E1('#chat-user-list-wrapper'), |
| 153 | activeUserList: E1('#chat-user-list'), |
| 154 | btnClearFilter: E1('#chat-clear-filter') |
| @@ -190,21 +192,31 @@ | |
| 190 | || !!e.querySelector('[data-hashtag="'+this.activeTag+'"]'); |
| 191 | } |
| 192 | }, |
| 193 | current: undefined/*gets set to current active filter*/ |
| 194 | }, |
| 195 | /** Gets (no args) or sets (1 arg) the current input text field value, |
| 196 | taking into account single- vs multi-line input. The getter returns |
| 197 | a string and the setter returns this object. */ |
| 198 | inputValue: function(){ |
| 199 | const e = this.inputElement(); |
| 200 | if(arguments.length){ |
| 201 | if(e.isContentEditable) e.innerText = arguments[0]; |
| 202 | else e.value = arguments[0]; |
| 203 | return this; |
| 204 | } |
| 205 | return e.isContentEditable ? e.innerText : e.value; |
| 206 | }, |
| 207 | /** Asks the current user input field to take focus. Returns this. */ |
| 208 | inputFocus: function(){ |
| 209 | this.inputElement().focus(); |
| 210 | return this; |
| @@ -529,11 +541,11 @@ | |
| 529 | const uDate = self.usersLastSeen[u]; |
| 530 | if(self.filter.user.activeTag===u){ |
| 531 | uSpan.classList.add('selected'); |
| 532 | } |
| 533 | uSpan.dataset.uname = u; |
| 534 | D.append(uSpan, u, "\n", |
| 535 | D.append( |
| 536 | D.addClass(D.span(),'timestamp'), |
| 537 | localTimeString(uDate)//.substr(5/*chop off year*/) |
| 538 | )); |
| 539 | if(uDate.$uColor){ |
| @@ -1075,11 +1087,11 @@ | |
| 1075 | Chat.MessageWidget = (function(){ |
| 1076 | /** |
| 1077 | Constructor. If passed an argument, it is passed to |
| 1078 | this.setMessage() after initialization. |
| 1079 | */ |
| 1080 | const cf = function(){ |
| 1081 | this.e = { |
| 1082 | body: D.addClass(D.div(), 'message-widget'), |
| 1083 | tab: D.addClass(D.div(), 'message-widget-tab'), |
| 1084 | content: D.addClass(D.div(), 'message-widget-content') |
| 1085 | }; |
| @@ -1094,20 +1106,33 @@ | |
| 1094 | /* Map of Date.getDay() values to weekday names. */ |
| 1095 | 0: "Sunday", 1: "Monday", 2: "Tuesday", |
| 1096 | 3: "Wednesday", 4: "Thursday", 5: "Friday", |
| 1097 | 6: "Saturday" |
| 1098 | }; |
| 1099 | /* Given a Date, returns the timestamp string used in the |
| 1100 | "tab" part of message widgets. */ |
| 1101 | const theTime = function(d){ |
| 1102 | return [ |
| 1103 | //d.getFullYear(),'-',pad2(d.getMonth()+1/*sigh*/), |
| 1104 | //'-',pad2(d.getDate()), ' ', |
| 1105 | d.getHours(),":", |
| 1106 | (d.getMinutes()+100).toString().slice(1,3), |
| 1107 | ' ', dowMap[d.getDay()] |
| 1108 | ].join(''); |
| 1109 | }; |
| 1110 | |
| 1111 | /** |
| 1112 | Returns true if this page believes it can embed a view of the |
| 1113 | file wrapped by the given message object, else returns false. |
| @@ -1114,19 +1139,20 @@ | |
| 1114 | */ |
| 1115 | const canEmbedFile = function f(msg){ |
| 1116 | if(!f.$rx){ |
| 1117 | f.$rx = /\.((html?)|(txt)|(md)|(wiki)|(pikchr))$/i; |
| 1118 | f.$specificTypes = [ |
| 1119 | 'text/plain', |
| 1120 | 'text/html', |
| 1121 | 'text/x-markdown', |
| 1122 | /* Firefox sends text/markdown when uploading .md files */ |
| 1123 | 'text/markdown', |
| 1124 | 'text/x-pikchr', |
| 1125 | 'text/x-fossil-wiki' |
| 1126 | // add more as we discover which ones Firefox won't |
| 1127 | // force the user to try to download. |
| 1128 | ]; |
| 1129 | } |
| 1130 | if(msg.fmime){ |
| 1131 | if(msg.fmime.startsWith("image/") |
| 1132 | || f.$specificTypes.indexOf(msg.fmime)>=0){ |
| @@ -1140,20 +1166,18 @@ | |
| 1140 | Returns true if the given message object "should" |
| 1141 | be embedded in fossil-rendered form instead of |
| 1142 | raw content form. This is only intended to be passed |
| 1143 | message objects for which canEmbedFile() returns true. |
| 1144 | */ |
| 1145 | const shouldWikiRenderEmbed = function f(msg){ |
| 1146 | if(!f.$rx){ |
| 1147 | f.$rx = /\.((md)|(wiki)|(pikchr))$/i; |
| 1148 | f.$specificTypes = [ |
| 1149 | 'text/x-markdown', |
| 1150 | 'text/markdown' /* Firefox-uploaded md files */, |
| 1151 | 'text/x-pikchr', |
| 1152 | 'text/x-fossil-wiki' |
| 1153 | // add more as we discover which ones Firefox won't |
| 1154 | // force the user to try to download. |
| 1155 | ]; |
| 1156 | } |
| 1157 | if(msg.fmime){ |
| 1158 | if(f.$specificTypes.indexOf(msg.fmime)>=0) return true; |
| 1159 | } |
| @@ -1179,12 +1203,12 @@ | |
| 1179 | iframe.style.maxHeight = iframe.style.height |
| 1180 | = iframe.contentWindow.document.documentElement.scrollHeight + 'px'; |
| 1181 | if(isHidden) D.addClass(iframe, 'hidden'); |
| 1182 | } |
| 1183 | }; |
| 1184 | |
| 1185 | cf.prototype = { |
| 1186 | scrollIntoView: function(){ |
| 1187 | this.e.content.scrollIntoView(); |
| 1188 | }, |
| 1189 | setMessage: function(m){ |
| 1190 | const ds = this.e.body.dataset; |
| @@ -1205,20 +1229,26 @@ | |
| 1205 | var eXFrom /* element holding xfrom name */; |
| 1206 | if(m.xfrom){ |
| 1207 | eXFrom = D.append(D.addClass(D.span(), 'xfrom'), m.xfrom); |
| 1208 | const wrapper = D.append( |
| 1209 | D.span(), eXFrom, |
| 1210 | D.text(" #",(m.msgid||'???'),' @ ',theTime(d))) |
| 1211 | D.append(this.e.tab, wrapper); |
| 1212 | }else{/*notification*/ |
| 1213 | D.addClass(this.e.body, 'notification'); |
| 1214 | if(m.isError){ |
| 1215 | D.addClass([contentTarget, this.e.tab], 'error'); |
| 1216 | } |
| 1217 | D.append( |
| 1218 | this.e.tab, |
| 1219 | D.append(D.code(), 'notification @ ',theTime(d)) |
| 1220 | ); |
| 1221 | } |
| 1222 | if( m.xfrom && m.fsize>0 ){ |
| 1223 | if( m.fmime |
| 1224 | && m.fmime.startsWith("image/") |
| @@ -1241,18 +1271,18 @@ | |
| 1241 | D.attr(a,'target','_blank'); |
| 1242 | D.append(w, a); |
| 1243 | if(canEmbedFile(m)){ |
| 1244 | /* Add an option to embed HTML attachments in an iframe. The primary |
| 1245 | use case is attached diffs. */ |
| 1246 | const shouldWikiRender = shouldWikiRenderEmbed(m); |
| 1247 | const downloadArgs = shouldWikiRender ? '?render' : ''; |
| 1248 | D.addClass(contentTarget, 'wide'); |
| 1249 | const embedTarget = this.e.content; |
| 1250 | const self = this; |
| 1251 | const btnEmbed = D.attr(D.checkbox("1", false), 'id', |
| 1252 | 'embed-'+ds.msgid); |
| 1253 | const btnLabel = D.label(btnEmbed, shouldWikiRender |
| 1254 | ? "Embed (fossil-rendered)" : "Embed"); |
| 1255 | /* Maintenance reminder: do not disable the toggle |
| 1256 | button while the content is loading because that will |
| 1257 | cause it to get stuck in disabled mode if the browser |
| 1258 | decides that loading the content should prompt the |
| @@ -1460,13 +1490,182 @@ | |
| 1460 | Chat.setCurrentView(Chat.e.viewMessages); |
| 1461 | e.scrollIntoView(false); |
| 1462 | Chat.animate(e, 'anim-fade-out-in'); |
| 1463 | } |
| 1464 | }; |
| 1465 | return cf; |
| 1466 | })()/*MessageWidget*/; |
| 1467 | |
| 1468 | const BlobXferState = (function(){ |
| 1469 | /* State for paste and drag/drop */ |
| 1470 | const bxs = { |
| 1471 | dropDetails: document.querySelector('#chat-drop-details'), |
| 1472 | blob: undefined, |
| @@ -1605,16 +1804,26 @@ | |
| 1605 | |
| 1606 | /** |
| 1607 | Submits the contents of the message input field (if not empty) |
| 1608 | and/or the file attachment field to the server. If both are |
| 1609 | empty, this is a no-op. |
| 1610 | */ |
| 1611 | Chat.submitMessage = function f(){ |
| 1612 | if(!f.spaces){ |
| 1613 | f.spaces = /\s+$/; |
| 1614 | f.markdownContinuation = /\\\s+$/; |
| 1615 | f.spaces2 = /\s{3,}$/; |
| 1616 | } |
| 1617 | this.setCurrentView(this.e.viewMessages); |
| 1618 | const fd = new FormData(); |
| 1619 | const fallback = {msg: this.inputValue()}; |
| 1620 | var msg = fallback.msg; |
| @@ -1687,14 +1896,16 @@ | |
| 1687 | //console.debug("Enter key event:", ctrlMode, ev.ctrlKey, ev.shiftKey, ev); |
| 1688 | if(ev.shiftKey){ |
| 1689 | const compactMode = Chat.settings.getBool('edit-compact-mode', false); |
| 1690 | ev.preventDefault(); |
| 1691 | ev.stopPropagation(); |
| 1692 | /* Shift-enter will run preview mode UNLESS preview mode is |
| 1693 | active AND the input field is empty, in which case it will |
| 1694 | switch back to message view. */ |
| 1695 | if(Chat.e.currentView===Chat.e.viewPreview && !text){ |
| 1696 | Chat.setCurrentView(Chat.e.viewMessages); |
| 1697 | }else if(!text){ |
| 1698 | f.$toggleCompact(compactMode); |
| 1699 | }else if(Chat.settings.getBool('edit-shift-enter-preview', true)){ |
| 1700 | Chat.e.btnPreview.click(); |
| @@ -1752,19 +1963,19 @@ | |
| 1752 | tall vs wide. Can be toggled via settings. */ |
| 1753 | document.body.classList.add('my-messages-right'); |
| 1754 | } |
| 1755 | const settingsButton = document.querySelector('#chat-button-settings'); |
| 1756 | const optionsMenu = E1('#chat-config-options'); |
| 1757 | const cbToggle = function(ev){ |
| 1758 | ev.preventDefault(); |
| 1759 | ev.stopPropagation(); |
| 1760 | Chat.setCurrentView(Chat.e.currentView===Chat.e.viewConfig |
| 1761 | ? Chat.e.viewMessages : Chat.e.viewConfig); |
| 1762 | return false; |
| 1763 | }; |
| 1764 | D.attr(settingsButton, 'role', 'button').addEventListener('click', cbToggle, false); |
| 1765 | Chat.e.viewConfig.querySelector('button').addEventListener('click', cbToggle, false); |
| 1766 | |
| 1767 | /** Internal acrobatics to allow certain settings toggles to access |
| 1768 | related toggles. */ |
| 1769 | const namedOptions = { |
| 1770 | activeUsers:{ |
| @@ -1850,12 +2061,13 @@ | |
| 1850 | boolValue: 'edit-ctrl-send' |
| 1851 | },{ |
| 1852 | label: "Compact mode", |
| 1853 | hint: [ |
| 1854 | "Toggle between a space-saving or more spacious writing area. ", |
| 1855 | "When the input field has focus, is empty, and preview mode ", |
| 1856 | "is NOT active then Shift-Enter toggles this setting."].join(''), |
| 1857 | boolValue: 'edit-compact-mode' |
| 1858 | },{ |
| 1859 | label: "Use 'contenteditable' editing mode", |
| 1860 | boolValue: 'edit-widget-x', |
| 1861 | hint: [ |
| @@ -2020,11 +2232,11 @@ | |
| 2020 | op.persistentSetting, |
| 2021 | function(setting){ |
| 2022 | if(op.checkbox) op.checkbox.checked = !!setting.value; |
| 2023 | else if(op.select) op.select.value = setting.value; |
| 2024 | if(op.callback) op.callback(setting); |
| 2025 | } |
| 2026 | ); |
| 2027 | if(op.checkbox){ |
| 2028 | op.checkbox.addEventListener( |
| 2029 | 'change', function(){ |
| 2030 | Chat.settings.set(op.persistentSetting, op.checkbox.checked) |
| @@ -2096,11 +2308,11 @@ | |
| 2096 | s.value ? 'add' : 'remove' |
| 2097 | ]('compact'); |
| 2098 | Chat.e.inputFields[Chat.e.inputFields.$currentIndex].focus(); |
| 2099 | }); |
| 2100 | Chat.settings.addListener('edit-ctrl-send',function(s){ |
| 2101 | const label = (s.value ? "Ctrl-" : "")+"Enter submits messages."; |
| 2102 | Chat.e.inputFields.forEach((e)=>{ |
| 2103 | const v = e.dataset.placeholder0 + " " +label; |
| 2104 | if(e.isContentEditable) e.dataset.placeholder = v; |
| 2105 | else D.attr(e,'placeholder',v); |
| 2106 | }); |
| @@ -2128,11 +2340,11 @@ | |
| 2128 | this.e.previewContent.innerHTML = t; |
| 2129 | this.e.viewPreview.querySelectorAll('a').forEach(addAnchorTargetBlank); |
| 2130 | setupHashtags(this.e.previewContent)/*arguable, for usability reasons*/; |
| 2131 | this.inputFocus(); |
| 2132 | }; |
| 2133 | Chat.e.viewPreview.querySelector('#chat-preview-close'). |
| 2134 | addEventListener('click', ()=>Chat.setCurrentView(Chat.e.viewMessages), false); |
| 2135 | let previewPending = false; |
| 2136 | const elemsToEnable = [btnPreview, Chat.e.btnSubmit, Chat.e.inputFields]; |
| 2137 | const submit = function(ev){ |
| 2138 | ev.preventDefault(); |
| @@ -2175,10 +2387,40 @@ | |
| 2175 | }); |
| 2176 | return false; |
| 2177 | }; |
| 2178 | btnPreview.addEventListener('click', submit, false); |
| 2179 | })()/*message preview setup*/; |
| 2180 | |
| 2181 | /** Callback for poll() to inject new content into the page. jx == |
| 2182 | the response from /chat-poll. If atEnd is true, the message is |
| 2183 | appended to the end of the chat list (for loading older |
| 2184 | messages), else the beginning (the default). */ |
| @@ -2307,10 +2549,82 @@ | |
| 2307 | btn.addEventListener('click',()=>loadOldMessages(-1)); |
| 2308 | D.append(Chat.e.viewMessages, toolbar); |
| 2309 | toolbar.disabled = true /*will be enabled when msg load finishes */; |
| 2310 | })()/*end history loading widget setup*/; |
| 2311 | |
| 2312 | const afterFetch = function f(){ |
| 2313 | if(true===f.isFirstCall){ |
| 2314 | f.isFirstCall = false; |
| 2315 | Chat.ajaxEnd(); |
| 2316 | Chat.e.viewMessages.classList.remove('loading'); |
| @@ -2326,10 +2640,25 @@ | |
| 2326 | delete Chat.intervalTimer; |
| 2327 | } |
| 2328 | poll.running = false; |
| 2329 | }; |
| 2330 | afterFetch.isFirstCall = true; |
| 2331 | const poll = async function f(){ |
| 2332 | if(f.running) return; |
| 2333 | f.running = true; |
| 2334 | Chat._isBatchLoading = f.isFirstCall; |
| 2335 | if(true===f.isFirstCall){ |
| @@ -2368,17 +2697,11 @@ | |
| 2368 | Chat._gotServerError = poll.running = false; |
| 2369 | if( window.fossil.config.chat.fromcli ){ |
| 2370 | Chat.chatOnlyMode(true); |
| 2371 | } |
| 2372 | Chat.intervalTimer = setInterval(poll, 1000); |
| 2373 | if(0){ |
| 2374 | const flip = (ev)=>Chat.animate(ev.target,'anim-flip-h'); |
| 2375 | document.querySelectorAll('#chat-buttons-wrapper .cbutton').forEach(function(e){ |
| 2376 | e.addEventListener('click',flip, false); |
| 2377 | }); |
| 2378 | } |
| 2379 | delete ForceResizeKludge.$disabled; |
| 2380 | ForceResizeKludge(); |
| 2381 | Chat.animate.$disabled = false; |
| 2382 | setTimeout( ()=>Chat.inputFocus(), 0 ); |
| 2383 | F.page.chat = Chat/* enables testing the APIs via the dev tools */; |
| 2384 | }); |
| 2385 |
| --- src/fossil.page.chat.js | |
| +++ src/fossil.page.chat.js | |
| @@ -145,10 +145,12 @@ | |
| 145 | inputFile: E1('#chat-input-file'), |
| 146 | contentDiv: E1('div.content'), |
| 147 | viewConfig: E1('#chat-config'), |
| 148 | viewPreview: E1('#chat-preview'), |
| 149 | previewContent: E1('#chat-preview-content'), |
| 150 | viewSearch: E1('#chat-search'), |
| 151 | searchContent: E1('#chat-search-content'), |
| 152 | btnPreview: E1('#chat-button-preview'), |
| 153 | views: document.querySelectorAll('.chat-view'), |
| 154 | activeUserListWrapper: E1('#chat-user-list-wrapper'), |
| 155 | activeUserList: E1('#chat-user-list'), |
| 156 | btnClearFilter: E1('#chat-clear-filter') |
| @@ -190,21 +192,31 @@ | |
| 192 | || !!e.querySelector('[data-hashtag="'+this.activeTag+'"]'); |
| 193 | } |
| 194 | }, |
| 195 | current: undefined/*gets set to current active filter*/ |
| 196 | }, |
| 197 | /** |
| 198 | Gets (no args) or sets (1 arg) the current input text field |
| 199 | value, taking into account single- vs multi-line input. The |
| 200 | getter returns a trim()'d string and the setter returns this |
| 201 | object. As a special case, if arguments[0] is a boolean |
| 202 | value, it behaves like a getter and, if arguments[0]===true |
| 203 | it clears the input field before returning. |
| 204 | */ |
| 205 | inputValue: function(/*string newValue | bool clearInputField*/){ |
| 206 | const e = this.inputElement(); |
| 207 | if(arguments.length && 'boolean'!==typeof arguments[0]){ |
| 208 | if(e.isContentEditable) e.innerText = arguments[0]; |
| 209 | else e.value = arguments[0]; |
| 210 | return this; |
| 211 | } |
| 212 | const rc = e.isContentEditable ? e.innerText : e.value; |
| 213 | if( true===arguments[0] ){ |
| 214 | if(e.isContentEditable) e.innerText = ''; |
| 215 | else e.value = ''; |
| 216 | } |
| 217 | return rc && rc.trim(); |
| 218 | }, |
| 219 | /** Asks the current user input field to take focus. Returns this. */ |
| 220 | inputFocus: function(){ |
| 221 | this.inputElement().focus(); |
| 222 | return this; |
| @@ -529,11 +541,11 @@ | |
| 541 | const uDate = self.usersLastSeen[u]; |
| 542 | if(self.filter.user.activeTag===u){ |
| 543 | uSpan.classList.add('selected'); |
| 544 | } |
| 545 | uSpan.dataset.uname = u; |
| 546 | D.append(uSpan, u, "\n", |
| 547 | D.append( |
| 548 | D.addClass(D.span(),'timestamp'), |
| 549 | localTimeString(uDate)//.substr(5/*chop off year*/) |
| 550 | )); |
| 551 | if(uDate.$uColor){ |
| @@ -1075,11 +1087,11 @@ | |
| 1087 | Chat.MessageWidget = (function(){ |
| 1088 | /** |
| 1089 | Constructor. If passed an argument, it is passed to |
| 1090 | this.setMessage() after initialization. |
| 1091 | */ |
| 1092 | const ctor = function(){ |
| 1093 | this.e = { |
| 1094 | body: D.addClass(D.div(), 'message-widget'), |
| 1095 | tab: D.addClass(D.div(), 'message-widget-tab'), |
| 1096 | content: D.addClass(D.div(), 'message-widget-content') |
| 1097 | }; |
| @@ -1094,20 +1106,33 @@ | |
| 1106 | /* Map of Date.getDay() values to weekday names. */ |
| 1107 | 0: "Sunday", 1: "Monday", 2: "Tuesday", |
| 1108 | 3: "Wednesday", 4: "Thursday", 5: "Friday", |
| 1109 | 6: "Saturday" |
| 1110 | }; |
| 1111 | /* Given a Date, returns the timestamp string used in the "tab" |
| 1112 | part of message widgets. If longFmt is true then a verbose |
| 1113 | format is used, else a brief format is used. The returned string |
| 1114 | is in client-local time. */ |
| 1115 | const theTime = function(d, longFmt=false){ |
| 1116 | const li = []; |
| 1117 | if( longFmt ){ |
| 1118 | li.push( |
| 1119 | d.getFullYear(), |
| 1120 | '-', pad2(d.getMonth()+1), |
| 1121 | '-', pad2(d.getDate()), |
| 1122 | ' ', |
| 1123 | d.getHours(), ":", |
| 1124 | (d.getMinutes()+100).toString().slice(1,3) |
| 1125 | ); |
| 1126 | }else{ |
| 1127 | li.push( |
| 1128 | d.getHours(),":", |
| 1129 | (d.getMinutes()+100).toString().slice(1,3), |
| 1130 | ' ', dowMap[d.getDay()] |
| 1131 | ); |
| 1132 | } |
| 1133 | return li.join(''); |
| 1134 | }; |
| 1135 | |
| 1136 | /** |
| 1137 | Returns true if this page believes it can embed a view of the |
| 1138 | file wrapped by the given message object, else returns false. |
| @@ -1114,19 +1139,20 @@ | |
| 1139 | */ |
| 1140 | const canEmbedFile = function f(msg){ |
| 1141 | if(!f.$rx){ |
| 1142 | f.$rx = /\.((html?)|(txt)|(md)|(wiki)|(pikchr))$/i; |
| 1143 | f.$specificTypes = [ |
| 1144 | /* Mime types we know we can embed, sans image/... */ |
| 1145 | 'text/plain', |
| 1146 | 'text/html', |
| 1147 | 'text/x-markdown', |
| 1148 | /* Firefox sends text/markdown when uploading .md files */ |
| 1149 | 'text/markdown', |
| 1150 | 'text/x-pikchr', |
| 1151 | 'text/x-fossil-wiki' |
| 1152 | /* Add more as we discover which ones Firefox won't |
| 1153 | force the user to try to download. */ |
| 1154 | ]; |
| 1155 | } |
| 1156 | if(msg.fmime){ |
| 1157 | if(msg.fmime.startsWith("image/") |
| 1158 | || f.$specificTypes.indexOf(msg.fmime)>=0){ |
| @@ -1140,20 +1166,18 @@ | |
| 1166 | Returns true if the given message object "should" |
| 1167 | be embedded in fossil-rendered form instead of |
| 1168 | raw content form. This is only intended to be passed |
| 1169 | message objects for which canEmbedFile() returns true. |
| 1170 | */ |
| 1171 | const shouldFossilRenderEmbed = function f(msg){ |
| 1172 | if(!f.$rx){ |
| 1173 | f.$rx = /\.((md)|(wiki)|(pikchr))$/i; |
| 1174 | f.$specificTypes = [ |
| 1175 | 'text/x-markdown', |
| 1176 | 'text/markdown' /* Firefox-uploaded md files */, |
| 1177 | 'text/x-pikchr', |
| 1178 | 'text/x-fossil-wiki' |
| 1179 | ]; |
| 1180 | } |
| 1181 | if(msg.fmime){ |
| 1182 | if(f.$specificTypes.indexOf(msg.fmime)>=0) return true; |
| 1183 | } |
| @@ -1179,12 +1203,12 @@ | |
| 1203 | iframe.style.maxHeight = iframe.style.height |
| 1204 | = iframe.contentWindow.document.documentElement.scrollHeight + 'px'; |
| 1205 | if(isHidden) D.addClass(iframe, 'hidden'); |
| 1206 | } |
| 1207 | }; |
| 1208 | |
| 1209 | ctor.prototype = { |
| 1210 | scrollIntoView: function(){ |
| 1211 | this.e.content.scrollIntoView(); |
| 1212 | }, |
| 1213 | setMessage: function(m){ |
| 1214 | const ds = this.e.body.dataset; |
| @@ -1205,20 +1229,26 @@ | |
| 1229 | var eXFrom /* element holding xfrom name */; |
| 1230 | if(m.xfrom){ |
| 1231 | eXFrom = D.append(D.addClass(D.span(), 'xfrom'), m.xfrom); |
| 1232 | const wrapper = D.append( |
| 1233 | D.span(), eXFrom, |
| 1234 | ' ', |
| 1235 | D.append(D.addClass(D.span(), 'msgid'), |
| 1236 | '#' + (m.msgid||'???')), |
| 1237 | (m.isSearchResult ? ' ' : ' @ '), |
| 1238 | D.append(D.addClass(D.span(), 'timestamp'), |
| 1239 | theTime(d,!!m.isSearchResult)) |
| 1240 | ); |
| 1241 | D.append(this.e.tab, wrapper); |
| 1242 | }else{/*notification*/ |
| 1243 | D.addClass(this.e.body, 'notification'); |
| 1244 | if(m.isError){ |
| 1245 | D.addClass([contentTarget, this.e.tab], 'error'); |
| 1246 | } |
| 1247 | D.append( |
| 1248 | this.e.tab, |
| 1249 | D.append(D.code(), 'notification @ ',theTime(d,false)) |
| 1250 | ); |
| 1251 | } |
| 1252 | if( m.xfrom && m.fsize>0 ){ |
| 1253 | if( m.fmime |
| 1254 | && m.fmime.startsWith("image/") |
| @@ -1241,18 +1271,18 @@ | |
| 1271 | D.attr(a,'target','_blank'); |
| 1272 | D.append(w, a); |
| 1273 | if(canEmbedFile(m)){ |
| 1274 | /* Add an option to embed HTML attachments in an iframe. The primary |
| 1275 | use case is attached diffs. */ |
| 1276 | const shouldFossilRender = shouldFossilRenderEmbed(m); |
| 1277 | const downloadArgs = shouldFossilRender ? '?render' : ''; |
| 1278 | D.addClass(contentTarget, 'wide'); |
| 1279 | const embedTarget = this.e.content; |
| 1280 | const self = this; |
| 1281 | const btnEmbed = D.attr(D.checkbox("1", false), 'id', |
| 1282 | 'embed-'+ds.msgid); |
| 1283 | const btnLabel = D.label(btnEmbed, shouldFossilRender |
| 1284 | ? "Embed (fossil-rendered)" : "Embed"); |
| 1285 | /* Maintenance reminder: do not disable the toggle |
| 1286 | button while the content is loading because that will |
| 1287 | cause it to get stuck in disabled mode if the browser |
| 1288 | decides that loading the content should prompt the |
| @@ -1460,13 +1490,182 @@ | |
| 1490 | Chat.setCurrentView(Chat.e.viewMessages); |
| 1491 | e.scrollIntoView(false); |
| 1492 | Chat.animate(e, 'anim-fade-out-in'); |
| 1493 | } |
| 1494 | }; |
| 1495 | return ctor; |
| 1496 | })()/*MessageWidget*/; |
| 1497 | |
| 1498 | /** |
| 1499 | A widget for loading more messages (context) around a /chat-query |
| 1500 | result message. |
| 1501 | */ |
| 1502 | Chat.SearchCtxLoader = (function(){ |
| 1503 | const nMsgContext = 5; |
| 1504 | const zUpArrow = '\u25B2'; |
| 1505 | const zDownArrow = '\u25BC'; |
| 1506 | const ctor = function(o){ |
| 1507 | |
| 1508 | /* iFirstInTable: |
| 1509 | ** msgid of first row in chatfts table. |
| 1510 | ** |
| 1511 | ** iLastInTable: |
| 1512 | ** msgid of last row in chatfts table. |
| 1513 | ** |
| 1514 | ** iPrevId: |
| 1515 | ** msgid of message immediately above this spacer. Or 0 if this |
| 1516 | ** spacer is above all results. |
| 1517 | ** |
| 1518 | ** iNextId: |
| 1519 | ** msgid of message immediately below this spacer. Or 0 if this |
| 1520 | ** spacer is below all results. |
| 1521 | ** |
| 1522 | ** bIgnoreClick: |
| 1523 | ** ignore any clicks if this is true. This is used to ensure there |
| 1524 | ** is only ever one request belonging to this widget outstanding |
| 1525 | ** at any time. |
| 1526 | */ |
| 1527 | this.o = { |
| 1528 | iFirstInTable: o.first, |
| 1529 | iLastInTable: o.last, |
| 1530 | iPrevId: o.previd, |
| 1531 | iNextId: o.nextid, |
| 1532 | bIgnoreClick: false |
| 1533 | }; |
| 1534 | |
| 1535 | this.e = { |
| 1536 | body: D.addClass(D.div(), 'spacer-widget'), |
| 1537 | up: D.addClass( |
| 1538 | D.button(zDownArrow+' Load '+nMsgContext+' more '+zDownArrow), |
| 1539 | 'up' |
| 1540 | ), |
| 1541 | down: D.addClass( |
| 1542 | D.button(zUpArrow+' Load '+nMsgContext+' more '+zUpArrow), |
| 1543 | 'down' |
| 1544 | ), |
| 1545 | all: D.addClass(D.button('Load More'), 'all') |
| 1546 | }; |
| 1547 | D.append( this.e.body, this.e.up, this.e.down, this.e.all ); |
| 1548 | const ms = this; |
| 1549 | this.e.up.addEventListener('click', ()=>ms.load_messages(false)); |
| 1550 | this.e.down.addEventListener('click', ()=>ms.load_messages(true)); |
| 1551 | this.e.all.addEventListener('click', ()=>ms.load_messages( (ms.o.iPrevId==0) )); |
| 1552 | this.set_button_visibility(); |
| 1553 | }; |
| 1554 | |
| 1555 | ctor.prototype = { |
| 1556 | set_button_visibility: function() { |
| 1557 | if( !this.e ) return; |
| 1558 | const o = this.o; |
| 1559 | |
| 1560 | const iPrevId = (o.iPrevId!=0) ? o.iPrevId : o.iFirstInTable-1; |
| 1561 | const iNextId = (o.iNextId!=0) ? o.iNextId : o.iLastInTable+1; |
| 1562 | let nDiff = (iNextId - iPrevId) - 1; |
| 1563 | |
| 1564 | for( const x of [this.e.up, this.e.down, this.e.all] ){ |
| 1565 | if( x ) D.addClass(x, 'hidden'); |
| 1566 | } |
| 1567 | let nVisible = 0; |
| 1568 | if( nDiff>0 ){ |
| 1569 | if( nDiff>nMsgContext && (o.iPrevId==0 || o.iNextId==0) ){ |
| 1570 | nDiff = nMsgContext; |
| 1571 | } |
| 1572 | |
| 1573 | if( nDiff<=nMsgContext && o.iPrevId!=0 && o.iNextId!=0 ){ |
| 1574 | D.removeClass(this.e.all, 'hidden'); |
| 1575 | ++nVisible; |
| 1576 | this.e.all.innerText = ( |
| 1577 | zUpArrow + " Load " + nDiff + " more " + zDownArrow |
| 1578 | ); |
| 1579 | }else{ |
| 1580 | if( o.iPrevId!=0 ){ |
| 1581 | ++nVisible; |
| 1582 | D.removeClass(this.e.up, 'hidden'); |
| 1583 | }else if( this.e.up ){ |
| 1584 | if( this.e.up.parentNode ) D.remove(this.e.up); |
| 1585 | delete this.e.up; |
| 1586 | } |
| 1587 | if( o.iNextId!=0 ){ |
| 1588 | ++nVisible; |
| 1589 | D.removeClass(this.e.down, 'hidden'); |
| 1590 | }else if( this.e.down ){ |
| 1591 | if( this.e.down.parentNode ) D.remove( this.e.down ); |
| 1592 | delete this.e.down; |
| 1593 | } |
| 1594 | } |
| 1595 | } |
| 1596 | if( !nVisible ){ |
| 1597 | /* The DOM elements can now be disposed of. */ |
| 1598 | for( const x of [this.e.up, this.e.down, this.e.all, this.e.body] ){ |
| 1599 | if( x?.parentNode ) D.remove(x); |
| 1600 | } |
| 1601 | delete this.e; |
| 1602 | } |
| 1603 | }, |
| 1604 | |
| 1605 | load_messages: function(bDown) { |
| 1606 | if( this.bIgnoreClick ) return; |
| 1607 | |
| 1608 | var iFirst = 0; /* msgid of first message to fetch */ |
| 1609 | var nFetch = 0; /* Number of messages to fetch */ |
| 1610 | var iEof = 0; /* last msgid in spacers range, plus 1 */ |
| 1611 | |
| 1612 | const e = this.e, o = this.o; |
| 1613 | this.bIgnoreClick = true; |
| 1614 | |
| 1615 | /* Figure out the required range of messages. */ |
| 1616 | if( bDown ){ |
| 1617 | iFirst = this.o.iNextId - nMsgContext; |
| 1618 | if( iFirst<this.o.iFirstInTable ){ |
| 1619 | iFirst = this.o.iFirstInTable; |
| 1620 | } |
| 1621 | }else{ |
| 1622 | iFirst = this.o.iPrevId+1; |
| 1623 | } |
| 1624 | nFetch = nMsgContext; |
| 1625 | iEof = (this.o.iNextId > 0) ? this.o.iNextId : this.o.iLastInTable+1; |
| 1626 | if( iFirst+nFetch>iEof ){ |
| 1627 | nFetch = iEof - iFirst; |
| 1628 | } |
| 1629 | const ms = this; |
| 1630 | F.fetch("chat-query",{ |
| 1631 | urlParams:{ |
| 1632 | q: '', |
| 1633 | n: nFetch, |
| 1634 | i: iFirst |
| 1635 | }, |
| 1636 | responseType: "json", |
| 1637 | onload:function(jx){ |
| 1638 | if( bDown ) jx.msgs.reverse(); |
| 1639 | jx.msgs.forEach((m) => { |
| 1640 | var mw = new Chat.MessageWidget(m); |
| 1641 | if( bDown ){ |
| 1642 | /* Inject the message below this object's body, or |
| 1643 | append it to Chat.e.searchContent if this element |
| 1644 | is the final one in its parent (Chat.e.searchContent). */ |
| 1645 | const eAnchor = e.body.nextElementSibling; |
| 1646 | if( eAnchor ) Chat.e.searchContent.insertBefore(mw.e.body, eAnchor); |
| 1647 | else D.append(Chat.e.searchContent, mw.e.body); |
| 1648 | }else{ |
| 1649 | Chat.e.searchContent.insertBefore(mw.e.body, e.body); |
| 1650 | } |
| 1651 | }); |
| 1652 | if( bDown ){ |
| 1653 | o.iNextId -= jx.msgs.length; |
| 1654 | }else{ |
| 1655 | o.iPrevId += jx.msgs.length; |
| 1656 | } |
| 1657 | ms.set_button_visibility(); |
| 1658 | ms.bIgnoreClick = false; |
| 1659 | } |
| 1660 | }); |
| 1661 | } |
| 1662 | }; |
| 1663 | |
| 1664 | return ctor; |
| 1665 | })() /*SearchCtxLoader*/; |
| 1666 | |
| 1667 | const BlobXferState = (function(){ |
| 1668 | /* State for paste and drag/drop */ |
| 1669 | const bxs = { |
| 1670 | dropDetails: document.querySelector('#chat-drop-details'), |
| 1671 | blob: undefined, |
| @@ -1605,16 +1804,26 @@ | |
| 1804 | |
| 1805 | /** |
| 1806 | Submits the contents of the message input field (if not empty) |
| 1807 | and/or the file attachment field to the server. If both are |
| 1808 | empty, this is a no-op. |
| 1809 | |
| 1810 | If the current view is the history search, this instead sends the |
| 1811 | input text to that widget. |
| 1812 | */ |
| 1813 | Chat.submitMessage = function f(){ |
| 1814 | if(!f.spaces){ |
| 1815 | f.spaces = /\s+$/; |
| 1816 | f.markdownContinuation = /\\\s+$/; |
| 1817 | f.spaces2 = /\s{3,}$/; |
| 1818 | } |
| 1819 | switch( this.e.currentView ){ |
| 1820 | case this.e.viewSearch: this.submitSearch(); |
| 1821 | return; |
| 1822 | case this.e.viewPreview: this.e.btnPreview.click(); |
| 1823 | return; |
| 1824 | default: break; |
| 1825 | } |
| 1826 | this.setCurrentView(this.e.viewMessages); |
| 1827 | const fd = new FormData(); |
| 1828 | const fallback = {msg: this.inputValue()}; |
| 1829 | var msg = fallback.msg; |
| @@ -1687,14 +1896,16 @@ | |
| 1896 | //console.debug("Enter key event:", ctrlMode, ev.ctrlKey, ev.shiftKey, ev); |
| 1897 | if(ev.shiftKey){ |
| 1898 | const compactMode = Chat.settings.getBool('edit-compact-mode', false); |
| 1899 | ev.preventDefault(); |
| 1900 | ev.stopPropagation(); |
| 1901 | /* Shift-enter will run preview mode UNLESS the input field is empty |
| 1902 | AND (preview or search mode) is active, in which cases it will |
| 1903 | switch back to message view. */ |
| 1904 | if(!text && |
| 1905 | (Chat.e.currentView===Chat.e.viewPreview |
| 1906 | | Chat.e.currentView===Chat.e.viewSearch)){ |
| 1907 | Chat.setCurrentView(Chat.e.viewMessages); |
| 1908 | }else if(!text){ |
| 1909 | f.$toggleCompact(compactMode); |
| 1910 | }else if(Chat.settings.getBool('edit-shift-enter-preview', true)){ |
| 1911 | Chat.e.btnPreview.click(); |
| @@ -1752,19 +1963,19 @@ | |
| 1963 | tall vs wide. Can be toggled via settings. */ |
| 1964 | document.body.classList.add('my-messages-right'); |
| 1965 | } |
| 1966 | const settingsButton = document.querySelector('#chat-button-settings'); |
| 1967 | const optionsMenu = E1('#chat-config-options'); |
| 1968 | const eToggleView = function(ev){ |
| 1969 | ev.preventDefault(); |
| 1970 | ev.stopPropagation(); |
| 1971 | Chat.setCurrentView(Chat.e.currentView===Chat.e.viewConfig |
| 1972 | ? Chat.e.viewMessages : Chat.e.viewConfig); |
| 1973 | return false; |
| 1974 | }; |
| 1975 | D.attr(settingsButton, 'role', 'button').addEventListener('click', eToggleView, false); |
| 1976 | Chat.e.viewConfig.querySelector('button.action-close').addEventListener('click', eToggleView, false); |
| 1977 | |
| 1978 | /** Internal acrobatics to allow certain settings toggles to access |
| 1979 | related toggles. */ |
| 1980 | const namedOptions = { |
| 1981 | activeUsers:{ |
| @@ -1850,12 +2061,13 @@ | |
| 2061 | boolValue: 'edit-ctrl-send' |
| 2062 | },{ |
| 2063 | label: "Compact mode", |
| 2064 | hint: [ |
| 2065 | "Toggle between a space-saving or more spacious writing area. ", |
| 2066 | "When the input field has focus and is empty ", |
| 2067 | "then Shift-Enter may (depending on the current view) toggle this setting." |
| 2068 | ].join(''), |
| 2069 | boolValue: 'edit-compact-mode' |
| 2070 | },{ |
| 2071 | label: "Use 'contenteditable' editing mode", |
| 2072 | boolValue: 'edit-widget-x', |
| 2073 | hint: [ |
| @@ -2020,11 +2232,11 @@ | |
| 2232 | op.persistentSetting, |
| 2233 | function(setting){ |
| 2234 | if(op.checkbox) op.checkbox.checked = !!setting.value; |
| 2235 | else if(op.select) op.select.value = setting.value; |
| 2236 | if(op.callback) op.callback(setting); |
| 2237 | } |
| 2238 | ); |
| 2239 | if(op.checkbox){ |
| 2240 | op.checkbox.addEventListener( |
| 2241 | 'change', function(){ |
| 2242 | Chat.settings.set(op.persistentSetting, op.checkbox.checked) |
| @@ -2096,11 +2308,11 @@ | |
| 2308 | s.value ? 'add' : 'remove' |
| 2309 | ]('compact'); |
| 2310 | Chat.e.inputFields[Chat.e.inputFields.$currentIndex].focus(); |
| 2311 | }); |
| 2312 | Chat.settings.addListener('edit-ctrl-send',function(s){ |
| 2313 | const label = (s.value ? "Ctrl-" : "")+"Enter submits message"; |
| 2314 | Chat.e.inputFields.forEach((e)=>{ |
| 2315 | const v = e.dataset.placeholder0 + " " +label; |
| 2316 | if(e.isContentEditable) e.dataset.placeholder = v; |
| 2317 | else D.attr(e,'placeholder',v); |
| 2318 | }); |
| @@ -2128,11 +2340,11 @@ | |
| 2340 | this.e.previewContent.innerHTML = t; |
| 2341 | this.e.viewPreview.querySelectorAll('a').forEach(addAnchorTargetBlank); |
| 2342 | setupHashtags(this.e.previewContent)/*arguable, for usability reasons*/; |
| 2343 | this.inputFocus(); |
| 2344 | }; |
| 2345 | Chat.e.viewPreview.querySelector('button.action-close'). |
| 2346 | addEventListener('click', ()=>Chat.setCurrentView(Chat.e.viewMessages), false); |
| 2347 | let previewPending = false; |
| 2348 | const elemsToEnable = [btnPreview, Chat.e.btnSubmit, Chat.e.inputFields]; |
| 2349 | const submit = function(ev){ |
| 2350 | ev.preventDefault(); |
| @@ -2175,10 +2387,40 @@ | |
| 2387 | }); |
| 2388 | return false; |
| 2389 | }; |
| 2390 | btnPreview.addEventListener('click', submit, false); |
| 2391 | })()/*message preview setup*/; |
| 2392 | |
| 2393 | (function(){/*Set up #chat-search and related bits */ |
| 2394 | const btn = document.querySelector('#chat-button-search'); |
| 2395 | D.attr(btn, 'role', 'button').addEventListener('click', function(ev){ |
| 2396 | ev.preventDefault(); |
| 2397 | ev.stopPropagation(); |
| 2398 | const msg = Chat.inputValue(); |
| 2399 | if( Chat.e.currentView===Chat.e.viewSearch ){ |
| 2400 | if( msg ) Chat.submitSearch(); |
| 2401 | else Chat.setCurrentView(Chat.e.viewMessages); |
| 2402 | }else{ |
| 2403 | Chat.setCurrentView(Chat.e.viewSearch); |
| 2404 | if( msg ) Chat.submitSearch(); |
| 2405 | } |
| 2406 | return false; |
| 2407 | }, false); |
| 2408 | Chat.e.viewSearch.querySelector('button.action-clear').addEventListener('click', function(ev){ |
| 2409 | ev.preventDefault(); |
| 2410 | ev.stopPropagation(); |
| 2411 | Chat.clearSearch(true); |
| 2412 | Chat.setCurrentView(Chat.e.viewMessages); |
| 2413 | return false; |
| 2414 | }, false); |
| 2415 | Chat.e.viewSearch.querySelector('button.action-close').addEventListener('click', function(ev){ |
| 2416 | ev.preventDefault(); |
| 2417 | ev.stopPropagation(); |
| 2418 | Chat.setCurrentView(Chat.e.viewMessages); |
| 2419 | return false; |
| 2420 | }, false); |
| 2421 | })()/*search view setup*/; |
| 2422 | |
| 2423 | /** Callback for poll() to inject new content into the page. jx == |
| 2424 | the response from /chat-poll. If atEnd is true, the message is |
| 2425 | appended to the end of the chat list (for loading older |
| 2426 | messages), else the beginning (the default). */ |
| @@ -2307,10 +2549,82 @@ | |
| 2549 | btn.addEventListener('click',()=>loadOldMessages(-1)); |
| 2550 | D.append(Chat.e.viewMessages, toolbar); |
| 2551 | toolbar.disabled = true /*will be enabled when msg load finishes */; |
| 2552 | })()/*end history loading widget setup*/; |
| 2553 | |
| 2554 | /** |
| 2555 | Clears the search result view. If addInstructions is true it adds |
| 2556 | text to that view instructing the user to enter their query into |
| 2557 | the message-entry widget (noting that that widget has text |
| 2558 | implying that it's only for submitting a message, which isn't |
| 2559 | exactly true when the search view is active). |
| 2560 | |
| 2561 | Returns the DOM element which wraps all of the chat search |
| 2562 | result elements. |
| 2563 | */ |
| 2564 | Chat.clearSearch = function(addInstructions=false){ |
| 2565 | const e = D.clearElement( this.e.searchContent ); |
| 2566 | if(addInstructions){ |
| 2567 | D.append(e, "Enter search terms in the message field. "+ |
| 2568 | "Use #NNNNN to search for the message with ID NNNNN."); |
| 2569 | } |
| 2570 | return e; |
| 2571 | }; |
| 2572 | Chat.clearSearch(true); |
| 2573 | /** |
| 2574 | Submits a history search using the main input field's current |
| 2575 | text. It is assumed that Chat.e.viewSearch===Chat.e.currentView. |
| 2576 | */ |
| 2577 | Chat.submitSearch = function(){ |
| 2578 | const term = this.inputValue(true); |
| 2579 | const eMsgTgt = this.clearSearch(true); |
| 2580 | if( !term ) return; |
| 2581 | D.append( eMsgTgt, "Searching for ",term," ..."); |
| 2582 | const fd = new FormData(); |
| 2583 | fd.set('q', term); |
| 2584 | F.fetch( |
| 2585 | "chat-query", { |
| 2586 | payload: fd, |
| 2587 | responseType: 'json', |
| 2588 | onerror:function(err){ |
| 2589 | Chat.setCurrentView(Chat.e.viewMessages); |
| 2590 | Chat.reportErrorAsMessage(err); |
| 2591 | }, |
| 2592 | onload:function(jx){ |
| 2593 | let previd = 0; |
| 2594 | D.clearElement(eMsgTgt); |
| 2595 | jx.msgs.forEach((m)=>{ |
| 2596 | m.isSearchResult = true; |
| 2597 | const mw = new Chat.MessageWidget(m); |
| 2598 | const spacer = new Chat.SearchCtxLoader({ |
| 2599 | first: jx.first, |
| 2600 | last: jx.last, |
| 2601 | previd: previd, |
| 2602 | nextid: m.msgid |
| 2603 | }); |
| 2604 | if( spacer.e ) D.append( eMsgTgt, spacer.e.body ); |
| 2605 | D.append( eMsgTgt, mw.e.body ); |
| 2606 | previd = m.msgid; |
| 2607 | }); |
| 2608 | if( jx.msgs.length ){ |
| 2609 | const spacer = new Chat.SearchCtxLoader({ |
| 2610 | first: jx.first, |
| 2611 | last: jx.last, |
| 2612 | previd: previd, |
| 2613 | nextid: 0 |
| 2614 | }); |
| 2615 | if( spacer.e ) D.append( eMsgTgt, spacer.e.body ); |
| 2616 | }else{ |
| 2617 | D.append( D.clearElement(eMsgTgt), |
| 2618 | 'No search results found for: ', |
| 2619 | term ); |
| 2620 | } |
| 2621 | } |
| 2622 | } |
| 2623 | ); |
| 2624 | }/*Chat.submitSearch()*/; |
| 2625 | |
| 2626 | const afterFetch = function f(){ |
| 2627 | if(true===f.isFirstCall){ |
| 2628 | f.isFirstCall = false; |
| 2629 | Chat.ajaxEnd(); |
| 2630 | Chat.e.viewMessages.classList.remove('loading'); |
| @@ -2326,10 +2640,25 @@ | |
| 2640 | delete Chat.intervalTimer; |
| 2641 | } |
| 2642 | poll.running = false; |
| 2643 | }; |
| 2644 | afterFetch.isFirstCall = true; |
| 2645 | /** |
| 2646 | FIXME: when polling fails because the remote server is |
| 2647 | reachable but it's not accepting HTTP requests, we should back |
| 2648 | off on polling for a while. e.g. if the remote web server process |
| 2649 | is killed, the poll fails quickly and immediately retries, |
| 2650 | hammering the remote server until the httpd is back up. That |
| 2651 | happens often during development of this application. |
| 2652 | |
| 2653 | XHR does not offer a direct way of distinguishing between |
| 2654 | HTTP/connection errors, but we can hypothetically use the |
| 2655 | xhrRequest.status value to do so, with status==0 being a |
| 2656 | connection error. We do not currently have a clean way of passing |
| 2657 | that info back to the fossil.fetch() client, so we'll need to |
| 2658 | hammer on that API a bit to get this working. |
| 2659 | */ |
| 2660 | const poll = async function f(){ |
| 2661 | if(f.running) return; |
| 2662 | f.running = true; |
| 2663 | Chat._isBatchLoading = f.isFirstCall; |
| 2664 | if(true===f.isFirstCall){ |
| @@ -2368,17 +2697,11 @@ | |
| 2697 | Chat._gotServerError = poll.running = false; |
| 2698 | if( window.fossil.config.chat.fromcli ){ |
| 2699 | Chat.chatOnlyMode(true); |
| 2700 | } |
| 2701 | Chat.intervalTimer = setInterval(poll, 1000); |
| 2702 | delete ForceResizeKludge.$disabled; |
| 2703 | ForceResizeKludge(); |
| 2704 | Chat.animate.$disabled = false; |
| 2705 | setTimeout( ()=>Chat.inputFocus(), 0 ); |
| 2706 | F.page.chat = Chat/* enables testing the APIs via the dev tools */; |
| 2707 | }); |
| 2708 |
+371
-48
| --- src/fossil.page.chat.js | ||
| +++ src/fossil.page.chat.js | ||
| @@ -145,10 +145,12 @@ | ||
| 145 | 145 | inputFile: E1('#chat-input-file'), |
| 146 | 146 | contentDiv: E1('div.content'), |
| 147 | 147 | viewConfig: E1('#chat-config'), |
| 148 | 148 | viewPreview: E1('#chat-preview'), |
| 149 | 149 | previewContent: E1('#chat-preview-content'), |
| 150 | + viewSearch: E1('#chat-search'), | |
| 151 | + searchContent: E1('#chat-search-content'), | |
| 150 | 152 | btnPreview: E1('#chat-button-preview'), |
| 151 | 153 | views: document.querySelectorAll('.chat-view'), |
| 152 | 154 | activeUserListWrapper: E1('#chat-user-list-wrapper'), |
| 153 | 155 | activeUserList: E1('#chat-user-list'), |
| 154 | 156 | btnClearFilter: E1('#chat-clear-filter') |
| @@ -190,21 +192,31 @@ | ||
| 190 | 192 | || !!e.querySelector('[data-hashtag="'+this.activeTag+'"]'); |
| 191 | 193 | } |
| 192 | 194 | }, |
| 193 | 195 | current: undefined/*gets set to current active filter*/ |
| 194 | 196 | }, |
| 195 | - /** Gets (no args) or sets (1 arg) the current input text field value, | |
| 196 | - taking into account single- vs multi-line input. The getter returns | |
| 197 | - a string and the setter returns this object. */ | |
| 198 | - inputValue: function(){ | |
| 197 | + /** | |
| 198 | + Gets (no args) or sets (1 arg) the current input text field | |
| 199 | + value, taking into account single- vs multi-line input. The | |
| 200 | + getter returns a trim()'d string and the setter returns this | |
| 201 | + object. As a special case, if arguments[0] is a boolean | |
| 202 | + value, it behaves like a getter and, if arguments[0]===true | |
| 203 | + it clears the input field before returning. | |
| 204 | + */ | |
| 205 | + inputValue: function(/*string newValue | bool clearInputField*/){ | |
| 199 | 206 | const e = this.inputElement(); |
| 200 | - if(arguments.length){ | |
| 207 | + if(arguments.length && 'boolean'!==typeof arguments[0]){ | |
| 201 | 208 | if(e.isContentEditable) e.innerText = arguments[0]; |
| 202 | 209 | else e.value = arguments[0]; |
| 203 | 210 | return this; |
| 204 | 211 | } |
| 205 | - return e.isContentEditable ? e.innerText : e.value; | |
| 212 | + const rc = e.isContentEditable ? e.innerText : e.value; | |
| 213 | + if( true===arguments[0] ){ | |
| 214 | + if(e.isContentEditable) e.innerText = ''; | |
| 215 | + else e.value = ''; | |
| 216 | + } | |
| 217 | + return rc && rc.trim(); | |
| 206 | 218 | }, |
| 207 | 219 | /** Asks the current user input field to take focus. Returns this. */ |
| 208 | 220 | inputFocus: function(){ |
| 209 | 221 | this.inputElement().focus(); |
| 210 | 222 | return this; |
| @@ -529,11 +541,11 @@ | ||
| 529 | 541 | const uDate = self.usersLastSeen[u]; |
| 530 | 542 | if(self.filter.user.activeTag===u){ |
| 531 | 543 | uSpan.classList.add('selected'); |
| 532 | 544 | } |
| 533 | 545 | uSpan.dataset.uname = u; |
| 534 | - D.append(uSpan, u, "\n", | |
| 546 | + D.append(uSpan, u, "\n", | |
| 535 | 547 | D.append( |
| 536 | 548 | D.addClass(D.span(),'timestamp'), |
| 537 | 549 | localTimeString(uDate)//.substr(5/*chop off year*/) |
| 538 | 550 | )); |
| 539 | 551 | if(uDate.$uColor){ |
| @@ -1075,11 +1087,11 @@ | ||
| 1075 | 1087 | Chat.MessageWidget = (function(){ |
| 1076 | 1088 | /** |
| 1077 | 1089 | Constructor. If passed an argument, it is passed to |
| 1078 | 1090 | this.setMessage() after initialization. |
| 1079 | 1091 | */ |
| 1080 | - const cf = function(){ | |
| 1092 | + const ctor = function(){ | |
| 1081 | 1093 | this.e = { |
| 1082 | 1094 | body: D.addClass(D.div(), 'message-widget'), |
| 1083 | 1095 | tab: D.addClass(D.div(), 'message-widget-tab'), |
| 1084 | 1096 | content: D.addClass(D.div(), 'message-widget-content') |
| 1085 | 1097 | }; |
| @@ -1094,20 +1106,33 @@ | ||
| 1094 | 1106 | /* Map of Date.getDay() values to weekday names. */ |
| 1095 | 1107 | 0: "Sunday", 1: "Monday", 2: "Tuesday", |
| 1096 | 1108 | 3: "Wednesday", 4: "Thursday", 5: "Friday", |
| 1097 | 1109 | 6: "Saturday" |
| 1098 | 1110 | }; |
| 1099 | - /* Given a Date, returns the timestamp string used in the | |
| 1100 | - "tab" part of message widgets. */ | |
| 1101 | - const theTime = function(d){ | |
| 1102 | - return [ | |
| 1103 | - //d.getFullYear(),'-',pad2(d.getMonth()+1/*sigh*/), | |
| 1104 | - //'-',pad2(d.getDate()), ' ', | |
| 1105 | - d.getHours(),":", | |
| 1106 | - (d.getMinutes()+100).toString().slice(1,3), | |
| 1107 | - ' ', dowMap[d.getDay()] | |
| 1108 | - ].join(''); | |
| 1111 | + /* Given a Date, returns the timestamp string used in the "tab" | |
| 1112 | + part of message widgets. If longFmt is true then a verbose | |
| 1113 | + format is used, else a brief format is used. The returned string | |
| 1114 | + is in client-local time. */ | |
| 1115 | + const theTime = function(d, longFmt=false){ | |
| 1116 | + const li = []; | |
| 1117 | + if( longFmt ){ | |
| 1118 | + li.push( | |
| 1119 | + d.getFullYear(), | |
| 1120 | + '-', pad2(d.getMonth()+1), | |
| 1121 | + '-', pad2(d.getDate()), | |
| 1122 | + ' ', | |
| 1123 | + d.getHours(), ":", | |
| 1124 | + (d.getMinutes()+100).toString().slice(1,3) | |
| 1125 | + ); | |
| 1126 | + }else{ | |
| 1127 | + li.push( | |
| 1128 | + d.getHours(),":", | |
| 1129 | + (d.getMinutes()+100).toString().slice(1,3), | |
| 1130 | + ' ', dowMap[d.getDay()] | |
| 1131 | + ); | |
| 1132 | + } | |
| 1133 | + return li.join(''); | |
| 1109 | 1134 | }; |
| 1110 | 1135 | |
| 1111 | 1136 | /** |
| 1112 | 1137 | Returns true if this page believes it can embed a view of the |
| 1113 | 1138 | file wrapped by the given message object, else returns false. |
| @@ -1114,19 +1139,20 @@ | ||
| 1114 | 1139 | */ |
| 1115 | 1140 | const canEmbedFile = function f(msg){ |
| 1116 | 1141 | if(!f.$rx){ |
| 1117 | 1142 | f.$rx = /\.((html?)|(txt)|(md)|(wiki)|(pikchr))$/i; |
| 1118 | 1143 | f.$specificTypes = [ |
| 1144 | + /* Mime types we know we can embed, sans image/... */ | |
| 1119 | 1145 | 'text/plain', |
| 1120 | 1146 | 'text/html', |
| 1121 | 1147 | 'text/x-markdown', |
| 1122 | 1148 | /* Firefox sends text/markdown when uploading .md files */ |
| 1123 | 1149 | 'text/markdown', |
| 1124 | 1150 | 'text/x-pikchr', |
| 1125 | 1151 | 'text/x-fossil-wiki' |
| 1126 | - // add more as we discover which ones Firefox won't | |
| 1127 | - // force the user to try to download. | |
| 1152 | + /* Add more as we discover which ones Firefox won't | |
| 1153 | + force the user to try to download. */ | |
| 1128 | 1154 | ]; |
| 1129 | 1155 | } |
| 1130 | 1156 | if(msg.fmime){ |
| 1131 | 1157 | if(msg.fmime.startsWith("image/") |
| 1132 | 1158 | || f.$specificTypes.indexOf(msg.fmime)>=0){ |
| @@ -1140,20 +1166,18 @@ | ||
| 1140 | 1166 | Returns true if the given message object "should" |
| 1141 | 1167 | be embedded in fossil-rendered form instead of |
| 1142 | 1168 | raw content form. This is only intended to be passed |
| 1143 | 1169 | message objects for which canEmbedFile() returns true. |
| 1144 | 1170 | */ |
| 1145 | - const shouldWikiRenderEmbed = function f(msg){ | |
| 1171 | + const shouldFossilRenderEmbed = function f(msg){ | |
| 1146 | 1172 | if(!f.$rx){ |
| 1147 | 1173 | f.$rx = /\.((md)|(wiki)|(pikchr))$/i; |
| 1148 | 1174 | f.$specificTypes = [ |
| 1149 | 1175 | 'text/x-markdown', |
| 1150 | 1176 | 'text/markdown' /* Firefox-uploaded md files */, |
| 1151 | 1177 | 'text/x-pikchr', |
| 1152 | 1178 | 'text/x-fossil-wiki' |
| 1153 | - // add more as we discover which ones Firefox won't | |
| 1154 | - // force the user to try to download. | |
| 1155 | 1179 | ]; |
| 1156 | 1180 | } |
| 1157 | 1181 | if(msg.fmime){ |
| 1158 | 1182 | if(f.$specificTypes.indexOf(msg.fmime)>=0) return true; |
| 1159 | 1183 | } |
| @@ -1179,12 +1203,12 @@ | ||
| 1179 | 1203 | iframe.style.maxHeight = iframe.style.height |
| 1180 | 1204 | = iframe.contentWindow.document.documentElement.scrollHeight + 'px'; |
| 1181 | 1205 | if(isHidden) D.addClass(iframe, 'hidden'); |
| 1182 | 1206 | } |
| 1183 | 1207 | }; |
| 1184 | - | |
| 1185 | - cf.prototype = { | |
| 1208 | + | |
| 1209 | + ctor.prototype = { | |
| 1186 | 1210 | scrollIntoView: function(){ |
| 1187 | 1211 | this.e.content.scrollIntoView(); |
| 1188 | 1212 | }, |
| 1189 | 1213 | setMessage: function(m){ |
| 1190 | 1214 | const ds = this.e.body.dataset; |
| @@ -1205,20 +1229,26 @@ | ||
| 1205 | 1229 | var eXFrom /* element holding xfrom name */; |
| 1206 | 1230 | if(m.xfrom){ |
| 1207 | 1231 | eXFrom = D.append(D.addClass(D.span(), 'xfrom'), m.xfrom); |
| 1208 | 1232 | const wrapper = D.append( |
| 1209 | 1233 | D.span(), eXFrom, |
| 1210 | - D.text(" #",(m.msgid||'???'),' @ ',theTime(d))) | |
| 1234 | + ' ', | |
| 1235 | + D.append(D.addClass(D.span(), 'msgid'), | |
| 1236 | + '#' + (m.msgid||'???')), | |
| 1237 | + (m.isSearchResult ? ' ' : ' @ '), | |
| 1238 | + D.append(D.addClass(D.span(), 'timestamp'), | |
| 1239 | + theTime(d,!!m.isSearchResult)) | |
| 1240 | + ); | |
| 1211 | 1241 | D.append(this.e.tab, wrapper); |
| 1212 | 1242 | }else{/*notification*/ |
| 1213 | 1243 | D.addClass(this.e.body, 'notification'); |
| 1214 | 1244 | if(m.isError){ |
| 1215 | 1245 | D.addClass([contentTarget, this.e.tab], 'error'); |
| 1216 | 1246 | } |
| 1217 | 1247 | D.append( |
| 1218 | 1248 | this.e.tab, |
| 1219 | - D.append(D.code(), 'notification @ ',theTime(d)) | |
| 1249 | + D.append(D.code(), 'notification @ ',theTime(d,false)) | |
| 1220 | 1250 | ); |
| 1221 | 1251 | } |
| 1222 | 1252 | if( m.xfrom && m.fsize>0 ){ |
| 1223 | 1253 | if( m.fmime |
| 1224 | 1254 | && m.fmime.startsWith("image/") |
| @@ -1241,18 +1271,18 @@ | ||
| 1241 | 1271 | D.attr(a,'target','_blank'); |
| 1242 | 1272 | D.append(w, a); |
| 1243 | 1273 | if(canEmbedFile(m)){ |
| 1244 | 1274 | /* Add an option to embed HTML attachments in an iframe. The primary |
| 1245 | 1275 | use case is attached diffs. */ |
| 1246 | - const shouldWikiRender = shouldWikiRenderEmbed(m); | |
| 1247 | - const downloadArgs = shouldWikiRender ? '?render' : ''; | |
| 1276 | + const shouldFossilRender = shouldFossilRenderEmbed(m); | |
| 1277 | + const downloadArgs = shouldFossilRender ? '?render' : ''; | |
| 1248 | 1278 | D.addClass(contentTarget, 'wide'); |
| 1249 | 1279 | const embedTarget = this.e.content; |
| 1250 | 1280 | const self = this; |
| 1251 | 1281 | const btnEmbed = D.attr(D.checkbox("1", false), 'id', |
| 1252 | 1282 | 'embed-'+ds.msgid); |
| 1253 | - const btnLabel = D.label(btnEmbed, shouldWikiRender | |
| 1283 | + const btnLabel = D.label(btnEmbed, shouldFossilRender | |
| 1254 | 1284 | ? "Embed (fossil-rendered)" : "Embed"); |
| 1255 | 1285 | /* Maintenance reminder: do not disable the toggle |
| 1256 | 1286 | button while the content is loading because that will |
| 1257 | 1287 | cause it to get stuck in disabled mode if the browser |
| 1258 | 1288 | decides that loading the content should prompt the |
| @@ -1460,13 +1490,182 @@ | ||
| 1460 | 1490 | Chat.setCurrentView(Chat.e.viewMessages); |
| 1461 | 1491 | e.scrollIntoView(false); |
| 1462 | 1492 | Chat.animate(e, 'anim-fade-out-in'); |
| 1463 | 1493 | } |
| 1464 | 1494 | }; |
| 1465 | - return cf; | |
| 1495 | + return ctor; | |
| 1466 | 1496 | })()/*MessageWidget*/; |
| 1467 | 1497 | |
| 1498 | + /** | |
| 1499 | + A widget for loading more messages (context) around a /chat-query | |
| 1500 | + result message. | |
| 1501 | + */ | |
| 1502 | + Chat.SearchCtxLoader = (function(){ | |
| 1503 | + const nMsgContext = 5; | |
| 1504 | + const zUpArrow = '\u25B2'; | |
| 1505 | + const zDownArrow = '\u25BC'; | |
| 1506 | + const ctor = function(o){ | |
| 1507 | + | |
| 1508 | + /* iFirstInTable: | |
| 1509 | + ** msgid of first row in chatfts table. | |
| 1510 | + ** | |
| 1511 | + ** iLastInTable: | |
| 1512 | + ** msgid of last row in chatfts table. | |
| 1513 | + ** | |
| 1514 | + ** iPrevId: | |
| 1515 | + ** msgid of message immediately above this spacer. Or 0 if this | |
| 1516 | + ** spacer is above all results. | |
| 1517 | + ** | |
| 1518 | + ** iNextId: | |
| 1519 | + ** msgid of message immediately below this spacer. Or 0 if this | |
| 1520 | + ** spacer is below all results. | |
| 1521 | + ** | |
| 1522 | + ** bIgnoreClick: | |
| 1523 | + ** ignore any clicks if this is true. This is used to ensure there | |
| 1524 | + ** is only ever one request belonging to this widget outstanding | |
| 1525 | + ** at any time. | |
| 1526 | + */ | |
| 1527 | + this.o = { | |
| 1528 | + iFirstInTable: o.first, | |
| 1529 | + iLastInTable: o.last, | |
| 1530 | + iPrevId: o.previd, | |
| 1531 | + iNextId: o.nextid, | |
| 1532 | + bIgnoreClick: false | |
| 1533 | + }; | |
| 1534 | + | |
| 1535 | + this.e = { | |
| 1536 | + body: D.addClass(D.div(), 'spacer-widget'), | |
| 1537 | + up: D.addClass( | |
| 1538 | + D.button(zDownArrow+' Load '+nMsgContext+' more '+zDownArrow), | |
| 1539 | + 'up' | |
| 1540 | + ), | |
| 1541 | + down: D.addClass( | |
| 1542 | + D.button(zUpArrow+' Load '+nMsgContext+' more '+zUpArrow), | |
| 1543 | + 'down' | |
| 1544 | + ), | |
| 1545 | + all: D.addClass(D.button('Load More'), 'all') | |
| 1546 | + }; | |
| 1547 | + D.append( this.e.body, this.e.up, this.e.down, this.e.all ); | |
| 1548 | + const ms = this; | |
| 1549 | + this.e.up.addEventListener('click', ()=>ms.load_messages(false)); | |
| 1550 | + this.e.down.addEventListener('click', ()=>ms.load_messages(true)); | |
| 1551 | + this.e.all.addEventListener('click', ()=>ms.load_messages( (ms.o.iPrevId==0) )); | |
| 1552 | + this.set_button_visibility(); | |
| 1553 | + }; | |
| 1554 | + | |
| 1555 | + ctor.prototype = { | |
| 1556 | + set_button_visibility: function() { | |
| 1557 | + if( !this.e ) return; | |
| 1558 | + const o = this.o; | |
| 1559 | + | |
| 1560 | + const iPrevId = (o.iPrevId!=0) ? o.iPrevId : o.iFirstInTable-1; | |
| 1561 | + const iNextId = (o.iNextId!=0) ? o.iNextId : o.iLastInTable+1; | |
| 1562 | + let nDiff = (iNextId - iPrevId) - 1; | |
| 1563 | + | |
| 1564 | + for( const x of [this.e.up, this.e.down, this.e.all] ){ | |
| 1565 | + if( x ) D.addClass(x, 'hidden'); | |
| 1566 | + } | |
| 1567 | + let nVisible = 0; | |
| 1568 | + if( nDiff>0 ){ | |
| 1569 | + if( nDiff>nMsgContext && (o.iPrevId==0 || o.iNextId==0) ){ | |
| 1570 | + nDiff = nMsgContext; | |
| 1571 | + } | |
| 1572 | + | |
| 1573 | + if( nDiff<=nMsgContext && o.iPrevId!=0 && o.iNextId!=0 ){ | |
| 1574 | + D.removeClass(this.e.all, 'hidden'); | |
| 1575 | + ++nVisible; | |
| 1576 | + this.e.all.innerText = ( | |
| 1577 | + zUpArrow + " Load " + nDiff + " more " + zDownArrow | |
| 1578 | + ); | |
| 1579 | + }else{ | |
| 1580 | + if( o.iPrevId!=0 ){ | |
| 1581 | + ++nVisible; | |
| 1582 | + D.removeClass(this.e.up, 'hidden'); | |
| 1583 | + }else if( this.e.up ){ | |
| 1584 | + if( this.e.up.parentNode ) D.remove(this.e.up); | |
| 1585 | + delete this.e.up; | |
| 1586 | + } | |
| 1587 | + if( o.iNextId!=0 ){ | |
| 1588 | + ++nVisible; | |
| 1589 | + D.removeClass(this.e.down, 'hidden'); | |
| 1590 | + }else if( this.e.down ){ | |
| 1591 | + if( this.e.down.parentNode ) D.remove( this.e.down ); | |
| 1592 | + delete this.e.down; | |
| 1593 | + } | |
| 1594 | + } | |
| 1595 | + } | |
| 1596 | + if( !nVisible ){ | |
| 1597 | + /* The DOM elements can now be disposed of. */ | |
| 1598 | + for( const x of [this.e.up, this.e.down, this.e.all, this.e.body] ){ | |
| 1599 | + if( x?.parentNode ) D.remove(x); | |
| 1600 | + } | |
| 1601 | + delete this.e; | |
| 1602 | + } | |
| 1603 | + }, | |
| 1604 | + | |
| 1605 | + load_messages: function(bDown) { | |
| 1606 | + if( this.bIgnoreClick ) return; | |
| 1607 | + | |
| 1608 | + var iFirst = 0; /* msgid of first message to fetch */ | |
| 1609 | + var nFetch = 0; /* Number of messages to fetch */ | |
| 1610 | + var iEof = 0; /* last msgid in spacers range, plus 1 */ | |
| 1611 | + | |
| 1612 | + const e = this.e, o = this.o; | |
| 1613 | + this.bIgnoreClick = true; | |
| 1614 | + | |
| 1615 | + /* Figure out the required range of messages. */ | |
| 1616 | + if( bDown ){ | |
| 1617 | + iFirst = this.o.iNextId - nMsgContext; | |
| 1618 | + if( iFirst<this.o.iFirstInTable ){ | |
| 1619 | + iFirst = this.o.iFirstInTable; | |
| 1620 | + } | |
| 1621 | + }else{ | |
| 1622 | + iFirst = this.o.iPrevId+1; | |
| 1623 | + } | |
| 1624 | + nFetch = nMsgContext; | |
| 1625 | + iEof = (this.o.iNextId > 0) ? this.o.iNextId : this.o.iLastInTable+1; | |
| 1626 | + if( iFirst+nFetch>iEof ){ | |
| 1627 | + nFetch = iEof - iFirst; | |
| 1628 | + } | |
| 1629 | + const ms = this; | |
| 1630 | + F.fetch("chat-query",{ | |
| 1631 | + urlParams:{ | |
| 1632 | + q: '', | |
| 1633 | + n: nFetch, | |
| 1634 | + i: iFirst | |
| 1635 | + }, | |
| 1636 | + responseType: "json", | |
| 1637 | + onload:function(jx){ | |
| 1638 | + if( bDown ) jx.msgs.reverse(); | |
| 1639 | + jx.msgs.forEach((m) => { | |
| 1640 | + var mw = new Chat.MessageWidget(m); | |
| 1641 | + if( bDown ){ | |
| 1642 | + /* Inject the message below this object's body, or | |
| 1643 | + append it to Chat.e.searchContent if this element | |
| 1644 | + is the final one in its parent (Chat.e.searchContent). */ | |
| 1645 | + const eAnchor = e.body.nextElementSibling; | |
| 1646 | + if( eAnchor ) Chat.e.searchContent.insertBefore(mw.e.body, eAnchor); | |
| 1647 | + else D.append(Chat.e.searchContent, mw.e.body); | |
| 1648 | + }else{ | |
| 1649 | + Chat.e.searchContent.insertBefore(mw.e.body, e.body); | |
| 1650 | + } | |
| 1651 | + }); | |
| 1652 | + if( bDown ){ | |
| 1653 | + o.iNextId -= jx.msgs.length; | |
| 1654 | + }else{ | |
| 1655 | + o.iPrevId += jx.msgs.length; | |
| 1656 | + } | |
| 1657 | + ms.set_button_visibility(); | |
| 1658 | + ms.bIgnoreClick = false; | |
| 1659 | + } | |
| 1660 | + }); | |
| 1661 | + } | |
| 1662 | + }; | |
| 1663 | + | |
| 1664 | + return ctor; | |
| 1665 | + })() /*SearchCtxLoader*/; | |
| 1666 | + | |
| 1468 | 1667 | const BlobXferState = (function(){ |
| 1469 | 1668 | /* State for paste and drag/drop */ |
| 1470 | 1669 | const bxs = { |
| 1471 | 1670 | dropDetails: document.querySelector('#chat-drop-details'), |
| 1472 | 1671 | blob: undefined, |
| @@ -1605,16 +1804,26 @@ | ||
| 1605 | 1804 | |
| 1606 | 1805 | /** |
| 1607 | 1806 | Submits the contents of the message input field (if not empty) |
| 1608 | 1807 | and/or the file attachment field to the server. If both are |
| 1609 | 1808 | empty, this is a no-op. |
| 1809 | + | |
| 1810 | + If the current view is the history search, this instead sends the | |
| 1811 | + input text to that widget. | |
| 1610 | 1812 | */ |
| 1611 | 1813 | Chat.submitMessage = function f(){ |
| 1612 | 1814 | if(!f.spaces){ |
| 1613 | 1815 | f.spaces = /\s+$/; |
| 1614 | 1816 | f.markdownContinuation = /\\\s+$/; |
| 1615 | 1817 | f.spaces2 = /\s{3,}$/; |
| 1818 | + } | |
| 1819 | + switch( this.e.currentView ){ | |
| 1820 | + case this.e.viewSearch: this.submitSearch(); | |
| 1821 | + return; | |
| 1822 | + case this.e.viewPreview: this.e.btnPreview.click(); | |
| 1823 | + return; | |
| 1824 | + default: break; | |
| 1616 | 1825 | } |
| 1617 | 1826 | this.setCurrentView(this.e.viewMessages); |
| 1618 | 1827 | const fd = new FormData(); |
| 1619 | 1828 | const fallback = {msg: this.inputValue()}; |
| 1620 | 1829 | var msg = fallback.msg; |
| @@ -1687,14 +1896,16 @@ | ||
| 1687 | 1896 | //console.debug("Enter key event:", ctrlMode, ev.ctrlKey, ev.shiftKey, ev); |
| 1688 | 1897 | if(ev.shiftKey){ |
| 1689 | 1898 | const compactMode = Chat.settings.getBool('edit-compact-mode', false); |
| 1690 | 1899 | ev.preventDefault(); |
| 1691 | 1900 | ev.stopPropagation(); |
| 1692 | - /* Shift-enter will run preview mode UNLESS preview mode is | |
| 1693 | - active AND the input field is empty, in which case it will | |
| 1901 | + /* Shift-enter will run preview mode UNLESS the input field is empty | |
| 1902 | + AND (preview or search mode) is active, in which cases it will | |
| 1694 | 1903 | switch back to message view. */ |
| 1695 | - if(Chat.e.currentView===Chat.e.viewPreview && !text){ | |
| 1904 | + if(!text && | |
| 1905 | + (Chat.e.currentView===Chat.e.viewPreview | |
| 1906 | + | Chat.e.currentView===Chat.e.viewSearch)){ | |
| 1696 | 1907 | Chat.setCurrentView(Chat.e.viewMessages); |
| 1697 | 1908 | }else if(!text){ |
| 1698 | 1909 | f.$toggleCompact(compactMode); |
| 1699 | 1910 | }else if(Chat.settings.getBool('edit-shift-enter-preview', true)){ |
| 1700 | 1911 | Chat.e.btnPreview.click(); |
| @@ -1752,19 +1963,19 @@ | ||
| 1752 | 1963 | tall vs wide. Can be toggled via settings. */ |
| 1753 | 1964 | document.body.classList.add('my-messages-right'); |
| 1754 | 1965 | } |
| 1755 | 1966 | const settingsButton = document.querySelector('#chat-button-settings'); |
| 1756 | 1967 | const optionsMenu = E1('#chat-config-options'); |
| 1757 | - const cbToggle = function(ev){ | |
| 1968 | + const eToggleView = function(ev){ | |
| 1758 | 1969 | ev.preventDefault(); |
| 1759 | 1970 | ev.stopPropagation(); |
| 1760 | 1971 | Chat.setCurrentView(Chat.e.currentView===Chat.e.viewConfig |
| 1761 | 1972 | ? Chat.e.viewMessages : Chat.e.viewConfig); |
| 1762 | 1973 | return false; |
| 1763 | 1974 | }; |
| 1764 | - D.attr(settingsButton, 'role', 'button').addEventListener('click', cbToggle, false); | |
| 1765 | - Chat.e.viewConfig.querySelector('button').addEventListener('click', cbToggle, false); | |
| 1975 | + D.attr(settingsButton, 'role', 'button').addEventListener('click', eToggleView, false); | |
| 1976 | + Chat.e.viewConfig.querySelector('button.action-close').addEventListener('click', eToggleView, false); | |
| 1766 | 1977 | |
| 1767 | 1978 | /** Internal acrobatics to allow certain settings toggles to access |
| 1768 | 1979 | related toggles. */ |
| 1769 | 1980 | const namedOptions = { |
| 1770 | 1981 | activeUsers:{ |
| @@ -1850,12 +2061,13 @@ | ||
| 1850 | 2061 | boolValue: 'edit-ctrl-send' |
| 1851 | 2062 | },{ |
| 1852 | 2063 | label: "Compact mode", |
| 1853 | 2064 | hint: [ |
| 1854 | 2065 | "Toggle between a space-saving or more spacious writing area. ", |
| 1855 | - "When the input field has focus, is empty, and preview mode ", | |
| 1856 | - "is NOT active then Shift-Enter toggles this setting."].join(''), | |
| 2066 | + "When the input field has focus and is empty ", | |
| 2067 | + "then Shift-Enter may (depending on the current view) toggle this setting." | |
| 2068 | + ].join(''), | |
| 1857 | 2069 | boolValue: 'edit-compact-mode' |
| 1858 | 2070 | },{ |
| 1859 | 2071 | label: "Use 'contenteditable' editing mode", |
| 1860 | 2072 | boolValue: 'edit-widget-x', |
| 1861 | 2073 | hint: [ |
| @@ -2020,11 +2232,11 @@ | ||
| 2020 | 2232 | op.persistentSetting, |
| 2021 | 2233 | function(setting){ |
| 2022 | 2234 | if(op.checkbox) op.checkbox.checked = !!setting.value; |
| 2023 | 2235 | else if(op.select) op.select.value = setting.value; |
| 2024 | 2236 | if(op.callback) op.callback(setting); |
| 2025 | - } | |
| 2237 | + } | |
| 2026 | 2238 | ); |
| 2027 | 2239 | if(op.checkbox){ |
| 2028 | 2240 | op.checkbox.addEventListener( |
| 2029 | 2241 | 'change', function(){ |
| 2030 | 2242 | Chat.settings.set(op.persistentSetting, op.checkbox.checked) |
| @@ -2096,11 +2308,11 @@ | ||
| 2096 | 2308 | s.value ? 'add' : 'remove' |
| 2097 | 2309 | ]('compact'); |
| 2098 | 2310 | Chat.e.inputFields[Chat.e.inputFields.$currentIndex].focus(); |
| 2099 | 2311 | }); |
| 2100 | 2312 | Chat.settings.addListener('edit-ctrl-send',function(s){ |
| 2101 | - const label = (s.value ? "Ctrl-" : "")+"Enter submits messages."; | |
| 2313 | + const label = (s.value ? "Ctrl-" : "")+"Enter submits message"; | |
| 2102 | 2314 | Chat.e.inputFields.forEach((e)=>{ |
| 2103 | 2315 | const v = e.dataset.placeholder0 + " " +label; |
| 2104 | 2316 | if(e.isContentEditable) e.dataset.placeholder = v; |
| 2105 | 2317 | else D.attr(e,'placeholder',v); |
| 2106 | 2318 | }); |
| @@ -2128,11 +2340,11 @@ | ||
| 2128 | 2340 | this.e.previewContent.innerHTML = t; |
| 2129 | 2341 | this.e.viewPreview.querySelectorAll('a').forEach(addAnchorTargetBlank); |
| 2130 | 2342 | setupHashtags(this.e.previewContent)/*arguable, for usability reasons*/; |
| 2131 | 2343 | this.inputFocus(); |
| 2132 | 2344 | }; |
| 2133 | - Chat.e.viewPreview.querySelector('#chat-preview-close'). | |
| 2345 | + Chat.e.viewPreview.querySelector('button.action-close'). | |
| 2134 | 2346 | addEventListener('click', ()=>Chat.setCurrentView(Chat.e.viewMessages), false); |
| 2135 | 2347 | let previewPending = false; |
| 2136 | 2348 | const elemsToEnable = [btnPreview, Chat.e.btnSubmit, Chat.e.inputFields]; |
| 2137 | 2349 | const submit = function(ev){ |
| 2138 | 2350 | ev.preventDefault(); |
| @@ -2175,10 +2387,40 @@ | ||
| 2175 | 2387 | }); |
| 2176 | 2388 | return false; |
| 2177 | 2389 | }; |
| 2178 | 2390 | btnPreview.addEventListener('click', submit, false); |
| 2179 | 2391 | })()/*message preview setup*/; |
| 2392 | + | |
| 2393 | + (function(){/*Set up #chat-search and related bits */ | |
| 2394 | + const btn = document.querySelector('#chat-button-search'); | |
| 2395 | + D.attr(btn, 'role', 'button').addEventListener('click', function(ev){ | |
| 2396 | + ev.preventDefault(); | |
| 2397 | + ev.stopPropagation(); | |
| 2398 | + const msg = Chat.inputValue(); | |
| 2399 | + if( Chat.e.currentView===Chat.e.viewSearch ){ | |
| 2400 | + if( msg ) Chat.submitSearch(); | |
| 2401 | + else Chat.setCurrentView(Chat.e.viewMessages); | |
| 2402 | + }else{ | |
| 2403 | + Chat.setCurrentView(Chat.e.viewSearch); | |
| 2404 | + if( msg ) Chat.submitSearch(); | |
| 2405 | + } | |
| 2406 | + return false; | |
| 2407 | + }, false); | |
| 2408 | + Chat.e.viewSearch.querySelector('button.action-clear').addEventListener('click', function(ev){ | |
| 2409 | + ev.preventDefault(); | |
| 2410 | + ev.stopPropagation(); | |
| 2411 | + Chat.clearSearch(true); | |
| 2412 | + Chat.setCurrentView(Chat.e.viewMessages); | |
| 2413 | + return false; | |
| 2414 | + }, false); | |
| 2415 | + Chat.e.viewSearch.querySelector('button.action-close').addEventListener('click', function(ev){ | |
| 2416 | + ev.preventDefault(); | |
| 2417 | + ev.stopPropagation(); | |
| 2418 | + Chat.setCurrentView(Chat.e.viewMessages); | |
| 2419 | + return false; | |
| 2420 | + }, false); | |
| 2421 | + })()/*search view setup*/; | |
| 2180 | 2422 | |
| 2181 | 2423 | /** Callback for poll() to inject new content into the page. jx == |
| 2182 | 2424 | the response from /chat-poll. If atEnd is true, the message is |
| 2183 | 2425 | appended to the end of the chat list (for loading older |
| 2184 | 2426 | messages), else the beginning (the default). */ |
| @@ -2307,10 +2549,82 @@ | ||
| 2307 | 2549 | btn.addEventListener('click',()=>loadOldMessages(-1)); |
| 2308 | 2550 | D.append(Chat.e.viewMessages, toolbar); |
| 2309 | 2551 | toolbar.disabled = true /*will be enabled when msg load finishes */; |
| 2310 | 2552 | })()/*end history loading widget setup*/; |
| 2311 | 2553 | |
| 2554 | + /** | |
| 2555 | + Clears the search result view. If addInstructions is true it adds | |
| 2556 | + text to that view instructing the user to enter their query into | |
| 2557 | + the message-entry widget (noting that that widget has text | |
| 2558 | + implying that it's only for submitting a message, which isn't | |
| 2559 | + exactly true when the search view is active). | |
| 2560 | + | |
| 2561 | + Returns the DOM element which wraps all of the chat search | |
| 2562 | + result elements. | |
| 2563 | + */ | |
| 2564 | + Chat.clearSearch = function(addInstructions=false){ | |
| 2565 | + const e = D.clearElement( this.e.searchContent ); | |
| 2566 | + if(addInstructions){ | |
| 2567 | + D.append(e, "Enter search terms in the message field. "+ | |
| 2568 | + "Use #NNNNN to search for the message with ID NNNNN."); | |
| 2569 | + } | |
| 2570 | + return e; | |
| 2571 | + }; | |
| 2572 | + Chat.clearSearch(true); | |
| 2573 | + /** | |
| 2574 | + Submits a history search using the main input field's current | |
| 2575 | + text. It is assumed that Chat.e.viewSearch===Chat.e.currentView. | |
| 2576 | + */ | |
| 2577 | + Chat.submitSearch = function(){ | |
| 2578 | + const term = this.inputValue(true); | |
| 2579 | + const eMsgTgt = this.clearSearch(true); | |
| 2580 | + if( !term ) return; | |
| 2581 | + D.append( eMsgTgt, "Searching for ",term," ..."); | |
| 2582 | + const fd = new FormData(); | |
| 2583 | + fd.set('q', term); | |
| 2584 | + F.fetch( | |
| 2585 | + "chat-query", { | |
| 2586 | + payload: fd, | |
| 2587 | + responseType: 'json', | |
| 2588 | + onerror:function(err){ | |
| 2589 | + Chat.setCurrentView(Chat.e.viewMessages); | |
| 2590 | + Chat.reportErrorAsMessage(err); | |
| 2591 | + }, | |
| 2592 | + onload:function(jx){ | |
| 2593 | + let previd = 0; | |
| 2594 | + D.clearElement(eMsgTgt); | |
| 2595 | + jx.msgs.forEach((m)=>{ | |
| 2596 | + m.isSearchResult = true; | |
| 2597 | + const mw = new Chat.MessageWidget(m); | |
| 2598 | + const spacer = new Chat.SearchCtxLoader({ | |
| 2599 | + first: jx.first, | |
| 2600 | + last: jx.last, | |
| 2601 | + previd: previd, | |
| 2602 | + nextid: m.msgid | |
| 2603 | + }); | |
| 2604 | + if( spacer.e ) D.append( eMsgTgt, spacer.e.body ); | |
| 2605 | + D.append( eMsgTgt, mw.e.body ); | |
| 2606 | + previd = m.msgid; | |
| 2607 | + }); | |
| 2608 | + if( jx.msgs.length ){ | |
| 2609 | + const spacer = new Chat.SearchCtxLoader({ | |
| 2610 | + first: jx.first, | |
| 2611 | + last: jx.last, | |
| 2612 | + previd: previd, | |
| 2613 | + nextid: 0 | |
| 2614 | + }); | |
| 2615 | + if( spacer.e ) D.append( eMsgTgt, spacer.e.body ); | |
| 2616 | + }else{ | |
| 2617 | + D.append( D.clearElement(eMsgTgt), | |
| 2618 | + 'No search results found for: ', | |
| 2619 | + term ); | |
| 2620 | + } | |
| 2621 | + } | |
| 2622 | + } | |
| 2623 | + ); | |
| 2624 | + }/*Chat.submitSearch()*/; | |
| 2625 | + | |
| 2312 | 2626 | const afterFetch = function f(){ |
| 2313 | 2627 | if(true===f.isFirstCall){ |
| 2314 | 2628 | f.isFirstCall = false; |
| 2315 | 2629 | Chat.ajaxEnd(); |
| 2316 | 2630 | Chat.e.viewMessages.classList.remove('loading'); |
| @@ -2326,10 +2640,25 @@ | ||
| 2326 | 2640 | delete Chat.intervalTimer; |
| 2327 | 2641 | } |
| 2328 | 2642 | poll.running = false; |
| 2329 | 2643 | }; |
| 2330 | 2644 | afterFetch.isFirstCall = true; |
| 2645 | + /** | |
| 2646 | + FIXME: when polling fails because the remote server is | |
| 2647 | + reachable but it's not accepting HTTP requests, we should back | |
| 2648 | + off on polling for a while. e.g. if the remote web server process | |
| 2649 | + is killed, the poll fails quickly and immediately retries, | |
| 2650 | + hammering the remote server until the httpd is back up. That | |
| 2651 | + happens often during development of this application. | |
| 2652 | + | |
| 2653 | + XHR does not offer a direct way of distinguishing between | |
| 2654 | + HTTP/connection errors, but we can hypothetically use the | |
| 2655 | + xhrRequest.status value to do so, with status==0 being a | |
| 2656 | + connection error. We do not currently have a clean way of passing | |
| 2657 | + that info back to the fossil.fetch() client, so we'll need to | |
| 2658 | + hammer on that API a bit to get this working. | |
| 2659 | + */ | |
| 2331 | 2660 | const poll = async function f(){ |
| 2332 | 2661 | if(f.running) return; |
| 2333 | 2662 | f.running = true; |
| 2334 | 2663 | Chat._isBatchLoading = f.isFirstCall; |
| 2335 | 2664 | if(true===f.isFirstCall){ |
| @@ -2368,17 +2697,11 @@ | ||
| 2368 | 2697 | Chat._gotServerError = poll.running = false; |
| 2369 | 2698 | if( window.fossil.config.chat.fromcli ){ |
| 2370 | 2699 | Chat.chatOnlyMode(true); |
| 2371 | 2700 | } |
| 2372 | 2701 | Chat.intervalTimer = setInterval(poll, 1000); |
| 2373 | - if(0){ | |
| 2374 | - const flip = (ev)=>Chat.animate(ev.target,'anim-flip-h'); | |
| 2375 | - document.querySelectorAll('#chat-buttons-wrapper .cbutton').forEach(function(e){ | |
| 2376 | - e.addEventListener('click',flip, false); | |
| 2377 | - }); | |
| 2378 | - } | |
| 2379 | 2702 | delete ForceResizeKludge.$disabled; |
| 2380 | 2703 | ForceResizeKludge(); |
| 2381 | 2704 | Chat.animate.$disabled = false; |
| 2382 | 2705 | setTimeout( ()=>Chat.inputFocus(), 0 ); |
| 2383 | 2706 | F.page.chat = Chat/* enables testing the APIs via the dev tools */; |
| 2384 | 2707 | }); |
| 2385 | 2708 |
| --- src/fossil.page.chat.js | |
| +++ src/fossil.page.chat.js | |
| @@ -145,10 +145,12 @@ | |
| 145 | inputFile: E1('#chat-input-file'), |
| 146 | contentDiv: E1('div.content'), |
| 147 | viewConfig: E1('#chat-config'), |
| 148 | viewPreview: E1('#chat-preview'), |
| 149 | previewContent: E1('#chat-preview-content'), |
| 150 | btnPreview: E1('#chat-button-preview'), |
| 151 | views: document.querySelectorAll('.chat-view'), |
| 152 | activeUserListWrapper: E1('#chat-user-list-wrapper'), |
| 153 | activeUserList: E1('#chat-user-list'), |
| 154 | btnClearFilter: E1('#chat-clear-filter') |
| @@ -190,21 +192,31 @@ | |
| 190 | || !!e.querySelector('[data-hashtag="'+this.activeTag+'"]'); |
| 191 | } |
| 192 | }, |
| 193 | current: undefined/*gets set to current active filter*/ |
| 194 | }, |
| 195 | /** Gets (no args) or sets (1 arg) the current input text field value, |
| 196 | taking into account single- vs multi-line input. The getter returns |
| 197 | a string and the setter returns this object. */ |
| 198 | inputValue: function(){ |
| 199 | const e = this.inputElement(); |
| 200 | if(arguments.length){ |
| 201 | if(e.isContentEditable) e.innerText = arguments[0]; |
| 202 | else e.value = arguments[0]; |
| 203 | return this; |
| 204 | } |
| 205 | return e.isContentEditable ? e.innerText : e.value; |
| 206 | }, |
| 207 | /** Asks the current user input field to take focus. Returns this. */ |
| 208 | inputFocus: function(){ |
| 209 | this.inputElement().focus(); |
| 210 | return this; |
| @@ -529,11 +541,11 @@ | |
| 529 | const uDate = self.usersLastSeen[u]; |
| 530 | if(self.filter.user.activeTag===u){ |
| 531 | uSpan.classList.add('selected'); |
| 532 | } |
| 533 | uSpan.dataset.uname = u; |
| 534 | D.append(uSpan, u, "\n", |
| 535 | D.append( |
| 536 | D.addClass(D.span(),'timestamp'), |
| 537 | localTimeString(uDate)//.substr(5/*chop off year*/) |
| 538 | )); |
| 539 | if(uDate.$uColor){ |
| @@ -1075,11 +1087,11 @@ | |
| 1075 | Chat.MessageWidget = (function(){ |
| 1076 | /** |
| 1077 | Constructor. If passed an argument, it is passed to |
| 1078 | this.setMessage() after initialization. |
| 1079 | */ |
| 1080 | const cf = function(){ |
| 1081 | this.e = { |
| 1082 | body: D.addClass(D.div(), 'message-widget'), |
| 1083 | tab: D.addClass(D.div(), 'message-widget-tab'), |
| 1084 | content: D.addClass(D.div(), 'message-widget-content') |
| 1085 | }; |
| @@ -1094,20 +1106,33 @@ | |
| 1094 | /* Map of Date.getDay() values to weekday names. */ |
| 1095 | 0: "Sunday", 1: "Monday", 2: "Tuesday", |
| 1096 | 3: "Wednesday", 4: "Thursday", 5: "Friday", |
| 1097 | 6: "Saturday" |
| 1098 | }; |
| 1099 | /* Given a Date, returns the timestamp string used in the |
| 1100 | "tab" part of message widgets. */ |
| 1101 | const theTime = function(d){ |
| 1102 | return [ |
| 1103 | //d.getFullYear(),'-',pad2(d.getMonth()+1/*sigh*/), |
| 1104 | //'-',pad2(d.getDate()), ' ', |
| 1105 | d.getHours(),":", |
| 1106 | (d.getMinutes()+100).toString().slice(1,3), |
| 1107 | ' ', dowMap[d.getDay()] |
| 1108 | ].join(''); |
| 1109 | }; |
| 1110 | |
| 1111 | /** |
| 1112 | Returns true if this page believes it can embed a view of the |
| 1113 | file wrapped by the given message object, else returns false. |
| @@ -1114,19 +1139,20 @@ | |
| 1114 | */ |
| 1115 | const canEmbedFile = function f(msg){ |
| 1116 | if(!f.$rx){ |
| 1117 | f.$rx = /\.((html?)|(txt)|(md)|(wiki)|(pikchr))$/i; |
| 1118 | f.$specificTypes = [ |
| 1119 | 'text/plain', |
| 1120 | 'text/html', |
| 1121 | 'text/x-markdown', |
| 1122 | /* Firefox sends text/markdown when uploading .md files */ |
| 1123 | 'text/markdown', |
| 1124 | 'text/x-pikchr', |
| 1125 | 'text/x-fossil-wiki' |
| 1126 | // add more as we discover which ones Firefox won't |
| 1127 | // force the user to try to download. |
| 1128 | ]; |
| 1129 | } |
| 1130 | if(msg.fmime){ |
| 1131 | if(msg.fmime.startsWith("image/") |
| 1132 | || f.$specificTypes.indexOf(msg.fmime)>=0){ |
| @@ -1140,20 +1166,18 @@ | |
| 1140 | Returns true if the given message object "should" |
| 1141 | be embedded in fossil-rendered form instead of |
| 1142 | raw content form. This is only intended to be passed |
| 1143 | message objects for which canEmbedFile() returns true. |
| 1144 | */ |
| 1145 | const shouldWikiRenderEmbed = function f(msg){ |
| 1146 | if(!f.$rx){ |
| 1147 | f.$rx = /\.((md)|(wiki)|(pikchr))$/i; |
| 1148 | f.$specificTypes = [ |
| 1149 | 'text/x-markdown', |
| 1150 | 'text/markdown' /* Firefox-uploaded md files */, |
| 1151 | 'text/x-pikchr', |
| 1152 | 'text/x-fossil-wiki' |
| 1153 | // add more as we discover which ones Firefox won't |
| 1154 | // force the user to try to download. |
| 1155 | ]; |
| 1156 | } |
| 1157 | if(msg.fmime){ |
| 1158 | if(f.$specificTypes.indexOf(msg.fmime)>=0) return true; |
| 1159 | } |
| @@ -1179,12 +1203,12 @@ | |
| 1179 | iframe.style.maxHeight = iframe.style.height |
| 1180 | = iframe.contentWindow.document.documentElement.scrollHeight + 'px'; |
| 1181 | if(isHidden) D.addClass(iframe, 'hidden'); |
| 1182 | } |
| 1183 | }; |
| 1184 | |
| 1185 | cf.prototype = { |
| 1186 | scrollIntoView: function(){ |
| 1187 | this.e.content.scrollIntoView(); |
| 1188 | }, |
| 1189 | setMessage: function(m){ |
| 1190 | const ds = this.e.body.dataset; |
| @@ -1205,20 +1229,26 @@ | |
| 1205 | var eXFrom /* element holding xfrom name */; |
| 1206 | if(m.xfrom){ |
| 1207 | eXFrom = D.append(D.addClass(D.span(), 'xfrom'), m.xfrom); |
| 1208 | const wrapper = D.append( |
| 1209 | D.span(), eXFrom, |
| 1210 | D.text(" #",(m.msgid||'???'),' @ ',theTime(d))) |
| 1211 | D.append(this.e.tab, wrapper); |
| 1212 | }else{/*notification*/ |
| 1213 | D.addClass(this.e.body, 'notification'); |
| 1214 | if(m.isError){ |
| 1215 | D.addClass([contentTarget, this.e.tab], 'error'); |
| 1216 | } |
| 1217 | D.append( |
| 1218 | this.e.tab, |
| 1219 | D.append(D.code(), 'notification @ ',theTime(d)) |
| 1220 | ); |
| 1221 | } |
| 1222 | if( m.xfrom && m.fsize>0 ){ |
| 1223 | if( m.fmime |
| 1224 | && m.fmime.startsWith("image/") |
| @@ -1241,18 +1271,18 @@ | |
| 1241 | D.attr(a,'target','_blank'); |
| 1242 | D.append(w, a); |
| 1243 | if(canEmbedFile(m)){ |
| 1244 | /* Add an option to embed HTML attachments in an iframe. The primary |
| 1245 | use case is attached diffs. */ |
| 1246 | const shouldWikiRender = shouldWikiRenderEmbed(m); |
| 1247 | const downloadArgs = shouldWikiRender ? '?render' : ''; |
| 1248 | D.addClass(contentTarget, 'wide'); |
| 1249 | const embedTarget = this.e.content; |
| 1250 | const self = this; |
| 1251 | const btnEmbed = D.attr(D.checkbox("1", false), 'id', |
| 1252 | 'embed-'+ds.msgid); |
| 1253 | const btnLabel = D.label(btnEmbed, shouldWikiRender |
| 1254 | ? "Embed (fossil-rendered)" : "Embed"); |
| 1255 | /* Maintenance reminder: do not disable the toggle |
| 1256 | button while the content is loading because that will |
| 1257 | cause it to get stuck in disabled mode if the browser |
| 1258 | decides that loading the content should prompt the |
| @@ -1460,13 +1490,182 @@ | |
| 1460 | Chat.setCurrentView(Chat.e.viewMessages); |
| 1461 | e.scrollIntoView(false); |
| 1462 | Chat.animate(e, 'anim-fade-out-in'); |
| 1463 | } |
| 1464 | }; |
| 1465 | return cf; |
| 1466 | })()/*MessageWidget*/; |
| 1467 | |
| 1468 | const BlobXferState = (function(){ |
| 1469 | /* State for paste and drag/drop */ |
| 1470 | const bxs = { |
| 1471 | dropDetails: document.querySelector('#chat-drop-details'), |
| 1472 | blob: undefined, |
| @@ -1605,16 +1804,26 @@ | |
| 1605 | |
| 1606 | /** |
| 1607 | Submits the contents of the message input field (if not empty) |
| 1608 | and/or the file attachment field to the server. If both are |
| 1609 | empty, this is a no-op. |
| 1610 | */ |
| 1611 | Chat.submitMessage = function f(){ |
| 1612 | if(!f.spaces){ |
| 1613 | f.spaces = /\s+$/; |
| 1614 | f.markdownContinuation = /\\\s+$/; |
| 1615 | f.spaces2 = /\s{3,}$/; |
| 1616 | } |
| 1617 | this.setCurrentView(this.e.viewMessages); |
| 1618 | const fd = new FormData(); |
| 1619 | const fallback = {msg: this.inputValue()}; |
| 1620 | var msg = fallback.msg; |
| @@ -1687,14 +1896,16 @@ | |
| 1687 | //console.debug("Enter key event:", ctrlMode, ev.ctrlKey, ev.shiftKey, ev); |
| 1688 | if(ev.shiftKey){ |
| 1689 | const compactMode = Chat.settings.getBool('edit-compact-mode', false); |
| 1690 | ev.preventDefault(); |
| 1691 | ev.stopPropagation(); |
| 1692 | /* Shift-enter will run preview mode UNLESS preview mode is |
| 1693 | active AND the input field is empty, in which case it will |
| 1694 | switch back to message view. */ |
| 1695 | if(Chat.e.currentView===Chat.e.viewPreview && !text){ |
| 1696 | Chat.setCurrentView(Chat.e.viewMessages); |
| 1697 | }else if(!text){ |
| 1698 | f.$toggleCompact(compactMode); |
| 1699 | }else if(Chat.settings.getBool('edit-shift-enter-preview', true)){ |
| 1700 | Chat.e.btnPreview.click(); |
| @@ -1752,19 +1963,19 @@ | |
| 1752 | tall vs wide. Can be toggled via settings. */ |
| 1753 | document.body.classList.add('my-messages-right'); |
| 1754 | } |
| 1755 | const settingsButton = document.querySelector('#chat-button-settings'); |
| 1756 | const optionsMenu = E1('#chat-config-options'); |
| 1757 | const cbToggle = function(ev){ |
| 1758 | ev.preventDefault(); |
| 1759 | ev.stopPropagation(); |
| 1760 | Chat.setCurrentView(Chat.e.currentView===Chat.e.viewConfig |
| 1761 | ? Chat.e.viewMessages : Chat.e.viewConfig); |
| 1762 | return false; |
| 1763 | }; |
| 1764 | D.attr(settingsButton, 'role', 'button').addEventListener('click', cbToggle, false); |
| 1765 | Chat.e.viewConfig.querySelector('button').addEventListener('click', cbToggle, false); |
| 1766 | |
| 1767 | /** Internal acrobatics to allow certain settings toggles to access |
| 1768 | related toggles. */ |
| 1769 | const namedOptions = { |
| 1770 | activeUsers:{ |
| @@ -1850,12 +2061,13 @@ | |
| 1850 | boolValue: 'edit-ctrl-send' |
| 1851 | },{ |
| 1852 | label: "Compact mode", |
| 1853 | hint: [ |
| 1854 | "Toggle between a space-saving or more spacious writing area. ", |
| 1855 | "When the input field has focus, is empty, and preview mode ", |
| 1856 | "is NOT active then Shift-Enter toggles this setting."].join(''), |
| 1857 | boolValue: 'edit-compact-mode' |
| 1858 | },{ |
| 1859 | label: "Use 'contenteditable' editing mode", |
| 1860 | boolValue: 'edit-widget-x', |
| 1861 | hint: [ |
| @@ -2020,11 +2232,11 @@ | |
| 2020 | op.persistentSetting, |
| 2021 | function(setting){ |
| 2022 | if(op.checkbox) op.checkbox.checked = !!setting.value; |
| 2023 | else if(op.select) op.select.value = setting.value; |
| 2024 | if(op.callback) op.callback(setting); |
| 2025 | } |
| 2026 | ); |
| 2027 | if(op.checkbox){ |
| 2028 | op.checkbox.addEventListener( |
| 2029 | 'change', function(){ |
| 2030 | Chat.settings.set(op.persistentSetting, op.checkbox.checked) |
| @@ -2096,11 +2308,11 @@ | |
| 2096 | s.value ? 'add' : 'remove' |
| 2097 | ]('compact'); |
| 2098 | Chat.e.inputFields[Chat.e.inputFields.$currentIndex].focus(); |
| 2099 | }); |
| 2100 | Chat.settings.addListener('edit-ctrl-send',function(s){ |
| 2101 | const label = (s.value ? "Ctrl-" : "")+"Enter submits messages."; |
| 2102 | Chat.e.inputFields.forEach((e)=>{ |
| 2103 | const v = e.dataset.placeholder0 + " " +label; |
| 2104 | if(e.isContentEditable) e.dataset.placeholder = v; |
| 2105 | else D.attr(e,'placeholder',v); |
| 2106 | }); |
| @@ -2128,11 +2340,11 @@ | |
| 2128 | this.e.previewContent.innerHTML = t; |
| 2129 | this.e.viewPreview.querySelectorAll('a').forEach(addAnchorTargetBlank); |
| 2130 | setupHashtags(this.e.previewContent)/*arguable, for usability reasons*/; |
| 2131 | this.inputFocus(); |
| 2132 | }; |
| 2133 | Chat.e.viewPreview.querySelector('#chat-preview-close'). |
| 2134 | addEventListener('click', ()=>Chat.setCurrentView(Chat.e.viewMessages), false); |
| 2135 | let previewPending = false; |
| 2136 | const elemsToEnable = [btnPreview, Chat.e.btnSubmit, Chat.e.inputFields]; |
| 2137 | const submit = function(ev){ |
| 2138 | ev.preventDefault(); |
| @@ -2175,10 +2387,40 @@ | |
| 2175 | }); |
| 2176 | return false; |
| 2177 | }; |
| 2178 | btnPreview.addEventListener('click', submit, false); |
| 2179 | })()/*message preview setup*/; |
| 2180 | |
| 2181 | /** Callback for poll() to inject new content into the page. jx == |
| 2182 | the response from /chat-poll. If atEnd is true, the message is |
| 2183 | appended to the end of the chat list (for loading older |
| 2184 | messages), else the beginning (the default). */ |
| @@ -2307,10 +2549,82 @@ | |
| 2307 | btn.addEventListener('click',()=>loadOldMessages(-1)); |
| 2308 | D.append(Chat.e.viewMessages, toolbar); |
| 2309 | toolbar.disabled = true /*will be enabled when msg load finishes */; |
| 2310 | })()/*end history loading widget setup*/; |
| 2311 | |
| 2312 | const afterFetch = function f(){ |
| 2313 | if(true===f.isFirstCall){ |
| 2314 | f.isFirstCall = false; |
| 2315 | Chat.ajaxEnd(); |
| 2316 | Chat.e.viewMessages.classList.remove('loading'); |
| @@ -2326,10 +2640,25 @@ | |
| 2326 | delete Chat.intervalTimer; |
| 2327 | } |
| 2328 | poll.running = false; |
| 2329 | }; |
| 2330 | afterFetch.isFirstCall = true; |
| 2331 | const poll = async function f(){ |
| 2332 | if(f.running) return; |
| 2333 | f.running = true; |
| 2334 | Chat._isBatchLoading = f.isFirstCall; |
| 2335 | if(true===f.isFirstCall){ |
| @@ -2368,17 +2697,11 @@ | |
| 2368 | Chat._gotServerError = poll.running = false; |
| 2369 | if( window.fossil.config.chat.fromcli ){ |
| 2370 | Chat.chatOnlyMode(true); |
| 2371 | } |
| 2372 | Chat.intervalTimer = setInterval(poll, 1000); |
| 2373 | if(0){ |
| 2374 | const flip = (ev)=>Chat.animate(ev.target,'anim-flip-h'); |
| 2375 | document.querySelectorAll('#chat-buttons-wrapper .cbutton').forEach(function(e){ |
| 2376 | e.addEventListener('click',flip, false); |
| 2377 | }); |
| 2378 | } |
| 2379 | delete ForceResizeKludge.$disabled; |
| 2380 | ForceResizeKludge(); |
| 2381 | Chat.animate.$disabled = false; |
| 2382 | setTimeout( ()=>Chat.inputFocus(), 0 ); |
| 2383 | F.page.chat = Chat/* enables testing the APIs via the dev tools */; |
| 2384 | }); |
| 2385 |
| --- src/fossil.page.chat.js | |
| +++ src/fossil.page.chat.js | |
| @@ -145,10 +145,12 @@ | |
| 145 | inputFile: E1('#chat-input-file'), |
| 146 | contentDiv: E1('div.content'), |
| 147 | viewConfig: E1('#chat-config'), |
| 148 | viewPreview: E1('#chat-preview'), |
| 149 | previewContent: E1('#chat-preview-content'), |
| 150 | viewSearch: E1('#chat-search'), |
| 151 | searchContent: E1('#chat-search-content'), |
| 152 | btnPreview: E1('#chat-button-preview'), |
| 153 | views: document.querySelectorAll('.chat-view'), |
| 154 | activeUserListWrapper: E1('#chat-user-list-wrapper'), |
| 155 | activeUserList: E1('#chat-user-list'), |
| 156 | btnClearFilter: E1('#chat-clear-filter') |
| @@ -190,21 +192,31 @@ | |
| 192 | || !!e.querySelector('[data-hashtag="'+this.activeTag+'"]'); |
| 193 | } |
| 194 | }, |
| 195 | current: undefined/*gets set to current active filter*/ |
| 196 | }, |
| 197 | /** |
| 198 | Gets (no args) or sets (1 arg) the current input text field |
| 199 | value, taking into account single- vs multi-line input. The |
| 200 | getter returns a trim()'d string and the setter returns this |
| 201 | object. As a special case, if arguments[0] is a boolean |
| 202 | value, it behaves like a getter and, if arguments[0]===true |
| 203 | it clears the input field before returning. |
| 204 | */ |
| 205 | inputValue: function(/*string newValue | bool clearInputField*/){ |
| 206 | const e = this.inputElement(); |
| 207 | if(arguments.length && 'boolean'!==typeof arguments[0]){ |
| 208 | if(e.isContentEditable) e.innerText = arguments[0]; |
| 209 | else e.value = arguments[0]; |
| 210 | return this; |
| 211 | } |
| 212 | const rc = e.isContentEditable ? e.innerText : e.value; |
| 213 | if( true===arguments[0] ){ |
| 214 | if(e.isContentEditable) e.innerText = ''; |
| 215 | else e.value = ''; |
| 216 | } |
| 217 | return rc && rc.trim(); |
| 218 | }, |
| 219 | /** Asks the current user input field to take focus. Returns this. */ |
| 220 | inputFocus: function(){ |
| 221 | this.inputElement().focus(); |
| 222 | return this; |
| @@ -529,11 +541,11 @@ | |
| 541 | const uDate = self.usersLastSeen[u]; |
| 542 | if(self.filter.user.activeTag===u){ |
| 543 | uSpan.classList.add('selected'); |
| 544 | } |
| 545 | uSpan.dataset.uname = u; |
| 546 | D.append(uSpan, u, "\n", |
| 547 | D.append( |
| 548 | D.addClass(D.span(),'timestamp'), |
| 549 | localTimeString(uDate)//.substr(5/*chop off year*/) |
| 550 | )); |
| 551 | if(uDate.$uColor){ |
| @@ -1075,11 +1087,11 @@ | |
| 1087 | Chat.MessageWidget = (function(){ |
| 1088 | /** |
| 1089 | Constructor. If passed an argument, it is passed to |
| 1090 | this.setMessage() after initialization. |
| 1091 | */ |
| 1092 | const ctor = function(){ |
| 1093 | this.e = { |
| 1094 | body: D.addClass(D.div(), 'message-widget'), |
| 1095 | tab: D.addClass(D.div(), 'message-widget-tab'), |
| 1096 | content: D.addClass(D.div(), 'message-widget-content') |
| 1097 | }; |
| @@ -1094,20 +1106,33 @@ | |
| 1106 | /* Map of Date.getDay() values to weekday names. */ |
| 1107 | 0: "Sunday", 1: "Monday", 2: "Tuesday", |
| 1108 | 3: "Wednesday", 4: "Thursday", 5: "Friday", |
| 1109 | 6: "Saturday" |
| 1110 | }; |
| 1111 | /* Given a Date, returns the timestamp string used in the "tab" |
| 1112 | part of message widgets. If longFmt is true then a verbose |
| 1113 | format is used, else a brief format is used. The returned string |
| 1114 | is in client-local time. */ |
| 1115 | const theTime = function(d, longFmt=false){ |
| 1116 | const li = []; |
| 1117 | if( longFmt ){ |
| 1118 | li.push( |
| 1119 | d.getFullYear(), |
| 1120 | '-', pad2(d.getMonth()+1), |
| 1121 | '-', pad2(d.getDate()), |
| 1122 | ' ', |
| 1123 | d.getHours(), ":", |
| 1124 | (d.getMinutes()+100).toString().slice(1,3) |
| 1125 | ); |
| 1126 | }else{ |
| 1127 | li.push( |
| 1128 | d.getHours(),":", |
| 1129 | (d.getMinutes()+100).toString().slice(1,3), |
| 1130 | ' ', dowMap[d.getDay()] |
| 1131 | ); |
| 1132 | } |
| 1133 | return li.join(''); |
| 1134 | }; |
| 1135 | |
| 1136 | /** |
| 1137 | Returns true if this page believes it can embed a view of the |
| 1138 | file wrapped by the given message object, else returns false. |
| @@ -1114,19 +1139,20 @@ | |
| 1139 | */ |
| 1140 | const canEmbedFile = function f(msg){ |
| 1141 | if(!f.$rx){ |
| 1142 | f.$rx = /\.((html?)|(txt)|(md)|(wiki)|(pikchr))$/i; |
| 1143 | f.$specificTypes = [ |
| 1144 | /* Mime types we know we can embed, sans image/... */ |
| 1145 | 'text/plain', |
| 1146 | 'text/html', |
| 1147 | 'text/x-markdown', |
| 1148 | /* Firefox sends text/markdown when uploading .md files */ |
| 1149 | 'text/markdown', |
| 1150 | 'text/x-pikchr', |
| 1151 | 'text/x-fossil-wiki' |
| 1152 | /* Add more as we discover which ones Firefox won't |
| 1153 | force the user to try to download. */ |
| 1154 | ]; |
| 1155 | } |
| 1156 | if(msg.fmime){ |
| 1157 | if(msg.fmime.startsWith("image/") |
| 1158 | || f.$specificTypes.indexOf(msg.fmime)>=0){ |
| @@ -1140,20 +1166,18 @@ | |
| 1166 | Returns true if the given message object "should" |
| 1167 | be embedded in fossil-rendered form instead of |
| 1168 | raw content form. This is only intended to be passed |
| 1169 | message objects for which canEmbedFile() returns true. |
| 1170 | */ |
| 1171 | const shouldFossilRenderEmbed = function f(msg){ |
| 1172 | if(!f.$rx){ |
| 1173 | f.$rx = /\.((md)|(wiki)|(pikchr))$/i; |
| 1174 | f.$specificTypes = [ |
| 1175 | 'text/x-markdown', |
| 1176 | 'text/markdown' /* Firefox-uploaded md files */, |
| 1177 | 'text/x-pikchr', |
| 1178 | 'text/x-fossil-wiki' |
| 1179 | ]; |
| 1180 | } |
| 1181 | if(msg.fmime){ |
| 1182 | if(f.$specificTypes.indexOf(msg.fmime)>=0) return true; |
| 1183 | } |
| @@ -1179,12 +1203,12 @@ | |
| 1203 | iframe.style.maxHeight = iframe.style.height |
| 1204 | = iframe.contentWindow.document.documentElement.scrollHeight + 'px'; |
| 1205 | if(isHidden) D.addClass(iframe, 'hidden'); |
| 1206 | } |
| 1207 | }; |
| 1208 | |
| 1209 | ctor.prototype = { |
| 1210 | scrollIntoView: function(){ |
| 1211 | this.e.content.scrollIntoView(); |
| 1212 | }, |
| 1213 | setMessage: function(m){ |
| 1214 | const ds = this.e.body.dataset; |
| @@ -1205,20 +1229,26 @@ | |
| 1229 | var eXFrom /* element holding xfrom name */; |
| 1230 | if(m.xfrom){ |
| 1231 | eXFrom = D.append(D.addClass(D.span(), 'xfrom'), m.xfrom); |
| 1232 | const wrapper = D.append( |
| 1233 | D.span(), eXFrom, |
| 1234 | ' ', |
| 1235 | D.append(D.addClass(D.span(), 'msgid'), |
| 1236 | '#' + (m.msgid||'???')), |
| 1237 | (m.isSearchResult ? ' ' : ' @ '), |
| 1238 | D.append(D.addClass(D.span(), 'timestamp'), |
| 1239 | theTime(d,!!m.isSearchResult)) |
| 1240 | ); |
| 1241 | D.append(this.e.tab, wrapper); |
| 1242 | }else{/*notification*/ |
| 1243 | D.addClass(this.e.body, 'notification'); |
| 1244 | if(m.isError){ |
| 1245 | D.addClass([contentTarget, this.e.tab], 'error'); |
| 1246 | } |
| 1247 | D.append( |
| 1248 | this.e.tab, |
| 1249 | D.append(D.code(), 'notification @ ',theTime(d,false)) |
| 1250 | ); |
| 1251 | } |
| 1252 | if( m.xfrom && m.fsize>0 ){ |
| 1253 | if( m.fmime |
| 1254 | && m.fmime.startsWith("image/") |
| @@ -1241,18 +1271,18 @@ | |
| 1271 | D.attr(a,'target','_blank'); |
| 1272 | D.append(w, a); |
| 1273 | if(canEmbedFile(m)){ |
| 1274 | /* Add an option to embed HTML attachments in an iframe. The primary |
| 1275 | use case is attached diffs. */ |
| 1276 | const shouldFossilRender = shouldFossilRenderEmbed(m); |
| 1277 | const downloadArgs = shouldFossilRender ? '?render' : ''; |
| 1278 | D.addClass(contentTarget, 'wide'); |
| 1279 | const embedTarget = this.e.content; |
| 1280 | const self = this; |
| 1281 | const btnEmbed = D.attr(D.checkbox("1", false), 'id', |
| 1282 | 'embed-'+ds.msgid); |
| 1283 | const btnLabel = D.label(btnEmbed, shouldFossilRender |
| 1284 | ? "Embed (fossil-rendered)" : "Embed"); |
| 1285 | /* Maintenance reminder: do not disable the toggle |
| 1286 | button while the content is loading because that will |
| 1287 | cause it to get stuck in disabled mode if the browser |
| 1288 | decides that loading the content should prompt the |
| @@ -1460,13 +1490,182 @@ | |
| 1490 | Chat.setCurrentView(Chat.e.viewMessages); |
| 1491 | e.scrollIntoView(false); |
| 1492 | Chat.animate(e, 'anim-fade-out-in'); |
| 1493 | } |
| 1494 | }; |
| 1495 | return ctor; |
| 1496 | })()/*MessageWidget*/; |
| 1497 | |
| 1498 | /** |
| 1499 | A widget for loading more messages (context) around a /chat-query |
| 1500 | result message. |
| 1501 | */ |
| 1502 | Chat.SearchCtxLoader = (function(){ |
| 1503 | const nMsgContext = 5; |
| 1504 | const zUpArrow = '\u25B2'; |
| 1505 | const zDownArrow = '\u25BC'; |
| 1506 | const ctor = function(o){ |
| 1507 | |
| 1508 | /* iFirstInTable: |
| 1509 | ** msgid of first row in chatfts table. |
| 1510 | ** |
| 1511 | ** iLastInTable: |
| 1512 | ** msgid of last row in chatfts table. |
| 1513 | ** |
| 1514 | ** iPrevId: |
| 1515 | ** msgid of message immediately above this spacer. Or 0 if this |
| 1516 | ** spacer is above all results. |
| 1517 | ** |
| 1518 | ** iNextId: |
| 1519 | ** msgid of message immediately below this spacer. Or 0 if this |
| 1520 | ** spacer is below all results. |
| 1521 | ** |
| 1522 | ** bIgnoreClick: |
| 1523 | ** ignore any clicks if this is true. This is used to ensure there |
| 1524 | ** is only ever one request belonging to this widget outstanding |
| 1525 | ** at any time. |
| 1526 | */ |
| 1527 | this.o = { |
| 1528 | iFirstInTable: o.first, |
| 1529 | iLastInTable: o.last, |
| 1530 | iPrevId: o.previd, |
| 1531 | iNextId: o.nextid, |
| 1532 | bIgnoreClick: false |
| 1533 | }; |
| 1534 | |
| 1535 | this.e = { |
| 1536 | body: D.addClass(D.div(), 'spacer-widget'), |
| 1537 | up: D.addClass( |
| 1538 | D.button(zDownArrow+' Load '+nMsgContext+' more '+zDownArrow), |
| 1539 | 'up' |
| 1540 | ), |
| 1541 | down: D.addClass( |
| 1542 | D.button(zUpArrow+' Load '+nMsgContext+' more '+zUpArrow), |
| 1543 | 'down' |
| 1544 | ), |
| 1545 | all: D.addClass(D.button('Load More'), 'all') |
| 1546 | }; |
| 1547 | D.append( this.e.body, this.e.up, this.e.down, this.e.all ); |
| 1548 | const ms = this; |
| 1549 | this.e.up.addEventListener('click', ()=>ms.load_messages(false)); |
| 1550 | this.e.down.addEventListener('click', ()=>ms.load_messages(true)); |
| 1551 | this.e.all.addEventListener('click', ()=>ms.load_messages( (ms.o.iPrevId==0) )); |
| 1552 | this.set_button_visibility(); |
| 1553 | }; |
| 1554 | |
| 1555 | ctor.prototype = { |
| 1556 | set_button_visibility: function() { |
| 1557 | if( !this.e ) return; |
| 1558 | const o = this.o; |
| 1559 | |
| 1560 | const iPrevId = (o.iPrevId!=0) ? o.iPrevId : o.iFirstInTable-1; |
| 1561 | const iNextId = (o.iNextId!=0) ? o.iNextId : o.iLastInTable+1; |
| 1562 | let nDiff = (iNextId - iPrevId) - 1; |
| 1563 | |
| 1564 | for( const x of [this.e.up, this.e.down, this.e.all] ){ |
| 1565 | if( x ) D.addClass(x, 'hidden'); |
| 1566 | } |
| 1567 | let nVisible = 0; |
| 1568 | if( nDiff>0 ){ |
| 1569 | if( nDiff>nMsgContext && (o.iPrevId==0 || o.iNextId==0) ){ |
| 1570 | nDiff = nMsgContext; |
| 1571 | } |
| 1572 | |
| 1573 | if( nDiff<=nMsgContext && o.iPrevId!=0 && o.iNextId!=0 ){ |
| 1574 | D.removeClass(this.e.all, 'hidden'); |
| 1575 | ++nVisible; |
| 1576 | this.e.all.innerText = ( |
| 1577 | zUpArrow + " Load " + nDiff + " more " + zDownArrow |
| 1578 | ); |
| 1579 | }else{ |
| 1580 | if( o.iPrevId!=0 ){ |
| 1581 | ++nVisible; |
| 1582 | D.removeClass(this.e.up, 'hidden'); |
| 1583 | }else if( this.e.up ){ |
| 1584 | if( this.e.up.parentNode ) D.remove(this.e.up); |
| 1585 | delete this.e.up; |
| 1586 | } |
| 1587 | if( o.iNextId!=0 ){ |
| 1588 | ++nVisible; |
| 1589 | D.removeClass(this.e.down, 'hidden'); |
| 1590 | }else if( this.e.down ){ |
| 1591 | if( this.e.down.parentNode ) D.remove( this.e.down ); |
| 1592 | delete this.e.down; |
| 1593 | } |
| 1594 | } |
| 1595 | } |
| 1596 | if( !nVisible ){ |
| 1597 | /* The DOM elements can now be disposed of. */ |
| 1598 | for( const x of [this.e.up, this.e.down, this.e.all, this.e.body] ){ |
| 1599 | if( x?.parentNode ) D.remove(x); |
| 1600 | } |
| 1601 | delete this.e; |
| 1602 | } |
| 1603 | }, |
| 1604 | |
| 1605 | load_messages: function(bDown) { |
| 1606 | if( this.bIgnoreClick ) return; |
| 1607 | |
| 1608 | var iFirst = 0; /* msgid of first message to fetch */ |
| 1609 | var nFetch = 0; /* Number of messages to fetch */ |
| 1610 | var iEof = 0; /* last msgid in spacers range, plus 1 */ |
| 1611 | |
| 1612 | const e = this.e, o = this.o; |
| 1613 | this.bIgnoreClick = true; |
| 1614 | |
| 1615 | /* Figure out the required range of messages. */ |
| 1616 | if( bDown ){ |
| 1617 | iFirst = this.o.iNextId - nMsgContext; |
| 1618 | if( iFirst<this.o.iFirstInTable ){ |
| 1619 | iFirst = this.o.iFirstInTable; |
| 1620 | } |
| 1621 | }else{ |
| 1622 | iFirst = this.o.iPrevId+1; |
| 1623 | } |
| 1624 | nFetch = nMsgContext; |
| 1625 | iEof = (this.o.iNextId > 0) ? this.o.iNextId : this.o.iLastInTable+1; |
| 1626 | if( iFirst+nFetch>iEof ){ |
| 1627 | nFetch = iEof - iFirst; |
| 1628 | } |
| 1629 | const ms = this; |
| 1630 | F.fetch("chat-query",{ |
| 1631 | urlParams:{ |
| 1632 | q: '', |
| 1633 | n: nFetch, |
| 1634 | i: iFirst |
| 1635 | }, |
| 1636 | responseType: "json", |
| 1637 | onload:function(jx){ |
| 1638 | if( bDown ) jx.msgs.reverse(); |
| 1639 | jx.msgs.forEach((m) => { |
| 1640 | var mw = new Chat.MessageWidget(m); |
| 1641 | if( bDown ){ |
| 1642 | /* Inject the message below this object's body, or |
| 1643 | append it to Chat.e.searchContent if this element |
| 1644 | is the final one in its parent (Chat.e.searchContent). */ |
| 1645 | const eAnchor = e.body.nextElementSibling; |
| 1646 | if( eAnchor ) Chat.e.searchContent.insertBefore(mw.e.body, eAnchor); |
| 1647 | else D.append(Chat.e.searchContent, mw.e.body); |
| 1648 | }else{ |
| 1649 | Chat.e.searchContent.insertBefore(mw.e.body, e.body); |
| 1650 | } |
| 1651 | }); |
| 1652 | if( bDown ){ |
| 1653 | o.iNextId -= jx.msgs.length; |
| 1654 | }else{ |
| 1655 | o.iPrevId += jx.msgs.length; |
| 1656 | } |
| 1657 | ms.set_button_visibility(); |
| 1658 | ms.bIgnoreClick = false; |
| 1659 | } |
| 1660 | }); |
| 1661 | } |
| 1662 | }; |
| 1663 | |
| 1664 | return ctor; |
| 1665 | })() /*SearchCtxLoader*/; |
| 1666 | |
| 1667 | const BlobXferState = (function(){ |
| 1668 | /* State for paste and drag/drop */ |
| 1669 | const bxs = { |
| 1670 | dropDetails: document.querySelector('#chat-drop-details'), |
| 1671 | blob: undefined, |
| @@ -1605,16 +1804,26 @@ | |
| 1804 | |
| 1805 | /** |
| 1806 | Submits the contents of the message input field (if not empty) |
| 1807 | and/or the file attachment field to the server. If both are |
| 1808 | empty, this is a no-op. |
| 1809 | |
| 1810 | If the current view is the history search, this instead sends the |
| 1811 | input text to that widget. |
| 1812 | */ |
| 1813 | Chat.submitMessage = function f(){ |
| 1814 | if(!f.spaces){ |
| 1815 | f.spaces = /\s+$/; |
| 1816 | f.markdownContinuation = /\\\s+$/; |
| 1817 | f.spaces2 = /\s{3,}$/; |
| 1818 | } |
| 1819 | switch( this.e.currentView ){ |
| 1820 | case this.e.viewSearch: this.submitSearch(); |
| 1821 | return; |
| 1822 | case this.e.viewPreview: this.e.btnPreview.click(); |
| 1823 | return; |
| 1824 | default: break; |
| 1825 | } |
| 1826 | this.setCurrentView(this.e.viewMessages); |
| 1827 | const fd = new FormData(); |
| 1828 | const fallback = {msg: this.inputValue()}; |
| 1829 | var msg = fallback.msg; |
| @@ -1687,14 +1896,16 @@ | |
| 1896 | //console.debug("Enter key event:", ctrlMode, ev.ctrlKey, ev.shiftKey, ev); |
| 1897 | if(ev.shiftKey){ |
| 1898 | const compactMode = Chat.settings.getBool('edit-compact-mode', false); |
| 1899 | ev.preventDefault(); |
| 1900 | ev.stopPropagation(); |
| 1901 | /* Shift-enter will run preview mode UNLESS the input field is empty |
| 1902 | AND (preview or search mode) is active, in which cases it will |
| 1903 | switch back to message view. */ |
| 1904 | if(!text && |
| 1905 | (Chat.e.currentView===Chat.e.viewPreview |
| 1906 | | Chat.e.currentView===Chat.e.viewSearch)){ |
| 1907 | Chat.setCurrentView(Chat.e.viewMessages); |
| 1908 | }else if(!text){ |
| 1909 | f.$toggleCompact(compactMode); |
| 1910 | }else if(Chat.settings.getBool('edit-shift-enter-preview', true)){ |
| 1911 | Chat.e.btnPreview.click(); |
| @@ -1752,19 +1963,19 @@ | |
| 1963 | tall vs wide. Can be toggled via settings. */ |
| 1964 | document.body.classList.add('my-messages-right'); |
| 1965 | } |
| 1966 | const settingsButton = document.querySelector('#chat-button-settings'); |
| 1967 | const optionsMenu = E1('#chat-config-options'); |
| 1968 | const eToggleView = function(ev){ |
| 1969 | ev.preventDefault(); |
| 1970 | ev.stopPropagation(); |
| 1971 | Chat.setCurrentView(Chat.e.currentView===Chat.e.viewConfig |
| 1972 | ? Chat.e.viewMessages : Chat.e.viewConfig); |
| 1973 | return false; |
| 1974 | }; |
| 1975 | D.attr(settingsButton, 'role', 'button').addEventListener('click', eToggleView, false); |
| 1976 | Chat.e.viewConfig.querySelector('button.action-close').addEventListener('click', eToggleView, false); |
| 1977 | |
| 1978 | /** Internal acrobatics to allow certain settings toggles to access |
| 1979 | related toggles. */ |
| 1980 | const namedOptions = { |
| 1981 | activeUsers:{ |
| @@ -1850,12 +2061,13 @@ | |
| 2061 | boolValue: 'edit-ctrl-send' |
| 2062 | },{ |
| 2063 | label: "Compact mode", |
| 2064 | hint: [ |
| 2065 | "Toggle between a space-saving or more spacious writing area. ", |
| 2066 | "When the input field has focus and is empty ", |
| 2067 | "then Shift-Enter may (depending on the current view) toggle this setting." |
| 2068 | ].join(''), |
| 2069 | boolValue: 'edit-compact-mode' |
| 2070 | },{ |
| 2071 | label: "Use 'contenteditable' editing mode", |
| 2072 | boolValue: 'edit-widget-x', |
| 2073 | hint: [ |
| @@ -2020,11 +2232,11 @@ | |
| 2232 | op.persistentSetting, |
| 2233 | function(setting){ |
| 2234 | if(op.checkbox) op.checkbox.checked = !!setting.value; |
| 2235 | else if(op.select) op.select.value = setting.value; |
| 2236 | if(op.callback) op.callback(setting); |
| 2237 | } |
| 2238 | ); |
| 2239 | if(op.checkbox){ |
| 2240 | op.checkbox.addEventListener( |
| 2241 | 'change', function(){ |
| 2242 | Chat.settings.set(op.persistentSetting, op.checkbox.checked) |
| @@ -2096,11 +2308,11 @@ | |
| 2308 | s.value ? 'add' : 'remove' |
| 2309 | ]('compact'); |
| 2310 | Chat.e.inputFields[Chat.e.inputFields.$currentIndex].focus(); |
| 2311 | }); |
| 2312 | Chat.settings.addListener('edit-ctrl-send',function(s){ |
| 2313 | const label = (s.value ? "Ctrl-" : "")+"Enter submits message"; |
| 2314 | Chat.e.inputFields.forEach((e)=>{ |
| 2315 | const v = e.dataset.placeholder0 + " " +label; |
| 2316 | if(e.isContentEditable) e.dataset.placeholder = v; |
| 2317 | else D.attr(e,'placeholder',v); |
| 2318 | }); |
| @@ -2128,11 +2340,11 @@ | |
| 2340 | this.e.previewContent.innerHTML = t; |
| 2341 | this.e.viewPreview.querySelectorAll('a').forEach(addAnchorTargetBlank); |
| 2342 | setupHashtags(this.e.previewContent)/*arguable, for usability reasons*/; |
| 2343 | this.inputFocus(); |
| 2344 | }; |
| 2345 | Chat.e.viewPreview.querySelector('button.action-close'). |
| 2346 | addEventListener('click', ()=>Chat.setCurrentView(Chat.e.viewMessages), false); |
| 2347 | let previewPending = false; |
| 2348 | const elemsToEnable = [btnPreview, Chat.e.btnSubmit, Chat.e.inputFields]; |
| 2349 | const submit = function(ev){ |
| 2350 | ev.preventDefault(); |
| @@ -2175,10 +2387,40 @@ | |
| 2387 | }); |
| 2388 | return false; |
| 2389 | }; |
| 2390 | btnPreview.addEventListener('click', submit, false); |
| 2391 | })()/*message preview setup*/; |
| 2392 | |
| 2393 | (function(){/*Set up #chat-search and related bits */ |
| 2394 | const btn = document.querySelector('#chat-button-search'); |
| 2395 | D.attr(btn, 'role', 'button').addEventListener('click', function(ev){ |
| 2396 | ev.preventDefault(); |
| 2397 | ev.stopPropagation(); |
| 2398 | const msg = Chat.inputValue(); |
| 2399 | if( Chat.e.currentView===Chat.e.viewSearch ){ |
| 2400 | if( msg ) Chat.submitSearch(); |
| 2401 | else Chat.setCurrentView(Chat.e.viewMessages); |
| 2402 | }else{ |
| 2403 | Chat.setCurrentView(Chat.e.viewSearch); |
| 2404 | if( msg ) Chat.submitSearch(); |
| 2405 | } |
| 2406 | return false; |
| 2407 | }, false); |
| 2408 | Chat.e.viewSearch.querySelector('button.action-clear').addEventListener('click', function(ev){ |
| 2409 | ev.preventDefault(); |
| 2410 | ev.stopPropagation(); |
| 2411 | Chat.clearSearch(true); |
| 2412 | Chat.setCurrentView(Chat.e.viewMessages); |
| 2413 | return false; |
| 2414 | }, false); |
| 2415 | Chat.e.viewSearch.querySelector('button.action-close').addEventListener('click', function(ev){ |
| 2416 | ev.preventDefault(); |
| 2417 | ev.stopPropagation(); |
| 2418 | Chat.setCurrentView(Chat.e.viewMessages); |
| 2419 | return false; |
| 2420 | }, false); |
| 2421 | })()/*search view setup*/; |
| 2422 | |
| 2423 | /** Callback for poll() to inject new content into the page. jx == |
| 2424 | the response from /chat-poll. If atEnd is true, the message is |
| 2425 | appended to the end of the chat list (for loading older |
| 2426 | messages), else the beginning (the default). */ |
| @@ -2307,10 +2549,82 @@ | |
| 2549 | btn.addEventListener('click',()=>loadOldMessages(-1)); |
| 2550 | D.append(Chat.e.viewMessages, toolbar); |
| 2551 | toolbar.disabled = true /*will be enabled when msg load finishes */; |
| 2552 | })()/*end history loading widget setup*/; |
| 2553 | |
| 2554 | /** |
| 2555 | Clears the search result view. If addInstructions is true it adds |
| 2556 | text to that view instructing the user to enter their query into |
| 2557 | the message-entry widget (noting that that widget has text |
| 2558 | implying that it's only for submitting a message, which isn't |
| 2559 | exactly true when the search view is active). |
| 2560 | |
| 2561 | Returns the DOM element which wraps all of the chat search |
| 2562 | result elements. |
| 2563 | */ |
| 2564 | Chat.clearSearch = function(addInstructions=false){ |
| 2565 | const e = D.clearElement( this.e.searchContent ); |
| 2566 | if(addInstructions){ |
| 2567 | D.append(e, "Enter search terms in the message field. "+ |
| 2568 | "Use #NNNNN to search for the message with ID NNNNN."); |
| 2569 | } |
| 2570 | return e; |
| 2571 | }; |
| 2572 | Chat.clearSearch(true); |
| 2573 | /** |
| 2574 | Submits a history search using the main input field's current |
| 2575 | text. It is assumed that Chat.e.viewSearch===Chat.e.currentView. |
| 2576 | */ |
| 2577 | Chat.submitSearch = function(){ |
| 2578 | const term = this.inputValue(true); |
| 2579 | const eMsgTgt = this.clearSearch(true); |
| 2580 | if( !term ) return; |
| 2581 | D.append( eMsgTgt, "Searching for ",term," ..."); |
| 2582 | const fd = new FormData(); |
| 2583 | fd.set('q', term); |
| 2584 | F.fetch( |
| 2585 | "chat-query", { |
| 2586 | payload: fd, |
| 2587 | responseType: 'json', |
| 2588 | onerror:function(err){ |
| 2589 | Chat.setCurrentView(Chat.e.viewMessages); |
| 2590 | Chat.reportErrorAsMessage(err); |
| 2591 | }, |
| 2592 | onload:function(jx){ |
| 2593 | let previd = 0; |
| 2594 | D.clearElement(eMsgTgt); |
| 2595 | jx.msgs.forEach((m)=>{ |
| 2596 | m.isSearchResult = true; |
| 2597 | const mw = new Chat.MessageWidget(m); |
| 2598 | const spacer = new Chat.SearchCtxLoader({ |
| 2599 | first: jx.first, |
| 2600 | last: jx.last, |
| 2601 | previd: previd, |
| 2602 | nextid: m.msgid |
| 2603 | }); |
| 2604 | if( spacer.e ) D.append( eMsgTgt, spacer.e.body ); |
| 2605 | D.append( eMsgTgt, mw.e.body ); |
| 2606 | previd = m.msgid; |
| 2607 | }); |
| 2608 | if( jx.msgs.length ){ |
| 2609 | const spacer = new Chat.SearchCtxLoader({ |
| 2610 | first: jx.first, |
| 2611 | last: jx.last, |
| 2612 | previd: previd, |
| 2613 | nextid: 0 |
| 2614 | }); |
| 2615 | if( spacer.e ) D.append( eMsgTgt, spacer.e.body ); |
| 2616 | }else{ |
| 2617 | D.append( D.clearElement(eMsgTgt), |
| 2618 | 'No search results found for: ', |
| 2619 | term ); |
| 2620 | } |
| 2621 | } |
| 2622 | } |
| 2623 | ); |
| 2624 | }/*Chat.submitSearch()*/; |
| 2625 | |
| 2626 | const afterFetch = function f(){ |
| 2627 | if(true===f.isFirstCall){ |
| 2628 | f.isFirstCall = false; |
| 2629 | Chat.ajaxEnd(); |
| 2630 | Chat.e.viewMessages.classList.remove('loading'); |
| @@ -2326,10 +2640,25 @@ | |
| 2640 | delete Chat.intervalTimer; |
| 2641 | } |
| 2642 | poll.running = false; |
| 2643 | }; |
| 2644 | afterFetch.isFirstCall = true; |
| 2645 | /** |
| 2646 | FIXME: when polling fails because the remote server is |
| 2647 | reachable but it's not accepting HTTP requests, we should back |
| 2648 | off on polling for a while. e.g. if the remote web server process |
| 2649 | is killed, the poll fails quickly and immediately retries, |
| 2650 | hammering the remote server until the httpd is back up. That |
| 2651 | happens often during development of this application. |
| 2652 | |
| 2653 | XHR does not offer a direct way of distinguishing between |
| 2654 | HTTP/connection errors, but we can hypothetically use the |
| 2655 | xhrRequest.status value to do so, with status==0 being a |
| 2656 | connection error. We do not currently have a clean way of passing |
| 2657 | that info back to the fossil.fetch() client, so we'll need to |
| 2658 | hammer on that API a bit to get this working. |
| 2659 | */ |
| 2660 | const poll = async function f(){ |
| 2661 | if(f.running) return; |
| 2662 | f.running = true; |
| 2663 | Chat._isBatchLoading = f.isFirstCall; |
| 2664 | if(true===f.isFirstCall){ |
| @@ -2368,17 +2697,11 @@ | |
| 2697 | Chat._gotServerError = poll.running = false; |
| 2698 | if( window.fossil.config.chat.fromcli ){ |
| 2699 | Chat.chatOnlyMode(true); |
| 2700 | } |
| 2701 | Chat.intervalTimer = setInterval(poll, 1000); |
| 2702 | delete ForceResizeKludge.$disabled; |
| 2703 | ForceResizeKludge(); |
| 2704 | Chat.animate.$disabled = false; |
| 2705 | setTimeout( ()=>Chat.inputFocus(), 0 ); |
| 2706 | F.page.chat = Chat/* enables testing the APIs via the dev tools */; |
| 2707 | }); |
| 2708 |
+47
-19
| --- src/search.c | ||
| +++ src/search.c | ||
| @@ -975,10 +975,33 @@ | ||
| 975 | 975 | } |
| 976 | 976 | #else |
| 977 | 977 | sqlite3_result_double(context, r); |
| 978 | 978 | #endif |
| 979 | 979 | } |
| 980 | + | |
| 981 | +/* | |
| 982 | +** Expects a search pattern string. Makes a copy of the string, | |
| 983 | +** replaces all non-alphanum ASCII characters with a space, and | |
| 984 | +** lower-cases all upper-case ASCII characters. The intent is to avoid | |
| 985 | +** causing errors in FTS5 searches with inputs which contain AND, OR, | |
| 986 | +** and symbols like #. The caller is responsible for passing the | |
| 987 | +** result to fossil_free(). | |
| 988 | +*/ | |
| 989 | +char *search_simplify_pattern(const char * zPattern){ | |
| 990 | + char *zPat = mprintf("%s",zPattern); | |
| 991 | + int i; | |
| 992 | + for(i=0; zPat[i]; i++){ | |
| 993 | + if( (zPat[i]&0x80)==0 && !fossil_isalnum(zPat[i]) ) zPat[i] = ' '; | |
| 994 | + if( fossil_isupper(zPat[i]) ) zPat[i] = fossil_tolower(zPat[i]); | |
| 995 | + } | |
| 996 | + for(i--; i>=0 && zPat[i]==' '; i--){} | |
| 997 | + if( i<0 ){ | |
| 998 | + fossil_free(zPat); | |
| 999 | + zPat = mprintf("\"\""); | |
| 1000 | + } | |
| 1001 | + return zPat; | |
| 1002 | +} | |
| 980 | 1003 | |
| 981 | 1004 | /* |
| 982 | 1005 | ** When this routine is called, there already exists a table |
| 983 | 1006 | ** |
| 984 | 1007 | ** x(label,url,score,id,snip). |
| @@ -997,25 +1020,16 @@ | ||
| 997 | 1020 | LOCAL void search_indexed( |
| 998 | 1021 | const char *zPattern, /* The query pattern */ |
| 999 | 1022 | unsigned int srchFlags /* What to search over */ |
| 1000 | 1023 | ){ |
| 1001 | 1024 | Blob sql; |
| 1002 | - char *zPat = mprintf("%s",zPattern); | |
| 1003 | - int i; | |
| 1025 | + char *zPat; | |
| 1004 | 1026 | static const char *zSnippetCall; |
| 1005 | 1027 | if( srchFlags==0 ) return; |
| 1006 | 1028 | sqlite3_create_function(g.db, "rank", 1, SQLITE_UTF8|SQLITE_INNOCUOUS, 0, |
| 1007 | 1029 | search_rank_sqlfunc, 0, 0); |
| 1008 | - for(i=0; zPat[i]; i++){ | |
| 1009 | - if( (zPat[i]&0x80)==0 && !fossil_isalnum(zPat[i]) ) zPat[i] = ' '; | |
| 1010 | - if( fossil_isupper(zPat[i]) ) zPat[i] = fossil_tolower(zPat[i]); | |
| 1011 | - } | |
| 1012 | - for(i--; i>=0 && zPat[i]==' '; i--){} | |
| 1013 | - if( i<0 ){ | |
| 1014 | - fossil_free(zPat); | |
| 1015 | - zPat = mprintf("\"\""); | |
| 1016 | - } | |
| 1030 | + zPat = search_simplify_pattern(zPattern); | |
| 1017 | 1031 | blob_init(&sql, 0, 0); |
| 1018 | 1032 | if( search_index_type(0)==4 ){ |
| 1019 | 1033 | /* If this repo is still using the legacy FTS4 search index, then |
| 1020 | 1034 | ** the snippet() function is slightly different */ |
| 1021 | 1035 | zSnippetCall = "snippet(ftsidx,'<mark>','</mark>',' ... ',-1,35)"; |
| @@ -1637,10 +1651,11 @@ | ||
| 1637 | 1651 | ; |
| 1638 | 1652 | static const char zFtsDrop[] = |
| 1639 | 1653 | @ DROP TABLE IF EXISTS repository.ftsidx; |
| 1640 | 1654 | @ DROP VIEW IF EXISTS repository.ftscontent; |
| 1641 | 1655 | @ DROP TABLE IF EXISTS repository.ftsdocs; |
| 1656 | +@ DROP TABLE IF EXISTS repository.chatfts1; | |
| 1642 | 1657 | ; |
| 1643 | 1658 | |
| 1644 | 1659 | #if INTERFACE |
| 1645 | 1660 | /* |
| 1646 | 1661 | ** Values for the search-tokenizer config option. |
| @@ -1681,10 +1696,25 @@ | ||
| 1681 | 1696 | iFtsTokenizer = is_truth(z) ? FTS5TOK_PORTER : FTS5TOK_NONE; |
| 1682 | 1697 | } |
| 1683 | 1698 | fossil_free(z); |
| 1684 | 1699 | return iFtsTokenizer; |
| 1685 | 1700 | } |
| 1701 | + | |
| 1702 | +/* | |
| 1703 | +** Returns a string in the form ",tokenize=X", where X is the string | |
| 1704 | +** counterpart of the given FTS5TOK_xyz value. Returns "" if tokType | |
| 1705 | +** does not correspond to a known FTS5 tokenizer. | |
| 1706 | +*/ | |
| 1707 | +const char * search_tokenize_arg_for_type(int tokType){ | |
| 1708 | + switch( tokType ){ | |
| 1709 | + case FTS5TOK_PORTER: return ",tokenize=porter"; | |
| 1710 | + case FTS5TOK_UNICODE61: return ",tokenize=unicode61"; | |
| 1711 | + case FTS5TOK_TRIGRAM: return ",tokenize=trigram"; | |
| 1712 | + case FTS5TOK_NONE: | |
| 1713 | + default: return ""; | |
| 1714 | + } | |
| 1715 | +} | |
| 1686 | 1716 | |
| 1687 | 1717 | /* |
| 1688 | 1718 | ** Returns a string value suitable for use as the search-tokenizer |
| 1689 | 1719 | ** setting's value, depending on the value of z. If z is 0 then the |
| 1690 | 1720 | ** current search-tokenizer value is used as the basis for formulating |
| @@ -1726,18 +1756,13 @@ | ||
| 1726 | 1756 | /* |
| 1727 | 1757 | ** Create or drop the tables associated with a full-text index. |
| 1728 | 1758 | */ |
| 1729 | 1759 | static int searchIdxExists = -1; |
| 1730 | 1760 | void search_create_index(void){ |
| 1731 | - const int useTokenizer = search_tokenizer_type(0); | |
| 1732 | - const char *zExtra; | |
| 1733 | - switch(useTokenizer){ | |
| 1734 | - case FTS5TOK_PORTER: zExtra = ",tokenize=porter"; break; | |
| 1735 | - case FTS5TOK_UNICODE61: zExtra = ",tokenize=unicode61"; break; | |
| 1736 | - case FTS5TOK_TRIGRAM: zExtra = ",tokenize=trigram"; break; | |
| 1737 | - default: zExtra = ""; break; | |
| 1738 | - } | |
| 1761 | + const char *zExtra = | |
| 1762 | + search_tokenize_arg_for_type(search_tokenizer_type(0)); | |
| 1763 | + assert( zExtra ); | |
| 1739 | 1764 | search_sql_setup(g.db); |
| 1740 | 1765 | db_multi_exec(zFtsSchema/*works-like:"%s"*/, zExtra/*safe-for-%s*/); |
| 1741 | 1766 | searchIdxExists = 1; |
| 1742 | 1767 | } |
| 1743 | 1768 | void search_drop_index(void){ |
| @@ -2057,10 +2082,13 @@ | ||
| 2057 | 2082 | fossil_print("rebuilding the search index..."); |
| 2058 | 2083 | fflush(stdout); |
| 2059 | 2084 | search_create_index(); |
| 2060 | 2085 | search_fill_index(); |
| 2061 | 2086 | search_update_index(search_restrict(SRCH_ALL)); |
| 2087 | + if( db_table_exists("repository","chat") ){ | |
| 2088 | + chat_rebuild_index(1); | |
| 2089 | + } | |
| 2062 | 2090 | fossil_print(" done\n"); |
| 2063 | 2091 | } |
| 2064 | 2092 | |
| 2065 | 2093 | /* |
| 2066 | 2094 | ** COMMAND: fts-config* |
| 2067 | 2095 |
| --- src/search.c | |
| +++ src/search.c | |
| @@ -975,10 +975,33 @@ | |
| 975 | } |
| 976 | #else |
| 977 | sqlite3_result_double(context, r); |
| 978 | #endif |
| 979 | } |
| 980 | |
| 981 | /* |
| 982 | ** When this routine is called, there already exists a table |
| 983 | ** |
| 984 | ** x(label,url,score,id,snip). |
| @@ -997,25 +1020,16 @@ | |
| 997 | LOCAL void search_indexed( |
| 998 | const char *zPattern, /* The query pattern */ |
| 999 | unsigned int srchFlags /* What to search over */ |
| 1000 | ){ |
| 1001 | Blob sql; |
| 1002 | char *zPat = mprintf("%s",zPattern); |
| 1003 | int i; |
| 1004 | static const char *zSnippetCall; |
| 1005 | if( srchFlags==0 ) return; |
| 1006 | sqlite3_create_function(g.db, "rank", 1, SQLITE_UTF8|SQLITE_INNOCUOUS, 0, |
| 1007 | search_rank_sqlfunc, 0, 0); |
| 1008 | for(i=0; zPat[i]; i++){ |
| 1009 | if( (zPat[i]&0x80)==0 && !fossil_isalnum(zPat[i]) ) zPat[i] = ' '; |
| 1010 | if( fossil_isupper(zPat[i]) ) zPat[i] = fossil_tolower(zPat[i]); |
| 1011 | } |
| 1012 | for(i--; i>=0 && zPat[i]==' '; i--){} |
| 1013 | if( i<0 ){ |
| 1014 | fossil_free(zPat); |
| 1015 | zPat = mprintf("\"\""); |
| 1016 | } |
| 1017 | blob_init(&sql, 0, 0); |
| 1018 | if( search_index_type(0)==4 ){ |
| 1019 | /* If this repo is still using the legacy FTS4 search index, then |
| 1020 | ** the snippet() function is slightly different */ |
| 1021 | zSnippetCall = "snippet(ftsidx,'<mark>','</mark>',' ... ',-1,35)"; |
| @@ -1637,10 +1651,11 @@ | |
| 1637 | ; |
| 1638 | static const char zFtsDrop[] = |
| 1639 | @ DROP TABLE IF EXISTS repository.ftsidx; |
| 1640 | @ DROP VIEW IF EXISTS repository.ftscontent; |
| 1641 | @ DROP TABLE IF EXISTS repository.ftsdocs; |
| 1642 | ; |
| 1643 | |
| 1644 | #if INTERFACE |
| 1645 | /* |
| 1646 | ** Values for the search-tokenizer config option. |
| @@ -1681,10 +1696,25 @@ | |
| 1681 | iFtsTokenizer = is_truth(z) ? FTS5TOK_PORTER : FTS5TOK_NONE; |
| 1682 | } |
| 1683 | fossil_free(z); |
| 1684 | return iFtsTokenizer; |
| 1685 | } |
| 1686 | |
| 1687 | /* |
| 1688 | ** Returns a string value suitable for use as the search-tokenizer |
| 1689 | ** setting's value, depending on the value of z. If z is 0 then the |
| 1690 | ** current search-tokenizer value is used as the basis for formulating |
| @@ -1726,18 +1756,13 @@ | |
| 1726 | /* |
| 1727 | ** Create or drop the tables associated with a full-text index. |
| 1728 | */ |
| 1729 | static int searchIdxExists = -1; |
| 1730 | void search_create_index(void){ |
| 1731 | const int useTokenizer = search_tokenizer_type(0); |
| 1732 | const char *zExtra; |
| 1733 | switch(useTokenizer){ |
| 1734 | case FTS5TOK_PORTER: zExtra = ",tokenize=porter"; break; |
| 1735 | case FTS5TOK_UNICODE61: zExtra = ",tokenize=unicode61"; break; |
| 1736 | case FTS5TOK_TRIGRAM: zExtra = ",tokenize=trigram"; break; |
| 1737 | default: zExtra = ""; break; |
| 1738 | } |
| 1739 | search_sql_setup(g.db); |
| 1740 | db_multi_exec(zFtsSchema/*works-like:"%s"*/, zExtra/*safe-for-%s*/); |
| 1741 | searchIdxExists = 1; |
| 1742 | } |
| 1743 | void search_drop_index(void){ |
| @@ -2057,10 +2082,13 @@ | |
| 2057 | fossil_print("rebuilding the search index..."); |
| 2058 | fflush(stdout); |
| 2059 | search_create_index(); |
| 2060 | search_fill_index(); |
| 2061 | search_update_index(search_restrict(SRCH_ALL)); |
| 2062 | fossil_print(" done\n"); |
| 2063 | } |
| 2064 | |
| 2065 | /* |
| 2066 | ** COMMAND: fts-config* |
| 2067 |
| --- src/search.c | |
| +++ src/search.c | |
| @@ -975,10 +975,33 @@ | |
| 975 | } |
| 976 | #else |
| 977 | sqlite3_result_double(context, r); |
| 978 | #endif |
| 979 | } |
| 980 | |
| 981 | /* |
| 982 | ** Expects a search pattern string. Makes a copy of the string, |
| 983 | ** replaces all non-alphanum ASCII characters with a space, and |
| 984 | ** lower-cases all upper-case ASCII characters. The intent is to avoid |
| 985 | ** causing errors in FTS5 searches with inputs which contain AND, OR, |
| 986 | ** and symbols like #. The caller is responsible for passing the |
| 987 | ** result to fossil_free(). |
| 988 | */ |
| 989 | char *search_simplify_pattern(const char * zPattern){ |
| 990 | char *zPat = mprintf("%s",zPattern); |
| 991 | int i; |
| 992 | for(i=0; zPat[i]; i++){ |
| 993 | if( (zPat[i]&0x80)==0 && !fossil_isalnum(zPat[i]) ) zPat[i] = ' '; |
| 994 | if( fossil_isupper(zPat[i]) ) zPat[i] = fossil_tolower(zPat[i]); |
| 995 | } |
| 996 | for(i--; i>=0 && zPat[i]==' '; i--){} |
| 997 | if( i<0 ){ |
| 998 | fossil_free(zPat); |
| 999 | zPat = mprintf("\"\""); |
| 1000 | } |
| 1001 | return zPat; |
| 1002 | } |
| 1003 | |
| 1004 | /* |
| 1005 | ** When this routine is called, there already exists a table |
| 1006 | ** |
| 1007 | ** x(label,url,score,id,snip). |
| @@ -997,25 +1020,16 @@ | |
| 1020 | LOCAL void search_indexed( |
| 1021 | const char *zPattern, /* The query pattern */ |
| 1022 | unsigned int srchFlags /* What to search over */ |
| 1023 | ){ |
| 1024 | Blob sql; |
| 1025 | char *zPat; |
| 1026 | static const char *zSnippetCall; |
| 1027 | if( srchFlags==0 ) return; |
| 1028 | sqlite3_create_function(g.db, "rank", 1, SQLITE_UTF8|SQLITE_INNOCUOUS, 0, |
| 1029 | search_rank_sqlfunc, 0, 0); |
| 1030 | zPat = search_simplify_pattern(zPattern); |
| 1031 | blob_init(&sql, 0, 0); |
| 1032 | if( search_index_type(0)==4 ){ |
| 1033 | /* If this repo is still using the legacy FTS4 search index, then |
| 1034 | ** the snippet() function is slightly different */ |
| 1035 | zSnippetCall = "snippet(ftsidx,'<mark>','</mark>',' ... ',-1,35)"; |
| @@ -1637,10 +1651,11 @@ | |
| 1651 | ; |
| 1652 | static const char zFtsDrop[] = |
| 1653 | @ DROP TABLE IF EXISTS repository.ftsidx; |
| 1654 | @ DROP VIEW IF EXISTS repository.ftscontent; |
| 1655 | @ DROP TABLE IF EXISTS repository.ftsdocs; |
| 1656 | @ DROP TABLE IF EXISTS repository.chatfts1; |
| 1657 | ; |
| 1658 | |
| 1659 | #if INTERFACE |
| 1660 | /* |
| 1661 | ** Values for the search-tokenizer config option. |
| @@ -1681,10 +1696,25 @@ | |
| 1696 | iFtsTokenizer = is_truth(z) ? FTS5TOK_PORTER : FTS5TOK_NONE; |
| 1697 | } |
| 1698 | fossil_free(z); |
| 1699 | return iFtsTokenizer; |
| 1700 | } |
| 1701 | |
| 1702 | /* |
| 1703 | ** Returns a string in the form ",tokenize=X", where X is the string |
| 1704 | ** counterpart of the given FTS5TOK_xyz value. Returns "" if tokType |
| 1705 | ** does not correspond to a known FTS5 tokenizer. |
| 1706 | */ |
| 1707 | const char * search_tokenize_arg_for_type(int tokType){ |
| 1708 | switch( tokType ){ |
| 1709 | case FTS5TOK_PORTER: return ",tokenize=porter"; |
| 1710 | case FTS5TOK_UNICODE61: return ",tokenize=unicode61"; |
| 1711 | case FTS5TOK_TRIGRAM: return ",tokenize=trigram"; |
| 1712 | case FTS5TOK_NONE: |
| 1713 | default: return ""; |
| 1714 | } |
| 1715 | } |
| 1716 | |
| 1717 | /* |
| 1718 | ** Returns a string value suitable for use as the search-tokenizer |
| 1719 | ** setting's value, depending on the value of z. If z is 0 then the |
| 1720 | ** current search-tokenizer value is used as the basis for formulating |
| @@ -1726,18 +1756,13 @@ | |
| 1756 | /* |
| 1757 | ** Create or drop the tables associated with a full-text index. |
| 1758 | */ |
| 1759 | static int searchIdxExists = -1; |
| 1760 | void search_create_index(void){ |
| 1761 | const char *zExtra = |
| 1762 | search_tokenize_arg_for_type(search_tokenizer_type(0)); |
| 1763 | assert( zExtra ); |
| 1764 | search_sql_setup(g.db); |
| 1765 | db_multi_exec(zFtsSchema/*works-like:"%s"*/, zExtra/*safe-for-%s*/); |
| 1766 | searchIdxExists = 1; |
| 1767 | } |
| 1768 | void search_drop_index(void){ |
| @@ -2057,10 +2082,13 @@ | |
| 2082 | fossil_print("rebuilding the search index..."); |
| 2083 | fflush(stdout); |
| 2084 | search_create_index(); |
| 2085 | search_fill_index(); |
| 2086 | search_update_index(search_restrict(SRCH_ALL)); |
| 2087 | if( db_table_exists("repository","chat") ){ |
| 2088 | chat_rebuild_index(1); |
| 2089 | } |
| 2090 | fossil_print(" done\n"); |
| 2091 | } |
| 2092 | |
| 2093 | /* |
| 2094 | ** COMMAND: fts-config* |
| 2095 |
+3
-2
| --- src/style.c | ||
| +++ src/style.c | ||
| @@ -419,10 +419,11 @@ | ||
| 419 | 419 | ** or after a change to the stylesheet. |
| 420 | 420 | */ |
| 421 | 421 | static void stylesheet_url_var(void){ |
| 422 | 422 | char *zBuiltin; /* Auxiliary page-specific CSS page */ |
| 423 | 423 | Blob url; /* The URL */ |
| 424 | + const char * zPage = local_zCurrentPage ? local_zCurrentPage : g.zPath; | |
| 424 | 425 | |
| 425 | 426 | /* Initialize the URL to its baseline */ |
| 426 | 427 | url = empty_blob; |
| 427 | 428 | blob_appendf(&url, "%R/style.css"); |
| 428 | 429 | |
| @@ -438,13 +439,13 @@ | ||
| 438 | 439 | ** |
| 439 | 440 | ** The /style.css page (implemented below) will detect this extra "wikiedit" |
| 440 | 441 | ** path information and include the page-specific CSS along with the |
| 441 | 442 | ** default CSS when it delivers the page. |
| 442 | 443 | */ |
| 443 | - zBuiltin = mprintf("style.%s.css", g.zPath); | |
| 444 | + zBuiltin = mprintf("style.%s.css", zPage); | |
| 444 | 445 | if( builtin_file(zBuiltin,0)!=0 ){ |
| 445 | - blob_appendf(&url, "/%s", g.zPath); | |
| 446 | + blob_appendf(&url, "/%s", zPage); | |
| 446 | 447 | } |
| 447 | 448 | fossil_free(zBuiltin); |
| 448 | 449 | |
| 449 | 450 | /* Add query parameters that will change whenever the skin changes |
| 450 | 451 | ** or after any updates to the CSS files |
| 451 | 452 |
| --- src/style.c | |
| +++ src/style.c | |
| @@ -419,10 +419,11 @@ | |
| 419 | ** or after a change to the stylesheet. |
| 420 | */ |
| 421 | static void stylesheet_url_var(void){ |
| 422 | char *zBuiltin; /* Auxiliary page-specific CSS page */ |
| 423 | Blob url; /* The URL */ |
| 424 | |
| 425 | /* Initialize the URL to its baseline */ |
| 426 | url = empty_blob; |
| 427 | blob_appendf(&url, "%R/style.css"); |
| 428 | |
| @@ -438,13 +439,13 @@ | |
| 438 | ** |
| 439 | ** The /style.css page (implemented below) will detect this extra "wikiedit" |
| 440 | ** path information and include the page-specific CSS along with the |
| 441 | ** default CSS when it delivers the page. |
| 442 | */ |
| 443 | zBuiltin = mprintf("style.%s.css", g.zPath); |
| 444 | if( builtin_file(zBuiltin,0)!=0 ){ |
| 445 | blob_appendf(&url, "/%s", g.zPath); |
| 446 | } |
| 447 | fossil_free(zBuiltin); |
| 448 | |
| 449 | /* Add query parameters that will change whenever the skin changes |
| 450 | ** or after any updates to the CSS files |
| 451 |
| --- src/style.c | |
| +++ src/style.c | |
| @@ -419,10 +419,11 @@ | |
| 419 | ** or after a change to the stylesheet. |
| 420 | */ |
| 421 | static void stylesheet_url_var(void){ |
| 422 | char *zBuiltin; /* Auxiliary page-specific CSS page */ |
| 423 | Blob url; /* The URL */ |
| 424 | const char * zPage = local_zCurrentPage ? local_zCurrentPage : g.zPath; |
| 425 | |
| 426 | /* Initialize the URL to its baseline */ |
| 427 | url = empty_blob; |
| 428 | blob_appendf(&url, "%R/style.css"); |
| 429 | |
| @@ -438,13 +439,13 @@ | |
| 439 | ** |
| 440 | ** The /style.css page (implemented below) will detect this extra "wikiedit" |
| 441 | ** path information and include the page-specific CSS along with the |
| 442 | ** default CSS when it delivers the page. |
| 443 | */ |
| 444 | zBuiltin = mprintf("style.%s.css", zPage); |
| 445 | if( builtin_file(zBuiltin,0)!=0 ){ |
| 446 | blob_appendf(&url, "/%s", zPage); |
| 447 | } |
| 448 | fossil_free(zBuiltin); |
| 449 | |
| 450 | /* Add query parameters that will change whenever the skin changes |
| 451 | ** or after any updates to the CSS files |
| 452 |
+49
-25
| --- src/style.chat.css | ||
| +++ src/style.chat.css | ||
| @@ -68,11 +68,11 @@ | ||
| 68 | 68 | content placed below this. */ |
| 69 | 69 | border-bottom: 1px transparent; |
| 70 | 70 | } |
| 71 | 71 | body.chat.monospace-messages .message-widget-content, |
| 72 | 72 | body.chat.monospace-messages .chat-input-field{ |
| 73 | - font-family: monospace; | |
| 73 | + font-family: monospace; | |
| 74 | 74 | } |
| 75 | 75 | body.chat .message-widget-content > * { |
| 76 | 76 | margin: 0; |
| 77 | 77 | padding: 0; |
| 78 | 78 | } |
| @@ -115,16 +115,37 @@ | ||
| 115 | 115 | white-space: nowrap; |
| 116 | 116 | } |
| 117 | 117 | body.chat .fossil-tooltip.help-buttonlet-content { |
| 118 | 118 | font-size: 80%; |
| 119 | 119 | } |
| 120 | + | |
| 121 | +body.chat .message-widget .message-widget-tab { | |
| 122 | + /* Element which renders the main metadata for a given message. */ | |
| 123 | +} | |
| 120 | 124 | body.chat .message-widget .message-widget-tab .xfrom { |
| 121 | - /* Element which holds the "this message is from user X" part | |
| 122 | - of the message banner. */ | |
| 125 | + /* xfrom part of the message tab */ | |
| 123 | 126 | font-style: italic; |
| 124 | 127 | font-weight: bold; |
| 125 | 128 | } |
| 129 | + | |
| 130 | +body.chat .message-widget .message-widget-tab .mtime { | |
| 131 | + /* mtime part of the message tab */ | |
| 132 | +} | |
| 133 | + | |
| 134 | +body.chat .message-widget .message-widget-tab .msgid { | |
| 135 | + /* msgid part of the message tab */ | |
| 136 | +} | |
| 137 | + | |
| 138 | +body.chat .message-widget .match { | |
| 139 | + font-weight: bold; | |
| 140 | + background-color: yellow; | |
| 141 | +} | |
| 142 | + | |
| 143 | +body.chat.fossil-dark-style .message-widget .match { | |
| 144 | + background-color: #ff4800; | |
| 145 | +} | |
| 146 | + | |
| 126 | 147 | /* The popup element for displaying message timestamps |
| 127 | 148 | and deletion controls. */ |
| 128 | 149 | body.chat .chat-message-popup { |
| 129 | 150 | font-family: monospace; |
| 130 | 151 | font-size: 0.9em; |
| @@ -182,20 +203,10 @@ | ||
| 182 | 203 | body.chat.chat-only-mode{ |
| 183 | 204 | padding: 0; |
| 184 | 205 | margin: 0 auto; |
| 185 | 206 | } |
| 186 | 207 | body.chat #chat-button-settings {} |
| 187 | -/** Popup widget for the /chat settings. */ | |
| 188 | -body.chat .chat-settings-popup { | |
| 189 | - font-size: 0.8em; | |
| 190 | - text-align: left; | |
| 191 | - display: flex; | |
| 192 | - flex-direction: column; | |
| 193 | - align-items: stretch; | |
| 194 | - padding: 0.25em; | |
| 195 | - z-index: 200; | |
| 196 | -} | |
| 197 | 208 | |
| 198 | 209 | /** Container for the list of /chat messages. */ |
| 199 | 210 | body.chat #chat-messages-wrapper { |
| 200 | 211 | overflow: auto; |
| 201 | 212 | padding: 0 0.25em; |
| @@ -346,18 +357,18 @@ | ||
| 346 | 357 | body.chat #chat-buttons-wrapper > .cbutton:hover { |
| 347 | 358 | background-color: rgba(200,200,200,0.3); |
| 348 | 359 | } |
| 349 | 360 | body.chat #chat-input-line-wrapper.compact #chat-buttons-wrapper > .cbutton { |
| 350 | 361 | margin: 2px 0.125em 0 0.125em; |
| 351 | - min-width: 6ex; | |
| 352 | - max-width: 6ex; | |
| 362 | + min-width: 4.5ex; | |
| 363 | + max-width: 4.5ex; | |
| 353 | 364 | min-height: 2.3ex; |
| 354 | 365 | max-height: 2.3ex; |
| 355 | 366 | font-size: 120%; |
| 356 | 367 | } |
| 357 | 368 | body.chat #chat-input-line-wrapper.compact #chat-buttons-wrapper #chat-button-submit { |
| 358 | - min-width: 12ex; | |
| 369 | + min-width: 10ex; | |
| 359 | 370 | } |
| 360 | 371 | .chat-input-field { |
| 361 | 372 | font-family: inherit |
| 362 | 373 | } |
| 363 | 374 | body.chat #chat-input-line-wrapper:not(.compact) #chat-input-field-multi, |
| @@ -440,10 +451,11 @@ | ||
| 440 | 451 | /*ensure that these grow more than the non-.chat-view elements. |
| 441 | 452 | Note that setting flex shrink to 0 breaks/disables scrolling!*/; |
| 442 | 453 | margin-bottom: 0.2em; |
| 443 | 454 | } |
| 444 | 455 | body.chat #chat-config, |
| 456 | +body.chat #chat-search, | |
| 445 | 457 | body.chat #chat-preview { |
| 446 | 458 | /* /chat configuration widget */ |
| 447 | 459 | display: flex; |
| 448 | 460 | flex-direction: column; |
| 449 | 461 | overflow: auto; |
| @@ -518,31 +530,38 @@ | ||
| 518 | 530 | display: inline-block; |
| 519 | 531 | opacity: 0.85; |
| 520 | 532 | } |
| 521 | 533 | body.chat #chat-config #chat-config-options .menu-entry select { |
| 522 | 534 | } |
| 523 | -body.chat #chat-preview #chat-preview-content { | |
| 535 | +body.chat #chat-preview #chat-preview-content, | |
| 536 | +body.chat #chat-search #chat-search-content { | |
| 524 | 537 | overflow: auto; |
| 525 | 538 | flex: 1 1 auto; |
| 526 | 539 | padding: 0.5em; |
| 527 | 540 | border: 1px dotted; |
| 528 | 541 | } |
| 542 | + | |
| 529 | 543 | body.chat #chat-preview #chat-preview-content > * { |
| 530 | 544 | margin: 0; |
| 531 | 545 | padding: 0; |
| 532 | 546 | } |
| 533 | -body.chat #chat-preview #chat-preview-buttons { | |
| 547 | +body.chat .chat-view .button-bar { | |
| 534 | 548 | flex: 0 1 auto; |
| 535 | 549 | display: flex; |
| 536 | 550 | flex-direction: column; |
| 537 | 551 | } |
| 538 | -body.chat #chat-config > button, | |
| 539 | -body.chat #chat-preview #chat-preview-buttons > button { | |
| 552 | +body.chat .chat-view .button-bar button { | |
| 540 | 553 | padding: 0.5em; |
| 541 | - flex: 0 1 auto; | |
| 554 | + flex: 1 1 auto; | |
| 542 | 555 | margin: 0.25em 0; |
| 543 | 556 | } |
| 557 | + | |
| 558 | +body.chat #chat-search .button-bar { | |
| 559 | + flex: 0 1 auto; | |
| 560 | + display: flex; | |
| 561 | + flex-direction: row; | |
| 562 | +} | |
| 544 | 563 | |
| 545 | 564 | body.chat #chat-user-list-wrapper { |
| 546 | 565 | /* Safari can't do fieldsets right, so we emulate one. */ |
| 547 | 566 | border-radius: 0.5em; |
| 548 | 567 | margin: 1em 0 0.2em 0; |
| @@ -616,14 +635,19 @@ | ||
| 616 | 635 | |
| 617 | 636 | body.chat #chat-clear-filter { |
| 618 | 637 | margin: 0.25em 0.5em; |
| 619 | 638 | } |
| 620 | 639 | |
| 621 | -body.chat.fossil-dark-style #chat-button-attach > svg { | |
| 622 | - /* The black paperclip is barely visible in dark-mode | |
| 623 | - skins when they have dark buttons */ | |
| 624 | - filter: invert(0.8); | |
| 640 | +body.chat .searchForm { | |
| 641 | + margin-top: 1em; | |
| 642 | +} | |
| 643 | +body.chat .spacer-widget button { | |
| 644 | + margin-left: 1ex; | |
| 645 | + margin-right: 1ex; | |
| 646 | + display: block; | |
| 647 | + margin-top: 0.5em; | |
| 648 | + margin-bottom: 0.5em; | |
| 625 | 649 | } |
| 626 | 650 | |
| 627 | 651 | body.chat .anim-rotate-360 { |
| 628 | 652 | animation: rotate-360 750ms linear; |
| 629 | 653 | } |
| 630 | 654 |
| --- src/style.chat.css | |
| +++ src/style.chat.css | |
| @@ -68,11 +68,11 @@ | |
| 68 | content placed below this. */ |
| 69 | border-bottom: 1px transparent; |
| 70 | } |
| 71 | body.chat.monospace-messages .message-widget-content, |
| 72 | body.chat.monospace-messages .chat-input-field{ |
| 73 | font-family: monospace; |
| 74 | } |
| 75 | body.chat .message-widget-content > * { |
| 76 | margin: 0; |
| 77 | padding: 0; |
| 78 | } |
| @@ -115,16 +115,37 @@ | |
| 115 | white-space: nowrap; |
| 116 | } |
| 117 | body.chat .fossil-tooltip.help-buttonlet-content { |
| 118 | font-size: 80%; |
| 119 | } |
| 120 | body.chat .message-widget .message-widget-tab .xfrom { |
| 121 | /* Element which holds the "this message is from user X" part |
| 122 | of the message banner. */ |
| 123 | font-style: italic; |
| 124 | font-weight: bold; |
| 125 | } |
| 126 | /* The popup element for displaying message timestamps |
| 127 | and deletion controls. */ |
| 128 | body.chat .chat-message-popup { |
| 129 | font-family: monospace; |
| 130 | font-size: 0.9em; |
| @@ -182,20 +203,10 @@ | |
| 182 | body.chat.chat-only-mode{ |
| 183 | padding: 0; |
| 184 | margin: 0 auto; |
| 185 | } |
| 186 | body.chat #chat-button-settings {} |
| 187 | /** Popup widget for the /chat settings. */ |
| 188 | body.chat .chat-settings-popup { |
| 189 | font-size: 0.8em; |
| 190 | text-align: left; |
| 191 | display: flex; |
| 192 | flex-direction: column; |
| 193 | align-items: stretch; |
| 194 | padding: 0.25em; |
| 195 | z-index: 200; |
| 196 | } |
| 197 | |
| 198 | /** Container for the list of /chat messages. */ |
| 199 | body.chat #chat-messages-wrapper { |
| 200 | overflow: auto; |
| 201 | padding: 0 0.25em; |
| @@ -346,18 +357,18 @@ | |
| 346 | body.chat #chat-buttons-wrapper > .cbutton:hover { |
| 347 | background-color: rgba(200,200,200,0.3); |
| 348 | } |
| 349 | body.chat #chat-input-line-wrapper.compact #chat-buttons-wrapper > .cbutton { |
| 350 | margin: 2px 0.125em 0 0.125em; |
| 351 | min-width: 6ex; |
| 352 | max-width: 6ex; |
| 353 | min-height: 2.3ex; |
| 354 | max-height: 2.3ex; |
| 355 | font-size: 120%; |
| 356 | } |
| 357 | body.chat #chat-input-line-wrapper.compact #chat-buttons-wrapper #chat-button-submit { |
| 358 | min-width: 12ex; |
| 359 | } |
| 360 | .chat-input-field { |
| 361 | font-family: inherit |
| 362 | } |
| 363 | body.chat #chat-input-line-wrapper:not(.compact) #chat-input-field-multi, |
| @@ -440,10 +451,11 @@ | |
| 440 | /*ensure that these grow more than the non-.chat-view elements. |
| 441 | Note that setting flex shrink to 0 breaks/disables scrolling!*/; |
| 442 | margin-bottom: 0.2em; |
| 443 | } |
| 444 | body.chat #chat-config, |
| 445 | body.chat #chat-preview { |
| 446 | /* /chat configuration widget */ |
| 447 | display: flex; |
| 448 | flex-direction: column; |
| 449 | overflow: auto; |
| @@ -518,31 +530,38 @@ | |
| 518 | display: inline-block; |
| 519 | opacity: 0.85; |
| 520 | } |
| 521 | body.chat #chat-config #chat-config-options .menu-entry select { |
| 522 | } |
| 523 | body.chat #chat-preview #chat-preview-content { |
| 524 | overflow: auto; |
| 525 | flex: 1 1 auto; |
| 526 | padding: 0.5em; |
| 527 | border: 1px dotted; |
| 528 | } |
| 529 | body.chat #chat-preview #chat-preview-content > * { |
| 530 | margin: 0; |
| 531 | padding: 0; |
| 532 | } |
| 533 | body.chat #chat-preview #chat-preview-buttons { |
| 534 | flex: 0 1 auto; |
| 535 | display: flex; |
| 536 | flex-direction: column; |
| 537 | } |
| 538 | body.chat #chat-config > button, |
| 539 | body.chat #chat-preview #chat-preview-buttons > button { |
| 540 | padding: 0.5em; |
| 541 | flex: 0 1 auto; |
| 542 | margin: 0.25em 0; |
| 543 | } |
| 544 | |
| 545 | body.chat #chat-user-list-wrapper { |
| 546 | /* Safari can't do fieldsets right, so we emulate one. */ |
| 547 | border-radius: 0.5em; |
| 548 | margin: 1em 0 0.2em 0; |
| @@ -616,14 +635,19 @@ | |
| 616 | |
| 617 | body.chat #chat-clear-filter { |
| 618 | margin: 0.25em 0.5em; |
| 619 | } |
| 620 | |
| 621 | body.chat.fossil-dark-style #chat-button-attach > svg { |
| 622 | /* The black paperclip is barely visible in dark-mode |
| 623 | skins when they have dark buttons */ |
| 624 | filter: invert(0.8); |
| 625 | } |
| 626 | |
| 627 | body.chat .anim-rotate-360 { |
| 628 | animation: rotate-360 750ms linear; |
| 629 | } |
| 630 |
| --- src/style.chat.css | |
| +++ src/style.chat.css | |
| @@ -68,11 +68,11 @@ | |
| 68 | content placed below this. */ |
| 69 | border-bottom: 1px transparent; |
| 70 | } |
| 71 | body.chat.monospace-messages .message-widget-content, |
| 72 | body.chat.monospace-messages .chat-input-field{ |
| 73 | font-family: monospace; |
| 74 | } |
| 75 | body.chat .message-widget-content > * { |
| 76 | margin: 0; |
| 77 | padding: 0; |
| 78 | } |
| @@ -115,16 +115,37 @@ | |
| 115 | white-space: nowrap; |
| 116 | } |
| 117 | body.chat .fossil-tooltip.help-buttonlet-content { |
| 118 | font-size: 80%; |
| 119 | } |
| 120 | |
| 121 | body.chat .message-widget .message-widget-tab { |
| 122 | /* Element which renders the main metadata for a given message. */ |
| 123 | } |
| 124 | body.chat .message-widget .message-widget-tab .xfrom { |
| 125 | /* xfrom part of the message tab */ |
| 126 | font-style: italic; |
| 127 | font-weight: bold; |
| 128 | } |
| 129 | |
| 130 | body.chat .message-widget .message-widget-tab .mtime { |
| 131 | /* mtime part of the message tab */ |
| 132 | } |
| 133 | |
| 134 | body.chat .message-widget .message-widget-tab .msgid { |
| 135 | /* msgid part of the message tab */ |
| 136 | } |
| 137 | |
| 138 | body.chat .message-widget .match { |
| 139 | font-weight: bold; |
| 140 | background-color: yellow; |
| 141 | } |
| 142 | |
| 143 | body.chat.fossil-dark-style .message-widget .match { |
| 144 | background-color: #ff4800; |
| 145 | } |
| 146 | |
| 147 | /* The popup element for displaying message timestamps |
| 148 | and deletion controls. */ |
| 149 | body.chat .chat-message-popup { |
| 150 | font-family: monospace; |
| 151 | font-size: 0.9em; |
| @@ -182,20 +203,10 @@ | |
| 203 | body.chat.chat-only-mode{ |
| 204 | padding: 0; |
| 205 | margin: 0 auto; |
| 206 | } |
| 207 | body.chat #chat-button-settings {} |
| 208 | |
| 209 | /** Container for the list of /chat messages. */ |
| 210 | body.chat #chat-messages-wrapper { |
| 211 | overflow: auto; |
| 212 | padding: 0 0.25em; |
| @@ -346,18 +357,18 @@ | |
| 357 | body.chat #chat-buttons-wrapper > .cbutton:hover { |
| 358 | background-color: rgba(200,200,200,0.3); |
| 359 | } |
| 360 | body.chat #chat-input-line-wrapper.compact #chat-buttons-wrapper > .cbutton { |
| 361 | margin: 2px 0.125em 0 0.125em; |
| 362 | min-width: 4.5ex; |
| 363 | max-width: 4.5ex; |
| 364 | min-height: 2.3ex; |
| 365 | max-height: 2.3ex; |
| 366 | font-size: 120%; |
| 367 | } |
| 368 | body.chat #chat-input-line-wrapper.compact #chat-buttons-wrapper #chat-button-submit { |
| 369 | min-width: 10ex; |
| 370 | } |
| 371 | .chat-input-field { |
| 372 | font-family: inherit |
| 373 | } |
| 374 | body.chat #chat-input-line-wrapper:not(.compact) #chat-input-field-multi, |
| @@ -440,10 +451,11 @@ | |
| 451 | /*ensure that these grow more than the non-.chat-view elements. |
| 452 | Note that setting flex shrink to 0 breaks/disables scrolling!*/; |
| 453 | margin-bottom: 0.2em; |
| 454 | } |
| 455 | body.chat #chat-config, |
| 456 | body.chat #chat-search, |
| 457 | body.chat #chat-preview { |
| 458 | /* /chat configuration widget */ |
| 459 | display: flex; |
| 460 | flex-direction: column; |
| 461 | overflow: auto; |
| @@ -518,31 +530,38 @@ | |
| 530 | display: inline-block; |
| 531 | opacity: 0.85; |
| 532 | } |
| 533 | body.chat #chat-config #chat-config-options .menu-entry select { |
| 534 | } |
| 535 | body.chat #chat-preview #chat-preview-content, |
| 536 | body.chat #chat-search #chat-search-content { |
| 537 | overflow: auto; |
| 538 | flex: 1 1 auto; |
| 539 | padding: 0.5em; |
| 540 | border: 1px dotted; |
| 541 | } |
| 542 | |
| 543 | body.chat #chat-preview #chat-preview-content > * { |
| 544 | margin: 0; |
| 545 | padding: 0; |
| 546 | } |
| 547 | body.chat .chat-view .button-bar { |
| 548 | flex: 0 1 auto; |
| 549 | display: flex; |
| 550 | flex-direction: column; |
| 551 | } |
| 552 | body.chat .chat-view .button-bar button { |
| 553 | padding: 0.5em; |
| 554 | flex: 1 1 auto; |
| 555 | margin: 0.25em 0; |
| 556 | } |
| 557 | |
| 558 | body.chat #chat-search .button-bar { |
| 559 | flex: 0 1 auto; |
| 560 | display: flex; |
| 561 | flex-direction: row; |
| 562 | } |
| 563 | |
| 564 | body.chat #chat-user-list-wrapper { |
| 565 | /* Safari can't do fieldsets right, so we emulate one. */ |
| 566 | border-radius: 0.5em; |
| 567 | margin: 1em 0 0.2em 0; |
| @@ -616,14 +635,19 @@ | |
| 635 | |
| 636 | body.chat #chat-clear-filter { |
| 637 | margin: 0.25em 0.5em; |
| 638 | } |
| 639 | |
| 640 | body.chat .searchForm { |
| 641 | margin-top: 1em; |
| 642 | } |
| 643 | body.chat .spacer-widget button { |
| 644 | margin-left: 1ex; |
| 645 | margin-right: 1ex; |
| 646 | display: block; |
| 647 | margin-top: 0.5em; |
| 648 | margin-bottom: 0.5em; |
| 649 | } |
| 650 | |
| 651 | body.chat .anim-rotate-360 { |
| 652 | animation: rotate-360 750ms linear; |
| 653 | } |
| 654 |
+49
-25
| --- src/style.chat.css | ||
| +++ src/style.chat.css | ||
| @@ -68,11 +68,11 @@ | ||
| 68 | 68 | content placed below this. */ |
| 69 | 69 | border-bottom: 1px transparent; |
| 70 | 70 | } |
| 71 | 71 | body.chat.monospace-messages .message-widget-content, |
| 72 | 72 | body.chat.monospace-messages .chat-input-field{ |
| 73 | - font-family: monospace; | |
| 73 | + font-family: monospace; | |
| 74 | 74 | } |
| 75 | 75 | body.chat .message-widget-content > * { |
| 76 | 76 | margin: 0; |
| 77 | 77 | padding: 0; |
| 78 | 78 | } |
| @@ -115,16 +115,37 @@ | ||
| 115 | 115 | white-space: nowrap; |
| 116 | 116 | } |
| 117 | 117 | body.chat .fossil-tooltip.help-buttonlet-content { |
| 118 | 118 | font-size: 80%; |
| 119 | 119 | } |
| 120 | + | |
| 121 | +body.chat .message-widget .message-widget-tab { | |
| 122 | + /* Element which renders the main metadata for a given message. */ | |
| 123 | +} | |
| 120 | 124 | body.chat .message-widget .message-widget-tab .xfrom { |
| 121 | - /* Element which holds the "this message is from user X" part | |
| 122 | - of the message banner. */ | |
| 125 | + /* xfrom part of the message tab */ | |
| 123 | 126 | font-style: italic; |
| 124 | 127 | font-weight: bold; |
| 125 | 128 | } |
| 129 | + | |
| 130 | +body.chat .message-widget .message-widget-tab .mtime { | |
| 131 | + /* mtime part of the message tab */ | |
| 132 | +} | |
| 133 | + | |
| 134 | +body.chat .message-widget .message-widget-tab .msgid { | |
| 135 | + /* msgid part of the message tab */ | |
| 136 | +} | |
| 137 | + | |
| 138 | +body.chat .message-widget .match { | |
| 139 | + font-weight: bold; | |
| 140 | + background-color: yellow; | |
| 141 | +} | |
| 142 | + | |
| 143 | +body.chat.fossil-dark-style .message-widget .match { | |
| 144 | + background-color: #ff4800; | |
| 145 | +} | |
| 146 | + | |
| 126 | 147 | /* The popup element for displaying message timestamps |
| 127 | 148 | and deletion controls. */ |
| 128 | 149 | body.chat .chat-message-popup { |
| 129 | 150 | font-family: monospace; |
| 130 | 151 | font-size: 0.9em; |
| @@ -182,20 +203,10 @@ | ||
| 182 | 203 | body.chat.chat-only-mode{ |
| 183 | 204 | padding: 0; |
| 184 | 205 | margin: 0 auto; |
| 185 | 206 | } |
| 186 | 207 | body.chat #chat-button-settings {} |
| 187 | -/** Popup widget for the /chat settings. */ | |
| 188 | -body.chat .chat-settings-popup { | |
| 189 | - font-size: 0.8em; | |
| 190 | - text-align: left; | |
| 191 | - display: flex; | |
| 192 | - flex-direction: column; | |
| 193 | - align-items: stretch; | |
| 194 | - padding: 0.25em; | |
| 195 | - z-index: 200; | |
| 196 | -} | |
| 197 | 208 | |
| 198 | 209 | /** Container for the list of /chat messages. */ |
| 199 | 210 | body.chat #chat-messages-wrapper { |
| 200 | 211 | overflow: auto; |
| 201 | 212 | padding: 0 0.25em; |
| @@ -346,18 +357,18 @@ | ||
| 346 | 357 | body.chat #chat-buttons-wrapper > .cbutton:hover { |
| 347 | 358 | background-color: rgba(200,200,200,0.3); |
| 348 | 359 | } |
| 349 | 360 | body.chat #chat-input-line-wrapper.compact #chat-buttons-wrapper > .cbutton { |
| 350 | 361 | margin: 2px 0.125em 0 0.125em; |
| 351 | - min-width: 6ex; | |
| 352 | - max-width: 6ex; | |
| 362 | + min-width: 4.5ex; | |
| 363 | + max-width: 4.5ex; | |
| 353 | 364 | min-height: 2.3ex; |
| 354 | 365 | max-height: 2.3ex; |
| 355 | 366 | font-size: 120%; |
| 356 | 367 | } |
| 357 | 368 | body.chat #chat-input-line-wrapper.compact #chat-buttons-wrapper #chat-button-submit { |
| 358 | - min-width: 12ex; | |
| 369 | + min-width: 10ex; | |
| 359 | 370 | } |
| 360 | 371 | .chat-input-field { |
| 361 | 372 | font-family: inherit |
| 362 | 373 | } |
| 363 | 374 | body.chat #chat-input-line-wrapper:not(.compact) #chat-input-field-multi, |
| @@ -440,10 +451,11 @@ | ||
| 440 | 451 | /*ensure that these grow more than the non-.chat-view elements. |
| 441 | 452 | Note that setting flex shrink to 0 breaks/disables scrolling!*/; |
| 442 | 453 | margin-bottom: 0.2em; |
| 443 | 454 | } |
| 444 | 455 | body.chat #chat-config, |
| 456 | +body.chat #chat-search, | |
| 445 | 457 | body.chat #chat-preview { |
| 446 | 458 | /* /chat configuration widget */ |
| 447 | 459 | display: flex; |
| 448 | 460 | flex-direction: column; |
| 449 | 461 | overflow: auto; |
| @@ -518,31 +530,38 @@ | ||
| 518 | 530 | display: inline-block; |
| 519 | 531 | opacity: 0.85; |
| 520 | 532 | } |
| 521 | 533 | body.chat #chat-config #chat-config-options .menu-entry select { |
| 522 | 534 | } |
| 523 | -body.chat #chat-preview #chat-preview-content { | |
| 535 | +body.chat #chat-preview #chat-preview-content, | |
| 536 | +body.chat #chat-search #chat-search-content { | |
| 524 | 537 | overflow: auto; |
| 525 | 538 | flex: 1 1 auto; |
| 526 | 539 | padding: 0.5em; |
| 527 | 540 | border: 1px dotted; |
| 528 | 541 | } |
| 542 | + | |
| 529 | 543 | body.chat #chat-preview #chat-preview-content > * { |
| 530 | 544 | margin: 0; |
| 531 | 545 | padding: 0; |
| 532 | 546 | } |
| 533 | -body.chat #chat-preview #chat-preview-buttons { | |
| 547 | +body.chat .chat-view .button-bar { | |
| 534 | 548 | flex: 0 1 auto; |
| 535 | 549 | display: flex; |
| 536 | 550 | flex-direction: column; |
| 537 | 551 | } |
| 538 | -body.chat #chat-config > button, | |
| 539 | -body.chat #chat-preview #chat-preview-buttons > button { | |
| 552 | +body.chat .chat-view .button-bar button { | |
| 540 | 553 | padding: 0.5em; |
| 541 | - flex: 0 1 auto; | |
| 554 | + flex: 1 1 auto; | |
| 542 | 555 | margin: 0.25em 0; |
| 543 | 556 | } |
| 557 | + | |
| 558 | +body.chat #chat-search .button-bar { | |
| 559 | + flex: 0 1 auto; | |
| 560 | + display: flex; | |
| 561 | + flex-direction: row; | |
| 562 | +} | |
| 544 | 563 | |
| 545 | 564 | body.chat #chat-user-list-wrapper { |
| 546 | 565 | /* Safari can't do fieldsets right, so we emulate one. */ |
| 547 | 566 | border-radius: 0.5em; |
| 548 | 567 | margin: 1em 0 0.2em 0; |
| @@ -616,14 +635,19 @@ | ||
| 616 | 635 | |
| 617 | 636 | body.chat #chat-clear-filter { |
| 618 | 637 | margin: 0.25em 0.5em; |
| 619 | 638 | } |
| 620 | 639 | |
| 621 | -body.chat.fossil-dark-style #chat-button-attach > svg { | |
| 622 | - /* The black paperclip is barely visible in dark-mode | |
| 623 | - skins when they have dark buttons */ | |
| 624 | - filter: invert(0.8); | |
| 640 | +body.chat .searchForm { | |
| 641 | + margin-top: 1em; | |
| 642 | +} | |
| 643 | +body.chat .spacer-widget button { | |
| 644 | + margin-left: 1ex; | |
| 645 | + margin-right: 1ex; | |
| 646 | + display: block; | |
| 647 | + margin-top: 0.5em; | |
| 648 | + margin-bottom: 0.5em; | |
| 625 | 649 | } |
| 626 | 650 | |
| 627 | 651 | body.chat .anim-rotate-360 { |
| 628 | 652 | animation: rotate-360 750ms linear; |
| 629 | 653 | } |
| 630 | 654 |
| --- src/style.chat.css | |
| +++ src/style.chat.css | |
| @@ -68,11 +68,11 @@ | |
| 68 | content placed below this. */ |
| 69 | border-bottom: 1px transparent; |
| 70 | } |
| 71 | body.chat.monospace-messages .message-widget-content, |
| 72 | body.chat.monospace-messages .chat-input-field{ |
| 73 | font-family: monospace; |
| 74 | } |
| 75 | body.chat .message-widget-content > * { |
| 76 | margin: 0; |
| 77 | padding: 0; |
| 78 | } |
| @@ -115,16 +115,37 @@ | |
| 115 | white-space: nowrap; |
| 116 | } |
| 117 | body.chat .fossil-tooltip.help-buttonlet-content { |
| 118 | font-size: 80%; |
| 119 | } |
| 120 | body.chat .message-widget .message-widget-tab .xfrom { |
| 121 | /* Element which holds the "this message is from user X" part |
| 122 | of the message banner. */ |
| 123 | font-style: italic; |
| 124 | font-weight: bold; |
| 125 | } |
| 126 | /* The popup element for displaying message timestamps |
| 127 | and deletion controls. */ |
| 128 | body.chat .chat-message-popup { |
| 129 | font-family: monospace; |
| 130 | font-size: 0.9em; |
| @@ -182,20 +203,10 @@ | |
| 182 | body.chat.chat-only-mode{ |
| 183 | padding: 0; |
| 184 | margin: 0 auto; |
| 185 | } |
| 186 | body.chat #chat-button-settings {} |
| 187 | /** Popup widget for the /chat settings. */ |
| 188 | body.chat .chat-settings-popup { |
| 189 | font-size: 0.8em; |
| 190 | text-align: left; |
| 191 | display: flex; |
| 192 | flex-direction: column; |
| 193 | align-items: stretch; |
| 194 | padding: 0.25em; |
| 195 | z-index: 200; |
| 196 | } |
| 197 | |
| 198 | /** Container for the list of /chat messages. */ |
| 199 | body.chat #chat-messages-wrapper { |
| 200 | overflow: auto; |
| 201 | padding: 0 0.25em; |
| @@ -346,18 +357,18 @@ | |
| 346 | body.chat #chat-buttons-wrapper > .cbutton:hover { |
| 347 | background-color: rgba(200,200,200,0.3); |
| 348 | } |
| 349 | body.chat #chat-input-line-wrapper.compact #chat-buttons-wrapper > .cbutton { |
| 350 | margin: 2px 0.125em 0 0.125em; |
| 351 | min-width: 6ex; |
| 352 | max-width: 6ex; |
| 353 | min-height: 2.3ex; |
| 354 | max-height: 2.3ex; |
| 355 | font-size: 120%; |
| 356 | } |
| 357 | body.chat #chat-input-line-wrapper.compact #chat-buttons-wrapper #chat-button-submit { |
| 358 | min-width: 12ex; |
| 359 | } |
| 360 | .chat-input-field { |
| 361 | font-family: inherit |
| 362 | } |
| 363 | body.chat #chat-input-line-wrapper:not(.compact) #chat-input-field-multi, |
| @@ -440,10 +451,11 @@ | |
| 440 | /*ensure that these grow more than the non-.chat-view elements. |
| 441 | Note that setting flex shrink to 0 breaks/disables scrolling!*/; |
| 442 | margin-bottom: 0.2em; |
| 443 | } |
| 444 | body.chat #chat-config, |
| 445 | body.chat #chat-preview { |
| 446 | /* /chat configuration widget */ |
| 447 | display: flex; |
| 448 | flex-direction: column; |
| 449 | overflow: auto; |
| @@ -518,31 +530,38 @@ | |
| 518 | display: inline-block; |
| 519 | opacity: 0.85; |
| 520 | } |
| 521 | body.chat #chat-config #chat-config-options .menu-entry select { |
| 522 | } |
| 523 | body.chat #chat-preview #chat-preview-content { |
| 524 | overflow: auto; |
| 525 | flex: 1 1 auto; |
| 526 | padding: 0.5em; |
| 527 | border: 1px dotted; |
| 528 | } |
| 529 | body.chat #chat-preview #chat-preview-content > * { |
| 530 | margin: 0; |
| 531 | padding: 0; |
| 532 | } |
| 533 | body.chat #chat-preview #chat-preview-buttons { |
| 534 | flex: 0 1 auto; |
| 535 | display: flex; |
| 536 | flex-direction: column; |
| 537 | } |
| 538 | body.chat #chat-config > button, |
| 539 | body.chat #chat-preview #chat-preview-buttons > button { |
| 540 | padding: 0.5em; |
| 541 | flex: 0 1 auto; |
| 542 | margin: 0.25em 0; |
| 543 | } |
| 544 | |
| 545 | body.chat #chat-user-list-wrapper { |
| 546 | /* Safari can't do fieldsets right, so we emulate one. */ |
| 547 | border-radius: 0.5em; |
| 548 | margin: 1em 0 0.2em 0; |
| @@ -616,14 +635,19 @@ | |
| 616 | |
| 617 | body.chat #chat-clear-filter { |
| 618 | margin: 0.25em 0.5em; |
| 619 | } |
| 620 | |
| 621 | body.chat.fossil-dark-style #chat-button-attach > svg { |
| 622 | /* The black paperclip is barely visible in dark-mode |
| 623 | skins when they have dark buttons */ |
| 624 | filter: invert(0.8); |
| 625 | } |
| 626 | |
| 627 | body.chat .anim-rotate-360 { |
| 628 | animation: rotate-360 750ms linear; |
| 629 | } |
| 630 |
| --- src/style.chat.css | |
| +++ src/style.chat.css | |
| @@ -68,11 +68,11 @@ | |
| 68 | content placed below this. */ |
| 69 | border-bottom: 1px transparent; |
| 70 | } |
| 71 | body.chat.monospace-messages .message-widget-content, |
| 72 | body.chat.monospace-messages .chat-input-field{ |
| 73 | font-family: monospace; |
| 74 | } |
| 75 | body.chat .message-widget-content > * { |
| 76 | margin: 0; |
| 77 | padding: 0; |
| 78 | } |
| @@ -115,16 +115,37 @@ | |
| 115 | white-space: nowrap; |
| 116 | } |
| 117 | body.chat .fossil-tooltip.help-buttonlet-content { |
| 118 | font-size: 80%; |
| 119 | } |
| 120 | |
| 121 | body.chat .message-widget .message-widget-tab { |
| 122 | /* Element which renders the main metadata for a given message. */ |
| 123 | } |
| 124 | body.chat .message-widget .message-widget-tab .xfrom { |
| 125 | /* xfrom part of the message tab */ |
| 126 | font-style: italic; |
| 127 | font-weight: bold; |
| 128 | } |
| 129 | |
| 130 | body.chat .message-widget .message-widget-tab .mtime { |
| 131 | /* mtime part of the message tab */ |
| 132 | } |
| 133 | |
| 134 | body.chat .message-widget .message-widget-tab .msgid { |
| 135 | /* msgid part of the message tab */ |
| 136 | } |
| 137 | |
| 138 | body.chat .message-widget .match { |
| 139 | font-weight: bold; |
| 140 | background-color: yellow; |
| 141 | } |
| 142 | |
| 143 | body.chat.fossil-dark-style .message-widget .match { |
| 144 | background-color: #ff4800; |
| 145 | } |
| 146 | |
| 147 | /* The popup element for displaying message timestamps |
| 148 | and deletion controls. */ |
| 149 | body.chat .chat-message-popup { |
| 150 | font-family: monospace; |
| 151 | font-size: 0.9em; |
| @@ -182,20 +203,10 @@ | |
| 203 | body.chat.chat-only-mode{ |
| 204 | padding: 0; |
| 205 | margin: 0 auto; |
| 206 | } |
| 207 | body.chat #chat-button-settings {} |
| 208 | |
| 209 | /** Container for the list of /chat messages. */ |
| 210 | body.chat #chat-messages-wrapper { |
| 211 | overflow: auto; |
| 212 | padding: 0 0.25em; |
| @@ -346,18 +357,18 @@ | |
| 357 | body.chat #chat-buttons-wrapper > .cbutton:hover { |
| 358 | background-color: rgba(200,200,200,0.3); |
| 359 | } |
| 360 | body.chat #chat-input-line-wrapper.compact #chat-buttons-wrapper > .cbutton { |
| 361 | margin: 2px 0.125em 0 0.125em; |
| 362 | min-width: 4.5ex; |
| 363 | max-width: 4.5ex; |
| 364 | min-height: 2.3ex; |
| 365 | max-height: 2.3ex; |
| 366 | font-size: 120%; |
| 367 | } |
| 368 | body.chat #chat-input-line-wrapper.compact #chat-buttons-wrapper #chat-button-submit { |
| 369 | min-width: 10ex; |
| 370 | } |
| 371 | .chat-input-field { |
| 372 | font-family: inherit |
| 373 | } |
| 374 | body.chat #chat-input-line-wrapper:not(.compact) #chat-input-field-multi, |
| @@ -440,10 +451,11 @@ | |
| 451 | /*ensure that these grow more than the non-.chat-view elements. |
| 452 | Note that setting flex shrink to 0 breaks/disables scrolling!*/; |
| 453 | margin-bottom: 0.2em; |
| 454 | } |
| 455 | body.chat #chat-config, |
| 456 | body.chat #chat-search, |
| 457 | body.chat #chat-preview { |
| 458 | /* /chat configuration widget */ |
| 459 | display: flex; |
| 460 | flex-direction: column; |
| 461 | overflow: auto; |
| @@ -518,31 +530,38 @@ | |
| 530 | display: inline-block; |
| 531 | opacity: 0.85; |
| 532 | } |
| 533 | body.chat #chat-config #chat-config-options .menu-entry select { |
| 534 | } |
| 535 | body.chat #chat-preview #chat-preview-content, |
| 536 | body.chat #chat-search #chat-search-content { |
| 537 | overflow: auto; |
| 538 | flex: 1 1 auto; |
| 539 | padding: 0.5em; |
| 540 | border: 1px dotted; |
| 541 | } |
| 542 | |
| 543 | body.chat #chat-preview #chat-preview-content > * { |
| 544 | margin: 0; |
| 545 | padding: 0; |
| 546 | } |
| 547 | body.chat .chat-view .button-bar { |
| 548 | flex: 0 1 auto; |
| 549 | display: flex; |
| 550 | flex-direction: column; |
| 551 | } |
| 552 | body.chat .chat-view .button-bar button { |
| 553 | padding: 0.5em; |
| 554 | flex: 1 1 auto; |
| 555 | margin: 0.25em 0; |
| 556 | } |
| 557 | |
| 558 | body.chat #chat-search .button-bar { |
| 559 | flex: 0 1 auto; |
| 560 | display: flex; |
| 561 | flex-direction: row; |
| 562 | } |
| 563 | |
| 564 | body.chat #chat-user-list-wrapper { |
| 565 | /* Safari can't do fieldsets right, so we emulate one. */ |
| 566 | border-radius: 0.5em; |
| 567 | margin: 1em 0 0.2em 0; |
| @@ -616,14 +635,19 @@ | |
| 635 | |
| 636 | body.chat #chat-clear-filter { |
| 637 | margin: 0.25em 0.5em; |
| 638 | } |
| 639 | |
| 640 | body.chat .searchForm { |
| 641 | margin-top: 1em; |
| 642 | } |
| 643 | body.chat .spacer-widget button { |
| 644 | margin-left: 1ex; |
| 645 | margin-right: 1ex; |
| 646 | display: block; |
| 647 | margin-top: 0.5em; |
| 648 | margin-bottom: 0.5em; |
| 649 | } |
| 650 | |
| 651 | body.chat .anim-rotate-360 { |
| 652 | animation: rotate-360 750ms linear; |
| 653 | } |
| 654 |
+3
-2
| --- www/changes.wiki | ||
| +++ www/changes.wiki | ||
| @@ -10,12 +10,13 @@ | ||
| 10 | 10 | * Change the name "fossil cherry-pick" command to "fossil cherrypick", |
| 11 | 11 | which is more familiar to Git users. Retain the legacy name for |
| 12 | 12 | compatibility. |
| 13 | 13 | * Add new query parameters to the [/help?cmd=/timeline|/timeline page]: |
| 14 | 14 | d2=, p2=, and dp2=. |
| 15 | - * Add options to the [/help?cmd=tag|fossil tag] command that will list tag values | |
| 15 | + * Add options to the [/help?cmd=tag|fossil tag] command that will list tag values. | |
| 16 | 16 | * Add ability to upload unversioned files via the [/help?cmd=/uvlist|/uvlist page]. |
| 17 | + * Add history search to the [/help?cmd=/chat|/chat page]. | |
| 17 | 18 | |
| 18 | 19 | |
| 19 | 20 | <h2 id='v2_24'>Changes for version 2.24 (2024-04-23)</h2> |
| 20 | 21 | |
| 21 | 22 | * Apache change work-around → As part of a security fix, the Apache webserver |
| @@ -34,11 +35,11 @@ | ||
| 34 | 35 | offset command examples, etc. Adjusted colors slightly to bring |
| 35 | 36 | things into better accord with the WCAG accessibility guidelines. |
| 36 | 37 | This constitutes a <strong>breaking change</strong> for those with |
| 37 | 38 | custom skins; see [./customskin.md#version-2.24 | this section of |
| 38 | 39 | the docs] for migration advice. |
| 39 | - <li> Add a new link added to the [/login] page that allows the user to | |
| 40 | + <li> Add a new link added to the [/login] page that allows the user to | |
| 40 | 41 | [/skins|select their preferred skin]. This preference is stored in |
| 41 | 42 | the [/fdscookie|fossil display_settings cookie]. |
| 42 | 43 | <li> The /setup_skin_admin page is simplified to let administrators easily |
| 43 | 44 | select one of the built-in skins as a default, or to specify a |
| 44 | 45 | custom skin. |
| 45 | 46 |
| --- www/changes.wiki | |
| +++ www/changes.wiki | |
| @@ -10,12 +10,13 @@ | |
| 10 | * Change the name "fossil cherry-pick" command to "fossil cherrypick", |
| 11 | which is more familiar to Git users. Retain the legacy name for |
| 12 | compatibility. |
| 13 | * Add new query parameters to the [/help?cmd=/timeline|/timeline page]: |
| 14 | d2=, p2=, and dp2=. |
| 15 | * Add options to the [/help?cmd=tag|fossil tag] command that will list tag values |
| 16 | * Add ability to upload unversioned files via the [/help?cmd=/uvlist|/uvlist page]. |
| 17 | |
| 18 | |
| 19 | <h2 id='v2_24'>Changes for version 2.24 (2024-04-23)</h2> |
| 20 | |
| 21 | * Apache change work-around → As part of a security fix, the Apache webserver |
| @@ -34,11 +35,11 @@ | |
| 34 | offset command examples, etc. Adjusted colors slightly to bring |
| 35 | things into better accord with the WCAG accessibility guidelines. |
| 36 | This constitutes a <strong>breaking change</strong> for those with |
| 37 | custom skins; see [./customskin.md#version-2.24 | this section of |
| 38 | the docs] for migration advice. |
| 39 | <li> Add a new link added to the [/login] page that allows the user to |
| 40 | [/skins|select their preferred skin]. This preference is stored in |
| 41 | the [/fdscookie|fossil display_settings cookie]. |
| 42 | <li> The /setup_skin_admin page is simplified to let administrators easily |
| 43 | select one of the built-in skins as a default, or to specify a |
| 44 | custom skin. |
| 45 |
| --- www/changes.wiki | |
| +++ www/changes.wiki | |
| @@ -10,12 +10,13 @@ | |
| 10 | * Change the name "fossil cherry-pick" command to "fossil cherrypick", |
| 11 | which is more familiar to Git users. Retain the legacy name for |
| 12 | compatibility. |
| 13 | * Add new query parameters to the [/help?cmd=/timeline|/timeline page]: |
| 14 | d2=, p2=, and dp2=. |
| 15 | * Add options to the [/help?cmd=tag|fossil tag] command that will list tag values. |
| 16 | * Add ability to upload unversioned files via the [/help?cmd=/uvlist|/uvlist page]. |
| 17 | * Add history search to the [/help?cmd=/chat|/chat page]. |
| 18 | |
| 19 | |
| 20 | <h2 id='v2_24'>Changes for version 2.24 (2024-04-23)</h2> |
| 21 | |
| 22 | * Apache change work-around → As part of a security fix, the Apache webserver |
| @@ -34,11 +35,11 @@ | |
| 35 | offset command examples, etc. Adjusted colors slightly to bring |
| 36 | things into better accord with the WCAG accessibility guidelines. |
| 37 | This constitutes a <strong>breaking change</strong> for those with |
| 38 | custom skins; see [./customskin.md#version-2.24 | this section of |
| 39 | the docs] for migration advice. |
| 40 | <li> Add a new link added to the [/login] page that allows the user to |
| 41 | [/skins|select their preferred skin]. This preference is stored in |
| 42 | the [/fdscookie|fossil display_settings cookie]. |
| 43 | <li> The /setup_skin_admin page is simplified to let administrators easily |
| 44 | select one of the built-in skins as a default, or to specify a |
| 45 | custom skin. |
| 46 |