@yz-social/civildefense.io 4.4.4 → 4.5.4

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.4",
5
5
  "keywords": [
6
6
  "map",
7
7
  "browser",
@@ -16,7 +16,7 @@
16
16
  ".": "./index.js"
17
17
  },
18
18
  "dependencies": {
19
- "@axona/protocol": "github:axona-net/axona-protocol#semver:^4.18.2",
19
+ "@axona/protocol": "github:axona-net/axona-protocol#semver:^4.22.1",
20
20
  "cors": "^2.8.6",
21
21
  "express": "^4.22.1",
22
22
  "leaflet": "^1.9.4",
@@ -0,0 +1,556 @@
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(subject = 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 (subject !== null) params.set('sub', subject);
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, subject = 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 (subject) {
71
+ Alert.openPopup(subject) || (openOnReceive = subject);
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, subject
81
+ const maxPublish = 5;
82
+ // Publish an alert to all applicable eventNames, canceling as required. Promises subject (msgId).
83
+ let publishing = false;
84
+
85
+ export class Alert extends Conversation { // A wrapper around L.marker
86
+ // When we resubscribe to different cells covering the same place, we will get the same
87
+ // sticky data. We don't want to change the marker. Fortunately, the publication to each
88
+ // of the cells (at different scales) are all published with the same data.
89
+ static updateSubscriptions(oldKeys = subscriptions, newKeys) { // Update current subscriptions to the new map bounds.
90
+ // A value of [] passed for oldKeys is used to start things off fresh (i.e., without supressing subscription of any carry-overs).
91
+ if (!networkPromise) { console.warn("No network through which to subscribe."); return; } // Does this ever happen? Why?
92
+ let region;
93
+ if (!newKeys) { // None specified. Compute them.
94
+ const center = map.getCenter();
95
+ const bounds = map.getBounds();
96
+ const northEast = bounds.getNorthEast();
97
+ const newCells = findCoverCellsByCenterAndPoint(center.lat, center.lng, northEast.lat, northEast.lng); // array of cell IDs (BigInts)
98
+ region = P2PWebNetwork.regionCode(center.lat, center.lng);
99
+ newKeys = newCells.flatMap(cell => Hashtags.getSubscribe().map(hash => alertTopic(cell, hash)));
100
+ Agent.current.trackPublicChanges(region);
101
+ // Record a zoomed-out cell id in case next session does not have geolocation services.
102
+ let level9Cell = getContainingCells(center.lat, center.lng)[9];
103
+ if (level9Cell !== lastLevel9Cell) localStorage.setItem('level9Cell', lastLevel9Cell = level9Cell);
104
+ }
105
+
106
+ const subscribe = (key, region, handler) =>
107
+ networkPromise.then(async contact => contact.subscribe({eventName: key, region, handler}));
108
+
109
+ // For each entry in the new subscription set that was not previously subscribed, subscribe now.
110
+ for (const key of newKeys) oldKeys.includes(key) || subscribe(key, region, data => Alert.ensure(data));
111
+
112
+ // For each existing subscription, if it does not appear in the new set then unsubscribe.
113
+ for (const key of oldKeys) newKeys.includes(key) || subscribe(key, subscriptionsRegion, null);
114
+ console.log('Subscribed', {newKeys, region, length: newKeys.length, oldKeys, subscriptionsRegion});
115
+
116
+ subscriptions = newKeys;
117
+ subscriptionsRegion = region;
118
+ }
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; } // 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, 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, 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(subject) { // Open the marker specified by subject.
195
+ const wrapper = this.getItem(subject);
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({subject, topic, ts, issuedTime, ...rest}) { // Add marker at position with appropriate fade if not already present.
234
+ const alert = super.ensure({tag: subject, subject, 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.
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({tag: subject, 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.tag = data.subject; //fixme
413
+ const reply = super.ensure(data);
414
+ if (reply) {
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, subject: replyElement.dataset.subject, payload: null}));
457
+ }
458
+ showNotification({issuedTime = this.issuedTime, body = '', agent = this.agent, tag = 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
+ if (agent == Agent.tag || !notificationsAllowed()) return;
461
+ navigator.serviceWorker.ready.then(registration => {
462
+ const timestamp = issuedTime;
463
+ const icon = new URL('./images/civil-defense-192.png', location.href).href;
464
+ const url = getShareableURL(tag, [hashtag]).href; // For opening page when it has been closed.
465
+ const data = {lat, lng, url};
466
+ const options = {icon, timestamp, tag, body, data};
467
+ console.log('showNotification', hashtag, options);
468
+ registration.showNotification(hashtag, options);
469
+ });
470
+ }
471
+ formatReplies() { // Answer HTML for the replies and input box.
472
+ const { items, agent, originalPosting } = this;
473
+ const formatReply = ({subject, payload, ...rest}) => {
474
+ const {message = payload, file, name} = payload;
475
+ let text = message
476
+ .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
477
+ .replace(/https?:\/\/\S+\.(mp4|mov|webm)$/ig, url => `<video controls src="${url}"></video>`) // show video urls as players
478
+ .replace(/(?<!")https?:\/\/\S+/g, url => `<a href="${url}" target="yz.sidebar">${url}</a>`); // show urls as links
479
+ let attachment = '';
480
+ if (file?.startsWith('data:image')) attachment = `<a href="${file}" download="${name}"><img class="attachment" src="${file}"></img></a>`;
481
+ else if (file?.startsWith('data:audio')) attachment = `<a href="${file}" download="${name}"><audio controls class="attachment" src="${file}"></audio></a>`;
482
+ else if (file?.startsWith('data:video')) attachment = `<a href="${file}" download="${name}"><video controls class="attachment" src="${file}"></video></a>`;
483
+ else if (file) attachment = `
484
+ <div class="attachment file">
485
+ <a href="${file}" download="${name}">
486
+ <md-icon class="material-icons">attachment</md-icon>
487
+ ${name}
488
+ </a>
489
+ </div>`;
490
+ const messageDisplay = message ? `<span class="message">${text}</span>` : '';
491
+ let dataAttributes = `data-subject="${subject}" data-text="${message}"`;
492
+ if (file) dataAttributes += ` data-file="${file}" data-name="${name}"`;
493
+ return `<div class="reply" ${dataAttributes}>${this.formatAttribution(rest)}${attachment}${messageDisplay}</div>`;
494
+ };
495
+ const formattedReplies = items.map(formatReply).join('');
496
+ return `
497
+ <div class="replies">${formattedReplies}</div>
498
+ <div class="attachment-preview"></div>
499
+ <md-outlined-text-field class="reply-input" type="textarea" rows="1" label="${Int`reply here`}">
500
+ <md-tonal-icon-button slot="leading-icon">
501
+ <md-icon class="material-icons">attach_file</md-icon>
502
+ </md-tonal-icon-button>
503
+ <md-filled-icon-button disabled slot="trailing-icon">
504
+ <md-icon class="material-icons">send</md-icon>
505
+ </md-filled-icon-button>
506
+ </md-outlined-text-field>
507
+ <input type="file"></input>`;
508
+ }
509
+
510
+ async share(event) { // Share reply or post
511
+ resetInactivityTimer();
512
+ // TODO: Preserve attribution data. Maybe by including the subject reply tag in the url, and metadata in the text?
513
+ const shareable = event.currentTarget.closest('[data-text]');
514
+ const {text, file, name = 'unknown'} = shareable.dataset;
515
+ const {lat, lng} = this;
516
+ console.log('Share', shareable.dataset);
517
+ const url = getShareableURL(this.subject, [this.hashtag]).href;
518
+ let textBase = `New CivilDefense.io alert @${lat},${lng}`;
519
+ const extendedText = text ? `${textBase}\n${text}` : textBase;
520
+ const data = {text: extendedText, url};
521
+ if (file) data.files = [await P2PWebNetwork.dataURL2blob(file, name)];
522
+ share(data);
523
+ }
524
+ startFader(selector, remaining) { // Set up or update fader on the specified marker element, returning that element.
525
+ const { marker } = this;
526
+ const element = marker.getElement().querySelector(selector);
527
+ const fraction = remaining / ttl; // Start at 1 and go to 0, but we may be some way along that.
528
+ const endOpacity = 0.5; // Fully transparent is 0, but that's too hard to see. :-)
529
+ const endGrayscale = 1; // Fully gray.
530
+ let opacity = Math.max(endOpacity, fraction);
531
+ let grayscale = 1 - fraction;
532
+ element.style.filter = `grayscale(${grayscale})`;
533
+ element.style.opacity = opacity;
534
+ // I'd like to let css transitions do the work, but as we zoom, we make different subscriptions and thus start
535
+ // the "same" marker over again. This initial setup clashes with zooming if done with a next-tick step opacity+filter value.
536
+ const interval = 2e3; // Milliseconds / step
537
+ const opacityFade = (endOpacity - opacity) * interval / remaining; // change / step
538
+ const grayscaleFade = (endGrayscale - grayscale) * interval / remaining;
539
+ clearInterval(this[selector]);
540
+ this[selector] = setInterval(() => {
541
+ element.style.filter = `grayscale(${grayscale += grayscaleFade})`;
542
+ element.style.opacity = (opacity += opacityFade);
543
+ }, interval);
544
+ return element;
545
+ }
546
+ destroy() { // Remove this Alert pin entirely.
547
+ clearInterval(this['.alert-pin']);
548
+ clearInterval(this['.alert-commented']);
549
+ clearInterval(this.destroyer);
550
+ this.clearAvatars();
551
+ // Unsubscribe from replies.
552
+ networkPromise?.then(async contact => contact.subscribe({eventName: this.subject, region: this.region, handler: null}));
553
+ this.marker.removeFrom(map);
554
+ super.destroy();
555
+ }
556
+ }
@@ -0,0 +1,100 @@
1
+ // TODO:
2
+ // - clarify common lifecycle management, including existing reply to not get re-initialized
3
+ // - In Alert, get rid of subject (just use tag)
4
+ // - key off 'deleted' instead of payload:null, and bring handling here
5
+ // - Bring pub/sub here, customized for SpatialConversation subclass
6
+ // - Agents
7
+ // - Document lifecycle and subclass requirements
8
+
9
+ export class Tagged { // Maintains cached existence within a (possibly instance-specific) container
10
+
11
+ // These next three instance methods are not generally called directly, but are here for extending by subclasses.
12
+ initialize({...properties} = {}) { // Initialization of a new object. (Includes tag.) Must return this, or null to not cache.
13
+ // Application subclass will typically extend this with UI initialization
14
+ Object.assign(this, properties);
15
+ return this;
16
+ }
17
+ update({...properties} = {}) { // Re-initialize an existing object with properties. Must return this, or null to remove item.
18
+ // This version gives error when specified values (if any) do not match existing.
19
+ // Since axona publications are immutable, an update with different values would generally be meaningless.
20
+ for (const key in properties) {
21
+ const existing = this[key];
22
+ const proposed = properties[key];
23
+ if (JSON.stringify(existing) !== JSON.stringify(proposed)) throw new Error(`Cannot update ${key} ${existing} to ${proposed}.`);
24
+ }
25
+ return this;
26
+ }
27
+ destroy() { // Subclasses extend to remove UI.
28
+ // Subclass extensions of ensure may expect this answer falsy, indicating that an item was removed.
29
+ // NOTE: Does not destroy replies, as these may have been published by others.
30
+ return null;
31
+ }
32
+
33
+ static ensureIn(data, container, kind = container.itemKind) { // update() or initialize() item and remember what those answer. (Falsy is deleted).
34
+ const {tag, payload, ...rest} = data;
35
+ let item = container.getItem(tag);
36
+ if (!payload) return item && container.removeItem(tag)?.destroy();
37
+ if (item) item = item.update(data);
38
+ else item = new kind().initialize(data);
39
+
40
+ if (!item) return container.removeItem(tag)?.destroy();
41
+ return container.setItem(tag, item);
42
+ }
43
+ }
44
+
45
+ export class Reply extends Tagged { // An individual reply to a conversation.
46
+ }
47
+
48
+ export class Conversation extends Tagged { // A conversation with replies.
49
+
50
+ // get/set/removeItem for replies. The instance maintains the collection as a list.
51
+ items = []; // List of replies, in increasing time order.
52
+ getItem(tag) { // Find the reply if known, else falsy.
53
+ const { items } = this;
54
+ return items.find(reply => reply.tag === tag);
55
+ }
56
+ setItem(tag, item) { // Adds reply to cache, maintaining order. Ignores tag.
57
+ const { items } = this;
58
+ items.push(item);
59
+ items.sort((a, b) => a.issuedTime - b.issuedTime); // In case they arrive out of order. Typically just a check.
60
+ return item;
61
+ }
62
+ removeItem(tag) { // Remove reply from cache.
63
+ const { items } = this;
64
+ return items.splice(items.findIndex(reply => reply.tag === tag), 1)?.[0];
65
+ }
66
+ get itemKind() { // Answer class of reply items.
67
+ return Reply;
68
+ }
69
+ ensure(data) { // Initialize or update Reply from data.
70
+ return this.constructor.ensureIn(data, this);
71
+ }
72
+
73
+ // get/set/removeItem for conversations. The class maintains the collection as a dictionary.
74
+ // Multiton pattern, with each subclass getting its own dictionary.
75
+ static _conversations = {}; // Maps tag => conversation
76
+ static get conversations() { // Answer conversation dictionary for this specific class.
77
+ if (!Object.hasOwn(this, '_conversations')) this._conversations = {};
78
+ return this._conversations;
79
+ }
80
+ static items() {
81
+ return Object.values(this.conversations);
82
+ }
83
+ static getItem(tag) { // Get conversation if known, else falsy.
84
+ return this.conversations[tag];
85
+ }
86
+ static setItem(tag, item) { // Cache conversation at tag.
87
+ return this.conversations[tag] = item;
88
+ }
89
+ static removeItem(tag) { // Remove conversation from cache.
90
+ let existing = this.getItem(tag);
91
+ delete this._conversations[tag];
92
+ return existing;
93
+ }
94
+ static get itemKind() { // Answer class of Converstion items.
95
+ return this;
96
+ }
97
+ static ensure(data) { // Initialize or update Conversation from data.
98
+ return this.ensureIn(data, this);
99
+ }
100
+ }