Fossil

Check-in [b9c5792e34]
Login

Check-in [b9c5792e34]

Many hyperlinks are disabled.
Use anonymous login to enable hyperlinks.

Overview
Comment:Significant reworking of chat input mode to use a single contenteditable element instead of two text input elements. This required considerable collateral cleanup in how the various settings are handled and communicated within the app.
Downloads: Tarball | ZIP archive
Timelines: family | ancestors | descendants | both | chat-input-rework
Files: files | file ages | folders
SHA3-256: b9c5792e34bc133d0ec383ef30a38510d73051b43d2f645d620a75ce736c26ee
User & Date: stephan 2021-09-29 22:08:10.715
Context
2021-09-29
22:30
Added mini-help hint text to /chat config view entries. ... (check-in: 3259251aeb user: stephan tags: chat-input-rework)
22:08
Significant reworking of chat input mode to use a single contenteditable element instead of two text input elements. This required considerable collateral cleanup in how the various settings are handled and communicated within the app. ... (check-in: b9c5792e34 user: stephan tags: chat-input-rework)
17:03
/chat: shift-enter when in preview mode now switches back to message list mode if the input field is empty. It was previously only possible to switch back via button or sending a message. ... (check-in: 79995e1355 user: stephan tags: trunk)
Changes
Unified Diff Ignore Whitespace Patch
Changes to src/chat.c.
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
**
** Other /chat-OP pages are used by XHR requests from this page to
** send new chat message, delete older messages, or poll for changes.
*/
void chat_webpage(void){
  char *zAlert;
  char *zProjectName;
  const char * zInputPlaceholder1 = /* Placeholder for 1-line input */
    "Enter sends and Shift-Enter previews.";
  const char * zInputPlaceholder2 = /* Placeholder for textarea input*/
    "Ctrl-Enter sends and Shift-Enter previews.";
  char * zInputPlaceholder0;  /* Common text input placeholder value */
  login_check_credentials();
  if( !g.perm.Chat ){
    login_needed(g.anon.Chat);
    return;
  }
  zAlert = mprintf("%s/builtin/%s", g.zBaseURL,
                db_get("chat-alert-sound","alerts/plunk.wav"));
  zProjectName = db_get("project-name","Unnamed project");
  zInputPlaceholder0 =
    mprintf("Type markdown-formatted message for %h.", zProjectName);
  style_set_current_feature("chat");
  style_header("Chat");
  @ <div id='chat-input-area'>
  @   <div id='chat-input-line' class='single-line'>
  @     <input type="text" name="msg" id="chat-input-single" \
  @      placeholder="%h(zInputPlaceholder0) %h(zInputPlaceholder1)" \
  @      autocomplete="off">
  @     <textarea rows="8" id="chat-input-multi" \
  @      placeholder="%h(zInputPlaceholder0) %h(zInputPlaceholder2)" \
  @      class="hidden"></textarea>
  @     <div id='chat-edit-buttons'>
  @       <button id="chat-preview-button" \
  @         title="Preview message (Shift-Enter)">&#128065;</button>
  @       <button id="chat-settings-button" \
  @         title="Configure chat">&#9881;</button>
  @       <button id="chat-message-submit" \
  @         title="Send message (Ctrl-Enter)">&#128228;</button>







<
<

















<
<
<
|

|







140
141
142
143
144
145
146


147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163



164
165
166
167
168
169
170
171
172
173
**
** Other /chat-OP pages are used by XHR requests from this page to
** send new chat message, delete older messages, or poll for changes.
*/
void chat_webpage(void){
  char *zAlert;
  char *zProjectName;


  const char * zInputPlaceholder2 = /* Placeholder for textarea input*/
    "Ctrl-Enter sends and Shift-Enter previews.";
  char * zInputPlaceholder0;  /* Common text input placeholder value */
  login_check_credentials();
  if( !g.perm.Chat ){
    login_needed(g.anon.Chat);
    return;
  }
  zAlert = mprintf("%s/builtin/%s", g.zBaseURL,
                db_get("chat-alert-sound","alerts/plunk.wav"));
  zProjectName = db_get("project-name","Unnamed project");
  zInputPlaceholder0 =
    mprintf("Type markdown-formatted message for %h.", zProjectName);
  style_set_current_feature("chat");
  style_header("Chat");
  @ <div id='chat-input-area'>
  @   <div id='chat-input-line' class='single-line'>



  @     <div contenteditable id="chat-input-field" \
  @      placeholder="%h(zInputPlaceholder0) %h(zInputPlaceholder2)" \
  @      class></div>
  @     <div id='chat-edit-buttons'>
  @       <button id="chat-preview-button" \
  @         title="Preview message (Shift-Enter)">&#128065;</button>
  @       <button id="chat-settings-button" \
  @         title="Configure chat">&#9881;</button>
  @       <button id="chat-message-submit" \
  @         title="Send message (Ctrl-Enter)">&#128228;</button>
Changes to src/fossil.page.chat.js.
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
        pageTitle: E1('head title'),
        loadOlderToolbar: undefined /* the load-posts toolbar (dynamically created) */,
        inputWrapper: E1("#chat-input-area"),
        inputLine: E1('#chat-input-line'),
        fileSelectWrapper: E1('#chat-input-file-area'),
        viewMessages: E1('#chat-messages-wrapper'),
        btnSubmit: E1('#chat-message-submit'),
        inputSingle: E1('#chat-input-single'),
        inputMulti: E1('#chat-input-multi'),
        inputCurrent: undefined/*one of inputSingle or inputMulti*/,
        inputFile: E1('#chat-input-file'),
        contentDiv: E1('div.content'),
        viewConfig: E1('#chat-config'),
        viewPreview: E1('#chat-preview'),
        previewContent: E1('#chat-preview-content'),
        btnPreview: E1('#chat-preview-button'),
        views: document.querySelectorAll('.chat-view'),







<
|
<







125
126
127
128
129
130
131

132

133
134
135
136
137
138
139
        pageTitle: E1('head title'),
        loadOlderToolbar: undefined /* the load-posts toolbar (dynamically created) */,
        inputWrapper: E1("#chat-input-area"),
        inputLine: E1('#chat-input-line'),
        fileSelectWrapper: E1('#chat-input-file-area'),
        viewMessages: E1('#chat-messages-wrapper'),
        btnSubmit: E1('#chat-message-submit'),

        inputField: E1('#chat-input-field'),

        inputFile: E1('#chat-input-file'),
        contentDiv: E1('div.content'),
        viewConfig: E1('#chat-config'),
        viewPreview: E1('#chat-preview'),
        previewContent: E1('#chat-preview-content'),
        btnPreview: E1('#chat-preview-button'),
        views: document.querySelectorAll('.chat-view'),
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
      },
      /** Gets (no args) or sets (1 arg) the current input text field value,
          taking into account single- vs multi-line input. The getter returns
          a string and the setter returns this object. */
      inputValue: function(){
        const e = this.inputElement();
        if(arguments.length){
          e.value = arguments[0];
          return this;
        }
        return e.value;
      },
      /** Asks the current user input field to take focus. Returns this. */
      inputFocus: function(){
        this.inputElement().focus();
        return this;
      },
      /** Returns the current message input element. */
      inputElement: function(){
        return this.e.inputCurrent;
      },
      /** Toggles between single- and multi-line edit modes. Returns this. */
      inputToggleSingleMulti: function(){
        const old = this.e.inputCurrent;
        if(this.e.inputCurrent === this.e.inputSingle){
          this.e.inputCurrent = this.e.inputMulti;
          this.e.inputLine.classList.remove('single-line');
        }else{
          this.e.inputCurrent = this.e.inputSingle;
          this.e.inputLine.classList.add('single-line');
        }
        const m = this.e.viewMessages,
              sTop = m.scrollTop,
              mh1 = m.clientHeight;
        D.addClass(old, 'hidden');
        D.removeClass(this.e.inputCurrent, 'hidden');
        const mh2 = m.clientHeight;
        m.scrollTo(0, sTop + (mh1-mh2));
        this.e.inputCurrent.value = old.value;
        old.value = '';
        return this;
      },
      /**
         If passed true or no arguments, switches to multi-line mode
         if currently in single-line mode. If passed false, switches
         to single-line mode if currently in multi-line mode. Returns
         this.
      */
      inputMultilineMode: function(yes){
        if(!arguments.length) yes = true;
        if(yes && this.e.inputCurrent === this.e.inputMulti) return this;
        else if(!yes && this.e.inputCurrent === this.e.inputSingle) return this;
        else return this.inputToggleSingleMulti();
      },
      /** Enables (if yes is truthy) or disables all elements in
       * this.disableDuringAjax. */
      enableAjaxComponents: function(yes){
        D[yes ? 'enable' : 'disable'](this.disableDuringAjax);
        return this;
      },







|


|








|
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<







165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184

































185
186
187
188
189
190
191
      },
      /** Gets (no args) or sets (1 arg) the current input text field value,
          taking into account single- vs multi-line input. The getter returns
          a string and the setter returns this object. */
      inputValue: function(){
        const e = this.inputElement();
        if(arguments.length){
          e.innerText = arguments[0];
          return this;
        }
        return e.innerText;
      },
      /** Asks the current user input field to take focus. Returns this. */
      inputFocus: function(){
        this.inputElement().focus();
        return this;
      },
      /** Returns the current message input element. */
      inputElement: function(){
        return this.e.inputField;

































      },
      /** Enables (if yes is truthy) or disables all elements in
       * this.disableDuringAjax. */
      enableAjaxComponents: function(yes){
        D[yes ? 'enable' : 'disable'](this.disableDuringAjax);
        return this;
      },
390
391
392
393
394
395
396

397


398
399
400
401
402
403





404
405
406
407
408
409
410
      },
      messageIsInView: function(e){
        return e ? overlapsElemView(e, this.e.viewMessages) : false;
      },
      settings:{
        get: (k,dflt)=>F.storage.get(k,dflt),
        getBool: (k,dflt)=>F.storage.getBool(k,dflt),

        set: (k,v)=>F.storage.set(k,v),


        /* Toggles the boolean setting specified by k. Returns the
           new value.*/
        toggle: function(k){
          const v = this.getBool(k);
          this.set(k, !v);
          return !v;





        },
        defaults:{
          "images-inline": !!F.config.chat.imagesInline,
          "edit-multiline": false,
          "monospace-messages": false,
          "chat-only-mode": false,
          "audible-alert": true,







>
|
>
>






>
>
>
>
>







355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
      },
      messageIsInView: function(e){
        return e ? overlapsElemView(e, this.e.viewMessages) : false;
      },
      settings:{
        get: (k,dflt)=>F.storage.get(k,dflt),
        getBool: (k,dflt)=>F.storage.getBool(k,dflt),
        set: function(k,v){
          F.storage.set(k,v);
          F.page.dispatchEvent('chat-setting',{key: k, value: v});
        },
        /* Toggles the boolean setting specified by k. Returns the
           new value.*/
        toggle: function(k){
          const v = this.getBool(k);
          this.set(k, !v);
          return !v;
        },
        addListener: function(setting, f){
          F.page.addEventListener('chat-setting', function(ev){
            if(ev.detail.key===setting) f(ev.detail);
          }, false);
        },
        defaults:{
          "images-inline": !!F.config.chat.imagesInline,
          "edit-multiline": false,
          "monospace-messages": false,
          "chat-only-mode": false,
          "audible-alert": true,
447
448
449
450
451
452
453
454


455
456
457
458
459
460
461
      setCurrentView: function(e){
        if(e===this.e.currentView){
          return e;
        }
        this.e.views.forEach(function(E){
          if(e!==E) D.addClass(E,'hidden');
        });
        this.e.currentView = D.removeClass(e,'hidden');


        this.animate(this.e.currentView, 'anim-fade-in-fast');
        return this.e.currentView;
      },
      /**
         Updates the "active user list" view if we are not currently
         batch-loading messages and if the active user list UI element
         is active.







|
>
>







420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
      setCurrentView: function(e){
        if(e===this.e.currentView){
          return e;
        }
        this.e.views.forEach(function(E){
          if(e!==E) D.addClass(E,'hidden');
        });
        this.e.currentView = e;
        if(this.e.currentView.$beforeShow) this.e.currentView.$beforeShow();
        D.removeClass(e,'hidden');
        this.animate(this.e.currentView, 'anim-fade-in-fast');
        return this.e.currentView;
      },
      /**
         Updates the "active user list" view if we are not currently
         batch-loading messages and if the active user list UI element
         is active.
497
498
499
500
501
502
503

























504
505
506
507
508
509
510
        //D.clearElement(this.e.activeUserList);
        D.remove(this.e.activeUserList.querySelectorAll('.chat-user'));
        Object.keys(this.usersLastSeen).sort(
          callee.sortUsersSeen
        ).forEach(callee.addUserElem);
        return this;
      },

























      /**
         Applies user name filter to all current messages, or clears
         the filter if uname is falsy.
      */
      setUserFilter: function(uname){
        this.filterState.activeUser = uname;
        const mw = this.e.viewMessages.querySelectorAll('.message-widget');







>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>







472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
        //D.clearElement(this.e.activeUserList);
        D.remove(this.e.activeUserList.querySelectorAll('.chat-user'));
        Object.keys(this.usersLastSeen).sort(
          callee.sortUsersSeen
        ).forEach(callee.addUserElem);
        return this;
      },
      /** Show or hide the active user list. Returns this object. */
      showActiveUserList: function(yes){
        if(0===arguments.length) yes = true;
        this.e.activeUserListWrapper.classList[
          yes ? 'remove' : 'add'
        ]('hidden');
        D.removeClass(Chat.e.activeUserListWrapper, 'collapsed');
        if(Chat.e.activeUserListWrapper.classList.contains('hidden')){
          /* When hiding this element, undo all filtering */
          Chat.setUserFilter(false);
          /*Ideally we'd scroll the final message into view
            now, but because viewMessages is currently hidden behind
            viewConfig, scrolling is a no-op. */
          Chat.scrollMessagesTo(1);
        }else{
          Chat.updateActiveUserList();
          Chat.animate(Chat.e.activeUserListWrapper, 'anim-flip-v');
        }
        return this;
      },
      showActiveUserTimestamps: function(yes){
        if(0===arguments.length) yes = true;
        this.e.activeUserList.classList[yes ? 'add' : 'remove']('timestamps');
        return this;
      },
      /**
         Applies user name filter to all current messages, or clears
         the filter if uname is falsy.
      */
      setUserFilter: function(uname){
        this.filterState.activeUser = uname;
        const mw = this.e.viewMessages.querySelectorAll('.message-widget');
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
        }
        return this;
      }
    };
    cs.animate.$disabled = true;
    F.fetch.beforesend = ()=>cs.ajaxStart();
    F.fetch.aftersend = ()=>cs.ajaxEnd();
    cs.e.inputCurrent = cs.e.inputSingle;
    /* Install default settings... */
    Object.keys(cs.settings.defaults).forEach(function(k){
      const v = cs.settings.get(k,cs);
      if(cs===v) cs.settings.set(k,cs.settings.defaults[k]);
    });
    if(window.innerWidth<window.innerHeight){
      /* Alignment of 'my' messages: right alignment is conventional
         for mobile chat apps but can be difficult to read in wide
         windows (desktop/tablet landscape mode), so we default to a
         layout based on the apparent "orientation" of the window:
         tall vs wide. Can be toggled via settings popup. */
      document.body.classList.add('my-messages-right');
    }
    if(cs.settings.getBool('monospace-messages',false)){
      document.body.classList.add('monospace-messages');
    }
    if(cs.settings.getBool('active-user-list',false)){
      cs.e.activeUserListWrapper.classList.remove('hidden');
    }
    if(cs.settings.getBool('active-user-list-timestamps',false)){
      cs.e.activeUserList.classList.add('timestamps');
    }
    cs.inputMultilineMode(cs.settings.getBool('edit-multiline',false));
    cs.chatOnlyMode(cs.settings.getBool('chat-only-mode'));
    cs.pageTitleOrig = cs.e.pageTitle.innerText;
    const qs = (e)=>document.querySelector(e);
    const argsToArray = function(args){
      return Array.prototype.slice.call(args,0);
    };
    /**
       Reports an error via console.error() and as a toast message.







<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<







543
544
545
546
547
548
549

























550
551
552
553
554
555
556
        }
        return this;
      }
    };
    cs.animate.$disabled = true;
    F.fetch.beforesend = ()=>cs.ajaxStart();
    F.fetch.aftersend = ()=>cs.ajaxEnd();

























    cs.pageTitleOrig = cs.e.pageTitle.innerText;
    const qs = (e)=>document.querySelector(e);
    const argsToArray = function(args){
      return Array.prototype.slice.call(args,0);
    };
    /**
       Reports an error via console.error() and as a toast message.
1192
1193
1194
1195
1196
1197
1198
1199






1200

1201
1202
1203
1204
1205
1206

1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219


1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235









1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288

1289
1290

1291


1292

1293
1294

1295











1296
1297
1298
1299
1300


1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331


1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
        }
      }
    });
    BlobXferState.clear();
    Chat.inputValue("").inputFocus();
  };

  const inputWidgetKeydown = function(ev){






    if(13 === ev.keyCode){

      if(ev.shiftKey){
        ev.preventDefault();
        ev.stopPropagation();
        /* Shift-enter will run preview mode UNLESS preview mode is
           active AND the input field is empty, in which case it will
           switch back to message view. */

        if(Chat.e.currentView===Chat.e.viewPreview
           && !Chat.e.inputCurrent.value){
          Chat.setCurrentView(Chat.e.viewMessages);
        }else{
          Chat.e.btnPreview.click();
        }
        return false;
      }else if((Chat.e.inputSingle===ev.target)
               || (ev.ctrlKey && Chat.e.inputMulti===ev.target)){
        /* ^^^ note that it is intended that both ctrl-enter and enter
           work for single-line input mode. */
        ev.preventDefault();
        ev.stopPropagation();


        Chat.submitMessage();
        return false;
      }
    }
  };  
  Chat.e.inputSingle
    .addEventListener('keydown', inputWidgetKeydown, false);
  Chat.e.inputMulti
    .addEventListener('keydown', inputWidgetKeydown, false);
  Chat.e.btnSubmit.addEventListener('click',(e)=>{
    e.preventDefault();
    Chat.submitMessage();
    return false;
  });

  (function(){/*Set up #chat-settings-button */









    const settingsButton = document.querySelector('#chat-settings-button');
    const optionsMenu = E1('#chat-config-options');
    const cbToggle = function(ev){
      ev.preventDefault();
      ev.stopPropagation();
      Chat.setCurrentView(Chat.e.currentView===Chat.e.viewConfig
                          ? Chat.e.viewMessages : Chat.e.viewConfig);
      return false;
    };
    D.attr(settingsButton, 'role', 'button').addEventListener('click', cbToggle, false);
    Chat.e.viewConfig.querySelector('button').addEventListener('click', cbToggle, false);

    /** Internal acrobatics to allow certain settings toggles to access
        related toggles. */
    const namedOptions = {
      activeUsers:{
        label: "Show active users list",
        boolValue: ()=>!Chat.e.activeUserListWrapper.classList.contains('hidden'),
        persistentSetting: 'active-user-list',
        callback: function(){
          D.toggleClass(Chat.e.activeUserListWrapper,'hidden');
          D.removeClass(Chat.e.activeUserListWrapper, 'collapsed');
          if(Chat.e.activeUserListWrapper.classList.contains('hidden')){
            /* When hiding this element, undo all filtering */
            Chat.setUserFilter(false);
            /*Ideally we'd scroll the final message into view
              now, but because viewMessages is currently hidden behind
              viewConfig, scrolling is a no-op. */
            Chat.scrollMessagesTo(1);
          }else{
            Chat.updateActiveUserList();
            Chat.animate(Chat.e.activeUserListWrapper, 'anim-flip-v');
          }
        }
      }
    };
    if(1){
      /* Per user request, toggle the list of users on and off if the
         legend element is tapped. */
      const optAu = namedOptions.activeUsers;
      optAu.theLegend = Chat.e.activeUserListWrapper.firstElementChild/*LEGEND*/;
      optAu.theList = optAu.theLegend.nextElementSibling/*user list container*/;
      optAu.theLegend.addEventListener('click',function(){
        D.toggleClass(Chat.e.activeUserListWrapper, 'collapsed');
        if(!Chat.e.activeUserListWrapper.classList.contains('collapsed')){
          Chat.animate(optAu.theList,'anim-flip-v');
        }
      }, false);
    }/*namedOptions.activeUsers additional setup*/
    /* Settings menu entries... Remember that they will be rendered in
       reverse order and the most frequently-needed ones "should"
       (arguably) be closer to the start of this list so that they
       will be rendered within easier reach of the settings button. */

    const settingsOps = [{
      label: "Multi-line input",

      boolValue: ()=>Chat.inputElement()===Chat.e.inputMulti,


      persistentSetting: 'edit-multiline',

      callback: function(){
        Chat.inputToggleSingleMulti();

      }











    },{
      label: "Left-align my posts",
      boolValue: ()=>!document.body.classList.contains('my-messages-right'),
      callback: function f(){
        document.body.classList.toggle('my-messages-right');


      }
    },{
      label: "Show images inline",
      boolValue: ()=>Chat.settings.getBool('images-inline'),
      callback: function(){
        const v = Chat.settings.toggle('images-inline');
        F.toast.message("Image mode set to "+(v ? "inline" : "hyperlink")+".");
      }
    },{
      label: "Timestamps in active users list",
      boolValue: ()=>Chat.e.activeUserList.classList.contains('timestamps'),
      persistentSetting: 'active-user-list-timestamps',
      callback: function(){
        D.toggleClass(Chat.e.activeUserList,'timestamps');
        /* If the timestamp option is activated but
           namedOptions.activeUsers is not currently checked then
           toggle that option on as well. */
        if(Chat.e.activeUserList.classList.contains('timestamps')
           && !namedOptions.activeUsers.boolValue()){
          namedOptions.activeUsers.checkbox.checked = true;
          namedOptions.activeUsers.callback();
          Chat.settings.set(namedOptions.activeUsers.persistentSetting, true);
        }
      }
    },
    namedOptions.activeUsers,{
      label: "Monospace message font",
      boolValue: ()=>document.body.classList.contains('monospace-messages'),
      persistentSetting: 'monospace-messages',
      callback: function(){
        document.body.classList.toggle('monospace-messages');


      }
    },{
      label: "Chat-only mode",
      boolValue: ()=>Chat.isChatOnlyMode(),
      persistentSetting: 'chat-only-mode',
      callback: function(){
        Chat.toggleChatOnlyMode();
      }
    }];

    /** Set up selection list of notification sounds. */
    if(1){
      const selectSound = D.select();
      D.option(selectSound, "", "(no audio)");
      const firstSoundIndex = selectSound.options.length;







|
>
>
>
>
>
>

>






>
|
<
<
|
|
<

<
|




>
>
|




<
|
<
<






|
>
>
>
>
>
>
>
>
>

















<
|
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<



















>
|
|
>
|
>
>
|
>
|
<
>
|
>
>
>
>
>
>
>
>
>
>
>




|
>
>



<
<
|
<
<


<
|
<
<
<
<
<
<
<
<
<
<
<
<



<
|
|
|
>
>



<
|
<
<
<







1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190


1191
1192

1193

1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205

1206


1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239

1240















1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269

1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292


1293


1294
1295

1296












1297
1298
1299

1300
1301
1302
1303
1304
1305
1306
1307

1308



1309
1310
1311
1312
1313
1314
1315
        }
      }
    });
    BlobXferState.clear();
    Chat.inputValue("").inputFocus();
  };

  const inputWidgetKeydown = function f(ev){
    if(!f.$toggle){
      f.$toggle = function(currentMode){
        currentMode = !currentMode;
        Chat.settings.set('edit-multiline', currentMode);
      };
    }
    if(13 === ev.keyCode){
      const multi = Chat.settings.getBool('edit-multiline', false);
      if(ev.shiftKey){
        ev.preventDefault();
        ev.stopPropagation();
        /* Shift-enter will run preview mode UNLESS preview mode is
           active AND the input field is empty, in which case it will
           switch back to message view. */
        const text = Chat.inputValue().trim();
        if(Chat.e.currentView===Chat.e.viewPreview && !text) Chat.setCurrentView(Chat.e.viewMessages);


        else if(!text) f.$toggle(multi);
        else Chat.e.btnPreview.click();

        return false;

      }else if(!multi || (ev.ctrlKey && multi)){
        /* ^^^ note that it is intended that both ctrl-enter and enter
           work for single-line input mode. */
        ev.preventDefault();
        ev.stopPropagation();
        const text = Chat.inputValue().trim();
        if(!text) f.$toggle(multi);
        else Chat.submitMessage();
        return false;
      }
    }
  };  

  Chat.e.inputField.addEventListener('keydown', inputWidgetKeydown, false);


  Chat.e.btnSubmit.addEventListener('click',(e)=>{
    e.preventDefault();
    Chat.submitMessage();
    return false;
  });

  (function(){/*Set up #chat-settings-button and related bits */
    if(window.innerWidth<window.innerHeight){
      // Must be set up before config view is...
      /* Alignment of 'my' messages: right alignment is conventional
         for mobile chat apps but can be difficult to read in wide
         windows (desktop/tablet landscape mode), so we default to a
         layout based on the apparent "orientation" of the window:
         tall vs wide. Can be toggled via settings. */
      document.body.classList.add('my-messages-right');
    }
    const settingsButton = document.querySelector('#chat-settings-button');
    const optionsMenu = E1('#chat-config-options');
    const cbToggle = function(ev){
      ev.preventDefault();
      ev.stopPropagation();
      Chat.setCurrentView(Chat.e.currentView===Chat.e.viewConfig
                          ? Chat.e.viewMessages : Chat.e.viewConfig);
      return false;
    };
    D.attr(settingsButton, 'role', 'button').addEventListener('click', cbToggle, false);
    Chat.e.viewConfig.querySelector('button').addEventListener('click', cbToggle, false);

    /** Internal acrobatics to allow certain settings toggles to access
        related toggles. */
    const namedOptions = {
      activeUsers:{
        label: "Show active users list",

        boolValue: 'active-user-list'















      }
    };
    if(1){
      /* Per user request, toggle the list of users on and off if the
         legend element is tapped. */
      const optAu = namedOptions.activeUsers;
      optAu.theLegend = Chat.e.activeUserListWrapper.firstElementChild/*LEGEND*/;
      optAu.theList = optAu.theLegend.nextElementSibling/*user list container*/;
      optAu.theLegend.addEventListener('click',function(){
        D.toggleClass(Chat.e.activeUserListWrapper, 'collapsed');
        if(!Chat.e.activeUserListWrapper.classList.contains('collapsed')){
          Chat.animate(optAu.theList,'anim-flip-v');
        }
      }, false);
    }/*namedOptions.activeUsers additional setup*/
    /* Settings menu entries... Remember that they will be rendered in
       reverse order and the most frequently-needed ones "should"
       (arguably) be closer to the start of this list so that they
       will be rendered within easier reach of the settings button. */
    /**
       Settings ops structure:

       label: string for the UI

       boolValue: string (name of Chat.settings setting) or a
       function which returns true or false.

       select: SELECT element (instead of boolValue)


       callback: optional handler to call after setting is modified.

       If a setting has a boolValue set, that gets transformed into a
       checkbox which toggles the given persistent setting (if
       boolValue is a string) AND listens for changes to that setting
       fired via Chat.settings.set() so that the checkbox can stay in
       sync with external changes to that setting. Various Chat UI
       elements stay in sync with the config UI via those settings
       events.
     */
    const settingsOps = [{
      label: "Multi-line input",
      boolValue: 'edit-multiline'
    },{
      label: "Left-align my posts",
      boolValue: ()=>!document.body.classList.contains('my-messages-right'),
      callback: function f(){
        document.body.classList[
          this.checkbox.checked ? 'remove' : 'add'
        ]('my-messages-right');
      }
    },{
      label: "Show images inline",


      boolValue: 'images-inline'


    },{
      label: "Timestamps in active users list",

      boolValue: 'active-user-list-timestamps'












    },
    namedOptions.activeUsers,{
      label: "Monospace message font",

      boolValue: 'monospace-messages',
      callback: function(setting){
        document.body.classList[
          setting.value ? 'add' : 'remove'
        ]('monospace-messages');
      }
    },{
      label: "Chat-only mode",

      boolValue: 'chat-only-mode'



    }];

    /** Set up selection list of notification sounds. */
    if(1){
      const selectSound = D.select();
      D.option(selectSound, "", "(no audio)");
      const firstSoundIndex = selectSound.options.length;
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387

1388

1389
1390
1391





1392
1393
1394
1395
1396

1397
1398
1399
1400
1401
1402
1403
1404
1405
1406








1407
1408


1409

1410
1411




1412
1413





































1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
       Build UI for config options...
    */
    settingsOps.forEach(function f(op){
      const line = D.addClass(D.div(), 'menu-entry');
      const btn = D.append(
        D.addClass(D.label(), 'cbutton'/*bootstrap skin hijacks 'button'*/),
        op.label);
      const callback = function(ev){
        op.callback(ev);
        if(op.persistentSetting){
          Chat.settings.set(op.persistentSetting, op.boolValue());
        }
      };
      if(op.hasOwnProperty('select')){
        D.append(line, btn, op.select);

        op.select.addEventListener('change', callback, false);

      }else if(op.hasOwnProperty('boolValue')){
        if(undefined === f.$id) f.$id = 0;
        ++f.$id;





        const check = op.checkbox
              = D.attr(D.checkbox(1, op.boolValue()),
                       'aria-label', op.label);
        const id = 'cfgopt'+f.$id;
        if(op.boolValue()) check.checked = true;

        D.attr(check, 'id', id);
        D.attr(btn, 'for', id);
        D.append(line, check);
        check.addEventListener('change', callback);
        D.append(line, btn);
      }else{
        line.addEventListener('click', callback);
        D.append(line, btn);
      }
      D.append(optionsMenu, line);








    });
    if(0 && settingsOps.selectSound){


      D.append(optionsMenu, settingsOps.selectSound);

    }
    //settingsButton.click()/*for for development*/;




  })()/*#chat-settings-button setup*/;






































  (function(){/*set up message preview*/
    const btnPreview = Chat.e.btnPreview;
    Chat.setPreviewText = function(t){
      this.setCurrentView(this.e.viewPreview);
      this.e.previewContent.innerHTML = t;
      this.e.viewPreview.querySelectorAll('a').forEach(addAnchorTargetBlank);
      this.e.inputCurrent.focus();
    };
    Chat.e.viewPreview.querySelector('#chat-preview-close').
      addEventListener('click', ()=>Chat.setCurrentView(Chat.e.viewMessages), false);
    let previewPending = false;
    const elemsToEnable = [
      btnPreview, Chat.e.btnSubmit,
      Chat.e.inputSingle, Chat.e.inputMulti];
    const submit = function(ev){
      ev.preventDefault();
      ev.stopPropagation();
      if(previewPending) return false;
      const txt = Chat.e.inputCurrent.value;
      if(!txt){
        Chat.setPreviewText('');
        previewPending = false;
        return false;
      }
      const fd = new FormData();
      fd.append('content', txt);







<
<
<
<
<
<


>
|
>



>
>
>
>
>




|
>



<






>
>
>
>
>
>
>
>
|
|
>
>
|
>
|
<
>
>
>
>


>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>






|




|
<
<




|







1342
1343
1344
1345
1346
1347
1348






1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370

1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391

1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446


1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
       Build UI for config options...
    */
    settingsOps.forEach(function f(op){
      const line = D.addClass(D.div(), 'menu-entry');
      const btn = D.append(
        D.addClass(D.label(), 'cbutton'/*bootstrap skin hijacks 'button'*/),
        op.label);






      if(op.hasOwnProperty('select')){
        D.append(line, btn, op.select);
        if(op.callback){
          op.select.addEventListener('change', (ev)=>op.callback(ev), false);
        }
      }else if(op.hasOwnProperty('boolValue')){
        if(undefined === f.$id) f.$id = 0;
        ++f.$id;
        if('string' ===typeof op.boolValue){
          const key = op.boolValue;
          op.boolValue = ()=>Chat.settings.getBool(key);
          op.persistentSetting = key;
        }
        const check = op.checkbox
              = D.attr(D.checkbox(1, op.boolValue()),
                       'aria-label', op.label);
        const id = 'cfgopt'+f.$id;
        check.checked = op.boolValue();
        op.checkbox = check;
        D.attr(check, 'id', id);
        D.attr(btn, 'for', id);
        D.append(line, check);

        D.append(line, btn);
      }else{
        line.addEventListener('click', callback);
        D.append(line, btn);
      }
      D.append(optionsMenu, line);
      if(op.persistentSetting){
        Chat.settings.addListener(
          op.persistentSetting,
          function(setting){
            if(op.checkbox) op.checkbox.checked = !!setting.value;
            else if(op.select) op.select.value = setting.value;
            if(op.callback) op.callback(setting);
          }             
        );
        if(op.checkbox){
          op.checkbox.addEventListener(
            'change', function(){
              Chat.settings.set(op.persistentSetting, op.checkbox.checked)
            }, false);
        }

      }else if(op.callback && op.checkbox){
        op.checkbox.addEventListener('change', (ev)=>op.callback(ev), false);
      }
    });
  })()/*#chat-settings-button setup*/;

  (function(){
    /* Install default settings... must come after
       chat-settings-button setup so that the listeners which that
       installs are notified via the properties getting initialized
       here. */
    Chat.settings.addListener('monospace-messages',function(s){
      document.body.classList[s.value ? 'add' : 'remove']('monospace-messages');
    })
    Chat.settings.addListener('active-user-list',function(s){
      Chat.showActiveUserList(s.value);
    });
    Chat.settings.addListener('active-user-list-timestamps',function(s){
      Chat.showActiveUserTimestamps(s.value);
    });
    Chat.settings.addListener('chat-only-mode',function(s){
      Chat.chatOnlyMode(s.value);
    });
    Chat.settings.addListener('edit-multiline',function(s){
      Chat.e.inputLine.classList[
        s.value ? 'remove' : 'add'
      ]('single-line');
    });
    const valueKludges = {
      /* Convert certain string-format values to other types... */
      "false": false,
      "true": true
    };
    Object.keys(Chat.settings.defaults).forEach(function(k){
      var v = Chat.settings.get(k,Chat);
      if(Chat===v) v = Chat.settings.defaults[k];
      if(valueKludges.hasOwnProperty(v)) v = valueKludges[v];
      Chat.settings.set(k,v)
      /* fires event listeners so that the Config area checkboxes
         get in sync */;
    });
  })();
  
  (function(){/*set up message preview*/
    const btnPreview = Chat.e.btnPreview;
    Chat.setPreviewText = function(t){
      this.setCurrentView(this.e.viewPreview);
      this.e.previewContent.innerHTML = t;
      this.e.viewPreview.querySelectorAll('a').forEach(addAnchorTargetBlank);
      this.inputFocus();
    };
    Chat.e.viewPreview.querySelector('#chat-preview-close').
      addEventListener('click', ()=>Chat.setCurrentView(Chat.e.viewMessages), false);
    let previewPending = false;
    const elemsToEnable = [btnPreview, Chat.e.btnSubmit, Chat.e.inputField];


    const submit = function(ev){
      ev.preventDefault();
      ev.stopPropagation();
      if(previewPending) return false;
      const txt = Chat.inputValue();
      if(!txt){
        Chat.setPreviewText('');
        previewPending = false;
        return false;
      }
      const fd = new FormData();
      fd.append('content', txt);
Changes to src/style.chat.css.
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181






182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209









210
211
212
213
214
215
216
}
/* Wrapper for /chat user input controls */
body.chat #chat-input-area {
  display: flex;
  flex-direction: column;
  padding: 0;
  margin: 0;
  position: initial /*sticky currently disabled due to scrolling-related issues*/;
  /*bottom: 0;*/
}
body.chat:not(.chat-only-mode) #chat-input-area{
  /* Safari user reports that 2em is necessary to keep the file selection
     widget from overlapping the page footer, whereas a margin of 0 is fine
     for FF/Chrome (and 2em is a *huge* waste of space for those). */
  margin-bottom: 0;
}







/* Widget holding the chat message input field, send button, and
   settings button. */
body.chat #chat-input-line {
  display: flex;
  flex-direction: row;
  align-items: stretch;
}
body.chat #chat-input-line.single-line {
  flex-wrap: wrap;
}
body.chat #chat-edit-buttons {
  flex: 1 1 auto;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
}
body.chat #chat-input-line.single-line #chat-edit-buttons {
  flex-direction: row;
}
body.chat #chat-edit-buttons > * {
  flex: 1 1 auto;
  padding: initial/*some skins mess this up for buttons*/;
}
body.chat #chat-input-line:not(.single-line) #chat-edit-buttons > * {
  max-width: 4em;
  margin: 0.25em;
}









body.chat #chat-input-line.single-line #chat-edit-buttons > * {
  margin: 0 0.25em;
}

body.chat #chat-input-line > button {
  max-width: 4em;
}







<
|







>
>
>
>
>
>







<
<



















>
>
>
>
>
>
>
>
>







166
167
168
169
170
171
172

173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193


194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
}
/* Wrapper for /chat user input controls */
body.chat #chat-input-area {
  display: flex;
  flex-direction: column;
  padding: 0;
  margin: 0;

  flex: 0 1 auto;
}
body.chat:not(.chat-only-mode) #chat-input-area{
  /* Safari user reports that 2em is necessary to keep the file selection
     widget from overlapping the page footer, whereas a margin of 0 is fine
     for FF/Chrome (and 2em is a *huge* waste of space for those). */
  margin-bottom: 0;
}
#chat-input-field {
  padding: 0.2em;
  flex: 20 1 auto;
  border-width: 1px;
  border-style: solid;
}

/* Widget holding the chat message input field, send button, and
   settings button. */
body.chat #chat-input-line {
  display: flex;
  flex-direction: row;
  align-items: stretch;


  flex-wrap: wrap;
}
body.chat #chat-edit-buttons {
  flex: 1 1 auto;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
}
body.chat #chat-input-line.single-line #chat-edit-buttons {
  flex-direction: row;
}
body.chat #chat-edit-buttons > * {
  flex: 1 1 auto;
  padding: initial/*some skins mess this up for buttons*/;
}
body.chat #chat-input-line:not(.single-line) #chat-edit-buttons > * {
  max-width: 4em;
  margin: 0.25em;
}
body.chat #chat-input-line:not(.single-line) #chat-input-field {
  border-left-style: double;
  border-left-width: 3px;
  border-right-style: double;
  border-right-width: 3px;
  max-height: 10em/*arbitrary!*/;
  overflow: auto;
}

body.chat #chat-input-line.single-line #chat-edit-buttons > * {
  margin: 0 0.25em;
}

body.chat #chat-input-line > button {
  max-width: 4em;
}