Fossil SCM

Add /chat history search.

stephan 2024-07-03 12:38 trunk merge
Commit fc853823b2218a7634304a2487e16f05032388ef141bfc7ffdd442070c300309
+1 -1
--- src/alerts.c
+++ src/alerts.c
@@ -327,11 +327,11 @@
327327
login_insert_csrf_secret();
328328
329329
entry_attribute("Canonical Server URL", 40, "email-url",
330330
"eurl", "", 0);
331331
@ <p><b>Required.</b>
332
- @ This is URL used as the basename for hyperlinks included in
332
+ @ This URL is used as the basename for hyperlinks included in
333333
@ email alert text. Omit the trailing "/".
334334
@ Suggested value: "%h(g.zBaseURL)"
335335
@ (Property: "email-url")</p>
336336
@ <hr>
337337
338338
--- src/alerts.c
+++ src/alerts.c
@@ -327,11 +327,11 @@
327 login_insert_csrf_secret();
328
329 entry_attribute("Canonical Server URL", 40, "email-url",
330 "eurl", "", 0);
331 @ <p><b>Required.</b>
332 @ This is URL used as the basename for hyperlinks included in
333 @ email alert text. Omit the trailing "/".
334 @ Suggested value: "%h(g.zBaseURL)"
335 @ (Property: "email-url")</p>
336 @ <hr>
337
338
--- src/alerts.c
+++ src/alerts.c
@@ -327,11 +327,11 @@
327 login_insert_csrf_secret();
328
329 entry_attribute("Canonical Server URL", 40, "email-url",
330 "eurl", "", 0);
331 @ <p><b>Required.</b>
332 @ This URL is used as the basename for hyperlinks included in
333 @ email alert text. Omit the trailing "/".
334 @ Suggested value: "%h(g.zBaseURL)"
335 @ (Property: "email-url")</p>
336 @ <hr>
337
338
+204 -84
--- src/chat.c
+++ src/chat.c
@@ -147,36 +147,19 @@
147147
**
148148
** Start up a browser-based chat session.
149149
**
150150
** This is the main page that humans use to access the chatroom. Simply
151151
** 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.
153153
**
154154
** Other /chat-OP pages are used by XHR requests from this page to
155155
** send new chat message, delete older messages, or poll for changes.
156156
*/
157157
void chat_webpage(void){
158158
char *zAlert;
159159
char *zProjectName;
160160
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>";
178161
179162
login_check_credentials();
180163
if( !g.perm.Chat ){
181164
login_needed(g.anon.Chat);
182165
return;
@@ -203,12 +186,14 @@
203186
@ data-placeholder="%h(zInputPlaceholder0)" \
204187
@ class="chat-input-field hidden"></div>
205188
@ <div id='chat-buttons-wrapper'>
206189
@ <span class='cbutton' id="chat-button-preview" \
207190
@ title="Preview message (Shift-Enter)">&#128065;</span>
191
+ @ <span class='cbutton' id="chat-button-search" \
192
+ @ title="Search chat history">&#x1f50d;</span>
208193
@ <span class='cbutton' id="chat-button-attach" \
209
- @ title="Attach file to message">%s(zPaperclip)</span>
194
+ @ title="Attach file to message">&#x1f4ce;</span>
210195
@ <span class='cbutton' id="chat-button-settings" \
211196
@ title="Configure chat">&#9881;</span>
212197
@ <span class='cbutton' id="chat-button-submit" \
213198
@ title="Send message (Ctrl-Enter)">&#128228;</span>
214199
@ </div>
@@ -233,17 +218,25 @@
233218
@ </div>
234219
@ <div id='chat-user-list'></div>
235220
@ </div>
236221
@ <div id='chat-preview' class='hidden chat-view'>
237222
@ <header>Preview: (<a href='%R/md_rules' target='_blank'>markdown reference</a>)</header>
238
- @ <div id='chat-preview-content' class='message-widget-content'></div>
239
- @ <div id='chat-preview-buttons'><button id='chat-preview-close'>Close Preview</button></div>
223
+ @ <div id='chat-preview-content'></div>
224
+ @ <div class='button-bar'><button class='action-close'>Close Preview</button></div>
240225
@ </div>
241226
@ <div id='chat-config' class='hidden chat-view'>
242227
@ <div id='chat-config-options'></div>
243228
/* ^^^populated client-side */
244
- @ <button>Close Settings</button>
229
+ @ <div class='button-bar'><button class='action-close'>Close Settings</button></div>
230
+ @ </div>
231
+ @ <div id='chat-search' class='hidden chat-view'>
232
+ @ <div id='chat-search-content'></div>
233
+ /* ^^^populated client-side */
234
+ @ <div class='button-bar'>
235
+ @ <button class='action-clear'>Clear results</button>
236
+ @ <button class='action-close'>Close Search</button>
237
+ @ </div>
245238
@ </div>
246239
@ <div id='chat-messages-wrapper' class='chat-view'>
247240
/* New chat messages get inserted immediately after this element */
248241
@ <span id='message-inject-point'></span>
249242
@ </div>
@@ -271,11 +264,12 @@
271264
@ </script>
272265
builtin_request_js("fossil.page.chat.js");
273266
style_finish_page();
274267
}
275268
276
-/* Definition of repository tables used by chat
269
+/*
270
+** Definition of repository tables used by chat
277271
*/
278272
static const char zChatSchema1[] =
279273
@ CREATE TABLE repository.chat(
280274
@ msgid INTEGER PRIMARY KEY AUTOINCREMENT,
281275
@ mtime JULIANDAY, -- Time for this entry - Julianday Zulu
@@ -289,12 +283,42 @@
289283
@ );
290284
;
291285
292286
293287
/*
294
-** Make sure the repository data tables used by chat exist. Create them
295
-** if they do not.
288
+** Create or rebuild the /chat search index. Requires that the
289
+** repository.chat table exists. If bForce is true, it will drop the
290
+** chatfts1 table and recreate/reindex it. If bForce is 0, it will
291
+** only index the chat content if the chatfts1 table does not already
292
+** exist.
293
+*/
294
+void chat_rebuild_index(int bForce){
295
+ if( bForce!=0 ){
296
+ db_multi_exec("DROP TABLE IF EXISTS chatfts1");
297
+ }
298
+ if( bForce!=0 || !db_table_exists("repository", "chatfts1") ){
299
+ const int tokType = search_tokenizer_type(0);
300
+ const char *zTokenizer = search_tokenize_arg_for_type(
301
+ tokType==FTS5TOK_NONE ? FTS5TOK_PORTER : tokType
302
+ /* Special case: if fts search is disabled for the main repo
303
+ ** content, use a default tokenizer here. */
304
+ );
305
+ assert( zTokenizer && zTokenizer[0] );
306
+ db_multi_exec(
307
+ "CREATE VIRTUAL TABLE repository.chatfts1 USING fts5("
308
+ " xmsg, content=chat, content_rowid=msgid%s"
309
+ ");"
310
+ "INSERT INTO repository.chatfts1(chatfts1) VALUES('rebuild');",
311
+ zTokenizer/*safe-for-%s*/
312
+ );
313
+ }
314
+}
315
+
316
+/*
317
+** Make sure the repository data tables used by chat exist. Create
318
+** them if they do not. Set up TEMP triggers (if needed) to update the
319
+** chatfts1 table as the chat table is updated.
296320
*/
297321
static void chat_create_tables(void){
298322
if( !db_table_exists("repository","chat") ){
299323
db_multi_exec(zChatSchema1/*works-like:""*/);
300324
}else if( !db_table_has_column("repository","chat","lmtime") ){
@@ -301,10 +325,20 @@
301325
if( !db_table_has_column("repository","chat","mdel") ){
302326
db_multi_exec("ALTER TABLE chat ADD COLUMN mdel INT");
303327
}
304328
db_multi_exec("ALTER TABLE chat ADD COLUMN lmtime TEXT");
305329
}
330
+ chat_rebuild_index(0);
331
+ db_multi_exec(
332
+ "CREATE TEMP TRIGGER IF NOT EXISTS chat_ai AFTER INSERT ON chat BEGIN "
333
+ " INSERT INTO chatfts1(rowid, xmsg) VALUES(new.msgid, new.xmsg);"
334
+ "END;"
335
+ "CREATE TEMP TRIGGER IF NOT EXISTS chat_ad AFTER DELETE ON chat BEGIN "
336
+ " INSERT INTO chatfts1(chatfts1, rowid, xmsg) "
337
+ " VALUES('delete', old.msgid, old.xmsg);"
338
+ "END;"
339
+ );
306340
}
307341
308342
/*
309343
** Delete old content from the chat table.
310344
*/
@@ -452,28 +486,101 @@
452486
}
453487
454488
/*
455489
** COMMAND: test-chat-formatter
456490
**
457
-** Usage: %fossil test-chat-formatter STRING ...
491
+** Usage: %fossil test-chat-formatter ?OPTIONS? STRING ...
458492
**
459493
** Transform each argument string into HTML that will display the
460494
** chat message. This is used to test the formatter and to verify
461495
** that a malicious message text will not cause HTML or JS injection
462496
** into the chat display in a browser.
497
+**
498
+** Options:
499
+**
500
+** -w|--wiki Assume fossil wiki format instead of markdown
463501
*/
464502
void chat_test_formatter_cmd(void){
465503
int i;
466504
char *zOut;
505
+ int const isWiki = find_option("w","wiki",0)!=0;
467506
db_find_and_open_repository(0,0);
468507
g.perm.Hyperlink = 1;
469
- for(i=0; i<g.argc; i++){
470
- zOut = chat_format_to_html(g.argv[i], 0);
471
- fossil_print("[%d]: %s\n", i, zOut);
508
+ for(i=2; i<g.argc; i++){
509
+ zOut = chat_format_to_html(g.argv[i], isWiki);
510
+ fossil_print("[%d]: %s\n", i-1, zOut);
472511
fossil_free(zOut);
473512
}
474513
}
514
+
515
+/*
516
+**
517
+*/
518
+static int chat_poll_rowstojson(
519
+ Stmt *p, /* Statement to read rows from */
520
+ const char *zChatUser, /* Current user */
521
+ int bRaw, /* True to return raw format xmsg */
522
+ Blob *pJson /* Append json array entries here */
523
+){
524
+ int cnt = 0;
525
+ while( db_step(p)==SQLITE_ROW ){
526
+ int isWiki = 0; /* True if chat message is x-fossil-wiki */
527
+ int id = db_column_int(p, 0);
528
+ const char *zDate = db_column_text(p, 1);
529
+ const char *zFrom = db_column_text(p, 2);
530
+ const char *zRawMsg = db_column_text(p, 3);
531
+ int nByte = db_column_int(p, 4);
532
+ const char *zFName = db_column_text(p, 5);
533
+ const char *zFMime = db_column_text(p, 6);
534
+ int iToDel = db_column_int(p, 7);
535
+ const char *zLMtime = db_column_text(p, 8);
536
+ char *zMsg;
537
+ if(cnt++){
538
+ blob_append(pJson, ",\n", 2);
539
+ }
540
+ blob_appendf(pJson, "{\"msgid\":%d,", id);
541
+ blob_appendf(pJson, "\"mtime\":\"%.10sT%sZ\",", zDate, zDate+11);
542
+ if( zLMtime && zLMtime[0] ){
543
+ blob_appendf(pJson, "\"lmtime\":%!j,", zLMtime);
544
+ }
545
+ blob_append(pJson, "\"xfrom\":", -1);
546
+ if(zFrom){
547
+ blob_appendf(pJson, "%!j,", zFrom);
548
+ isWiki = fossil_strcmp(zFrom,zChatUser)==0;
549
+ }else{
550
+ /* see https://fossil-scm.org/forum/forumpost/e0be0eeb4c */
551
+ blob_appendf(pJson, "null,");
552
+ isWiki = 0;
553
+ }
554
+ blob_appendf(pJson, "\"uclr\":%!j,",
555
+ isWiki ? "transparent" : user_color(zFrom ? zFrom : "nobody"));
556
+
557
+ if(bRaw){
558
+ blob_appendf(pJson, "\"xmsg\":%!j,", zRawMsg);
559
+ }else{
560
+ zMsg = chat_format_to_html(zRawMsg ? zRawMsg : "", isWiki);
561
+ blob_appendf(pJson, "\"xmsg\":%!j,", zMsg);
562
+ fossil_free(zMsg);
563
+ }
564
+
565
+ if( nByte==0 ){
566
+ blob_appendf(pJson, "\"fsize\":0");
567
+ }else{
568
+ blob_appendf(pJson, "\"fsize\":%d,\"fname\":%!j,\"fmime\":%!j",
569
+ nByte, zFName, zFMime);
570
+ }
571
+
572
+ if( iToDel ){
573
+ blob_appendf(pJson, ",\"mdel\":%d}", iToDel);
574
+ }else{
575
+ blob_append(pJson, "}", 1);
576
+ }
577
+ }
578
+ db_reset(p);
579
+
580
+ return cnt;
581
+}
475582
476583
/*
477584
** WEBPAGE: chat-poll hidden loadavg-exempt
478585
**
479586
** The chat page generated by /chat using an XHR to this page to
@@ -569,11 +676,10 @@
569676
Blob json; /* The json to be constructed and returned */
570677
sqlite3_int64 dataVersion; /* Data version. Used for polling. */
571678
const int iDelay = 1000; /* Delay until next poll (milliseconds) */
572679
int nDelay; /* Maximum delay.*/
573680
const char *zChatUser; /* chat-timeline-user */
574
- int isWiki = 0; /* True if chat message is x-fossil-wiki */
575681
int msgid = atoi(PD("name","0"));
576682
const int msgBefore = atoi(PD("before","0"));
577683
int nLimit = msgBefore>0 ? atoi(PD("n","0")) : 0;
578684
const int bRaw = P("raw")!=0;
579685
@@ -623,64 +729,11 @@
623729
}
624730
db_prepare(&q1, "%s", blob_sql_text(&sql));
625731
blob_reset(&sql);
626732
blob_init(&json, "{\"msgs\":[\n", -1);
627733
while( nDelay>0 ){
628
- int cnt = 0;
629
- while( db_step(&q1)==SQLITE_ROW ){
630
- int id = db_column_int(&q1, 0);
631
- const char *zDate = db_column_text(&q1, 1);
632
- const char *zFrom = db_column_text(&q1, 2);
633
- const char *zRawMsg = db_column_text(&q1, 3);
634
- int nByte = db_column_int(&q1, 4);
635
- const char *zFName = db_column_text(&q1, 5);
636
- const char *zFMime = db_column_text(&q1, 6);
637
- int iToDel = db_column_int(&q1, 7);
638
- const char *zLMtime = db_column_text(&q1, 8);
639
- char *zMsg;
640
- if(cnt++){
641
- blob_append(&json, ",\n", 2);
642
- }
643
- blob_appendf(&json, "{\"msgid\":%d,", id);
644
- blob_appendf(&json, "\"mtime\":\"%.10sT%sZ\",", zDate, zDate+11);
645
- if( zLMtime && zLMtime[0] ){
646
- blob_appendf(&json, "\"lmtime\":%!j,", zLMtime);
647
- }
648
- blob_append(&json, "\"xfrom\":", -1);
649
- if(zFrom){
650
- blob_appendf(&json, "%!j,", zFrom);
651
- isWiki = fossil_strcmp(zFrom,zChatUser)==0;
652
- }else{
653
- /* see https://fossil-scm.org/forum/forumpost/e0be0eeb4c */
654
- blob_appendf(&json, "null,");
655
- isWiki = 0;
656
- }
657
- blob_appendf(&json, "\"uclr\":%!j,",
658
- isWiki ? "transparent" : user_color(zFrom ? zFrom : "nobody"));
659
-
660
- if(bRaw){
661
- blob_appendf(&json, "\"xmsg\":%!j,", zRawMsg);
662
- }else{
663
- zMsg = chat_format_to_html(zRawMsg ? zRawMsg : "", isWiki);
664
- blob_appendf(&json, "\"xmsg\":%!j,", zMsg);
665
- fossil_free(zMsg);
666
- }
667
-
668
- if( nByte==0 ){
669
- blob_appendf(&json, "\"fsize\":0");
670
- }else{
671
- blob_appendf(&json, "\"fsize\":%d,\"fname\":%!j,\"fmime\":%!j",
672
- nByte, zFName, zFMime);
673
- }
674
-
675
- if( iToDel ){
676
- blob_appendf(&json, ",\"mdel\":%d}", iToDel);
677
- }else{
678
- blob_append(&json, "}", 1);
679
- }
680
- }
681
- db_reset(&q1);
734
+ int cnt = chat_poll_rowstojson(&q1, zChatUser, bRaw, &json);
682735
if( cnt || msgBefore>0 ){
683736
break;
684737
}
685738
sqlite3_sleep(iDelay); nDelay--;
686739
while( nDelay>0 ){
@@ -696,10 +749,77 @@
696749
blob_append(&json, "\n]}", 3);
697750
cgi_set_content(&json);
698751
return;
699752
}
700753
754
+
755
+/*
756
+** WEBPAGE: chat-query hidden loadavg-exempt
757
+*/
758
+void chat_query_webpage(void){
759
+ Blob json; /* The json to be constructed and returned */
760
+ Blob sql = empty_blob;
761
+ Stmt q1;
762
+ int nLimit = atoi(PD("n","500"));
763
+ int iFirst = atoi(PD("i","0"));
764
+ const char *zQuery = PD("q", "");
765
+ i64 iMin = 0;
766
+ i64 iMax = 0;
767
+
768
+ login_check_credentials();
769
+ if( !g.perm.Chat ) {
770
+ chat_emit_permissions_error(1);
771
+ return;
772
+ }
773
+ chat_create_tables();
774
+ cgi_set_content_type("application/json");
775
+
776
+ if( zQuery[0] ){
777
+ iMax = db_int64(0, "SELECT max(msgid) FROM chat");
778
+ iMin = db_int64(0, "SELECT min(msgid) FROM chat");
779
+ if( '#'==zQuery[0] ){
780
+ /* Assume we're looking for an exact msgid match. */
781
+ ++zQuery;
782
+ blob_append_sql(&sql,
783
+ "SELECT msgid, datetime(mtime), xfrom, "
784
+ " xmsg, octet_length(file), fname, fmime, mdel, lmtime "
785
+ " FROM chat WHERE msgid=+%Q",
786
+ zQuery
787
+ );
788
+ }else{
789
+ char * zPat = search_simplify_pattern(zQuery);
790
+ blob_append_sql(&sql,
791
+ "SELECT * FROM ("
792
+ "SELECT c.msgid, datetime(c.mtime), c.xfrom, "
793
+ " highlight(chatfts1, 0, '<span class=\"match\">', '</span>'), "
794
+ " octet_length(c.file), c.fname, c.fmime, c.mdel, c.lmtime "
795
+ " FROM chatfts1(%Q) f, chat c "
796
+ " WHERE f.rowid=c.msgid"
797
+ " ORDER BY f.rowid DESC LIMIT %d"
798
+ ") ORDER BY 1 ASC", zPat, nLimit
799
+ );
800
+ fossil_free(zPat);
801
+ }
802
+ }else{
803
+ blob_append_sql(&sql,
804
+ "SELECT msgid, datetime(mtime), xfrom, "
805
+ " xmsg, octet_length(file), fname, fmime, mdel, lmtime"
806
+ " FROM chat WHERE msgid>=%d LIMIT %d",
807
+ iFirst, nLimit
808
+ );
809
+ }
810
+
811
+ db_prepare(&q1, "%s", blob_sql_text(&sql));
812
+ blob_reset(&sql);
813
+ blob_init(&json, "{\"msgs\":[\n", -1);
814
+ chat_poll_rowstojson(&q1, "", 0, &json);
815
+ db_finalize(&q1);
816
+ blob_appendf(&json, "\n], \"first\":%lld, \"last\":%lld}", iMin, iMax);
817
+ cgi_set_content(&json);
818
+ return;
819
+}
820
+
701821
/*
702822
** WEBPAGE: chat-fetch-one hidden loadavg-exempt
703823
**
704824
** /chat-fetch-one/N
705825
**
706826
--- 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)">&#128065;</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">&#9881;</span>
212 @ <span class='cbutton' id="chat-button-submit" \
213 @ title="Send message (Ctrl-Enter)">&#128228;</span>
214 @ </div>
@@ -233,17 +218,25 @@
233 @ </div>
234 @ <div id='chat-user-list'></div>
235 @ </div>
236 @ <div id='chat-preview' class='hidden chat-view'>
237 @ <header>Preview: (<a href='%R/md_rules' target='_blank'>markdown reference</a>)</header>
238 @ <div id='chat-preview-content' class='message-widget-content'></div>
239 @ <div id='chat-preview-buttons'><button id='chat-preview-close'>Close Preview</button></div>
240 @ </div>
241 @ <div id='chat-config' class='hidden chat-view'>
242 @ <div id='chat-config-options'></div>
243 /* ^^^populated client-side */
244 @ <button>Close Settings</button>
 
 
 
 
 
 
 
 
245 @ </div>
246 @ <div id='chat-messages-wrapper' class='chat-view'>
247 /* New chat messages get inserted immediately after this element */
248 @ <span id='message-inject-point'></span>
249 @ </div>
@@ -271,11 +264,12 @@
271 @ </script>
272 builtin_request_js("fossil.page.chat.js");
273 style_finish_page();
274 }
275
276 /* Definition of repository tables used by chat
 
277 */
278 static const char zChatSchema1[] =
279 @ CREATE TABLE repository.chat(
280 @ msgid INTEGER PRIMARY KEY AUTOINCREMENT,
281 @ mtime JULIANDAY, -- Time for this entry - Julianday Zulu
@@ -289,12 +283,42 @@
289 @ );
290 ;
291
292
293 /*
294 ** Make sure the repository data tables used by chat exist. Create them
295 ** if they do not.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
296 */
297 static void chat_create_tables(void){
298 if( !db_table_exists("repository","chat") ){
299 db_multi_exec(zChatSchema1/*works-like:""*/);
300 }else if( !db_table_has_column("repository","chat","lmtime") ){
@@ -301,10 +325,20 @@
301 if( !db_table_has_column("repository","chat","mdel") ){
302 db_multi_exec("ALTER TABLE chat ADD COLUMN mdel INT");
303 }
304 db_multi_exec("ALTER TABLE chat ADD COLUMN lmtime TEXT");
305 }
 
 
 
 
 
 
 
 
 
 
306 }
307
308 /*
309 ** Delete old content from the chat table.
310 */
@@ -452,28 +486,101 @@
452 }
453
454 /*
455 ** COMMAND: test-chat-formatter
456 **
457 ** Usage: %fossil test-chat-formatter STRING ...
458 **
459 ** Transform each argument string into HTML that will display the
460 ** chat message. This is used to test the formatter and to verify
461 ** that a malicious message text will not cause HTML or JS injection
462 ** into the chat display in a browser.
 
 
 
 
463 */
464 void chat_test_formatter_cmd(void){
465 int i;
466 char *zOut;
 
467 db_find_and_open_repository(0,0);
468 g.perm.Hyperlink = 1;
469 for(i=0; i<g.argc; i++){
470 zOut = chat_format_to_html(g.argv[i], 0);
471 fossil_print("[%d]: %s\n", i, zOut);
472 fossil_free(zOut);
473 }
474 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
475
476 /*
477 ** WEBPAGE: chat-poll hidden loadavg-exempt
478 **
479 ** The chat page generated by /chat using an XHR to this page to
@@ -569,11 +676,10 @@
569 Blob json; /* The json to be constructed and returned */
570 sqlite3_int64 dataVersion; /* Data version. Used for polling. */
571 const int iDelay = 1000; /* Delay until next poll (milliseconds) */
572 int nDelay; /* Maximum delay.*/
573 const char *zChatUser; /* chat-timeline-user */
574 int isWiki = 0; /* True if chat message is x-fossil-wiki */
575 int msgid = atoi(PD("name","0"));
576 const int msgBefore = atoi(PD("before","0"));
577 int nLimit = msgBefore>0 ? atoi(PD("n","0")) : 0;
578 const int bRaw = P("raw")!=0;
579
@@ -623,64 +729,11 @@
623 }
624 db_prepare(&q1, "%s", blob_sql_text(&sql));
625 blob_reset(&sql);
626 blob_init(&json, "{\"msgs\":[\n", -1);
627 while( nDelay>0 ){
628 int cnt = 0;
629 while( db_step(&q1)==SQLITE_ROW ){
630 int id = db_column_int(&q1, 0);
631 const char *zDate = db_column_text(&q1, 1);
632 const char *zFrom = db_column_text(&q1, 2);
633 const char *zRawMsg = db_column_text(&q1, 3);
634 int nByte = db_column_int(&q1, 4);
635 const char *zFName = db_column_text(&q1, 5);
636 const char *zFMime = db_column_text(&q1, 6);
637 int iToDel = db_column_int(&q1, 7);
638 const char *zLMtime = db_column_text(&q1, 8);
639 char *zMsg;
640 if(cnt++){
641 blob_append(&json, ",\n", 2);
642 }
643 blob_appendf(&json, "{\"msgid\":%d,", id);
644 blob_appendf(&json, "\"mtime\":\"%.10sT%sZ\",", zDate, zDate+11);
645 if( zLMtime && zLMtime[0] ){
646 blob_appendf(&json, "\"lmtime\":%!j,", zLMtime);
647 }
648 blob_append(&json, "\"xfrom\":", -1);
649 if(zFrom){
650 blob_appendf(&json, "%!j,", zFrom);
651 isWiki = fossil_strcmp(zFrom,zChatUser)==0;
652 }else{
653 /* see https://fossil-scm.org/forum/forumpost/e0be0eeb4c */
654 blob_appendf(&json, "null,");
655 isWiki = 0;
656 }
657 blob_appendf(&json, "\"uclr\":%!j,",
658 isWiki ? "transparent" : user_color(zFrom ? zFrom : "nobody"));
659
660 if(bRaw){
661 blob_appendf(&json, "\"xmsg\":%!j,", zRawMsg);
662 }else{
663 zMsg = chat_format_to_html(zRawMsg ? zRawMsg : "", isWiki);
664 blob_appendf(&json, "\"xmsg\":%!j,", zMsg);
665 fossil_free(zMsg);
666 }
667
668 if( nByte==0 ){
669 blob_appendf(&json, "\"fsize\":0");
670 }else{
671 blob_appendf(&json, "\"fsize\":%d,\"fname\":%!j,\"fmime\":%!j",
672 nByte, zFName, zFMime);
673 }
674
675 if( iToDel ){
676 blob_appendf(&json, ",\"mdel\":%d}", iToDel);
677 }else{
678 blob_append(&json, "}", 1);
679 }
680 }
681 db_reset(&q1);
682 if( cnt || msgBefore>0 ){
683 break;
684 }
685 sqlite3_sleep(iDelay); nDelay--;
686 while( nDelay>0 ){
@@ -696,10 +749,77 @@
696 blob_append(&json, "\n]}", 3);
697 cgi_set_content(&json);
698 return;
699 }
700
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
701 /*
702 ** WEBPAGE: chat-fetch-one hidden loadavg-exempt
703 **
704 ** /chat-fetch-one/N
705 **
706
--- 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)">&#128065;</span>
191 @ <span class='cbutton' id="chat-button-search" \
192 @ title="Search chat history">&#x1f50d;</span>
193 @ <span class='cbutton' id="chat-button-attach" \
194 @ title="Attach file to message">&#x1f4ce;</span>
195 @ <span class='cbutton' id="chat-button-settings" \
196 @ title="Configure chat">&#9881;</span>
197 @ <span class='cbutton' id="chat-button-submit" \
198 @ title="Send message (Ctrl-Enter)">&#128228;</span>
199 @ </div>
@@ -233,17 +218,25 @@
218 @ </div>
219 @ <div id='chat-user-list'></div>
220 @ </div>
221 @ <div id='chat-preview' class='hidden chat-view'>
222 @ <header>Preview: (<a href='%R/md_rules' target='_blank'>markdown reference</a>)</header>
223 @ <div id='chat-preview-content'></div>
224 @ <div class='button-bar'><button class='action-close'>Close Preview</button></div>
225 @ </div>
226 @ <div id='chat-config' class='hidden chat-view'>
227 @ <div id='chat-config-options'></div>
228 /* ^^^populated client-side */
229 @ <div class='button-bar'><button class='action-close'>Close Settings</button></div>
230 @ </div>
231 @ <div id='chat-search' class='hidden chat-view'>
232 @ <div id='chat-search-content'></div>
233 /* ^^^populated client-side */
234 @ <div class='button-bar'>
235 @ <button class='action-clear'>Clear results</button>
236 @ <button class='action-close'>Close Search</button>
237 @ </div>
238 @ </div>
239 @ <div id='chat-messages-wrapper' class='chat-view'>
240 /* New chat messages get inserted immediately after this element */
241 @ <span id='message-inject-point'></span>
242 @ </div>
@@ -271,11 +264,12 @@
264 @ </script>
265 builtin_request_js("fossil.page.chat.js");
266 style_finish_page();
267 }
268
269 /*
270 ** Definition of repository tables used by chat
271 */
272 static const char zChatSchema1[] =
273 @ CREATE TABLE repository.chat(
274 @ msgid INTEGER PRIMARY KEY AUTOINCREMENT,
275 @ mtime JULIANDAY, -- Time for this entry - Julianday Zulu
@@ -289,12 +283,42 @@
283 @ );
284 ;
285
286
287 /*
288 ** Create or rebuild the /chat search index. Requires that the
289 ** repository.chat table exists. If bForce is true, it will drop the
290 ** chatfts1 table and recreate/reindex it. If bForce is 0, it will
291 ** only index the chat content if the chatfts1 table does not already
292 ** exist.
293 */
294 void chat_rebuild_index(int bForce){
295 if( bForce!=0 ){
296 db_multi_exec("DROP TABLE IF EXISTS chatfts1");
297 }
298 if( bForce!=0 || !db_table_exists("repository", "chatfts1") ){
299 const int tokType = search_tokenizer_type(0);
300 const char *zTokenizer = search_tokenize_arg_for_type(
301 tokType==FTS5TOK_NONE ? FTS5TOK_PORTER : tokType
302 /* Special case: if fts search is disabled for the main repo
303 ** content, use a default tokenizer here. */
304 );
305 assert( zTokenizer && zTokenizer[0] );
306 db_multi_exec(
307 "CREATE VIRTUAL TABLE repository.chatfts1 USING fts5("
308 " xmsg, content=chat, content_rowid=msgid%s"
309 ");"
310 "INSERT INTO repository.chatfts1(chatfts1) VALUES('rebuild');",
311 zTokenizer/*safe-for-%s*/
312 );
313 }
314 }
315
316 /*
317 ** Make sure the repository data tables used by chat exist. Create
318 ** them if they do not. Set up TEMP triggers (if needed) to update the
319 ** chatfts1 table as the chat table is updated.
320 */
321 static void chat_create_tables(void){
322 if( !db_table_exists("repository","chat") ){
323 db_multi_exec(zChatSchema1/*works-like:""*/);
324 }else if( !db_table_has_column("repository","chat","lmtime") ){
@@ -301,10 +325,20 @@
325 if( !db_table_has_column("repository","chat","mdel") ){
326 db_multi_exec("ALTER TABLE chat ADD COLUMN mdel INT");
327 }
328 db_multi_exec("ALTER TABLE chat ADD COLUMN lmtime TEXT");
329 }
330 chat_rebuild_index(0);
331 db_multi_exec(
332 "CREATE TEMP TRIGGER IF NOT EXISTS chat_ai AFTER INSERT ON chat BEGIN "
333 " INSERT INTO chatfts1(rowid, xmsg) VALUES(new.msgid, new.xmsg);"
334 "END;"
335 "CREATE TEMP TRIGGER IF NOT EXISTS chat_ad AFTER DELETE ON chat BEGIN "
336 " INSERT INTO chatfts1(chatfts1, rowid, xmsg) "
337 " VALUES('delete', old.msgid, old.xmsg);"
338 "END;"
339 );
340 }
341
342 /*
343 ** Delete old content from the chat table.
344 */
@@ -452,28 +486,101 @@
486 }
487
488 /*
489 ** COMMAND: test-chat-formatter
490 **
491 ** Usage: %fossil test-chat-formatter ?OPTIONS? STRING ...
492 **
493 ** Transform each argument string into HTML that will display the
494 ** chat message. This is used to test the formatter and to verify
495 ** that a malicious message text will not cause HTML or JS injection
496 ** into the chat display in a browser.
497 **
498 ** Options:
499 **
500 ** -w|--wiki Assume fossil wiki format instead of markdown
501 */
502 void chat_test_formatter_cmd(void){
503 int i;
504 char *zOut;
505 int const isWiki = find_option("w","wiki",0)!=0;
506 db_find_and_open_repository(0,0);
507 g.perm.Hyperlink = 1;
508 for(i=2; i<g.argc; i++){
509 zOut = chat_format_to_html(g.argv[i], isWiki);
510 fossil_print("[%d]: %s\n", i-1, zOut);
511 fossil_free(zOut);
512 }
513 }
514
515 /*
516 **
517 */
518 static int chat_poll_rowstojson(
519 Stmt *p, /* Statement to read rows from */
520 const char *zChatUser, /* Current user */
521 int bRaw, /* True to return raw format xmsg */
522 Blob *pJson /* Append json array entries here */
523 ){
524 int cnt = 0;
525 while( db_step(p)==SQLITE_ROW ){
526 int isWiki = 0; /* True if chat message is x-fossil-wiki */
527 int id = db_column_int(p, 0);
528 const char *zDate = db_column_text(p, 1);
529 const char *zFrom = db_column_text(p, 2);
530 const char *zRawMsg = db_column_text(p, 3);
531 int nByte = db_column_int(p, 4);
532 const char *zFName = db_column_text(p, 5);
533 const char *zFMime = db_column_text(p, 6);
534 int iToDel = db_column_int(p, 7);
535 const char *zLMtime = db_column_text(p, 8);
536 char *zMsg;
537 if(cnt++){
538 blob_append(pJson, ",\n", 2);
539 }
540 blob_appendf(pJson, "{\"msgid\":%d,", id);
541 blob_appendf(pJson, "\"mtime\":\"%.10sT%sZ\",", zDate, zDate+11);
542 if( zLMtime && zLMtime[0] ){
543 blob_appendf(pJson, "\"lmtime\":%!j,", zLMtime);
544 }
545 blob_append(pJson, "\"xfrom\":", -1);
546 if(zFrom){
547 blob_appendf(pJson, "%!j,", zFrom);
548 isWiki = fossil_strcmp(zFrom,zChatUser)==0;
549 }else{
550 /* see https://fossil-scm.org/forum/forumpost/e0be0eeb4c */
551 blob_appendf(pJson, "null,");
552 isWiki = 0;
553 }
554 blob_appendf(pJson, "\"uclr\":%!j,",
555 isWiki ? "transparent" : user_color(zFrom ? zFrom : "nobody"));
556
557 if(bRaw){
558 blob_appendf(pJson, "\"xmsg\":%!j,", zRawMsg);
559 }else{
560 zMsg = chat_format_to_html(zRawMsg ? zRawMsg : "", isWiki);
561 blob_appendf(pJson, "\"xmsg\":%!j,", zMsg);
562 fossil_free(zMsg);
563 }
564
565 if( nByte==0 ){
566 blob_appendf(pJson, "\"fsize\":0");
567 }else{
568 blob_appendf(pJson, "\"fsize\":%d,\"fname\":%!j,\"fmime\":%!j",
569 nByte, zFName, zFMime);
570 }
571
572 if( iToDel ){
573 blob_appendf(pJson, ",\"mdel\":%d}", iToDel);
574 }else{
575 blob_append(pJson, "}", 1);
576 }
577 }
578 db_reset(p);
579
580 return cnt;
581 }
582
583 /*
584 ** WEBPAGE: chat-poll hidden loadavg-exempt
585 **
586 ** The chat page generated by /chat using an XHR to this page to
@@ -569,11 +676,10 @@
676 Blob json; /* The json to be constructed and returned */
677 sqlite3_int64 dataVersion; /* Data version. Used for polling. */
678 const int iDelay = 1000; /* Delay until next poll (milliseconds) */
679 int nDelay; /* Maximum delay.*/
680 const char *zChatUser; /* chat-timeline-user */
 
681 int msgid = atoi(PD("name","0"));
682 const int msgBefore = atoi(PD("before","0"));
683 int nLimit = msgBefore>0 ? atoi(PD("n","0")) : 0;
684 const int bRaw = P("raw")!=0;
685
@@ -623,64 +729,11 @@
729 }
730 db_prepare(&q1, "%s", blob_sql_text(&sql));
731 blob_reset(&sql);
732 blob_init(&json, "{\"msgs\":[\n", -1);
733 while( nDelay>0 ){
734 int cnt = chat_poll_rowstojson(&q1, zChatUser, bRaw, &json);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
735 if( cnt || msgBefore>0 ){
736 break;
737 }
738 sqlite3_sleep(iDelay); nDelay--;
739 while( nDelay>0 ){
@@ -696,10 +749,77 @@
749 blob_append(&json, "\n]}", 3);
750 cgi_set_content(&json);
751 return;
752 }
753
754
755 /*
756 ** WEBPAGE: chat-query hidden loadavg-exempt
757 */
758 void chat_query_webpage(void){
759 Blob json; /* The json to be constructed and returned */
760 Blob sql = empty_blob;
761 Stmt q1;
762 int nLimit = atoi(PD("n","500"));
763 int iFirst = atoi(PD("i","0"));
764 const char *zQuery = PD("q", "");
765 i64 iMin = 0;
766 i64 iMax = 0;
767
768 login_check_credentials();
769 if( !g.perm.Chat ) {
770 chat_emit_permissions_error(1);
771 return;
772 }
773 chat_create_tables();
774 cgi_set_content_type("application/json");
775
776 if( zQuery[0] ){
777 iMax = db_int64(0, "SELECT max(msgid) FROM chat");
778 iMin = db_int64(0, "SELECT min(msgid) FROM chat");
779 if( '#'==zQuery[0] ){
780 /* Assume we're looking for an exact msgid match. */
781 ++zQuery;
782 blob_append_sql(&sql,
783 "SELECT msgid, datetime(mtime), xfrom, "
784 " xmsg, octet_length(file), fname, fmime, mdel, lmtime "
785 " FROM chat WHERE msgid=+%Q",
786 zQuery
787 );
788 }else{
789 char * zPat = search_simplify_pattern(zQuery);
790 blob_append_sql(&sql,
791 "SELECT * FROM ("
792 "SELECT c.msgid, datetime(c.mtime), c.xfrom, "
793 " highlight(chatfts1, 0, '<span class=\"match\">', '</span>'), "
794 " octet_length(c.file), c.fname, c.fmime, c.mdel, c.lmtime "
795 " FROM chatfts1(%Q) f, chat c "
796 " WHERE f.rowid=c.msgid"
797 " ORDER BY f.rowid DESC LIMIT %d"
798 ") ORDER BY 1 ASC", zPat, nLimit
799 );
800 fossil_free(zPat);
801 }
802 }else{
803 blob_append_sql(&sql,
804 "SELECT msgid, datetime(mtime), xfrom, "
805 " xmsg, octet_length(file), fname, fmime, mdel, lmtime"
806 " FROM chat WHERE msgid>=%d LIMIT %d",
807 iFirst, nLimit
808 );
809 }
810
811 db_prepare(&q1, "%s", blob_sql_text(&sql));
812 blob_reset(&sql);
813 blob_init(&json, "{\"msgs\":[\n", -1);
814 chat_poll_rowstojson(&q1, "", 0, &json);
815 db_finalize(&q1);
816 blob_appendf(&json, "\n], \"first\":%lld, \"last\":%lld}", iMin, iMax);
817 cgi_set_content(&json);
818 return;
819 }
820
821 /*
822 ** WEBPAGE: chat-fetch-one hidden loadavg-exempt
823 **
824 ** /chat-fetch-one/N
825 **
826
--- src/fossil.dom.js
+++ src/fossil.dom.js
@@ -61,11 +61,11 @@
6161
if(!f.rx){
6262
f.rx = /(\s+|\s*,\s*)/;
6363
}
6464
return str ? str.split(f.rx) : [str];
6565
};
66
-
66
+
6767
dom.div = dom.createElemFactory('div');
6868
dom.p = dom.createElemFactory('p');
6969
dom.code = dom.createElemFactory('code');
7070
dom.pre = dom.createElemFactory('pre');
7171
dom.header = dom.createElemFactory('header');
@@ -549,11 +549,11 @@
549549
}
550550
if(!f.counter){
551551
old.parentNode.removeChild(old);
552552
}
553553
};
554
- dom.replaceNode.counter = 0;
554
+ dom.replaceNode.counter = 0;
555555
/**
556556
Two args == getter: (e,key), returns value
557557
558558
Three or more == setter: (e,key,val[...,keyN,valN]), returns
559559
e. If val===null or val===undefined then the attribute is
560560
--- src/fossil.dom.js
+++ src/fossil.dom.js
@@ -61,11 +61,11 @@
61 if(!f.rx){
62 f.rx = /(\s+|\s*,\s*)/;
63 }
64 return str ? str.split(f.rx) : [str];
65 };
66
67 dom.div = dom.createElemFactory('div');
68 dom.p = dom.createElemFactory('p');
69 dom.code = dom.createElemFactory('code');
70 dom.pre = dom.createElemFactory('pre');
71 dom.header = dom.createElemFactory('header');
@@ -549,11 +549,11 @@
549 }
550 if(!f.counter){
551 old.parentNode.removeChild(old);
552 }
553 };
554 dom.replaceNode.counter = 0;
555 /**
556 Two args == getter: (e,key), returns value
557
558 Three or more == setter: (e,key,val[...,keyN,valN]), returns
559 e. If val===null or val===undefined then the attribute is
560
--- src/fossil.dom.js
+++ src/fossil.dom.js
@@ -61,11 +61,11 @@
61 if(!f.rx){
62 f.rx = /(\s+|\s*,\s*)/;
63 }
64 return str ? str.split(f.rx) : [str];
65 };
66
67 dom.div = dom.createElemFactory('div');
68 dom.p = dom.createElemFactory('p');
69 dom.code = dom.createElemFactory('code');
70 dom.pre = dom.createElemFactory('pre');
71 dom.header = dom.createElemFactory('header');
@@ -549,11 +549,11 @@
549 }
550 if(!f.counter){
551 old.parentNode.removeChild(old);
552 }
553 };
554 dom.replaceNode.counter = 0;
555 /**
556 Two args == getter: (e,key), returns value
557
558 Three or more == setter: (e,key,val[...,keyN,valN]), returns
559 e. If val===null or val===undefined then the attribute is
560
--- src/fossil.fetch.js
+++ src/fossil.fetch.js
@@ -234,11 +234,11 @@
234234
urlTransform() must refer to a function which accepts a relative path
235235
to the same site as fetch() is served from and an optional set of
236236
URL parameters to pass with it (in the form a of a string
237237
("a=b&c=d...") or an object of key/value pairs (which it converts
238238
to such a string), and returns the resulting URL or URI as a string.
239
-*/
239
+*/
240240
fossil.fetch.urlTransform = (u,p)=>fossil.repoUrl(u,p);
241241
fossil.fetch.beforesend = function(){};
242242
fossil.fetch.aftersend = function(){};
243243
fossil.fetch.timeout = 15000/* Default timeout, in ms. */;
244244
})(window.fossil);
245245
--- src/fossil.fetch.js
+++ src/fossil.fetch.js
@@ -234,11 +234,11 @@
234 urlTransform() must refer to a function which accepts a relative path
235 to the same site as fetch() is served from and an optional set of
236 URL parameters to pass with it (in the form a of a string
237 ("a=b&c=d...") or an object of key/value pairs (which it converts
238 to such a string), and returns the resulting URL or URI as a string.
239 */
240 fossil.fetch.urlTransform = (u,p)=>fossil.repoUrl(u,p);
241 fossil.fetch.beforesend = function(){};
242 fossil.fetch.aftersend = function(){};
243 fossil.fetch.timeout = 15000/* Default timeout, in ms. */;
244 })(window.fossil);
245
--- src/fossil.fetch.js
+++ src/fossil.fetch.js
@@ -234,11 +234,11 @@
234 urlTransform() must refer to a function which accepts a relative path
235 to the same site as fetch() is served from and an optional set of
236 URL parameters to pass with it (in the form a of a string
237 ("a=b&c=d...") or an object of key/value pairs (which it converts
238 to such a string), and returns the resulting URL or URI as a string.
239 */
240 fossil.fetch.urlTransform = (u,p)=>fossil.repoUrl(u,p);
241 fossil.fetch.beforesend = function(){};
242 fossil.fetch.aftersend = function(){};
243 fossil.fetch.timeout = 15000/* Default timeout, in ms. */;
244 })(window.fossil);
245
--- src/fossil.page.chat.js
+++ src/fossil.page.chat.js
@@ -146,10 +146,12 @@
146146
inputFile: E1('#chat-input-file'),
147147
contentDiv: E1('div.content'),
148148
viewConfig: E1('#chat-config'),
149149
viewPreview: E1('#chat-preview'),
150150
previewContent: E1('#chat-preview-content'),
151
+ viewSearch: E1('#chat-search'),
152
+ searchContent: E1('#chat-search-content'),
151153
btnPreview: E1('#chat-button-preview'),
152154
views: document.querySelectorAll('.chat-view'),
153155
activeUserListWrapper: E1('#chat-user-list-wrapper'),
154156
activeUserList: E1('#chat-user-list')
155157
},
@@ -174,21 +176,31 @@
174176
activeUser: undefined,
175177
match: function(uname){
176178
return this.activeUser===uname || !this.activeUser;
177179
}
178180
},
179
- /** Gets (no args) or sets (1 arg) the current input text field value,
180
- taking into account single- vs multi-line input. The getter returns
181
- a string and the setter returns this object. */
182
- inputValue: function(){
181
+ /**
182
+ Gets (no args) or sets (1 arg) the current input text field
183
+ value, taking into account single- vs multi-line input. The
184
+ getter returns a trim()'d string and the setter returns this
185
+ object. As a special case, if arguments[0] is a boolean
186
+ value, it behaves like a getter and, if arguments[0]===true
187
+ it clears the input field before returning.
188
+ */
189
+ inputValue: function(/*string newValue | bool clearInputField*/){
183190
const e = this.inputElement();
184
- if(arguments.length){
191
+ if(arguments.length && 'boolean'!==typeof arguments[0]){
185192
if(e.isContentEditable) e.innerText = arguments[0];
186193
else e.value = arguments[0];
187194
return this;
188195
}
189
- return e.isContentEditable ? e.innerText : e.value;
196
+ const rc = e.isContentEditable ? e.innerText : e.value;
197
+ if( true===arguments[0] ){
198
+ if(e.isContentEditable) e.innerText = '';
199
+ else e.value = '';
200
+ }
201
+ return rc && rc.trim();
190202
},
191203
/** Asks the current user input field to take focus. Returns this. */
192204
inputFocus: function(){
193205
this.inputElement().focus();
194206
return this;
@@ -513,11 +525,11 @@
513525
const uDate = self.usersLastSeen[u];
514526
if(self.filterState.activeUser===u){
515527
uSpan.classList.add('selected');
516528
}
517529
uSpan.dataset.uname = u;
518
- D.append(uSpan, u, "\n",
530
+ D.append(uSpan, u, "\n",
519531
D.append(
520532
D.addClass(D.span(),'timestamp'),
521533
localTimeString(uDate)//.substr(5/*chop off year*/)
522534
));
523535
if(uDate.$uColor){
@@ -898,11 +910,11 @@
898910
Chat.MessageWidget = (function(){
899911
/**
900912
Constructor. If passed an argument, it is passed to
901913
this.setMessage() after initialization.
902914
*/
903
- const cf = function(){
915
+ const ctor = function(){
904916
this.e = {
905917
body: D.addClass(D.div(), 'message-widget'),
906918
tab: D.addClass(D.div(), 'message-widget-tab'),
907919
content: D.addClass(D.div(), 'message-widget-content')
908920
};
@@ -917,20 +929,33 @@
917929
/* Map of Date.getDay() values to weekday names. */
918930
0: "Sunday", 1: "Monday", 2: "Tuesday",
919931
3: "Wednesday", 4: "Thursday", 5: "Friday",
920932
6: "Saturday"
921933
};
922
- /* Given a Date, returns the timestamp string used in the
923
- "tab" part of message widgets. */
924
- const theTime = function(d){
925
- return [
926
- //d.getFullYear(),'-',pad2(d.getMonth()+1/*sigh*/),
927
- //'-',pad2(d.getDate()), ' ',
928
- d.getHours(),":",
929
- (d.getMinutes()+100).toString().slice(1,3),
930
- ' ', dowMap[d.getDay()]
931
- ].join('');
934
+ /* Given a Date, returns the timestamp string used in the "tab"
935
+ part of message widgets. If longFmt is true then a verbose
936
+ format is used, else a brief format is used. The returned string
937
+ is in client-local time. */
938
+ const theTime = function(d, longFmt=false){
939
+ const li = [];
940
+ if( longFmt ){
941
+ li.push(
942
+ d.getFullYear(),
943
+ '-', pad2(d.getMonth()+1),
944
+ '-', pad2(d.getDate()),
945
+ ' ',
946
+ d.getHours(), ":",
947
+ (d.getMinutes()+100).toString().slice(1,3)
948
+ );
949
+ }else{
950
+ li.push(
951
+ d.getHours(),":",
952
+ (d.getMinutes()+100).toString().slice(1,3),
953
+ ' ', dowMap[d.getDay()]
954
+ );
955
+ }
956
+ return li.join('');
932957
};
933958
934959
/**
935960
Returns true if this page believes it can embed a view of the
936961
file wrapped by the given message object, else returns false.
@@ -937,19 +962,20 @@
937962
*/
938963
const canEmbedFile = function f(msg){
939964
if(!f.$rx){
940965
f.$rx = /\.((html?)|(txt)|(md)|(wiki)|(pikchr))$/i;
941966
f.$specificTypes = [
967
+ /* Mime types we know we can embed, sans image/... */
942968
'text/plain',
943969
'text/html',
944970
'text/x-markdown',
945971
/* Firefox sends text/markdown when uploading .md files */
946972
'text/markdown',
947973
'text/x-pikchr',
948974
'text/x-fossil-wiki'
949
- // add more as we discover which ones Firefox won't
950
- // force the user to try to download.
975
+ /* Add more as we discover which ones Firefox won't
976
+ force the user to try to download. */
951977
];
952978
}
953979
if(msg.fmime){
954980
if(msg.fmime.startsWith("image/")
955981
|| f.$specificTypes.indexOf(msg.fmime)>=0){
@@ -963,20 +989,18 @@
963989
Returns true if the given message object "should"
964990
be embedded in fossil-rendered form instead of
965991
raw content form. This is only intended to be passed
966992
message objects for which canEmbedFile() returns true.
967993
*/
968
- const shouldWikiRenderEmbed = function f(msg){
994
+ const shouldFossilRenderEmbed = function f(msg){
969995
if(!f.$rx){
970996
f.$rx = /\.((md)|(wiki)|(pikchr))$/i;
971997
f.$specificTypes = [
972998
'text/x-markdown',
973999
'text/markdown' /* Firefox-uploaded md files */,
9741000
'text/x-pikchr',
9751001
'text/x-fossil-wiki'
976
- // add more as we discover which ones Firefox won't
977
- // force the user to try to download.
9781002
];
9791003
}
9801004
if(msg.fmime){
9811005
if(f.$specificTypes.indexOf(msg.fmime)>=0) return true;
9821006
}
@@ -1002,12 +1026,12 @@
10021026
iframe.style.maxHeight = iframe.style.height
10031027
= iframe.contentWindow.document.documentElement.scrollHeight + 'px';
10041028
if(isHidden) D.addClass(iframe, 'hidden');
10051029
}
10061030
};
1007
-
1008
- cf.prototype = {
1031
+
1032
+ ctor.prototype = {
10091033
scrollIntoView: function(){
10101034
this.e.content.scrollIntoView();
10111035
},
10121036
setMessage: function(m){
10131037
const ds = this.e.body.dataset;
@@ -1028,20 +1052,26 @@
10281052
var eXFrom /* element holding xfrom name */;
10291053
if(m.xfrom){
10301054
eXFrom = D.append(D.addClass(D.span(), 'xfrom'), m.xfrom);
10311055
const wrapper = D.append(
10321056
D.span(), eXFrom,
1033
- D.text(" #",(m.msgid||'???'),' @ ',theTime(d)))
1057
+ ' ',
1058
+ D.append(D.addClass(D.span(), 'msgid'),
1059
+ '#' + (m.msgid||'???')),
1060
+ (m.isSearchResult ? ' ' : ' @ '),
1061
+ D.append(D.addClass(D.span(), 'timestamp'),
1062
+ theTime(d,!!m.isSearchResult))
1063
+ );
10341064
D.append(this.e.tab, wrapper);
10351065
}else{/*notification*/
10361066
D.addClass(this.e.body, 'notification');
10371067
if(m.isError){
10381068
D.addClass([contentTarget, this.e.tab], 'error');
10391069
}
10401070
D.append(
10411071
this.e.tab,
1042
- D.append(D.code(), 'notification @ ',theTime(d))
1072
+ D.append(D.code(), 'notification @ ',theTime(d,false))
10431073
);
10441074
}
10451075
if( m.xfrom && m.fsize>0 ){
10461076
if( m.fmime
10471077
&& m.fmime.startsWith("image/")
@@ -1064,18 +1094,18 @@
10641094
D.attr(a,'target','_blank');
10651095
D.append(w, a);
10661096
if(canEmbedFile(m)){
10671097
/* Add an option to embed HTML attachments in an iframe. The primary
10681098
use case is attached diffs. */
1069
- const shouldWikiRender = shouldWikiRenderEmbed(m);
1070
- const downloadArgs = shouldWikiRender ? '?render' : '';
1099
+ const shouldFossilRender = shouldFossilRenderEmbed(m);
1100
+ const downloadArgs = shouldFossilRender ? '?render' : '';
10711101
D.addClass(contentTarget, 'wide');
10721102
const embedTarget = this.e.content;
10731103
const self = this;
10741104
const btnEmbed = D.attr(D.checkbox("1", false), 'id',
10751105
'embed-'+ds.msgid);
1076
- const btnLabel = D.label(btnEmbed, shouldWikiRender
1106
+ const btnLabel = D.label(btnEmbed, shouldFossilRender
10771107
? "Embed (fossil-rendered)" : "Embed");
10781108
/* Maintenance reminder: do not disable the toggle
10791109
button while the content is loading because that will
10801110
cause it to get stuck in disabled mode if the browser
10811111
decides that loading the content should prompt the
@@ -1280,13 +1310,182 @@
12801310
}/*end static init*/
12811311
const theMsg = findMessageWidgetParent(ev.target);
12821312
if(theMsg) f.popup.show(theMsg);
12831313
}/*_handleLegendClicked()*/
12841314
};
1285
- return cf;
1315
+ return ctor;
12861316
})()/*MessageWidget*/;
12871317
1318
+ /**
1319
+ A widget for loading more messages (context) around a /chat-query
1320
+ result message.
1321
+ */
1322
+ Chat.SearchCtxLoader = (function(){
1323
+ const nMsgContext = 5;
1324
+ const zUpArrow = '\u25B2';
1325
+ const zDownArrow = '\u25BC';
1326
+ const ctor = function(o){
1327
+
1328
+ /* iFirstInTable:
1329
+ ** msgid of first row in chatfts table.
1330
+ **
1331
+ ** iLastInTable:
1332
+ ** msgid of last row in chatfts table.
1333
+ **
1334
+ ** iPrevId:
1335
+ ** msgid of message immediately above this spacer. Or 0 if this
1336
+ ** spacer is above all results.
1337
+ **
1338
+ ** iNextId:
1339
+ ** msgid of message immediately below this spacer. Or 0 if this
1340
+ ** spacer is below all results.
1341
+ **
1342
+ ** bIgnoreClick:
1343
+ ** ignore any clicks if this is true. This is used to ensure there
1344
+ ** is only ever one request belonging to this widget outstanding
1345
+ ** at any time.
1346
+ */
1347
+ this.o = {
1348
+ iFirstInTable: o.first,
1349
+ iLastInTable: o.last,
1350
+ iPrevId: o.previd,
1351
+ iNextId: o.nextid,
1352
+ bIgnoreClick: false
1353
+ };
1354
+
1355
+ this.e = {
1356
+ body: D.addClass(D.div(), 'spacer-widget'),
1357
+ up: D.addClass(
1358
+ D.button(zDownArrow+' Load '+nMsgContext+' more '+zDownArrow),
1359
+ 'up'
1360
+ ),
1361
+ down: D.addClass(
1362
+ D.button(zUpArrow+' Load '+nMsgContext+' more '+zUpArrow),
1363
+ 'down'
1364
+ ),
1365
+ all: D.addClass(D.button('Load More'), 'all')
1366
+ };
1367
+ D.append( this.e.body, this.e.up, this.e.down, this.e.all );
1368
+ const ms = this;
1369
+ this.e.up.addEventListener('click', ()=>ms.load_messages(false));
1370
+ this.e.down.addEventListener('click', ()=>ms.load_messages(true));
1371
+ this.e.all.addEventListener('click', ()=>ms.load_messages( (ms.o.iPrevId==0) ));
1372
+ this.set_button_visibility();
1373
+ };
1374
+
1375
+ ctor.prototype = {
1376
+ set_button_visibility: function() {
1377
+ if( !this.e ) return;
1378
+ const o = this.o;
1379
+
1380
+ const iPrevId = (o.iPrevId!=0) ? o.iPrevId : o.iFirstInTable-1;
1381
+ const iNextId = (o.iNextId!=0) ? o.iNextId : o.iLastInTable+1;
1382
+ let nDiff = (iNextId - iPrevId) - 1;
1383
+
1384
+ for( const x of [this.e.up, this.e.down, this.e.all] ){
1385
+ if( x ) D.addClass(x, 'hidden');
1386
+ }
1387
+ let nVisible = 0;
1388
+ if( nDiff>0 ){
1389
+ if( nDiff>nMsgContext && (o.iPrevId==0 || o.iNextId==0) ){
1390
+ nDiff = nMsgContext;
1391
+ }
1392
+
1393
+ if( nDiff<=nMsgContext && o.iPrevId!=0 && o.iNextId!=0 ){
1394
+ D.removeClass(this.e.all, 'hidden');
1395
+ ++nVisible;
1396
+ this.e.all.innerText = (
1397
+ zUpArrow + " Load " + nDiff + " more " + zDownArrow
1398
+ );
1399
+ }else{
1400
+ if( o.iPrevId!=0 ){
1401
+ ++nVisible;
1402
+ D.removeClass(this.e.up, 'hidden');
1403
+ }else if( this.e.up ){
1404
+ if( this.e.up.parentNode ) D.remove(this.e.up);
1405
+ delete this.e.up;
1406
+ }
1407
+ if( o.iNextId!=0 ){
1408
+ ++nVisible;
1409
+ D.removeClass(this.e.down, 'hidden');
1410
+ }else if( this.e.down ){
1411
+ if( this.e.down.parentNode ) D.remove( this.e.down );
1412
+ delete this.e.down;
1413
+ }
1414
+ }
1415
+ }
1416
+ if( !nVisible ){
1417
+ /* The DOM elements can now be disposed of. */
1418
+ for( const x of [this.e.up, this.e.down, this.e.all, this.e.body] ){
1419
+ if( x?.parentNode ) D.remove(x);
1420
+ }
1421
+ delete this.e;
1422
+ }
1423
+ },
1424
+
1425
+ load_messages: function(bDown) {
1426
+ if( this.bIgnoreClick ) return;
1427
+
1428
+ var iFirst = 0; /* msgid of first message to fetch */
1429
+ var nFetch = 0; /* Number of messages to fetch */
1430
+ var iEof = 0; /* last msgid in spacers range, plus 1 */
1431
+
1432
+ const e = this.e, o = this.o;
1433
+ this.bIgnoreClick = true;
1434
+
1435
+ /* Figure out the required range of messages. */
1436
+ if( bDown ){
1437
+ iFirst = this.o.iNextId - nMsgContext;
1438
+ if( iFirst<this.o.iFirstInTable ){
1439
+ iFirst = this.o.iFirstInTable;
1440
+ }
1441
+ }else{
1442
+ iFirst = this.o.iPrevId+1;
1443
+ }
1444
+ nFetch = nMsgContext;
1445
+ iEof = (this.o.iNextId > 0) ? this.o.iNextId : this.o.iLastInTable+1;
1446
+ if( iFirst+nFetch>iEof ){
1447
+ nFetch = iEof - iFirst;
1448
+ }
1449
+ const ms = this;
1450
+ F.fetch("chat-query",{
1451
+ urlParams:{
1452
+ q: '',
1453
+ n: nFetch,
1454
+ i: iFirst
1455
+ },
1456
+ responseType: "json",
1457
+ onload:function(jx){
1458
+ if( bDown ) jx.msgs.reverse();
1459
+ jx.msgs.forEach((m) => {
1460
+ var mw = new Chat.MessageWidget(m);
1461
+ if( bDown ){
1462
+ /* Inject the message below this object's body, or
1463
+ append it to Chat.e.searchContent if this element
1464
+ is the final one in its parent (Chat.e.searchContent). */
1465
+ const eAnchor = e.body.nextElementSibling;
1466
+ if( eAnchor ) Chat.e.searchContent.insertBefore(mw.e.body, eAnchor);
1467
+ else D.append(Chat.e.searchContent, mw.e.body);
1468
+ }else{
1469
+ Chat.e.searchContent.insertBefore(mw.e.body, e.body);
1470
+ }
1471
+ });
1472
+ if( bDown ){
1473
+ o.iNextId -= jx.msgs.length;
1474
+ }else{
1475
+ o.iPrevId += jx.msgs.length;
1476
+ }
1477
+ ms.set_button_visibility();
1478
+ ms.bIgnoreClick = false;
1479
+ }
1480
+ });
1481
+ }
1482
+ };
1483
+
1484
+ return ctor;
1485
+ })() /*SearchCtxLoader*/;
1486
+
12881487
const BlobXferState = (function(){
12891488
/* State for paste and drag/drop */
12901489
const bxs = {
12911490
dropDetails: document.querySelector('#chat-drop-details'),
12921491
blob: undefined,
@@ -1425,16 +1624,26 @@
14251624
14261625
/**
14271626
Submits the contents of the message input field (if not empty)
14281627
and/or the file attachment field to the server. If both are
14291628
empty, this is a no-op.
1629
+
1630
+ If the current view is the history search, this instead sends the
1631
+ input text to that widget.
14301632
*/
14311633
Chat.submitMessage = function f(){
14321634
if(!f.spaces){
14331635
f.spaces = /\s+$/;
14341636
f.markdownContinuation = /\\\s+$/;
14351637
f.spaces2 = /\s{3,}$/;
1638
+ }
1639
+ switch( this.e.currentView ){
1640
+ case this.e.viewSearch: this.submitSearch();
1641
+ return;
1642
+ case this.e.viewPreview: this.e.btnPreview.click();
1643
+ return;
1644
+ default: break;
14361645
}
14371646
this.setCurrentView(this.e.viewMessages);
14381647
const fd = new FormData();
14391648
const fallback = {msg: this.inputValue()};
14401649
var msg = fallback.msg;
@@ -1507,14 +1716,16 @@
15071716
//console.debug("Enter key event:", ctrlMode, ev.ctrlKey, ev.shiftKey, ev);
15081717
if(ev.shiftKey){
15091718
const compactMode = Chat.settings.getBool('edit-compact-mode', false);
15101719
ev.preventDefault();
15111720
ev.stopPropagation();
1512
- /* Shift-enter will run preview mode UNLESS preview mode is
1513
- active AND the input field is empty, in which case it will
1721
+ /* Shift-enter will run preview mode UNLESS the input field is empty
1722
+ AND (preview or search mode) is active, in which cases it will
15141723
switch back to message view. */
1515
- if(Chat.e.currentView===Chat.e.viewPreview && !text){
1724
+ if(!text &&
1725
+ (Chat.e.currentView===Chat.e.viewPreview
1726
+ | Chat.e.currentView===Chat.e.viewSearch)){
15161727
Chat.setCurrentView(Chat.e.viewMessages);
15171728
}else if(!text){
15181729
f.$toggleCompact(compactMode);
15191730
}else if(Chat.settings.getBool('edit-shift-enter-preview', true)){
15201731
Chat.e.btnPreview.click();
@@ -1572,19 +1783,19 @@
15721783
tall vs wide. Can be toggled via settings. */
15731784
document.body.classList.add('my-messages-right');
15741785
}
15751786
const settingsButton = document.querySelector('#chat-button-settings');
15761787
const optionsMenu = E1('#chat-config-options');
1577
- const cbToggle = function(ev){
1788
+ const eToggleView = function(ev){
15781789
ev.preventDefault();
15791790
ev.stopPropagation();
15801791
Chat.setCurrentView(Chat.e.currentView===Chat.e.viewConfig
15811792
? Chat.e.viewMessages : Chat.e.viewConfig);
15821793
return false;
15831794
};
1584
- D.attr(settingsButton, 'role', 'button').addEventListener('click', cbToggle, false);
1585
- Chat.e.viewConfig.querySelector('button').addEventListener('click', cbToggle, false);
1795
+ D.attr(settingsButton, 'role', 'button').addEventListener('click', eToggleView, false);
1796
+ Chat.e.viewConfig.querySelector('button.action-close').addEventListener('click', eToggleView, false);
15861797
15871798
/** Internal acrobatics to allow certain settings toggles to access
15881799
related toggles. */
15891800
const namedOptions = {
15901801
activeUsers:{
@@ -1670,12 +1881,13 @@
16701881
boolValue: 'edit-ctrl-send'
16711882
},{
16721883
label: "Compact mode",
16731884
hint: [
16741885
"Toggle between a space-saving or more spacious writing area. ",
1675
- "When the input field has focus, is empty, and preview mode ",
1676
- "is NOT active then Shift-Enter toggles this setting."].join(''),
1886
+ "When the input field has focus and is empty ",
1887
+ "then Shift-Enter may (depending on the current view) toggle this setting."
1888
+ ].join(''),
16771889
boolValue: 'edit-compact-mode'
16781890
},{
16791891
label: "Use 'contenteditable' editing mode",
16801892
boolValue: 'edit-widget-x',
16811893
hint: [
@@ -1840,11 +2052,11 @@
18402052
op.persistentSetting,
18412053
function(setting){
18422054
if(op.checkbox) op.checkbox.checked = !!setting.value;
18432055
else if(op.select) op.select.value = setting.value;
18442056
if(op.callback) op.callback(setting);
1845
- }
2057
+ }
18462058
);
18472059
if(op.checkbox){
18482060
op.checkbox.addEventListener(
18492061
'change', function(){
18502062
Chat.settings.set(op.persistentSetting, op.checkbox.checked)
@@ -1916,11 +2128,11 @@
19162128
s.value ? 'add' : 'remove'
19172129
]('compact');
19182130
Chat.e.inputFields[Chat.e.inputFields.$currentIndex].focus();
19192131
});
19202132
Chat.settings.addListener('edit-ctrl-send',function(s){
1921
- const label = (s.value ? "Ctrl-" : "")+"Enter submits messages.";
2133
+ const label = (s.value ? "Ctrl-" : "")+"Enter submits message";
19222134
Chat.e.inputFields.forEach((e)=>{
19232135
const v = e.dataset.placeholder0 + " " +label;
19242136
if(e.isContentEditable) e.dataset.placeholder = v;
19252137
else D.attr(e,'placeholder',v);
19262138
});
@@ -1947,11 +2159,11 @@
19472159
this.setCurrentView(this.e.viewPreview);
19482160
this.e.previewContent.innerHTML = t;
19492161
this.e.viewPreview.querySelectorAll('a').forEach(addAnchorTargetBlank);
19502162
this.inputFocus();
19512163
};
1952
- Chat.e.viewPreview.querySelector('#chat-preview-close').
2164
+ Chat.e.viewPreview.querySelector('button.action-close').
19532165
addEventListener('click', ()=>Chat.setCurrentView(Chat.e.viewMessages), false);
19542166
let previewPending = false;
19552167
const elemsToEnable = [btnPreview, Chat.e.btnSubmit, Chat.e.inputFields];
19562168
const submit = function(ev){
19572169
ev.preventDefault();
@@ -1994,10 +2206,40 @@
19942206
});
19952207
return false;
19962208
};
19972209
btnPreview.addEventListener('click', submit, false);
19982210
})()/*message preview setup*/;
2211
+
2212
+ (function(){/*Set up #chat-search and related bits */
2213
+ const btn = document.querySelector('#chat-button-search');
2214
+ D.attr(btn, 'role', 'button').addEventListener('click', function(ev){
2215
+ ev.preventDefault();
2216
+ ev.stopPropagation();
2217
+ const msg = Chat.inputValue();
2218
+ if( Chat.e.currentView===Chat.e.viewSearch ){
2219
+ if( msg ) Chat.submitSearch();
2220
+ else Chat.setCurrentView(Chat.e.viewMessages);
2221
+ }else{
2222
+ Chat.setCurrentView(Chat.e.viewSearch);
2223
+ if( msg ) Chat.submitSearch();
2224
+ }
2225
+ return false;
2226
+ }, false);
2227
+ Chat.e.viewSearch.querySelector('button.action-clear').addEventListener('click', function(ev){
2228
+ ev.preventDefault();
2229
+ ev.stopPropagation();
2230
+ Chat.clearSearch(true);
2231
+ Chat.setCurrentView(Chat.e.viewMessages);
2232
+ return false;
2233
+ }, false);
2234
+ Chat.e.viewSearch.querySelector('button.action-close').addEventListener('click', function(ev){
2235
+ ev.preventDefault();
2236
+ ev.stopPropagation();
2237
+ Chat.setCurrentView(Chat.e.viewMessages);
2238
+ return false;
2239
+ }, false);
2240
+ })()/*search view setup*/;
19992241
20002242
/** Callback for poll() to inject new content into the page. jx ==
20012243
the response from /chat-poll. If atEnd is true, the message is
20022244
appended to the end of the chat list (for loading older
20032245
messages), else the beginning (the default). */
@@ -2126,10 +2368,82 @@
21262368
btn.addEventListener('click',()=>loadOldMessages(-1));
21272369
D.append(Chat.e.viewMessages, toolbar);
21282370
toolbar.disabled = true /*will be enabled when msg load finishes */;
21292371
})()/*end history loading widget setup*/;
21302372
2373
+ /**
2374
+ Clears the search result view. If addInstructions is true it adds
2375
+ text to that view instructing the user to enter their query into
2376
+ the message-entry widget (noting that that widget has text
2377
+ implying that it's only for submitting a message, which isn't
2378
+ exactly true when the search view is active).
2379
+
2380
+ Returns the DOM element which wraps all of the chat search
2381
+ result elements.
2382
+ */
2383
+ Chat.clearSearch = function(addInstructions=false){
2384
+ const e = D.clearElement( this.e.searchContent );
2385
+ if(addInstructions){
2386
+ D.append(e, "Enter search terms in the message field. "+
2387
+ "Use #NNNNN to search for the message with ID NNNNN.");
2388
+ }
2389
+ return e;
2390
+ };
2391
+ Chat.clearSearch(true);
2392
+ /**
2393
+ Submits a history search using the main input field's current
2394
+ text. It is assumed that Chat.e.viewSearch===Chat.e.currentView.
2395
+ */
2396
+ Chat.submitSearch = function(){
2397
+ const term = this.inputValue(true);
2398
+ const eMsgTgt = this.clearSearch(true);
2399
+ if( !term ) return;
2400
+ D.append( eMsgTgt, "Searching for ",term," ...");
2401
+ const fd = new FormData();
2402
+ fd.set('q', term);
2403
+ F.fetch(
2404
+ "chat-query", {
2405
+ payload: fd,
2406
+ responseType: 'json',
2407
+ onerror:function(err){
2408
+ Chat.setCurrentView(Chat.e.viewMessages);
2409
+ Chat.reportErrorAsMessage(err);
2410
+ },
2411
+ onload:function(jx){
2412
+ let previd = 0;
2413
+ D.clearElement(eMsgTgt);
2414
+ jx.msgs.forEach((m)=>{
2415
+ m.isSearchResult = true;
2416
+ const mw = new Chat.MessageWidget(m);
2417
+ const spacer = new Chat.SearchCtxLoader({
2418
+ first: jx.first,
2419
+ last: jx.last,
2420
+ previd: previd,
2421
+ nextid: m.msgid
2422
+ });
2423
+ if( spacer.e ) D.append( eMsgTgt, spacer.e.body );
2424
+ D.append( eMsgTgt, mw.e.body );
2425
+ previd = m.msgid;
2426
+ });
2427
+ if( jx.msgs.length ){
2428
+ const spacer = new Chat.SearchCtxLoader({
2429
+ first: jx.first,
2430
+ last: jx.last,
2431
+ previd: previd,
2432
+ nextid: 0
2433
+ });
2434
+ if( spacer.e ) D.append( eMsgTgt, spacer.e.body );
2435
+ }else{
2436
+ D.append( D.clearElement(eMsgTgt),
2437
+ 'No search results found for: ',
2438
+ term );
2439
+ }
2440
+ }
2441
+ }
2442
+ );
2443
+ }/*Chat.submitSearch()*/;
2444
+
21312445
const afterFetch = function f(){
21322446
if(true===f.isFirstCall){
21332447
f.isFirstCall = false;
21342448
Chat.ajaxEnd();
21352449
Chat.e.viewMessages.classList.remove('loading');
@@ -2145,10 +2459,25 @@
21452459
delete Chat.intervalTimer;
21462460
}
21472461
poll.running = false;
21482462
};
21492463
afterFetch.isFirstCall = true;
2464
+ /**
2465
+ FIXME: when polling fails because the remote server is
2466
+ reachable but it's not accepting HTTP requests, we should back
2467
+ off on polling for a while. e.g. if the remote web server process
2468
+ is killed, the poll fails quickly and immediately retries,
2469
+ hammering the remote server until the httpd is back up. That
2470
+ happens often during development of this application.
2471
+
2472
+ XHR does not offer a direct way of distinguishing between
2473
+ HTTP/connection errors, but we can hypothetically use the
2474
+ xhrRequest.status value to do so, with status==0 being a
2475
+ connection error. We do not currently have a clean way of passing
2476
+ that info back to the fossil.fetch() client, so we'll need to
2477
+ hammer on that API a bit to get this working.
2478
+ */
21502479
const poll = async function f(){
21512480
if(f.running) return;
21522481
f.running = true;
21532482
Chat._isBatchLoading = f.isFirstCall;
21542483
if(true===f.isFirstCall){
@@ -2187,17 +2516,11 @@
21872516
Chat._gotServerError = poll.running = false;
21882517
if( window.fossil.config.chat.fromcli ){
21892518
Chat.chatOnlyMode(true);
21902519
}
21912520
Chat.intervalTimer = setInterval(poll, 1000);
2192
- if(0){
2193
- const flip = (ev)=>Chat.animate(ev.target,'anim-flip-h');
2194
- document.querySelectorAll('#chat-buttons-wrapper .cbutton').forEach(function(e){
2195
- e.addEventListener('click',flip, false);
2196
- });
2197
- }
21982521
delete ForceResizeKludge.$disabled;
21992522
ForceResizeKludge();
22002523
Chat.animate.$disabled = false;
22012524
setTimeout( ()=>Chat.inputFocus(), 0 );
22022525
F.page.chat = Chat/* enables testing the APIs via the dev tools */;
22032526
});
22042527
--- src/fossil.page.chat.js
+++ src/fossil.page.chat.js
@@ -146,10 +146,12 @@
146 inputFile: E1('#chat-input-file'),
147 contentDiv: E1('div.content'),
148 viewConfig: E1('#chat-config'),
149 viewPreview: E1('#chat-preview'),
150 previewContent: E1('#chat-preview-content'),
 
 
151 btnPreview: E1('#chat-button-preview'),
152 views: document.querySelectorAll('.chat-view'),
153 activeUserListWrapper: E1('#chat-user-list-wrapper'),
154 activeUserList: E1('#chat-user-list')
155 },
@@ -174,21 +176,31 @@
174 activeUser: undefined,
175 match: function(uname){
176 return this.activeUser===uname || !this.activeUser;
177 }
178 },
179 /** Gets (no args) or sets (1 arg) the current input text field value,
180 taking into account single- vs multi-line input. The getter returns
181 a string and the setter returns this object. */
182 inputValue: function(){
 
 
 
 
 
183 const e = this.inputElement();
184 if(arguments.length){
185 if(e.isContentEditable) e.innerText = arguments[0];
186 else e.value = arguments[0];
187 return this;
188 }
189 return e.isContentEditable ? e.innerText : e.value;
 
 
 
 
 
190 },
191 /** Asks the current user input field to take focus. Returns this. */
192 inputFocus: function(){
193 this.inputElement().focus();
194 return this;
@@ -513,11 +525,11 @@
513 const uDate = self.usersLastSeen[u];
514 if(self.filterState.activeUser===u){
515 uSpan.classList.add('selected');
516 }
517 uSpan.dataset.uname = u;
518 D.append(uSpan, u, "\n",
519 D.append(
520 D.addClass(D.span(),'timestamp'),
521 localTimeString(uDate)//.substr(5/*chop off year*/)
522 ));
523 if(uDate.$uColor){
@@ -898,11 +910,11 @@
898 Chat.MessageWidget = (function(){
899 /**
900 Constructor. If passed an argument, it is passed to
901 this.setMessage() after initialization.
902 */
903 const cf = function(){
904 this.e = {
905 body: D.addClass(D.div(), 'message-widget'),
906 tab: D.addClass(D.div(), 'message-widget-tab'),
907 content: D.addClass(D.div(), 'message-widget-content')
908 };
@@ -917,20 +929,33 @@
917 /* Map of Date.getDay() values to weekday names. */
918 0: "Sunday", 1: "Monday", 2: "Tuesday",
919 3: "Wednesday", 4: "Thursday", 5: "Friday",
920 6: "Saturday"
921 };
922 /* Given a Date, returns the timestamp string used in the
923 "tab" part of message widgets. */
924 const theTime = function(d){
925 return [
926 //d.getFullYear(),'-',pad2(d.getMonth()+1/*sigh*/),
927 //'-',pad2(d.getDate()), ' ',
928 d.getHours(),":",
929 (d.getMinutes()+100).toString().slice(1,3),
930 ' ', dowMap[d.getDay()]
931 ].join('');
 
 
 
 
 
 
 
 
 
 
 
 
 
932 };
933
934 /**
935 Returns true if this page believes it can embed a view of the
936 file wrapped by the given message object, else returns false.
@@ -937,19 +962,20 @@
937 */
938 const canEmbedFile = function f(msg){
939 if(!f.$rx){
940 f.$rx = /\.((html?)|(txt)|(md)|(wiki)|(pikchr))$/i;
941 f.$specificTypes = [
 
942 'text/plain',
943 'text/html',
944 'text/x-markdown',
945 /* Firefox sends text/markdown when uploading .md files */
946 'text/markdown',
947 'text/x-pikchr',
948 'text/x-fossil-wiki'
949 // add more as we discover which ones Firefox won't
950 // force the user to try to download.
951 ];
952 }
953 if(msg.fmime){
954 if(msg.fmime.startsWith("image/")
955 || f.$specificTypes.indexOf(msg.fmime)>=0){
@@ -963,20 +989,18 @@
963 Returns true if the given message object "should"
964 be embedded in fossil-rendered form instead of
965 raw content form. This is only intended to be passed
966 message objects for which canEmbedFile() returns true.
967 */
968 const shouldWikiRenderEmbed = function f(msg){
969 if(!f.$rx){
970 f.$rx = /\.((md)|(wiki)|(pikchr))$/i;
971 f.$specificTypes = [
972 'text/x-markdown',
973 'text/markdown' /* Firefox-uploaded md files */,
974 'text/x-pikchr',
975 'text/x-fossil-wiki'
976 // add more as we discover which ones Firefox won't
977 // force the user to try to download.
978 ];
979 }
980 if(msg.fmime){
981 if(f.$specificTypes.indexOf(msg.fmime)>=0) return true;
982 }
@@ -1002,12 +1026,12 @@
1002 iframe.style.maxHeight = iframe.style.height
1003 = iframe.contentWindow.document.documentElement.scrollHeight + 'px';
1004 if(isHidden) D.addClass(iframe, 'hidden');
1005 }
1006 };
1007
1008 cf.prototype = {
1009 scrollIntoView: function(){
1010 this.e.content.scrollIntoView();
1011 },
1012 setMessage: function(m){
1013 const ds = this.e.body.dataset;
@@ -1028,20 +1052,26 @@
1028 var eXFrom /* element holding xfrom name */;
1029 if(m.xfrom){
1030 eXFrom = D.append(D.addClass(D.span(), 'xfrom'), m.xfrom);
1031 const wrapper = D.append(
1032 D.span(), eXFrom,
1033 D.text(" #",(m.msgid||'???'),' @ ',theTime(d)))
 
 
 
 
 
 
1034 D.append(this.e.tab, wrapper);
1035 }else{/*notification*/
1036 D.addClass(this.e.body, 'notification');
1037 if(m.isError){
1038 D.addClass([contentTarget, this.e.tab], 'error');
1039 }
1040 D.append(
1041 this.e.tab,
1042 D.append(D.code(), 'notification @ ',theTime(d))
1043 );
1044 }
1045 if( m.xfrom && m.fsize>0 ){
1046 if( m.fmime
1047 && m.fmime.startsWith("image/")
@@ -1064,18 +1094,18 @@
1064 D.attr(a,'target','_blank');
1065 D.append(w, a);
1066 if(canEmbedFile(m)){
1067 /* Add an option to embed HTML attachments in an iframe. The primary
1068 use case is attached diffs. */
1069 const shouldWikiRender = shouldWikiRenderEmbed(m);
1070 const downloadArgs = shouldWikiRender ? '?render' : '';
1071 D.addClass(contentTarget, 'wide');
1072 const embedTarget = this.e.content;
1073 const self = this;
1074 const btnEmbed = D.attr(D.checkbox("1", false), 'id',
1075 'embed-'+ds.msgid);
1076 const btnLabel = D.label(btnEmbed, shouldWikiRender
1077 ? "Embed (fossil-rendered)" : "Embed");
1078 /* Maintenance reminder: do not disable the toggle
1079 button while the content is loading because that will
1080 cause it to get stuck in disabled mode if the browser
1081 decides that loading the content should prompt the
@@ -1280,13 +1310,182 @@
1280 }/*end static init*/
1281 const theMsg = findMessageWidgetParent(ev.target);
1282 if(theMsg) f.popup.show(theMsg);
1283 }/*_handleLegendClicked()*/
1284 };
1285 return cf;
1286 })()/*MessageWidget*/;
1287
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1288 const BlobXferState = (function(){
1289 /* State for paste and drag/drop */
1290 const bxs = {
1291 dropDetails: document.querySelector('#chat-drop-details'),
1292 blob: undefined,
@@ -1425,16 +1624,26 @@
1425
1426 /**
1427 Submits the contents of the message input field (if not empty)
1428 and/or the file attachment field to the server. If both are
1429 empty, this is a no-op.
 
 
 
1430 */
1431 Chat.submitMessage = function f(){
1432 if(!f.spaces){
1433 f.spaces = /\s+$/;
1434 f.markdownContinuation = /\\\s+$/;
1435 f.spaces2 = /\s{3,}$/;
 
 
 
 
 
 
 
1436 }
1437 this.setCurrentView(this.e.viewMessages);
1438 const fd = new FormData();
1439 const fallback = {msg: this.inputValue()};
1440 var msg = fallback.msg;
@@ -1507,14 +1716,16 @@
1507 //console.debug("Enter key event:", ctrlMode, ev.ctrlKey, ev.shiftKey, ev);
1508 if(ev.shiftKey){
1509 const compactMode = Chat.settings.getBool('edit-compact-mode', false);
1510 ev.preventDefault();
1511 ev.stopPropagation();
1512 /* Shift-enter will run preview mode UNLESS preview mode is
1513 active AND the input field is empty, in which case it will
1514 switch back to message view. */
1515 if(Chat.e.currentView===Chat.e.viewPreview && !text){
 
 
1516 Chat.setCurrentView(Chat.e.viewMessages);
1517 }else if(!text){
1518 f.$toggleCompact(compactMode);
1519 }else if(Chat.settings.getBool('edit-shift-enter-preview', true)){
1520 Chat.e.btnPreview.click();
@@ -1572,19 +1783,19 @@
1572 tall vs wide. Can be toggled via settings. */
1573 document.body.classList.add('my-messages-right');
1574 }
1575 const settingsButton = document.querySelector('#chat-button-settings');
1576 const optionsMenu = E1('#chat-config-options');
1577 const cbToggle = function(ev){
1578 ev.preventDefault();
1579 ev.stopPropagation();
1580 Chat.setCurrentView(Chat.e.currentView===Chat.e.viewConfig
1581 ? Chat.e.viewMessages : Chat.e.viewConfig);
1582 return false;
1583 };
1584 D.attr(settingsButton, 'role', 'button').addEventListener('click', cbToggle, false);
1585 Chat.e.viewConfig.querySelector('button').addEventListener('click', cbToggle, false);
1586
1587 /** Internal acrobatics to allow certain settings toggles to access
1588 related toggles. */
1589 const namedOptions = {
1590 activeUsers:{
@@ -1670,12 +1881,13 @@
1670 boolValue: 'edit-ctrl-send'
1671 },{
1672 label: "Compact mode",
1673 hint: [
1674 "Toggle between a space-saving or more spacious writing area. ",
1675 "When the input field has focus, is empty, and preview mode ",
1676 "is NOT active then Shift-Enter toggles this setting."].join(''),
 
1677 boolValue: 'edit-compact-mode'
1678 },{
1679 label: "Use 'contenteditable' editing mode",
1680 boolValue: 'edit-widget-x',
1681 hint: [
@@ -1840,11 +2052,11 @@
1840 op.persistentSetting,
1841 function(setting){
1842 if(op.checkbox) op.checkbox.checked = !!setting.value;
1843 else if(op.select) op.select.value = setting.value;
1844 if(op.callback) op.callback(setting);
1845 }
1846 );
1847 if(op.checkbox){
1848 op.checkbox.addEventListener(
1849 'change', function(){
1850 Chat.settings.set(op.persistentSetting, op.checkbox.checked)
@@ -1916,11 +2128,11 @@
1916 s.value ? 'add' : 'remove'
1917 ]('compact');
1918 Chat.e.inputFields[Chat.e.inputFields.$currentIndex].focus();
1919 });
1920 Chat.settings.addListener('edit-ctrl-send',function(s){
1921 const label = (s.value ? "Ctrl-" : "")+"Enter submits messages.";
1922 Chat.e.inputFields.forEach((e)=>{
1923 const v = e.dataset.placeholder0 + " " +label;
1924 if(e.isContentEditable) e.dataset.placeholder = v;
1925 else D.attr(e,'placeholder',v);
1926 });
@@ -1947,11 +2159,11 @@
1947 this.setCurrentView(this.e.viewPreview);
1948 this.e.previewContent.innerHTML = t;
1949 this.e.viewPreview.querySelectorAll('a').forEach(addAnchorTargetBlank);
1950 this.inputFocus();
1951 };
1952 Chat.e.viewPreview.querySelector('#chat-preview-close').
1953 addEventListener('click', ()=>Chat.setCurrentView(Chat.e.viewMessages), false);
1954 let previewPending = false;
1955 const elemsToEnable = [btnPreview, Chat.e.btnSubmit, Chat.e.inputFields];
1956 const submit = function(ev){
1957 ev.preventDefault();
@@ -1994,10 +2206,40 @@
1994 });
1995 return false;
1996 };
1997 btnPreview.addEventListener('click', submit, false);
1998 })()/*message preview setup*/;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1999
2000 /** Callback for poll() to inject new content into the page. jx ==
2001 the response from /chat-poll. If atEnd is true, the message is
2002 appended to the end of the chat list (for loading older
2003 messages), else the beginning (the default). */
@@ -2126,10 +2368,82 @@
2126 btn.addEventListener('click',()=>loadOldMessages(-1));
2127 D.append(Chat.e.viewMessages, toolbar);
2128 toolbar.disabled = true /*will be enabled when msg load finishes */;
2129 })()/*end history loading widget setup*/;
2130
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2131 const afterFetch = function f(){
2132 if(true===f.isFirstCall){
2133 f.isFirstCall = false;
2134 Chat.ajaxEnd();
2135 Chat.e.viewMessages.classList.remove('loading');
@@ -2145,10 +2459,25 @@
2145 delete Chat.intervalTimer;
2146 }
2147 poll.running = false;
2148 };
2149 afterFetch.isFirstCall = true;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2150 const poll = async function f(){
2151 if(f.running) return;
2152 f.running = true;
2153 Chat._isBatchLoading = f.isFirstCall;
2154 if(true===f.isFirstCall){
@@ -2187,17 +2516,11 @@
2187 Chat._gotServerError = poll.running = false;
2188 if( window.fossil.config.chat.fromcli ){
2189 Chat.chatOnlyMode(true);
2190 }
2191 Chat.intervalTimer = setInterval(poll, 1000);
2192 if(0){
2193 const flip = (ev)=>Chat.animate(ev.target,'anim-flip-h');
2194 document.querySelectorAll('#chat-buttons-wrapper .cbutton').forEach(function(e){
2195 e.addEventListener('click',flip, false);
2196 });
2197 }
2198 delete ForceResizeKludge.$disabled;
2199 ForceResizeKludge();
2200 Chat.animate.$disabled = false;
2201 setTimeout( ()=>Chat.inputFocus(), 0 );
2202 F.page.chat = Chat/* enables testing the APIs via the dev tools */;
2203 });
2204
--- src/fossil.page.chat.js
+++ src/fossil.page.chat.js
@@ -146,10 +146,12 @@
146 inputFile: E1('#chat-input-file'),
147 contentDiv: E1('div.content'),
148 viewConfig: E1('#chat-config'),
149 viewPreview: E1('#chat-preview'),
150 previewContent: E1('#chat-preview-content'),
151 viewSearch: E1('#chat-search'),
152 searchContent: E1('#chat-search-content'),
153 btnPreview: E1('#chat-button-preview'),
154 views: document.querySelectorAll('.chat-view'),
155 activeUserListWrapper: E1('#chat-user-list-wrapper'),
156 activeUserList: E1('#chat-user-list')
157 },
@@ -174,21 +176,31 @@
176 activeUser: undefined,
177 match: function(uname){
178 return this.activeUser===uname || !this.activeUser;
179 }
180 },
181 /**
182 Gets (no args) or sets (1 arg) the current input text field
183 value, taking into account single- vs multi-line input. The
184 getter returns a trim()'d string and the setter returns this
185 object. As a special case, if arguments[0] is a boolean
186 value, it behaves like a getter and, if arguments[0]===true
187 it clears the input field before returning.
188 */
189 inputValue: function(/*string newValue | bool clearInputField*/){
190 const e = this.inputElement();
191 if(arguments.length && 'boolean'!==typeof arguments[0]){
192 if(e.isContentEditable) e.innerText = arguments[0];
193 else e.value = arguments[0];
194 return this;
195 }
196 const rc = e.isContentEditable ? e.innerText : e.value;
197 if( true===arguments[0] ){
198 if(e.isContentEditable) e.innerText = '';
199 else e.value = '';
200 }
201 return rc && rc.trim();
202 },
203 /** Asks the current user input field to take focus. Returns this. */
204 inputFocus: function(){
205 this.inputElement().focus();
206 return this;
@@ -513,11 +525,11 @@
525 const uDate = self.usersLastSeen[u];
526 if(self.filterState.activeUser===u){
527 uSpan.classList.add('selected');
528 }
529 uSpan.dataset.uname = u;
530 D.append(uSpan, u, "\n",
531 D.append(
532 D.addClass(D.span(),'timestamp'),
533 localTimeString(uDate)//.substr(5/*chop off year*/)
534 ));
535 if(uDate.$uColor){
@@ -898,11 +910,11 @@
910 Chat.MessageWidget = (function(){
911 /**
912 Constructor. If passed an argument, it is passed to
913 this.setMessage() after initialization.
914 */
915 const ctor = function(){
916 this.e = {
917 body: D.addClass(D.div(), 'message-widget'),
918 tab: D.addClass(D.div(), 'message-widget-tab'),
919 content: D.addClass(D.div(), 'message-widget-content')
920 };
@@ -917,20 +929,33 @@
929 /* Map of Date.getDay() values to weekday names. */
930 0: "Sunday", 1: "Monday", 2: "Tuesday",
931 3: "Wednesday", 4: "Thursday", 5: "Friday",
932 6: "Saturday"
933 };
934 /* Given a Date, returns the timestamp string used in the "tab"
935 part of message widgets. If longFmt is true then a verbose
936 format is used, else a brief format is used. The returned string
937 is in client-local time. */
938 const theTime = function(d, longFmt=false){
939 const li = [];
940 if( longFmt ){
941 li.push(
942 d.getFullYear(),
943 '-', pad2(d.getMonth()+1),
944 '-', pad2(d.getDate()),
945 ' ',
946 d.getHours(), ":",
947 (d.getMinutes()+100).toString().slice(1,3)
948 );
949 }else{
950 li.push(
951 d.getHours(),":",
952 (d.getMinutes()+100).toString().slice(1,3),
953 ' ', dowMap[d.getDay()]
954 );
955 }
956 return li.join('');
957 };
958
959 /**
960 Returns true if this page believes it can embed a view of the
961 file wrapped by the given message object, else returns false.
@@ -937,19 +962,20 @@
962 */
963 const canEmbedFile = function f(msg){
964 if(!f.$rx){
965 f.$rx = /\.((html?)|(txt)|(md)|(wiki)|(pikchr))$/i;
966 f.$specificTypes = [
967 /* Mime types we know we can embed, sans image/... */
968 'text/plain',
969 'text/html',
970 'text/x-markdown',
971 /* Firefox sends text/markdown when uploading .md files */
972 'text/markdown',
973 'text/x-pikchr',
974 'text/x-fossil-wiki'
975 /* Add more as we discover which ones Firefox won't
976 force the user to try to download. */
977 ];
978 }
979 if(msg.fmime){
980 if(msg.fmime.startsWith("image/")
981 || f.$specificTypes.indexOf(msg.fmime)>=0){
@@ -963,20 +989,18 @@
989 Returns true if the given message object "should"
990 be embedded in fossil-rendered form instead of
991 raw content form. This is only intended to be passed
992 message objects for which canEmbedFile() returns true.
993 */
994 const shouldFossilRenderEmbed = function f(msg){
995 if(!f.$rx){
996 f.$rx = /\.((md)|(wiki)|(pikchr))$/i;
997 f.$specificTypes = [
998 'text/x-markdown',
999 'text/markdown' /* Firefox-uploaded md files */,
1000 'text/x-pikchr',
1001 'text/x-fossil-wiki'
 
 
1002 ];
1003 }
1004 if(msg.fmime){
1005 if(f.$specificTypes.indexOf(msg.fmime)>=0) return true;
1006 }
@@ -1002,12 +1026,12 @@
1026 iframe.style.maxHeight = iframe.style.height
1027 = iframe.contentWindow.document.documentElement.scrollHeight + 'px';
1028 if(isHidden) D.addClass(iframe, 'hidden');
1029 }
1030 };
1031
1032 ctor.prototype = {
1033 scrollIntoView: function(){
1034 this.e.content.scrollIntoView();
1035 },
1036 setMessage: function(m){
1037 const ds = this.e.body.dataset;
@@ -1028,20 +1052,26 @@
1052 var eXFrom /* element holding xfrom name */;
1053 if(m.xfrom){
1054 eXFrom = D.append(D.addClass(D.span(), 'xfrom'), m.xfrom);
1055 const wrapper = D.append(
1056 D.span(), eXFrom,
1057 ' ',
1058 D.append(D.addClass(D.span(), 'msgid'),
1059 '#' + (m.msgid||'???')),
1060 (m.isSearchResult ? ' ' : ' @ '),
1061 D.append(D.addClass(D.span(), 'timestamp'),
1062 theTime(d,!!m.isSearchResult))
1063 );
1064 D.append(this.e.tab, wrapper);
1065 }else{/*notification*/
1066 D.addClass(this.e.body, 'notification');
1067 if(m.isError){
1068 D.addClass([contentTarget, this.e.tab], 'error');
1069 }
1070 D.append(
1071 this.e.tab,
1072 D.append(D.code(), 'notification @ ',theTime(d,false))
1073 );
1074 }
1075 if( m.xfrom && m.fsize>0 ){
1076 if( m.fmime
1077 && m.fmime.startsWith("image/")
@@ -1064,18 +1094,18 @@
1094 D.attr(a,'target','_blank');
1095 D.append(w, a);
1096 if(canEmbedFile(m)){
1097 /* Add an option to embed HTML attachments in an iframe. The primary
1098 use case is attached diffs. */
1099 const shouldFossilRender = shouldFossilRenderEmbed(m);
1100 const downloadArgs = shouldFossilRender ? '?render' : '';
1101 D.addClass(contentTarget, 'wide');
1102 const embedTarget = this.e.content;
1103 const self = this;
1104 const btnEmbed = D.attr(D.checkbox("1", false), 'id',
1105 'embed-'+ds.msgid);
1106 const btnLabel = D.label(btnEmbed, shouldFossilRender
1107 ? "Embed (fossil-rendered)" : "Embed");
1108 /* Maintenance reminder: do not disable the toggle
1109 button while the content is loading because that will
1110 cause it to get stuck in disabled mode if the browser
1111 decides that loading the content should prompt the
@@ -1280,13 +1310,182 @@
1310 }/*end static init*/
1311 const theMsg = findMessageWidgetParent(ev.target);
1312 if(theMsg) f.popup.show(theMsg);
1313 }/*_handleLegendClicked()*/
1314 };
1315 return ctor;
1316 })()/*MessageWidget*/;
1317
1318 /**
1319 A widget for loading more messages (context) around a /chat-query
1320 result message.
1321 */
1322 Chat.SearchCtxLoader = (function(){
1323 const nMsgContext = 5;
1324 const zUpArrow = '\u25B2';
1325 const zDownArrow = '\u25BC';
1326 const ctor = function(o){
1327
1328 /* iFirstInTable:
1329 ** msgid of first row in chatfts table.
1330 **
1331 ** iLastInTable:
1332 ** msgid of last row in chatfts table.
1333 **
1334 ** iPrevId:
1335 ** msgid of message immediately above this spacer. Or 0 if this
1336 ** spacer is above all results.
1337 **
1338 ** iNextId:
1339 ** msgid of message immediately below this spacer. Or 0 if this
1340 ** spacer is below all results.
1341 **
1342 ** bIgnoreClick:
1343 ** ignore any clicks if this is true. This is used to ensure there
1344 ** is only ever one request belonging to this widget outstanding
1345 ** at any time.
1346 */
1347 this.o = {
1348 iFirstInTable: o.first,
1349 iLastInTable: o.last,
1350 iPrevId: o.previd,
1351 iNextId: o.nextid,
1352 bIgnoreClick: false
1353 };
1354
1355 this.e = {
1356 body: D.addClass(D.div(), 'spacer-widget'),
1357 up: D.addClass(
1358 D.button(zDownArrow+' Load '+nMsgContext+' more '+zDownArrow),
1359 'up'
1360 ),
1361 down: D.addClass(
1362 D.button(zUpArrow+' Load '+nMsgContext+' more '+zUpArrow),
1363 'down'
1364 ),
1365 all: D.addClass(D.button('Load More'), 'all')
1366 };
1367 D.append( this.e.body, this.e.up, this.e.down, this.e.all );
1368 const ms = this;
1369 this.e.up.addEventListener('click', ()=>ms.load_messages(false));
1370 this.e.down.addEventListener('click', ()=>ms.load_messages(true));
1371 this.e.all.addEventListener('click', ()=>ms.load_messages( (ms.o.iPrevId==0) ));
1372 this.set_button_visibility();
1373 };
1374
1375 ctor.prototype = {
1376 set_button_visibility: function() {
1377 if( !this.e ) return;
1378 const o = this.o;
1379
1380 const iPrevId = (o.iPrevId!=0) ? o.iPrevId : o.iFirstInTable-1;
1381 const iNextId = (o.iNextId!=0) ? o.iNextId : o.iLastInTable+1;
1382 let nDiff = (iNextId - iPrevId) - 1;
1383
1384 for( const x of [this.e.up, this.e.down, this.e.all] ){
1385 if( x ) D.addClass(x, 'hidden');
1386 }
1387 let nVisible = 0;
1388 if( nDiff>0 ){
1389 if( nDiff>nMsgContext && (o.iPrevId==0 || o.iNextId==0) ){
1390 nDiff = nMsgContext;
1391 }
1392
1393 if( nDiff<=nMsgContext && o.iPrevId!=0 && o.iNextId!=0 ){
1394 D.removeClass(this.e.all, 'hidden');
1395 ++nVisible;
1396 this.e.all.innerText = (
1397 zUpArrow + " Load " + nDiff + " more " + zDownArrow
1398 );
1399 }else{
1400 if( o.iPrevId!=0 ){
1401 ++nVisible;
1402 D.removeClass(this.e.up, 'hidden');
1403 }else if( this.e.up ){
1404 if( this.e.up.parentNode ) D.remove(this.e.up);
1405 delete this.e.up;
1406 }
1407 if( o.iNextId!=0 ){
1408 ++nVisible;
1409 D.removeClass(this.e.down, 'hidden');
1410 }else if( this.e.down ){
1411 if( this.e.down.parentNode ) D.remove( this.e.down );
1412 delete this.e.down;
1413 }
1414 }
1415 }
1416 if( !nVisible ){
1417 /* The DOM elements can now be disposed of. */
1418 for( const x of [this.e.up, this.e.down, this.e.all, this.e.body] ){
1419 if( x?.parentNode ) D.remove(x);
1420 }
1421 delete this.e;
1422 }
1423 },
1424
1425 load_messages: function(bDown) {
1426 if( this.bIgnoreClick ) return;
1427
1428 var iFirst = 0; /* msgid of first message to fetch */
1429 var nFetch = 0; /* Number of messages to fetch */
1430 var iEof = 0; /* last msgid in spacers range, plus 1 */
1431
1432 const e = this.e, o = this.o;
1433 this.bIgnoreClick = true;
1434
1435 /* Figure out the required range of messages. */
1436 if( bDown ){
1437 iFirst = this.o.iNextId - nMsgContext;
1438 if( iFirst<this.o.iFirstInTable ){
1439 iFirst = this.o.iFirstInTable;
1440 }
1441 }else{
1442 iFirst = this.o.iPrevId+1;
1443 }
1444 nFetch = nMsgContext;
1445 iEof = (this.o.iNextId > 0) ? this.o.iNextId : this.o.iLastInTable+1;
1446 if( iFirst+nFetch>iEof ){
1447 nFetch = iEof - iFirst;
1448 }
1449 const ms = this;
1450 F.fetch("chat-query",{
1451 urlParams:{
1452 q: '',
1453 n: nFetch,
1454 i: iFirst
1455 },
1456 responseType: "json",
1457 onload:function(jx){
1458 if( bDown ) jx.msgs.reverse();
1459 jx.msgs.forEach((m) => {
1460 var mw = new Chat.MessageWidget(m);
1461 if( bDown ){
1462 /* Inject the message below this object's body, or
1463 append it to Chat.e.searchContent if this element
1464 is the final one in its parent (Chat.e.searchContent). */
1465 const eAnchor = e.body.nextElementSibling;
1466 if( eAnchor ) Chat.e.searchContent.insertBefore(mw.e.body, eAnchor);
1467 else D.append(Chat.e.searchContent, mw.e.body);
1468 }else{
1469 Chat.e.searchContent.insertBefore(mw.e.body, e.body);
1470 }
1471 });
1472 if( bDown ){
1473 o.iNextId -= jx.msgs.length;
1474 }else{
1475 o.iPrevId += jx.msgs.length;
1476 }
1477 ms.set_button_visibility();
1478 ms.bIgnoreClick = false;
1479 }
1480 });
1481 }
1482 };
1483
1484 return ctor;
1485 })() /*SearchCtxLoader*/;
1486
1487 const BlobXferState = (function(){
1488 /* State for paste and drag/drop */
1489 const bxs = {
1490 dropDetails: document.querySelector('#chat-drop-details'),
1491 blob: undefined,
@@ -1425,16 +1624,26 @@
1624
1625 /**
1626 Submits the contents of the message input field (if not empty)
1627 and/or the file attachment field to the server. If both are
1628 empty, this is a no-op.
1629
1630 If the current view is the history search, this instead sends the
1631 input text to that widget.
1632 */
1633 Chat.submitMessage = function f(){
1634 if(!f.spaces){
1635 f.spaces = /\s+$/;
1636 f.markdownContinuation = /\\\s+$/;
1637 f.spaces2 = /\s{3,}$/;
1638 }
1639 switch( this.e.currentView ){
1640 case this.e.viewSearch: this.submitSearch();
1641 return;
1642 case this.e.viewPreview: this.e.btnPreview.click();
1643 return;
1644 default: break;
1645 }
1646 this.setCurrentView(this.e.viewMessages);
1647 const fd = new FormData();
1648 const fallback = {msg: this.inputValue()};
1649 var msg = fallback.msg;
@@ -1507,14 +1716,16 @@
1716 //console.debug("Enter key event:", ctrlMode, ev.ctrlKey, ev.shiftKey, ev);
1717 if(ev.shiftKey){
1718 const compactMode = Chat.settings.getBool('edit-compact-mode', false);
1719 ev.preventDefault();
1720 ev.stopPropagation();
1721 /* Shift-enter will run preview mode UNLESS the input field is empty
1722 AND (preview or search mode) is active, in which cases it will
1723 switch back to message view. */
1724 if(!text &&
1725 (Chat.e.currentView===Chat.e.viewPreview
1726 | Chat.e.currentView===Chat.e.viewSearch)){
1727 Chat.setCurrentView(Chat.e.viewMessages);
1728 }else if(!text){
1729 f.$toggleCompact(compactMode);
1730 }else if(Chat.settings.getBool('edit-shift-enter-preview', true)){
1731 Chat.e.btnPreview.click();
@@ -1572,19 +1783,19 @@
1783 tall vs wide. Can be toggled via settings. */
1784 document.body.classList.add('my-messages-right');
1785 }
1786 const settingsButton = document.querySelector('#chat-button-settings');
1787 const optionsMenu = E1('#chat-config-options');
1788 const eToggleView = function(ev){
1789 ev.preventDefault();
1790 ev.stopPropagation();
1791 Chat.setCurrentView(Chat.e.currentView===Chat.e.viewConfig
1792 ? Chat.e.viewMessages : Chat.e.viewConfig);
1793 return false;
1794 };
1795 D.attr(settingsButton, 'role', 'button').addEventListener('click', eToggleView, false);
1796 Chat.e.viewConfig.querySelector('button.action-close').addEventListener('click', eToggleView, false);
1797
1798 /** Internal acrobatics to allow certain settings toggles to access
1799 related toggles. */
1800 const namedOptions = {
1801 activeUsers:{
@@ -1670,12 +1881,13 @@
1881 boolValue: 'edit-ctrl-send'
1882 },{
1883 label: "Compact mode",
1884 hint: [
1885 "Toggle between a space-saving or more spacious writing area. ",
1886 "When the input field has focus and is empty ",
1887 "then Shift-Enter may (depending on the current view) toggle this setting."
1888 ].join(''),
1889 boolValue: 'edit-compact-mode'
1890 },{
1891 label: "Use 'contenteditable' editing mode",
1892 boolValue: 'edit-widget-x',
1893 hint: [
@@ -1840,11 +2052,11 @@
2052 op.persistentSetting,
2053 function(setting){
2054 if(op.checkbox) op.checkbox.checked = !!setting.value;
2055 else if(op.select) op.select.value = setting.value;
2056 if(op.callback) op.callback(setting);
2057 }
2058 );
2059 if(op.checkbox){
2060 op.checkbox.addEventListener(
2061 'change', function(){
2062 Chat.settings.set(op.persistentSetting, op.checkbox.checked)
@@ -1916,11 +2128,11 @@
2128 s.value ? 'add' : 'remove'
2129 ]('compact');
2130 Chat.e.inputFields[Chat.e.inputFields.$currentIndex].focus();
2131 });
2132 Chat.settings.addListener('edit-ctrl-send',function(s){
2133 const label = (s.value ? "Ctrl-" : "")+"Enter submits message";
2134 Chat.e.inputFields.forEach((e)=>{
2135 const v = e.dataset.placeholder0 + " " +label;
2136 if(e.isContentEditable) e.dataset.placeholder = v;
2137 else D.attr(e,'placeholder',v);
2138 });
@@ -1947,11 +2159,11 @@
2159 this.setCurrentView(this.e.viewPreview);
2160 this.e.previewContent.innerHTML = t;
2161 this.e.viewPreview.querySelectorAll('a').forEach(addAnchorTargetBlank);
2162 this.inputFocus();
2163 };
2164 Chat.e.viewPreview.querySelector('button.action-close').
2165 addEventListener('click', ()=>Chat.setCurrentView(Chat.e.viewMessages), false);
2166 let previewPending = false;
2167 const elemsToEnable = [btnPreview, Chat.e.btnSubmit, Chat.e.inputFields];
2168 const submit = function(ev){
2169 ev.preventDefault();
@@ -1994,10 +2206,40 @@
2206 });
2207 return false;
2208 };
2209 btnPreview.addEventListener('click', submit, false);
2210 })()/*message preview setup*/;
2211
2212 (function(){/*Set up #chat-search and related bits */
2213 const btn = document.querySelector('#chat-button-search');
2214 D.attr(btn, 'role', 'button').addEventListener('click', function(ev){
2215 ev.preventDefault();
2216 ev.stopPropagation();
2217 const msg = Chat.inputValue();
2218 if( Chat.e.currentView===Chat.e.viewSearch ){
2219 if( msg ) Chat.submitSearch();
2220 else Chat.setCurrentView(Chat.e.viewMessages);
2221 }else{
2222 Chat.setCurrentView(Chat.e.viewSearch);
2223 if( msg ) Chat.submitSearch();
2224 }
2225 return false;
2226 }, false);
2227 Chat.e.viewSearch.querySelector('button.action-clear').addEventListener('click', function(ev){
2228 ev.preventDefault();
2229 ev.stopPropagation();
2230 Chat.clearSearch(true);
2231 Chat.setCurrentView(Chat.e.viewMessages);
2232 return false;
2233 }, false);
2234 Chat.e.viewSearch.querySelector('button.action-close').addEventListener('click', function(ev){
2235 ev.preventDefault();
2236 ev.stopPropagation();
2237 Chat.setCurrentView(Chat.e.viewMessages);
2238 return false;
2239 }, false);
2240 })()/*search view setup*/;
2241
2242 /** Callback for poll() to inject new content into the page. jx ==
2243 the response from /chat-poll. If atEnd is true, the message is
2244 appended to the end of the chat list (for loading older
2245 messages), else the beginning (the default). */
@@ -2126,10 +2368,82 @@
2368 btn.addEventListener('click',()=>loadOldMessages(-1));
2369 D.append(Chat.e.viewMessages, toolbar);
2370 toolbar.disabled = true /*will be enabled when msg load finishes */;
2371 })()/*end history loading widget setup*/;
2372
2373 /**
2374 Clears the search result view. If addInstructions is true it adds
2375 text to that view instructing the user to enter their query into
2376 the message-entry widget (noting that that widget has text
2377 implying that it's only for submitting a message, which isn't
2378 exactly true when the search view is active).
2379
2380 Returns the DOM element which wraps all of the chat search
2381 result elements.
2382 */
2383 Chat.clearSearch = function(addInstructions=false){
2384 const e = D.clearElement( this.e.searchContent );
2385 if(addInstructions){
2386 D.append(e, "Enter search terms in the message field. "+
2387 "Use #NNNNN to search for the message with ID NNNNN.");
2388 }
2389 return e;
2390 };
2391 Chat.clearSearch(true);
2392 /**
2393 Submits a history search using the main input field's current
2394 text. It is assumed that Chat.e.viewSearch===Chat.e.currentView.
2395 */
2396 Chat.submitSearch = function(){
2397 const term = this.inputValue(true);
2398 const eMsgTgt = this.clearSearch(true);
2399 if( !term ) return;
2400 D.append( eMsgTgt, "Searching for ",term," ...");
2401 const fd = new FormData();
2402 fd.set('q', term);
2403 F.fetch(
2404 "chat-query", {
2405 payload: fd,
2406 responseType: 'json',
2407 onerror:function(err){
2408 Chat.setCurrentView(Chat.e.viewMessages);
2409 Chat.reportErrorAsMessage(err);
2410 },
2411 onload:function(jx){
2412 let previd = 0;
2413 D.clearElement(eMsgTgt);
2414 jx.msgs.forEach((m)=>{
2415 m.isSearchResult = true;
2416 const mw = new Chat.MessageWidget(m);
2417 const spacer = new Chat.SearchCtxLoader({
2418 first: jx.first,
2419 last: jx.last,
2420 previd: previd,
2421 nextid: m.msgid
2422 });
2423 if( spacer.e ) D.append( eMsgTgt, spacer.e.body );
2424 D.append( eMsgTgt, mw.e.body );
2425 previd = m.msgid;
2426 });
2427 if( jx.msgs.length ){
2428 const spacer = new Chat.SearchCtxLoader({
2429 first: jx.first,
2430 last: jx.last,
2431 previd: previd,
2432 nextid: 0
2433 });
2434 if( spacer.e ) D.append( eMsgTgt, spacer.e.body );
2435 }else{
2436 D.append( D.clearElement(eMsgTgt),
2437 'No search results found for: ',
2438 term );
2439 }
2440 }
2441 }
2442 );
2443 }/*Chat.submitSearch()*/;
2444
2445 const afterFetch = function f(){
2446 if(true===f.isFirstCall){
2447 f.isFirstCall = false;
2448 Chat.ajaxEnd();
2449 Chat.e.viewMessages.classList.remove('loading');
@@ -2145,10 +2459,25 @@
2459 delete Chat.intervalTimer;
2460 }
2461 poll.running = false;
2462 };
2463 afterFetch.isFirstCall = true;
2464 /**
2465 FIXME: when polling fails because the remote server is
2466 reachable but it's not accepting HTTP requests, we should back
2467 off on polling for a while. e.g. if the remote web server process
2468 is killed, the poll fails quickly and immediately retries,
2469 hammering the remote server until the httpd is back up. That
2470 happens often during development of this application.
2471
2472 XHR does not offer a direct way of distinguishing between
2473 HTTP/connection errors, but we can hypothetically use the
2474 xhrRequest.status value to do so, with status==0 being a
2475 connection error. We do not currently have a clean way of passing
2476 that info back to the fossil.fetch() client, so we'll need to
2477 hammer on that API a bit to get this working.
2478 */
2479 const poll = async function f(){
2480 if(f.running) return;
2481 f.running = true;
2482 Chat._isBatchLoading = f.isFirstCall;
2483 if(true===f.isFirstCall){
@@ -2187,17 +2516,11 @@
2516 Chat._gotServerError = poll.running = false;
2517 if( window.fossil.config.chat.fromcli ){
2518 Chat.chatOnlyMode(true);
2519 }
2520 Chat.intervalTimer = setInterval(poll, 1000);
 
 
 
 
 
 
2521 delete ForceResizeKludge.$disabled;
2522 ForceResizeKludge();
2523 Chat.animate.$disabled = false;
2524 setTimeout( ()=>Chat.inputFocus(), 0 );
2525 F.page.chat = Chat/* enables testing the APIs via the dev tools */;
2526 });
2527
+47 -19
--- src/search.c
+++ src/search.c
@@ -975,10 +975,33 @@
975975
}
976976
#else
977977
sqlite3_result_double(context, r);
978978
#endif
979979
}
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
+}
9801003
9811004
/*
9821005
** When this routine is called, there already exists a table
9831006
**
9841007
** x(label,url,score,id,snip).
@@ -997,25 +1020,16 @@
9971020
LOCAL void search_indexed(
9981021
const char *zPattern, /* The query pattern */
9991022
unsigned int srchFlags /* What to search over */
10001023
){
10011024
Blob sql;
1002
- char *zPat = mprintf("%s",zPattern);
1003
- int i;
1025
+ char *zPat;
10041026
static const char *zSnippetCall;
10051027
if( srchFlags==0 ) return;
10061028
sqlite3_create_function(g.db, "rank", 1, SQLITE_UTF8|SQLITE_INNOCUOUS, 0,
10071029
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);
10171031
blob_init(&sql, 0, 0);
10181032
if( search_index_type(0)==4 ){
10191033
/* If this repo is still using the legacy FTS4 search index, then
10201034
** the snippet() function is slightly different */
10211035
zSnippetCall = "snippet(ftsidx,'<mark>','</mark>',' ... ',-1,35)";
@@ -1637,10 +1651,11 @@
16371651
;
16381652
static const char zFtsDrop[] =
16391653
@ DROP TABLE IF EXISTS repository.ftsidx;
16401654
@ DROP VIEW IF EXISTS repository.ftscontent;
16411655
@ DROP TABLE IF EXISTS repository.ftsdocs;
1656
+@ DROP TABLE IF EXISTS repository.chatfts1;
16421657
;
16431658
16441659
#if INTERFACE
16451660
/*
16461661
** Values for the search-tokenizer config option.
@@ -1681,10 +1696,25 @@
16811696
iFtsTokenizer = is_truth(z) ? FTS5TOK_PORTER : FTS5TOK_NONE;
16821697
}
16831698
fossil_free(z);
16841699
return iFtsTokenizer;
16851700
}
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
+}
16861716
16871717
/*
16881718
** Returns a string value suitable for use as the search-tokenizer
16891719
** setting's value, depending on the value of z. If z is 0 then the
16901720
** current search-tokenizer value is used as the basis for formulating
@@ -1726,18 +1756,13 @@
17261756
/*
17271757
** Create or drop the tables associated with a full-text index.
17281758
*/
17291759
static int searchIdxExists = -1;
17301760
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 );
17391764
search_sql_setup(g.db);
17401765
db_multi_exec(zFtsSchema/*works-like:"%s"*/, zExtra/*safe-for-%s*/);
17411766
searchIdxExists = 1;
17421767
}
17431768
void search_drop_index(void){
@@ -2057,10 +2082,13 @@
20572082
fossil_print("rebuilding the search index...");
20582083
fflush(stdout);
20592084
search_create_index();
20602085
search_fill_index();
20612086
search_update_index(search_restrict(SRCH_ALL));
2087
+ if( db_table_exists("repository","chat") ){
2088
+ chat_rebuild_index(1);
2089
+ }
20622090
fossil_print(" done\n");
20632091
}
20642092
20652093
/*
20662094
** COMMAND: fts-config*
20672095
--- 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 @@
419419
** or after a change to the stylesheet.
420420
*/
421421
static void stylesheet_url_var(void){
422422
char *zBuiltin; /* Auxiliary page-specific CSS page */
423423
Blob url; /* The URL */
424
+ const char * zPage = local_zCurrentPage ? local_zCurrentPage : g.zPath;
424425
425426
/* Initialize the URL to its baseline */
426427
url = empty_blob;
427428
blob_appendf(&url, "%R/style.css");
428429
@@ -438,13 +439,13 @@
438439
**
439440
** The /style.css page (implemented below) will detect this extra "wikiedit"
440441
** path information and include the page-specific CSS along with the
441442
** default CSS when it delivers the page.
442443
*/
443
- zBuiltin = mprintf("style.%s.css", g.zPath);
444
+ zBuiltin = mprintf("style.%s.css", zPage);
444445
if( builtin_file(zBuiltin,0)!=0 ){
445
- blob_appendf(&url, "/%s", g.zPath);
446
+ blob_appendf(&url, "/%s", zPage);
446447
}
447448
fossil_free(zBuiltin);
448449
449450
/* Add query parameters that will change whenever the skin changes
450451
** or after any updates to the CSS files
451452
--- 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
+50 -25
--- src/style.chat.css
+++ src/style.chat.css
@@ -68,11 +68,11 @@
6868
content placed below this. */
6969
border-bottom: 1px transparent;
7070
}
7171
body.chat.monospace-messages .message-widget-content,
7272
body.chat.monospace-messages .chat-input-field{
73
- font-family: monospace;
73
+ font-family: monospace;
7474
}
7575
body.chat .message-widget-content > * {
7676
margin: 0;
7777
padding: 0;
7878
}
@@ -115,16 +115,37 @@
115115
white-space: nowrap;
116116
}
117117
body.chat .fossil-tooltip.help-buttonlet-content {
118118
font-size: 80%;
119119
}
120
+
121
+body.chat .message-widget .message-widget-tab {
122
+ /* Element which renders the main metadata for a given message. */
123
+}
120124
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 */
123126
font-style: italic;
124127
font-weight: bold;
125128
}
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
+
126147
/* The popup element for displaying message timestamps
127148
and deletion controls. */
128149
body.chat .chat-message-popup {
129150
font-family: monospace;
130151
font-size: 0.9em;
@@ -182,20 +203,10 @@
182203
body.chat.chat-only-mode{
183204
padding: 0;
184205
margin: 0 auto;
185206
}
186207
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
-}
197208
198209
/** Container for the list of /chat messages. */
199210
body.chat #chat-messages-wrapper {
200211
overflow: auto;
201212
padding: 0 0.25em;
@@ -346,18 +357,18 @@
346357
body.chat #chat-buttons-wrapper > .cbutton:hover {
347358
background-color: rgba(200,200,200,0.3);
348359
}
349360
body.chat #chat-input-line-wrapper.compact #chat-buttons-wrapper > .cbutton {
350361
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;
353364
min-height: 2.3ex;
354365
max-height: 2.3ex;
355366
font-size: 120%;
356367
}
357368
body.chat #chat-input-line-wrapper.compact #chat-buttons-wrapper #chat-button-submit {
358
- min-width: 12ex;
369
+ min-width: 10ex;
359370
}
360371
.chat-input-field {
361372
font-family: inherit
362373
}
363374
body.chat #chat-input-line-wrapper:not(.compact) #chat-input-field-multi,
@@ -440,10 +451,11 @@
440451
/*ensure that these grow more than the non-.chat-view elements.
441452
Note that setting flex shrink to 0 breaks/disables scrolling!*/;
442453
margin-bottom: 0.2em;
443454
}
444455
body.chat #chat-config,
456
+body.chat #chat-search,
445457
body.chat #chat-preview {
446458
/* /chat configuration widget */
447459
display: flex;
448460
flex-direction: column;
449461
overflow: auto;
@@ -518,31 +530,38 @@
518530
display: inline-block;
519531
opacity: 0.85;
520532
}
521533
body.chat #chat-config #chat-config-options .menu-entry select {
522534
}
523
-body.chat #chat-preview #chat-preview-content {
535
+body.chat #chat-preview #chat-preview-content,
536
+body.chat #chat-search #chat-search-content {
524537
overflow: auto;
525538
flex: 1 1 auto;
526539
padding: 0.5em;
527540
border: 1px dotted;
528541
}
542
+
529543
body.chat #chat-preview #chat-preview-content > * {
530544
margin: 0;
531545
padding: 0;
532546
}
533
-body.chat #chat-preview #chat-preview-buttons {
547
+body.chat .chat-view .button-bar {
534548
flex: 0 1 auto;
535549
display: flex;
536550
flex-direction: column;
537551
}
538
-body.chat #chat-config > button,
539
-body.chat #chat-preview #chat-preview-buttons > button {
552
+body.chat .chat-view .button-bar button {
540553
padding: 0.5em;
541
- flex: 0 1 auto;
554
+ flex: 1 1 auto;
542555
margin: 0.25em 0;
543556
}
557
+
558
+body.chat #chat-search .button-bar {
559
+ flex: 0 1 auto;
560
+ display: flex;
561
+ flex-direction: row;
562
+}
544563
545564
body.chat #chat-user-list-wrapper {
546565
/* Safari can't do fieldsets right, so we emulate one. */
547566
border-radius: 0.5em;
548567
margin: 1em 0 0.2em 0;
@@ -605,14 +624,19 @@
605624
body.chat #chat-user-list .chat-user.selected {
606625
font-weight: bold;
607626
text-decoration: underline;
608627
}
609628
610
-body.chat.fossil-dark-style #chat-button-attach > svg {
611
- /* The black paperclip is barely visible in dark-mode
612
- skins when they have dark buttons */
613
- filter: invert(0.8);
629
+body.chat .searchForm {
630
+ margin-top: 1em;
631
+}
632
+body.chat .spacer-widget button {
633
+ margin-left: 1ex;
634
+ margin-right: 1ex;
635
+ display: block;
636
+ margin-top: 0.5em;
637
+ margin-bottom: 0.5em;
614638
}
615639
616640
body.chat .anim-rotate-360 {
617641
animation: rotate-360 750ms linear;
618642
}
@@ -649,5 +673,6 @@
649673
}
650674
@keyframes fade-out {
651675
from { opacity: 1; }
652676
to { opacity: 0; }
653677
}
678
+
654679
--- 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;
@@ -605,14 +624,19 @@
605 body.chat #chat-user-list .chat-user.selected {
606 font-weight: bold;
607 text-decoration: underline;
608 }
609
610 body.chat.fossil-dark-style #chat-button-attach > svg {
611 /* The black paperclip is barely visible in dark-mode
612 skins when they have dark buttons */
613 filter: invert(0.8);
 
 
 
 
 
614 }
615
616 body.chat .anim-rotate-360 {
617 animation: rotate-360 750ms linear;
618 }
@@ -649,5 +673,6 @@
649 }
650 @keyframes fade-out {
651 from { opacity: 1; }
652 to { opacity: 0; }
653 }
 
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
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;
@@ -605,14 +624,19 @@
624 body.chat #chat-user-list .chat-user.selected {
625 font-weight: bold;
626 text-decoration: underline;
627 }
628
629 body.chat .searchForm {
630 margin-top: 1em;
631 }
632 body.chat .spacer-widget button {
633 margin-left: 1ex;
634 margin-right: 1ex;
635 display: block;
636 margin-top: 0.5em;
637 margin-bottom: 0.5em;
638 }
639
640 body.chat .anim-rotate-360 {
641 animation: rotate-360 750ms linear;
642 }
@@ -649,5 +673,6 @@
673 }
674 @keyframes fade-out {
675 from { opacity: 1; }
676 to { opacity: 0; }
677 }
678
679
--- www/changes.wiki
+++ www/changes.wiki
@@ -10,12 +10,13 @@
1010
* Change the name "fossil cherry-pick" command to "fossil cherrypick",
1111
which is more familiar to Git users. Retain the legacy name for
1212
compatibility.
1313
* Add new query parameters to the [/help?cmd=/timeline|/timeline page]:
1414
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.
1616
* Add ability to upload unversioned files via the [/help?cmd=/uvlist|/uvlist page].
17
+ * Add history search to the [/help?cmd=/chat|/chat page].
1718
1819
1920
<h2 id='v2_24'>Changes for version 2.24 (2024-04-23)</h2>
2021
2122
* Apache change work-around &rarr; As part of a security fix, the Apache webserver
@@ -34,11 +35,11 @@
3435
offset command examples, etc. Adjusted colors slightly to bring
3536
things into better accord with the WCAG accessibility guidelines.
3637
This constitutes a <strong>breaking change</strong> for those with
3738
custom skins; see [./customskin.md#version-2.24 | this section of
3839
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
4041
[/skins|select their preferred skin]. This preference is stored in
4142
the [/fdscookie|fossil display_settings cookie].
4243
<li> The /setup_skin_admin page is simplified to let administrators easily
4344
select one of the built-in skins as a default, or to specify a
4445
custom skin.
4546
--- 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 &rarr; 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 &rarr; 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

Keyboard Shortcuts

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