Fossil

Check-in [9d86a4af61]
Login

Check-in [9d86a4af61]

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

Overview
Comment:Added chat setting chat-inline-images: specifies whether /chat images default to display inline or as download links. Various code-adjacent tweaks.
Downloads: Tarball | ZIP archive
Timelines: family | ancestors | descendants | both | trunk
Files: files | file ages | folders
SHA3-256: 9d86a4af61b8cb57782ed47383934a1bbd0c8c27d04e53b38fcfa6d4e6d1054c
User & Date: stephan 2020-12-25 14:58:22.951
Context
2020-12-25
15:27
Chat settings menu tweaks based on chat session feedback. ... (check-in: 9e797bf9bf user: stephan tags: trunk)
14:58
Added chat setting chat-inline-images: specifies whether /chat images default to display inline or as download links. Various code-adjacent tweaks. ... (check-in: 9d86a4af61 user: stephan tags: trunk)
14:36
In the default skin, add a Chat menu item for wide screens if Chat is enabled for the user. ... (check-in: 8049da83c4 user: drh tags: trunk)
Changes
Unified Diff Ignore Whitespace Patch
Changes to src/chat.c.
65
66
67
68
69
70
71
72






73
74
75
76
77
78
79
** The /chat subsystem will try to discard messages that are older then
** chat-keep-days.  The value of chat-keep-days can be a floating point
** number.  So, for example, if you only want to keep chat messages for
** 12 hours, set this value to 0.5.
**
** A value of 0.0 or less means that messages are retained forever.
*/







/*
** WEBPAGE: chat
**
** Start up a browser-based chat session.
**
** This is the main page that humans use to access the chatroom.  Simply
** point a web-browser at /chat and the screen fills with the latest







|
>
>
>
>
>
>







65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
** The /chat subsystem will try to discard messages that are older then
** chat-keep-days.  The value of chat-keep-days can be a floating point
** number.  So, for example, if you only want to keep chat messages for
** 12 hours, set this value to 0.5.
**
** A value of 0.0 or less means that messages are retained forever.
*/
/*
** SETTING: chat-inline-images    boolean default=on
**
** Specifies whether posted images in /chat should default to being
** displayed inline or as downloadable links. Each chat user can
** change this value for their current chat session in the UI.
*/
/*
** WEBPAGE: chat
**
** Start up a browser-based chat session.
**
** This is the main page that humans use to access the chatroom.  Simply
** point a web-browser at /chat and the screen fills with the latest
117
118
119
120
121
122
123
124
125
126
127
128
129
130

131
132


133
134
135
136
137
138
139
  @ </div>

  /* New chat messages get inserted immediately after this element */
  @ <div id='chat-messages-wrapper'>
  @ <span id='message-inject-point'></span>
  @ </div>

  builtin_fossil_js_bundle_or("popupwidget", NULL);
  /* Always in-line the javascript for the chat page */
  @ <script nonce="%h(style_nonce())">/* chat.c:%d(__LINE__) */
  /* We need an onload handler to ensure that window.fossil is
     initialized before the chat init code runs. */
  @ window.addEventListener('load', function(){
  @ window.fossil.config.chatInitSize =\

  @   %d(db_get_int("chat-initial-history",50));
  @ window.fossil.config.pingTcp = %d(iPingTcp);


  cgi_append_content(builtin_text("chat.js"),-1);
  @ }, false);
  @ </script>

  style_finish_page();
}








|





|
>
|
<
>
>







123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138

139
140
141
142
143
144
145
146
147
  @ </div>

  /* New chat messages get inserted immediately after this element */
  @ <div id='chat-messages-wrapper'>
  @ <span id='message-inject-point'></span>
  @ </div>

  builtin_fossil_js_bundle_or("popupwidget", "storage", NULL);
  /* Always in-line the javascript for the chat page */
  @ <script nonce="%h(style_nonce())">/* chat.c:%d(__LINE__) */
  /* We need an onload handler to ensure that window.fossil is
     initialized before the chat init code runs. */
  @ window.addEventListener('load', function(){
  @ window.fossil.config.chat = {
  @   pingTcp: %d(iPingTcp),
  @   initSize: %d(db_get_int("chat-initial-history",50)),

  @   imagesInline: !!%d(db_get_boolean("chat-inline-images",1))
  @ };
  cgi_append_content(builtin_text("chat.js"),-1);
  @ }, false);
  @ </script>

  style_finish_page();
}

Changes to src/chat.js.
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
        messageInjectPoint: E1('#message-inject-point'),
        pageTitle: E1('head title'),
        loadToolbar: undefined /* the load-posts toolbar (dynamically created) */,
        inputWrapper: E1("#chat-input-area"),
        messagesWrapper: E1('#chat-messages-wrapper')
      },
      me: F.user.name,
      mxMsg: F.config.chatInitSize ? -F.config.chatInitSize : -50,
      mnMsg: undefined/*lowest message ID we've seen so far (for history loading)*/,
      pageIsActive: 'visible'===document.visibilityState,
      changesSincePageHidden: 0,
      notificationBubbleColor: 'white',
      totalMessageCount: 0, // total # of inbound messages
      //! Number of messages to load for the history buttons
      loadMessageCount: Math.abs(F.config.chatInitSize || 20),
      /* Alignment of 'my' messages: must be 'left' or 'right'. Note
         that 'right' is conventional for mobile chat apps but can be
         difficult to read in wide windows (desktop/tablet landscape
         mode). Can be toggled via settings popup. */
      msgMyAlign: (window.innerWidth<window.innerHeight) ? 'right' : 'left',
      ajaxInflight: 0,
      /** Enables (if yes is truthy) or disables all elements in







|






|







16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
        messageInjectPoint: E1('#message-inject-point'),
        pageTitle: E1('head title'),
        loadToolbar: undefined /* the load-posts toolbar (dynamically created) */,
        inputWrapper: E1("#chat-input-area"),
        messagesWrapper: E1('#chat-messages-wrapper')
      },
      me: F.user.name,
      mxMsg: F.config.chat.initSize ? -F.config.chat.initSize : -50,
      mnMsg: undefined/*lowest message ID we've seen so far (for history loading)*/,
      pageIsActive: 'visible'===document.visibilityState,
      changesSincePageHidden: 0,
      notificationBubbleColor: 'white',
      totalMessageCount: 0, // total # of inbound messages
      //! Number of messages to load for the history buttons
      loadMessageCount: Math.abs(F.config.chat.initSize || 20),
      /* Alignment of 'my' messages: must be 'left' or 'right'. Note
         that 'right' is conventional for mobile chat apps but can be
         difficult to read in wide windows (desktop/tablet landscape
         mode). Can be toggled via settings popup. */
      msgMyAlign: (window.innerWidth<window.innerHeight) ? 'right' : 'left',
      ajaxInflight: 0,
      /** Enables (if yes is truthy) or disables all elements in
83
84
85
86
87
88
89







90

91




92
93
94
95
96
97
98
        }else{
          if(mip.nextSibling){
            mip.parentNode.insertBefore(e, mip.nextSibling);
          }else{
            mip.parentNode.appendChild(e);
          }
        }







      }

    };




    cs.pageTitleOrig = cs.e.pageTitle.innerText;
    const qs = (e)=>document.querySelector(e);
    const argsToArray = function(args){
      return Array.prototype.slice.call(args,0);
    };
    cs.reportError = function(/*msg args*/){
      const args = argsToArray(arguments);







>
>
>
>
>
>
>
|
>

>
>
>
>







83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
        }else{
          if(mip.nextSibling){
            mip.parentNode.insertBefore(e, mip.nextSibling);
          }else{
            mip.parentNode.appendChild(e);
          }
        }
      },
      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),
        defaults:{
          "images-inline": !!F.config.chat.imagesInline
        }
      }
    };
    Object.keys(cs.settings.defaults).forEach(function f(k){
      const v = cs.settings.get(k,f);
      if(f===v) cs.settings.set(k,cs.settings.defaults[k]);
    });
    cs.pageTitleOrig = cs.e.pageTitle.innerText;
    const qs = (e)=>document.querySelector(e);
    const argsToArray = function(args){
      return Array.prototype.slice.call(args,0);
    };
    cs.reportError = function(/*msg args*/){
      const args = argsToArray(arguments);
366
367
368
369
370
371
372
373
374
375
376
377

378
379
380
381
382
383
384


385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416


417
418
419
420
421
422
423
424
425
426
427










428
429
430

431
432

433
434







435
436
437
438
439


440






441
442
443
444
445
446
447
    const settingsPopup = new F.PopupWidget({
      cssClass: ['fossil-tooltip', 'chat-settings-popup'],
      adjustY: function(y){
        const rect = settingsButton.getBoundingClientRect();
        return rect.top + rect.height + 2;
      }
    });
    settingsPopup.installClickToHide();

    /* Settings menu entries... */
    const settingsOps = [{
      label: "Toggle page body",

      callback: function f(){
        if(undefined === f.isHidden){
          f.isHidden = false;
          f.elemsToToggle = [];
          document.body.childNodes.forEach(function(e){
            if(!e.classList) return/*TEXT nodes and such*/;
            else if(!e.classList.contains('content')){


              f.elemsToToggle.push(e);
            }
          });
          /* In order to make the input area opaque, such that the
             message list scrolls under it without being visible, we
             have to ensure that the input area has a non-inherited
             background color. Ideally we'd select the color of
             div.content, but that is not necessarily set, so we fall
             back to using the body's background color. If we rely on
             the input area having its own color specified in CSS then
             all skins would have to define an appropriate color.
             Thus our selection of the body color, while slightly unfortunate,
             is in the interest of keeping skins from being forced to
             define an opaque bg color.
          */
          f.initialBg = Chat.e.messagesWrapper.style.backgroundColor;
          const cs = window.getComputedStyle(document.body);
          f.inheritedBg = cs.backgroundColor;
        }
        const cs = Chat.e.inputWrapper.style;
        if((f.isHidden = !f.isHidden)){
          D.addClass(f.elemsToToggle, 'hidden');
          D.addClass(document.body, 'chat-only-mode');
          cs.backgroundColor = f.inheritedBg;
        }else{
          D.removeClass(f.elemsToToggle, 'hidden');
          D.removeClass(document.body, 'chat-only-mode');
          cs.backgroundColor = f.initialBg;
        }
      }
    },{
      label: "Toggle left/right layout",


      callback: function f(){
        if('right'===Chat.msgMyAlign) Chat.msgMyAlign = 'left';
        else Chat.msgMyAlign = 'right';
        const msgs = Chat.e.messagesWrapper.querySelectorAll('.message-row');
        msgs.forEach(function(row){
          if(row.dataset.xfrom!==Chat.me) return;
          row.querySelector('legend').setAttribute('align', Chat.msgMyAlign);
          if('right'===Chat.msgMyAlign) row.style.justifyContent = "flex-end";
          else row.style.justifyContent = "flex-start";
        });
      }










    }];

    settingsOps.forEach(function(op){

      const btn = D.append(D.span(), op.label);
      D.append(settingsPopup.e, btn);

      op.callback.button = btn;
      if('function'===op.init) op.init();







      btn.addEventListener('click', function(ev){
        settingsPopup.hide();
        op.callback.call(this,ev);
      });
    });


    settingsButton.addEventListener('click',()=>settingsPopup.show(settingsButton), false);







    /* Find an ideal X position for the popup, directly under the settings
       button, based on the size of the popup... */
    settingsPopup.show(document.body);
    popupSize = settingsPopup.e.getBoundingClientRect();
    settingsPopup.hide();
    settingsPopup.options.adjustX = function(x){







<
<


|
>






|
>
>



















|



|



|




>
>











>
>
>
>
>
>
>
>
>
>



>
|
<
>


>
>
>
>
>
>
>





>
>
|
>
>
>
>
>
>







378
379
380
381
382
383
384


385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457

458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
    const settingsPopup = new F.PopupWidget({
      cssClass: ['fossil-tooltip', 'chat-settings-popup'],
      adjustY: function(y){
        const rect = settingsButton.getBoundingClientRect();
        return rect.top + rect.height + 2;
      }
    });


    /* Settings menu entries... */
    const settingsOps = [{
      label: "Toggle chat-only mode",
      tooltip: "Toggles the page's header and footer on and off.",
      callback: function f(){
        if(undefined === f.isHidden){
          f.isHidden = false;
          f.elemsToToggle = [];
          document.body.childNodes.forEach(function(e){
            if(!e.classList) return/*TEXT nodes and such*/;
            else if(!e.classList.contains('content')
                    && !e.classList.contains('fossil-PopupWidget')
                    /*kludge^^^ for settingsPopup click handling!*/){
              f.elemsToToggle.push(e);
            }
          });
          /* In order to make the input area opaque, such that the
             message list scrolls under it without being visible, we
             have to ensure that the input area has a non-inherited
             background color. Ideally we'd select the color of
             div.content, but that is not necessarily set, so we fall
             back to using the body's background color. If we rely on
             the input area having its own color specified in CSS then
             all skins would have to define an appropriate color.
             Thus our selection of the body color, while slightly unfortunate,
             is in the interest of keeping skins from being forced to
             define an opaque bg color.
          */
          f.initialBg = Chat.e.messagesWrapper.style.backgroundColor;
          const cs = window.getComputedStyle(document.body);
          f.inheritedBg = cs.backgroundColor;
        }
        const iws = Chat.e.inputWrapper.style;
        if((f.isHidden = !f.isHidden)){
          D.addClass(f.elemsToToggle, 'hidden');
          D.addClass(document.body, 'chat-only-mode');
          iws.backgroundColor = f.inheritedBg;
        }else{
          D.removeClass(f.elemsToToggle, 'hidden');
          D.removeClass(document.body, 'chat-only-mode');
          iws.backgroundColor = f.initialBg;
        }
      }
    },{
      label: "Toggle left/right layout",
      tooltip: "Toggles your own messages between the right (mobile-style) "+
        "or left of the screen (more readable on large windows).",
      callback: function f(){
        if('right'===Chat.msgMyAlign) Chat.msgMyAlign = 'left';
        else Chat.msgMyAlign = 'right';
        const msgs = Chat.e.messagesWrapper.querySelectorAll('.message-row');
        msgs.forEach(function(row){
          if(row.dataset.xfrom!==Chat.me) return;
          row.querySelector('legend').setAttribute('align', Chat.msgMyAlign);
          if('right'===Chat.msgMyAlign) row.style.justifyContent = "flex-end";
          else row.style.justifyContent = "flex-start";
        });
      }
    },{
      label: "Toggle images inline",
      persistent: true,
      tooltip: "Toggles whether newly-arrived images appear "+
        "inline or as download links.",
      callback: function(){
        const v = Chat.settings.getBool('images-inline',true);
        Chat.settings.set('images-inline', !v);
        F.toast.message("Image mode set to "+(v ? "hyperlink" : "inline")+".");
      }
    }];

    settingsOps.forEach(function(op){
      const line = D.addClass(D.span(), 'menu-entry');
      const btn = D.append(D.addClass(D.span(), 'button'),

                          (op.persistent ? "[P] " : "")+op.label);
      op.callback.button = btn;
      if('function'===op.init) op.init();
      if(op.tooltip){
        const help = D.span();
        D.append(line, help);
        F.helpButtonlets.create(help, op.tooltip);
      }
      D.append(line, btn);
      D.append(settingsPopup.e, line);
      btn.addEventListener('click', function(ev){
        settingsPopup.hide();
        op.callback.call(this,ev);
      });
    });
    D.append(settingsPopup.e, D.append(D.span(),"[P] = locally-persistent setting"));
    // settingsPopup.installClickToHide();// Don't do this for this popup!
    settingsButton.addEventListener('click',function(ev){
      //ev.preventDefault();
      if(settingsPopup.isShown()) settingsPopup.hide();
      else settingsPopup.show(settingsButton);
      /* Reminder: we cannot toggle the visibility from her
       */
    }, false);

    /* Find an ideal X position for the popup, directly under the settings
       button, based on the size of the popup... */
    settingsPopup.show(document.body);
    popupSize = settingsPopup.e.getBoundingClientRect();
    settingsPopup.hide();
    settingsPopup.options.adjustX = function(x){
499
500
501
502
503
504
505

506


507
508
509
510
511
512
513
514



515
516
517
518
519
520
521
          /* Show UTC on systems where Date() does not work */
          eWho.append(D.text(whoName+' @ '+m.mtime.slice(11,16)))
        }
        let eContent = D.addClass(D.div(),'message-content','chat-message');
        eContent.style.backgroundColor = m.uclr;
        row.appendChild(eContent);
        if( m.fsize>0 ){

          if( m.fmime && m.fmime.startsWith("image/") ){


            eContent.appendChild(D.img("chat-download/" + m.msgid));
          }else{
            eContent.appendChild(D.a(
              window.fossil.rootPath+
                'chat-download/' + m.msgid+'/'+encodeURIComponent(m.fname),
              // ^^^ add m.fname to URL to cause downloaded file to have that name.
              "(" + m.fname + " " + m.fsize + " bytes)"
            ));



          }
          const br = D.br();
          br.style.clear = "both";
          eContent.appendChild(br);
        }
        if(m.xmsg){
          // The m.xmsg text comes from the same server as this script and







>
|
>
>


|




<
>
>
>







540
541
542
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
          /* Show UTC on systems where Date() does not work */
          eWho.append(D.text(whoName+' @ '+m.mtime.slice(11,16)))
        }
        let eContent = D.addClass(D.div(),'message-content','chat-message');
        eContent.style.backgroundColor = m.uclr;
        row.appendChild(eContent);
        if( m.fsize>0 ){
          if( m.fmime
              && m.fmime.startsWith("image/")
              && Chat.settings.getBool('images-inline',true)
            ){
            eContent.appendChild(D.img("chat-download/" + m.msgid));
          }else{
            const a = D.a(
              window.fossil.rootPath+
                'chat-download/' + m.msgid+'/'+encodeURIComponent(m.fname),
              // ^^^ add m.fname to URL to cause downloaded file to have that name.
              "(" + m.fname + " " + m.fsize + " bytes)"

            )
            D.attr(a,'target','_blank');
            eContent.appendChild(a);
          }
          const br = D.br();
          br.style.clear = "both";
          eContent.appendChild(br);
        }
        if(m.xmsg){
          // The m.xmsg text comes from the same server as this script and
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
        Chat.e.pageTitle.innerText = Chat.pageTitleOrig;
      }
    }else{
      Chat.changesSincePageHidden += jx.msgs.length;
      Chat.e.pageTitle.innerText = '('+Chat.changesSincePageHidden+') '+
        Chat.pageTitleOrig;
    }
    if(jx.msgs.length && F.config.pingTcp){
      fetch("http:/"+"/localhost:"+window.fossil.config.pingTcp+"/chat-ping");
    }
  }/*newcontent()*/;

  (function(){
    /** Add toolbar for loading older messages. We use a FIELDSET here
        because a fieldset is the only parent element type which can
        automatically enable/disable its children by







|
|







584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
        Chat.e.pageTitle.innerText = Chat.pageTitleOrig;
      }
    }else{
      Chat.changesSincePageHidden += jx.msgs.length;
      Chat.e.pageTitle.innerText = '('+Chat.changesSincePageHidden+') '+
        Chat.pageTitleOrig;
    }
    if(jx.msgs.length && F.config.chat.pingTcp){
      fetch("http:/"+"/localhost:"+F.config.chat.pingTcp+"/chat-ping");
    }
  }/*newcontent()*/;

  (function(){
    /** Add toolbar for loading older messages. We use a FIELDSET here
        because a fieldset is the only parent element type which can
        automatically enable/disable its children by
Changes to src/default.css.
1259
1260
1261
1262
1263
1264
1265





1266
1267
1268
1269
1270
1271
1272
1273
  position: absolute;
  display: inline-block;
  z-index: 19/*below default skin's hamburger popup*/;
  box-shadow: -0.15em 0.15em 0.2em rgba(0, 0, 0, 0.75);
  background-color: inherit;
  color: inherit;
}






.fossil-toast-message {
  /* "toast"-style popup message.
     See fossil.popupwidget:toast() */
  position: absolute;
  display: block;
  z-index: 1001;
  text-align: left;







>
>
>
>
>
|







1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
  position: absolute;
  display: inline-block;
  z-index: 19/*below default skin's hamburger popup*/;
  box-shadow: -0.15em 0.15em 0.2em rgba(0, 0, 0, 0.75);
  background-color: inherit;
  color: inherit;
}
.fossil-PopupWidget {
  /* This class is ALWAYS set on every fossil.PopupWidget instance, in
     addition to client/app-configured classes. It should not get any
     style - it is only used for DOM element selecting/filtering
     purposes. */
}
.fossil-toast-message {
  /* "toast"-style popup message.
     See fossil.popupwidget:toast() */
  position: absolute;
  display: block;
  z-index: 1001;
  text-align: left;
1575
1576
1577
1578
1579
1580
1581
1582
1583


1584
1585
1586
1587









1588
1589
1590
1591
1592
1593
1594
  display: flex;
  flex-direction: column;
  align-items: stretch;
  padding: 0.25em;
  z-index: 200;
}
body.chat .chat-settings-popup > span {
  margin: 0.25em 0.2em;
  padding: 0.5em;


  white-space: nowrap;
  cursor: pointer;
  border: 1px outset;
  border-radius: 0.25em;









}
body.chat #chat-messages-wrapper {
  display: flex;
  flex-direction: column;
}
body.chat.chat-only-mode{
}







|
<
>
>




>
>
>
>
>
>
>
>
>







1580
1581
1582
1583
1584
1585
1586
1587

1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
  display: flex;
  flex-direction: column;
  align-items: stretch;
  padding: 0.25em;
  z-index: 200;
}
body.chat .chat-settings-popup > span {
  vertical-align: middle;

}
body.chat .chat-settings-popup > span.menu-entry{
  white-space: nowrap;
  cursor: pointer;
  border: 1px outset;
  border-radius: 0.25em;
  margin: 0.25em 0.2em;
  padding: 0.5em;
}
body.chat .chat-settings-popup > span.menu-entry > .help-buttonlet {
  vertical-align: middle;
}
body.chat .chat-settings-popup > span.menu-entry > span.button {
  margin: 0.25em 0.2em;
  padding: 0.5em;
}
body.chat #chat-messages-wrapper {
  display: flex;
  flex-direction: column;
}
body.chat.chat-only-mode{
}
Changes to src/fossil.popupwidget.js.
47
48
49
50
51
52
53
54



55
56
57
58
59
60
61
     removed from the object immediately after it is called.

     All callback options are called with the PopupWidget object as
     their "this".


     .cssClass: optional CSS class, or list of classes, to apply to
     the new element.




     .style: optional object of properties to copy directly into
     the element's style object.     

     The options passed to this constructor get normalized into a
     separate object which includes any default values for options not
     provided by the caller. That object is available this the







|
>
>
>







47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
     removed from the object immediately after it is called.

     All callback options are called with the PopupWidget object as
     their "this".


     .cssClass: optional CSS class, or list of classes, to apply to
     the new element. In addition to any supplied here (or inherited
     from the default), the class "fossil-PopupWidget" is always set
     in order to allow certain app-internal CSS to account for popup
     windows in special cases.

     .style: optional object of properties to copy directly into
     the element's style object.     

     The options passed to this constructor get normalized into a
     separate object which includes any default values for options not
     provided by the caller. That object is available this the
82
83
84
85
86
87
88
89

90
91
92
93
94
95
96
     tip.show(50, 100);
     // ^^^ viewport-relative coordinates. See show() for other options.

  */
  F.PopupWidget = function f(opt){
    opt = F.mergeLastWins(f.defaultOptions,opt);
    this.options = opt;
    const e = this.e = D.addClass(D.div(), opt.cssClass);

    this.show(false);
    if(opt.style){
      let k;
      for(k in opt.style){
        if(opt.style.hasOwnProperty(k)) e.style[k] = opt.style[k];
      }
    }







|
>







85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
     tip.show(50, 100);
     // ^^^ viewport-relative coordinates. See show() for other options.

  */
  F.PopupWidget = function f(opt){
    opt = F.mergeLastWins(f.defaultOptions,opt);
    this.options = opt;
    const e = this.e = D.addClass(D.div(), opt.cssClass,
                                  "fossil-PopupWidget");
    this.show(false);
    if(opt.style){
      let k;
      for(k in opt.style){
        if(opt.style.hasOwnProperty(k)) e.style[k] = opt.style[k];
      }
    }
328
329
330
331
332
333
334

335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356





















357
358
359
360
361
362
363
       during initialization and stashed away for use in a PopupWidget
       when the botton is clicked.

    */
    setup: function f(){
      if(!f.hasOwnProperty('clickHandler')){
        f.clickHandler = function fch(ev){

          if(!fch.popup){
            fch.popup = new F.PopupWidget({
              cssClass: ['fossil-tooltip', 'help-buttonlet-content'],
              refresh: function(){
              }
            });
            fch.popup.e.style.maxWidth = '80%'/*of body*/;
            fch.popup.installClickToHide();
          }
          D.append(D.clearElement(fch.popup.e), ev.target.$helpContent);
          var popupRect = ev.target.getClientRects()[0];
          var x = popupRect.left, y = popupRect.top;
          if(x<0) x = 0;
          if(y<0) y = 0;
          /* Shift the help around a bit to "better" fit the
             screen. However, fch.popup.e.getClientRects() is empty
             until the popup is shown, so we have to show it,
             calculate the resulting size, then move and/or resize it.

             This algorithm/these heuristics can certainly be improved
             upon.
          */





















          fch.popup.show(x, y);
          x = popupRect.left, y = popupRect.top;
          popupRect = fch.popup.e.getBoundingClientRect();
          const rectBody = document.body.getClientRects()[0];
          if(popupRect.right > rectBody.right){
            x -= (popupRect.right - rectBody.right);
          }







>










<
<
<
<








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







332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349




350
351
352
353
354
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
384
385
       during initialization and stashed away for use in a PopupWidget
       when the botton is clicked.

    */
    setup: function f(){
      if(!f.hasOwnProperty('clickHandler')){
        f.clickHandler = function fch(ev){
          ev.preventDefault();
          if(!fch.popup){
            fch.popup = new F.PopupWidget({
              cssClass: ['fossil-tooltip', 'help-buttonlet-content'],
              refresh: function(){
              }
            });
            fch.popup.e.style.maxWidth = '80%'/*of body*/;
            fch.popup.installClickToHide();
          }
          D.append(D.clearElement(fch.popup.e), ev.target.$helpContent);




          /* Shift the help around a bit to "better" fit the
             screen. However, fch.popup.e.getClientRects() is empty
             until the popup is shown, so we have to show it,
             calculate the resulting size, then move and/or resize it.

             This algorithm/these heuristics can certainly be improved
             upon.
          */
          var popupRect, rectElem = ev.target;
          while(rectElem){
            popupRect = rectElem.getClientRects()[0]/*undefined if off-screen!*/;
            if(popupRect) break;
            rectElem = rectElem.parentNode;
          }
          if(!popupRect) popupRect = {x:0, y:0, left:0, right:0};
          var x = popupRect.left, y = popupRect.top;
          if(x<0) x = 0;
          if(y<0) y = 0;
          if(rectElem){
            /* Try to ensure that the popup's z-level is higher than this element's */
            const rz = window.getComputedStyle(rectElem).zIndex;
            var myZ;
            if(rz && !isNaN(+rz)){
              myZ = +rz + 1;
            }else{
              myZ = 10000/*guess!*/;
            }
            fch.popup.e.style.zIndex = myZ;
          }
          fch.popup.show(x, y);
          x = popupRect.left, y = popupRect.top;
          popupRect = fch.popup.e.getBoundingClientRect();
          const rectBody = document.body.getClientRects()[0];
          if(popupRect.right > rectBody.right){
            x -= (popupRect.right - rectBody.right);
          }
Changes to src/fossil.storage.js.
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82

     See: https://fossil-scm.org/forum/forumpost/4afc4d34de

     Sidebar: it might seem odd to provide a key prefix and stick all
     properties in the topmost level of the storage object. We do that
     because adding a layer of object to sandbox each repo would mean
     (de)serializing that whole tree on every storage property change
     (and we update storage often during editing
     sessions). e.g. instead of storageObject.projectName.foo we have
     storageObject[storageKeyPrefix+'foo']. That's soley for
     efficiency's sake (in terms of battery life and
     environment-internal storage-level effort). Even so, it might
     (or might not) be useful to do that someday.
  */
  const storageKeyPrefix = (
    $storageHolder===$storage/*localStorage or sessionStorage*/
      ? (
        F.config.projectCode || F.config.projectName
          || F.config.shortProjectName || window.location.pathname
      )+'::' : (







|
|


|
|







63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82

     See: https://fossil-scm.org/forum/forumpost/4afc4d34de

     Sidebar: it might seem odd to provide a key prefix and stick all
     properties in the topmost level of the storage object. We do that
     because adding a layer of object to sandbox each repo would mean
     (de)serializing that whole tree on every storage property change
     (and we update storage often during editing sessions).
     e.g. instead of storageObject.projectName.foo we have
     storageObject[storageKeyPrefix+'foo']. That's soley for
     efficiency's sake (in terms of battery life and
     environment-internal storage-level effort). Even so, it might (or
     might not) be useful to do that someday.
  */
  const storageKeyPrefix = (
    $storageHolder===$storage/*localStorage or sessionStorage*/
      ? (
        F.config.projectCode || F.config.projectName
          || F.config.shortProjectName || window.location.pathname
      )+'::' : (
99
100
101
102
103
104
105







106
107
108
109
110
111
112
    /** Sets storage key k to JSON.stringify(v). */
    setJSON: (k,v)=>$storage.setItem(storageKeyPrefix+k,JSON.stringify(v)),
    /** Returns the value for the given storage key, or
        dflt if the key is not found in the storage. */
    get: (k,dflt)=>$storageHolder.hasOwnProperty(
      storageKeyPrefix+k
    ) ? $storage.getItem(storageKeyPrefix+k) : dflt,







    /** Returns the JSON.parse()'d value of the given
        storage key's value, or dflt is the key is not
        found or JSON.parse() fails. */
    getJSON: function f(k,dflt){
      try {
        const x = this.get(k,f);
        return x===f ? dflt : JSON.parse(x);







>
>
>
>
>
>
>







99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
    /** Sets storage key k to JSON.stringify(v). */
    setJSON: (k,v)=>$storage.setItem(storageKeyPrefix+k,JSON.stringify(v)),
    /** Returns the value for the given storage key, or
        dflt if the key is not found in the storage. */
    get: (k,dflt)=>$storageHolder.hasOwnProperty(
      storageKeyPrefix+k
    ) ? $storage.getItem(storageKeyPrefix+k) : dflt,
    /** Returns true if the given key has a value of "true".  If the
        key is not found, it returns true if the boolean value of dflt
        is "true". (Remember that JS persistent storage values are all
        strings.) */
    getBool: function(k,dflt){
      return 'true'===this.get(k,''+(!!dflt));
    },
    /** Returns the JSON.parse()'d value of the given
        storage key's value, or dflt is the key is not
        found or JSON.parse() fails. */
    getJSON: function f(k,dflt){
      try {
        const x = this.get(k,f);
        return x===f ? dflt : JSON.parse(x);