@yz-social/civildefense.io 4.4.4 → 4.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@yz-social/civildefense.io",
3
3
  "description": "Browser app to safely share live sightings on a map.",
4
- "version": "4.4.4",
4
+ "version": "4.5.5",
5
5
  "keywords": [
6
6
  "map",
7
7
  "browser",
@@ -9,14 +9,15 @@
9
9
  ],
10
10
  "scripts": {
11
11
  "start": "npm stop; node ./server/app.js",
12
- "stop": "pkill yz.social"
12
+ "stop": "pkill yz.social",
13
+ "postinstall": "touch server/location.json; chmod a+w server/location.json"
13
14
  },
14
15
  "type": "module",
15
16
  "exports": {
16
17
  ".": "./index.js"
17
18
  },
18
19
  "dependencies": {
19
- "@axona/protocol": "github:axona-net/axona-protocol#semver:^4.18.2",
20
+ "@axona/protocol": "github:axona-net/axona-protocol#5bf6f888cbbb5a58b9700c11582a4b6ca4c3141e",
20
21
  "cors": "^2.8.6",
21
22
  "express": "^4.22.1",
22
23
  "leaflet": "^1.9.4",
@@ -70,7 +70,7 @@ export class Agent {
70
70
  });
71
71
  }
72
72
  async setPublicData(data) { // Subscription to public data has fired. Update value, but do not not re-publish.
73
- let {payload, subject, type, topic, ts} = data; // fixme remove topic and ts
73
+ let {payload, tag, type, topic, ts} = data; // fixme remove topic and ts
74
74
  // WARNING: IF we chunkify avatars, and we use since:'all', then we need lock out asynchronous
75
75
  // decoding of later timestamps, or of null payloads.
76
76
  // if (payload && (type === 'avatar')) {
@@ -79,7 +79,7 @@ export class Agent {
79
79
  // payload = dataURL;
80
80
  // }
81
81
  this.updateValue(payload, 'public', type, false);
82
- if (subject) this.publicMsgId[type] = subject;
82
+ if (tag) this.publicMsgId[type] = tag;
83
83
  }
84
84
 
85
85
  static agents = {}; // tag => Agent
@@ -0,0 +1,557 @@
1
+ import * as L from 'leaflet';
2
+ import { P2PWebNetwork } from './p2pWebNetwork.js';
3
+ import { Int } from './translations.js';
4
+ import { map, trackMap, showMessage } from './map.js';
5
+ import { networkPromise, resetInactivityTimer, notificationsAllowed, tooltip, clickTip, openAbout, delay, osName } from './main.js';
6
+ import { consume } from './display.js';
7
+ import { Hashtags } from './hashtags.js';
8
+ import { Agent } from './agent.js';
9
+ import { Conversation } from './conversation.js';
10
+ import { alertTopic } from './versions.js';
11
+ import { getContainingCells, findCoverCellsByCenterAndPoint } from './s2.js';
12
+ const { localStorage, getComputedStyle, URL, URLSearchParams, domtoimage } = globalThis;
13
+
14
+
15
+ export function getShareableURL(tag = null, tags = Hashtags.getSubscribe()) { // Answer a url that reflects application state.
16
+ const params = new URLSearchParams(location.search);
17
+ const zoom = map.getZoom();
18
+ const { lat, lng } = map.getCenter();
19
+
20
+ params.set('tags', tags.map(tag => encodeURIComponent(tag)).join(','));
21
+ if (lat !== null) params.set('lat', lat);
22
+ if (lng !== null) params.set('lng', lng);
23
+ if (zoom !== null) params.set('z', zoom);
24
+ if (tag !== null) params.set('alert', tag);
25
+ return new URL(`?${params.toString()}`, location);
26
+ }
27
+ export async function share(properties) { // Invoke platform share API on properties.
28
+ if (!navigator.share) {
29
+ showMessage(navigator.userAgent.includes('Firefox') ? Int`In Firefox, sharing must be explicitly enabled through the <a target="civildefense_help" href="https://developer.mozilla.org/en-US/docs/Mozilla/Firefox/Experimental_features#webshare_api">dom.webshare.enabled</a> preference in about:config.` : Int`This browser does not support sharing.`);
30
+ return;
31
+ }
32
+ if (properties.files) {
33
+ if (!navigator.canShare) {
34
+ showMessage(Int`This browser does not support file sharing.`);
35
+ return;
36
+ }
37
+ if (!navigator.canShare({files: properties.files})) {
38
+ showMessage(Int`This browser does not support sharing this type of file.`);
39
+ return;
40
+ }
41
+ }
42
+ if (!properties.files) {
43
+ Alert.closePopup();
44
+ await delay(500); // Allow popup time to close. It doesn't render well because of the web component style sheets.
45
+ const target = document.getElementById('mapCapture');
46
+ const icon = target.lastElementChild;
47
+ const subPopoverControls = document.getElementById('subPopoverControls');
48
+ const leafletControls = document.querySelector('.leaflet-control-container');
49
+ subPopoverControls.style = leafletControls.style = 'opacity: 0;';
50
+ icon.style = 'opacity: 1;';
51
+ const capture = await domtoimage.toPng(target);
52
+ subPopoverControls.style = leafletControls.style = icon.style = '';
53
+ const file = await P2PWebNetwork.dataURL2blob(capture, 'map.png');
54
+ trackMap();
55
+ properties.files = [file];
56
+ }
57
+ navigator.share({title: "CivilDefense.io", ...properties})
58
+ .catch(error => { if (!['AbortError', 'InvalidStateError'].includes(error.name)) throw error; });
59
+ }
60
+
61
+
62
+ const ttl = 24 * 60 * 60e3; // 24 hours
63
+ let openOnReceive = null;
64
+ export function go({lat = null, lng = null, zoom = null, alert = null}) { // Go to specified location (if any) and open marker (if any).
65
+ if (lat !== null && lng !== null) {
66
+ if (zoom) map.flyTo({lat, lng}, zoom);
67
+ else map.flyTo({lat, lng});
68
+ }
69
+ openOnReceive = null;
70
+ if (alert) {
71
+ Alert.openPopup(alert) || (openOnReceive = alert);
72
+ }
73
+ }
74
+
75
+ let subscriptions = []; // array of stringy keys <mumble>:<cellID>:<hashtag>
76
+ let subscriptionsRegion;
77
+ // We do not record exactly where you were looking across sessions, but we do record the containing level 9 cell.
78
+ let lastLevel9Cell; // S2 level 9 cells average a radius of about 10km ~ 6.5 miles.
79
+
80
+ let last = []; // Last published lat, lng, tag
81
+ const maxPublish = 5;
82
+ let publishing = false;
83
+
84
+ export class Alert extends Conversation { // A wrapper around L.marker
85
+ // When we resubscribe to different cells covering the same place, we will get the same
86
+ // sticky data. We don't want to change the marker. Fortunately, the publication to each
87
+ // of the cells (at different scales) are all published with the same data.
88
+ static updateSubscriptions(oldKeys = subscriptions, newKeys) { // Update current subscriptions to the new map bounds.
89
+ // A value of [] passed for oldKeys is used to start things off fresh (i.e., without supressing subscription of any carry-overs).
90
+ if (!networkPromise) { console.warn("No network through which to subscribe."); return; } // Does this ever happen? Why?
91
+ let region;
92
+ if (!newKeys) { // None specified. Compute them.
93
+ const center = map.getCenter();
94
+ const bounds = map.getBounds();
95
+ const northEast = bounds.getNorthEast();
96
+ const newCells = findCoverCellsByCenterAndPoint(center.lat, center.lng, northEast.lat, northEast.lng); // array of cell IDs (BigInts)
97
+ region = P2PWebNetwork.regionCode(center.lat, center.lng);
98
+ newKeys = newCells.flatMap(cell => Hashtags.getSubscribe().map(hash => alertTopic(cell, hash)));
99
+ Agent.current.trackPublicChanges(region);
100
+ // Record a zoomed-out cell id in case next session does not have geolocation services.
101
+ let level9Cell = getContainingCells(center.lat, center.lng)[9];
102
+ if (level9Cell !== lastLevel9Cell) localStorage.setItem('level9Cell', lastLevel9Cell = level9Cell);
103
+ }
104
+
105
+ const subscribe = (key, region, handler) =>
106
+ networkPromise.then(async contact => contact.subscribe({eventName: key, region, handler}));
107
+
108
+ // For each entry in the new subscription set that was not previously subscribed, subscribe now.
109
+ for (const key of newKeys) oldKeys.includes(key) || subscribe(key, region, data => Alert.ensure(data));
110
+
111
+ // For each existing subscription, if it does not appear in the new set then unsubscribe.
112
+ for (const key of oldKeys) newKeys.includes(key) || subscribe(key, subscriptionsRegion, null);
113
+ console.log('Subscribed', {newKeys, region, length: newKeys.length, oldKeys, subscriptionsRegion});
114
+
115
+ subscriptions = newKeys;
116
+ subscriptionsRegion = region;
117
+ }
118
+ // Publish an alert to all applicable eventNames, canceling as required. Promises tag (msgId).
119
+ static async publish({lat, lng,
120
+ originalPosting = undefined,
121
+ hashtag = Hashtags.getPublish(),
122
+ payload = {lat, lng, originalPosting}, // If payload is null (cancels subject), lat & lng are still used to generate eventNames.
123
+ cancel = undefined, // First unpublish the specified data, if any. Complicated default.
124
+ issuedTime = Date.now(), subject,
125
+ throttleMS = 0,
126
+ ...rest
127
+ }) {
128
+ // We call all the publishing at once and return subject, without waiting for each to occur.
129
+ // However, the 'unpublishing' (if any) is invoked first.
130
+ // To do this, we must hash the eventName ourselves.
131
+ //console.log('publish', {lat, lng, hashtag, payload, cancel, subject, issuedTime, rest});
132
+ if (publishing) { console.log('skiping overlappying publish'); return null; } // do not stack them up.
133
+ try {
134
+ publishing = true;
135
+
136
+ const contact = await networkPromise; // subtle: The rest of this all happens synchronously, with any null payloads definitely first.
137
+ let oldCells = null, oldHash, oldSubject = null; // Recorded for logging, below.
138
+ let lastFillIn;
139
+ if (payload) {
140
+ lastFillIn = {lat, lng, hashtag, issuedTime};
141
+ last.push(lastFillIn); // Capture the added data.
142
+ const periodStart = Date.now() - (maxPublish * 60e3); // maxPublish minutes ago.
143
+ last = last.filter(past => past.issuedTime >= periodStart);
144
+ if (cancel === undefined && last.length > maxPublish) { // Unless specified otherwise, cancel oldest over maxPublish.
145
+ showMessage(Int`Too many posts. (5 allowed every 5 minutes.) Removing oldest from this period.`);
146
+ cancel = last.shift();
147
+ }
148
+ }
149
+ if (cancel) {
150
+ const {lat, lng, hashtag, subject} = cancel;
151
+ oldCells = getContainingCells(lat, lng);
152
+ oldHash = hashtag; oldSubject = subject;
153
+ const region = P2PWebNetwork.regionCode(lat, lng);
154
+ for (const cell of oldCells) {
155
+ const eventName = alertTopic(cell, hashtag);
156
+ // Note: we cannot unpublish replies by others, but they expire after a while anyway.
157
+ await contact.publish({eventName, region, killTag: subject, payload: null});
158
+ throttleMS && await P2PWebNetwork.delay(throttleMS);
159
+ }
160
+ }
161
+
162
+ const cells = getContainingCells(lat, lng);
163
+ const region = P2PWebNetwork.regionCode(lat, lng);
164
+ for (const cell of cells) {
165
+ const eventName = alertTopic(cell, hashtag);
166
+ if (payload) {
167
+ const msgId = await contact.publish({eventName, region, payload, issuedTime, hashtag, ...rest});
168
+ if (subject && subject !== msgId) throw new Error(`msgId is drifting: ${subject} => ${msgId}`);
169
+ subject = msgId;
170
+ if (lastFillIn) {
171
+ lastFillIn.subject = subject;
172
+ lastFillIn = null;
173
+ }
174
+ } else {
175
+ await contact.publish({eventName, region, killTag: subject, payload: null});
176
+ throttleMS && await P2PWebNetwork.delay(throttleMS);
177
+ }
178
+ }
179
+ if (!payload) {
180
+ const index = last.findIndex(past => past.subject === subject);
181
+ if (index >= 0) last.splice(index, 1);
182
+ }
183
+ console.log('Published', {cells, n: cells.length, region, hashtag, subject, payload, oldCells, oldHash, oldSubject});
184
+ return subject;
185
+ } finally {
186
+ publishing = false;
187
+ }
188
+ }
189
+
190
+ static noMessage = Int`No additional information.`;
191
+ static closePopup() { // Close any open popup.
192
+ map.closePopup();
193
+ }
194
+ static openPopup(alertTag) { // Open the marker specified by subject.
195
+ const wrapper = this.getItem(alertTag);
196
+ wrapper?.openPopup();
197
+ }
198
+ async openPopup() { // Open this wrapper's popup, and resolve any waiting promise.
199
+ const { resolveGo } = this; // A handy hook for scripting.
200
+ if (resolveGo) {
201
+ resolveGo(this);
202
+ delete this.resolveGo;
203
+ await delay(100);
204
+ }
205
+ this.marker.openPopup();
206
+ }
207
+ static makeIcon(hashtag) { // Return a Leaflet icon
208
+ return L.divIcon({
209
+ html: `<div class="alert-commented"></div><div class="alert-pin">${Hashtags.formatAlert(hashtag)}</div>`,
210
+ iconSize: [40, 40],
211
+ popupAnchor: [0, 0],
212
+ className: 'alert-marker'
213
+ });
214
+ }
215
+ static updateAlerts(canonicalHashtag, extendedHashtag) { // Update markers becase we have discovered an extendedHashtag that we have only had as canonical.
216
+ this.items.forEach(wrapper => {
217
+ const { hashtag, marker, agent } = wrapper;
218
+ if (hashtag !== canonicalHashtag) return;
219
+ const newIcon = this.makeIcon(extendedHashtag);
220
+ const popup = marker.getPopup();
221
+ marker.setIcon(newIcon);
222
+ wrapper.hashtag = extendedHashtag;
223
+ wrapper.needsRedisplay = true; // See comment for initializeHandlers. We need to clear and rebuild content on re-open.
224
+ if (!popup.isOpen()) return;
225
+ // Fix what's showing now without flashing everything. Make sure menu works.
226
+ const popupAttribution = popup.getElement().querySelector('.attribution');
227
+ const attributionActions = popupAttribution.lastElementChild;
228
+ attributionActions.lastElementChild.remove();
229
+ attributionActions.insertAdjacentHTML('beforeend', this.formatAttributionHashtag(agent, extendedHashtag));
230
+ wrapper.initChangeHashtag(popupAttribution);
231
+ });
232
+ }
233
+ static ensure({tag, topic, ts, issuedTime, ...rest}) { // Add marker at position with appropriate fade if not already present.
234
+ const alert = super.ensure({tag, subject: tag, issuedTime, ...rest}); // Does not include topic or ts. fixme: get rid of subject. fixme: why is ts different?
235
+ if (!alert) return null;
236
+ // Regardless of initialize vs update, reset fader.
237
+ const now = Date.now(),
238
+ expiration = issuedTime + ttl,
239
+ remaining = expiration - now;
240
+ if (remaining < 0) return alert?.destroy(); // Expired.
241
+ alert.startFader('.alert-pin', remaining); // From the new value of remaining, after marker is set in wrapper, regardless of popup/dirty state.
242
+ alert.destroyer = setTimeout(() => alert.destroy(), remaining);
243
+ return alert;
244
+ }
245
+ initialize({payload, hashtag, subject, agent, issuedTime, ...rest}) { // Set up the marker for a newly received alert.
246
+ if (!payload) return null; // Do not cache. E.g., Received a delete event without the initial creation.
247
+ const icon = this.constructor.makeIcon(hashtag);
248
+ const {lat, lng, originalPosting} = payload;
249
+ const marker = this.marker = L.marker([lat, lng], {icon, autoPan: false}).addTo(map);
250
+ const region = P2PWebNetwork.regionCode(lat, lng);
251
+ hashtag = Hashtags.add(hashtag); // We already have it and are subscribing, but this updates our extended form if needed.
252
+ super.initialize({payload, hashtag, subject, agent, lat, lng, issuedTime, originalPosting, ...rest});
253
+
254
+ marker.bindPopup('', {className: 'alert'}).on('popupopen', event => this.ensureContent(event.popup));
255
+ tooltip(marker.getElement(), Int`Show conversation for this ${hashtag} alert.`);
256
+ if (subject === openOnReceive) {
257
+ openOnReceive = false;
258
+ this.openPopup();
259
+ }
260
+ // Subscribe to replies to this subject, now that we're set up to receive them.
261
+ networkPromise.then(async contact => {
262
+ contact.subscribe({eventName: subject, region, handler: data => this.ensure(data)});
263
+ });
264
+ this.showNotification({agent, issuedTime});
265
+ return this;
266
+ }
267
+
268
+ needsRedisplay = true;
269
+ ensureContent(popup = this.marker.getPopup()) { // Set content and handlers in popup if/as needed.
270
+ if (!popup.isOpen()) return;
271
+ if (!this.needsRedisplay) {
272
+ this.initializeHandlers(popup);
273
+ return;
274
+ }
275
+ this.needsRedisplay = false;
276
+ const {issuedTime, originalPosting, hashtag, agent} = this;
277
+ this.clearAvatars(popup);
278
+ let content = this.formatAttribution({agent, issuedTime, originalPosting, hashtag});
279
+ content += this.formatReplies();
280
+ popup.setContent(content);
281
+ delay(100).then(() => {
282
+ this.marker.getPopup().update();
283
+ this.initializeHandlers(popup);
284
+ });
285
+ console.warn(`latitude: ${this.lat}, longitude: ${this.lng}`);
286
+ }
287
+ clearAvatars(popup = this.marker?.getPopup()) {
288
+ popup?.getElement()?.querySelectorAll('.correspondent[data-tag]')
289
+ .forEach(element => Agent.ensure({tag: element.dataset.tag}).removeElement(element, 'mixed', element.classList.contains('avatar') ? 'avatar' : 'handle'));
290
+ }
291
+ initializeHandlers(popup) { // subtle: Leaflet pupup will recreate from last setContent string. Need to re-establish handlers.
292
+ const popupElement = popup.getElement();
293
+ const replyInput = popupElement.querySelector('.reply-input');
294
+ const replyButton = replyInput.querySelector('md-filled-icon-button');
295
+ const replyAttachButton = replyInput.querySelector('md-tonal-icon-button');
296
+ const fileChooser = popupElement.querySelector('input[type="file"]');
297
+ replyInput.oninput = event => {
298
+ replyButton.removeAttribute('disabled');
299
+ const input = event.currentTarget;
300
+ const textarea = input.shadowRoot.querySelector('textarea');
301
+ const internalHighWater = Math.round(textarea.scrollHeight / parseFloat(getComputedStyle(textarea).lineHeight));
302
+ input.rows = internalHighWater;
303
+ };
304
+ clickTip(replyButton, Int`Post your reply.`, event => this.postReply(event));
305
+ clickTip(replyAttachButton, Int`Attach a file to your reply.`, event => { resetInactivityTimer(); fileChooser.click(); });
306
+ fileChooser.onchange = event => {
307
+ resetInactivityTimer();
308
+ replyButton.removeAttribute('disabled');
309
+ let filenameDisplay = popupElement.querySelector('.attachment-preview');
310
+ filenameDisplay.textContent = fileChooser.files.length ? (fileChooser.files[0].name || 'camera') : '';
311
+ };
312
+ this.initChangeHashtag(popupElement);
313
+ for (const correspondent of popupElement.querySelectorAll('.correspondent')) {
314
+ const tag = correspondent.dataset.tag;
315
+ const agent = Agent.ensure({tag, region: this.region});
316
+ const isAvatar = correspondent.classList.contains('avatar');
317
+ if (agent.addElement(correspondent, 'mixed', isAvatar ? 'avatar' : 'handle')) {
318
+ const isMine = Agent.isMine(tag);
319
+ clickTip(correspondent, isMine ?
320
+ Int`Control how others see me.` :
321
+ Int`Control how this person is labeled on my device.`,
322
+ event => {
323
+ if (isMine) openAbout(event);
324
+ else agent.describe(event);
325
+ });
326
+ }
327
+ }
328
+ for (const deleter of popupElement.querySelectorAll('.reply .attribution > div:last-child md-outlined-icon-button')) {
329
+ clickTip(deleter, Int`Delete your reply.`, event => { // Delete reply.
330
+ consume(event);
331
+ this.deleteReply(event.currentTarget.closest('.reply'));
332
+ });
333
+ }
334
+ for (const downloadable of popupElement.querySelectorAll('[download]')) {
335
+ tooltip(downloadable, Int`Click to download ${downloadable.download}.`);
336
+ }
337
+ const shareable = popupElement.querySelectorAll('.share');
338
+ for (const element of shareable) clickTip(element, element.closest('.reply') ?
339
+ Int`Share though ${osName()} the text and attachments of this reply, with a link to open this alert.` :
340
+ Int`Share through ${osName()} a link to open this alert.`, event => this.share(event));
341
+ }
342
+ initChangeHashtag(someParent) { // Init handler on the menu button, if any, as (re-) init of menu for open popup
343
+ const changeHashtag = someParent.querySelector('.changeHashtag');
344
+ if (!changeHashtag) return;
345
+ const menu = document.getElementById('popoverMenu');
346
+ menu.anchorElement = changeHashtag;
347
+ clickTip(changeHashtag, Int`Change the topic or delete your alert.`, event => {
348
+ consume(event);
349
+ menu.open = !menu.open;
350
+ menu.onclick = consume; // Must be onlick rather than addEventListener.
351
+ const handler = event => {
352
+ menu.removeEventListener('close-menu', handler);
353
+ this.updatePost(event.detail.initiator.dataset.tag);
354
+ };
355
+ menu.addEventListener('close-menu', handler); // Must be addEventListener because there's no onclosemenu.
356
+ });
357
+ }
358
+ static formatAttributionHashtag(agent, hashtag) { // Answer HTML for the hashtag button/display in an a post attribution.
359
+ // It will be either a simple HTML element with pubtag.
360
+ const pubtag = Hashtags.formatPubtag(hashtag);
361
+ if (agent !== Agent.tag) return `<span>${pubtag}</span>`;
362
+
363
+ // ... or an HTML button, with a side-effect of populating the popoverMenu with the choices to display when the button is pressed.
364
+ document.getElementById('popoverMenu').innerHTML = `
365
+ ${Hashtags.getSubscribe().map(tag => `<md-menu-item class:"pubtag-choice" data-tag="${tag}"><div slot="headline">${Hashtags.formatPubtag(tag)}</div></md-menu-item>`).join('')}
366
+ <md-divider></md-divider>
367
+ <md-menu-item data-tag="" class="remove">
368
+ <md-icon slot="end" class="material-icons">delete_forever</md-icon>
369
+ <div slot="headline">${Int`remove`}</div>
370
+ <div slot="supporting-text">${Int`cancel alert`}</div></md-menu-item>
371
+ `;
372
+ return `<md-outlined-button class="changeHashtag">${pubtag}</md-outlined-button>`;
373
+ }
374
+ formatAttributionActions({agent, hashtag}) { // Anser div HTML containing: [deleter] sharer [hashtag]
375
+ // Where deletere appears if it our reply (no hashtag), and hashtag if present is a button if ours (and otherwise just text).
376
+ const isOurs = agent === Agent.tag;
377
+ const deleter = !hashtag && isOurs ? `<md-outlined-icon-button><md-icon class="material-icons">delete_forever</md-icon></md-outlined-icon-button>` : '';
378
+ const pubtag = hashtag ? this.constructor.formatAttributionHashtag(agent, hashtag) : '';
379
+ if (isOurs && !this.items.length) showMessage(Int`Change the topic or remove the alert with the topic button in the upper right of the conversation dialog.`, 'instructions');
380
+ return `<div>${deleter} ${pubtag}</div>`;
381
+ }
382
+ formatAttribution({agent, issuedTime, originalPosting, hashtag = null}) { // Answer HTML for a row of sender/timestamp(s)/[deleter]+sharer+[hashtag]
383
+ const sharer = `<md-outlined-icon-button class="share"><md-icon class="material-icons">ios_share</md-icon></md-outlined-icon-button>`;
384
+ const actions = this.formatAttributionActions({agent, hashtag});
385
+ const dataText = hashtag ? 'data-text=""' : ''; // Used in sharing.
386
+ return `
387
+ <div class="attribution" ${dataText}>
388
+ ${sharer}
389
+ <md-outlined-icon-button class="correspondent avatar" data-tag="${agent}"></md-outlined-icon-button>
390
+ <div class="attribution-metadata">
391
+ <div class="correspondent handle" data-tag="${agent}"></div>
392
+ <div>${new Date(originalPosting || issuedTime).toLocaleString()}</div>
393
+ ${originalPosting ? `<div>${Int`updated`} ${new Date(issuedTime).toLocaleString()}</div>` : ''}
394
+ </div>
395
+ ${actions}
396
+ </div>`;
397
+ }
398
+ updatePost(newTag) { // Republish under a different hashtag, or cancel altogether if no newTag (which is not allowed as a hashtag).
399
+ resetInactivityTimer();
400
+ const {lat, lng, hashtag, subject, issuedTime, originalPosting = issuedTime} = this;
401
+ console.log("updatePost", {newTag, lat, lng, hashtag, subject, issuedTime, originalPosting, self:this});
402
+ if (!newTag) return Alert.publish({lat, lng, subject, originalPosting, hashtag, payload: null, cancel: null}); // Remove post with null payload, cancel.
403
+ if (newTag === hashtag) return this.needsRedisplay = true;
404
+ const cancel = {lat, lng, subject, hashtag}; // Cancel old hashtag as we publish newTag, below.
405
+ Hashtags.setPublish(newTag);
406
+ Hashtags.onchange({redisplaySubscribers: false, resetSubscriptions: false});
407
+ return Alert.publish({lat, lng, hashtag: newTag, originalPosting, cancel}); // Publish new alert w/cancellation.
408
+ }
409
+
410
+ // Each reply is separately published by its author, and only they can modify/unpublish it.
411
+ async ensure(data) { // Add or update reply for this marker.
412
+ data.subject = data.tag; //fixme
413
+ const reply = super.ensure(data);
414
+ if (reply) { // TODO? Move to AlertReply.initialize()?
415
+ const {agent, issuedTime, payload} = data;
416
+ const {file} = payload;
417
+ if (file) {
418
+ data.fileTopic = file;
419
+ const contact = await networkPromise;
420
+ // Before pushing data on to replies.
421
+ const {dataURL, name} = await contact.assembleChunkedDataURL(file);
422
+ payload.file = dataURL;
423
+ payload.name = name;
424
+ }
425
+ const element = this.startFader('.alert-commented', issuedTime + ttl - Date.now());
426
+ element.style.display = 'block';
427
+ // Restart the pulse animation by setting animationName to something it isn't.
428
+ element.style.animationName = element.style.animationName === 'pulse2' ? 'pulse' : 'pulse2';
429
+ this.showNotification({agent, issuedTime, body: payload.message || payload.name || payload});
430
+ }
431
+ this.needsRedisplay = true;
432
+ this.ensureContent();
433
+ }
434
+ async postReply(event) { // Post a reply to this marker's subject, in response to a text-field change event.
435
+ resetInactivityTimer();
436
+ event.stopPropagation();
437
+ const button = event.target;
438
+ const inputElement = button.parentElement;
439
+ let payload = inputElement.value.trim();
440
+ const {subject, hashtag, region} = this;
441
+ const files = inputElement.parentElement.querySelector('input[type="file"]').files;
442
+ if (!payload && !files.length) return;
443
+ inputElement.value = '';
444
+ inputElement.querySelector('md-filled-icon-button').toggleAttribute('disabled', true);
445
+ const contact = await networkPromise;
446
+ if (files.length) {
447
+ const {topic:file} = await contact.chunkifyBlob({blob: files[0], region});
448
+ payload = {message: payload, file};
449
+ }
450
+ await contact.publish({eventName: subject, region, payload}); // Publish the new reply.
451
+ Agent.current.persistPublicMetadata(region);
452
+ }
453
+ deleteReply(replyElement) {
454
+ resetInactivityTimer();
455
+ const {region} = this;
456
+ networkPromise.then(async contact => contact.publish({eventName: this.subject, region, killTag: replyElement.dataset.subject, payload: null}));
457
+ }
458
+ showNotification({issuedTime = this.issuedTime, body = '', agent = this.agent, alert = this.subject, lat = this.lat, lng = this.lng, hashtag = this.hashtag}) {
459
+ // Give OS notification that comes back to here, unless act is us.
460
+ // All notifications on the same alert (e.g., the post and each reply) have the same tag, so OS can collapse them.
461
+ if (agent === Agent.tag || !notificationsAllowed()) return;
462
+ navigator.serviceWorker.ready.then(registration => {
463
+ const timestamp = issuedTime;
464
+ const icon = new URL('./images/civil-defense-192.png', location.href).href;
465
+ const url = getShareableURL(alert, [hashtag]).href; // For opening page when it has been closed.
466
+ const data = {lat, lng, url};
467
+ const options = {icon, timestamp, tag: alert, body, data};
468
+ console.log('showNotification', hashtag, options);
469
+ registration.showNotification(hashtag, options);
470
+ });
471
+ }
472
+ formatReplies() { // Answer HTML for the replies and input box.
473
+ const { items, agent, originalPosting } = this;
474
+ const formatReply = ({subject, payload, ...rest}) => {
475
+ const {message = payload, file, name} = payload;
476
+ let text = message
477
+ .replace(/https?:\/\/\S+\.(mp3|aac|ogg|oga|opus|m4a|m3u8|m3u|mpu|mpd)$/ig, url => `<audio controls src="${url}"></audio>`) // show audio urls as players
478
+ .replace(/https?:\/\/\S+\.(mp4|mov|webm)$/ig, url => `<video controls src="${url}"></video>`) // show video urls as players
479
+ .replace(/(?<!")https?:\/\/\S+/g, url => `<a href="${url}" target="yz.sidebar">${url}</a>`); // show urls as links
480
+ let attachment = '';
481
+ if (file?.startsWith('data:image')) attachment = `<a href="${file}" download="${name}"><img class="attachment" src="${file}"></img></a>`;
482
+ else if (file?.startsWith('data:audio')) attachment = `<a href="${file}" download="${name}"><audio controls class="attachment" src="${file}"></audio></a>`;
483
+ else if (file?.startsWith('data:video')) attachment = `<a href="${file}" download="${name}"><video controls class="attachment" src="${file}"></video></a>`;
484
+ else if (file) attachment = `
485
+ <div class="attachment file">
486
+ <a href="${file}" download="${name}">
487
+ <md-icon class="material-icons">attachment</md-icon>
488
+ ${name}
489
+ </a>
490
+ </div>`;
491
+ const messageDisplay = message ? `<span class="message">${text}</span>` : '';
492
+ let dataAttributes = `data-subject="${subject}" data-text="${message}"`;
493
+ if (file) dataAttributes += ` data-file="${file}" data-name="${name}"`;
494
+ return `<div class="reply" ${dataAttributes}>${this.formatAttribution(rest)}${attachment}${messageDisplay}</div>`;
495
+ };
496
+ const formattedReplies = items.map(formatReply).join('');
497
+ return `
498
+ <div class="replies">${formattedReplies}</div>
499
+ <div class="attachment-preview"></div>
500
+ <md-outlined-text-field class="reply-input" type="textarea" rows="1" label="${Int`reply here`}">
501
+ <md-tonal-icon-button slot="leading-icon">
502
+ <md-icon class="material-icons">attach_file</md-icon>
503
+ </md-tonal-icon-button>
504
+ <md-filled-icon-button disabled slot="trailing-icon">
505
+ <md-icon class="material-icons">send</md-icon>
506
+ </md-filled-icon-button>
507
+ </md-outlined-text-field>
508
+ <input type="file"></input>`;
509
+ }
510
+
511
+ async share(event) { // Share reply or post
512
+ resetInactivityTimer();
513
+ // TODO: Preserve attribution data. Maybe by including the subject reply tag in the url, and metadata in the text?
514
+ const shareable = event.currentTarget.closest('[data-text]');
515
+ const {text, file, name = 'unknown'} = shareable.dataset;
516
+ const {lat, lng} = this;
517
+ console.log('Share', shareable.dataset);
518
+ const url = getShareableURL(this.subject, [this.hashtag]).href;
519
+ let textBase = `New CivilDefense.io alert @${lat},${lng}`;
520
+ const extendedText = text ? `${textBase}\n${text}` : textBase;
521
+ const data = {text: extendedText, url};
522
+ if (file) data.files = [await P2PWebNetwork.dataURL2blob(file, name)];
523
+ share(data);
524
+ }
525
+ startFader(selector, remaining) { // Set up or update fader on the specified marker element, returning that element.
526
+ const { marker } = this;
527
+ const element = marker.getElement().querySelector(selector);
528
+ const fraction = remaining / ttl; // Start at 1 and go to 0, but we may be some way along that.
529
+ const endOpacity = 0.5; // Fully transparent is 0, but that's too hard to see. :-)
530
+ const endGrayscale = 1; // Fully gray.
531
+ let opacity = Math.max(endOpacity, fraction);
532
+ let grayscale = 1 - fraction;
533
+ element.style.filter = `grayscale(${grayscale})`;
534
+ element.style.opacity = opacity;
535
+ // I'd like to let css transitions do the work, but as we zoom, we make different subscriptions and thus start
536
+ // the "same" marker over again. This initial setup clashes with zooming if done with a next-tick step opacity+filter value.
537
+ const interval = 2e3; // Milliseconds / step
538
+ const opacityFade = (endOpacity - opacity) * interval / remaining; // change / step
539
+ const grayscaleFade = (endGrayscale - grayscale) * interval / remaining;
540
+ clearInterval(this[selector]);
541
+ this[selector] = setInterval(() => {
542
+ element.style.filter = `grayscale(${grayscale += grayscaleFade})`;
543
+ element.style.opacity = (opacity += opacityFade);
544
+ }, interval);
545
+ return element;
546
+ }
547
+ destroy() { // Remove this Alert pin entirely.
548
+ clearInterval(this['.alert-pin']);
549
+ clearInterval(this['.alert-commented']);
550
+ clearInterval(this.destroyer);
551
+ this.clearAvatars();
552
+ // Unsubscribe from replies.
553
+ networkPromise?.then(async contact => contact.subscribe({eventName: this.subject, region: this.region, handler: null}));
554
+ this.marker.removeFrom(map);
555
+ super.destroy();
556
+ }
557
+ }