@yz-social/civildefense.io 4.4.1

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.
Files changed (77) hide show
  1. package/README.md +68 -0
  2. package/announce.js +24 -0
  3. package/docs/YZ-Brief.pdf +0 -0
  4. package/index.js +2 -0
  5. package/movie/camera.jpg +0 -0
  6. package/movie/movie.js +151 -0
  7. package/movie/script.js +272 -0
  8. package/movie/test.js +2 -0
  9. package/nginx/nginx.conf +83 -0
  10. package/nginx/yz.social +81 -0
  11. package/package.json +34 -0
  12. package/public/.well-known/appspecific/com.chrome.devtools.json +1 -0
  13. package/public/about/CivilDefense.mp4 +0 -0
  14. package/public/about/In case of Nazis, use CivilDefense.io.png +0 -0
  15. package/public/about/broadcast.png +0 -0
  16. package/public/about/civil-defense.png +0 -0
  17. package/public/about/conversation-and-image-high.png +0 -0
  18. package/public/about/conversation-and-image.png +0 -0
  19. package/public/about/en.html +157 -0
  20. package/public/about/es.html +142 -0
  21. package/public/about/flood-fire-ice-high.png +0 -0
  22. package/public/about/flood-fire-ice.png +0 -0
  23. package/public/about/hero-high.png +0 -0
  24. package/public/about/hero.png +0 -0
  25. package/public/about/index.html +8 -0
  26. package/public/about/nazis.png +0 -0
  27. package/public/about/script.js +69 -0
  28. package/public/about/streaming-radio-high.png +0 -0
  29. package/public/about/streaming-radio.png +0 -0
  30. package/public/about/style.css +306 -0
  31. package/public/control-safe-rectangle.html +40 -0
  32. package/public/favicon.ico +0 -0
  33. package/public/images/Achtung.png +0 -0
  34. package/public/images/YZ Owl.png +0 -0
  35. package/public/images/civil-defense-122.png +0 -0
  36. package/public/images/civil-defense-192.png +0 -0
  37. package/public/images/civil-defense-240.png +0 -0
  38. package/public/images/civil-defense-512.png +0 -0
  39. package/public/images/civil-defense.png +0 -0
  40. package/public/images/hero-small.png +0 -0
  41. package/public/images/qr-scan.svg +2 -0
  42. package/public/images/qr.png +0 -0
  43. package/public/images/qr.svg +155 -0
  44. package/public/images/recenter.svg +5 -0
  45. package/public/images/share.png +0 -0
  46. package/public/images/share.svg +2 -0
  47. package/public/index.html +170 -0
  48. package/public/javascripts/agent.js +324 -0
  49. package/public/javascripts/display.js +25 -0
  50. package/public/javascripts/hashtags.js +205 -0
  51. package/public/javascripts/main.js +394 -0
  52. package/public/javascripts/map.js +725 -0
  53. package/public/javascripts/p2pWebNetwork.js +245 -0
  54. package/public/javascripts/s2.js +77 -0
  55. package/public/javascripts/scripting.js +112 -0
  56. package/public/javascripts/service-manager.js +149 -0
  57. package/public/javascripts/translations.js +139 -0
  58. package/public/javascripts/versions.js +23 -0
  59. package/public/manifest.json +25 -0
  60. package/public/owl.ico +0 -0
  61. package/public/platformer.html +52 -0
  62. package/public/robots.txt +6 -0
  63. package/public/service-worker.js +218 -0
  64. package/public/stylesheets/style.css +442 -0
  65. package/routes/index.js +123 -0
  66. package/server/app.js +116 -0
  67. package/server/bridge.js +397 -0
  68. package/server/dirname.js +15 -0
  69. package/server/getLocation.js +18 -0
  70. package/server/identity.js +111 -0
  71. package/server/location.json +12 -0
  72. package/spec/axonSpec.gratuitousNameChangeForSignal +246 -0
  73. package/spec/axonSpec.js +280 -0
  74. package/spec/axonSpec.jsRemoveThePartAfterJS +252 -0
  75. package/spec/civildefenseSpec.js +61 -0
  76. package/spec/pubsubSpec.js +128 -0
  77. package/spec/support/jasmine.mjs +14 -0
@@ -0,0 +1,725 @@
1
+ const { domtoimage, localStorage, URL, File, URLSearchParams, getComputedStyle } = globalThis;
2
+ import * as L from 'leaflet';
3
+ import { Int } from './translations.js';
4
+ import { consume, openDisplay } from './display.js';
5
+ import { alertTopic } from './versions.js';
6
+ import { Agent } from './agent.js';
7
+ import { P2PWebNetwork } from './p2pWebNetwork.js';
8
+ import { networkPromise, resetInactivityTimer, delay, notificationsAllowed, openAbout, clickTip, tooltip, osName } from './main.js';
9
+ import { Hashtags } from './hashtags.js';
10
+ import { getContainingCells, findCoverCellsByCenterAndPoint } from './s2.js';
11
+
12
+ export let map; // Leaflet map object.
13
+ const ttl = 24 * 60 * 60e3; // 24 hours
14
+
15
+ const infoBanner = document.getElementById('info');
16
+ let messageTimeout;
17
+ export function showMessage(message, type = 'loading', errorObject) { // Show loading/instructions/error message.
18
+ if (errorObject || type === 'error' ) console.error(message, errorObject || '');
19
+ else if (message) console.warn(message);
20
+ if (!message) {
21
+ infoBanner.style.display = 'none';
22
+ return;
23
+ }
24
+
25
+ if (infoBanner.style) infoBanner.style = '';
26
+ infoBanner.innerHTML = message;
27
+ const className = `info-banner ${type}`;
28
+ if (infoBanner.className !== className) infoBanner.className = className;
29
+
30
+ if (type === 'instructions') {
31
+ clearTimeout(messageTimeout);
32
+ messageTimeout = setTimeout(() => infoBanner.style.display = 'none', 5e3);
33
+ }
34
+ }
35
+
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 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|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
+ let yourLocation; // marker
599
+ let lastLatitude, lastLongitude;
600
+
601
+ export function updateLocation(lat, lng, zoom, positionLabel) { // initMap if necessary, and set our position.
602
+ //console.log('updateLocation', lat, lng, map, yourLocation);
603
+ // Can't call getCurrentPosition while watching. So set it here for use in recenterMap.
604
+ lastLatitude = lat;
605
+ lastLongitude = lng;
606
+
607
+ if (!map) {
608
+ initMap(lat, lng, zoom, positionLabel);
609
+
610
+ const params = new URL(location).searchParams;
611
+ const tags = params.get('tags');
612
+ const tagsArray = tags?.split(',') || [];
613
+ tagsArray.forEach(tag => Hashtags.add(decodeURIComponent(tag)));
614
+ Hashtags.onchange({resetSubscriptions: false}); // Too early to subscribe, but will be done during initialization.
615
+ go({lat: params.get('lat'), lng: params.get('lng'), zoom: params.get('z'), subject: params.get('sub')});
616
+ // We don't need the query parameters now. Get rid of them. They're annoying.
617
+ const copy = new URL(location);
618
+ const dht = copy.searchParams.get('dht');
619
+ if (copy.searchParams.size > 0) {
620
+ copy.search = '';
621
+ history.replaceState(null, '', copy);
622
+ }
623
+
624
+ return;
625
+ }
626
+ // Otherwise just update the yourLocation marker if appropriate (and not update zoom).
627
+ if (positionLabel) yourLocation.getPopup().setContent(positionLabel);
628
+
629
+ // setLatLng can cause the map to autoPan to put the marker within bounds.
630
+ // It seems like that shouldn't happen with autoPan:false, above, but it does.
631
+ // So let's not even update it if it is outside the displayed area.
632
+ // However, that means we will need to updateLocation from the last position on map moveend.
633
+ if (!map.getBounds().contains(L.latLng(lat, lng))) return;
634
+
635
+ const latLng = [lat, lng];
636
+ setTimeout(() => yourLocation.setLatLng(latLng), 100); // It seems that yourLocation can be set, but not yet ready to be moved?
637
+ }
638
+
639
+ export function recenterMap(event) {
640
+ consume(event);
641
+ Marker.closePopup();
642
+ const latLng = [lastLatitude, lastLongitude];
643
+ map.flyTo(latLng);
644
+ }
645
+
646
+ var trackMap;
647
+
648
+ export function initMap(lat, lng, zoom, positionLabel) { // Set up appropriate zoomed initial map and handlers for this position.
649
+ // Then show initial message and updateSubscriptions.
650
+
651
+ P2PWebNetwork.setSessionRegion({lat, lng});
652
+
653
+ // Map will be centered at the given current location marker, unless overriden by query parameters.
654
+ let center = {lat, lng};
655
+ const queryParameters = new URLSearchParams(location.search);
656
+ if (queryParameters.has('lat')) center.lat = queryParameters.get('lat');
657
+ if (queryParameters.has('lng')) center.lng = queryParameters.get('lng');
658
+ if (queryParameters.has('z')) zoom = queryParameters.get('z');
659
+
660
+ map = L.map('map', { // Ensuring the default values, in case they have changed in some library version.
661
+ worldCopyJump: false,
662
+ center,
663
+ zoom,
664
+ minZoom: 2,
665
+ zoomControl: navigator.maxTouchPoints <= 1, // Only when no multi-touch.
666
+ maxBounds: [[90, 180], [-90, -180]]
667
+ }).stopLocate(); // Just in case some library version initates this.
668
+
669
+ // Add OpenStreetMap tiles
670
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
671
+ attribution: '© OpenStreetMap contributors',
672
+ // Because we have a service worker AND rebuild dom structure within canvas in domtoimage, we need to tell Leaflet to not be opaque.
673
+ crossOrigin: 'anonymous',
674
+ maxZoom: 19
675
+ }).addTo(map);
676
+
677
+ // Add the "About" button. This is incredibly subtle, because we need for the button
678
+ // to be rendered above the map, but below the popups. The Leaflet pupups are in their
679
+ // own stacking context, and there is no way to arrange for some element to be rendered
680
+ // WITHIN some other stacking context. (This makes sense if you think about how to
681
+ // render efficiently.) However, that whole stacking context gets transformed as the
682
+ // map moves around under the viewport. There's no way to position right:10px from the
683
+ // viewport when there's a transform in between you and the viewport. So instead,
684
+ // we handle map 'move' events by adjusting the about container element's style so as
685
+ // to keep it 10px from the right edge of the viewport.
686
+ const subPopoverControls = document.getElementById('subPopoverControls');
687
+ const popupPane = document.querySelector('.leaflet-popup-pane');
688
+ const mapPane = document.querySelector('.leaflet-map-pane');
689
+ trackMap = () => {
690
+ const rect = mapPane.getBoundingClientRect();
691
+ subPopoverControls.style = `left: ${-rect.left}px; top: ${-rect.top}px;`;
692
+ };
693
+ popupPane.parentElement.insertBefore(subPopoverControls, popupPane);
694
+ trackMap();
695
+ map.on('move', trackMap);
696
+
697
+ // Add a marker at user's current location
698
+ L.Icon.Default.prototype.options.crossOrigin = 'anonymous'; // Set default prop, as it is used on next line.
699
+ yourLocation = L.marker([lat, lng], {autoPan: false})
700
+ .addTo(map)
701
+ .bindPopup(positionLabel)
702
+ .openPopup();
703
+ // We close the popup on move, because the map will try to keep an open popup from straddling the bounds,
704
+ // which can be confusing. It also closes when another marker is made, so it's nice to just close it
705
+ // upon interaction.
706
+ map.on('movestart', () => {
707
+ resetInactivityTimer();
708
+ map.closePopup(yourLocation.getPopup());
709
+ });
710
+ map.on('moveend', () => {
711
+ updateSubscriptions();
712
+ updateLocation(lastLatitude, lastLongitude); // Might now be within map.
713
+ });
714
+
715
+ // Add click event to note position
716
+ map.on('click', async function(e) {
717
+ resetInactivityTimer();
718
+ if (document.getElementById('map').querySelector('.leaflet-popup')) return; // Ignore clicks with popup open.
719
+ const { lat, lng } = e.latlng;
720
+ Marker.openPopup(await publishAlert({lat, lng}));
721
+ Agent.current.persistPublicMetadata(P2PWebNetwork.regionCode(lat, lng));
722
+ });
723
+ tooltip('.leaflet-control-zoom-in', Int`Zoom in to show more detail in the map.`);
724
+ tooltip('.leaflet-control-zoom-out', Int`Zoom out to show a larger area in the map.`);
725
+ }