Fossil

Check-in [b7f106da8a]
Login

Check-in [b7f106da8a]

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

Overview
Comment:Added UI to delete chat posts (tap on the message header). Made a change to the semantics of when fossil.PopupWidget's refresh() callback is triggered to account for the common case of having to show() the popup twice in a row without a hide() in between.
Downloads: Tarball | ZIP archive
Timelines: family | ancestors | descendants | both | trunk
Files: files | file ages | folders
SHA3-256: b7f106da8a6e31b05dd930172bdfe4b72e45b493521fd37e5a68aa88ecde5672
User & Date: stephan 2020-12-24 05:58:51.749
Context
2020-12-24
06:18
Removed some debug output. ... (check-in: f315268e2c user: stephan tags: trunk)
05:58
Added UI to delete chat posts (tap on the message header). Made a change to the semantics of when fossil.PopupWidget's refresh() callback is triggered to account for the common case of having to show() the popup twice in a row without a hide() in between. ... (check-in: b7f106da8a user: stephan tags: trunk)
05:03
Initial impl for chat message deletion. The ajax bits are in place and message deletion propagates to other connected clients (if the message is owned by the poster or the user is an admin) but there's not currently a user interface. TODO: add related controls to the same popup used for the message timestamps. ... (check-in: 247276113c user: stephan tags: trunk)
Changes
Unified Diff Ignore Whitespace Patch
Changes to src/chat.c.
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
  @   border:1px solid rgba(0,0,0,0);/*avoid UI shift during drop-targeting*/
  @   border-radius: 0.25em;
  @   padding: 0.25em;
  @ }
  @ #chat-input-file > input {
  @   flex: 1 0 auto;
  @ }
  @ .chat-timestamp {
  @    font-family: monospace;
  @    font-size: 0.8em;
  @    white-space: pre;
  @    text-align: left;
  @    opacity: 0.8;
  @ }
  @ #chat-input-file.dragover {
  @   border: 1px dashed green;
  @ }
  @ #chat-drop-details {
  @   flex: 0 1 auto;
  @   padding: 0.5em 1em;
  @   margin-left: 0.5em;







<
<
<
<
<
<
<







124
125
126
127
128
129
130







131
132
133
134
135
136
137
  @   border:1px solid rgba(0,0,0,0);/*avoid UI shift during drop-targeting*/
  @   border-radius: 0.25em;
  @   padding: 0.25em;
  @ }
  @ #chat-input-file > input {
  @   flex: 1 0 auto;
  @ }







  @ #chat-input-file.dragover {
  @   border: 1px dashed green;
  @ }
  @ #chat-drop-details {
  @   flex: 0 1 auto;
  @   padding: 0.5em 1em;
  @   margin-left: 0.5em;
Changes to src/chat.js.
24
25
26
27
28
29
30





31





32



33


34
35











36
37
38
39
40



41
42





43

44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
      console.error("chat error:",args);
      F.toast.error.apply(F.toast, args);
    };

    cs.getMessageElemById = function(id){
      return qs('[data-msgid="'+id+'"]');
    };





    cs.deleteMessageElemById = function(id){





      const e = this.getMessageElemById(id);



      if(e) D.remove(e);


      return !!e;
    };












    /**
       Removes the given message ID from the local chat record and, if
       the message was posted by this user OR this user in an
       admin/setup, also submits it for removal on the remote.



    */
    cs.deleteMessageById = function(id){





      const e = this.getMessageElemById(id);

      if(!e) return;
      if(this.me === e.dataset.xfrom
         || F.user.isAdmin/*will be confirmed server-side*/
        ){
        fetch("chat-delete?name=" + id)
          .then(()=>D.remove(e))
          .then(()=>F.toast.message("Deleted message "+id+"."))
          .catch(err=>this.reportError(err))
      }else{
        D.remove(e);
        F.toast.message("Locally removed message "+id+".");
      }
    };

    return cs;
  })();
  /* State for paste and drag/drop */
  const BlobXferState = {







>
>
>
>
>
|
>
>
>
>
>
|
>
>
>
|
>
>


>
>
>
>
>
>
>
>
>
>
>





>
>
>

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

|
<


<
|







24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80


81
82

83
84

85
86
87
88
89
90
91
92
      console.error("chat error:",args);
      F.toast.error.apply(F.toast, args);
    };

    cs.getMessageElemById = function(id){
      return qs('[data-msgid="'+id+'"]');
    };
    /**
       LOCALLY deletes a message element by the message ID or passing
       the .message-row element. Returns true if it removes an element,
       else false.
    */
    cs.deleteMessageElem = function(id){
      var e;
      if(id instanceof HTMLElement){
        e = id;
        id = e.dataset.msgid;
      }else{
        e = this.getMessageElemById(id);
      }
      console.debug("e && id ===",e&&id, e, id);
      if(e && id){
        D.remove(e);
        F.toast.message("Deleted message "+id+".");
      }
      return !!e;
    };

    /** Given a .message-row element, this function returns whethe the
        current user may, at least hypothetically, delete the message
        globally.  A user may always delete a local copy of a
        post. The server may trump this, e.g. if the login has been
        cancelled after this page was loaded.
    */
    cs.userMayDelete = function(eMsg){
      return this.me === eMsg.dataset.xfrom
        || F.user.isAdmin/*will be confirmed server-side*/;
    };

    /**
       Removes the given message ID from the local chat record and, if
       the message was posted by this user OR this user in an
       admin/setup, also submits it for removal on the remote.

       id may optionally be a DOM element, in which case it must be a
       .message-row element.
    */
    cs.deleteMessage = function(id){
      var e;
      if(id instanceof HTMLElement){
        e = id;
        id = e.dataset.msgid;
      }else{
        e = this.getMessageElemById(id);
      }
      if(!(e instanceof HTMLElement)) return;
      if(this.userMayDelete(e)){


        fetch("chat-delete?name=" + id)
          .then(()=>this.deleteMessageElem(e))

          .catch(err=>this.reportError(err))
      }else{

        this.deleteMessageElem(id);
      }
    };

    return cs;
  })();
  /* State for paste and drag/drop */
  const BlobXferState = {
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
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246

247
248
249
250
251
252
253
254
255
  };
  /* Event handler for clicking .message-user elements to show their
     timestamps. */
  const handleLegendClicked = function f(ev){
    if(!f.popup){
      /* Timestamp popup widget */
      f.popup = new F.PopupWidget({
        cssClass: ['fossil-tooltip', 'chat-timestamp'],
        refresh:function(){
          const D = F.dom;

          D.clearElement(this.e);
          const d = new Date(this._timestamp+"Z");
          if(d.getMinutes().toString()!=="NaN"){
            // Date works, render informative timestamps
            D.append(this.e, localTimeString(d)," client-local", D.br(),

                     iso8601ish(d));
          }else{
            // Date doesn't work, so dumb it down...
            D.append(this.e, this._timestamp," GMT");
          }











        }
      });
      f.popup.installClickToHide();





    }
    const rect = ev.target.getBoundingClientRect();

    f.popup._timestamp = ev.target.dataset.timestamp;
    let x = rect.left, y = rect.top - 10;
    f.popup.show(ev.target)/*so we can get its computed size*/;
    if('right'===ev.target.getAttribute('align')){
      // Shift popup to the left for right-aligned messages to avoid
      // truncation off the right edge of the page.
      const pRect = f.popup.e.getBoundingClientRect();
      x -= pRect.width/3*2;
    }
    f.popup.show(x, y);
  };
  /** Callback for poll() to inject new content into the page. */
  function newcontent(jx){
    var i;
    for(i=0; i<jx.msgs.length; ++i){
      const m = jx.msgs[i];
      if( m.msgid>Chat.mxMsg ) Chat.mxMsg = m.msgid;
      if( m.mdel ){
        /* A record deletion notice. */
        Chat.deleteMessageElemById(m.mdel);
        continue;
      }
      const eWho = D.create('legend'),
            row = D.addClass(D.fieldset(eWho), 'message-row');
      row.dataset.msgid = m.msgid;
      row.dataset.xfrom = m.xfrom;

      injectMessage(row);
      eWho.dataset.timestamp = m.mtime;
      eWho.addEventListener('click', handleLegendClicked, false);
      if( m.xfrom==Chat.me && window.outerWidth<1000 ){
        eWho.setAttribute('align', 'right');
        row.style.justifyContent = "flex-end";
      }else{
        eWho.setAttribute('align', 'left');
      }







|

|
>

|


|
>
|


|

>
>
>
>
>
>
>
>
>
>
>



>
>
>
>
>


>
|


















|






>

<







227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298

299
300
301
302
303
304
305
  };
  /* Event handler for clicking .message-user elements to show their
     timestamps. */
  const handleLegendClicked = function f(ev){
    if(!f.popup){
      /* Timestamp popup widget */
      f.popup = new F.PopupWidget({
        cssClass: ['fossil-tooltip', 'chat-message-popup'],
        refresh:function(){
          const eMsg = this._eMsg;
          if(!eMsg) return;
          D.clearElement(this.e);
          const d = new Date(eMsg.dataset.timestamp+"Z");
          if(d.getMinutes().toString()!=="NaN"){
            // Date works, render informative timestamps
            D.append(this.e,
                     D.append(D.span(), localTimeString(d)," client-local"),
                     D.append(D.span(), iso8601ish(d)));
          }else{
            // Date doesn't work, so dumb it down...
            D.append(this.e, D.append(D.span(), eMsg.dataset.timestamp," GMT"));
          }
          const toolbar = D.addClass(D.div(), 'toolbar');
          const btnDelete = D.button("Delete "+
                                     (Chat.userMayDelete(eMsg)
                                      ? "globally" : "locally"));
          const self = this;
          btnDelete.addEventListener('click', function(){
            self.hide();
            Chat.deleteMessage(eMsg);
          });
          D.append(this.e, toolbar);
          D.append(toolbar, btnDelete);
        }
      });
      f.popup.installClickToHide();
      f.popup.hide = function(){
        delete this._eMsg;
        D.clearElement(this.e);
        return this.show(false);
      };
    }
    const rect = ev.target.getBoundingClientRect();
    const eMsg = ev.target.parentNode/*the owning fieldset element*/;
    f.popup._eMsg = eMsg;
    let x = rect.left, y = rect.top - 10;
    f.popup.show(ev.target)/*so we can get its computed size*/;
    if('right'===ev.target.getAttribute('align')){
      // Shift popup to the left for right-aligned messages to avoid
      // truncation off the right edge of the page.
      const pRect = f.popup.e.getBoundingClientRect();
      x -= pRect.width/3*2;
    }
    f.popup.show(x, y);
  };
  /** Callback for poll() to inject new content into the page. */
  function newcontent(jx){
    var i;
    for(i=0; i<jx.msgs.length; ++i){
      const m = jx.msgs[i];
      if( m.msgid>Chat.mxMsg ) Chat.mxMsg = m.msgid;
      if( m.mdel ){
        /* A record deletion notice. */
        Chat.deleteMessageElem(m.mdel);
        continue;
      }
      const eWho = D.create('legend'),
            row = D.addClass(D.fieldset(eWho), 'message-row');
      row.dataset.msgid = m.msgid;
      row.dataset.xfrom = m.xfrom;
      row.dataset.timestamp = m.mtime;
      injectMessage(row);

      eWho.addEventListener('click', handleLegendClicked, false);
      if( m.xfrom==Chat.me && window.outerWidth<1000 ){
        eWho.setAttribute('align', 'right');
        row.style.justifyContent = "flex-end";
      }else{
        eWho.setAttribute('align', 'left');
      }
Changes to src/default.css.
1501
1502
1503
1504
1505
1506
1507

















  margin-bottom: 0.4em;
  cursor: pointer;
}

body.chat .fossil-tooltip.help-buttonlet-content {
  font-size: 80%;
}
























>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
  margin-bottom: 0.4em;
  cursor: pointer;
}

body.chat .fossil-tooltip.help-buttonlet-content {
  font-size: 80%;
}

body.chat .chat-message-popup {
  font-family: monospace;
  font-size: 0.8em;
  text-align: left;
  opacity: 0.8;
  display: flex;
  flex-direction: column;
  align-items: stretch;
}
.chat-message-popup > span { white-space: nowrap; }
.chat-message-popup > .toolbar {
  padding: 0.2em;
  margin: 0;
  border: 2px inset rgba(0,0,0,0.3);
  border-radius: 0.25em;
}
Changes to src/fossil.popupwidget.js.
10
11
12
13
14
15
16
17
18
19
20
21








22
23
24
25
26
27
28

  /**
     Creates a new tooltip-like widget using the given options object.

     Options:

     .refresh: callback which is called just before the tooltip is
     revealed or moved. It must refresh the contents of the tooltip,
     if needed, by applying the content to/within this.e, which is the
     base DOM element for the tooltip (and is a child of
     document.body). If the contents are static and set up via the
     .init option then this callback is not needed.









     .adjustX: an optional callback which is called when the tooltip
     is to be displayed at a given position and passed the X
     viewport-relative coordinate. This routine must either return its
     argument as-is or return an adjusted value. The intent is to
     allow a given tooltip may be positioned more appropriately for a
     given context, if needed (noting that the desired position can,







|
|
|
|
|
>
>
>
>
>
>
>
>







10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

  /**
     Creates a new tooltip-like widget using the given options object.

     Options:

     .refresh: callback which is called just before the tooltip is
     revealed. It must refresh the contents of the tooltip, if needed,
     by applying the content to/within this.e, which is the base DOM
     element for the tooltip (and is a child of document.body). If the
     contents are static and set up via the .init option then this
     callback is not needed. When moving an already-shown tooltip,
     this is *not* called. It arguably should be, but the fact is that
     we often have to show() a popup twice in a row without hiding it
     between those calls: once to get its computed size and another to
     move it by some amount relative to that size. If the state of the
     popup depends on its position and a "double-show()" is needed
     then the client must hide() the popup between the two calls to
     show() in order to force a call to refresh() on the second
     show().

     .adjustX: an optional callback which is called when the tooltip
     is to be displayed at a given position and passed the X
     viewport-relative coordinate. This routine must either return its
     argument as-is or return an adjusted value. The intent is to
     allow a given tooltip may be positioned more appropriately for a
     given context, if needed (noting that the desired position can,
138
139
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
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
       the given element (adjusted slightly).

       For the latter two, this.options.adjustX() and adjustY() will
       be called to adjust it further.

       Returns this object.





       Sidebar: showing/hiding the widget is, as is conventional for
       this framework, done by removing/adding the 'hidden' CSS class
       to it, so that class must be defined appropriately.
    */
    show: function(){
      var x = undefined, y = undefined, showIt;

      if(2===arguments.length){
        x = arguments[0];
        y = arguments[1];
        showIt = true;
      }else if(1===arguments.length){
        if(arguments[0] instanceof HTMLElement){
          const p = arguments[0];
          const r = p.getBoundingClientRect();
          x = r.x + r.x/5;
          y = r.y - r.height/2;
          showIt = true;
        }else{
          showIt = !!arguments[0];
        }
      }
      if(showIt){
        this.refresh();
        x = this.options.adjustX.call(this,x);
        y = this.options.adjustY.call(this,y);
        x += window.pageXOffset;
        y += window.pageYOffset;
      }
      if(showIt){
        if('number'===typeof x && 'number'===typeof y){
          this.e.style.left = x+"px";
          this.e.style.top = y+"px";
        }
        D.removeClass(this.e, 'hidden');
      }else{
        D.addClass(this.e, 'hidden');
        this.e.style.removeProperty('left');
        this.e.style.removeProperty('top');
      }
      return this;
    },












    hide: function(){return this.show(false)},

    /**
       A convenience method which adds click handlers to this popup's
       main element and document.body to hide the popup when either
       element is clicked or the ESC key is pressed. Only call this
       once per instance, if at all. Returns this;
    */
    installClickToHide: function f(){
      this.e.addEventListener('click', ()=>this.show(false), false);
      document.body.addEventListener('click', ()=>this.show(false), true);
      const self = this;
      document.body.addEventListener('keydown', function(ev){
        if(self.isShown() && 27===ev.which) self.show(false);
      }, true);
      return this;
    }
  }/*F.PopupWidget.prototype*/;

  /**
     Internal impl for F.toast() and friends.







>
>
>
>





|
>
















|



















>
>
>
>
>
>
>
>
>
>
>




|
|
|


|
|


|







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
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
229
230
231
       the given element (adjusted slightly).

       For the latter two, this.options.adjustX() and adjustY() will
       be called to adjust it further.

       Returns this object.

       If this call will reveal the element then it calls
       this.refresh() to update the UI state. If the element was
       already revealed, the call to refresh() is skipped.

       Sidebar: showing/hiding the widget is, as is conventional for
       this framework, done by removing/adding the 'hidden' CSS class
       to it, so that class must be defined appropriately.
    */
    show: function(){
      var x = undefined, y = undefined, showIt,
          wasShown = !this.e.classList.contains('hidden');
      if(2===arguments.length){
        x = arguments[0];
        y = arguments[1];
        showIt = true;
      }else if(1===arguments.length){
        if(arguments[0] instanceof HTMLElement){
          const p = arguments[0];
          const r = p.getBoundingClientRect();
          x = r.x + r.x/5;
          y = r.y - r.height/2;
          showIt = true;
        }else{
          showIt = !!arguments[0];
        }
      }
      if(showIt){
        if(!wasShown) this.refresh();
        x = this.options.adjustX.call(this,x);
        y = this.options.adjustY.call(this,y);
        x += window.pageXOffset;
        y += window.pageYOffset;
      }
      if(showIt){
        if('number'===typeof x && 'number'===typeof y){
          this.e.style.left = x+"px";
          this.e.style.top = y+"px";
        }
        D.removeClass(this.e, 'hidden');
      }else{
        D.addClass(this.e, 'hidden');
        this.e.style.removeProperty('left');
        this.e.style.removeProperty('top');
      }
      return this;
    },

    /**
       Equivalent to show(false), but may be overridden by instances,
       so long as they also call this.show(false) to perform the
       actual hiding. Overriding can be used to clean up any state so
       that the next call to refresh() (before the popup is show()n
       again) can recognize whether it needs to do something, noting
       that it's legal, and sometimes necessary, to call show()
       multiple times without needing/wanting to completely refresh
       the popup between each call (e.g. when moving the popup after
       it's been show()n).
    */
    hide: function(){return this.show(false)},

    /**
       A convenience method which adds click handlers to this popup's
       main element and document.body to hide (via hide()) the popup
       when either element is clicked or the ESC key is pressed. Only
       call this once per instance, if at all. Returns this;
    */
    installClickToHide: function f(){
      this.e.addEventListener('click', ()=>this.hide(), false);
      document.body.addEventListener('click', ()=>this.hide(), true);
      const self = this;
      document.body.addEventListener('keydown', function(ev){
        if(self.isShown() && 27===ev.which) self.hide();
      }, true);
      return this;
    }
  }/*F.PopupWidget.prototype*/;

  /**
     Internal impl for F.toast() and friends.