Fossil SCM

Merge trunk into the markdown-tagrefs branch to begin experimentation with tying chat #NNN references into the new search capabilities.

stephan 2024-07-03 12:55 markdown-tagrefs merge
Commit 5e26fd4c10af87762fbbf9ae74e97401ec5886012fb3e3a55a393016bfbc703b
+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>
@@ -234,17 +219,25 @@
234219
@ <div id='chat-user-list'></div>
235220
@ </div>
236221
@ <button id='chat-clear-filter' class='hidden'>Clear filter</button>
237222
@ <div id='chat-preview' class='hidden chat-view'>
238223
@ <header>Preview: (<a href='%R/md_rules' target='_blank'>markdown reference</a>)</header>
239
- @ <div id='chat-preview-content' class='message-widget-content'></div>
240
- @ <div id='chat-preview-buttons'><button id='chat-preview-close'>Close Preview</button></div>
224
+ @ <div id='chat-preview-content'></div>
225
+ @ <div class='button-bar'><button class='action-close'>Close Preview</button></div>
241226
@ </div>
242227
@ <div id='chat-config' class='hidden chat-view'>
243228
@ <div id='chat-config-options'></div>
244229
/* ^^^populated client-side */
245
- @ <button>Close Settings</button>
230
+ @ <div class='button-bar'><button class='action-close'>Close Settings</button></div>
231
+ @ </div>
232
+ @ <div id='chat-search' class='hidden chat-view'>
233
+ @ <div id='chat-search-content'></div>
234
+ /* ^^^populated client-side */
235
+ @ <div class='button-bar'>
236
+ @ <button class='action-clear'>Clear results</button>
237
+ @ <button class='action-close'>Close Search</button>
238
+ @ </div>
246239
@ </div>
247240
@ <div id='chat-messages-wrapper' class='chat-view'>
248241
/* New chat messages get inserted immediately after this element */
249242
@ <span id='message-inject-point'></span>
250243
@ </div>
@@ -272,11 +265,12 @@
272265
@ </script>
273266
builtin_request_js("fossil.page.chat.js");
274267
style_finish_page();
275268
}
276269
277
-/* Definition of repository tables used by chat
270
+/*
271
+** Definition of repository tables used by chat
278272
*/
279273
static const char zChatSchema1[] =
280274
@ CREATE TABLE repository.chat(
281275
@ msgid INTEGER PRIMARY KEY AUTOINCREMENT,
282276
@ mtime JULIANDAY, -- Time for this entry - Julianday Zulu
@@ -290,12 +284,42 @@
290284
@ );
291285
;
292286
293287
294288
/*
295
-** Make sure the repository data tables used by chat exist. Create them
296
-** if they do not.
289
+** Create or rebuild the /chat search index. Requires that the
290
+** repository.chat table exists. If bForce is true, it will drop the
291
+** chatfts1 table and recreate/reindex it. If bForce is 0, it will
292
+** only index the chat content if the chatfts1 table does not already
293
+** exist.
294
+*/
295
+void chat_rebuild_index(int bForce){
296
+ if( bForce!=0 ){
297
+ db_multi_exec("DROP TABLE IF EXISTS chatfts1");
298
+ }
299
+ if( bForce!=0 || !db_table_exists("repository", "chatfts1") ){
300
+ const int tokType = search_tokenizer_type(0);
301
+ const char *zTokenizer = search_tokenize_arg_for_type(
302
+ tokType==FTS5TOK_NONE ? FTS5TOK_PORTER : tokType
303
+ /* Special case: if fts search is disabled for the main repo
304
+ ** content, use a default tokenizer here. */
305
+ );
306
+ assert( zTokenizer && zTokenizer[0] );
307
+ db_multi_exec(
308
+ "CREATE VIRTUAL TABLE repository.chatfts1 USING fts5("
309
+ " xmsg, content=chat, content_rowid=msgid%s"
310
+ ");"
311
+ "INSERT INTO repository.chatfts1(chatfts1) VALUES('rebuild');",
312
+ zTokenizer/*safe-for-%s*/
313
+ );
314
+ }
315
+}
316
+
317
+/*
318
+** Make sure the repository data tables used by chat exist. Create
319
+** them if they do not. Set up TEMP triggers (if needed) to update the
320
+** chatfts1 table as the chat table is updated.
297321
*/
298322
static void chat_create_tables(void){
299323
if( !db_table_exists("repository","chat") ){
300324
db_multi_exec(zChatSchema1/*works-like:""*/);
301325
}else if( !db_table_has_column("repository","chat","lmtime") ){
@@ -302,10 +326,20 @@
302326
if( !db_table_has_column("repository","chat","mdel") ){
303327
db_multi_exec("ALTER TABLE chat ADD COLUMN mdel INT");
304328
}
305329
db_multi_exec("ALTER TABLE chat ADD COLUMN lmtime TEXT");
306330
}
331
+ chat_rebuild_index(0);
332
+ db_multi_exec(
333
+ "CREATE TEMP TRIGGER IF NOT EXISTS chat_ai AFTER INSERT ON chat BEGIN "
334
+ " INSERT INTO chatfts1(rowid, xmsg) VALUES(new.msgid, new.xmsg);"
335
+ "END;"
336
+ "CREATE TEMP TRIGGER IF NOT EXISTS chat_ad AFTER DELETE ON chat BEGIN "
337
+ " INSERT INTO chatfts1(chatfts1, rowid, xmsg) "
338
+ " VALUES('delete', old.msgid, old.xmsg);"
339
+ "END;"
340
+ );
307341
}
308342
309343
/*
310344
** Delete old content from the chat table.
311345
*/
@@ -453,28 +487,101 @@
453487
}
454488
455489
/*
456490
** COMMAND: test-chat-formatter
457491
**
458
-** Usage: %fossil test-chat-formatter STRING ...
492
+** Usage: %fossil test-chat-formatter ?OPTIONS? STRING ...
459493
**
460494
** Transform each argument string into HTML that will display the
461495
** chat message. This is used to test the formatter and to verify
462496
** that a malicious message text will not cause HTML or JS injection
463497
** into the chat display in a browser.
498
+**
499
+** Options:
500
+**
501
+** -w|--wiki Assume fossil wiki format instead of markdown
464502
*/
465503
void chat_test_formatter_cmd(void){
466504
int i;
467505
char *zOut;
506
+ int const isWiki = find_option("w","wiki",0)!=0;
468507
db_find_and_open_repository(0,0);
469508
g.perm.Hyperlink = 1;
470
- for(i=0; i<g.argc; i++){
471
- zOut = chat_format_to_html(g.argv[i], 0);
472
- fossil_print("[%d]: %s\n", i, zOut);
509
+ for(i=2; i<g.argc; i++){
510
+ zOut = chat_format_to_html(g.argv[i], isWiki);
511
+ fossil_print("[%d]: %s\n", i-1, zOut);
473512
fossil_free(zOut);
474513
}
475514
}
515
+
516
+/*
517
+**
518
+*/
519
+static int chat_poll_rowstojson(
520
+ Stmt *p, /* Statement to read rows from */
521
+ const char *zChatUser, /* Current user */
522
+ int bRaw, /* True to return raw format xmsg */
523
+ Blob *pJson /* Append json array entries here */
524
+){
525
+ int cnt = 0;
526
+ while( db_step(p)==SQLITE_ROW ){
527
+ int isWiki = 0; /* True if chat message is x-fossil-wiki */
528
+ int id = db_column_int(p, 0);
529
+ const char *zDate = db_column_text(p, 1);
530
+ const char *zFrom = db_column_text(p, 2);
531
+ const char *zRawMsg = db_column_text(p, 3);
532
+ int nByte = db_column_int(p, 4);
533
+ const char *zFName = db_column_text(p, 5);
534
+ const char *zFMime = db_column_text(p, 6);
535
+ int iToDel = db_column_int(p, 7);
536
+ const char *zLMtime = db_column_text(p, 8);
537
+ char *zMsg;
538
+ if(cnt++){
539
+ blob_append(pJson, ",\n", 2);
540
+ }
541
+ blob_appendf(pJson, "{\"msgid\":%d,", id);
542
+ blob_appendf(pJson, "\"mtime\":\"%.10sT%sZ\",", zDate, zDate+11);
543
+ if( zLMtime && zLMtime[0] ){
544
+ blob_appendf(pJson, "\"lmtime\":%!j,", zLMtime);
545
+ }
546
+ blob_append(pJson, "\"xfrom\":", -1);
547
+ if(zFrom){
548
+ blob_appendf(pJson, "%!j,", zFrom);
549
+ isWiki = fossil_strcmp(zFrom,zChatUser)==0;
550
+ }else{
551
+ /* see https://fossil-scm.org/forum/forumpost/e0be0eeb4c */
552
+ blob_appendf(pJson, "null,");
553
+ isWiki = 0;
554
+ }
555
+ blob_appendf(pJson, "\"uclr\":%!j,",
556
+ isWiki ? "transparent" : user_color(zFrom ? zFrom : "nobody"));
557
+
558
+ if(bRaw){
559
+ blob_appendf(pJson, "\"xmsg\":%!j,", zRawMsg);
560
+ }else{
561
+ zMsg = chat_format_to_html(zRawMsg ? zRawMsg : "", isWiki);
562
+ blob_appendf(pJson, "\"xmsg\":%!j,", zMsg);
563
+ fossil_free(zMsg);
564
+ }
565
+
566
+ if( nByte==0 ){
567
+ blob_appendf(pJson, "\"fsize\":0");
568
+ }else{
569
+ blob_appendf(pJson, "\"fsize\":%d,\"fname\":%!j,\"fmime\":%!j",
570
+ nByte, zFName, zFMime);
571
+ }
572
+
573
+ if( iToDel ){
574
+ blob_appendf(pJson, ",\"mdel\":%d}", iToDel);
575
+ }else{
576
+ blob_append(pJson, "}", 1);
577
+ }
578
+ }
579
+ db_reset(p);
580
+
581
+ return cnt;
582
+}
476583
477584
/*
478585
** WEBPAGE: chat-poll hidden loadavg-exempt
479586
**
480587
** The chat page generated by /chat using an XHR to this page to
@@ -570,11 +677,10 @@
570677
Blob json; /* The json to be constructed and returned */
571678
sqlite3_int64 dataVersion; /* Data version. Used for polling. */
572679
const int iDelay = 1000; /* Delay until next poll (milliseconds) */
573680
int nDelay; /* Maximum delay.*/
574681
const char *zChatUser; /* chat-timeline-user */
575
- int isWiki = 0; /* True if chat message is x-fossil-wiki */
576682
int msgid = atoi(PD("name","0"));
577683
const int msgBefore = atoi(PD("before","0"));
578684
int nLimit = msgBefore>0 ? atoi(PD("n","0")) : 0;
579685
const int bRaw = P("raw")!=0;
580686
@@ -624,64 +730,11 @@
624730
}
625731
db_prepare(&q1, "%s", blob_sql_text(&sql));
626732
blob_reset(&sql);
627733
blob_init(&json, "{\"msgs\":[\n", -1);
628734
while( nDelay>0 ){
629
- int cnt = 0;
630
- while( db_step(&q1)==SQLITE_ROW ){
631
- int id = db_column_int(&q1, 0);
632
- const char *zDate = db_column_text(&q1, 1);
633
- const char *zFrom = db_column_text(&q1, 2);
634
- const char *zRawMsg = db_column_text(&q1, 3);
635
- int nByte = db_column_int(&q1, 4);
636
- const char *zFName = db_column_text(&q1, 5);
637
- const char *zFMime = db_column_text(&q1, 6);
638
- int iToDel = db_column_int(&q1, 7);
639
- const char *zLMtime = db_column_text(&q1, 8);
640
- char *zMsg;
641
- if(cnt++){
642
- blob_append(&json, ",\n", 2);
643
- }
644
- blob_appendf(&json, "{\"msgid\":%d,", id);
645
- blob_appendf(&json, "\"mtime\":\"%.10sT%sZ\",", zDate, zDate+11);
646
- if( zLMtime && zLMtime[0] ){
647
- blob_appendf(&json, "\"lmtime\":%!j,", zLMtime);
648
- }
649
- blob_append(&json, "\"xfrom\":", -1);
650
- if(zFrom){
651
- blob_appendf(&json, "%!j,", zFrom);
652
- isWiki = fossil_strcmp(zFrom,zChatUser)==0;
653
- }else{
654
- /* see https://fossil-scm.org/forum/forumpost/e0be0eeb4c */
655
- blob_appendf(&json, "null,");
656
- isWiki = 0;
657
- }
658
- blob_appendf(&json, "\"uclr\":%!j,",
659
- isWiki ? "transparent" : user_color(zFrom ? zFrom : "nobody"));
660
-
661
- if(bRaw){
662
- blob_appendf(&json, "\"xmsg\":%!j,", zRawMsg);
663
- }else{
664
- zMsg = chat_format_to_html(zRawMsg ? zRawMsg : "", isWiki);
665
- blob_appendf(&json, "\"xmsg\":%!j,", zMsg);
666
- fossil_free(zMsg);
667
- }
668
-
669
- if( nByte==0 ){
670
- blob_appendf(&json, "\"fsize\":0");
671
- }else{
672
- blob_appendf(&json, "\"fsize\":%d,\"fname\":%!j,\"fmime\":%!j",
673
- nByte, zFName, zFMime);
674
- }
675
-
676
- if( iToDel ){
677
- blob_appendf(&json, ",\"mdel\":%d}", iToDel);
678
- }else{
679
- blob_append(&json, "}", 1);
680
- }
681
- }
682
- db_reset(&q1);
735
+ int cnt = chat_poll_rowstojson(&q1, zChatUser, bRaw, &json);
683736
if( cnt || msgBefore>0 ){
684737
break;
685738
}
686739
sqlite3_sleep(iDelay); nDelay--;
687740
while( nDelay>0 ){
@@ -697,10 +750,77 @@
697750
blob_append(&json, "\n]}", 3);
698751
cgi_set_content(&json);
699752
return;
700753
}
701754
755
+
756
+/*
757
+** WEBPAGE: chat-query hidden loadavg-exempt
758
+*/
759
+void chat_query_webpage(void){
760
+ Blob json; /* The json to be constructed and returned */
761
+ Blob sql = empty_blob;
762
+ Stmt q1;
763
+ int nLimit = atoi(PD("n","500"));
764
+ int iFirst = atoi(PD("i","0"));
765
+ const char *zQuery = PD("q", "");
766
+ i64 iMin = 0;
767
+ i64 iMax = 0;
768
+
769
+ login_check_credentials();
770
+ if( !g.perm.Chat ) {
771
+ chat_emit_permissions_error(1);
772
+ return;
773
+ }
774
+ chat_create_tables();
775
+ cgi_set_content_type("application/json");
776
+
777
+ if( zQuery[0] ){
778
+ iMax = db_int64(0, "SELECT max(msgid) FROM chat");
779
+ iMin = db_int64(0, "SELECT min(msgid) FROM chat");
780
+ if( '#'==zQuery[0] ){
781
+ /* Assume we're looking for an exact msgid match. */
782
+ ++zQuery;
783
+ blob_append_sql(&sql,
784
+ "SELECT msgid, datetime(mtime), xfrom, "
785
+ " xmsg, octet_length(file), fname, fmime, mdel, lmtime "
786
+ " FROM chat WHERE msgid=+%Q",
787
+ zQuery
788
+ );
789
+ }else{
790
+ char * zPat = search_simplify_pattern(zQuery);
791
+ blob_append_sql(&sql,
792
+ "SELECT * FROM ("
793
+ "SELECT c.msgid, datetime(c.mtime), c.xfrom, "
794
+ " highlight(chatfts1, 0, '<span class=\"match\">', '</span>'), "
795
+ " octet_length(c.file), c.fname, c.fmime, c.mdel, c.lmtime "
796
+ " FROM chatfts1(%Q) f, chat c "
797
+ " WHERE f.rowid=c.msgid"
798
+ " ORDER BY f.rowid DESC LIMIT %d"
799
+ ") ORDER BY 1 ASC", zPat, nLimit
800
+ );
801
+ fossil_free(zPat);
802
+ }
803
+ }else{
804
+ blob_append_sql(&sql,
805
+ "SELECT msgid, datetime(mtime), xfrom, "
806
+ " xmsg, octet_length(file), fname, fmime, mdel, lmtime"
807
+ " FROM chat WHERE msgid>=%d LIMIT %d",
808
+ iFirst, nLimit
809
+ );
810
+ }
811
+
812
+ db_prepare(&q1, "%s", blob_sql_text(&sql));
813
+ blob_reset(&sql);
814
+ blob_init(&json, "{\"msgs\":[\n", -1);
815
+ chat_poll_rowstojson(&q1, "", 0, &json);
816
+ db_finalize(&q1);
817
+ blob_appendf(&json, "\n], \"first\":%lld, \"last\":%lld}", iMin, iMax);
818
+ cgi_set_content(&json);
819
+ return;
820
+}
821
+
702822
/*
703823
** WEBPAGE: chat-fetch-one hidden loadavg-exempt
704824
**
705825
** /chat-fetch-one/N
706826
**
707827
--- 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>
@@ -234,17 +219,25 @@
234 @ <div id='chat-user-list'></div>
235 @ </div>
236 @ <button id='chat-clear-filter' class='hidden'>Clear filter</button>
237 @ <div id='chat-preview' class='hidden chat-view'>
238 @ <header>Preview: (<a href='%R/md_rules' target='_blank'>markdown reference</a>)</header>
239 @ <div id='chat-preview-content' class='message-widget-content'></div>
240 @ <div id='chat-preview-buttons'><button id='chat-preview-close'>Close Preview</button></div>
241 @ </div>
242 @ <div id='chat-config' class='hidden chat-view'>
243 @ <div id='chat-config-options'></div>
244 /* ^^^populated client-side */
245 @ <button>Close Settings</button>
 
 
 
 
 
 
 
 
246 @ </div>
247 @ <div id='chat-messages-wrapper' class='chat-view'>
248 /* New chat messages get inserted immediately after this element */
249 @ <span id='message-inject-point'></span>
250 @ </div>
@@ -272,11 +265,12 @@
272 @ </script>
273 builtin_request_js("fossil.page.chat.js");
274 style_finish_page();
275 }
276
277 /* Definition of repository tables used by chat
 
278 */
279 static const char zChatSchema1[] =
280 @ CREATE TABLE repository.chat(
281 @ msgid INTEGER PRIMARY KEY AUTOINCREMENT,
282 @ mtime JULIANDAY, -- Time for this entry - Julianday Zulu
@@ -290,12 +284,42 @@
290 @ );
291 ;
292
293
294 /*
295 ** Make sure the repository data tables used by chat exist. Create them
296 ** if they do not.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
297 */
298 static void chat_create_tables(void){
299 if( !db_table_exists("repository","chat") ){
300 db_multi_exec(zChatSchema1/*works-like:""*/);
301 }else if( !db_table_has_column("repository","chat","lmtime") ){
@@ -302,10 +326,20 @@
302 if( !db_table_has_column("repository","chat","mdel") ){
303 db_multi_exec("ALTER TABLE chat ADD COLUMN mdel INT");
304 }
305 db_multi_exec("ALTER TABLE chat ADD COLUMN lmtime TEXT");
306 }
 
 
 
 
 
 
 
 
 
 
307 }
308
309 /*
310 ** Delete old content from the chat table.
311 */
@@ -453,28 +487,101 @@
453 }
454
455 /*
456 ** COMMAND: test-chat-formatter
457 **
458 ** Usage: %fossil test-chat-formatter STRING ...
459 **
460 ** Transform each argument string into HTML that will display the
461 ** chat message. This is used to test the formatter and to verify
462 ** that a malicious message text will not cause HTML or JS injection
463 ** into the chat display in a browser.
 
 
 
 
464 */
465 void chat_test_formatter_cmd(void){
466 int i;
467 char *zOut;
 
468 db_find_and_open_repository(0,0);
469 g.perm.Hyperlink = 1;
470 for(i=0; i<g.argc; i++){
471 zOut = chat_format_to_html(g.argv[i], 0);
472 fossil_print("[%d]: %s\n", i, zOut);
473 fossil_free(zOut);
474 }
475 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
476
477 /*
478 ** WEBPAGE: chat-poll hidden loadavg-exempt
479 **
480 ** The chat page generated by /chat using an XHR to this page to
@@ -570,11 +677,10 @@
570 Blob json; /* The json to be constructed and returned */
571 sqlite3_int64 dataVersion; /* Data version. Used for polling. */
572 const int iDelay = 1000; /* Delay until next poll (milliseconds) */
573 int nDelay; /* Maximum delay.*/
574 const char *zChatUser; /* chat-timeline-user */
575 int isWiki = 0; /* True if chat message is x-fossil-wiki */
576 int msgid = atoi(PD("name","0"));
577 const int msgBefore = atoi(PD("before","0"));
578 int nLimit = msgBefore>0 ? atoi(PD("n","0")) : 0;
579 const int bRaw = P("raw")!=0;
580
@@ -624,64 +730,11 @@
624 }
625 db_prepare(&q1, "%s", blob_sql_text(&sql));
626 blob_reset(&sql);
627 blob_init(&json, "{\"msgs\":[\n", -1);
628 while( nDelay>0 ){
629 int cnt = 0;
630 while( db_step(&q1)==SQLITE_ROW ){
631 int id = db_column_int(&q1, 0);
632 const char *zDate = db_column_text(&q1, 1);
633 const char *zFrom = db_column_text(&q1, 2);
634 const char *zRawMsg = db_column_text(&q1, 3);
635 int nByte = db_column_int(&q1, 4);
636 const char *zFName = db_column_text(&q1, 5);
637 const char *zFMime = db_column_text(&q1, 6);
638 int iToDel = db_column_int(&q1, 7);
639 const char *zLMtime = db_column_text(&q1, 8);
640 char *zMsg;
641 if(cnt++){
642 blob_append(&json, ",\n", 2);
643 }
644 blob_appendf(&json, "{\"msgid\":%d,", id);
645 blob_appendf(&json, "\"mtime\":\"%.10sT%sZ\",", zDate, zDate+11);
646 if( zLMtime && zLMtime[0] ){
647 blob_appendf(&json, "\"lmtime\":%!j,", zLMtime);
648 }
649 blob_append(&json, "\"xfrom\":", -1);
650 if(zFrom){
651 blob_appendf(&json, "%!j,", zFrom);
652 isWiki = fossil_strcmp(zFrom,zChatUser)==0;
653 }else{
654 /* see https://fossil-scm.org/forum/forumpost/e0be0eeb4c */
655 blob_appendf(&json, "null,");
656 isWiki = 0;
657 }
658 blob_appendf(&json, "\"uclr\":%!j,",
659 isWiki ? "transparent" : user_color(zFrom ? zFrom : "nobody"));
660
661 if(bRaw){
662 blob_appendf(&json, "\"xmsg\":%!j,", zRawMsg);
663 }else{
664 zMsg = chat_format_to_html(zRawMsg ? zRawMsg : "", isWiki);
665 blob_appendf(&json, "\"xmsg\":%!j,", zMsg);
666 fossil_free(zMsg);
667 }
668
669 if( nByte==0 ){
670 blob_appendf(&json, "\"fsize\":0");
671 }else{
672 blob_appendf(&json, "\"fsize\":%d,\"fname\":%!j,\"fmime\":%!j",
673 nByte, zFName, zFMime);
674 }
675
676 if( iToDel ){
677 blob_appendf(&json, ",\"mdel\":%d}", iToDel);
678 }else{
679 blob_append(&json, "}", 1);
680 }
681 }
682 db_reset(&q1);
683 if( cnt || msgBefore>0 ){
684 break;
685 }
686 sqlite3_sleep(iDelay); nDelay--;
687 while( nDelay>0 ){
@@ -697,10 +750,77 @@
697 blob_append(&json, "\n]}", 3);
698 cgi_set_content(&json);
699 return;
700 }
701
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
702 /*
703 ** WEBPAGE: chat-fetch-one hidden loadavg-exempt
704 **
705 ** /chat-fetch-one/N
706 **
707
--- src/chat.c
+++ src/chat.c
@@ -147,36 +147,19 @@
147 **
148 ** Start up a browser-based chat session.
149 **
150 ** This is the main page that humans use to access the chatroom. Simply
151 ** point a web-browser at /chat and the screen fills with the latest
152 ** chat messages, and waits for new ones.
153 **
154 ** Other /chat-OP pages are used by XHR requests from this page to
155 ** send new chat message, delete older messages, or poll for changes.
156 */
157 void chat_webpage(void){
158 char *zAlert;
159 char *zProjectName;
160 char * zInputPlaceholder0; /* Common text input placeholder value */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
162 login_check_credentials();
163 if( !g.perm.Chat ){
164 login_needed(g.anon.Chat);
165 return;
@@ -203,12 +186,14 @@
186 @ data-placeholder="%h(zInputPlaceholder0)" \
187 @ class="chat-input-field hidden"></div>
188 @ <div id='chat-buttons-wrapper'>
189 @ <span class='cbutton' id="chat-button-preview" \
190 @ title="Preview message (Shift-Enter)">&#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>
@@ -234,17 +219,25 @@
219 @ <div id='chat-user-list'></div>
220 @ </div>
221 @ <button id='chat-clear-filter' class='hidden'>Clear filter</button>
222 @ <div id='chat-preview' class='hidden chat-view'>
223 @ <header>Preview: (<a href='%R/md_rules' target='_blank'>markdown reference</a>)</header>
224 @ <div id='chat-preview-content'></div>
225 @ <div class='button-bar'><button class='action-close'>Close Preview</button></div>
226 @ </div>
227 @ <div id='chat-config' class='hidden chat-view'>
228 @ <div id='chat-config-options'></div>
229 /* ^^^populated client-side */
230 @ <div class='button-bar'><button class='action-close'>Close Settings</button></div>
231 @ </div>
232 @ <div id='chat-search' class='hidden chat-view'>
233 @ <div id='chat-search-content'></div>
234 /* ^^^populated client-side */
235 @ <div class='button-bar'>
236 @ <button class='action-clear'>Clear results</button>
237 @ <button class='action-close'>Close Search</button>
238 @ </div>
239 @ </div>
240 @ <div id='chat-messages-wrapper' class='chat-view'>
241 /* New chat messages get inserted immediately after this element */
242 @ <span id='message-inject-point'></span>
243 @ </div>
@@ -272,11 +265,12 @@
265 @ </script>
266 builtin_request_js("fossil.page.chat.js");
267 style_finish_page();
268 }
269
270 /*
271 ** Definition of repository tables used by chat
272 */
273 static const char zChatSchema1[] =
274 @ CREATE TABLE repository.chat(
275 @ msgid INTEGER PRIMARY KEY AUTOINCREMENT,
276 @ mtime JULIANDAY, -- Time for this entry - Julianday Zulu
@@ -290,12 +284,42 @@
284 @ );
285 ;
286
287
288 /*
289 ** Create or rebuild the /chat search index. Requires that the
290 ** repository.chat table exists. If bForce is true, it will drop the
291 ** chatfts1 table and recreate/reindex it. If bForce is 0, it will
292 ** only index the chat content if the chatfts1 table does not already
293 ** exist.
294 */
295 void chat_rebuild_index(int bForce){
296 if( bForce!=0 ){
297 db_multi_exec("DROP TABLE IF EXISTS chatfts1");
298 }
299 if( bForce!=0 || !db_table_exists("repository", "chatfts1") ){
300 const int tokType = search_tokenizer_type(0);
301 const char *zTokenizer = search_tokenize_arg_for_type(
302 tokType==FTS5TOK_NONE ? FTS5TOK_PORTER : tokType
303 /* Special case: if fts search is disabled for the main repo
304 ** content, use a default tokenizer here. */
305 );
306 assert( zTokenizer && zTokenizer[0] );
307 db_multi_exec(
308 "CREATE VIRTUAL TABLE repository.chatfts1 USING fts5("
309 " xmsg, content=chat, content_rowid=msgid%s"
310 ");"
311 "INSERT INTO repository.chatfts1(chatfts1) VALUES('rebuild');",
312 zTokenizer/*safe-for-%s*/
313 );
314 }
315 }
316
317 /*
318 ** Make sure the repository data tables used by chat exist. Create
319 ** them if they do not. Set up TEMP triggers (if needed) to update the
320 ** chatfts1 table as the chat table is updated.
321 */
322 static void chat_create_tables(void){
323 if( !db_table_exists("repository","chat") ){
324 db_multi_exec(zChatSchema1/*works-like:""*/);
325 }else if( !db_table_has_column("repository","chat","lmtime") ){
@@ -302,10 +326,20 @@
326 if( !db_table_has_column("repository","chat","mdel") ){
327 db_multi_exec("ALTER TABLE chat ADD COLUMN mdel INT");
328 }
329 db_multi_exec("ALTER TABLE chat ADD COLUMN lmtime TEXT");
330 }
331 chat_rebuild_index(0);
332 db_multi_exec(
333 "CREATE TEMP TRIGGER IF NOT EXISTS chat_ai AFTER INSERT ON chat BEGIN "
334 " INSERT INTO chatfts1(rowid, xmsg) VALUES(new.msgid, new.xmsg);"
335 "END;"
336 "CREATE TEMP TRIGGER IF NOT EXISTS chat_ad AFTER DELETE ON chat BEGIN "
337 " INSERT INTO chatfts1(chatfts1, rowid, xmsg) "
338 " VALUES('delete', old.msgid, old.xmsg);"
339 "END;"
340 );
341 }
342
343 /*
344 ** Delete old content from the chat table.
345 */
@@ -453,28 +487,101 @@
487 }
488
489 /*
490 ** COMMAND: test-chat-formatter
491 **
492 ** Usage: %fossil test-chat-formatter ?OPTIONS? STRING ...
493 **
494 ** Transform each argument string into HTML that will display the
495 ** chat message. This is used to test the formatter and to verify
496 ** that a malicious message text will not cause HTML or JS injection
497 ** into the chat display in a browser.
498 **
499 ** Options:
500 **
501 ** -w|--wiki Assume fossil wiki format instead of markdown
502 */
503 void chat_test_formatter_cmd(void){
504 int i;
505 char *zOut;
506 int const isWiki = find_option("w","wiki",0)!=0;
507 db_find_and_open_repository(0,0);
508 g.perm.Hyperlink = 1;
509 for(i=2; i<g.argc; i++){
510 zOut = chat_format_to_html(g.argv[i], isWiki);
511 fossil_print("[%d]: %s\n", i-1, zOut);
512 fossil_free(zOut);
513 }
514 }
515
516 /*
517 **
518 */
519 static int chat_poll_rowstojson(
520 Stmt *p, /* Statement to read rows from */
521 const char *zChatUser, /* Current user */
522 int bRaw, /* True to return raw format xmsg */
523 Blob *pJson /* Append json array entries here */
524 ){
525 int cnt = 0;
526 while( db_step(p)==SQLITE_ROW ){
527 int isWiki = 0; /* True if chat message is x-fossil-wiki */
528 int id = db_column_int(p, 0);
529 const char *zDate = db_column_text(p, 1);
530 const char *zFrom = db_column_text(p, 2);
531 const char *zRawMsg = db_column_text(p, 3);
532 int nByte = db_column_int(p, 4);
533 const char *zFName = db_column_text(p, 5);
534 const char *zFMime = db_column_text(p, 6);
535 int iToDel = db_column_int(p, 7);
536 const char *zLMtime = db_column_text(p, 8);
537 char *zMsg;
538 if(cnt++){
539 blob_append(pJson, ",\n", 2);
540 }
541 blob_appendf(pJson, "{\"msgid\":%d,", id);
542 blob_appendf(pJson, "\"mtime\":\"%.10sT%sZ\",", zDate, zDate+11);
543 if( zLMtime && zLMtime[0] ){
544 blob_appendf(pJson, "\"lmtime\":%!j,", zLMtime);
545 }
546 blob_append(pJson, "\"xfrom\":", -1);
547 if(zFrom){
548 blob_appendf(pJson, "%!j,", zFrom);
549 isWiki = fossil_strcmp(zFrom,zChatUser)==0;
550 }else{
551 /* see https://fossil-scm.org/forum/forumpost/e0be0eeb4c */
552 blob_appendf(pJson, "null,");
553 isWiki = 0;
554 }
555 blob_appendf(pJson, "\"uclr\":%!j,",
556 isWiki ? "transparent" : user_color(zFrom ? zFrom : "nobody"));
557
558 if(bRaw){
559 blob_appendf(pJson, "\"xmsg\":%!j,", zRawMsg);
560 }else{
561 zMsg = chat_format_to_html(zRawMsg ? zRawMsg : "", isWiki);
562 blob_appendf(pJson, "\"xmsg\":%!j,", zMsg);
563 fossil_free(zMsg);
564 }
565
566 if( nByte==0 ){
567 blob_appendf(pJson, "\"fsize\":0");
568 }else{
569 blob_appendf(pJson, "\"fsize\":%d,\"fname\":%!j,\"fmime\":%!j",
570 nByte, zFName, zFMime);
571 }
572
573 if( iToDel ){
574 blob_appendf(pJson, ",\"mdel\":%d}", iToDel);
575 }else{
576 blob_append(pJson, "}", 1);
577 }
578 }
579 db_reset(p);
580
581 return cnt;
582 }
583
584 /*
585 ** WEBPAGE: chat-poll hidden loadavg-exempt
586 **
587 ** The chat page generated by /chat using an XHR to this page to
@@ -570,11 +677,10 @@
677 Blob json; /* The json to be constructed and returned */
678 sqlite3_int64 dataVersion; /* Data version. Used for polling. */
679 const int iDelay = 1000; /* Delay until next poll (milliseconds) */
680 int nDelay; /* Maximum delay.*/
681 const char *zChatUser; /* chat-timeline-user */
 
682 int msgid = atoi(PD("name","0"));
683 const int msgBefore = atoi(PD("before","0"));
684 int nLimit = msgBefore>0 ? atoi(PD("n","0")) : 0;
685 const int bRaw = P("raw")!=0;
686
@@ -624,64 +730,11 @@
730 }
731 db_prepare(&q1, "%s", blob_sql_text(&sql));
732 blob_reset(&sql);
733 blob_init(&json, "{\"msgs\":[\n", -1);
734 while( nDelay>0 ){
735 int cnt = chat_poll_rowstojson(&q1, zChatUser, bRaw, &json);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
736 if( cnt || msgBefore>0 ){
737 break;
738 }
739 sqlite3_sleep(iDelay); nDelay--;
740 while( nDelay>0 ){
@@ -697,10 +750,77 @@
750 blob_append(&json, "\n]}", 3);
751 cgi_set_content(&json);
752 return;
753 }
754
755
756 /*
757 ** WEBPAGE: chat-query hidden loadavg-exempt
758 */
759 void chat_query_webpage(void){
760 Blob json; /* The json to be constructed and returned */
761 Blob sql = empty_blob;
762 Stmt q1;
763 int nLimit = atoi(PD("n","500"));
764 int iFirst = atoi(PD("i","0"));
765 const char *zQuery = PD("q", "");
766 i64 iMin = 0;
767 i64 iMax = 0;
768
769 login_check_credentials();
770 if( !g.perm.Chat ) {
771 chat_emit_permissions_error(1);
772 return;
773 }
774 chat_create_tables();
775 cgi_set_content_type("application/json");
776
777 if( zQuery[0] ){
778 iMax = db_int64(0, "SELECT max(msgid) FROM chat");
779 iMin = db_int64(0, "SELECT min(msgid) FROM chat");
780 if( '#'==zQuery[0] ){
781 /* Assume we're looking for an exact msgid match. */
782 ++zQuery;
783 blob_append_sql(&sql,
784 "SELECT msgid, datetime(mtime), xfrom, "
785 " xmsg, octet_length(file), fname, fmime, mdel, lmtime "
786 " FROM chat WHERE msgid=+%Q",
787 zQuery
788 );
789 }else{
790 char * zPat = search_simplify_pattern(zQuery);
791 blob_append_sql(&sql,
792 "SELECT * FROM ("
793 "SELECT c.msgid, datetime(c.mtime), c.xfrom, "
794 " highlight(chatfts1, 0, '<span class=\"match\">', '</span>'), "
795 " octet_length(c.file), c.fname, c.fmime, c.mdel, c.lmtime "
796 " FROM chatfts1(%Q) f, chat c "
797 " WHERE f.rowid=c.msgid"
798 " ORDER BY f.rowid DESC LIMIT %d"
799 ") ORDER BY 1 ASC", zPat, nLimit
800 );
801 fossil_free(zPat);
802 }
803 }else{
804 blob_append_sql(&sql,
805 "SELECT msgid, datetime(mtime), xfrom, "
806 " xmsg, octet_length(file), fname, fmime, mdel, lmtime"
807 " FROM chat WHERE msgid>=%d LIMIT %d",
808 iFirst, nLimit
809 );
810 }
811
812 db_prepare(&q1, "%s", blob_sql_text(&sql));
813 blob_reset(&sql);
814 blob_init(&json, "{\"msgs\":[\n", -1);
815 chat_poll_rowstojson(&q1, "", 0, &json);
816 db_finalize(&q1);
817 blob_appendf(&json, "\n], \"first\":%lld, \"last\":%lld}", iMin, iMax);
818 cgi_set_content(&json);
819 return;
820 }
821
822 /*
823 ** WEBPAGE: chat-fetch-one hidden loadavg-exempt
824 **
825 ** /chat-fetch-one/N
826 **
827
+204 -84
--- src/chat.c
+++ src/chat.c
@@ -147,36 +147,19 @@
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>
@@ -234,17 +219,25 @@
234219
@ <div id='chat-user-list'></div>
235220
@ </div>
236221
@ <button id='chat-clear-filter' class='hidden'>Clear filter</button>
237222
@ <div id='chat-preview' class='hidden chat-view'>
238223
@ <header>Preview: (<a href='%R/md_rules' target='_blank'>markdown reference</a>)</header>
239
- @ <div id='chat-preview-content' class='message-widget-content'></div>
240
- @ <div id='chat-preview-buttons'><button id='chat-preview-close'>Close Preview</button></div>
224
+ @ <div id='chat-preview-content'></div>
225
+ @ <div class='button-bar'><button class='action-close'>Close Preview</button></div>
241226
@ </div>
242227
@ <div id='chat-config' class='hidden chat-view'>
243228
@ <div id='chat-config-options'></div>
244229
/* ^^^populated client-side */
245
- @ <button>Close Settings</button>
230
+ @ <div class='button-bar'><button class='action-close'>Close Settings</button></div>
231
+ @ </div>
232
+ @ <div id='chat-search' class='hidden chat-view'>
233
+ @ <div id='chat-search-content'></div>
234
+ /* ^^^populated client-side */
235
+ @ <div class='button-bar'>
236
+ @ <button class='action-clear'>Clear results</button>
237
+ @ <button class='action-close'>Close Search</button>
238
+ @ </div>
246239
@ </div>
247240
@ <div id='chat-messages-wrapper' class='chat-view'>
248241
/* New chat messages get inserted immediately after this element */
249242
@ <span id='message-inject-point'></span>
250243
@ </div>
@@ -272,11 +265,12 @@
272265
@ </script>
273266
builtin_request_js("fossil.page.chat.js");
274267
style_finish_page();
275268
}
276269
277
-/* Definition of repository tables used by chat
270
+/*
271
+** Definition of repository tables used by chat
278272
*/
279273
static const char zChatSchema1[] =
280274
@ CREATE TABLE repository.chat(
281275
@ msgid INTEGER PRIMARY KEY AUTOINCREMENT,
282276
@ mtime JULIANDAY, -- Time for this entry - Julianday Zulu
@@ -290,12 +284,42 @@
290284
@ );
291285
;
292286
293287
294288
/*
295
-** Make sure the repository data tables used by chat exist. Create them
296
-** if they do not.
289
+** Create or rebuild the /chat search index. Requires that the
290
+** repository.chat table exists. If bForce is true, it will drop the
291
+** chatfts1 table and recreate/reindex it. If bForce is 0, it will
292
+** only index the chat content if the chatfts1 table does not already
293
+** exist.
294
+*/
295
+void chat_rebuild_index(int bForce){
296
+ if( bForce!=0 ){
297
+ db_multi_exec("DROP TABLE IF EXISTS chatfts1");
298
+ }
299
+ if( bForce!=0 || !db_table_exists("repository", "chatfts1") ){
300
+ const int tokType = search_tokenizer_type(0);
301
+ const char *zTokenizer = search_tokenize_arg_for_type(
302
+ tokType==FTS5TOK_NONE ? FTS5TOK_PORTER : tokType
303
+ /* Special case: if fts search is disabled for the main repo
304
+ ** content, use a default tokenizer here. */
305
+ );
306
+ assert( zTokenizer && zTokenizer[0] );
307
+ db_multi_exec(
308
+ "CREATE VIRTUAL TABLE repository.chatfts1 USING fts5("
309
+ " xmsg, content=chat, content_rowid=msgid%s"
310
+ ");"
311
+ "INSERT INTO repository.chatfts1(chatfts1) VALUES('rebuild');",
312
+ zTokenizer/*safe-for-%s*/
313
+ );
314
+ }
315
+}
316
+
317
+/*
318
+** Make sure the repository data tables used by chat exist. Create
319
+** them if they do not. Set up TEMP triggers (if needed) to update the
320
+** chatfts1 table as the chat table is updated.
297321
*/
298322
static void chat_create_tables(void){
299323
if( !db_table_exists("repository","chat") ){
300324
db_multi_exec(zChatSchema1/*works-like:""*/);
301325
}else if( !db_table_has_column("repository","chat","lmtime") ){
@@ -302,10 +326,20 @@
302326
if( !db_table_has_column("repository","chat","mdel") ){
303327
db_multi_exec("ALTER TABLE chat ADD COLUMN mdel INT");
304328
}
305329
db_multi_exec("ALTER TABLE chat ADD COLUMN lmtime TEXT");
306330
}
331
+ chat_rebuild_index(0);
332
+ db_multi_exec(
333
+ "CREATE TEMP TRIGGER IF NOT EXISTS chat_ai AFTER INSERT ON chat BEGIN "
334
+ " INSERT INTO chatfts1(rowid, xmsg) VALUES(new.msgid, new.xmsg);"
335
+ "END;"
336
+ "CREATE TEMP TRIGGER IF NOT EXISTS chat_ad AFTER DELETE ON chat BEGIN "
337
+ " INSERT INTO chatfts1(chatfts1, rowid, xmsg) "
338
+ " VALUES('delete', old.msgid, old.xmsg);"
339
+ "END;"
340
+ );
307341
}
308342
309343
/*
310344
** Delete old content from the chat table.
311345
*/
@@ -453,28 +487,101 @@
453487
}
454488
455489
/*
456490
** COMMAND: test-chat-formatter
457491
**
458
-** Usage: %fossil test-chat-formatter STRING ...
492
+** Usage: %fossil test-chat-formatter ?OPTIONS? STRING ...
459493
**
460494
** Transform each argument string into HTML that will display the
461495
** chat message. This is used to test the formatter and to verify
462496
** that a malicious message text will not cause HTML or JS injection
463497
** into the chat display in a browser.
498
+**
499
+** Options:
500
+**
501
+** -w|--wiki Assume fossil wiki format instead of markdown
464502
*/
465503
void chat_test_formatter_cmd(void){
466504
int i;
467505
char *zOut;
506
+ int const isWiki = find_option("w","wiki",0)!=0;
468507
db_find_and_open_repository(0,0);
469508
g.perm.Hyperlink = 1;
470
- for(i=0; i<g.argc; i++){
471
- zOut = chat_format_to_html(g.argv[i], 0);
472
- fossil_print("[%d]: %s\n", i, zOut);
509
+ for(i=2; i<g.argc; i++){
510
+ zOut = chat_format_to_html(g.argv[i], isWiki);
511
+ fossil_print("[%d]: %s\n", i-1, zOut);
473512
fossil_free(zOut);
474513
}
475514
}
515
+
516
+/*
517
+**
518
+*/
519
+static int chat_poll_rowstojson(
520
+ Stmt *p, /* Statement to read rows from */
521
+ const char *zChatUser, /* Current user */
522
+ int bRaw, /* True to return raw format xmsg */
523
+ Blob *pJson /* Append json array entries here */
524
+){
525
+ int cnt = 0;
526
+ while( db_step(p)==SQLITE_ROW ){
527
+ int isWiki = 0; /* True if chat message is x-fossil-wiki */
528
+ int id = db_column_int(p, 0);
529
+ const char *zDate = db_column_text(p, 1);
530
+ const char *zFrom = db_column_text(p, 2);
531
+ const char *zRawMsg = db_column_text(p, 3);
532
+ int nByte = db_column_int(p, 4);
533
+ const char *zFName = db_column_text(p, 5);
534
+ const char *zFMime = db_column_text(p, 6);
535
+ int iToDel = db_column_int(p, 7);
536
+ const char *zLMtime = db_column_text(p, 8);
537
+ char *zMsg;
538
+ if(cnt++){
539
+ blob_append(pJson, ",\n", 2);
540
+ }
541
+ blob_appendf(pJson, "{\"msgid\":%d,", id);
542
+ blob_appendf(pJson, "\"mtime\":\"%.10sT%sZ\",", zDate, zDate+11);
543
+ if( zLMtime && zLMtime[0] ){
544
+ blob_appendf(pJson, "\"lmtime\":%!j,", zLMtime);
545
+ }
546
+ blob_append(pJson, "\"xfrom\":", -1);
547
+ if(zFrom){
548
+ blob_appendf(pJson, "%!j,", zFrom);
549
+ isWiki = fossil_strcmp(zFrom,zChatUser)==0;
550
+ }else{
551
+ /* see https://fossil-scm.org/forum/forumpost/e0be0eeb4c */
552
+ blob_appendf(pJson, "null,");
553
+ isWiki = 0;
554
+ }
555
+ blob_appendf(pJson, "\"uclr\":%!j,",
556
+ isWiki ? "transparent" : user_color(zFrom ? zFrom : "nobody"));
557
+
558
+ if(bRaw){
559
+ blob_appendf(pJson, "\"xmsg\":%!j,", zRawMsg);
560
+ }else{
561
+ zMsg = chat_format_to_html(zRawMsg ? zRawMsg : "", isWiki);
562
+ blob_appendf(pJson, "\"xmsg\":%!j,", zMsg);
563
+ fossil_free(zMsg);
564
+ }
565
+
566
+ if( nByte==0 ){
567
+ blob_appendf(pJson, "\"fsize\":0");
568
+ }else{
569
+ blob_appendf(pJson, "\"fsize\":%d,\"fname\":%!j,\"fmime\":%!j",
570
+ nByte, zFName, zFMime);
571
+ }
572
+
573
+ if( iToDel ){
574
+ blob_appendf(pJson, ",\"mdel\":%d}", iToDel);
575
+ }else{
576
+ blob_append(pJson, "}", 1);
577
+ }
578
+ }
579
+ db_reset(p);
580
+
581
+ return cnt;
582
+}
476583
477584
/*
478585
** WEBPAGE: chat-poll hidden loadavg-exempt
479586
**
480587
** The chat page generated by /chat using an XHR to this page to
@@ -570,11 +677,10 @@
570677
Blob json; /* The json to be constructed and returned */
571678
sqlite3_int64 dataVersion; /* Data version. Used for polling. */
572679
const int iDelay = 1000; /* Delay until next poll (milliseconds) */
573680
int nDelay; /* Maximum delay.*/
574681
const char *zChatUser; /* chat-timeline-user */
575
- int isWiki = 0; /* True if chat message is x-fossil-wiki */
576682
int msgid = atoi(PD("name","0"));
577683
const int msgBefore = atoi(PD("before","0"));
578684
int nLimit = msgBefore>0 ? atoi(PD("n","0")) : 0;
579685
const int bRaw = P("raw")!=0;
580686
@@ -624,64 +730,11 @@
624730
}
625731
db_prepare(&q1, "%s", blob_sql_text(&sql));
626732
blob_reset(&sql);
627733
blob_init(&json, "{\"msgs\":[\n", -1);
628734
while( nDelay>0 ){
629
- int cnt = 0;
630
- while( db_step(&q1)==SQLITE_ROW ){
631
- int id = db_column_int(&q1, 0);
632
- const char *zDate = db_column_text(&q1, 1);
633
- const char *zFrom = db_column_text(&q1, 2);
634
- const char *zRawMsg = db_column_text(&q1, 3);
635
- int nByte = db_column_int(&q1, 4);
636
- const char *zFName = db_column_text(&q1, 5);
637
- const char *zFMime = db_column_text(&q1, 6);
638
- int iToDel = db_column_int(&q1, 7);
639
- const char *zLMtime = db_column_text(&q1, 8);
640
- char *zMsg;
641
- if(cnt++){
642
- blob_append(&json, ",\n", 2);
643
- }
644
- blob_appendf(&json, "{\"msgid\":%d,", id);
645
- blob_appendf(&json, "\"mtime\":\"%.10sT%sZ\",", zDate, zDate+11);
646
- if( zLMtime && zLMtime[0] ){
647
- blob_appendf(&json, "\"lmtime\":%!j,", zLMtime);
648
- }
649
- blob_append(&json, "\"xfrom\":", -1);
650
- if(zFrom){
651
- blob_appendf(&json, "%!j,", zFrom);
652
- isWiki = fossil_strcmp(zFrom,zChatUser)==0;
653
- }else{
654
- /* see https://fossil-scm.org/forum/forumpost/e0be0eeb4c */
655
- blob_appendf(&json, "null,");
656
- isWiki = 0;
657
- }
658
- blob_appendf(&json, "\"uclr\":%!j,",
659
- isWiki ? "transparent" : user_color(zFrom ? zFrom : "nobody"));
660
-
661
- if(bRaw){
662
- blob_appendf(&json, "\"xmsg\":%!j,", zRawMsg);
663
- }else{
664
- zMsg = chat_format_to_html(zRawMsg ? zRawMsg : "", isWiki);
665
- blob_appendf(&json, "\"xmsg\":%!j,", zMsg);
666
- fossil_free(zMsg);
667
- }
668
-
669
- if( nByte==0 ){
670
- blob_appendf(&json, "\"fsize\":0");
671
- }else{
672
- blob_appendf(&json, "\"fsize\":%d,\"fname\":%!j,\"fmime\":%!j",
673
- nByte, zFName, zFMime);
674
- }
675
-
676
- if( iToDel ){
677
- blob_appendf(&json, ",\"mdel\":%d}", iToDel);
678
- }else{
679
- blob_append(&json, "}", 1);
680
- }
681
- }
682
- db_reset(&q1);
735
+ int cnt = chat_poll_rowstojson(&q1, zChatUser, bRaw, &json);
683736
if( cnt || msgBefore>0 ){
684737
break;
685738
}
686739
sqlite3_sleep(iDelay); nDelay--;
687740
while( nDelay>0 ){
@@ -697,10 +750,77 @@
697750
blob_append(&json, "\n]}", 3);
698751
cgi_set_content(&json);
699752
return;
700753
}
701754
755
+
756
+/*
757
+** WEBPAGE: chat-query hidden loadavg-exempt
758
+*/
759
+void chat_query_webpage(void){
760
+ Blob json; /* The json to be constructed and returned */
761
+ Blob sql = empty_blob;
762
+ Stmt q1;
763
+ int nLimit = atoi(PD("n","500"));
764
+ int iFirst = atoi(PD("i","0"));
765
+ const char *zQuery = PD("q", "");
766
+ i64 iMin = 0;
767
+ i64 iMax = 0;
768
+
769
+ login_check_credentials();
770
+ if( !g.perm.Chat ) {
771
+ chat_emit_permissions_error(1);
772
+ return;
773
+ }
774
+ chat_create_tables();
775
+ cgi_set_content_type("application/json");
776
+
777
+ if( zQuery[0] ){
778
+ iMax = db_int64(0, "SELECT max(msgid) FROM chat");
779
+ iMin = db_int64(0, "SELECT min(msgid) FROM chat");
780
+ if( '#'==zQuery[0] ){
781
+ /* Assume we're looking for an exact msgid match. */
782
+ ++zQuery;
783
+ blob_append_sql(&sql,
784
+ "SELECT msgid, datetime(mtime), xfrom, "
785
+ " xmsg, octet_length(file), fname, fmime, mdel, lmtime "
786
+ " FROM chat WHERE msgid=+%Q",
787
+ zQuery
788
+ );
789
+ }else{
790
+ char * zPat = search_simplify_pattern(zQuery);
791
+ blob_append_sql(&sql,
792
+ "SELECT * FROM ("
793
+ "SELECT c.msgid, datetime(c.mtime), c.xfrom, "
794
+ " highlight(chatfts1, 0, '<span class=\"match\">', '</span>'), "
795
+ " octet_length(c.file), c.fname, c.fmime, c.mdel, c.lmtime "
796
+ " FROM chatfts1(%Q) f, chat c "
797
+ " WHERE f.rowid=c.msgid"
798
+ " ORDER BY f.rowid DESC LIMIT %d"
799
+ ") ORDER BY 1 ASC", zPat, nLimit
800
+ );
801
+ fossil_free(zPat);
802
+ }
803
+ }else{
804
+ blob_append_sql(&sql,
805
+ "SELECT msgid, datetime(mtime), xfrom, "
806
+ " xmsg, octet_length(file), fname, fmime, mdel, lmtime"
807
+ " FROM chat WHERE msgid>=%d LIMIT %d",
808
+ iFirst, nLimit
809
+ );
810
+ }
811
+
812
+ db_prepare(&q1, "%s", blob_sql_text(&sql));
813
+ blob_reset(&sql);
814
+ blob_init(&json, "{\"msgs\":[\n", -1);
815
+ chat_poll_rowstojson(&q1, "", 0, &json);
816
+ db_finalize(&q1);
817
+ blob_appendf(&json, "\n], \"first\":%lld, \"last\":%lld}", iMin, iMax);
818
+ cgi_set_content(&json);
819
+ return;
820
+}
821
+
702822
/*
703823
** WEBPAGE: chat-fetch-one hidden loadavg-exempt
704824
**
705825
** /chat-fetch-one/N
706826
**
707827
--- 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>
@@ -234,17 +219,25 @@
234 @ <div id='chat-user-list'></div>
235 @ </div>
236 @ <button id='chat-clear-filter' class='hidden'>Clear filter</button>
237 @ <div id='chat-preview' class='hidden chat-view'>
238 @ <header>Preview: (<a href='%R/md_rules' target='_blank'>markdown reference</a>)</header>
239 @ <div id='chat-preview-content' class='message-widget-content'></div>
240 @ <div id='chat-preview-buttons'><button id='chat-preview-close'>Close Preview</button></div>
241 @ </div>
242 @ <div id='chat-config' class='hidden chat-view'>
243 @ <div id='chat-config-options'></div>
244 /* ^^^populated client-side */
245 @ <button>Close Settings</button>
 
 
 
 
 
 
 
 
246 @ </div>
247 @ <div id='chat-messages-wrapper' class='chat-view'>
248 /* New chat messages get inserted immediately after this element */
249 @ <span id='message-inject-point'></span>
250 @ </div>
@@ -272,11 +265,12 @@
272 @ </script>
273 builtin_request_js("fossil.page.chat.js");
274 style_finish_page();
275 }
276
277 /* Definition of repository tables used by chat
 
278 */
279 static const char zChatSchema1[] =
280 @ CREATE TABLE repository.chat(
281 @ msgid INTEGER PRIMARY KEY AUTOINCREMENT,
282 @ mtime JULIANDAY, -- Time for this entry - Julianday Zulu
@@ -290,12 +284,42 @@
290 @ );
291 ;
292
293
294 /*
295 ** Make sure the repository data tables used by chat exist. Create them
296 ** if they do not.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
297 */
298 static void chat_create_tables(void){
299 if( !db_table_exists("repository","chat") ){
300 db_multi_exec(zChatSchema1/*works-like:""*/);
301 }else if( !db_table_has_column("repository","chat","lmtime") ){
@@ -302,10 +326,20 @@
302 if( !db_table_has_column("repository","chat","mdel") ){
303 db_multi_exec("ALTER TABLE chat ADD COLUMN mdel INT");
304 }
305 db_multi_exec("ALTER TABLE chat ADD COLUMN lmtime TEXT");
306 }
 
 
 
 
 
 
 
 
 
 
307 }
308
309 /*
310 ** Delete old content from the chat table.
311 */
@@ -453,28 +487,101 @@
453 }
454
455 /*
456 ** COMMAND: test-chat-formatter
457 **
458 ** Usage: %fossil test-chat-formatter STRING ...
459 **
460 ** Transform each argument string into HTML that will display the
461 ** chat message. This is used to test the formatter and to verify
462 ** that a malicious message text will not cause HTML or JS injection
463 ** into the chat display in a browser.
 
 
 
 
464 */
465 void chat_test_formatter_cmd(void){
466 int i;
467 char *zOut;
 
468 db_find_and_open_repository(0,0);
469 g.perm.Hyperlink = 1;
470 for(i=0; i<g.argc; i++){
471 zOut = chat_format_to_html(g.argv[i], 0);
472 fossil_print("[%d]: %s\n", i, zOut);
473 fossil_free(zOut);
474 }
475 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
476
477 /*
478 ** WEBPAGE: chat-poll hidden loadavg-exempt
479 **
480 ** The chat page generated by /chat using an XHR to this page to
@@ -570,11 +677,10 @@
570 Blob json; /* The json to be constructed and returned */
571 sqlite3_int64 dataVersion; /* Data version. Used for polling. */
572 const int iDelay = 1000; /* Delay until next poll (milliseconds) */
573 int nDelay; /* Maximum delay.*/
574 const char *zChatUser; /* chat-timeline-user */
575 int isWiki = 0; /* True if chat message is x-fossil-wiki */
576 int msgid = atoi(PD("name","0"));
577 const int msgBefore = atoi(PD("before","0"));
578 int nLimit = msgBefore>0 ? atoi(PD("n","0")) : 0;
579 const int bRaw = P("raw")!=0;
580
@@ -624,64 +730,11 @@
624 }
625 db_prepare(&q1, "%s", blob_sql_text(&sql));
626 blob_reset(&sql);
627 blob_init(&json, "{\"msgs\":[\n", -1);
628 while( nDelay>0 ){
629 int cnt = 0;
630 while( db_step(&q1)==SQLITE_ROW ){
631 int id = db_column_int(&q1, 0);
632 const char *zDate = db_column_text(&q1, 1);
633 const char *zFrom = db_column_text(&q1, 2);
634 const char *zRawMsg = db_column_text(&q1, 3);
635 int nByte = db_column_int(&q1, 4);
636 const char *zFName = db_column_text(&q1, 5);
637 const char *zFMime = db_column_text(&q1, 6);
638 int iToDel = db_column_int(&q1, 7);
639 const char *zLMtime = db_column_text(&q1, 8);
640 char *zMsg;
641 if(cnt++){
642 blob_append(&json, ",\n", 2);
643 }
644 blob_appendf(&json, "{\"msgid\":%d,", id);
645 blob_appendf(&json, "\"mtime\":\"%.10sT%sZ\",", zDate, zDate+11);
646 if( zLMtime && zLMtime[0] ){
647 blob_appendf(&json, "\"lmtime\":%!j,", zLMtime);
648 }
649 blob_append(&json, "\"xfrom\":", -1);
650 if(zFrom){
651 blob_appendf(&json, "%!j,", zFrom);
652 isWiki = fossil_strcmp(zFrom,zChatUser)==0;
653 }else{
654 /* see https://fossil-scm.org/forum/forumpost/e0be0eeb4c */
655 blob_appendf(&json, "null,");
656 isWiki = 0;
657 }
658 blob_appendf(&json, "\"uclr\":%!j,",
659 isWiki ? "transparent" : user_color(zFrom ? zFrom : "nobody"));
660
661 if(bRaw){
662 blob_appendf(&json, "\"xmsg\":%!j,", zRawMsg);
663 }else{
664 zMsg = chat_format_to_html(zRawMsg ? zRawMsg : "", isWiki);
665 blob_appendf(&json, "\"xmsg\":%!j,", zMsg);
666 fossil_free(zMsg);
667 }
668
669 if( nByte==0 ){
670 blob_appendf(&json, "\"fsize\":0");
671 }else{
672 blob_appendf(&json, "\"fsize\":%d,\"fname\":%!j,\"fmime\":%!j",
673 nByte, zFName, zFMime);
674 }
675
676 if( iToDel ){
677 blob_appendf(&json, ",\"mdel\":%d}", iToDel);
678 }else{
679 blob_append(&json, "}", 1);
680 }
681 }
682 db_reset(&q1);
683 if( cnt || msgBefore>0 ){
684 break;
685 }
686 sqlite3_sleep(iDelay); nDelay--;
687 while( nDelay>0 ){
@@ -697,10 +750,77 @@
697 blob_append(&json, "\n]}", 3);
698 cgi_set_content(&json);
699 return;
700 }
701
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
702 /*
703 ** WEBPAGE: chat-fetch-one hidden loadavg-exempt
704 **
705 ** /chat-fetch-one/N
706 **
707
--- src/chat.c
+++ src/chat.c
@@ -147,36 +147,19 @@
147 **
148 ** Start up a browser-based chat session.
149 **
150 ** This is the main page that humans use to access the chatroom. Simply
151 ** point a web-browser at /chat and the screen fills with the latest
152 ** chat messages, and waits for new ones.
153 **
154 ** Other /chat-OP pages are used by XHR requests from this page to
155 ** send new chat message, delete older messages, or poll for changes.
156 */
157 void chat_webpage(void){
158 char *zAlert;
159 char *zProjectName;
160 char * zInputPlaceholder0; /* Common text input placeholder value */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
162 login_check_credentials();
163 if( !g.perm.Chat ){
164 login_needed(g.anon.Chat);
165 return;
@@ -203,12 +186,14 @@
186 @ data-placeholder="%h(zInputPlaceholder0)" \
187 @ class="chat-input-field hidden"></div>
188 @ <div id='chat-buttons-wrapper'>
189 @ <span class='cbutton' id="chat-button-preview" \
190 @ title="Preview message (Shift-Enter)">&#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>
@@ -234,17 +219,25 @@
219 @ <div id='chat-user-list'></div>
220 @ </div>
221 @ <button id='chat-clear-filter' class='hidden'>Clear filter</button>
222 @ <div id='chat-preview' class='hidden chat-view'>
223 @ <header>Preview: (<a href='%R/md_rules' target='_blank'>markdown reference</a>)</header>
224 @ <div id='chat-preview-content'></div>
225 @ <div class='button-bar'><button class='action-close'>Close Preview</button></div>
226 @ </div>
227 @ <div id='chat-config' class='hidden chat-view'>
228 @ <div id='chat-config-options'></div>
229 /* ^^^populated client-side */
230 @ <div class='button-bar'><button class='action-close'>Close Settings</button></div>
231 @ </div>
232 @ <div id='chat-search' class='hidden chat-view'>
233 @ <div id='chat-search-content'></div>
234 /* ^^^populated client-side */
235 @ <div class='button-bar'>
236 @ <button class='action-clear'>Clear results</button>
237 @ <button class='action-close'>Close Search</button>
238 @ </div>
239 @ </div>
240 @ <div id='chat-messages-wrapper' class='chat-view'>
241 /* New chat messages get inserted immediately after this element */
242 @ <span id='message-inject-point'></span>
243 @ </div>
@@ -272,11 +265,12 @@
265 @ </script>
266 builtin_request_js("fossil.page.chat.js");
267 style_finish_page();
268 }
269
270 /*
271 ** Definition of repository tables used by chat
272 */
273 static const char zChatSchema1[] =
274 @ CREATE TABLE repository.chat(
275 @ msgid INTEGER PRIMARY KEY AUTOINCREMENT,
276 @ mtime JULIANDAY, -- Time for this entry - Julianday Zulu
@@ -290,12 +284,42 @@
284 @ );
285 ;
286
287
288 /*
289 ** Create or rebuild the /chat search index. Requires that the
290 ** repository.chat table exists. If bForce is true, it will drop the
291 ** chatfts1 table and recreate/reindex it. If bForce is 0, it will
292 ** only index the chat content if the chatfts1 table does not already
293 ** exist.
294 */
295 void chat_rebuild_index(int bForce){
296 if( bForce!=0 ){
297 db_multi_exec("DROP TABLE IF EXISTS chatfts1");
298 }
299 if( bForce!=0 || !db_table_exists("repository", "chatfts1") ){
300 const int tokType = search_tokenizer_type(0);
301 const char *zTokenizer = search_tokenize_arg_for_type(
302 tokType==FTS5TOK_NONE ? FTS5TOK_PORTER : tokType
303 /* Special case: if fts search is disabled for the main repo
304 ** content, use a default tokenizer here. */
305 );
306 assert( zTokenizer && zTokenizer[0] );
307 db_multi_exec(
308 "CREATE VIRTUAL TABLE repository.chatfts1 USING fts5("
309 " xmsg, content=chat, content_rowid=msgid%s"
310 ");"
311 "INSERT INTO repository.chatfts1(chatfts1) VALUES('rebuild');",
312 zTokenizer/*safe-for-%s*/
313 );
314 }
315 }
316
317 /*
318 ** Make sure the repository data tables used by chat exist. Create
319 ** them if they do not. Set up TEMP triggers (if needed) to update the
320 ** chatfts1 table as the chat table is updated.
321 */
322 static void chat_create_tables(void){
323 if( !db_table_exists("repository","chat") ){
324 db_multi_exec(zChatSchema1/*works-like:""*/);
325 }else if( !db_table_has_column("repository","chat","lmtime") ){
@@ -302,10 +326,20 @@
326 if( !db_table_has_column("repository","chat","mdel") ){
327 db_multi_exec("ALTER TABLE chat ADD COLUMN mdel INT");
328 }
329 db_multi_exec("ALTER TABLE chat ADD COLUMN lmtime TEXT");
330 }
331 chat_rebuild_index(0);
332 db_multi_exec(
333 "CREATE TEMP TRIGGER IF NOT EXISTS chat_ai AFTER INSERT ON chat BEGIN "
334 " INSERT INTO chatfts1(rowid, xmsg) VALUES(new.msgid, new.xmsg);"
335 "END;"
336 "CREATE TEMP TRIGGER IF NOT EXISTS chat_ad AFTER DELETE ON chat BEGIN "
337 " INSERT INTO chatfts1(chatfts1, rowid, xmsg) "
338 " VALUES('delete', old.msgid, old.xmsg);"
339 "END;"
340 );
341 }
342
343 /*
344 ** Delete old content from the chat table.
345 */
@@ -453,28 +487,101 @@
487 }
488
489 /*
490 ** COMMAND: test-chat-formatter
491 **
492 ** Usage: %fossil test-chat-formatter ?OPTIONS? STRING ...
493 **
494 ** Transform each argument string into HTML that will display the
495 ** chat message. This is used to test the formatter and to verify
496 ** that a malicious message text will not cause HTML or JS injection
497 ** into the chat display in a browser.
498 **
499 ** Options:
500 **
501 ** -w|--wiki Assume fossil wiki format instead of markdown
502 */
503 void chat_test_formatter_cmd(void){
504 int i;
505 char *zOut;
506 int const isWiki = find_option("w","wiki",0)!=0;
507 db_find_and_open_repository(0,0);
508 g.perm.Hyperlink = 1;
509 for(i=2; i<g.argc; i++){
510 zOut = chat_format_to_html(g.argv[i], isWiki);
511 fossil_print("[%d]: %s\n", i-1, zOut);
512 fossil_free(zOut);
513 }
514 }
515
516 /*
517 **
518 */
519 static int chat_poll_rowstojson(
520 Stmt *p, /* Statement to read rows from */
521 const char *zChatUser, /* Current user */
522 int bRaw, /* True to return raw format xmsg */
523 Blob *pJson /* Append json array entries here */
524 ){
525 int cnt = 0;
526 while( db_step(p)==SQLITE_ROW ){
527 int isWiki = 0; /* True if chat message is x-fossil-wiki */
528 int id = db_column_int(p, 0);
529 const char *zDate = db_column_text(p, 1);
530 const char *zFrom = db_column_text(p, 2);
531 const char *zRawMsg = db_column_text(p, 3);
532 int nByte = db_column_int(p, 4);
533 const char *zFName = db_column_text(p, 5);
534 const char *zFMime = db_column_text(p, 6);
535 int iToDel = db_column_int(p, 7);
536 const char *zLMtime = db_column_text(p, 8);
537 char *zMsg;
538 if(cnt++){
539 blob_append(pJson, ",\n", 2);
540 }
541 blob_appendf(pJson, "{\"msgid\":%d,", id);
542 blob_appendf(pJson, "\"mtime\":\"%.10sT%sZ\",", zDate, zDate+11);
543 if( zLMtime && zLMtime[0] ){
544 blob_appendf(pJson, "\"lmtime\":%!j,", zLMtime);
545 }
546 blob_append(pJson, "\"xfrom\":", -1);
547 if(zFrom){
548 blob_appendf(pJson, "%!j,", zFrom);
549 isWiki = fossil_strcmp(zFrom,zChatUser)==0;
550 }else{
551 /* see https://fossil-scm.org/forum/forumpost/e0be0eeb4c */
552 blob_appendf(pJson, "null,");
553 isWiki = 0;
554 }
555 blob_appendf(pJson, "\"uclr\":%!j,",
556 isWiki ? "transparent" : user_color(zFrom ? zFrom : "nobody"));
557
558 if(bRaw){
559 blob_appendf(pJson, "\"xmsg\":%!j,", zRawMsg);
560 }else{
561 zMsg = chat_format_to_html(zRawMsg ? zRawMsg : "", isWiki);
562 blob_appendf(pJson, "\"xmsg\":%!j,", zMsg);
563 fossil_free(zMsg);
564 }
565
566 if( nByte==0 ){
567 blob_appendf(pJson, "\"fsize\":0");
568 }else{
569 blob_appendf(pJson, "\"fsize\":%d,\"fname\":%!j,\"fmime\":%!j",
570 nByte, zFName, zFMime);
571 }
572
573 if( iToDel ){
574 blob_appendf(pJson, ",\"mdel\":%d}", iToDel);
575 }else{
576 blob_append(pJson, "}", 1);
577 }
578 }
579 db_reset(p);
580
581 return cnt;
582 }
583
584 /*
585 ** WEBPAGE: chat-poll hidden loadavg-exempt
586 **
587 ** The chat page generated by /chat using an XHR to this page to
@@ -570,11 +677,10 @@
677 Blob json; /* The json to be constructed and returned */
678 sqlite3_int64 dataVersion; /* Data version. Used for polling. */
679 const int iDelay = 1000; /* Delay until next poll (milliseconds) */
680 int nDelay; /* Maximum delay.*/
681 const char *zChatUser; /* chat-timeline-user */
 
682 int msgid = atoi(PD("name","0"));
683 const int msgBefore = atoi(PD("before","0"));
684 int nLimit = msgBefore>0 ? atoi(PD("n","0")) : 0;
685 const int bRaw = P("raw")!=0;
686
@@ -624,64 +730,11 @@
730 }
731 db_prepare(&q1, "%s", blob_sql_text(&sql));
732 blob_reset(&sql);
733 blob_init(&json, "{\"msgs\":[\n", -1);
734 while( nDelay>0 ){
735 int cnt = chat_poll_rowstojson(&q1, zChatUser, bRaw, &json);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
736 if( cnt || msgBefore>0 ){
737 break;
738 }
739 sqlite3_sleep(iDelay); nDelay--;
740 while( nDelay>0 ){
@@ -697,10 +750,77 @@
750 blob_append(&json, "\n]}", 3);
751 cgi_set_content(&json);
752 return;
753 }
754
755
756 /*
757 ** WEBPAGE: chat-query hidden loadavg-exempt
758 */
759 void chat_query_webpage(void){
760 Blob json; /* The json to be constructed and returned */
761 Blob sql = empty_blob;
762 Stmt q1;
763 int nLimit = atoi(PD("n","500"));
764 int iFirst = atoi(PD("i","0"));
765 const char *zQuery = PD("q", "");
766 i64 iMin = 0;
767 i64 iMax = 0;
768
769 login_check_credentials();
770 if( !g.perm.Chat ) {
771 chat_emit_permissions_error(1);
772 return;
773 }
774 chat_create_tables();
775 cgi_set_content_type("application/json");
776
777 if( zQuery[0] ){
778 iMax = db_int64(0, "SELECT max(msgid) FROM chat");
779 iMin = db_int64(0, "SELECT min(msgid) FROM chat");
780 if( '#'==zQuery[0] ){
781 /* Assume we're looking for an exact msgid match. */
782 ++zQuery;
783 blob_append_sql(&sql,
784 "SELECT msgid, datetime(mtime), xfrom, "
785 " xmsg, octet_length(file), fname, fmime, mdel, lmtime "
786 " FROM chat WHERE msgid=+%Q",
787 zQuery
788 );
789 }else{
790 char * zPat = search_simplify_pattern(zQuery);
791 blob_append_sql(&sql,
792 "SELECT * FROM ("
793 "SELECT c.msgid, datetime(c.mtime), c.xfrom, "
794 " highlight(chatfts1, 0, '<span class=\"match\">', '</span>'), "
795 " octet_length(c.file), c.fname, c.fmime, c.mdel, c.lmtime "
796 " FROM chatfts1(%Q) f, chat c "
797 " WHERE f.rowid=c.msgid"
798 " ORDER BY f.rowid DESC LIMIT %d"
799 ") ORDER BY 1 ASC", zPat, nLimit
800 );
801 fossil_free(zPat);
802 }
803 }else{
804 blob_append_sql(&sql,
805 "SELECT msgid, datetime(mtime), xfrom, "
806 " xmsg, octet_length(file), fname, fmime, mdel, lmtime"
807 " FROM chat WHERE msgid>=%d LIMIT %d",
808 iFirst, nLimit
809 );
810 }
811
812 db_prepare(&q1, "%s", blob_sql_text(&sql));
813 blob_reset(&sql);
814 blob_init(&json, "{\"msgs\":[\n", -1);
815 chat_poll_rowstojson(&q1, "", 0, &json);
816 db_finalize(&q1);
817 blob_appendf(&json, "\n], \"first\":%lld, \"last\":%lld}", iMin, iMax);
818 cgi_set_content(&json);
819 return;
820 }
821
822 /*
823 ** WEBPAGE: chat-fetch-one hidden loadavg-exempt
824 **
825 ** /chat-fetch-one/N
826 **
827
--- src/fossil.page.chat.js
+++ src/fossil.page.chat.js
@@ -145,10 +145,12 @@
145145
inputFile: E1('#chat-input-file'),
146146
contentDiv: E1('div.content'),
147147
viewConfig: E1('#chat-config'),
148148
viewPreview: E1('#chat-preview'),
149149
previewContent: E1('#chat-preview-content'),
150
+ viewSearch: E1('#chat-search'),
151
+ searchContent: E1('#chat-search-content'),
150152
btnPreview: E1('#chat-button-preview'),
151153
views: document.querySelectorAll('.chat-view'),
152154
activeUserListWrapper: E1('#chat-user-list-wrapper'),
153155
activeUserList: E1('#chat-user-list'),
154156
btnClearFilter: E1('#chat-clear-filter')
@@ -190,21 +192,31 @@
190192
|| !!e.querySelector('[data-hashtag="'+this.activeTag+'"]');
191193
}
192194
},
193195
current: undefined/*gets set to current active filter*/
194196
},
195
- /** Gets (no args) or sets (1 arg) the current input text field value,
196
- taking into account single- vs multi-line input. The getter returns
197
- a string and the setter returns this object. */
198
- inputValue: function(){
197
+ /**
198
+ Gets (no args) or sets (1 arg) the current input text field
199
+ value, taking into account single- vs multi-line input. The
200
+ getter returns a trim()'d string and the setter returns this
201
+ object. As a special case, if arguments[0] is a boolean
202
+ value, it behaves like a getter and, if arguments[0]===true
203
+ it clears the input field before returning.
204
+ */
205
+ inputValue: function(/*string newValue | bool clearInputField*/){
199206
const e = this.inputElement();
200
- if(arguments.length){
207
+ if(arguments.length && 'boolean'!==typeof arguments[0]){
201208
if(e.isContentEditable) e.innerText = arguments[0];
202209
else e.value = arguments[0];
203210
return this;
204211
}
205
- return e.isContentEditable ? e.innerText : e.value;
212
+ const rc = e.isContentEditable ? e.innerText : e.value;
213
+ if( true===arguments[0] ){
214
+ if(e.isContentEditable) e.innerText = '';
215
+ else e.value = '';
216
+ }
217
+ return rc && rc.trim();
206218
},
207219
/** Asks the current user input field to take focus. Returns this. */
208220
inputFocus: function(){
209221
this.inputElement().focus();
210222
return this;
@@ -529,11 +541,11 @@
529541
const uDate = self.usersLastSeen[u];
530542
if(self.filter.user.activeTag===u){
531543
uSpan.classList.add('selected');
532544
}
533545
uSpan.dataset.uname = u;
534
- D.append(uSpan, u, "\n",
546
+ D.append(uSpan, u, "\n",
535547
D.append(
536548
D.addClass(D.span(),'timestamp'),
537549
localTimeString(uDate)//.substr(5/*chop off year*/)
538550
));
539551
if(uDate.$uColor){
@@ -1075,11 +1087,11 @@
10751087
Chat.MessageWidget = (function(){
10761088
/**
10771089
Constructor. If passed an argument, it is passed to
10781090
this.setMessage() after initialization.
10791091
*/
1080
- const cf = function(){
1092
+ const ctor = function(){
10811093
this.e = {
10821094
body: D.addClass(D.div(), 'message-widget'),
10831095
tab: D.addClass(D.div(), 'message-widget-tab'),
10841096
content: D.addClass(D.div(), 'message-widget-content')
10851097
};
@@ -1094,20 +1106,33 @@
10941106
/* Map of Date.getDay() values to weekday names. */
10951107
0: "Sunday", 1: "Monday", 2: "Tuesday",
10961108
3: "Wednesday", 4: "Thursday", 5: "Friday",
10971109
6: "Saturday"
10981110
};
1099
- /* Given a Date, returns the timestamp string used in the
1100
- "tab" part of message widgets. */
1101
- const theTime = function(d){
1102
- return [
1103
- //d.getFullYear(),'-',pad2(d.getMonth()+1/*sigh*/),
1104
- //'-',pad2(d.getDate()), ' ',
1105
- d.getHours(),":",
1106
- (d.getMinutes()+100).toString().slice(1,3),
1107
- ' ', dowMap[d.getDay()]
1108
- ].join('');
1111
+ /* Given a Date, returns the timestamp string used in the "tab"
1112
+ part of message widgets. If longFmt is true then a verbose
1113
+ format is used, else a brief format is used. The returned string
1114
+ is in client-local time. */
1115
+ const theTime = function(d, longFmt=false){
1116
+ const li = [];
1117
+ if( longFmt ){
1118
+ li.push(
1119
+ d.getFullYear(),
1120
+ '-', pad2(d.getMonth()+1),
1121
+ '-', pad2(d.getDate()),
1122
+ ' ',
1123
+ d.getHours(), ":",
1124
+ (d.getMinutes()+100).toString().slice(1,3)
1125
+ );
1126
+ }else{
1127
+ li.push(
1128
+ d.getHours(),":",
1129
+ (d.getMinutes()+100).toString().slice(1,3),
1130
+ ' ', dowMap[d.getDay()]
1131
+ );
1132
+ }
1133
+ return li.join('');
11091134
};
11101135
11111136
/**
11121137
Returns true if this page believes it can embed a view of the
11131138
file wrapped by the given message object, else returns false.
@@ -1114,19 +1139,20 @@
11141139
*/
11151140
const canEmbedFile = function f(msg){
11161141
if(!f.$rx){
11171142
f.$rx = /\.((html?)|(txt)|(md)|(wiki)|(pikchr))$/i;
11181143
f.$specificTypes = [
1144
+ /* Mime types we know we can embed, sans image/... */
11191145
'text/plain',
11201146
'text/html',
11211147
'text/x-markdown',
11221148
/* Firefox sends text/markdown when uploading .md files */
11231149
'text/markdown',
11241150
'text/x-pikchr',
11251151
'text/x-fossil-wiki'
1126
- // add more as we discover which ones Firefox won't
1127
- // force the user to try to download.
1152
+ /* Add more as we discover which ones Firefox won't
1153
+ force the user to try to download. */
11281154
];
11291155
}
11301156
if(msg.fmime){
11311157
if(msg.fmime.startsWith("image/")
11321158
|| f.$specificTypes.indexOf(msg.fmime)>=0){
@@ -1140,20 +1166,18 @@
11401166
Returns true if the given message object "should"
11411167
be embedded in fossil-rendered form instead of
11421168
raw content form. This is only intended to be passed
11431169
message objects for which canEmbedFile() returns true.
11441170
*/
1145
- const shouldWikiRenderEmbed = function f(msg){
1171
+ const shouldFossilRenderEmbed = function f(msg){
11461172
if(!f.$rx){
11471173
f.$rx = /\.((md)|(wiki)|(pikchr))$/i;
11481174
f.$specificTypes = [
11491175
'text/x-markdown',
11501176
'text/markdown' /* Firefox-uploaded md files */,
11511177
'text/x-pikchr',
11521178
'text/x-fossil-wiki'
1153
- // add more as we discover which ones Firefox won't
1154
- // force the user to try to download.
11551179
];
11561180
}
11571181
if(msg.fmime){
11581182
if(f.$specificTypes.indexOf(msg.fmime)>=0) return true;
11591183
}
@@ -1179,12 +1203,12 @@
11791203
iframe.style.maxHeight = iframe.style.height
11801204
= iframe.contentWindow.document.documentElement.scrollHeight + 'px';
11811205
if(isHidden) D.addClass(iframe, 'hidden');
11821206
}
11831207
};
1184
-
1185
- cf.prototype = {
1208
+
1209
+ ctor.prototype = {
11861210
scrollIntoView: function(){
11871211
this.e.content.scrollIntoView();
11881212
},
11891213
setMessage: function(m){
11901214
const ds = this.e.body.dataset;
@@ -1205,20 +1229,26 @@
12051229
var eXFrom /* element holding xfrom name */;
12061230
if(m.xfrom){
12071231
eXFrom = D.append(D.addClass(D.span(), 'xfrom'), m.xfrom);
12081232
const wrapper = D.append(
12091233
D.span(), eXFrom,
1210
- D.text(" #",(m.msgid||'???'),' @ ',theTime(d)))
1234
+ ' ',
1235
+ D.append(D.addClass(D.span(), 'msgid'),
1236
+ '#' + (m.msgid||'???')),
1237
+ (m.isSearchResult ? ' ' : ' @ '),
1238
+ D.append(D.addClass(D.span(), 'timestamp'),
1239
+ theTime(d,!!m.isSearchResult))
1240
+ );
12111241
D.append(this.e.tab, wrapper);
12121242
}else{/*notification*/
12131243
D.addClass(this.e.body, 'notification');
12141244
if(m.isError){
12151245
D.addClass([contentTarget, this.e.tab], 'error');
12161246
}
12171247
D.append(
12181248
this.e.tab,
1219
- D.append(D.code(), 'notification @ ',theTime(d))
1249
+ D.append(D.code(), 'notification @ ',theTime(d,false))
12201250
);
12211251
}
12221252
if( m.xfrom && m.fsize>0 ){
12231253
if( m.fmime
12241254
&& m.fmime.startsWith("image/")
@@ -1241,18 +1271,18 @@
12411271
D.attr(a,'target','_blank');
12421272
D.append(w, a);
12431273
if(canEmbedFile(m)){
12441274
/* Add an option to embed HTML attachments in an iframe. The primary
12451275
use case is attached diffs. */
1246
- const shouldWikiRender = shouldWikiRenderEmbed(m);
1247
- const downloadArgs = shouldWikiRender ? '?render' : '';
1276
+ const shouldFossilRender = shouldFossilRenderEmbed(m);
1277
+ const downloadArgs = shouldFossilRender ? '?render' : '';
12481278
D.addClass(contentTarget, 'wide');
12491279
const embedTarget = this.e.content;
12501280
const self = this;
12511281
const btnEmbed = D.attr(D.checkbox("1", false), 'id',
12521282
'embed-'+ds.msgid);
1253
- const btnLabel = D.label(btnEmbed, shouldWikiRender
1283
+ const btnLabel = D.label(btnEmbed, shouldFossilRender
12541284
? "Embed (fossil-rendered)" : "Embed");
12551285
/* Maintenance reminder: do not disable the toggle
12561286
button while the content is loading because that will
12571287
cause it to get stuck in disabled mode if the browser
12581288
decides that loading the content should prompt the
@@ -1460,13 +1490,182 @@
14601490
Chat.setCurrentView(Chat.e.viewMessages);
14611491
e.scrollIntoView(false);
14621492
Chat.animate(e, 'anim-fade-out-in');
14631493
}
14641494
};
1465
- return cf;
1495
+ return ctor;
14661496
})()/*MessageWidget*/;
14671497
1498
+ /**
1499
+ A widget for loading more messages (context) around a /chat-query
1500
+ result message.
1501
+ */
1502
+ Chat.SearchCtxLoader = (function(){
1503
+ const nMsgContext = 5;
1504
+ const zUpArrow = '\u25B2';
1505
+ const zDownArrow = '\u25BC';
1506
+ const ctor = function(o){
1507
+
1508
+ /* iFirstInTable:
1509
+ ** msgid of first row in chatfts table.
1510
+ **
1511
+ ** iLastInTable:
1512
+ ** msgid of last row in chatfts table.
1513
+ **
1514
+ ** iPrevId:
1515
+ ** msgid of message immediately above this spacer. Or 0 if this
1516
+ ** spacer is above all results.
1517
+ **
1518
+ ** iNextId:
1519
+ ** msgid of message immediately below this spacer. Or 0 if this
1520
+ ** spacer is below all results.
1521
+ **
1522
+ ** bIgnoreClick:
1523
+ ** ignore any clicks if this is true. This is used to ensure there
1524
+ ** is only ever one request belonging to this widget outstanding
1525
+ ** at any time.
1526
+ */
1527
+ this.o = {
1528
+ iFirstInTable: o.first,
1529
+ iLastInTable: o.last,
1530
+ iPrevId: o.previd,
1531
+ iNextId: o.nextid,
1532
+ bIgnoreClick: false
1533
+ };
1534
+
1535
+ this.e = {
1536
+ body: D.addClass(D.div(), 'spacer-widget'),
1537
+ up: D.addClass(
1538
+ D.button(zDownArrow+' Load '+nMsgContext+' more '+zDownArrow),
1539
+ 'up'
1540
+ ),
1541
+ down: D.addClass(
1542
+ D.button(zUpArrow+' Load '+nMsgContext+' more '+zUpArrow),
1543
+ 'down'
1544
+ ),
1545
+ all: D.addClass(D.button('Load More'), 'all')
1546
+ };
1547
+ D.append( this.e.body, this.e.up, this.e.down, this.e.all );
1548
+ const ms = this;
1549
+ this.e.up.addEventListener('click', ()=>ms.load_messages(false));
1550
+ this.e.down.addEventListener('click', ()=>ms.load_messages(true));
1551
+ this.e.all.addEventListener('click', ()=>ms.load_messages( (ms.o.iPrevId==0) ));
1552
+ this.set_button_visibility();
1553
+ };
1554
+
1555
+ ctor.prototype = {
1556
+ set_button_visibility: function() {
1557
+ if( !this.e ) return;
1558
+ const o = this.o;
1559
+
1560
+ const iPrevId = (o.iPrevId!=0) ? o.iPrevId : o.iFirstInTable-1;
1561
+ const iNextId = (o.iNextId!=0) ? o.iNextId : o.iLastInTable+1;
1562
+ let nDiff = (iNextId - iPrevId) - 1;
1563
+
1564
+ for( const x of [this.e.up, this.e.down, this.e.all] ){
1565
+ if( x ) D.addClass(x, 'hidden');
1566
+ }
1567
+ let nVisible = 0;
1568
+ if( nDiff>0 ){
1569
+ if( nDiff>nMsgContext && (o.iPrevId==0 || o.iNextId==0) ){
1570
+ nDiff = nMsgContext;
1571
+ }
1572
+
1573
+ if( nDiff<=nMsgContext && o.iPrevId!=0 && o.iNextId!=0 ){
1574
+ D.removeClass(this.e.all, 'hidden');
1575
+ ++nVisible;
1576
+ this.e.all.innerText = (
1577
+ zUpArrow + " Load " + nDiff + " more " + zDownArrow
1578
+ );
1579
+ }else{
1580
+ if( o.iPrevId!=0 ){
1581
+ ++nVisible;
1582
+ D.removeClass(this.e.up, 'hidden');
1583
+ }else if( this.e.up ){
1584
+ if( this.e.up.parentNode ) D.remove(this.e.up);
1585
+ delete this.e.up;
1586
+ }
1587
+ if( o.iNextId!=0 ){
1588
+ ++nVisible;
1589
+ D.removeClass(this.e.down, 'hidden');
1590
+ }else if( this.e.down ){
1591
+ if( this.e.down.parentNode ) D.remove( this.e.down );
1592
+ delete this.e.down;
1593
+ }
1594
+ }
1595
+ }
1596
+ if( !nVisible ){
1597
+ /* The DOM elements can now be disposed of. */
1598
+ for( const x of [this.e.up, this.e.down, this.e.all, this.e.body] ){
1599
+ if( x?.parentNode ) D.remove(x);
1600
+ }
1601
+ delete this.e;
1602
+ }
1603
+ },
1604
+
1605
+ load_messages: function(bDown) {
1606
+ if( this.bIgnoreClick ) return;
1607
+
1608
+ var iFirst = 0; /* msgid of first message to fetch */
1609
+ var nFetch = 0; /* Number of messages to fetch */
1610
+ var iEof = 0; /* last msgid in spacers range, plus 1 */
1611
+
1612
+ const e = this.e, o = this.o;
1613
+ this.bIgnoreClick = true;
1614
+
1615
+ /* Figure out the required range of messages. */
1616
+ if( bDown ){
1617
+ iFirst = this.o.iNextId - nMsgContext;
1618
+ if( iFirst<this.o.iFirstInTable ){
1619
+ iFirst = this.o.iFirstInTable;
1620
+ }
1621
+ }else{
1622
+ iFirst = this.o.iPrevId+1;
1623
+ }
1624
+ nFetch = nMsgContext;
1625
+ iEof = (this.o.iNextId > 0) ? this.o.iNextId : this.o.iLastInTable+1;
1626
+ if( iFirst+nFetch>iEof ){
1627
+ nFetch = iEof - iFirst;
1628
+ }
1629
+ const ms = this;
1630
+ F.fetch("chat-query",{
1631
+ urlParams:{
1632
+ q: '',
1633
+ n: nFetch,
1634
+ i: iFirst
1635
+ },
1636
+ responseType: "json",
1637
+ onload:function(jx){
1638
+ if( bDown ) jx.msgs.reverse();
1639
+ jx.msgs.forEach((m) => {
1640
+ var mw = new Chat.MessageWidget(m);
1641
+ if( bDown ){
1642
+ /* Inject the message below this object's body, or
1643
+ append it to Chat.e.searchContent if this element
1644
+ is the final one in its parent (Chat.e.searchContent). */
1645
+ const eAnchor = e.body.nextElementSibling;
1646
+ if( eAnchor ) Chat.e.searchContent.insertBefore(mw.e.body, eAnchor);
1647
+ else D.append(Chat.e.searchContent, mw.e.body);
1648
+ }else{
1649
+ Chat.e.searchContent.insertBefore(mw.e.body, e.body);
1650
+ }
1651
+ });
1652
+ if( bDown ){
1653
+ o.iNextId -= jx.msgs.length;
1654
+ }else{
1655
+ o.iPrevId += jx.msgs.length;
1656
+ }
1657
+ ms.set_button_visibility();
1658
+ ms.bIgnoreClick = false;
1659
+ }
1660
+ });
1661
+ }
1662
+ };
1663
+
1664
+ return ctor;
1665
+ })() /*SearchCtxLoader*/;
1666
+
14681667
const BlobXferState = (function(){
14691668
/* State for paste and drag/drop */
14701669
const bxs = {
14711670
dropDetails: document.querySelector('#chat-drop-details'),
14721671
blob: undefined,
@@ -1605,16 +1804,26 @@
16051804
16061805
/**
16071806
Submits the contents of the message input field (if not empty)
16081807
and/or the file attachment field to the server. If both are
16091808
empty, this is a no-op.
1809
+
1810
+ If the current view is the history search, this instead sends the
1811
+ input text to that widget.
16101812
*/
16111813
Chat.submitMessage = function f(){
16121814
if(!f.spaces){
16131815
f.spaces = /\s+$/;
16141816
f.markdownContinuation = /\\\s+$/;
16151817
f.spaces2 = /\s{3,}$/;
1818
+ }
1819
+ switch( this.e.currentView ){
1820
+ case this.e.viewSearch: this.submitSearch();
1821
+ return;
1822
+ case this.e.viewPreview: this.e.btnPreview.click();
1823
+ return;
1824
+ default: break;
16161825
}
16171826
this.setCurrentView(this.e.viewMessages);
16181827
const fd = new FormData();
16191828
const fallback = {msg: this.inputValue()};
16201829
var msg = fallback.msg;
@@ -1687,14 +1896,16 @@
16871896
//console.debug("Enter key event:", ctrlMode, ev.ctrlKey, ev.shiftKey, ev);
16881897
if(ev.shiftKey){
16891898
const compactMode = Chat.settings.getBool('edit-compact-mode', false);
16901899
ev.preventDefault();
16911900
ev.stopPropagation();
1692
- /* Shift-enter will run preview mode UNLESS preview mode is
1693
- active AND the input field is empty, in which case it will
1901
+ /* Shift-enter will run preview mode UNLESS the input field is empty
1902
+ AND (preview or search mode) is active, in which cases it will
16941903
switch back to message view. */
1695
- if(Chat.e.currentView===Chat.e.viewPreview && !text){
1904
+ if(!text &&
1905
+ (Chat.e.currentView===Chat.e.viewPreview
1906
+ | Chat.e.currentView===Chat.e.viewSearch)){
16961907
Chat.setCurrentView(Chat.e.viewMessages);
16971908
}else if(!text){
16981909
f.$toggleCompact(compactMode);
16991910
}else if(Chat.settings.getBool('edit-shift-enter-preview', true)){
17001911
Chat.e.btnPreview.click();
@@ -1752,19 +1963,19 @@
17521963
tall vs wide. Can be toggled via settings. */
17531964
document.body.classList.add('my-messages-right');
17541965
}
17551966
const settingsButton = document.querySelector('#chat-button-settings');
17561967
const optionsMenu = E1('#chat-config-options');
1757
- const cbToggle = function(ev){
1968
+ const eToggleView = function(ev){
17581969
ev.preventDefault();
17591970
ev.stopPropagation();
17601971
Chat.setCurrentView(Chat.e.currentView===Chat.e.viewConfig
17611972
? Chat.e.viewMessages : Chat.e.viewConfig);
17621973
return false;
17631974
};
1764
- D.attr(settingsButton, 'role', 'button').addEventListener('click', cbToggle, false);
1765
- Chat.e.viewConfig.querySelector('button').addEventListener('click', cbToggle, false);
1975
+ D.attr(settingsButton, 'role', 'button').addEventListener('click', eToggleView, false);
1976
+ Chat.e.viewConfig.querySelector('button.action-close').addEventListener('click', eToggleView, false);
17661977
17671978
/** Internal acrobatics to allow certain settings toggles to access
17681979
related toggles. */
17691980
const namedOptions = {
17701981
activeUsers:{
@@ -1850,12 +2061,13 @@
18502061
boolValue: 'edit-ctrl-send'
18512062
},{
18522063
label: "Compact mode",
18532064
hint: [
18542065
"Toggle between a space-saving or more spacious writing area. ",
1855
- "When the input field has focus, is empty, and preview mode ",
1856
- "is NOT active then Shift-Enter toggles this setting."].join(''),
2066
+ "When the input field has focus and is empty ",
2067
+ "then Shift-Enter may (depending on the current view) toggle this setting."
2068
+ ].join(''),
18572069
boolValue: 'edit-compact-mode'
18582070
},{
18592071
label: "Use 'contenteditable' editing mode",
18602072
boolValue: 'edit-widget-x',
18612073
hint: [
@@ -2020,11 +2232,11 @@
20202232
op.persistentSetting,
20212233
function(setting){
20222234
if(op.checkbox) op.checkbox.checked = !!setting.value;
20232235
else if(op.select) op.select.value = setting.value;
20242236
if(op.callback) op.callback(setting);
2025
- }
2237
+ }
20262238
);
20272239
if(op.checkbox){
20282240
op.checkbox.addEventListener(
20292241
'change', function(){
20302242
Chat.settings.set(op.persistentSetting, op.checkbox.checked)
@@ -2096,11 +2308,11 @@
20962308
s.value ? 'add' : 'remove'
20972309
]('compact');
20982310
Chat.e.inputFields[Chat.e.inputFields.$currentIndex].focus();
20992311
});
21002312
Chat.settings.addListener('edit-ctrl-send',function(s){
2101
- const label = (s.value ? "Ctrl-" : "")+"Enter submits messages.";
2313
+ const label = (s.value ? "Ctrl-" : "")+"Enter submits message";
21022314
Chat.e.inputFields.forEach((e)=>{
21032315
const v = e.dataset.placeholder0 + " " +label;
21042316
if(e.isContentEditable) e.dataset.placeholder = v;
21052317
else D.attr(e,'placeholder',v);
21062318
});
@@ -2128,11 +2340,11 @@
21282340
this.e.previewContent.innerHTML = t;
21292341
this.e.viewPreview.querySelectorAll('a').forEach(addAnchorTargetBlank);
21302342
setupHashtags(this.e.previewContent)/*arguable, for usability reasons*/;
21312343
this.inputFocus();
21322344
};
2133
- Chat.e.viewPreview.querySelector('#chat-preview-close').
2345
+ Chat.e.viewPreview.querySelector('button.action-close').
21342346
addEventListener('click', ()=>Chat.setCurrentView(Chat.e.viewMessages), false);
21352347
let previewPending = false;
21362348
const elemsToEnable = [btnPreview, Chat.e.btnSubmit, Chat.e.inputFields];
21372349
const submit = function(ev){
21382350
ev.preventDefault();
@@ -2175,10 +2387,40 @@
21752387
});
21762388
return false;
21772389
};
21782390
btnPreview.addEventListener('click', submit, false);
21792391
})()/*message preview setup*/;
2392
+
2393
+ (function(){/*Set up #chat-search and related bits */
2394
+ const btn = document.querySelector('#chat-button-search');
2395
+ D.attr(btn, 'role', 'button').addEventListener('click', function(ev){
2396
+ ev.preventDefault();
2397
+ ev.stopPropagation();
2398
+ const msg = Chat.inputValue();
2399
+ if( Chat.e.currentView===Chat.e.viewSearch ){
2400
+ if( msg ) Chat.submitSearch();
2401
+ else Chat.setCurrentView(Chat.e.viewMessages);
2402
+ }else{
2403
+ Chat.setCurrentView(Chat.e.viewSearch);
2404
+ if( msg ) Chat.submitSearch();
2405
+ }
2406
+ return false;
2407
+ }, false);
2408
+ Chat.e.viewSearch.querySelector('button.action-clear').addEventListener('click', function(ev){
2409
+ ev.preventDefault();
2410
+ ev.stopPropagation();
2411
+ Chat.clearSearch(true);
2412
+ Chat.setCurrentView(Chat.e.viewMessages);
2413
+ return false;
2414
+ }, false);
2415
+ Chat.e.viewSearch.querySelector('button.action-close').addEventListener('click', function(ev){
2416
+ ev.preventDefault();
2417
+ ev.stopPropagation();
2418
+ Chat.setCurrentView(Chat.e.viewMessages);
2419
+ return false;
2420
+ }, false);
2421
+ })()/*search view setup*/;
21802422
21812423
/** Callback for poll() to inject new content into the page. jx ==
21822424
the response from /chat-poll. If atEnd is true, the message is
21832425
appended to the end of the chat list (for loading older
21842426
messages), else the beginning (the default). */
@@ -2307,10 +2549,82 @@
23072549
btn.addEventListener('click',()=>loadOldMessages(-1));
23082550
D.append(Chat.e.viewMessages, toolbar);
23092551
toolbar.disabled = true /*will be enabled when msg load finishes */;
23102552
})()/*end history loading widget setup*/;
23112553
2554
+ /**
2555
+ Clears the search result view. If addInstructions is true it adds
2556
+ text to that view instructing the user to enter their query into
2557
+ the message-entry widget (noting that that widget has text
2558
+ implying that it's only for submitting a message, which isn't
2559
+ exactly true when the search view is active).
2560
+
2561
+ Returns the DOM element which wraps all of the chat search
2562
+ result elements.
2563
+ */
2564
+ Chat.clearSearch = function(addInstructions=false){
2565
+ const e = D.clearElement( this.e.searchContent );
2566
+ if(addInstructions){
2567
+ D.append(e, "Enter search terms in the message field. "+
2568
+ "Use #NNNNN to search for the message with ID NNNNN.");
2569
+ }
2570
+ return e;
2571
+ };
2572
+ Chat.clearSearch(true);
2573
+ /**
2574
+ Submits a history search using the main input field's current
2575
+ text. It is assumed that Chat.e.viewSearch===Chat.e.currentView.
2576
+ */
2577
+ Chat.submitSearch = function(){
2578
+ const term = this.inputValue(true);
2579
+ const eMsgTgt = this.clearSearch(true);
2580
+ if( !term ) return;
2581
+ D.append( eMsgTgt, "Searching for ",term," ...");
2582
+ const fd = new FormData();
2583
+ fd.set('q', term);
2584
+ F.fetch(
2585
+ "chat-query", {
2586
+ payload: fd,
2587
+ responseType: 'json',
2588
+ onerror:function(err){
2589
+ Chat.setCurrentView(Chat.e.viewMessages);
2590
+ Chat.reportErrorAsMessage(err);
2591
+ },
2592
+ onload:function(jx){
2593
+ let previd = 0;
2594
+ D.clearElement(eMsgTgt);
2595
+ jx.msgs.forEach((m)=>{
2596
+ m.isSearchResult = true;
2597
+ const mw = new Chat.MessageWidget(m);
2598
+ const spacer = new Chat.SearchCtxLoader({
2599
+ first: jx.first,
2600
+ last: jx.last,
2601
+ previd: previd,
2602
+ nextid: m.msgid
2603
+ });
2604
+ if( spacer.e ) D.append( eMsgTgt, spacer.e.body );
2605
+ D.append( eMsgTgt, mw.e.body );
2606
+ previd = m.msgid;
2607
+ });
2608
+ if( jx.msgs.length ){
2609
+ const spacer = new Chat.SearchCtxLoader({
2610
+ first: jx.first,
2611
+ last: jx.last,
2612
+ previd: previd,
2613
+ nextid: 0
2614
+ });
2615
+ if( spacer.e ) D.append( eMsgTgt, spacer.e.body );
2616
+ }else{
2617
+ D.append( D.clearElement(eMsgTgt),
2618
+ 'No search results found for: ',
2619
+ term );
2620
+ }
2621
+ }
2622
+ }
2623
+ );
2624
+ }/*Chat.submitSearch()*/;
2625
+
23122626
const afterFetch = function f(){
23132627
if(true===f.isFirstCall){
23142628
f.isFirstCall = false;
23152629
Chat.ajaxEnd();
23162630
Chat.e.viewMessages.classList.remove('loading');
@@ -2326,10 +2640,25 @@
23262640
delete Chat.intervalTimer;
23272641
}
23282642
poll.running = false;
23292643
};
23302644
afterFetch.isFirstCall = true;
2645
+ /**
2646
+ FIXME: when polling fails because the remote server is
2647
+ reachable but it's not accepting HTTP requests, we should back
2648
+ off on polling for a while. e.g. if the remote web server process
2649
+ is killed, the poll fails quickly and immediately retries,
2650
+ hammering the remote server until the httpd is back up. That
2651
+ happens often during development of this application.
2652
+
2653
+ XHR does not offer a direct way of distinguishing between
2654
+ HTTP/connection errors, but we can hypothetically use the
2655
+ xhrRequest.status value to do so, with status==0 being a
2656
+ connection error. We do not currently have a clean way of passing
2657
+ that info back to the fossil.fetch() client, so we'll need to
2658
+ hammer on that API a bit to get this working.
2659
+ */
23312660
const poll = async function f(){
23322661
if(f.running) return;
23332662
f.running = true;
23342663
Chat._isBatchLoading = f.isFirstCall;
23352664
if(true===f.isFirstCall){
@@ -2368,17 +2697,11 @@
23682697
Chat._gotServerError = poll.running = false;
23692698
if( window.fossil.config.chat.fromcli ){
23702699
Chat.chatOnlyMode(true);
23712700
}
23722701
Chat.intervalTimer = setInterval(poll, 1000);
2373
- if(0){
2374
- const flip = (ev)=>Chat.animate(ev.target,'anim-flip-h');
2375
- document.querySelectorAll('#chat-buttons-wrapper .cbutton').forEach(function(e){
2376
- e.addEventListener('click',flip, false);
2377
- });
2378
- }
23792702
delete ForceResizeKludge.$disabled;
23802703
ForceResizeKludge();
23812704
Chat.animate.$disabled = false;
23822705
setTimeout( ()=>Chat.inputFocus(), 0 );
23832706
F.page.chat = Chat/* enables testing the APIs via the dev tools */;
23842707
});
23852708
--- src/fossil.page.chat.js
+++ src/fossil.page.chat.js
@@ -145,10 +145,12 @@
145 inputFile: E1('#chat-input-file'),
146 contentDiv: E1('div.content'),
147 viewConfig: E1('#chat-config'),
148 viewPreview: E1('#chat-preview'),
149 previewContent: E1('#chat-preview-content'),
 
 
150 btnPreview: E1('#chat-button-preview'),
151 views: document.querySelectorAll('.chat-view'),
152 activeUserListWrapper: E1('#chat-user-list-wrapper'),
153 activeUserList: E1('#chat-user-list'),
154 btnClearFilter: E1('#chat-clear-filter')
@@ -190,21 +192,31 @@
190 || !!e.querySelector('[data-hashtag="'+this.activeTag+'"]');
191 }
192 },
193 current: undefined/*gets set to current active filter*/
194 },
195 /** Gets (no args) or sets (1 arg) the current input text field value,
196 taking into account single- vs multi-line input. The getter returns
197 a string and the setter returns this object. */
198 inputValue: function(){
 
 
 
 
 
199 const e = this.inputElement();
200 if(arguments.length){
201 if(e.isContentEditable) e.innerText = arguments[0];
202 else e.value = arguments[0];
203 return this;
204 }
205 return e.isContentEditable ? e.innerText : e.value;
 
 
 
 
 
206 },
207 /** Asks the current user input field to take focus. Returns this. */
208 inputFocus: function(){
209 this.inputElement().focus();
210 return this;
@@ -529,11 +541,11 @@
529 const uDate = self.usersLastSeen[u];
530 if(self.filter.user.activeTag===u){
531 uSpan.classList.add('selected');
532 }
533 uSpan.dataset.uname = u;
534 D.append(uSpan, u, "\n",
535 D.append(
536 D.addClass(D.span(),'timestamp'),
537 localTimeString(uDate)//.substr(5/*chop off year*/)
538 ));
539 if(uDate.$uColor){
@@ -1075,11 +1087,11 @@
1075 Chat.MessageWidget = (function(){
1076 /**
1077 Constructor. If passed an argument, it is passed to
1078 this.setMessage() after initialization.
1079 */
1080 const cf = function(){
1081 this.e = {
1082 body: D.addClass(D.div(), 'message-widget'),
1083 tab: D.addClass(D.div(), 'message-widget-tab'),
1084 content: D.addClass(D.div(), 'message-widget-content')
1085 };
@@ -1094,20 +1106,33 @@
1094 /* Map of Date.getDay() values to weekday names. */
1095 0: "Sunday", 1: "Monday", 2: "Tuesday",
1096 3: "Wednesday", 4: "Thursday", 5: "Friday",
1097 6: "Saturday"
1098 };
1099 /* Given a Date, returns the timestamp string used in the
1100 "tab" part of message widgets. */
1101 const theTime = function(d){
1102 return [
1103 //d.getFullYear(),'-',pad2(d.getMonth()+1/*sigh*/),
1104 //'-',pad2(d.getDate()), ' ',
1105 d.getHours(),":",
1106 (d.getMinutes()+100).toString().slice(1,3),
1107 ' ', dowMap[d.getDay()]
1108 ].join('');
 
 
 
 
 
 
 
 
 
 
 
 
 
1109 };
1110
1111 /**
1112 Returns true if this page believes it can embed a view of the
1113 file wrapped by the given message object, else returns false.
@@ -1114,19 +1139,20 @@
1114 */
1115 const canEmbedFile = function f(msg){
1116 if(!f.$rx){
1117 f.$rx = /\.((html?)|(txt)|(md)|(wiki)|(pikchr))$/i;
1118 f.$specificTypes = [
 
1119 'text/plain',
1120 'text/html',
1121 'text/x-markdown',
1122 /* Firefox sends text/markdown when uploading .md files */
1123 'text/markdown',
1124 'text/x-pikchr',
1125 'text/x-fossil-wiki'
1126 // add more as we discover which ones Firefox won't
1127 // force the user to try to download.
1128 ];
1129 }
1130 if(msg.fmime){
1131 if(msg.fmime.startsWith("image/")
1132 || f.$specificTypes.indexOf(msg.fmime)>=0){
@@ -1140,20 +1166,18 @@
1140 Returns true if the given message object "should"
1141 be embedded in fossil-rendered form instead of
1142 raw content form. This is only intended to be passed
1143 message objects for which canEmbedFile() returns true.
1144 */
1145 const shouldWikiRenderEmbed = function f(msg){
1146 if(!f.$rx){
1147 f.$rx = /\.((md)|(wiki)|(pikchr))$/i;
1148 f.$specificTypes = [
1149 'text/x-markdown',
1150 'text/markdown' /* Firefox-uploaded md files */,
1151 'text/x-pikchr',
1152 'text/x-fossil-wiki'
1153 // add more as we discover which ones Firefox won't
1154 // force the user to try to download.
1155 ];
1156 }
1157 if(msg.fmime){
1158 if(f.$specificTypes.indexOf(msg.fmime)>=0) return true;
1159 }
@@ -1179,12 +1203,12 @@
1179 iframe.style.maxHeight = iframe.style.height
1180 = iframe.contentWindow.document.documentElement.scrollHeight + 'px';
1181 if(isHidden) D.addClass(iframe, 'hidden');
1182 }
1183 };
1184
1185 cf.prototype = {
1186 scrollIntoView: function(){
1187 this.e.content.scrollIntoView();
1188 },
1189 setMessage: function(m){
1190 const ds = this.e.body.dataset;
@@ -1205,20 +1229,26 @@
1205 var eXFrom /* element holding xfrom name */;
1206 if(m.xfrom){
1207 eXFrom = D.append(D.addClass(D.span(), 'xfrom'), m.xfrom);
1208 const wrapper = D.append(
1209 D.span(), eXFrom,
1210 D.text(" #",(m.msgid||'???'),' @ ',theTime(d)))
 
 
 
 
 
 
1211 D.append(this.e.tab, wrapper);
1212 }else{/*notification*/
1213 D.addClass(this.e.body, 'notification');
1214 if(m.isError){
1215 D.addClass([contentTarget, this.e.tab], 'error');
1216 }
1217 D.append(
1218 this.e.tab,
1219 D.append(D.code(), 'notification @ ',theTime(d))
1220 );
1221 }
1222 if( m.xfrom && m.fsize>0 ){
1223 if( m.fmime
1224 && m.fmime.startsWith("image/")
@@ -1241,18 +1271,18 @@
1241 D.attr(a,'target','_blank');
1242 D.append(w, a);
1243 if(canEmbedFile(m)){
1244 /* Add an option to embed HTML attachments in an iframe. The primary
1245 use case is attached diffs. */
1246 const shouldWikiRender = shouldWikiRenderEmbed(m);
1247 const downloadArgs = shouldWikiRender ? '?render' : '';
1248 D.addClass(contentTarget, 'wide');
1249 const embedTarget = this.e.content;
1250 const self = this;
1251 const btnEmbed = D.attr(D.checkbox("1", false), 'id',
1252 'embed-'+ds.msgid);
1253 const btnLabel = D.label(btnEmbed, shouldWikiRender
1254 ? "Embed (fossil-rendered)" : "Embed");
1255 /* Maintenance reminder: do not disable the toggle
1256 button while the content is loading because that will
1257 cause it to get stuck in disabled mode if the browser
1258 decides that loading the content should prompt the
@@ -1460,13 +1490,182 @@
1460 Chat.setCurrentView(Chat.e.viewMessages);
1461 e.scrollIntoView(false);
1462 Chat.animate(e, 'anim-fade-out-in');
1463 }
1464 };
1465 return cf;
1466 })()/*MessageWidget*/;
1467
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1468 const BlobXferState = (function(){
1469 /* State for paste and drag/drop */
1470 const bxs = {
1471 dropDetails: document.querySelector('#chat-drop-details'),
1472 blob: undefined,
@@ -1605,16 +1804,26 @@
1605
1606 /**
1607 Submits the contents of the message input field (if not empty)
1608 and/or the file attachment field to the server. If both are
1609 empty, this is a no-op.
 
 
 
1610 */
1611 Chat.submitMessage = function f(){
1612 if(!f.spaces){
1613 f.spaces = /\s+$/;
1614 f.markdownContinuation = /\\\s+$/;
1615 f.spaces2 = /\s{3,}$/;
 
 
 
 
 
 
 
1616 }
1617 this.setCurrentView(this.e.viewMessages);
1618 const fd = new FormData();
1619 const fallback = {msg: this.inputValue()};
1620 var msg = fallback.msg;
@@ -1687,14 +1896,16 @@
1687 //console.debug("Enter key event:", ctrlMode, ev.ctrlKey, ev.shiftKey, ev);
1688 if(ev.shiftKey){
1689 const compactMode = Chat.settings.getBool('edit-compact-mode', false);
1690 ev.preventDefault();
1691 ev.stopPropagation();
1692 /* Shift-enter will run preview mode UNLESS preview mode is
1693 active AND the input field is empty, in which case it will
1694 switch back to message view. */
1695 if(Chat.e.currentView===Chat.e.viewPreview && !text){
 
 
1696 Chat.setCurrentView(Chat.e.viewMessages);
1697 }else if(!text){
1698 f.$toggleCompact(compactMode);
1699 }else if(Chat.settings.getBool('edit-shift-enter-preview', true)){
1700 Chat.e.btnPreview.click();
@@ -1752,19 +1963,19 @@
1752 tall vs wide. Can be toggled via settings. */
1753 document.body.classList.add('my-messages-right');
1754 }
1755 const settingsButton = document.querySelector('#chat-button-settings');
1756 const optionsMenu = E1('#chat-config-options');
1757 const cbToggle = function(ev){
1758 ev.preventDefault();
1759 ev.stopPropagation();
1760 Chat.setCurrentView(Chat.e.currentView===Chat.e.viewConfig
1761 ? Chat.e.viewMessages : Chat.e.viewConfig);
1762 return false;
1763 };
1764 D.attr(settingsButton, 'role', 'button').addEventListener('click', cbToggle, false);
1765 Chat.e.viewConfig.querySelector('button').addEventListener('click', cbToggle, false);
1766
1767 /** Internal acrobatics to allow certain settings toggles to access
1768 related toggles. */
1769 const namedOptions = {
1770 activeUsers:{
@@ -1850,12 +2061,13 @@
1850 boolValue: 'edit-ctrl-send'
1851 },{
1852 label: "Compact mode",
1853 hint: [
1854 "Toggle between a space-saving or more spacious writing area. ",
1855 "When the input field has focus, is empty, and preview mode ",
1856 "is NOT active then Shift-Enter toggles this setting."].join(''),
 
1857 boolValue: 'edit-compact-mode'
1858 },{
1859 label: "Use 'contenteditable' editing mode",
1860 boolValue: 'edit-widget-x',
1861 hint: [
@@ -2020,11 +2232,11 @@
2020 op.persistentSetting,
2021 function(setting){
2022 if(op.checkbox) op.checkbox.checked = !!setting.value;
2023 else if(op.select) op.select.value = setting.value;
2024 if(op.callback) op.callback(setting);
2025 }
2026 );
2027 if(op.checkbox){
2028 op.checkbox.addEventListener(
2029 'change', function(){
2030 Chat.settings.set(op.persistentSetting, op.checkbox.checked)
@@ -2096,11 +2308,11 @@
2096 s.value ? 'add' : 'remove'
2097 ]('compact');
2098 Chat.e.inputFields[Chat.e.inputFields.$currentIndex].focus();
2099 });
2100 Chat.settings.addListener('edit-ctrl-send',function(s){
2101 const label = (s.value ? "Ctrl-" : "")+"Enter submits messages.";
2102 Chat.e.inputFields.forEach((e)=>{
2103 const v = e.dataset.placeholder0 + " " +label;
2104 if(e.isContentEditable) e.dataset.placeholder = v;
2105 else D.attr(e,'placeholder',v);
2106 });
@@ -2128,11 +2340,11 @@
2128 this.e.previewContent.innerHTML = t;
2129 this.e.viewPreview.querySelectorAll('a').forEach(addAnchorTargetBlank);
2130 setupHashtags(this.e.previewContent)/*arguable, for usability reasons*/;
2131 this.inputFocus();
2132 };
2133 Chat.e.viewPreview.querySelector('#chat-preview-close').
2134 addEventListener('click', ()=>Chat.setCurrentView(Chat.e.viewMessages), false);
2135 let previewPending = false;
2136 const elemsToEnable = [btnPreview, Chat.e.btnSubmit, Chat.e.inputFields];
2137 const submit = function(ev){
2138 ev.preventDefault();
@@ -2175,10 +2387,40 @@
2175 });
2176 return false;
2177 };
2178 btnPreview.addEventListener('click', submit, false);
2179 })()/*message preview setup*/;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2180
2181 /** Callback for poll() to inject new content into the page. jx ==
2182 the response from /chat-poll. If atEnd is true, the message is
2183 appended to the end of the chat list (for loading older
2184 messages), else the beginning (the default). */
@@ -2307,10 +2549,82 @@
2307 btn.addEventListener('click',()=>loadOldMessages(-1));
2308 D.append(Chat.e.viewMessages, toolbar);
2309 toolbar.disabled = true /*will be enabled when msg load finishes */;
2310 })()/*end history loading widget setup*/;
2311
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2312 const afterFetch = function f(){
2313 if(true===f.isFirstCall){
2314 f.isFirstCall = false;
2315 Chat.ajaxEnd();
2316 Chat.e.viewMessages.classList.remove('loading');
@@ -2326,10 +2640,25 @@
2326 delete Chat.intervalTimer;
2327 }
2328 poll.running = false;
2329 };
2330 afterFetch.isFirstCall = true;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2331 const poll = async function f(){
2332 if(f.running) return;
2333 f.running = true;
2334 Chat._isBatchLoading = f.isFirstCall;
2335 if(true===f.isFirstCall){
@@ -2368,17 +2697,11 @@
2368 Chat._gotServerError = poll.running = false;
2369 if( window.fossil.config.chat.fromcli ){
2370 Chat.chatOnlyMode(true);
2371 }
2372 Chat.intervalTimer = setInterval(poll, 1000);
2373 if(0){
2374 const flip = (ev)=>Chat.animate(ev.target,'anim-flip-h');
2375 document.querySelectorAll('#chat-buttons-wrapper .cbutton').forEach(function(e){
2376 e.addEventListener('click',flip, false);
2377 });
2378 }
2379 delete ForceResizeKludge.$disabled;
2380 ForceResizeKludge();
2381 Chat.animate.$disabled = false;
2382 setTimeout( ()=>Chat.inputFocus(), 0 );
2383 F.page.chat = Chat/* enables testing the APIs via the dev tools */;
2384 });
2385
--- src/fossil.page.chat.js
+++ src/fossil.page.chat.js
@@ -145,10 +145,12 @@
145 inputFile: E1('#chat-input-file'),
146 contentDiv: E1('div.content'),
147 viewConfig: E1('#chat-config'),
148 viewPreview: E1('#chat-preview'),
149 previewContent: E1('#chat-preview-content'),
150 viewSearch: E1('#chat-search'),
151 searchContent: E1('#chat-search-content'),
152 btnPreview: E1('#chat-button-preview'),
153 views: document.querySelectorAll('.chat-view'),
154 activeUserListWrapper: E1('#chat-user-list-wrapper'),
155 activeUserList: E1('#chat-user-list'),
156 btnClearFilter: E1('#chat-clear-filter')
@@ -190,21 +192,31 @@
192 || !!e.querySelector('[data-hashtag="'+this.activeTag+'"]');
193 }
194 },
195 current: undefined/*gets set to current active filter*/
196 },
197 /**
198 Gets (no args) or sets (1 arg) the current input text field
199 value, taking into account single- vs multi-line input. The
200 getter returns a trim()'d string and the setter returns this
201 object. As a special case, if arguments[0] is a boolean
202 value, it behaves like a getter and, if arguments[0]===true
203 it clears the input field before returning.
204 */
205 inputValue: function(/*string newValue | bool clearInputField*/){
206 const e = this.inputElement();
207 if(arguments.length && 'boolean'!==typeof arguments[0]){
208 if(e.isContentEditable) e.innerText = arguments[0];
209 else e.value = arguments[0];
210 return this;
211 }
212 const rc = e.isContentEditable ? e.innerText : e.value;
213 if( true===arguments[0] ){
214 if(e.isContentEditable) e.innerText = '';
215 else e.value = '';
216 }
217 return rc && rc.trim();
218 },
219 /** Asks the current user input field to take focus. Returns this. */
220 inputFocus: function(){
221 this.inputElement().focus();
222 return this;
@@ -529,11 +541,11 @@
541 const uDate = self.usersLastSeen[u];
542 if(self.filter.user.activeTag===u){
543 uSpan.classList.add('selected');
544 }
545 uSpan.dataset.uname = u;
546 D.append(uSpan, u, "\n",
547 D.append(
548 D.addClass(D.span(),'timestamp'),
549 localTimeString(uDate)//.substr(5/*chop off year*/)
550 ));
551 if(uDate.$uColor){
@@ -1075,11 +1087,11 @@
1087 Chat.MessageWidget = (function(){
1088 /**
1089 Constructor. If passed an argument, it is passed to
1090 this.setMessage() after initialization.
1091 */
1092 const ctor = function(){
1093 this.e = {
1094 body: D.addClass(D.div(), 'message-widget'),
1095 tab: D.addClass(D.div(), 'message-widget-tab'),
1096 content: D.addClass(D.div(), 'message-widget-content')
1097 };
@@ -1094,20 +1106,33 @@
1106 /* Map of Date.getDay() values to weekday names. */
1107 0: "Sunday", 1: "Monday", 2: "Tuesday",
1108 3: "Wednesday", 4: "Thursday", 5: "Friday",
1109 6: "Saturday"
1110 };
1111 /* Given a Date, returns the timestamp string used in the "tab"
1112 part of message widgets. If longFmt is true then a verbose
1113 format is used, else a brief format is used. The returned string
1114 is in client-local time. */
1115 const theTime = function(d, longFmt=false){
1116 const li = [];
1117 if( longFmt ){
1118 li.push(
1119 d.getFullYear(),
1120 '-', pad2(d.getMonth()+1),
1121 '-', pad2(d.getDate()),
1122 ' ',
1123 d.getHours(), ":",
1124 (d.getMinutes()+100).toString().slice(1,3)
1125 );
1126 }else{
1127 li.push(
1128 d.getHours(),":",
1129 (d.getMinutes()+100).toString().slice(1,3),
1130 ' ', dowMap[d.getDay()]
1131 );
1132 }
1133 return li.join('');
1134 };
1135
1136 /**
1137 Returns true if this page believes it can embed a view of the
1138 file wrapped by the given message object, else returns false.
@@ -1114,19 +1139,20 @@
1139 */
1140 const canEmbedFile = function f(msg){
1141 if(!f.$rx){
1142 f.$rx = /\.((html?)|(txt)|(md)|(wiki)|(pikchr))$/i;
1143 f.$specificTypes = [
1144 /* Mime types we know we can embed, sans image/... */
1145 'text/plain',
1146 'text/html',
1147 'text/x-markdown',
1148 /* Firefox sends text/markdown when uploading .md files */
1149 'text/markdown',
1150 'text/x-pikchr',
1151 'text/x-fossil-wiki'
1152 /* Add more as we discover which ones Firefox won't
1153 force the user to try to download. */
1154 ];
1155 }
1156 if(msg.fmime){
1157 if(msg.fmime.startsWith("image/")
1158 || f.$specificTypes.indexOf(msg.fmime)>=0){
@@ -1140,20 +1166,18 @@
1166 Returns true if the given message object "should"
1167 be embedded in fossil-rendered form instead of
1168 raw content form. This is only intended to be passed
1169 message objects for which canEmbedFile() returns true.
1170 */
1171 const shouldFossilRenderEmbed = function f(msg){
1172 if(!f.$rx){
1173 f.$rx = /\.((md)|(wiki)|(pikchr))$/i;
1174 f.$specificTypes = [
1175 'text/x-markdown',
1176 'text/markdown' /* Firefox-uploaded md files */,
1177 'text/x-pikchr',
1178 'text/x-fossil-wiki'
 
 
1179 ];
1180 }
1181 if(msg.fmime){
1182 if(f.$specificTypes.indexOf(msg.fmime)>=0) return true;
1183 }
@@ -1179,12 +1203,12 @@
1203 iframe.style.maxHeight = iframe.style.height
1204 = iframe.contentWindow.document.documentElement.scrollHeight + 'px';
1205 if(isHidden) D.addClass(iframe, 'hidden');
1206 }
1207 };
1208
1209 ctor.prototype = {
1210 scrollIntoView: function(){
1211 this.e.content.scrollIntoView();
1212 },
1213 setMessage: function(m){
1214 const ds = this.e.body.dataset;
@@ -1205,20 +1229,26 @@
1229 var eXFrom /* element holding xfrom name */;
1230 if(m.xfrom){
1231 eXFrom = D.append(D.addClass(D.span(), 'xfrom'), m.xfrom);
1232 const wrapper = D.append(
1233 D.span(), eXFrom,
1234 ' ',
1235 D.append(D.addClass(D.span(), 'msgid'),
1236 '#' + (m.msgid||'???')),
1237 (m.isSearchResult ? ' ' : ' @ '),
1238 D.append(D.addClass(D.span(), 'timestamp'),
1239 theTime(d,!!m.isSearchResult))
1240 );
1241 D.append(this.e.tab, wrapper);
1242 }else{/*notification*/
1243 D.addClass(this.e.body, 'notification');
1244 if(m.isError){
1245 D.addClass([contentTarget, this.e.tab], 'error');
1246 }
1247 D.append(
1248 this.e.tab,
1249 D.append(D.code(), 'notification @ ',theTime(d,false))
1250 );
1251 }
1252 if( m.xfrom && m.fsize>0 ){
1253 if( m.fmime
1254 && m.fmime.startsWith("image/")
@@ -1241,18 +1271,18 @@
1271 D.attr(a,'target','_blank');
1272 D.append(w, a);
1273 if(canEmbedFile(m)){
1274 /* Add an option to embed HTML attachments in an iframe. The primary
1275 use case is attached diffs. */
1276 const shouldFossilRender = shouldFossilRenderEmbed(m);
1277 const downloadArgs = shouldFossilRender ? '?render' : '';
1278 D.addClass(contentTarget, 'wide');
1279 const embedTarget = this.e.content;
1280 const self = this;
1281 const btnEmbed = D.attr(D.checkbox("1", false), 'id',
1282 'embed-'+ds.msgid);
1283 const btnLabel = D.label(btnEmbed, shouldFossilRender
1284 ? "Embed (fossil-rendered)" : "Embed");
1285 /* Maintenance reminder: do not disable the toggle
1286 button while the content is loading because that will
1287 cause it to get stuck in disabled mode if the browser
1288 decides that loading the content should prompt the
@@ -1460,13 +1490,182 @@
1490 Chat.setCurrentView(Chat.e.viewMessages);
1491 e.scrollIntoView(false);
1492 Chat.animate(e, 'anim-fade-out-in');
1493 }
1494 };
1495 return ctor;
1496 })()/*MessageWidget*/;
1497
1498 /**
1499 A widget for loading more messages (context) around a /chat-query
1500 result message.
1501 */
1502 Chat.SearchCtxLoader = (function(){
1503 const nMsgContext = 5;
1504 const zUpArrow = '\u25B2';
1505 const zDownArrow = '\u25BC';
1506 const ctor = function(o){
1507
1508 /* iFirstInTable:
1509 ** msgid of first row in chatfts table.
1510 **
1511 ** iLastInTable:
1512 ** msgid of last row in chatfts table.
1513 **
1514 ** iPrevId:
1515 ** msgid of message immediately above this spacer. Or 0 if this
1516 ** spacer is above all results.
1517 **
1518 ** iNextId:
1519 ** msgid of message immediately below this spacer. Or 0 if this
1520 ** spacer is below all results.
1521 **
1522 ** bIgnoreClick:
1523 ** ignore any clicks if this is true. This is used to ensure there
1524 ** is only ever one request belonging to this widget outstanding
1525 ** at any time.
1526 */
1527 this.o = {
1528 iFirstInTable: o.first,
1529 iLastInTable: o.last,
1530 iPrevId: o.previd,
1531 iNextId: o.nextid,
1532 bIgnoreClick: false
1533 };
1534
1535 this.e = {
1536 body: D.addClass(D.div(), 'spacer-widget'),
1537 up: D.addClass(
1538 D.button(zDownArrow+' Load '+nMsgContext+' more '+zDownArrow),
1539 'up'
1540 ),
1541 down: D.addClass(
1542 D.button(zUpArrow+' Load '+nMsgContext+' more '+zUpArrow),
1543 'down'
1544 ),
1545 all: D.addClass(D.button('Load More'), 'all')
1546 };
1547 D.append( this.e.body, this.e.up, this.e.down, this.e.all );
1548 const ms = this;
1549 this.e.up.addEventListener('click', ()=>ms.load_messages(false));
1550 this.e.down.addEventListener('click', ()=>ms.load_messages(true));
1551 this.e.all.addEventListener('click', ()=>ms.load_messages( (ms.o.iPrevId==0) ));
1552 this.set_button_visibility();
1553 };
1554
1555 ctor.prototype = {
1556 set_button_visibility: function() {
1557 if( !this.e ) return;
1558 const o = this.o;
1559
1560 const iPrevId = (o.iPrevId!=0) ? o.iPrevId : o.iFirstInTable-1;
1561 const iNextId = (o.iNextId!=0) ? o.iNextId : o.iLastInTable+1;
1562 let nDiff = (iNextId - iPrevId) - 1;
1563
1564 for( const x of [this.e.up, this.e.down, this.e.all] ){
1565 if( x ) D.addClass(x, 'hidden');
1566 }
1567 let nVisible = 0;
1568 if( nDiff>0 ){
1569 if( nDiff>nMsgContext && (o.iPrevId==0 || o.iNextId==0) ){
1570 nDiff = nMsgContext;
1571 }
1572
1573 if( nDiff<=nMsgContext && o.iPrevId!=0 && o.iNextId!=0 ){
1574 D.removeClass(this.e.all, 'hidden');
1575 ++nVisible;
1576 this.e.all.innerText = (
1577 zUpArrow + " Load " + nDiff + " more " + zDownArrow
1578 );
1579 }else{
1580 if( o.iPrevId!=0 ){
1581 ++nVisible;
1582 D.removeClass(this.e.up, 'hidden');
1583 }else if( this.e.up ){
1584 if( this.e.up.parentNode ) D.remove(this.e.up);
1585 delete this.e.up;
1586 }
1587 if( o.iNextId!=0 ){
1588 ++nVisible;
1589 D.removeClass(this.e.down, 'hidden');
1590 }else if( this.e.down ){
1591 if( this.e.down.parentNode ) D.remove( this.e.down );
1592 delete this.e.down;
1593 }
1594 }
1595 }
1596 if( !nVisible ){
1597 /* The DOM elements can now be disposed of. */
1598 for( const x of [this.e.up, this.e.down, this.e.all, this.e.body] ){
1599 if( x?.parentNode ) D.remove(x);
1600 }
1601 delete this.e;
1602 }
1603 },
1604
1605 load_messages: function(bDown) {
1606 if( this.bIgnoreClick ) return;
1607
1608 var iFirst = 0; /* msgid of first message to fetch */
1609 var nFetch = 0; /* Number of messages to fetch */
1610 var iEof = 0; /* last msgid in spacers range, plus 1 */
1611
1612 const e = this.e, o = this.o;
1613 this.bIgnoreClick = true;
1614
1615 /* Figure out the required range of messages. */
1616 if( bDown ){
1617 iFirst = this.o.iNextId - nMsgContext;
1618 if( iFirst<this.o.iFirstInTable ){
1619 iFirst = this.o.iFirstInTable;
1620 }
1621 }else{
1622 iFirst = this.o.iPrevId+1;
1623 }
1624 nFetch = nMsgContext;
1625 iEof = (this.o.iNextId > 0) ? this.o.iNextId : this.o.iLastInTable+1;
1626 if( iFirst+nFetch>iEof ){
1627 nFetch = iEof - iFirst;
1628 }
1629 const ms = this;
1630 F.fetch("chat-query",{
1631 urlParams:{
1632 q: '',
1633 n: nFetch,
1634 i: iFirst
1635 },
1636 responseType: "json",
1637 onload:function(jx){
1638 if( bDown ) jx.msgs.reverse();
1639 jx.msgs.forEach((m) => {
1640 var mw = new Chat.MessageWidget(m);
1641 if( bDown ){
1642 /* Inject the message below this object's body, or
1643 append it to Chat.e.searchContent if this element
1644 is the final one in its parent (Chat.e.searchContent). */
1645 const eAnchor = e.body.nextElementSibling;
1646 if( eAnchor ) Chat.e.searchContent.insertBefore(mw.e.body, eAnchor);
1647 else D.append(Chat.e.searchContent, mw.e.body);
1648 }else{
1649 Chat.e.searchContent.insertBefore(mw.e.body, e.body);
1650 }
1651 });
1652 if( bDown ){
1653 o.iNextId -= jx.msgs.length;
1654 }else{
1655 o.iPrevId += jx.msgs.length;
1656 }
1657 ms.set_button_visibility();
1658 ms.bIgnoreClick = false;
1659 }
1660 });
1661 }
1662 };
1663
1664 return ctor;
1665 })() /*SearchCtxLoader*/;
1666
1667 const BlobXferState = (function(){
1668 /* State for paste and drag/drop */
1669 const bxs = {
1670 dropDetails: document.querySelector('#chat-drop-details'),
1671 blob: undefined,
@@ -1605,16 +1804,26 @@
1804
1805 /**
1806 Submits the contents of the message input field (if not empty)
1807 and/or the file attachment field to the server. If both are
1808 empty, this is a no-op.
1809
1810 If the current view is the history search, this instead sends the
1811 input text to that widget.
1812 */
1813 Chat.submitMessage = function f(){
1814 if(!f.spaces){
1815 f.spaces = /\s+$/;
1816 f.markdownContinuation = /\\\s+$/;
1817 f.spaces2 = /\s{3,}$/;
1818 }
1819 switch( this.e.currentView ){
1820 case this.e.viewSearch: this.submitSearch();
1821 return;
1822 case this.e.viewPreview: this.e.btnPreview.click();
1823 return;
1824 default: break;
1825 }
1826 this.setCurrentView(this.e.viewMessages);
1827 const fd = new FormData();
1828 const fallback = {msg: this.inputValue()};
1829 var msg = fallback.msg;
@@ -1687,14 +1896,16 @@
1896 //console.debug("Enter key event:", ctrlMode, ev.ctrlKey, ev.shiftKey, ev);
1897 if(ev.shiftKey){
1898 const compactMode = Chat.settings.getBool('edit-compact-mode', false);
1899 ev.preventDefault();
1900 ev.stopPropagation();
1901 /* Shift-enter will run preview mode UNLESS the input field is empty
1902 AND (preview or search mode) is active, in which cases it will
1903 switch back to message view. */
1904 if(!text &&
1905 (Chat.e.currentView===Chat.e.viewPreview
1906 | Chat.e.currentView===Chat.e.viewSearch)){
1907 Chat.setCurrentView(Chat.e.viewMessages);
1908 }else if(!text){
1909 f.$toggleCompact(compactMode);
1910 }else if(Chat.settings.getBool('edit-shift-enter-preview', true)){
1911 Chat.e.btnPreview.click();
@@ -1752,19 +1963,19 @@
1963 tall vs wide. Can be toggled via settings. */
1964 document.body.classList.add('my-messages-right');
1965 }
1966 const settingsButton = document.querySelector('#chat-button-settings');
1967 const optionsMenu = E1('#chat-config-options');
1968 const eToggleView = function(ev){
1969 ev.preventDefault();
1970 ev.stopPropagation();
1971 Chat.setCurrentView(Chat.e.currentView===Chat.e.viewConfig
1972 ? Chat.e.viewMessages : Chat.e.viewConfig);
1973 return false;
1974 };
1975 D.attr(settingsButton, 'role', 'button').addEventListener('click', eToggleView, false);
1976 Chat.e.viewConfig.querySelector('button.action-close').addEventListener('click', eToggleView, false);
1977
1978 /** Internal acrobatics to allow certain settings toggles to access
1979 related toggles. */
1980 const namedOptions = {
1981 activeUsers:{
@@ -1850,12 +2061,13 @@
2061 boolValue: 'edit-ctrl-send'
2062 },{
2063 label: "Compact mode",
2064 hint: [
2065 "Toggle between a space-saving or more spacious writing area. ",
2066 "When the input field has focus and is empty ",
2067 "then Shift-Enter may (depending on the current view) toggle this setting."
2068 ].join(''),
2069 boolValue: 'edit-compact-mode'
2070 },{
2071 label: "Use 'contenteditable' editing mode",
2072 boolValue: 'edit-widget-x',
2073 hint: [
@@ -2020,11 +2232,11 @@
2232 op.persistentSetting,
2233 function(setting){
2234 if(op.checkbox) op.checkbox.checked = !!setting.value;
2235 else if(op.select) op.select.value = setting.value;
2236 if(op.callback) op.callback(setting);
2237 }
2238 );
2239 if(op.checkbox){
2240 op.checkbox.addEventListener(
2241 'change', function(){
2242 Chat.settings.set(op.persistentSetting, op.checkbox.checked)
@@ -2096,11 +2308,11 @@
2308 s.value ? 'add' : 'remove'
2309 ]('compact');
2310 Chat.e.inputFields[Chat.e.inputFields.$currentIndex].focus();
2311 });
2312 Chat.settings.addListener('edit-ctrl-send',function(s){
2313 const label = (s.value ? "Ctrl-" : "")+"Enter submits message";
2314 Chat.e.inputFields.forEach((e)=>{
2315 const v = e.dataset.placeholder0 + " " +label;
2316 if(e.isContentEditable) e.dataset.placeholder = v;
2317 else D.attr(e,'placeholder',v);
2318 });
@@ -2128,11 +2340,11 @@
2340 this.e.previewContent.innerHTML = t;
2341 this.e.viewPreview.querySelectorAll('a').forEach(addAnchorTargetBlank);
2342 setupHashtags(this.e.previewContent)/*arguable, for usability reasons*/;
2343 this.inputFocus();
2344 };
2345 Chat.e.viewPreview.querySelector('button.action-close').
2346 addEventListener('click', ()=>Chat.setCurrentView(Chat.e.viewMessages), false);
2347 let previewPending = false;
2348 const elemsToEnable = [btnPreview, Chat.e.btnSubmit, Chat.e.inputFields];
2349 const submit = function(ev){
2350 ev.preventDefault();
@@ -2175,10 +2387,40 @@
2387 });
2388 return false;
2389 };
2390 btnPreview.addEventListener('click', submit, false);
2391 })()/*message preview setup*/;
2392
2393 (function(){/*Set up #chat-search and related bits */
2394 const btn = document.querySelector('#chat-button-search');
2395 D.attr(btn, 'role', 'button').addEventListener('click', function(ev){
2396 ev.preventDefault();
2397 ev.stopPropagation();
2398 const msg = Chat.inputValue();
2399 if( Chat.e.currentView===Chat.e.viewSearch ){
2400 if( msg ) Chat.submitSearch();
2401 else Chat.setCurrentView(Chat.e.viewMessages);
2402 }else{
2403 Chat.setCurrentView(Chat.e.viewSearch);
2404 if( msg ) Chat.submitSearch();
2405 }
2406 return false;
2407 }, false);
2408 Chat.e.viewSearch.querySelector('button.action-clear').addEventListener('click', function(ev){
2409 ev.preventDefault();
2410 ev.stopPropagation();
2411 Chat.clearSearch(true);
2412 Chat.setCurrentView(Chat.e.viewMessages);
2413 return false;
2414 }, false);
2415 Chat.e.viewSearch.querySelector('button.action-close').addEventListener('click', function(ev){
2416 ev.preventDefault();
2417 ev.stopPropagation();
2418 Chat.setCurrentView(Chat.e.viewMessages);
2419 return false;
2420 }, false);
2421 })()/*search view setup*/;
2422
2423 /** Callback for poll() to inject new content into the page. jx ==
2424 the response from /chat-poll. If atEnd is true, the message is
2425 appended to the end of the chat list (for loading older
2426 messages), else the beginning (the default). */
@@ -2307,10 +2549,82 @@
2549 btn.addEventListener('click',()=>loadOldMessages(-1));
2550 D.append(Chat.e.viewMessages, toolbar);
2551 toolbar.disabled = true /*will be enabled when msg load finishes */;
2552 })()/*end history loading widget setup*/;
2553
2554 /**
2555 Clears the search result view. If addInstructions is true it adds
2556 text to that view instructing the user to enter their query into
2557 the message-entry widget (noting that that widget has text
2558 implying that it's only for submitting a message, which isn't
2559 exactly true when the search view is active).
2560
2561 Returns the DOM element which wraps all of the chat search
2562 result elements.
2563 */
2564 Chat.clearSearch = function(addInstructions=false){
2565 const e = D.clearElement( this.e.searchContent );
2566 if(addInstructions){
2567 D.append(e, "Enter search terms in the message field. "+
2568 "Use #NNNNN to search for the message with ID NNNNN.");
2569 }
2570 return e;
2571 };
2572 Chat.clearSearch(true);
2573 /**
2574 Submits a history search using the main input field's current
2575 text. It is assumed that Chat.e.viewSearch===Chat.e.currentView.
2576 */
2577 Chat.submitSearch = function(){
2578 const term = this.inputValue(true);
2579 const eMsgTgt = this.clearSearch(true);
2580 if( !term ) return;
2581 D.append( eMsgTgt, "Searching for ",term," ...");
2582 const fd = new FormData();
2583 fd.set('q', term);
2584 F.fetch(
2585 "chat-query", {
2586 payload: fd,
2587 responseType: 'json',
2588 onerror:function(err){
2589 Chat.setCurrentView(Chat.e.viewMessages);
2590 Chat.reportErrorAsMessage(err);
2591 },
2592 onload:function(jx){
2593 let previd = 0;
2594 D.clearElement(eMsgTgt);
2595 jx.msgs.forEach((m)=>{
2596 m.isSearchResult = true;
2597 const mw = new Chat.MessageWidget(m);
2598 const spacer = new Chat.SearchCtxLoader({
2599 first: jx.first,
2600 last: jx.last,
2601 previd: previd,
2602 nextid: m.msgid
2603 });
2604 if( spacer.e ) D.append( eMsgTgt, spacer.e.body );
2605 D.append( eMsgTgt, mw.e.body );
2606 previd = m.msgid;
2607 });
2608 if( jx.msgs.length ){
2609 const spacer = new Chat.SearchCtxLoader({
2610 first: jx.first,
2611 last: jx.last,
2612 previd: previd,
2613 nextid: 0
2614 });
2615 if( spacer.e ) D.append( eMsgTgt, spacer.e.body );
2616 }else{
2617 D.append( D.clearElement(eMsgTgt),
2618 'No search results found for: ',
2619 term );
2620 }
2621 }
2622 }
2623 );
2624 }/*Chat.submitSearch()*/;
2625
2626 const afterFetch = function f(){
2627 if(true===f.isFirstCall){
2628 f.isFirstCall = false;
2629 Chat.ajaxEnd();
2630 Chat.e.viewMessages.classList.remove('loading');
@@ -2326,10 +2640,25 @@
2640 delete Chat.intervalTimer;
2641 }
2642 poll.running = false;
2643 };
2644 afterFetch.isFirstCall = true;
2645 /**
2646 FIXME: when polling fails because the remote server is
2647 reachable but it's not accepting HTTP requests, we should back
2648 off on polling for a while. e.g. if the remote web server process
2649 is killed, the poll fails quickly and immediately retries,
2650 hammering the remote server until the httpd is back up. That
2651 happens often during development of this application.
2652
2653 XHR does not offer a direct way of distinguishing between
2654 HTTP/connection errors, but we can hypothetically use the
2655 xhrRequest.status value to do so, with status==0 being a
2656 connection error. We do not currently have a clean way of passing
2657 that info back to the fossil.fetch() client, so we'll need to
2658 hammer on that API a bit to get this working.
2659 */
2660 const poll = async function f(){
2661 if(f.running) return;
2662 f.running = true;
2663 Chat._isBatchLoading = f.isFirstCall;
2664 if(true===f.isFirstCall){
@@ -2368,17 +2697,11 @@
2697 Chat._gotServerError = poll.running = false;
2698 if( window.fossil.config.chat.fromcli ){
2699 Chat.chatOnlyMode(true);
2700 }
2701 Chat.intervalTimer = setInterval(poll, 1000);
 
 
 
 
 
 
2702 delete ForceResizeKludge.$disabled;
2703 ForceResizeKludge();
2704 Chat.animate.$disabled = false;
2705 setTimeout( ()=>Chat.inputFocus(), 0 );
2706 F.page.chat = Chat/* enables testing the APIs via the dev tools */;
2707 });
2708
--- src/fossil.page.chat.js
+++ src/fossil.page.chat.js
@@ -145,10 +145,12 @@
145145
inputFile: E1('#chat-input-file'),
146146
contentDiv: E1('div.content'),
147147
viewConfig: E1('#chat-config'),
148148
viewPreview: E1('#chat-preview'),
149149
previewContent: E1('#chat-preview-content'),
150
+ viewSearch: E1('#chat-search'),
151
+ searchContent: E1('#chat-search-content'),
150152
btnPreview: E1('#chat-button-preview'),
151153
views: document.querySelectorAll('.chat-view'),
152154
activeUserListWrapper: E1('#chat-user-list-wrapper'),
153155
activeUserList: E1('#chat-user-list'),
154156
btnClearFilter: E1('#chat-clear-filter')
@@ -190,21 +192,31 @@
190192
|| !!e.querySelector('[data-hashtag="'+this.activeTag+'"]');
191193
}
192194
},
193195
current: undefined/*gets set to current active filter*/
194196
},
195
- /** Gets (no args) or sets (1 arg) the current input text field value,
196
- taking into account single- vs multi-line input. The getter returns
197
- a string and the setter returns this object. */
198
- inputValue: function(){
197
+ /**
198
+ Gets (no args) or sets (1 arg) the current input text field
199
+ value, taking into account single- vs multi-line input. The
200
+ getter returns a trim()'d string and the setter returns this
201
+ object. As a special case, if arguments[0] is a boolean
202
+ value, it behaves like a getter and, if arguments[0]===true
203
+ it clears the input field before returning.
204
+ */
205
+ inputValue: function(/*string newValue | bool clearInputField*/){
199206
const e = this.inputElement();
200
- if(arguments.length){
207
+ if(arguments.length && 'boolean'!==typeof arguments[0]){
201208
if(e.isContentEditable) e.innerText = arguments[0];
202209
else e.value = arguments[0];
203210
return this;
204211
}
205
- return e.isContentEditable ? e.innerText : e.value;
212
+ const rc = e.isContentEditable ? e.innerText : e.value;
213
+ if( true===arguments[0] ){
214
+ if(e.isContentEditable) e.innerText = '';
215
+ else e.value = '';
216
+ }
217
+ return rc && rc.trim();
206218
},
207219
/** Asks the current user input field to take focus. Returns this. */
208220
inputFocus: function(){
209221
this.inputElement().focus();
210222
return this;
@@ -529,11 +541,11 @@
529541
const uDate = self.usersLastSeen[u];
530542
if(self.filter.user.activeTag===u){
531543
uSpan.classList.add('selected');
532544
}
533545
uSpan.dataset.uname = u;
534
- D.append(uSpan, u, "\n",
546
+ D.append(uSpan, u, "\n",
535547
D.append(
536548
D.addClass(D.span(),'timestamp'),
537549
localTimeString(uDate)//.substr(5/*chop off year*/)
538550
));
539551
if(uDate.$uColor){
@@ -1075,11 +1087,11 @@
10751087
Chat.MessageWidget = (function(){
10761088
/**
10771089
Constructor. If passed an argument, it is passed to
10781090
this.setMessage() after initialization.
10791091
*/
1080
- const cf = function(){
1092
+ const ctor = function(){
10811093
this.e = {
10821094
body: D.addClass(D.div(), 'message-widget'),
10831095
tab: D.addClass(D.div(), 'message-widget-tab'),
10841096
content: D.addClass(D.div(), 'message-widget-content')
10851097
};
@@ -1094,20 +1106,33 @@
10941106
/* Map of Date.getDay() values to weekday names. */
10951107
0: "Sunday", 1: "Monday", 2: "Tuesday",
10961108
3: "Wednesday", 4: "Thursday", 5: "Friday",
10971109
6: "Saturday"
10981110
};
1099
- /* Given a Date, returns the timestamp string used in the
1100
- "tab" part of message widgets. */
1101
- const theTime = function(d){
1102
- return [
1103
- //d.getFullYear(),'-',pad2(d.getMonth()+1/*sigh*/),
1104
- //'-',pad2(d.getDate()), ' ',
1105
- d.getHours(),":",
1106
- (d.getMinutes()+100).toString().slice(1,3),
1107
- ' ', dowMap[d.getDay()]
1108
- ].join('');
1111
+ /* Given a Date, returns the timestamp string used in the "tab"
1112
+ part of message widgets. If longFmt is true then a verbose
1113
+ format is used, else a brief format is used. The returned string
1114
+ is in client-local time. */
1115
+ const theTime = function(d, longFmt=false){
1116
+ const li = [];
1117
+ if( longFmt ){
1118
+ li.push(
1119
+ d.getFullYear(),
1120
+ '-', pad2(d.getMonth()+1),
1121
+ '-', pad2(d.getDate()),
1122
+ ' ',
1123
+ d.getHours(), ":",
1124
+ (d.getMinutes()+100).toString().slice(1,3)
1125
+ );
1126
+ }else{
1127
+ li.push(
1128
+ d.getHours(),":",
1129
+ (d.getMinutes()+100).toString().slice(1,3),
1130
+ ' ', dowMap[d.getDay()]
1131
+ );
1132
+ }
1133
+ return li.join('');
11091134
};
11101135
11111136
/**
11121137
Returns true if this page believes it can embed a view of the
11131138
file wrapped by the given message object, else returns false.
@@ -1114,19 +1139,20 @@
11141139
*/
11151140
const canEmbedFile = function f(msg){
11161141
if(!f.$rx){
11171142
f.$rx = /\.((html?)|(txt)|(md)|(wiki)|(pikchr))$/i;
11181143
f.$specificTypes = [
1144
+ /* Mime types we know we can embed, sans image/... */
11191145
'text/plain',
11201146
'text/html',
11211147
'text/x-markdown',
11221148
/* Firefox sends text/markdown when uploading .md files */
11231149
'text/markdown',
11241150
'text/x-pikchr',
11251151
'text/x-fossil-wiki'
1126
- // add more as we discover which ones Firefox won't
1127
- // force the user to try to download.
1152
+ /* Add more as we discover which ones Firefox won't
1153
+ force the user to try to download. */
11281154
];
11291155
}
11301156
if(msg.fmime){
11311157
if(msg.fmime.startsWith("image/")
11321158
|| f.$specificTypes.indexOf(msg.fmime)>=0){
@@ -1140,20 +1166,18 @@
11401166
Returns true if the given message object "should"
11411167
be embedded in fossil-rendered form instead of
11421168
raw content form. This is only intended to be passed
11431169
message objects for which canEmbedFile() returns true.
11441170
*/
1145
- const shouldWikiRenderEmbed = function f(msg){
1171
+ const shouldFossilRenderEmbed = function f(msg){
11461172
if(!f.$rx){
11471173
f.$rx = /\.((md)|(wiki)|(pikchr))$/i;
11481174
f.$specificTypes = [
11491175
'text/x-markdown',
11501176
'text/markdown' /* Firefox-uploaded md files */,
11511177
'text/x-pikchr',
11521178
'text/x-fossil-wiki'
1153
- // add more as we discover which ones Firefox won't
1154
- // force the user to try to download.
11551179
];
11561180
}
11571181
if(msg.fmime){
11581182
if(f.$specificTypes.indexOf(msg.fmime)>=0) return true;
11591183
}
@@ -1179,12 +1203,12 @@
11791203
iframe.style.maxHeight = iframe.style.height
11801204
= iframe.contentWindow.document.documentElement.scrollHeight + 'px';
11811205
if(isHidden) D.addClass(iframe, 'hidden');
11821206
}
11831207
};
1184
-
1185
- cf.prototype = {
1208
+
1209
+ ctor.prototype = {
11861210
scrollIntoView: function(){
11871211
this.e.content.scrollIntoView();
11881212
},
11891213
setMessage: function(m){
11901214
const ds = this.e.body.dataset;
@@ -1205,20 +1229,26 @@
12051229
var eXFrom /* element holding xfrom name */;
12061230
if(m.xfrom){
12071231
eXFrom = D.append(D.addClass(D.span(), 'xfrom'), m.xfrom);
12081232
const wrapper = D.append(
12091233
D.span(), eXFrom,
1210
- D.text(" #",(m.msgid||'???'),' @ ',theTime(d)))
1234
+ ' ',
1235
+ D.append(D.addClass(D.span(), 'msgid'),
1236
+ '#' + (m.msgid||'???')),
1237
+ (m.isSearchResult ? ' ' : ' @ '),
1238
+ D.append(D.addClass(D.span(), 'timestamp'),
1239
+ theTime(d,!!m.isSearchResult))
1240
+ );
12111241
D.append(this.e.tab, wrapper);
12121242
}else{/*notification*/
12131243
D.addClass(this.e.body, 'notification');
12141244
if(m.isError){
12151245
D.addClass([contentTarget, this.e.tab], 'error');
12161246
}
12171247
D.append(
12181248
this.e.tab,
1219
- D.append(D.code(), 'notification @ ',theTime(d))
1249
+ D.append(D.code(), 'notification @ ',theTime(d,false))
12201250
);
12211251
}
12221252
if( m.xfrom && m.fsize>0 ){
12231253
if( m.fmime
12241254
&& m.fmime.startsWith("image/")
@@ -1241,18 +1271,18 @@
12411271
D.attr(a,'target','_blank');
12421272
D.append(w, a);
12431273
if(canEmbedFile(m)){
12441274
/* Add an option to embed HTML attachments in an iframe. The primary
12451275
use case is attached diffs. */
1246
- const shouldWikiRender = shouldWikiRenderEmbed(m);
1247
- const downloadArgs = shouldWikiRender ? '?render' : '';
1276
+ const shouldFossilRender = shouldFossilRenderEmbed(m);
1277
+ const downloadArgs = shouldFossilRender ? '?render' : '';
12481278
D.addClass(contentTarget, 'wide');
12491279
const embedTarget = this.e.content;
12501280
const self = this;
12511281
const btnEmbed = D.attr(D.checkbox("1", false), 'id',
12521282
'embed-'+ds.msgid);
1253
- const btnLabel = D.label(btnEmbed, shouldWikiRender
1283
+ const btnLabel = D.label(btnEmbed, shouldFossilRender
12541284
? "Embed (fossil-rendered)" : "Embed");
12551285
/* Maintenance reminder: do not disable the toggle
12561286
button while the content is loading because that will
12571287
cause it to get stuck in disabled mode if the browser
12581288
decides that loading the content should prompt the
@@ -1460,13 +1490,182 @@
14601490
Chat.setCurrentView(Chat.e.viewMessages);
14611491
e.scrollIntoView(false);
14621492
Chat.animate(e, 'anim-fade-out-in');
14631493
}
14641494
};
1465
- return cf;
1495
+ return ctor;
14661496
})()/*MessageWidget*/;
14671497
1498
+ /**
1499
+ A widget for loading more messages (context) around a /chat-query
1500
+ result message.
1501
+ */
1502
+ Chat.SearchCtxLoader = (function(){
1503
+ const nMsgContext = 5;
1504
+ const zUpArrow = '\u25B2';
1505
+ const zDownArrow = '\u25BC';
1506
+ const ctor = function(o){
1507
+
1508
+ /* iFirstInTable:
1509
+ ** msgid of first row in chatfts table.
1510
+ **
1511
+ ** iLastInTable:
1512
+ ** msgid of last row in chatfts table.
1513
+ **
1514
+ ** iPrevId:
1515
+ ** msgid of message immediately above this spacer. Or 0 if this
1516
+ ** spacer is above all results.
1517
+ **
1518
+ ** iNextId:
1519
+ ** msgid of message immediately below this spacer. Or 0 if this
1520
+ ** spacer is below all results.
1521
+ **
1522
+ ** bIgnoreClick:
1523
+ ** ignore any clicks if this is true. This is used to ensure there
1524
+ ** is only ever one request belonging to this widget outstanding
1525
+ ** at any time.
1526
+ */
1527
+ this.o = {
1528
+ iFirstInTable: o.first,
1529
+ iLastInTable: o.last,
1530
+ iPrevId: o.previd,
1531
+ iNextId: o.nextid,
1532
+ bIgnoreClick: false
1533
+ };
1534
+
1535
+ this.e = {
1536
+ body: D.addClass(D.div(), 'spacer-widget'),
1537
+ up: D.addClass(
1538
+ D.button(zDownArrow+' Load '+nMsgContext+' more '+zDownArrow),
1539
+ 'up'
1540
+ ),
1541
+ down: D.addClass(
1542
+ D.button(zUpArrow+' Load '+nMsgContext+' more '+zUpArrow),
1543
+ 'down'
1544
+ ),
1545
+ all: D.addClass(D.button('Load More'), 'all')
1546
+ };
1547
+ D.append( this.e.body, this.e.up, this.e.down, this.e.all );
1548
+ const ms = this;
1549
+ this.e.up.addEventListener('click', ()=>ms.load_messages(false));
1550
+ this.e.down.addEventListener('click', ()=>ms.load_messages(true));
1551
+ this.e.all.addEventListener('click', ()=>ms.load_messages( (ms.o.iPrevId==0) ));
1552
+ this.set_button_visibility();
1553
+ };
1554
+
1555
+ ctor.prototype = {
1556
+ set_button_visibility: function() {
1557
+ if( !this.e ) return;
1558
+ const o = this.o;
1559
+
1560
+ const iPrevId = (o.iPrevId!=0) ? o.iPrevId : o.iFirstInTable-1;
1561
+ const iNextId = (o.iNextId!=0) ? o.iNextId : o.iLastInTable+1;
1562
+ let nDiff = (iNextId - iPrevId) - 1;
1563
+
1564
+ for( const x of [this.e.up, this.e.down, this.e.all] ){
1565
+ if( x ) D.addClass(x, 'hidden');
1566
+ }
1567
+ let nVisible = 0;
1568
+ if( nDiff>0 ){
1569
+ if( nDiff>nMsgContext && (o.iPrevId==0 || o.iNextId==0) ){
1570
+ nDiff = nMsgContext;
1571
+ }
1572
+
1573
+ if( nDiff<=nMsgContext && o.iPrevId!=0 && o.iNextId!=0 ){
1574
+ D.removeClass(this.e.all, 'hidden');
1575
+ ++nVisible;
1576
+ this.e.all.innerText = (
1577
+ zUpArrow + " Load " + nDiff + " more " + zDownArrow
1578
+ );
1579
+ }else{
1580
+ if( o.iPrevId!=0 ){
1581
+ ++nVisible;
1582
+ D.removeClass(this.e.up, 'hidden');
1583
+ }else if( this.e.up ){
1584
+ if( this.e.up.parentNode ) D.remove(this.e.up);
1585
+ delete this.e.up;
1586
+ }
1587
+ if( o.iNextId!=0 ){
1588
+ ++nVisible;
1589
+ D.removeClass(this.e.down, 'hidden');
1590
+ }else if( this.e.down ){
1591
+ if( this.e.down.parentNode ) D.remove( this.e.down );
1592
+ delete this.e.down;
1593
+ }
1594
+ }
1595
+ }
1596
+ if( !nVisible ){
1597
+ /* The DOM elements can now be disposed of. */
1598
+ for( const x of [this.e.up, this.e.down, this.e.all, this.e.body] ){
1599
+ if( x?.parentNode ) D.remove(x);
1600
+ }
1601
+ delete this.e;
1602
+ }
1603
+ },
1604
+
1605
+ load_messages: function(bDown) {
1606
+ if( this.bIgnoreClick ) return;
1607
+
1608
+ var iFirst = 0; /* msgid of first message to fetch */
1609
+ var nFetch = 0; /* Number of messages to fetch */
1610
+ var iEof = 0; /* last msgid in spacers range, plus 1 */
1611
+
1612
+ const e = this.e, o = this.o;
1613
+ this.bIgnoreClick = true;
1614
+
1615
+ /* Figure out the required range of messages. */
1616
+ if( bDown ){
1617
+ iFirst = this.o.iNextId - nMsgContext;
1618
+ if( iFirst<this.o.iFirstInTable ){
1619
+ iFirst = this.o.iFirstInTable;
1620
+ }
1621
+ }else{
1622
+ iFirst = this.o.iPrevId+1;
1623
+ }
1624
+ nFetch = nMsgContext;
1625
+ iEof = (this.o.iNextId > 0) ? this.o.iNextId : this.o.iLastInTable+1;
1626
+ if( iFirst+nFetch>iEof ){
1627
+ nFetch = iEof - iFirst;
1628
+ }
1629
+ const ms = this;
1630
+ F.fetch("chat-query",{
1631
+ urlParams:{
1632
+ q: '',
1633
+ n: nFetch,
1634
+ i: iFirst
1635
+ },
1636
+ responseType: "json",
1637
+ onload:function(jx){
1638
+ if( bDown ) jx.msgs.reverse();
1639
+ jx.msgs.forEach((m) => {
1640
+ var mw = new Chat.MessageWidget(m);
1641
+ if( bDown ){
1642
+ /* Inject the message below this object's body, or
1643
+ append it to Chat.e.searchContent if this element
1644
+ is the final one in its parent (Chat.e.searchContent). */
1645
+ const eAnchor = e.body.nextElementSibling;
1646
+ if( eAnchor ) Chat.e.searchContent.insertBefore(mw.e.body, eAnchor);
1647
+ else D.append(Chat.e.searchContent, mw.e.body);
1648
+ }else{
1649
+ Chat.e.searchContent.insertBefore(mw.e.body, e.body);
1650
+ }
1651
+ });
1652
+ if( bDown ){
1653
+ o.iNextId -= jx.msgs.length;
1654
+ }else{
1655
+ o.iPrevId += jx.msgs.length;
1656
+ }
1657
+ ms.set_button_visibility();
1658
+ ms.bIgnoreClick = false;
1659
+ }
1660
+ });
1661
+ }
1662
+ };
1663
+
1664
+ return ctor;
1665
+ })() /*SearchCtxLoader*/;
1666
+
14681667
const BlobXferState = (function(){
14691668
/* State for paste and drag/drop */
14701669
const bxs = {
14711670
dropDetails: document.querySelector('#chat-drop-details'),
14721671
blob: undefined,
@@ -1605,16 +1804,26 @@
16051804
16061805
/**
16071806
Submits the contents of the message input field (if not empty)
16081807
and/or the file attachment field to the server. If both are
16091808
empty, this is a no-op.
1809
+
1810
+ If the current view is the history search, this instead sends the
1811
+ input text to that widget.
16101812
*/
16111813
Chat.submitMessage = function f(){
16121814
if(!f.spaces){
16131815
f.spaces = /\s+$/;
16141816
f.markdownContinuation = /\\\s+$/;
16151817
f.spaces2 = /\s{3,}$/;
1818
+ }
1819
+ switch( this.e.currentView ){
1820
+ case this.e.viewSearch: this.submitSearch();
1821
+ return;
1822
+ case this.e.viewPreview: this.e.btnPreview.click();
1823
+ return;
1824
+ default: break;
16161825
}
16171826
this.setCurrentView(this.e.viewMessages);
16181827
const fd = new FormData();
16191828
const fallback = {msg: this.inputValue()};
16201829
var msg = fallback.msg;
@@ -1687,14 +1896,16 @@
16871896
//console.debug("Enter key event:", ctrlMode, ev.ctrlKey, ev.shiftKey, ev);
16881897
if(ev.shiftKey){
16891898
const compactMode = Chat.settings.getBool('edit-compact-mode', false);
16901899
ev.preventDefault();
16911900
ev.stopPropagation();
1692
- /* Shift-enter will run preview mode UNLESS preview mode is
1693
- active AND the input field is empty, in which case it will
1901
+ /* Shift-enter will run preview mode UNLESS the input field is empty
1902
+ AND (preview or search mode) is active, in which cases it will
16941903
switch back to message view. */
1695
- if(Chat.e.currentView===Chat.e.viewPreview && !text){
1904
+ if(!text &&
1905
+ (Chat.e.currentView===Chat.e.viewPreview
1906
+ | Chat.e.currentView===Chat.e.viewSearch)){
16961907
Chat.setCurrentView(Chat.e.viewMessages);
16971908
}else if(!text){
16981909
f.$toggleCompact(compactMode);
16991910
}else if(Chat.settings.getBool('edit-shift-enter-preview', true)){
17001911
Chat.e.btnPreview.click();
@@ -1752,19 +1963,19 @@
17521963
tall vs wide. Can be toggled via settings. */
17531964
document.body.classList.add('my-messages-right');
17541965
}
17551966
const settingsButton = document.querySelector('#chat-button-settings');
17561967
const optionsMenu = E1('#chat-config-options');
1757
- const cbToggle = function(ev){
1968
+ const eToggleView = function(ev){
17581969
ev.preventDefault();
17591970
ev.stopPropagation();
17601971
Chat.setCurrentView(Chat.e.currentView===Chat.e.viewConfig
17611972
? Chat.e.viewMessages : Chat.e.viewConfig);
17621973
return false;
17631974
};
1764
- D.attr(settingsButton, 'role', 'button').addEventListener('click', cbToggle, false);
1765
- Chat.e.viewConfig.querySelector('button').addEventListener('click', cbToggle, false);
1975
+ D.attr(settingsButton, 'role', 'button').addEventListener('click', eToggleView, false);
1976
+ Chat.e.viewConfig.querySelector('button.action-close').addEventListener('click', eToggleView, false);
17661977
17671978
/** Internal acrobatics to allow certain settings toggles to access
17681979
related toggles. */
17691980
const namedOptions = {
17701981
activeUsers:{
@@ -1850,12 +2061,13 @@
18502061
boolValue: 'edit-ctrl-send'
18512062
},{
18522063
label: "Compact mode",
18532064
hint: [
18542065
"Toggle between a space-saving or more spacious writing area. ",
1855
- "When the input field has focus, is empty, and preview mode ",
1856
- "is NOT active then Shift-Enter toggles this setting."].join(''),
2066
+ "When the input field has focus and is empty ",
2067
+ "then Shift-Enter may (depending on the current view) toggle this setting."
2068
+ ].join(''),
18572069
boolValue: 'edit-compact-mode'
18582070
},{
18592071
label: "Use 'contenteditable' editing mode",
18602072
boolValue: 'edit-widget-x',
18612073
hint: [
@@ -2020,11 +2232,11 @@
20202232
op.persistentSetting,
20212233
function(setting){
20222234
if(op.checkbox) op.checkbox.checked = !!setting.value;
20232235
else if(op.select) op.select.value = setting.value;
20242236
if(op.callback) op.callback(setting);
2025
- }
2237
+ }
20262238
);
20272239
if(op.checkbox){
20282240
op.checkbox.addEventListener(
20292241
'change', function(){
20302242
Chat.settings.set(op.persistentSetting, op.checkbox.checked)
@@ -2096,11 +2308,11 @@
20962308
s.value ? 'add' : 'remove'
20972309
]('compact');
20982310
Chat.e.inputFields[Chat.e.inputFields.$currentIndex].focus();
20992311
});
21002312
Chat.settings.addListener('edit-ctrl-send',function(s){
2101
- const label = (s.value ? "Ctrl-" : "")+"Enter submits messages.";
2313
+ const label = (s.value ? "Ctrl-" : "")+"Enter submits message";
21022314
Chat.e.inputFields.forEach((e)=>{
21032315
const v = e.dataset.placeholder0 + " " +label;
21042316
if(e.isContentEditable) e.dataset.placeholder = v;
21052317
else D.attr(e,'placeholder',v);
21062318
});
@@ -2128,11 +2340,11 @@
21282340
this.e.previewContent.innerHTML = t;
21292341
this.e.viewPreview.querySelectorAll('a').forEach(addAnchorTargetBlank);
21302342
setupHashtags(this.e.previewContent)/*arguable, for usability reasons*/;
21312343
this.inputFocus();
21322344
};
2133
- Chat.e.viewPreview.querySelector('#chat-preview-close').
2345
+ Chat.e.viewPreview.querySelector('button.action-close').
21342346
addEventListener('click', ()=>Chat.setCurrentView(Chat.e.viewMessages), false);
21352347
let previewPending = false;
21362348
const elemsToEnable = [btnPreview, Chat.e.btnSubmit, Chat.e.inputFields];
21372349
const submit = function(ev){
21382350
ev.preventDefault();
@@ -2175,10 +2387,40 @@
21752387
});
21762388
return false;
21772389
};
21782390
btnPreview.addEventListener('click', submit, false);
21792391
})()/*message preview setup*/;
2392
+
2393
+ (function(){/*Set up #chat-search and related bits */
2394
+ const btn = document.querySelector('#chat-button-search');
2395
+ D.attr(btn, 'role', 'button').addEventListener('click', function(ev){
2396
+ ev.preventDefault();
2397
+ ev.stopPropagation();
2398
+ const msg = Chat.inputValue();
2399
+ if( Chat.e.currentView===Chat.e.viewSearch ){
2400
+ if( msg ) Chat.submitSearch();
2401
+ else Chat.setCurrentView(Chat.e.viewMessages);
2402
+ }else{
2403
+ Chat.setCurrentView(Chat.e.viewSearch);
2404
+ if( msg ) Chat.submitSearch();
2405
+ }
2406
+ return false;
2407
+ }, false);
2408
+ Chat.e.viewSearch.querySelector('button.action-clear').addEventListener('click', function(ev){
2409
+ ev.preventDefault();
2410
+ ev.stopPropagation();
2411
+ Chat.clearSearch(true);
2412
+ Chat.setCurrentView(Chat.e.viewMessages);
2413
+ return false;
2414
+ }, false);
2415
+ Chat.e.viewSearch.querySelector('button.action-close').addEventListener('click', function(ev){
2416
+ ev.preventDefault();
2417
+ ev.stopPropagation();
2418
+ Chat.setCurrentView(Chat.e.viewMessages);
2419
+ return false;
2420
+ }, false);
2421
+ })()/*search view setup*/;
21802422
21812423
/** Callback for poll() to inject new content into the page. jx ==
21822424
the response from /chat-poll. If atEnd is true, the message is
21832425
appended to the end of the chat list (for loading older
21842426
messages), else the beginning (the default). */
@@ -2307,10 +2549,82 @@
23072549
btn.addEventListener('click',()=>loadOldMessages(-1));
23082550
D.append(Chat.e.viewMessages, toolbar);
23092551
toolbar.disabled = true /*will be enabled when msg load finishes */;
23102552
})()/*end history loading widget setup*/;
23112553
2554
+ /**
2555
+ Clears the search result view. If addInstructions is true it adds
2556
+ text to that view instructing the user to enter their query into
2557
+ the message-entry widget (noting that that widget has text
2558
+ implying that it's only for submitting a message, which isn't
2559
+ exactly true when the search view is active).
2560
+
2561
+ Returns the DOM element which wraps all of the chat search
2562
+ result elements.
2563
+ */
2564
+ Chat.clearSearch = function(addInstructions=false){
2565
+ const e = D.clearElement( this.e.searchContent );
2566
+ if(addInstructions){
2567
+ D.append(e, "Enter search terms in the message field. "+
2568
+ "Use #NNNNN to search for the message with ID NNNNN.");
2569
+ }
2570
+ return e;
2571
+ };
2572
+ Chat.clearSearch(true);
2573
+ /**
2574
+ Submits a history search using the main input field's current
2575
+ text. It is assumed that Chat.e.viewSearch===Chat.e.currentView.
2576
+ */
2577
+ Chat.submitSearch = function(){
2578
+ const term = this.inputValue(true);
2579
+ const eMsgTgt = this.clearSearch(true);
2580
+ if( !term ) return;
2581
+ D.append( eMsgTgt, "Searching for ",term," ...");
2582
+ const fd = new FormData();
2583
+ fd.set('q', term);
2584
+ F.fetch(
2585
+ "chat-query", {
2586
+ payload: fd,
2587
+ responseType: 'json',
2588
+ onerror:function(err){
2589
+ Chat.setCurrentView(Chat.e.viewMessages);
2590
+ Chat.reportErrorAsMessage(err);
2591
+ },
2592
+ onload:function(jx){
2593
+ let previd = 0;
2594
+ D.clearElement(eMsgTgt);
2595
+ jx.msgs.forEach((m)=>{
2596
+ m.isSearchResult = true;
2597
+ const mw = new Chat.MessageWidget(m);
2598
+ const spacer = new Chat.SearchCtxLoader({
2599
+ first: jx.first,
2600
+ last: jx.last,
2601
+ previd: previd,
2602
+ nextid: m.msgid
2603
+ });
2604
+ if( spacer.e ) D.append( eMsgTgt, spacer.e.body );
2605
+ D.append( eMsgTgt, mw.e.body );
2606
+ previd = m.msgid;
2607
+ });
2608
+ if( jx.msgs.length ){
2609
+ const spacer = new Chat.SearchCtxLoader({
2610
+ first: jx.first,
2611
+ last: jx.last,
2612
+ previd: previd,
2613
+ nextid: 0
2614
+ });
2615
+ if( spacer.e ) D.append( eMsgTgt, spacer.e.body );
2616
+ }else{
2617
+ D.append( D.clearElement(eMsgTgt),
2618
+ 'No search results found for: ',
2619
+ term );
2620
+ }
2621
+ }
2622
+ }
2623
+ );
2624
+ }/*Chat.submitSearch()*/;
2625
+
23122626
const afterFetch = function f(){
23132627
if(true===f.isFirstCall){
23142628
f.isFirstCall = false;
23152629
Chat.ajaxEnd();
23162630
Chat.e.viewMessages.classList.remove('loading');
@@ -2326,10 +2640,25 @@
23262640
delete Chat.intervalTimer;
23272641
}
23282642
poll.running = false;
23292643
};
23302644
afterFetch.isFirstCall = true;
2645
+ /**
2646
+ FIXME: when polling fails because the remote server is
2647
+ reachable but it's not accepting HTTP requests, we should back
2648
+ off on polling for a while. e.g. if the remote web server process
2649
+ is killed, the poll fails quickly and immediately retries,
2650
+ hammering the remote server until the httpd is back up. That
2651
+ happens often during development of this application.
2652
+
2653
+ XHR does not offer a direct way of distinguishing between
2654
+ HTTP/connection errors, but we can hypothetically use the
2655
+ xhrRequest.status value to do so, with status==0 being a
2656
+ connection error. We do not currently have a clean way of passing
2657
+ that info back to the fossil.fetch() client, so we'll need to
2658
+ hammer on that API a bit to get this working.
2659
+ */
23312660
const poll = async function f(){
23322661
if(f.running) return;
23332662
f.running = true;
23342663
Chat._isBatchLoading = f.isFirstCall;
23352664
if(true===f.isFirstCall){
@@ -2368,17 +2697,11 @@
23682697
Chat._gotServerError = poll.running = false;
23692698
if( window.fossil.config.chat.fromcli ){
23702699
Chat.chatOnlyMode(true);
23712700
}
23722701
Chat.intervalTimer = setInterval(poll, 1000);
2373
- if(0){
2374
- const flip = (ev)=>Chat.animate(ev.target,'anim-flip-h');
2375
- document.querySelectorAll('#chat-buttons-wrapper .cbutton').forEach(function(e){
2376
- e.addEventListener('click',flip, false);
2377
- });
2378
- }
23792702
delete ForceResizeKludge.$disabled;
23802703
ForceResizeKludge();
23812704
Chat.animate.$disabled = false;
23822705
setTimeout( ()=>Chat.inputFocus(), 0 );
23832706
F.page.chat = Chat/* enables testing the APIs via the dev tools */;
23842707
});
23852708
--- src/fossil.page.chat.js
+++ src/fossil.page.chat.js
@@ -145,10 +145,12 @@
145 inputFile: E1('#chat-input-file'),
146 contentDiv: E1('div.content'),
147 viewConfig: E1('#chat-config'),
148 viewPreview: E1('#chat-preview'),
149 previewContent: E1('#chat-preview-content'),
 
 
150 btnPreview: E1('#chat-button-preview'),
151 views: document.querySelectorAll('.chat-view'),
152 activeUserListWrapper: E1('#chat-user-list-wrapper'),
153 activeUserList: E1('#chat-user-list'),
154 btnClearFilter: E1('#chat-clear-filter')
@@ -190,21 +192,31 @@
190 || !!e.querySelector('[data-hashtag="'+this.activeTag+'"]');
191 }
192 },
193 current: undefined/*gets set to current active filter*/
194 },
195 /** Gets (no args) or sets (1 arg) the current input text field value,
196 taking into account single- vs multi-line input. The getter returns
197 a string and the setter returns this object. */
198 inputValue: function(){
 
 
 
 
 
199 const e = this.inputElement();
200 if(arguments.length){
201 if(e.isContentEditable) e.innerText = arguments[0];
202 else e.value = arguments[0];
203 return this;
204 }
205 return e.isContentEditable ? e.innerText : e.value;
 
 
 
 
 
206 },
207 /** Asks the current user input field to take focus. Returns this. */
208 inputFocus: function(){
209 this.inputElement().focus();
210 return this;
@@ -529,11 +541,11 @@
529 const uDate = self.usersLastSeen[u];
530 if(self.filter.user.activeTag===u){
531 uSpan.classList.add('selected');
532 }
533 uSpan.dataset.uname = u;
534 D.append(uSpan, u, "\n",
535 D.append(
536 D.addClass(D.span(),'timestamp'),
537 localTimeString(uDate)//.substr(5/*chop off year*/)
538 ));
539 if(uDate.$uColor){
@@ -1075,11 +1087,11 @@
1075 Chat.MessageWidget = (function(){
1076 /**
1077 Constructor. If passed an argument, it is passed to
1078 this.setMessage() after initialization.
1079 */
1080 const cf = function(){
1081 this.e = {
1082 body: D.addClass(D.div(), 'message-widget'),
1083 tab: D.addClass(D.div(), 'message-widget-tab'),
1084 content: D.addClass(D.div(), 'message-widget-content')
1085 };
@@ -1094,20 +1106,33 @@
1094 /* Map of Date.getDay() values to weekday names. */
1095 0: "Sunday", 1: "Monday", 2: "Tuesday",
1096 3: "Wednesday", 4: "Thursday", 5: "Friday",
1097 6: "Saturday"
1098 };
1099 /* Given a Date, returns the timestamp string used in the
1100 "tab" part of message widgets. */
1101 const theTime = function(d){
1102 return [
1103 //d.getFullYear(),'-',pad2(d.getMonth()+1/*sigh*/),
1104 //'-',pad2(d.getDate()), ' ',
1105 d.getHours(),":",
1106 (d.getMinutes()+100).toString().slice(1,3),
1107 ' ', dowMap[d.getDay()]
1108 ].join('');
 
 
 
 
 
 
 
 
 
 
 
 
 
1109 };
1110
1111 /**
1112 Returns true if this page believes it can embed a view of the
1113 file wrapped by the given message object, else returns false.
@@ -1114,19 +1139,20 @@
1114 */
1115 const canEmbedFile = function f(msg){
1116 if(!f.$rx){
1117 f.$rx = /\.((html?)|(txt)|(md)|(wiki)|(pikchr))$/i;
1118 f.$specificTypes = [
 
1119 'text/plain',
1120 'text/html',
1121 'text/x-markdown',
1122 /* Firefox sends text/markdown when uploading .md files */
1123 'text/markdown',
1124 'text/x-pikchr',
1125 'text/x-fossil-wiki'
1126 // add more as we discover which ones Firefox won't
1127 // force the user to try to download.
1128 ];
1129 }
1130 if(msg.fmime){
1131 if(msg.fmime.startsWith("image/")
1132 || f.$specificTypes.indexOf(msg.fmime)>=0){
@@ -1140,20 +1166,18 @@
1140 Returns true if the given message object "should"
1141 be embedded in fossil-rendered form instead of
1142 raw content form. This is only intended to be passed
1143 message objects for which canEmbedFile() returns true.
1144 */
1145 const shouldWikiRenderEmbed = function f(msg){
1146 if(!f.$rx){
1147 f.$rx = /\.((md)|(wiki)|(pikchr))$/i;
1148 f.$specificTypes = [
1149 'text/x-markdown',
1150 'text/markdown' /* Firefox-uploaded md files */,
1151 'text/x-pikchr',
1152 'text/x-fossil-wiki'
1153 // add more as we discover which ones Firefox won't
1154 // force the user to try to download.
1155 ];
1156 }
1157 if(msg.fmime){
1158 if(f.$specificTypes.indexOf(msg.fmime)>=0) return true;
1159 }
@@ -1179,12 +1203,12 @@
1179 iframe.style.maxHeight = iframe.style.height
1180 = iframe.contentWindow.document.documentElement.scrollHeight + 'px';
1181 if(isHidden) D.addClass(iframe, 'hidden');
1182 }
1183 };
1184
1185 cf.prototype = {
1186 scrollIntoView: function(){
1187 this.e.content.scrollIntoView();
1188 },
1189 setMessage: function(m){
1190 const ds = this.e.body.dataset;
@@ -1205,20 +1229,26 @@
1205 var eXFrom /* element holding xfrom name */;
1206 if(m.xfrom){
1207 eXFrom = D.append(D.addClass(D.span(), 'xfrom'), m.xfrom);
1208 const wrapper = D.append(
1209 D.span(), eXFrom,
1210 D.text(" #",(m.msgid||'???'),' @ ',theTime(d)))
 
 
 
 
 
 
1211 D.append(this.e.tab, wrapper);
1212 }else{/*notification*/
1213 D.addClass(this.e.body, 'notification');
1214 if(m.isError){
1215 D.addClass([contentTarget, this.e.tab], 'error');
1216 }
1217 D.append(
1218 this.e.tab,
1219 D.append(D.code(), 'notification @ ',theTime(d))
1220 );
1221 }
1222 if( m.xfrom && m.fsize>0 ){
1223 if( m.fmime
1224 && m.fmime.startsWith("image/")
@@ -1241,18 +1271,18 @@
1241 D.attr(a,'target','_blank');
1242 D.append(w, a);
1243 if(canEmbedFile(m)){
1244 /* Add an option to embed HTML attachments in an iframe. The primary
1245 use case is attached diffs. */
1246 const shouldWikiRender = shouldWikiRenderEmbed(m);
1247 const downloadArgs = shouldWikiRender ? '?render' : '';
1248 D.addClass(contentTarget, 'wide');
1249 const embedTarget = this.e.content;
1250 const self = this;
1251 const btnEmbed = D.attr(D.checkbox("1", false), 'id',
1252 'embed-'+ds.msgid);
1253 const btnLabel = D.label(btnEmbed, shouldWikiRender
1254 ? "Embed (fossil-rendered)" : "Embed");
1255 /* Maintenance reminder: do not disable the toggle
1256 button while the content is loading because that will
1257 cause it to get stuck in disabled mode if the browser
1258 decides that loading the content should prompt the
@@ -1460,13 +1490,182 @@
1460 Chat.setCurrentView(Chat.e.viewMessages);
1461 e.scrollIntoView(false);
1462 Chat.animate(e, 'anim-fade-out-in');
1463 }
1464 };
1465 return cf;
1466 })()/*MessageWidget*/;
1467
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1468 const BlobXferState = (function(){
1469 /* State for paste and drag/drop */
1470 const bxs = {
1471 dropDetails: document.querySelector('#chat-drop-details'),
1472 blob: undefined,
@@ -1605,16 +1804,26 @@
1605
1606 /**
1607 Submits the contents of the message input field (if not empty)
1608 and/or the file attachment field to the server. If both are
1609 empty, this is a no-op.
 
 
 
1610 */
1611 Chat.submitMessage = function f(){
1612 if(!f.spaces){
1613 f.spaces = /\s+$/;
1614 f.markdownContinuation = /\\\s+$/;
1615 f.spaces2 = /\s{3,}$/;
 
 
 
 
 
 
 
1616 }
1617 this.setCurrentView(this.e.viewMessages);
1618 const fd = new FormData();
1619 const fallback = {msg: this.inputValue()};
1620 var msg = fallback.msg;
@@ -1687,14 +1896,16 @@
1687 //console.debug("Enter key event:", ctrlMode, ev.ctrlKey, ev.shiftKey, ev);
1688 if(ev.shiftKey){
1689 const compactMode = Chat.settings.getBool('edit-compact-mode', false);
1690 ev.preventDefault();
1691 ev.stopPropagation();
1692 /* Shift-enter will run preview mode UNLESS preview mode is
1693 active AND the input field is empty, in which case it will
1694 switch back to message view. */
1695 if(Chat.e.currentView===Chat.e.viewPreview && !text){
 
 
1696 Chat.setCurrentView(Chat.e.viewMessages);
1697 }else if(!text){
1698 f.$toggleCompact(compactMode);
1699 }else if(Chat.settings.getBool('edit-shift-enter-preview', true)){
1700 Chat.e.btnPreview.click();
@@ -1752,19 +1963,19 @@
1752 tall vs wide. Can be toggled via settings. */
1753 document.body.classList.add('my-messages-right');
1754 }
1755 const settingsButton = document.querySelector('#chat-button-settings');
1756 const optionsMenu = E1('#chat-config-options');
1757 const cbToggle = function(ev){
1758 ev.preventDefault();
1759 ev.stopPropagation();
1760 Chat.setCurrentView(Chat.e.currentView===Chat.e.viewConfig
1761 ? Chat.e.viewMessages : Chat.e.viewConfig);
1762 return false;
1763 };
1764 D.attr(settingsButton, 'role', 'button').addEventListener('click', cbToggle, false);
1765 Chat.e.viewConfig.querySelector('button').addEventListener('click', cbToggle, false);
1766
1767 /** Internal acrobatics to allow certain settings toggles to access
1768 related toggles. */
1769 const namedOptions = {
1770 activeUsers:{
@@ -1850,12 +2061,13 @@
1850 boolValue: 'edit-ctrl-send'
1851 },{
1852 label: "Compact mode",
1853 hint: [
1854 "Toggle between a space-saving or more spacious writing area. ",
1855 "When the input field has focus, is empty, and preview mode ",
1856 "is NOT active then Shift-Enter toggles this setting."].join(''),
 
1857 boolValue: 'edit-compact-mode'
1858 },{
1859 label: "Use 'contenteditable' editing mode",
1860 boolValue: 'edit-widget-x',
1861 hint: [
@@ -2020,11 +2232,11 @@
2020 op.persistentSetting,
2021 function(setting){
2022 if(op.checkbox) op.checkbox.checked = !!setting.value;
2023 else if(op.select) op.select.value = setting.value;
2024 if(op.callback) op.callback(setting);
2025 }
2026 );
2027 if(op.checkbox){
2028 op.checkbox.addEventListener(
2029 'change', function(){
2030 Chat.settings.set(op.persistentSetting, op.checkbox.checked)
@@ -2096,11 +2308,11 @@
2096 s.value ? 'add' : 'remove'
2097 ]('compact');
2098 Chat.e.inputFields[Chat.e.inputFields.$currentIndex].focus();
2099 });
2100 Chat.settings.addListener('edit-ctrl-send',function(s){
2101 const label = (s.value ? "Ctrl-" : "")+"Enter submits messages.";
2102 Chat.e.inputFields.forEach((e)=>{
2103 const v = e.dataset.placeholder0 + " " +label;
2104 if(e.isContentEditable) e.dataset.placeholder = v;
2105 else D.attr(e,'placeholder',v);
2106 });
@@ -2128,11 +2340,11 @@
2128 this.e.previewContent.innerHTML = t;
2129 this.e.viewPreview.querySelectorAll('a').forEach(addAnchorTargetBlank);
2130 setupHashtags(this.e.previewContent)/*arguable, for usability reasons*/;
2131 this.inputFocus();
2132 };
2133 Chat.e.viewPreview.querySelector('#chat-preview-close').
2134 addEventListener('click', ()=>Chat.setCurrentView(Chat.e.viewMessages), false);
2135 let previewPending = false;
2136 const elemsToEnable = [btnPreview, Chat.e.btnSubmit, Chat.e.inputFields];
2137 const submit = function(ev){
2138 ev.preventDefault();
@@ -2175,10 +2387,40 @@
2175 });
2176 return false;
2177 };
2178 btnPreview.addEventListener('click', submit, false);
2179 })()/*message preview setup*/;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2180
2181 /** Callback for poll() to inject new content into the page. jx ==
2182 the response from /chat-poll. If atEnd is true, the message is
2183 appended to the end of the chat list (for loading older
2184 messages), else the beginning (the default). */
@@ -2307,10 +2549,82 @@
2307 btn.addEventListener('click',()=>loadOldMessages(-1));
2308 D.append(Chat.e.viewMessages, toolbar);
2309 toolbar.disabled = true /*will be enabled when msg load finishes */;
2310 })()/*end history loading widget setup*/;
2311
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2312 const afterFetch = function f(){
2313 if(true===f.isFirstCall){
2314 f.isFirstCall = false;
2315 Chat.ajaxEnd();
2316 Chat.e.viewMessages.classList.remove('loading');
@@ -2326,10 +2640,25 @@
2326 delete Chat.intervalTimer;
2327 }
2328 poll.running = false;
2329 };
2330 afterFetch.isFirstCall = true;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2331 const poll = async function f(){
2332 if(f.running) return;
2333 f.running = true;
2334 Chat._isBatchLoading = f.isFirstCall;
2335 if(true===f.isFirstCall){
@@ -2368,17 +2697,11 @@
2368 Chat._gotServerError = poll.running = false;
2369 if( window.fossil.config.chat.fromcli ){
2370 Chat.chatOnlyMode(true);
2371 }
2372 Chat.intervalTimer = setInterval(poll, 1000);
2373 if(0){
2374 const flip = (ev)=>Chat.animate(ev.target,'anim-flip-h');
2375 document.querySelectorAll('#chat-buttons-wrapper .cbutton').forEach(function(e){
2376 e.addEventListener('click',flip, false);
2377 });
2378 }
2379 delete ForceResizeKludge.$disabled;
2380 ForceResizeKludge();
2381 Chat.animate.$disabled = false;
2382 setTimeout( ()=>Chat.inputFocus(), 0 );
2383 F.page.chat = Chat/* enables testing the APIs via the dev tools */;
2384 });
2385
--- src/fossil.page.chat.js
+++ src/fossil.page.chat.js
@@ -145,10 +145,12 @@
145 inputFile: E1('#chat-input-file'),
146 contentDiv: E1('div.content'),
147 viewConfig: E1('#chat-config'),
148 viewPreview: E1('#chat-preview'),
149 previewContent: E1('#chat-preview-content'),
150 viewSearch: E1('#chat-search'),
151 searchContent: E1('#chat-search-content'),
152 btnPreview: E1('#chat-button-preview'),
153 views: document.querySelectorAll('.chat-view'),
154 activeUserListWrapper: E1('#chat-user-list-wrapper'),
155 activeUserList: E1('#chat-user-list'),
156 btnClearFilter: E1('#chat-clear-filter')
@@ -190,21 +192,31 @@
192 || !!e.querySelector('[data-hashtag="'+this.activeTag+'"]');
193 }
194 },
195 current: undefined/*gets set to current active filter*/
196 },
197 /**
198 Gets (no args) or sets (1 arg) the current input text field
199 value, taking into account single- vs multi-line input. The
200 getter returns a trim()'d string and the setter returns this
201 object. As a special case, if arguments[0] is a boolean
202 value, it behaves like a getter and, if arguments[0]===true
203 it clears the input field before returning.
204 */
205 inputValue: function(/*string newValue | bool clearInputField*/){
206 const e = this.inputElement();
207 if(arguments.length && 'boolean'!==typeof arguments[0]){
208 if(e.isContentEditable) e.innerText = arguments[0];
209 else e.value = arguments[0];
210 return this;
211 }
212 const rc = e.isContentEditable ? e.innerText : e.value;
213 if( true===arguments[0] ){
214 if(e.isContentEditable) e.innerText = '';
215 else e.value = '';
216 }
217 return rc && rc.trim();
218 },
219 /** Asks the current user input field to take focus. Returns this. */
220 inputFocus: function(){
221 this.inputElement().focus();
222 return this;
@@ -529,11 +541,11 @@
541 const uDate = self.usersLastSeen[u];
542 if(self.filter.user.activeTag===u){
543 uSpan.classList.add('selected');
544 }
545 uSpan.dataset.uname = u;
546 D.append(uSpan, u, "\n",
547 D.append(
548 D.addClass(D.span(),'timestamp'),
549 localTimeString(uDate)//.substr(5/*chop off year*/)
550 ));
551 if(uDate.$uColor){
@@ -1075,11 +1087,11 @@
1087 Chat.MessageWidget = (function(){
1088 /**
1089 Constructor. If passed an argument, it is passed to
1090 this.setMessage() after initialization.
1091 */
1092 const ctor = function(){
1093 this.e = {
1094 body: D.addClass(D.div(), 'message-widget'),
1095 tab: D.addClass(D.div(), 'message-widget-tab'),
1096 content: D.addClass(D.div(), 'message-widget-content')
1097 };
@@ -1094,20 +1106,33 @@
1106 /* Map of Date.getDay() values to weekday names. */
1107 0: "Sunday", 1: "Monday", 2: "Tuesday",
1108 3: "Wednesday", 4: "Thursday", 5: "Friday",
1109 6: "Saturday"
1110 };
1111 /* Given a Date, returns the timestamp string used in the "tab"
1112 part of message widgets. If longFmt is true then a verbose
1113 format is used, else a brief format is used. The returned string
1114 is in client-local time. */
1115 const theTime = function(d, longFmt=false){
1116 const li = [];
1117 if( longFmt ){
1118 li.push(
1119 d.getFullYear(),
1120 '-', pad2(d.getMonth()+1),
1121 '-', pad2(d.getDate()),
1122 ' ',
1123 d.getHours(), ":",
1124 (d.getMinutes()+100).toString().slice(1,3)
1125 );
1126 }else{
1127 li.push(
1128 d.getHours(),":",
1129 (d.getMinutes()+100).toString().slice(1,3),
1130 ' ', dowMap[d.getDay()]
1131 );
1132 }
1133 return li.join('');
1134 };
1135
1136 /**
1137 Returns true if this page believes it can embed a view of the
1138 file wrapped by the given message object, else returns false.
@@ -1114,19 +1139,20 @@
1139 */
1140 const canEmbedFile = function f(msg){
1141 if(!f.$rx){
1142 f.$rx = /\.((html?)|(txt)|(md)|(wiki)|(pikchr))$/i;
1143 f.$specificTypes = [
1144 /* Mime types we know we can embed, sans image/... */
1145 'text/plain',
1146 'text/html',
1147 'text/x-markdown',
1148 /* Firefox sends text/markdown when uploading .md files */
1149 'text/markdown',
1150 'text/x-pikchr',
1151 'text/x-fossil-wiki'
1152 /* Add more as we discover which ones Firefox won't
1153 force the user to try to download. */
1154 ];
1155 }
1156 if(msg.fmime){
1157 if(msg.fmime.startsWith("image/")
1158 || f.$specificTypes.indexOf(msg.fmime)>=0){
@@ -1140,20 +1166,18 @@
1166 Returns true if the given message object "should"
1167 be embedded in fossil-rendered form instead of
1168 raw content form. This is only intended to be passed
1169 message objects for which canEmbedFile() returns true.
1170 */
1171 const shouldFossilRenderEmbed = function f(msg){
1172 if(!f.$rx){
1173 f.$rx = /\.((md)|(wiki)|(pikchr))$/i;
1174 f.$specificTypes = [
1175 'text/x-markdown',
1176 'text/markdown' /* Firefox-uploaded md files */,
1177 'text/x-pikchr',
1178 'text/x-fossil-wiki'
 
 
1179 ];
1180 }
1181 if(msg.fmime){
1182 if(f.$specificTypes.indexOf(msg.fmime)>=0) return true;
1183 }
@@ -1179,12 +1203,12 @@
1203 iframe.style.maxHeight = iframe.style.height
1204 = iframe.contentWindow.document.documentElement.scrollHeight + 'px';
1205 if(isHidden) D.addClass(iframe, 'hidden');
1206 }
1207 };
1208
1209 ctor.prototype = {
1210 scrollIntoView: function(){
1211 this.e.content.scrollIntoView();
1212 },
1213 setMessage: function(m){
1214 const ds = this.e.body.dataset;
@@ -1205,20 +1229,26 @@
1229 var eXFrom /* element holding xfrom name */;
1230 if(m.xfrom){
1231 eXFrom = D.append(D.addClass(D.span(), 'xfrom'), m.xfrom);
1232 const wrapper = D.append(
1233 D.span(), eXFrom,
1234 ' ',
1235 D.append(D.addClass(D.span(), 'msgid'),
1236 '#' + (m.msgid||'???')),
1237 (m.isSearchResult ? ' ' : ' @ '),
1238 D.append(D.addClass(D.span(), 'timestamp'),
1239 theTime(d,!!m.isSearchResult))
1240 );
1241 D.append(this.e.tab, wrapper);
1242 }else{/*notification*/
1243 D.addClass(this.e.body, 'notification');
1244 if(m.isError){
1245 D.addClass([contentTarget, this.e.tab], 'error');
1246 }
1247 D.append(
1248 this.e.tab,
1249 D.append(D.code(), 'notification @ ',theTime(d,false))
1250 );
1251 }
1252 if( m.xfrom && m.fsize>0 ){
1253 if( m.fmime
1254 && m.fmime.startsWith("image/")
@@ -1241,18 +1271,18 @@
1271 D.attr(a,'target','_blank');
1272 D.append(w, a);
1273 if(canEmbedFile(m)){
1274 /* Add an option to embed HTML attachments in an iframe. The primary
1275 use case is attached diffs. */
1276 const shouldFossilRender = shouldFossilRenderEmbed(m);
1277 const downloadArgs = shouldFossilRender ? '?render' : '';
1278 D.addClass(contentTarget, 'wide');
1279 const embedTarget = this.e.content;
1280 const self = this;
1281 const btnEmbed = D.attr(D.checkbox("1", false), 'id',
1282 'embed-'+ds.msgid);
1283 const btnLabel = D.label(btnEmbed, shouldFossilRender
1284 ? "Embed (fossil-rendered)" : "Embed");
1285 /* Maintenance reminder: do not disable the toggle
1286 button while the content is loading because that will
1287 cause it to get stuck in disabled mode if the browser
1288 decides that loading the content should prompt the
@@ -1460,13 +1490,182 @@
1490 Chat.setCurrentView(Chat.e.viewMessages);
1491 e.scrollIntoView(false);
1492 Chat.animate(e, 'anim-fade-out-in');
1493 }
1494 };
1495 return ctor;
1496 })()/*MessageWidget*/;
1497
1498 /**
1499 A widget for loading more messages (context) around a /chat-query
1500 result message.
1501 */
1502 Chat.SearchCtxLoader = (function(){
1503 const nMsgContext = 5;
1504 const zUpArrow = '\u25B2';
1505 const zDownArrow = '\u25BC';
1506 const ctor = function(o){
1507
1508 /* iFirstInTable:
1509 ** msgid of first row in chatfts table.
1510 **
1511 ** iLastInTable:
1512 ** msgid of last row in chatfts table.
1513 **
1514 ** iPrevId:
1515 ** msgid of message immediately above this spacer. Or 0 if this
1516 ** spacer is above all results.
1517 **
1518 ** iNextId:
1519 ** msgid of message immediately below this spacer. Or 0 if this
1520 ** spacer is below all results.
1521 **
1522 ** bIgnoreClick:
1523 ** ignore any clicks if this is true. This is used to ensure there
1524 ** is only ever one request belonging to this widget outstanding
1525 ** at any time.
1526 */
1527 this.o = {
1528 iFirstInTable: o.first,
1529 iLastInTable: o.last,
1530 iPrevId: o.previd,
1531 iNextId: o.nextid,
1532 bIgnoreClick: false
1533 };
1534
1535 this.e = {
1536 body: D.addClass(D.div(), 'spacer-widget'),
1537 up: D.addClass(
1538 D.button(zDownArrow+' Load '+nMsgContext+' more '+zDownArrow),
1539 'up'
1540 ),
1541 down: D.addClass(
1542 D.button(zUpArrow+' Load '+nMsgContext+' more '+zUpArrow),
1543 'down'
1544 ),
1545 all: D.addClass(D.button('Load More'), 'all')
1546 };
1547 D.append( this.e.body, this.e.up, this.e.down, this.e.all );
1548 const ms = this;
1549 this.e.up.addEventListener('click', ()=>ms.load_messages(false));
1550 this.e.down.addEventListener('click', ()=>ms.load_messages(true));
1551 this.e.all.addEventListener('click', ()=>ms.load_messages( (ms.o.iPrevId==0) ));
1552 this.set_button_visibility();
1553 };
1554
1555 ctor.prototype = {
1556 set_button_visibility: function() {
1557 if( !this.e ) return;
1558 const o = this.o;
1559
1560 const iPrevId = (o.iPrevId!=0) ? o.iPrevId : o.iFirstInTable-1;
1561 const iNextId = (o.iNextId!=0) ? o.iNextId : o.iLastInTable+1;
1562 let nDiff = (iNextId - iPrevId) - 1;
1563
1564 for( const x of [this.e.up, this.e.down, this.e.all] ){
1565 if( x ) D.addClass(x, 'hidden');
1566 }
1567 let nVisible = 0;
1568 if( nDiff>0 ){
1569 if( nDiff>nMsgContext && (o.iPrevId==0 || o.iNextId==0) ){
1570 nDiff = nMsgContext;
1571 }
1572
1573 if( nDiff<=nMsgContext && o.iPrevId!=0 && o.iNextId!=0 ){
1574 D.removeClass(this.e.all, 'hidden');
1575 ++nVisible;
1576 this.e.all.innerText = (
1577 zUpArrow + " Load " + nDiff + " more " + zDownArrow
1578 );
1579 }else{
1580 if( o.iPrevId!=0 ){
1581 ++nVisible;
1582 D.removeClass(this.e.up, 'hidden');
1583 }else if( this.e.up ){
1584 if( this.e.up.parentNode ) D.remove(this.e.up);
1585 delete this.e.up;
1586 }
1587 if( o.iNextId!=0 ){
1588 ++nVisible;
1589 D.removeClass(this.e.down, 'hidden');
1590 }else if( this.e.down ){
1591 if( this.e.down.parentNode ) D.remove( this.e.down );
1592 delete this.e.down;
1593 }
1594 }
1595 }
1596 if( !nVisible ){
1597 /* The DOM elements can now be disposed of. */
1598 for( const x of [this.e.up, this.e.down, this.e.all, this.e.body] ){
1599 if( x?.parentNode ) D.remove(x);
1600 }
1601 delete this.e;
1602 }
1603 },
1604
1605 load_messages: function(bDown) {
1606 if( this.bIgnoreClick ) return;
1607
1608 var iFirst = 0; /* msgid of first message to fetch */
1609 var nFetch = 0; /* Number of messages to fetch */
1610 var iEof = 0; /* last msgid in spacers range, plus 1 */
1611
1612 const e = this.e, o = this.o;
1613 this.bIgnoreClick = true;
1614
1615 /* Figure out the required range of messages. */
1616 if( bDown ){
1617 iFirst = this.o.iNextId - nMsgContext;
1618 if( iFirst<this.o.iFirstInTable ){
1619 iFirst = this.o.iFirstInTable;
1620 }
1621 }else{
1622 iFirst = this.o.iPrevId+1;
1623 }
1624 nFetch = nMsgContext;
1625 iEof = (this.o.iNextId > 0) ? this.o.iNextId : this.o.iLastInTable+1;
1626 if( iFirst+nFetch>iEof ){
1627 nFetch = iEof - iFirst;
1628 }
1629 const ms = this;
1630 F.fetch("chat-query",{
1631 urlParams:{
1632 q: '',
1633 n: nFetch,
1634 i: iFirst
1635 },
1636 responseType: "json",
1637 onload:function(jx){
1638 if( bDown ) jx.msgs.reverse();
1639 jx.msgs.forEach((m) => {
1640 var mw = new Chat.MessageWidget(m);
1641 if( bDown ){
1642 /* Inject the message below this object's body, or
1643 append it to Chat.e.searchContent if this element
1644 is the final one in its parent (Chat.e.searchContent). */
1645 const eAnchor = e.body.nextElementSibling;
1646 if( eAnchor ) Chat.e.searchContent.insertBefore(mw.e.body, eAnchor);
1647 else D.append(Chat.e.searchContent, mw.e.body);
1648 }else{
1649 Chat.e.searchContent.insertBefore(mw.e.body, e.body);
1650 }
1651 });
1652 if( bDown ){
1653 o.iNextId -= jx.msgs.length;
1654 }else{
1655 o.iPrevId += jx.msgs.length;
1656 }
1657 ms.set_button_visibility();
1658 ms.bIgnoreClick = false;
1659 }
1660 });
1661 }
1662 };
1663
1664 return ctor;
1665 })() /*SearchCtxLoader*/;
1666
1667 const BlobXferState = (function(){
1668 /* State for paste and drag/drop */
1669 const bxs = {
1670 dropDetails: document.querySelector('#chat-drop-details'),
1671 blob: undefined,
@@ -1605,16 +1804,26 @@
1804
1805 /**
1806 Submits the contents of the message input field (if not empty)
1807 and/or the file attachment field to the server. If both are
1808 empty, this is a no-op.
1809
1810 If the current view is the history search, this instead sends the
1811 input text to that widget.
1812 */
1813 Chat.submitMessage = function f(){
1814 if(!f.spaces){
1815 f.spaces = /\s+$/;
1816 f.markdownContinuation = /\\\s+$/;
1817 f.spaces2 = /\s{3,}$/;
1818 }
1819 switch( this.e.currentView ){
1820 case this.e.viewSearch: this.submitSearch();
1821 return;
1822 case this.e.viewPreview: this.e.btnPreview.click();
1823 return;
1824 default: break;
1825 }
1826 this.setCurrentView(this.e.viewMessages);
1827 const fd = new FormData();
1828 const fallback = {msg: this.inputValue()};
1829 var msg = fallback.msg;
@@ -1687,14 +1896,16 @@
1896 //console.debug("Enter key event:", ctrlMode, ev.ctrlKey, ev.shiftKey, ev);
1897 if(ev.shiftKey){
1898 const compactMode = Chat.settings.getBool('edit-compact-mode', false);
1899 ev.preventDefault();
1900 ev.stopPropagation();
1901 /* Shift-enter will run preview mode UNLESS the input field is empty
1902 AND (preview or search mode) is active, in which cases it will
1903 switch back to message view. */
1904 if(!text &&
1905 (Chat.e.currentView===Chat.e.viewPreview
1906 | Chat.e.currentView===Chat.e.viewSearch)){
1907 Chat.setCurrentView(Chat.e.viewMessages);
1908 }else if(!text){
1909 f.$toggleCompact(compactMode);
1910 }else if(Chat.settings.getBool('edit-shift-enter-preview', true)){
1911 Chat.e.btnPreview.click();
@@ -1752,19 +1963,19 @@
1963 tall vs wide. Can be toggled via settings. */
1964 document.body.classList.add('my-messages-right');
1965 }
1966 const settingsButton = document.querySelector('#chat-button-settings');
1967 const optionsMenu = E1('#chat-config-options');
1968 const eToggleView = function(ev){
1969 ev.preventDefault();
1970 ev.stopPropagation();
1971 Chat.setCurrentView(Chat.e.currentView===Chat.e.viewConfig
1972 ? Chat.e.viewMessages : Chat.e.viewConfig);
1973 return false;
1974 };
1975 D.attr(settingsButton, 'role', 'button').addEventListener('click', eToggleView, false);
1976 Chat.e.viewConfig.querySelector('button.action-close').addEventListener('click', eToggleView, false);
1977
1978 /** Internal acrobatics to allow certain settings toggles to access
1979 related toggles. */
1980 const namedOptions = {
1981 activeUsers:{
@@ -1850,12 +2061,13 @@
2061 boolValue: 'edit-ctrl-send'
2062 },{
2063 label: "Compact mode",
2064 hint: [
2065 "Toggle between a space-saving or more spacious writing area. ",
2066 "When the input field has focus and is empty ",
2067 "then Shift-Enter may (depending on the current view) toggle this setting."
2068 ].join(''),
2069 boolValue: 'edit-compact-mode'
2070 },{
2071 label: "Use 'contenteditable' editing mode",
2072 boolValue: 'edit-widget-x',
2073 hint: [
@@ -2020,11 +2232,11 @@
2232 op.persistentSetting,
2233 function(setting){
2234 if(op.checkbox) op.checkbox.checked = !!setting.value;
2235 else if(op.select) op.select.value = setting.value;
2236 if(op.callback) op.callback(setting);
2237 }
2238 );
2239 if(op.checkbox){
2240 op.checkbox.addEventListener(
2241 'change', function(){
2242 Chat.settings.set(op.persistentSetting, op.checkbox.checked)
@@ -2096,11 +2308,11 @@
2308 s.value ? 'add' : 'remove'
2309 ]('compact');
2310 Chat.e.inputFields[Chat.e.inputFields.$currentIndex].focus();
2311 });
2312 Chat.settings.addListener('edit-ctrl-send',function(s){
2313 const label = (s.value ? "Ctrl-" : "")+"Enter submits message";
2314 Chat.e.inputFields.forEach((e)=>{
2315 const v = e.dataset.placeholder0 + " " +label;
2316 if(e.isContentEditable) e.dataset.placeholder = v;
2317 else D.attr(e,'placeholder',v);
2318 });
@@ -2128,11 +2340,11 @@
2340 this.e.previewContent.innerHTML = t;
2341 this.e.viewPreview.querySelectorAll('a').forEach(addAnchorTargetBlank);
2342 setupHashtags(this.e.previewContent)/*arguable, for usability reasons*/;
2343 this.inputFocus();
2344 };
2345 Chat.e.viewPreview.querySelector('button.action-close').
2346 addEventListener('click', ()=>Chat.setCurrentView(Chat.e.viewMessages), false);
2347 let previewPending = false;
2348 const elemsToEnable = [btnPreview, Chat.e.btnSubmit, Chat.e.inputFields];
2349 const submit = function(ev){
2350 ev.preventDefault();
@@ -2175,10 +2387,40 @@
2387 });
2388 return false;
2389 };
2390 btnPreview.addEventListener('click', submit, false);
2391 })()/*message preview setup*/;
2392
2393 (function(){/*Set up #chat-search and related bits */
2394 const btn = document.querySelector('#chat-button-search');
2395 D.attr(btn, 'role', 'button').addEventListener('click', function(ev){
2396 ev.preventDefault();
2397 ev.stopPropagation();
2398 const msg = Chat.inputValue();
2399 if( Chat.e.currentView===Chat.e.viewSearch ){
2400 if( msg ) Chat.submitSearch();
2401 else Chat.setCurrentView(Chat.e.viewMessages);
2402 }else{
2403 Chat.setCurrentView(Chat.e.viewSearch);
2404 if( msg ) Chat.submitSearch();
2405 }
2406 return false;
2407 }, false);
2408 Chat.e.viewSearch.querySelector('button.action-clear').addEventListener('click', function(ev){
2409 ev.preventDefault();
2410 ev.stopPropagation();
2411 Chat.clearSearch(true);
2412 Chat.setCurrentView(Chat.e.viewMessages);
2413 return false;
2414 }, false);
2415 Chat.e.viewSearch.querySelector('button.action-close').addEventListener('click', function(ev){
2416 ev.preventDefault();
2417 ev.stopPropagation();
2418 Chat.setCurrentView(Chat.e.viewMessages);
2419 return false;
2420 }, false);
2421 })()/*search view setup*/;
2422
2423 /** Callback for poll() to inject new content into the page. jx ==
2424 the response from /chat-poll. If atEnd is true, the message is
2425 appended to the end of the chat list (for loading older
2426 messages), else the beginning (the default). */
@@ -2307,10 +2549,82 @@
2549 btn.addEventListener('click',()=>loadOldMessages(-1));
2550 D.append(Chat.e.viewMessages, toolbar);
2551 toolbar.disabled = true /*will be enabled when msg load finishes */;
2552 })()/*end history loading widget setup*/;
2553
2554 /**
2555 Clears the search result view. If addInstructions is true it adds
2556 text to that view instructing the user to enter their query into
2557 the message-entry widget (noting that that widget has text
2558 implying that it's only for submitting a message, which isn't
2559 exactly true when the search view is active).
2560
2561 Returns the DOM element which wraps all of the chat search
2562 result elements.
2563 */
2564 Chat.clearSearch = function(addInstructions=false){
2565 const e = D.clearElement( this.e.searchContent );
2566 if(addInstructions){
2567 D.append(e, "Enter search terms in the message field. "+
2568 "Use #NNNNN to search for the message with ID NNNNN.");
2569 }
2570 return e;
2571 };
2572 Chat.clearSearch(true);
2573 /**
2574 Submits a history search using the main input field's current
2575 text. It is assumed that Chat.e.viewSearch===Chat.e.currentView.
2576 */
2577 Chat.submitSearch = function(){
2578 const term = this.inputValue(true);
2579 const eMsgTgt = this.clearSearch(true);
2580 if( !term ) return;
2581 D.append( eMsgTgt, "Searching for ",term," ...");
2582 const fd = new FormData();
2583 fd.set('q', term);
2584 F.fetch(
2585 "chat-query", {
2586 payload: fd,
2587 responseType: 'json',
2588 onerror:function(err){
2589 Chat.setCurrentView(Chat.e.viewMessages);
2590 Chat.reportErrorAsMessage(err);
2591 },
2592 onload:function(jx){
2593 let previd = 0;
2594 D.clearElement(eMsgTgt);
2595 jx.msgs.forEach((m)=>{
2596 m.isSearchResult = true;
2597 const mw = new Chat.MessageWidget(m);
2598 const spacer = new Chat.SearchCtxLoader({
2599 first: jx.first,
2600 last: jx.last,
2601 previd: previd,
2602 nextid: m.msgid
2603 });
2604 if( spacer.e ) D.append( eMsgTgt, spacer.e.body );
2605 D.append( eMsgTgt, mw.e.body );
2606 previd = m.msgid;
2607 });
2608 if( jx.msgs.length ){
2609 const spacer = new Chat.SearchCtxLoader({
2610 first: jx.first,
2611 last: jx.last,
2612 previd: previd,
2613 nextid: 0
2614 });
2615 if( spacer.e ) D.append( eMsgTgt, spacer.e.body );
2616 }else{
2617 D.append( D.clearElement(eMsgTgt),
2618 'No search results found for: ',
2619 term );
2620 }
2621 }
2622 }
2623 );
2624 }/*Chat.submitSearch()*/;
2625
2626 const afterFetch = function f(){
2627 if(true===f.isFirstCall){
2628 f.isFirstCall = false;
2629 Chat.ajaxEnd();
2630 Chat.e.viewMessages.classList.remove('loading');
@@ -2326,10 +2640,25 @@
2640 delete Chat.intervalTimer;
2641 }
2642 poll.running = false;
2643 };
2644 afterFetch.isFirstCall = true;
2645 /**
2646 FIXME: when polling fails because the remote server is
2647 reachable but it's not accepting HTTP requests, we should back
2648 off on polling for a while. e.g. if the remote web server process
2649 is killed, the poll fails quickly and immediately retries,
2650 hammering the remote server until the httpd is back up. That
2651 happens often during development of this application.
2652
2653 XHR does not offer a direct way of distinguishing between
2654 HTTP/connection errors, but we can hypothetically use the
2655 xhrRequest.status value to do so, with status==0 being a
2656 connection error. We do not currently have a clean way of passing
2657 that info back to the fossil.fetch() client, so we'll need to
2658 hammer on that API a bit to get this working.
2659 */
2660 const poll = async function f(){
2661 if(f.running) return;
2662 f.running = true;
2663 Chat._isBatchLoading = f.isFirstCall;
2664 if(true===f.isFirstCall){
@@ -2368,17 +2697,11 @@
2697 Chat._gotServerError = poll.running = false;
2698 if( window.fossil.config.chat.fromcli ){
2699 Chat.chatOnlyMode(true);
2700 }
2701 Chat.intervalTimer = setInterval(poll, 1000);
 
 
 
 
 
 
2702 delete ForceResizeKludge.$disabled;
2703 ForceResizeKludge();
2704 Chat.animate.$disabled = false;
2705 setTimeout( ()=>Chat.inputFocus(), 0 );
2706 F.page.chat = Chat/* enables testing the APIs via the dev tools */;
2707 });
2708
+47 -19
--- src/search.c
+++ src/search.c
@@ -975,10 +975,33 @@
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
+49 -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;
@@ -616,14 +635,19 @@
616635
617636
body.chat #chat-clear-filter {
618637
margin: 0.25em 0.5em;
619638
}
620639
621
-body.chat.fossil-dark-style #chat-button-attach > svg {
622
- /* The black paperclip is barely visible in dark-mode
623
- skins when they have dark buttons */
624
- filter: invert(0.8);
640
+body.chat .searchForm {
641
+ margin-top: 1em;
642
+}
643
+body.chat .spacer-widget button {
644
+ margin-left: 1ex;
645
+ margin-right: 1ex;
646
+ display: block;
647
+ margin-top: 0.5em;
648
+ margin-bottom: 0.5em;
625649
}
626650
627651
body.chat .anim-rotate-360 {
628652
animation: rotate-360 750ms linear;
629653
}
630654
--- src/style.chat.css
+++ src/style.chat.css
@@ -68,11 +68,11 @@
68 content placed below this. */
69 border-bottom: 1px transparent;
70 }
71 body.chat.monospace-messages .message-widget-content,
72 body.chat.monospace-messages .chat-input-field{
73 font-family: monospace;
74 }
75 body.chat .message-widget-content > * {
76 margin: 0;
77 padding: 0;
78 }
@@ -115,16 +115,37 @@
115 white-space: nowrap;
116 }
117 body.chat .fossil-tooltip.help-buttonlet-content {
118 font-size: 80%;
119 }
 
 
 
 
120 body.chat .message-widget .message-widget-tab .xfrom {
121 /* Element which holds the "this message is from user X" part
122 of the message banner. */
123 font-style: italic;
124 font-weight: bold;
125 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126 /* The popup element for displaying message timestamps
127 and deletion controls. */
128 body.chat .chat-message-popup {
129 font-family: monospace;
130 font-size: 0.9em;
@@ -182,20 +203,10 @@
182 body.chat.chat-only-mode{
183 padding: 0;
184 margin: 0 auto;
185 }
186 body.chat #chat-button-settings {}
187 /** Popup widget for the /chat settings. */
188 body.chat .chat-settings-popup {
189 font-size: 0.8em;
190 text-align: left;
191 display: flex;
192 flex-direction: column;
193 align-items: stretch;
194 padding: 0.25em;
195 z-index: 200;
196 }
197
198 /** Container for the list of /chat messages. */
199 body.chat #chat-messages-wrapper {
200 overflow: auto;
201 padding: 0 0.25em;
@@ -346,18 +357,18 @@
346 body.chat #chat-buttons-wrapper > .cbutton:hover {
347 background-color: rgba(200,200,200,0.3);
348 }
349 body.chat #chat-input-line-wrapper.compact #chat-buttons-wrapper > .cbutton {
350 margin: 2px 0.125em 0 0.125em;
351 min-width: 6ex;
352 max-width: 6ex;
353 min-height: 2.3ex;
354 max-height: 2.3ex;
355 font-size: 120%;
356 }
357 body.chat #chat-input-line-wrapper.compact #chat-buttons-wrapper #chat-button-submit {
358 min-width: 12ex;
359 }
360 .chat-input-field {
361 font-family: inherit
362 }
363 body.chat #chat-input-line-wrapper:not(.compact) #chat-input-field-multi,
@@ -440,10 +451,11 @@
440 /*ensure that these grow more than the non-.chat-view elements.
441 Note that setting flex shrink to 0 breaks/disables scrolling!*/;
442 margin-bottom: 0.2em;
443 }
444 body.chat #chat-config,
 
445 body.chat #chat-preview {
446 /* /chat configuration widget */
447 display: flex;
448 flex-direction: column;
449 overflow: auto;
@@ -518,31 +530,38 @@
518 display: inline-block;
519 opacity: 0.85;
520 }
521 body.chat #chat-config #chat-config-options .menu-entry select {
522 }
523 body.chat #chat-preview #chat-preview-content {
 
524 overflow: auto;
525 flex: 1 1 auto;
526 padding: 0.5em;
527 border: 1px dotted;
528 }
 
529 body.chat #chat-preview #chat-preview-content > * {
530 margin: 0;
531 padding: 0;
532 }
533 body.chat #chat-preview #chat-preview-buttons {
534 flex: 0 1 auto;
535 display: flex;
536 flex-direction: column;
537 }
538 body.chat #chat-config > button,
539 body.chat #chat-preview #chat-preview-buttons > button {
540 padding: 0.5em;
541 flex: 0 1 auto;
542 margin: 0.25em 0;
543 }
 
 
 
 
 
 
544
545 body.chat #chat-user-list-wrapper {
546 /* Safari can't do fieldsets right, so we emulate one. */
547 border-radius: 0.5em;
548 margin: 1em 0 0.2em 0;
@@ -616,14 +635,19 @@
616
617 body.chat #chat-clear-filter {
618 margin: 0.25em 0.5em;
619 }
620
621 body.chat.fossil-dark-style #chat-button-attach > svg {
622 /* The black paperclip is barely visible in dark-mode
623 skins when they have dark buttons */
624 filter: invert(0.8);
 
 
 
 
 
625 }
626
627 body.chat .anim-rotate-360 {
628 animation: rotate-360 750ms linear;
629 }
630
--- src/style.chat.css
+++ src/style.chat.css
@@ -68,11 +68,11 @@
68 content placed below this. */
69 border-bottom: 1px transparent;
70 }
71 body.chat.monospace-messages .message-widget-content,
72 body.chat.monospace-messages .chat-input-field{
73 font-family: monospace;
74 }
75 body.chat .message-widget-content > * {
76 margin: 0;
77 padding: 0;
78 }
@@ -115,16 +115,37 @@
115 white-space: nowrap;
116 }
117 body.chat .fossil-tooltip.help-buttonlet-content {
118 font-size: 80%;
119 }
120
121 body.chat .message-widget .message-widget-tab {
122 /* Element which renders the main metadata for a given message. */
123 }
124 body.chat .message-widget .message-widget-tab .xfrom {
125 /* xfrom part of the message tab */
 
126 font-style: italic;
127 font-weight: bold;
128 }
129
130 body.chat .message-widget .message-widget-tab .mtime {
131 /* mtime part of the message tab */
132 }
133
134 body.chat .message-widget .message-widget-tab .msgid {
135 /* msgid part of the message tab */
136 }
137
138 body.chat .message-widget .match {
139 font-weight: bold;
140 background-color: yellow;
141 }
142
143 body.chat.fossil-dark-style .message-widget .match {
144 background-color: #ff4800;
145 }
146
147 /* The popup element for displaying message timestamps
148 and deletion controls. */
149 body.chat .chat-message-popup {
150 font-family: monospace;
151 font-size: 0.9em;
@@ -182,20 +203,10 @@
203 body.chat.chat-only-mode{
204 padding: 0;
205 margin: 0 auto;
206 }
207 body.chat #chat-button-settings {}
 
 
 
 
 
 
 
 
 
 
208
209 /** Container for the list of /chat messages. */
210 body.chat #chat-messages-wrapper {
211 overflow: auto;
212 padding: 0 0.25em;
@@ -346,18 +357,18 @@
357 body.chat #chat-buttons-wrapper > .cbutton:hover {
358 background-color: rgba(200,200,200,0.3);
359 }
360 body.chat #chat-input-line-wrapper.compact #chat-buttons-wrapper > .cbutton {
361 margin: 2px 0.125em 0 0.125em;
362 min-width: 4.5ex;
363 max-width: 4.5ex;
364 min-height: 2.3ex;
365 max-height: 2.3ex;
366 font-size: 120%;
367 }
368 body.chat #chat-input-line-wrapper.compact #chat-buttons-wrapper #chat-button-submit {
369 min-width: 10ex;
370 }
371 .chat-input-field {
372 font-family: inherit
373 }
374 body.chat #chat-input-line-wrapper:not(.compact) #chat-input-field-multi,
@@ -440,10 +451,11 @@
451 /*ensure that these grow more than the non-.chat-view elements.
452 Note that setting flex shrink to 0 breaks/disables scrolling!*/;
453 margin-bottom: 0.2em;
454 }
455 body.chat #chat-config,
456 body.chat #chat-search,
457 body.chat #chat-preview {
458 /* /chat configuration widget */
459 display: flex;
460 flex-direction: column;
461 overflow: auto;
@@ -518,31 +530,38 @@
530 display: inline-block;
531 opacity: 0.85;
532 }
533 body.chat #chat-config #chat-config-options .menu-entry select {
534 }
535 body.chat #chat-preview #chat-preview-content,
536 body.chat #chat-search #chat-search-content {
537 overflow: auto;
538 flex: 1 1 auto;
539 padding: 0.5em;
540 border: 1px dotted;
541 }
542
543 body.chat #chat-preview #chat-preview-content > * {
544 margin: 0;
545 padding: 0;
546 }
547 body.chat .chat-view .button-bar {
548 flex: 0 1 auto;
549 display: flex;
550 flex-direction: column;
551 }
552 body.chat .chat-view .button-bar button {
 
553 padding: 0.5em;
554 flex: 1 1 auto;
555 margin: 0.25em 0;
556 }
557
558 body.chat #chat-search .button-bar {
559 flex: 0 1 auto;
560 display: flex;
561 flex-direction: row;
562 }
563
564 body.chat #chat-user-list-wrapper {
565 /* Safari can't do fieldsets right, so we emulate one. */
566 border-radius: 0.5em;
567 margin: 1em 0 0.2em 0;
@@ -616,14 +635,19 @@
635
636 body.chat #chat-clear-filter {
637 margin: 0.25em 0.5em;
638 }
639
640 body.chat .searchForm {
641 margin-top: 1em;
642 }
643 body.chat .spacer-widget button {
644 margin-left: 1ex;
645 margin-right: 1ex;
646 display: block;
647 margin-top: 0.5em;
648 margin-bottom: 0.5em;
649 }
650
651 body.chat .anim-rotate-360 {
652 animation: rotate-360 750ms linear;
653 }
654
+49 -25
--- src/style.chat.css
+++ src/style.chat.css
@@ -68,11 +68,11 @@
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;
@@ -616,14 +635,19 @@
616635
617636
body.chat #chat-clear-filter {
618637
margin: 0.25em 0.5em;
619638
}
620639
621
-body.chat.fossil-dark-style #chat-button-attach > svg {
622
- /* The black paperclip is barely visible in dark-mode
623
- skins when they have dark buttons */
624
- filter: invert(0.8);
640
+body.chat .searchForm {
641
+ margin-top: 1em;
642
+}
643
+body.chat .spacer-widget button {
644
+ margin-left: 1ex;
645
+ margin-right: 1ex;
646
+ display: block;
647
+ margin-top: 0.5em;
648
+ margin-bottom: 0.5em;
625649
}
626650
627651
body.chat .anim-rotate-360 {
628652
animation: rotate-360 750ms linear;
629653
}
630654
--- src/style.chat.css
+++ src/style.chat.css
@@ -68,11 +68,11 @@
68 content placed below this. */
69 border-bottom: 1px transparent;
70 }
71 body.chat.monospace-messages .message-widget-content,
72 body.chat.monospace-messages .chat-input-field{
73 font-family: monospace;
74 }
75 body.chat .message-widget-content > * {
76 margin: 0;
77 padding: 0;
78 }
@@ -115,16 +115,37 @@
115 white-space: nowrap;
116 }
117 body.chat .fossil-tooltip.help-buttonlet-content {
118 font-size: 80%;
119 }
 
 
 
 
120 body.chat .message-widget .message-widget-tab .xfrom {
121 /* Element which holds the "this message is from user X" part
122 of the message banner. */
123 font-style: italic;
124 font-weight: bold;
125 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126 /* The popup element for displaying message timestamps
127 and deletion controls. */
128 body.chat .chat-message-popup {
129 font-family: monospace;
130 font-size: 0.9em;
@@ -182,20 +203,10 @@
182 body.chat.chat-only-mode{
183 padding: 0;
184 margin: 0 auto;
185 }
186 body.chat #chat-button-settings {}
187 /** Popup widget for the /chat settings. */
188 body.chat .chat-settings-popup {
189 font-size: 0.8em;
190 text-align: left;
191 display: flex;
192 flex-direction: column;
193 align-items: stretch;
194 padding: 0.25em;
195 z-index: 200;
196 }
197
198 /** Container for the list of /chat messages. */
199 body.chat #chat-messages-wrapper {
200 overflow: auto;
201 padding: 0 0.25em;
@@ -346,18 +357,18 @@
346 body.chat #chat-buttons-wrapper > .cbutton:hover {
347 background-color: rgba(200,200,200,0.3);
348 }
349 body.chat #chat-input-line-wrapper.compact #chat-buttons-wrapper > .cbutton {
350 margin: 2px 0.125em 0 0.125em;
351 min-width: 6ex;
352 max-width: 6ex;
353 min-height: 2.3ex;
354 max-height: 2.3ex;
355 font-size: 120%;
356 }
357 body.chat #chat-input-line-wrapper.compact #chat-buttons-wrapper #chat-button-submit {
358 min-width: 12ex;
359 }
360 .chat-input-field {
361 font-family: inherit
362 }
363 body.chat #chat-input-line-wrapper:not(.compact) #chat-input-field-multi,
@@ -440,10 +451,11 @@
440 /*ensure that these grow more than the non-.chat-view elements.
441 Note that setting flex shrink to 0 breaks/disables scrolling!*/;
442 margin-bottom: 0.2em;
443 }
444 body.chat #chat-config,
 
445 body.chat #chat-preview {
446 /* /chat configuration widget */
447 display: flex;
448 flex-direction: column;
449 overflow: auto;
@@ -518,31 +530,38 @@
518 display: inline-block;
519 opacity: 0.85;
520 }
521 body.chat #chat-config #chat-config-options .menu-entry select {
522 }
523 body.chat #chat-preview #chat-preview-content {
 
524 overflow: auto;
525 flex: 1 1 auto;
526 padding: 0.5em;
527 border: 1px dotted;
528 }
 
529 body.chat #chat-preview #chat-preview-content > * {
530 margin: 0;
531 padding: 0;
532 }
533 body.chat #chat-preview #chat-preview-buttons {
534 flex: 0 1 auto;
535 display: flex;
536 flex-direction: column;
537 }
538 body.chat #chat-config > button,
539 body.chat #chat-preview #chat-preview-buttons > button {
540 padding: 0.5em;
541 flex: 0 1 auto;
542 margin: 0.25em 0;
543 }
 
 
 
 
 
 
544
545 body.chat #chat-user-list-wrapper {
546 /* Safari can't do fieldsets right, so we emulate one. */
547 border-radius: 0.5em;
548 margin: 1em 0 0.2em 0;
@@ -616,14 +635,19 @@
616
617 body.chat #chat-clear-filter {
618 margin: 0.25em 0.5em;
619 }
620
621 body.chat.fossil-dark-style #chat-button-attach > svg {
622 /* The black paperclip is barely visible in dark-mode
623 skins when they have dark buttons */
624 filter: invert(0.8);
 
 
 
 
 
625 }
626
627 body.chat .anim-rotate-360 {
628 animation: rotate-360 750ms linear;
629 }
630
--- src/style.chat.css
+++ src/style.chat.css
@@ -68,11 +68,11 @@
68 content placed below this. */
69 border-bottom: 1px transparent;
70 }
71 body.chat.monospace-messages .message-widget-content,
72 body.chat.monospace-messages .chat-input-field{
73 font-family: monospace;
74 }
75 body.chat .message-widget-content > * {
76 margin: 0;
77 padding: 0;
78 }
@@ -115,16 +115,37 @@
115 white-space: nowrap;
116 }
117 body.chat .fossil-tooltip.help-buttonlet-content {
118 font-size: 80%;
119 }
120
121 body.chat .message-widget .message-widget-tab {
122 /* Element which renders the main metadata for a given message. */
123 }
124 body.chat .message-widget .message-widget-tab .xfrom {
125 /* xfrom part of the message tab */
 
126 font-style: italic;
127 font-weight: bold;
128 }
129
130 body.chat .message-widget .message-widget-tab .mtime {
131 /* mtime part of the message tab */
132 }
133
134 body.chat .message-widget .message-widget-tab .msgid {
135 /* msgid part of the message tab */
136 }
137
138 body.chat .message-widget .match {
139 font-weight: bold;
140 background-color: yellow;
141 }
142
143 body.chat.fossil-dark-style .message-widget .match {
144 background-color: #ff4800;
145 }
146
147 /* The popup element for displaying message timestamps
148 and deletion controls. */
149 body.chat .chat-message-popup {
150 font-family: monospace;
151 font-size: 0.9em;
@@ -182,20 +203,10 @@
203 body.chat.chat-only-mode{
204 padding: 0;
205 margin: 0 auto;
206 }
207 body.chat #chat-button-settings {}
 
 
 
 
 
 
 
 
 
 
208
209 /** Container for the list of /chat messages. */
210 body.chat #chat-messages-wrapper {
211 overflow: auto;
212 padding: 0 0.25em;
@@ -346,18 +357,18 @@
357 body.chat #chat-buttons-wrapper > .cbutton:hover {
358 background-color: rgba(200,200,200,0.3);
359 }
360 body.chat #chat-input-line-wrapper.compact #chat-buttons-wrapper > .cbutton {
361 margin: 2px 0.125em 0 0.125em;
362 min-width: 4.5ex;
363 max-width: 4.5ex;
364 min-height: 2.3ex;
365 max-height: 2.3ex;
366 font-size: 120%;
367 }
368 body.chat #chat-input-line-wrapper.compact #chat-buttons-wrapper #chat-button-submit {
369 min-width: 10ex;
370 }
371 .chat-input-field {
372 font-family: inherit
373 }
374 body.chat #chat-input-line-wrapper:not(.compact) #chat-input-field-multi,
@@ -440,10 +451,11 @@
451 /*ensure that these grow more than the non-.chat-view elements.
452 Note that setting flex shrink to 0 breaks/disables scrolling!*/;
453 margin-bottom: 0.2em;
454 }
455 body.chat #chat-config,
456 body.chat #chat-search,
457 body.chat #chat-preview {
458 /* /chat configuration widget */
459 display: flex;
460 flex-direction: column;
461 overflow: auto;
@@ -518,31 +530,38 @@
530 display: inline-block;
531 opacity: 0.85;
532 }
533 body.chat #chat-config #chat-config-options .menu-entry select {
534 }
535 body.chat #chat-preview #chat-preview-content,
536 body.chat #chat-search #chat-search-content {
537 overflow: auto;
538 flex: 1 1 auto;
539 padding: 0.5em;
540 border: 1px dotted;
541 }
542
543 body.chat #chat-preview #chat-preview-content > * {
544 margin: 0;
545 padding: 0;
546 }
547 body.chat .chat-view .button-bar {
548 flex: 0 1 auto;
549 display: flex;
550 flex-direction: column;
551 }
552 body.chat .chat-view .button-bar button {
 
553 padding: 0.5em;
554 flex: 1 1 auto;
555 margin: 0.25em 0;
556 }
557
558 body.chat #chat-search .button-bar {
559 flex: 0 1 auto;
560 display: flex;
561 flex-direction: row;
562 }
563
564 body.chat #chat-user-list-wrapper {
565 /* Safari can't do fieldsets right, so we emulate one. */
566 border-radius: 0.5em;
567 margin: 1em 0 0.2em 0;
@@ -616,14 +635,19 @@
635
636 body.chat #chat-clear-filter {
637 margin: 0.25em 0.5em;
638 }
639
640 body.chat .searchForm {
641 margin-top: 1em;
642 }
643 body.chat .spacer-widget button {
644 margin-left: 1ex;
645 margin-right: 1ex;
646 display: block;
647 margin-top: 0.5em;
648 margin-bottom: 0.5em;
649 }
650
651 body.chat .anim-rotate-360 {
652 animation: rotate-360 750ms linear;
653 }
654
--- 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