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