@yz-social/civildefense.io 4.5.22 → 4.5.28
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/README.md +1 -1
- package/package.json +3 -2
- package/public/about/checkpoint.jpg +0 -0
- package/public/about/en.html +13 -6
- package/public/about/es.html +62 -40
- package/public/about/radio-europe.jpg +0 -0
- package/public/about/streets-of-minneapolis.jpg +0 -0
- package/public/about/style.css +1 -0
- package/public/index.html +11 -0
- package/public/javascripts/alert.js +380 -155
- package/public/javascripts/conversation.js +5 -3
- package/public/javascripts/hashtags.js +16 -18
- package/public/javascripts/main.js +12 -0
- package/public/javascripts/p2pWebNetwork.js +4 -2
- package/public/javascripts/protocol.js +10 -8
- package/public/javascripts/pubsub.js +44 -22
- package/public/javascripts/s2.js +34 -18
- package/public/javascripts/translations.js +11 -7
- package/public/javascripts/versions.js +9 -3
- package/public/service-worker.js +5 -2
- package/public/stylesheets/style.css +109 -0
- package/server/app.js +30 -10
- package/server/location.save.json +1 -1
- package/server/websocket.js +8 -5
|
@@ -2,17 +2,20 @@ import * as L from 'leaflet';
|
|
|
2
2
|
import { P2PWebNetwork } from './p2pWebNetwork.js';
|
|
3
3
|
import { Int } from './translations.js';
|
|
4
4
|
import { map, trackMap, showMessage } from './map.js';
|
|
5
|
-
import { networkPromise, resetInactivityTimer, notificationsAllowed, tooltip, clickTip, openAbout, delay, osName } from './main.js';
|
|
5
|
+
import { networkPromise, resetInactivityTimer, notificationsAllowed, tooltip, clickTip, getText, openAbout, delay, osName } from './main.js';
|
|
6
6
|
import { consume } from './display.js';
|
|
7
7
|
import { Hashtags } from './hashtags.js';
|
|
8
8
|
import { Agent } from './agent.js';
|
|
9
9
|
import { Conversation, Reply } from './conversation.js';
|
|
10
|
-
import { alertTopic, topicRegion, topicCell,
|
|
11
|
-
import { getContainingCells, getSubdivision, findCoverCellsByMinMaxLatLng } from './s2.js';
|
|
10
|
+
import { alertTopic, topicRegion, cellHex, topicCell, topicTag } from './versions.js';
|
|
11
|
+
import { getContainingCells, getSmallestCellId, getSubdivision, findCoverCellsByMinMaxLatLng, cellContains, pointFromLatLng, cellFromCellID } from './s2.js';
|
|
12
12
|
const { localStorage, getComputedStyle, URL, URLSearchParams, domtoimage } = globalThis;
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
export function getShareableURL(tag = null, tags = Hashtags.getSubscribe()) { // Answer a url that reflects application state.
|
|
16
|
+
// Regardless of whether an alert tag is given, the receiving user is always given a lat/lng:
|
|
17
|
+
// - To position their map with the same coverage as the sender
|
|
18
|
+
// - In case the alert has expired, rolled over, or included in an aggregate.
|
|
16
19
|
const params = new URLSearchParams(location.search);
|
|
17
20
|
const zoom = map.getZoom();
|
|
18
21
|
const { lat, lng } = map.getCenter();
|
|
@@ -63,7 +66,9 @@ const ttl = 24 * 60 * 60e3; // 24 hours
|
|
|
63
66
|
let openOnReceive = null;
|
|
64
67
|
export function go({lat = null, lng = null, zoom = null, alert = null}) { // Go to specified location (if any) and open marker (if any).
|
|
65
68
|
if (lat !== null && lng !== null) {
|
|
66
|
-
|
|
69
|
+
lat = parseFloat(lat);
|
|
70
|
+
lng = parseFloat(lng);
|
|
71
|
+
if (zoom) map.flyTo({lat, lng}, parseFloat(zoom));
|
|
67
72
|
else map.flyTo({lat, lng});
|
|
68
73
|
}
|
|
69
74
|
openOnReceive = null;
|
|
@@ -102,13 +107,294 @@ class AlertReply extends Reply {
|
|
|
102
107
|
|
|
103
108
|
import { s2 } from 's2js';
|
|
104
109
|
export class Alert extends Conversation { // A wrapper around L.marker
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
110
|
+
// For each hashtag, we subscribe to a set of non-overlapping cells at varying S2 levels that cover the current map.
|
|
111
|
+
// An Alert is made when a subscription handler fires, and it keeps information that is (mostly) true regardless of
|
|
112
|
+
// which cell brought it in. E.g., each alert has a bookkeeping tag that identifies that map-click, which is
|
|
113
|
+
// the same at all S2 levels, and forms the topic for replies to that alert. When the map changes and the subscriptions
|
|
114
|
+
// are updated, we try to re-use the same Alerts so that the markers don't flash and we don't create garbage.
|
|
115
|
+
|
|
116
|
+
// INSTANCE MANAGEMENT
|
|
117
|
+
// When there are "too many" instances in a cell (at any level), we replace the individual Alerts with a single larger aggregate.
|
|
118
|
+
// This allows us to handle any any quantity of Alerts in memory and visual clutter, and to not mislead users in the presence
|
|
119
|
+
// of publication topic "rollover". However, it greatly complicates insance management.
|
|
120
|
+
|
|
121
|
+
// Conversation.ensure is the subscription handler, and it keeps track of the instances by tag, initializing a new one if needed.
|
|
122
|
+
initialize({topic, payload, hashtag, tag, agent, issuedTime, ...rest}) { // Make appropriate instance for a new individual tag, or update aggregate.
|
|
123
|
+
|
|
124
|
+
const eventName = topic.name;
|
|
125
|
+
const subscriptions = this.constructor.subscriptions;
|
|
126
|
+
if (!Hashtags.isSubscribed(hashtag)) return null; // A subscribed event may have been in flight while unsubscribing. Caller destroys instance.
|
|
127
|
+
if (!subscriptions.hasOwnProperty(eventName)) return null; // An event may have been in flight while zooming or panning.
|
|
128
|
+
const now = Date.now(),
|
|
129
|
+
expiration = issuedTime + ttl,
|
|
130
|
+
remaining = expiration - now;
|
|
131
|
+
if (remaining < 0) return null; // Network shouldn't send us expired, but if it does, let its instance be destroyed.
|
|
132
|
+
|
|
133
|
+
let keep = this; // Instance to be kept as marker.
|
|
134
|
+
let aggregate = this.constructor.getAggregate(eventName); // If we already have one for this eventName
|
|
135
|
+
|
|
136
|
+
// Each new initialization gets counted, which may be more than the network rollover.
|
|
137
|
+
// We do not "back up" for deletions and expirations - i.e., subtract and possibly go back to individual alerts.
|
|
138
|
+
// Note that initialize() will not be called for an event handler that has a tag (msgId) the same as one we have already seen for a different cell scale.
|
|
139
|
+
subscriptions[eventName]++;
|
|
140
|
+
|
|
141
|
+
if (aggregate) {
|
|
142
|
+
keep = null; // This new instance is superfluous. Tell ensure() to destroy it...
|
|
143
|
+
aggregate.lerp(eventName, payload.lat, payload.lng); // ...but nudge the existing aggregate towards us.
|
|
144
|
+
} else { // Does not exist yet
|
|
145
|
+
let {lat, lng, originalPosting} = payload;
|
|
146
|
+
lat = parseFloat(lat);
|
|
147
|
+
lng = parseFloat(lng);
|
|
148
|
+
if (this.constructor.cellCountOverLimit(eventName)) aggregate = this; // If now over, treat this marker as an aggregate.
|
|
149
|
+
const icon = this.constructor.makeIcon(hashtag, tag, aggregate);
|
|
150
|
+
const marker = this.marker = L.marker([lat, lng], {icon, autoPan: false}).addTo(map);
|
|
151
|
+
const region = P2PWebNetwork.regionCode(lat, lng);
|
|
152
|
+
hashtag = Hashtags.add(hashtag); // We already have it and are subscribing, but this updates our extended form if needed.
|
|
153
|
+
super.initialize({payload, hashtag, tag, agent, lat, lng, issuedTime, originalPosting, ...rest});
|
|
154
|
+
if (aggregate) {
|
|
155
|
+
// Destroy existing eventName markers and add their positions to the aggregate we are creating.
|
|
156
|
+
this.becomeAggregate(eventName);
|
|
157
|
+
this.constructor.clearEventMarkers(eventName, aggregate);
|
|
158
|
+
} else {
|
|
159
|
+
this.noteEventName(eventName);
|
|
160
|
+
marker.bindPopup('', {className: 'alert'}).on('popupopen', event => this.ensureContent(event.popup));
|
|
161
|
+
tooltip(marker.getElement(), Int`Show conversation for this ${hashtag} alert.`);
|
|
162
|
+
if (tag === openOnReceive) { // Bug! How can we handle URLs to an alert that has been aggregated?
|
|
163
|
+
openOnReceive = false;
|
|
164
|
+
this.openPopup();
|
|
165
|
+
}
|
|
166
|
+
networkPromise.then(async contact => { // Subscribe to replies to this tag, now that we have an alert for them to go to.
|
|
167
|
+
contact.subscribe({eventName: tag, region, handler: data => this.ensure(data)});
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const alert = aggregate || this;
|
|
172
|
+
alert.startExpiration('.alert-pin', remaining);
|
|
173
|
+
alert.showNotification({agent, issuedTime});
|
|
174
|
+
return keep;
|
|
175
|
+
}
|
|
176
|
+
update({topic, ts, ...rest}) { // Called when handling an existing Conversation. super confirms that nothing immutable has changed.
|
|
177
|
+
return super.update({...rest}); // topic and ts vary with level, and so must not be part of ensure/update checks.
|
|
178
|
+
}
|
|
179
|
+
async destroy(markerDelayMS = 400) { // Remove this Alert pin entirely, either through unpublish, expiration, or conversion of a cell to aggregate.
|
|
180
|
+
// We do not decrement Alert.subscriptions[this.eventName] and unaggregate into individual markers.
|
|
181
|
+
// That won't happen until the user completely unsubscribes from this cell and resubscribes (by toggle or map movement).
|
|
182
|
+
clearInterval(this['.alert-pin']);
|
|
183
|
+
clearInterval(this['.alert-commented']);
|
|
184
|
+
clearInterval(this.destroyer);
|
|
185
|
+
this.clearAvatars();
|
|
186
|
+
const {isAggregate, marker, tag, region} = this;
|
|
187
|
+
// Unsubscribe from replies.
|
|
188
|
+
if (!isAggregate) networkPromise?.then(async contact => contact.subscribe({eventName: tag, region, handler: null}));
|
|
189
|
+
this.cellBorder?.removeFrom(map);
|
|
190
|
+
super.destroy();
|
|
191
|
+
if (markerDelayMS) {
|
|
192
|
+
marker.closePopup();
|
|
193
|
+
marker.unbindPopup(); // It would be confusing if it happens to be open, or clicked on while being removed.
|
|
194
|
+
marker.setOpacity(0);
|
|
195
|
+
await P2PWebNetwork.delay(markerDelayMS);
|
|
196
|
+
}
|
|
197
|
+
marker.removeFrom(map);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
static subscriptionQueue = Promise.resolve(); // Serialize updates so they don't overlap each other.
|
|
201
|
+
static async updateSubscriptions({newKeys, oldKeys, throttleMS = 20} = {}) { // Update current subscriptions.
|
|
202
|
+
// A value of {} passed for oldKeys is used to start things off fresh (i.e., without supressing subscription of any carry-overs).
|
|
203
|
+
return this.subscriptionQueue = this.subscriptionQueue.then(async () => {
|
|
204
|
+
oldKeys ||= this.subscriptions;
|
|
205
|
+
newKeys ||= this.subscriptionFromMap();
|
|
206
|
+
|
|
207
|
+
if (!newKeys) return; // e.g., wacky computation. Don't change anything.
|
|
208
|
+
const contact = await networkPromise;
|
|
209
|
+
const dropped = [], added = [];
|
|
210
|
+
if (!contact) { console.warn("No network through which to subscribe."); return; } // Does this ever happen? Why?
|
|
211
|
+
this.subscriptions = newKeys; // Before subscribing.
|
|
212
|
+
const subscribe = async (eventName, handler) => {
|
|
213
|
+
if (!eventName) console.log('sub to no eventName', {oldKeys, newKeys, dropped, added, handler});
|
|
214
|
+
const region = topicRegion(eventName);
|
|
215
|
+
if (handler) Agent.current?.trackPublicChanges(region); // Background. No need to await.
|
|
216
|
+
await contact.subscribe({eventName, region, handler}).then(() => throttleMS && P2PWebNetwork.delay(throttleMS));
|
|
217
|
+
};
|
|
218
|
+
for (const key in newKeys) oldKeys.hasOwnProperty(key) || added.push(key);
|
|
219
|
+
for (const key in oldKeys) newKeys.hasOwnProperty(key) || dropped.push(key);
|
|
220
|
+
console.log('updating subscriptions', {added, dropped, newKeys, oldKeys});
|
|
221
|
+
|
|
222
|
+
// Before subscribing, as that that may bring in an alert with the same tag as one being cleared.
|
|
223
|
+
if (this.aggregateLimit) {
|
|
224
|
+
// TODO: more efficient way?
|
|
225
|
+
new Set(added.map(topicTag)).forEach(tag => {
|
|
226
|
+
const hasTag = topicName => topicTag(topicName) === tag;
|
|
227
|
+
this.transferOrClearEventMarkers(added.filter(hasTag), dropped.filter(hasTag), newKeys, oldKeys);
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
for (const key of added) await subscribe(key, data => Alert.ensure(data));
|
|
232
|
+
for (const key of dropped) await subscribe(key, null);
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Instance Management Internals
|
|
237
|
+
// In general here, a key is an eventName - i.e., a string <mumble>:<cellID>:<hashtag>.
|
|
238
|
+
// newKeys/oldKeys are a map of the currently subscribed eventName and the count of events for that cell+hashtag
|
|
108
239
|
static subscriptions = {}; // maps currently active eventNames (<mumble>:<cellID>:<hashtag>) to count of event received for it.
|
|
240
|
+
static aggregateLimit = parseInt(new URLSearchParams(location.search).get('max') || 100); // How many individuals are allowed before we aggregate.
|
|
241
|
+
static MAX_MAP_ZOOM = 19; // If the map is already zoomed to this, there's no place else to go.
|
|
242
|
+
static cellCountOverLimit(eventName, countsDictionary = this.subscriptions) { // Have we received enough that we must show an aggregate?
|
|
243
|
+
return (countsDictionary[eventName] >= this.aggregateLimit) && (map.getZoom() < this.MAX_MAP_ZOOM);
|
|
244
|
+
}
|
|
245
|
+
static getAggregate(eventName) { // Answer the existing aggregate for this cell, if any.
|
|
246
|
+
return this.getItem(eventName); // While individual Alerts are stored in items/conversations by tag, the tag for aggregate is the eventName.
|
|
247
|
+
}
|
|
248
|
+
logAlert(label = '') { // For debugging, when an individual or aggregate marker is clicked, show the alert, cell, and count in console.
|
|
249
|
+
const {lat, lng, tag, eventName} = this;
|
|
250
|
+
console.warn(`${label} lat: ${lat}, lng: ${lng}, ${eventName}: ${this.constructor.subscriptions[eventName]}, ${tag}`);
|
|
251
|
+
}
|
|
252
|
+
noteEventName(eventName) { // Be a part of the specified grouping.
|
|
253
|
+
if (this.eventName === eventName) return;
|
|
254
|
+
this.eventName = eventName;
|
|
255
|
+
if (!this.isAggregate) return;
|
|
256
|
+
this.cellBorder?.removeFrom(map);
|
|
257
|
+
const cell = cellFromCellID(topicCell(eventName));
|
|
258
|
+
const radiansToDegrees = 180 / Math.PI;
|
|
259
|
+
const corners = Array.from({ length: 4 }, (_, i) => {
|
|
260
|
+
const point = cell.vertex(i);
|
|
261
|
+
const latLng = s2.LatLng.fromPoint(point);
|
|
262
|
+
return [latLng.lat * radiansToDegrees, latLng.lng * radiansToDegrees];
|
|
263
|
+
});
|
|
264
|
+
const border = this.cellBorder = L.polygon(corners, {color: this.constructor.md_sys_color_secondary, fillOpacity: 0.1});
|
|
265
|
+
border.addTo(map);
|
|
266
|
+
}
|
|
267
|
+
static md_sys_color_secondary = '#BD2E2F';
|
|
268
|
+
static forEachAlertOf(eventName, callback, alerts = this.items) { // Apply callback to each of alerts matching eventName.
|
|
269
|
+
// TODO? Record a cell's Alert's more efficiently, so that we don't have to cycle through everything.
|
|
270
|
+
alerts.forEach((alert, index, alerts) => (alert.eventName === eventName) && callback(alert, index, alerts));
|
|
271
|
+
}
|
|
272
|
+
static clearEventMarkers(eventName, aggregate = null, alerts = this.items) { // destroy each, but
|
|
273
|
+
// if aggregate is specified, keep the aggregate and average all position into the aggregate.
|
|
274
|
+
if (!aggregate) return this.forEachAlertOf(eventName, alert => alert.destroy(), alerts);
|
|
275
|
+
const eachExceptAggregate = cb => this.forEachAlertOf(eventName, alert => (alert === aggregate) || cb(alert), alerts);
|
|
276
|
+
let lat = aggregate.lat, lng = aggregate.lng, count = 1;
|
|
277
|
+
if (aggregate.eventName != eventName) { // Loading from another eventName. Keep existing weight.
|
|
278
|
+
count = this.subscriptions[aggregate.eventName];
|
|
279
|
+
lat *= count;
|
|
280
|
+
lng *= count;
|
|
281
|
+
}
|
|
282
|
+
eachExceptAggregate(alert => { lat += alert.lat; lng += alert.lng; count++; });
|
|
283
|
+
lat /= count;
|
|
284
|
+
lng /= count;
|
|
285
|
+
return this.forEachAlertOf(eventName, alert => {
|
|
286
|
+
alert.reposition(lat, lng);
|
|
287
|
+
if (alert !== aggregate) alert.destroy(400); // May or may not be default. Half the translation transition time.
|
|
288
|
+
}, alerts);
|
|
289
|
+
}
|
|
290
|
+
becomeAggregate(eventName) { // Make an individual alert be an aggregate
|
|
291
|
+
const {tag, marker, hashtag, region} = this;
|
|
292
|
+
const element = marker.getElement();
|
|
293
|
+
const pin = element.querySelector('.alert-pin');
|
|
294
|
+
this.isAggregate = true;
|
|
295
|
+
pin.classList.toggle('aggregate', true); pin.classList.toggle('starting', true);
|
|
296
|
+
setTimeout(() => pin.classList.toggle('starting', false), 100);
|
|
297
|
+
marker.unbindPopup();
|
|
298
|
+
marker.off('click');
|
|
299
|
+
marker.on('click', event => {
|
|
300
|
+
this.logAlert();
|
|
301
|
+
this.constructor.zoomOn(this.lat, this.lng, hashtag);
|
|
302
|
+
});
|
|
303
|
+
tooltip(element, Int`Zoom in on multiple alerts in this area.`);
|
|
304
|
+
this.noteEventName(this.tag = eventName);
|
|
305
|
+
if (tag !== eventName) {
|
|
306
|
+
networkPromise.then(async contact => contact.subscribe({eventName: tag, region, handler: null})); // Unsubscribe from replies.
|
|
307
|
+
this.items = [];
|
|
308
|
+
clearInterval(this['.alert-commented']);
|
|
309
|
+
element.querySelector('.alert-commented').style = "opacity: 0;";
|
|
310
|
+
this.constructor.removeItem(tag);
|
|
311
|
+
}
|
|
312
|
+
this.constructor.setItem(eventName, this);
|
|
313
|
+
}
|
|
314
|
+
static handleAggregation(droppedEventName, addedEventName, newCounts, alerts) { // Return aggregate if over limit, setting it up if necessary. Else null.
|
|
315
|
+
// If over limit, converts an addedEventName Alert if needed and clears the rest, and then clears all droppedEventName Alerts.
|
|
316
|
+
if (!this.cellCountOverLimit(addedEventName, newCounts)) return null;
|
|
317
|
+
let aggregate = this.getAggregate(addedEventName);
|
|
318
|
+
if (!aggregate) { // If no existing aggregate:
|
|
319
|
+
aggregate = alerts.find(alert => alert.eventName === droppedEventName); // Pick a dropped individual to become aggregate. There will be one, by construction.
|
|
320
|
+
aggregate.becomeAggregate(addedEventName);
|
|
321
|
+
this.clearEventMarkers(addedEventName, aggregate, alerts); // Kill the rest, adding their positions to the aggregate.
|
|
322
|
+
}
|
|
323
|
+
return aggregate;
|
|
324
|
+
}
|
|
325
|
+
static transferOrClearEventMarkers(added, dropped, newCounts, oldCounts) { // Clear dropped alerts as necessary,
|
|
326
|
+
// but try to preserve (individual and aggregated) alerts so that the markers don't flash.
|
|
327
|
+
const addedCells = added.map(topicCell); // List of BigInt, in same order as added eventNames.
|
|
328
|
+
const alerts = this.items;
|
|
329
|
+
dropped.sort((a, b) => oldCounts[b] - oldCounts[a]); // So that we always process aggregates before related individuals.
|
|
330
|
+
dropped.forEach(droppedEventName => {
|
|
331
|
+
const droppedCell = topicCell(droppedEventName);
|
|
332
|
+
// Whether zooming in or out, each dropped cell may have added cells that contain it, or vice versa.
|
|
333
|
+
// We don't have to worry about cells that are neither added nor dropped, because those will not overlap added or dropped.
|
|
334
|
+
|
|
335
|
+
// Find the one added cell (if any) that contains the dropped cell. (Typically when zooming out.)
|
|
336
|
+
const containerIndex = addedCells.findIndex(added => cellContains(added, droppedCell));
|
|
337
|
+
if (containerIndex >= 0) {
|
|
338
|
+
const addedContainingEventName = added[containerIndex];
|
|
339
|
+
newCounts[addedContainingEventName] += oldCounts[droppedEventName]; // It all goes to the container, which may have already started filling.
|
|
340
|
+
const aggregate = this.handleAggregation(droppedEventName, addedContainingEventName, newCounts, alerts);
|
|
341
|
+
if (aggregate) {
|
|
342
|
+
this.clearEventMarkers(droppedEventName, aggregate, alerts); // Absorb the discarded. Their distinctiveness will be added to our own.
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
this.forEachAlertOf(droppedEventName, alert => alert.noteEventName(addedContainingEventName)); // Label the dropped individual alerts for new container.
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const aggregate = this.getAggregate(droppedEventName);
|
|
350
|
+
if (aggregate) { // We don't know how mucch of this will be included in any added subregion, and cannot reconstruct where they were.
|
|
351
|
+
aggregate.destroy();
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Find the 0-4 added cells that are contained by the dropped cell. (Typically when zooming in.)
|
|
356
|
+
const addedSubCells = addedCells.filter(added => cellContains(droppedCell, added));
|
|
357
|
+
// We don't know how many oldCounts to distribute to each subcell, so we have to work with each dropped alert's location.
|
|
358
|
+
if (addedSubCells.length) {
|
|
359
|
+
const addedS2Cells = addedSubCells.map(cellFromCellID);
|
|
360
|
+
this.forEachAlertOf(droppedEventName, alert => {
|
|
361
|
+
const point = pointFromLatLng(alert.lat, alert.lng);
|
|
362
|
+
const addedS2Cell = addedS2Cells.find(cell => cell.containsPoint(point));
|
|
363
|
+
if (addedS2Cell) {
|
|
364
|
+
const includedIndex = addedCells.indexOf(addedS2Cell.id);
|
|
365
|
+
const addedIncludedEventName = added[includedIndex];
|
|
366
|
+
newCounts[addedIncludedEventName] += 1;
|
|
367
|
+
if (this.handleAggregation(droppedEventName, addedIncludedEventName, newCounts, alerts)) return;
|
|
368
|
+
alert.noteEventName(addedIncludedEventName); // Leave this alert in place, but label where it belongs.
|
|
369
|
+
} else {
|
|
370
|
+
alert.destroy(); // This alert is not within an added subcell.
|
|
371
|
+
}
|
|
372
|
+
}, alerts);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Otherwise, no overlap, so clear the (individual and aggregate) markers of the dropped cell.
|
|
377
|
+
this.clearEventMarkers(droppedEventName);
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
lerp(eventName, additionaLatitude, additionalLongitude) { // Move this Alert towards this location, inversely weight by count in cell.
|
|
381
|
+
const count = this.constructor.subscriptions[eventName];
|
|
382
|
+
const k = 1 / count;
|
|
383
|
+
const k1 = 1 - k;
|
|
384
|
+
const {lat, lng} = this;
|
|
385
|
+
this.reposition(k1 * lat + k * additionaLatitude,
|
|
386
|
+
k1 * lng + k * additionalLongitude);
|
|
387
|
+
}
|
|
388
|
+
reposition(lat, lng) { // Update the marker's position for new data.
|
|
389
|
+
this.lat = lat;
|
|
390
|
+
this.lng = lng;
|
|
391
|
+
this.marker.setLatLng([lat, lng]);
|
|
392
|
+
this.startExpiration('.alert-pin', Date.now() + ttl);
|
|
393
|
+
}
|
|
394
|
+
|
|
109
395
|
// We do not record exactly where you were looking across sessions, but we do record the containing level 9 cell.
|
|
110
396
|
static lastLevel9Cell = null; // S2 level 9 cells average a radius of about 10km ~ 6.5 miles.
|
|
111
|
-
static subscriptionFromMap() { // Generate {eventName => count} for current map bounds.
|
|
397
|
+
static subscriptionFromMap() { // Generate new subscriptions list ({eventName => count}) for current map bounds.
|
|
112
398
|
const center = map.getCenter();
|
|
113
399
|
const bounds = map.getBounds();
|
|
114
400
|
const northEast = bounds.getNorthEast();
|
|
@@ -123,84 +409,54 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
123
409
|
});
|
|
124
410
|
if (!newCells) return null;
|
|
125
411
|
const newKeys = {};
|
|
126
|
-
|
|
127
|
-
newCells.forEach(cell => Hashtags.getSubscribe().forEach(hash => {
|
|
412
|
+
newCells.forEach(cell => Hashtags.getSubscribe().forEach(hash => { // Populate count with existing count (exctly carried over cell sizes), else 0.
|
|
128
413
|
const eventName = alertTopic(cell, hash);
|
|
129
414
|
newKeys[eventName] = this.subscriptions[eventName] || 0;
|
|
130
415
|
}));
|
|
131
416
|
// Record a zoomed-out cell id in case next session does not have geolocation services.
|
|
132
|
-
let level9Cell =
|
|
417
|
+
let level9Cell = getSmallestCellId(center.lat, center.lng, 9);
|
|
133
418
|
if (level9Cell !== this.lastLevel9Cell) localStorage.setItem('level9Cell', this.lastLevel9Cell = level9Cell);
|
|
134
419
|
return newKeys;
|
|
135
420
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
this.subscriptions = newKeys; // Before subscribing.
|
|
146
|
-
const subscribe = async (key, handlerIn) => {
|
|
147
|
-
const handler = handlerIn && (properties => {
|
|
148
|
-
// When there are enough alerts within a topic, it rolls over and just reports the most recent number.
|
|
149
|
-
// When get near that count, we update the subscriptions such that the overloading topic is replaced
|
|
150
|
-
// with the topics for each of the four subcells that that make up the one with too many alerts.
|
|
151
|
-
// This repeats until we reach cells that are not rolling over.
|
|
152
|
-
const maxAlertsInCell = 900;
|
|
153
|
-
let count = ++newKeys[key];
|
|
154
|
-
handlerIn(properties);
|
|
155
|
-
if (count >= maxAlertsInCell) {
|
|
156
|
-
let nextKeys = {};
|
|
157
|
-
for (const old in newKeys) {
|
|
158
|
-
if (old !== key) {
|
|
159
|
-
nextKeys[old] = newKeys[old];
|
|
160
|
-
} else {
|
|
161
|
-
const cellTag = topicCell(key);
|
|
162
|
-
const subdivisions = getSubdivision(cellTag);
|
|
163
|
-
const topics = subdivisions.map(cellTag => alertTopic(cellTag, properties.hashtag));
|
|
164
|
-
console.log('subdividing', key, 'into', topics);
|
|
165
|
-
topics.forEach(topic => nextKeys[topic] = 0);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
this.updateSubscriptions({oldKeys: newKeys, newKeys: nextKeys, throttleMS});
|
|
169
|
-
}
|
|
170
|
-
});
|
|
171
|
-
const region = topicRegion(key);
|
|
172
|
-
Agent.current?.trackPublicChanges(region); // Background. No need to await.
|
|
173
|
-
await contact.subscribe({eventName: key, region, handler}).then(() => throttleMS && P2PWebNetwork.delay(throttleMS));
|
|
174
|
-
};
|
|
175
|
-
console.log('updating subscriptions', {newKeys, oldKeys});
|
|
176
|
-
// For each entry in the new subscription set that was not previously subscribed, subscribe now.
|
|
177
|
-
for (const key in newKeys) oldKeys.hasOwnProperty(key) || await subscribe(key, data => Alert.ensure(data));
|
|
178
|
-
// For each existing subscription, if it does not appear in the new set then unsubscribe.
|
|
179
|
-
for (const key in oldKeys) newKeys.hasOwnProperty(key) || await subscribe(key, null);
|
|
180
|
-
}
|
|
421
|
+
|
|
422
|
+
// PUBLISHING NEW ALERTS
|
|
423
|
+
// Each map click publishes to that point at each supported S2 level that contains it, so that any map display of
|
|
424
|
+
// non-overlapping cells will catch it. (Network subscriptions are much more expensive than publishing, and the app
|
|
425
|
+
// users watch more than they publish, so we minimize the number of subscriptions that a user has at any moment.)
|
|
426
|
+
|
|
427
|
+
// The app (not the network) restricts the user to publish up to maxPublish alerts over the last maxPublish minutes.
|
|
428
|
+
// I.e., one / minute, but allowing bursts up to maxPublish. After that, one can still publish, but kill the oldest.
|
|
429
|
+
// We keep enough data in memory that we can reproduce what to kill,m even if Alert has since scrolled off the map and been destroyed.
|
|
181
430
|
static maxPublish = 5;
|
|
182
431
|
static publishing = false;
|
|
183
|
-
static lastPublished = []; // Last published lat, lng,
|
|
432
|
+
static lastPublished = []; // Last published lat, lng, hashtag
|
|
184
433
|
// Publish an alert to all applicable eventNames, canceling as required. Promises tag (msgId).
|
|
185
434
|
static async publish({lat, lng,
|
|
186
435
|
originalPosting = undefined,
|
|
187
436
|
hashtag = Hashtags.getPublish(true),
|
|
188
|
-
payload = {lat, lng, originalPosting}, // If payload is null (cancels
|
|
437
|
+
payload = {lat, lng, originalPosting}, // If payload is null (cancels tag), lat & lng are still used to generate eventNames.
|
|
189
438
|
cancel = undefined, // First unpublish the specified data, if any. Complicated default.
|
|
190
|
-
issuedTime = Date.now(),
|
|
439
|
+
issuedTime = Date.now(), tag,
|
|
191
440
|
throttleMS = 0,
|
|
192
441
|
...rest
|
|
193
442
|
}) {
|
|
194
|
-
// We call all the publishing at once and return
|
|
443
|
+
// We call all the publishing at once and return tag, without waiting for each to occur.
|
|
195
444
|
// However, the 'unpublishing' (if any) is invoked first.
|
|
196
445
|
// To do this, we must hash the eventName ourselves.
|
|
197
|
-
//console.log('publish', {lat, lng, hashtag, payload, cancel,
|
|
446
|
+
//console.log('publish', {lat, lng, hashtag, payload, cancel, tag, issuedTime, rest});
|
|
198
447
|
if (this.publishing) { console.log('skiping overlapping publish'); return null; } // do not stack them up.
|
|
199
448
|
try {
|
|
200
449
|
this.publishing = true;
|
|
450
|
+
const cells = getContainingCells(lat, lng);
|
|
451
|
+
const eventNames = cells.map(cell => alertTopic(cell, hashtag));
|
|
452
|
+
if (payload && eventNames.some(eventName => this.getAggregate(eventName))) { // Attemtpt to publish where we are showing an aggregate.
|
|
453
|
+
// There could already be an alert there, so zoom instead.
|
|
454
|
+
this.zoomOn(lat, lng, hashtag);
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
201
457
|
|
|
202
458
|
const contact = await networkPromise; // subtle: The rest of this all happens synchronously, with any null payloads definitely first.
|
|
203
|
-
let oldCells = null, oldHash,
|
|
459
|
+
let oldCells = null, oldHash, oldTag = null; // Recorded for logging, below.
|
|
204
460
|
let lastFillIn;
|
|
205
461
|
if (payload) {
|
|
206
462
|
lastFillIn = {lat, lng, hashtag, issuedTime};
|
|
@@ -213,55 +469,57 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
213
469
|
}
|
|
214
470
|
}
|
|
215
471
|
if (cancel) {
|
|
216
|
-
const {lat, lng, hashtag,
|
|
472
|
+
const {lat, lng, hashtag, tag} = cancel;
|
|
217
473
|
oldCells = getContainingCells(lat, lng);
|
|
218
|
-
oldHash = hashtag;
|
|
474
|
+
oldHash = hashtag; oldTag = tag;
|
|
219
475
|
const region = P2PWebNetwork.regionCode(lat, lng);
|
|
220
476
|
for (const cell of oldCells) {
|
|
221
477
|
const eventName = alertTopic(cell, hashtag);
|
|
222
478
|
// Note: we cannot unpublish replies by others, but they expire after a while anyway.
|
|
223
|
-
await contact.publish({eventName, region, killTag:
|
|
479
|
+
await contact.publish({eventName, region, killTag: tag, payload: null});
|
|
224
480
|
throttleMS && await P2PWebNetwork.delay(throttleMS);
|
|
225
481
|
}
|
|
226
482
|
}
|
|
227
483
|
|
|
228
|
-
const cells = getContainingCells(lat, lng);
|
|
229
484
|
const region = P2PWebNetwork.regionCode(lat, lng);
|
|
230
|
-
for (const
|
|
231
|
-
const eventName = alertTopic(cell, hashtag);
|
|
485
|
+
for (const eventName of eventNames) {
|
|
232
486
|
if (payload) {
|
|
233
487
|
// The Axona message will be {hashtag, issuedTime, payload:{lat, lng, originalPosting}}
|
|
234
488
|
// and when combined with the publisher's authorId will be unique to this user/time/hashtag,
|
|
235
489
|
// and yet the same for each of the individual publications at the different s2 scales.
|
|
236
490
|
const msgId = await contact.publish({eventName, region, payload, issuedTime, hashtag, ...rest});
|
|
237
|
-
if (
|
|
238
|
-
|
|
491
|
+
if (tag && tag !== msgId) throw new Error(`msgId is drifting: ${tag} => ${msgId}`);
|
|
492
|
+
tag = msgId;
|
|
239
493
|
if (lastFillIn) {
|
|
240
|
-
lastFillIn.
|
|
494
|
+
lastFillIn.tag = tag;
|
|
241
495
|
lastFillIn = null;
|
|
242
496
|
}
|
|
243
497
|
} else {
|
|
244
|
-
await contact.publish({eventName, region, killTag:
|
|
498
|
+
await contact.publish({eventName, region, killTag: tag, payload: null});
|
|
245
499
|
throttleMS && await P2PWebNetwork.delay(throttleMS);
|
|
246
500
|
}
|
|
247
501
|
}
|
|
248
502
|
if (!payload) {
|
|
249
|
-
const index = this.lastPublished.findIndex(past => past.
|
|
503
|
+
const index = this.lastPublished.findIndex(past => past.tag === tag);
|
|
250
504
|
if (index >= 0) this.lastPublished.splice(index, 1);
|
|
251
505
|
}
|
|
252
|
-
console.log('Published', {cells, n: cells.length, region, hashtag,
|
|
253
|
-
return
|
|
506
|
+
console.log('Published', {cells, n: cells.length, region, hashtag, tag, payload, oldCells, oldHash, oldTag});
|
|
507
|
+
return tag;
|
|
254
508
|
} finally {
|
|
255
509
|
this.publishing = false;
|
|
256
510
|
}
|
|
257
511
|
}
|
|
512
|
+
static zoomOn(lat, lng, hashtag = '', zoom = map.getZoom() + 1) {
|
|
513
|
+
if (hashtag) showMessage(Int`There are already at least ${this.aggregateLimit} alerts of type "${hashtag}" in the colored region. Zooming in to get a better look.`);
|
|
514
|
+
map.setZoomAround([lat, lng], zoom);
|
|
515
|
+
}
|
|
258
516
|
|
|
259
517
|
static noMessage = Int`No additional information.`;
|
|
260
518
|
static closePopup() { // Close any open popup.
|
|
261
519
|
map.closePopup();
|
|
262
520
|
Hashtags.closeSelector();
|
|
263
521
|
}
|
|
264
|
-
static openPopup(alertTag) { // Open the marker specified by
|
|
522
|
+
static openPopup(alertTag) { // Open the marker specified by tag.
|
|
265
523
|
const wrapper = this.getItem(alertTag);
|
|
266
524
|
wrapper?.openPopup() || (openOnReceive = alertTag);
|
|
267
525
|
}
|
|
@@ -274,9 +532,10 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
274
532
|
}
|
|
275
533
|
this.marker.openPopup();
|
|
276
534
|
}
|
|
277
|
-
static makeIcon(hashtag) { // Return a Leaflet icon.
|
|
535
|
+
static makeIcon(hashtag, tag, isAggregate = false) { // Return a Leaflet icon.
|
|
536
|
+
// tag is handy for debugging. TODO: are these cacheable and reusable?
|
|
278
537
|
return L.divIcon({
|
|
279
|
-
html: `<div class="alert-commented"></div><div class="alert-pin">${Hashtags.formatAlert(hashtag)}</div>`,
|
|
538
|
+
html: `<div class="alert-commented"></div><div class="alert-pin${isAggregate ? ' aggregate' : ''}" data-debug="${tag}">${Hashtags.formatAlert(hashtag)}</div>`,
|
|
280
539
|
iconSize: [40, 40],
|
|
281
540
|
popupAnchor: [0, 0],
|
|
282
541
|
className: 'alert-marker'
|
|
@@ -286,12 +545,12 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
286
545
|
this.items.forEach(wrapper => {
|
|
287
546
|
const { hashtag, marker, agent } = wrapper;
|
|
288
547
|
if (hashtag !== canonicalHashtag) return;
|
|
289
|
-
const newIcon = this.makeIcon(extendedHashtag);
|
|
290
|
-
const popup = marker.getPopup();
|
|
548
|
+
const newIcon = this.makeIcon(extendedHashtag, wrapper.tag);
|
|
291
549
|
marker.setIcon(newIcon);
|
|
292
550
|
wrapper.hashtag = extendedHashtag;
|
|
293
551
|
wrapper.needsRedisplay = true; // See comment for initializeHandlers. We need to clear and rebuild content on re-open.
|
|
294
|
-
|
|
552
|
+
const popup = marker.getPopup();
|
|
553
|
+
if (!popup?.isOpen()) return; // Either an aggregate or closed.
|
|
295
554
|
// Fix what's showing now without flashing everything. Make sure menu works.
|
|
296
555
|
const popupAttribution = popup.getElement().querySelector('.attribution');
|
|
297
556
|
const attributionActions = popupAttribution.lastElementChild;
|
|
@@ -300,45 +559,12 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
300
559
|
wrapper.initChangeHashtag(popupAttribution);
|
|
301
560
|
});
|
|
302
561
|
}
|
|
303
|
-
static async ensure({tag, topic, ts, issuedTime, ...rest}) { // Add marker at position with appropriate fade if not already present.
|
|
304
|
-
const alert = await super.ensure({tag, subject: tag, issuedTime, ...rest}); // Does not include topic or ts. fixme: get rid of subject. fixme: why is ts different?
|
|
305
|
-
if (!alert) return null;
|
|
306
|
-
// Regardless of initialize vs update, reset fader.
|
|
307
|
-
const now = Date.now(),
|
|
308
|
-
expiration = issuedTime + ttl,
|
|
309
|
-
remaining = expiration - now;
|
|
310
|
-
if (remaining < 0) return alert?.destroy(); // Expired.
|
|
311
|
-
alert.startFader('.alert-pin', remaining); // From the new value of remaining, after marker is set in wrapper, regardless of popup/dirty state.
|
|
312
|
-
alert.destroyer = setTimeout(() => alert.destroy(), remaining);
|
|
313
|
-
return alert;
|
|
314
|
-
}
|
|
315
|
-
initialize({payload, hashtag, subject, agent, issuedTime, ...rest}) { // Set up the marker for a newly received alert.
|
|
316
|
-
if (!payload) return null; // Do not cache. E.g., Received a delete event without the initial creation.
|
|
317
|
-
if (!Hashtags.isSubscribed(hashtag)) return null; // A subscribed event may have been in flight while unsubscribing.
|
|
318
|
-
const icon = this.constructor.makeIcon(hashtag);
|
|
319
|
-
const {lat, lng, originalPosting} = payload;
|
|
320
|
-
const marker = this.marker = L.marker([lat, lng], {icon, autoPan: false}).addTo(map);
|
|
321
|
-
const region = P2PWebNetwork.regionCode(lat, lng);
|
|
322
|
-
hashtag = Hashtags.add(hashtag); // We already have it and are subscribing, but this updates our extended form if needed.
|
|
323
|
-
super.initialize({payload, hashtag, subject, agent, lat, lng, issuedTime, originalPosting, ...rest});
|
|
324
|
-
|
|
325
|
-
marker.bindPopup('', {className: 'alert'}).on('popupopen', event => this.ensureContent(event.popup));
|
|
326
|
-
tooltip(marker.getElement(), Int`Show conversation for this ${hashtag} alert.`);
|
|
327
|
-
if (subject === openOnReceive) {
|
|
328
|
-
openOnReceive = false;
|
|
329
|
-
this.openPopup();
|
|
330
|
-
}
|
|
331
|
-
// Subscribe to replies to this subject, now that we're set up to receive them.
|
|
332
|
-
networkPromise.then(async contact => {
|
|
333
|
-
contact.subscribe({eventName: subject, region, handler: data => this.ensure(data)});
|
|
334
|
-
});
|
|
335
|
-
this.showNotification({agent, issuedTime});
|
|
336
|
-
return this;
|
|
337
|
-
}
|
|
338
562
|
|
|
339
563
|
needsRedisplay = true;
|
|
340
564
|
ensureContent(popup = this.marker.getPopup()) { // Set content and handlers in popup if/as needed.
|
|
565
|
+
if (!popup) return;
|
|
341
566
|
if (!popup.isOpen()) return;
|
|
567
|
+
this.logAlert();
|
|
342
568
|
if (!this.needsRedisplay) {
|
|
343
569
|
this.initializeHandlers(popup);
|
|
344
570
|
return;
|
|
@@ -352,8 +578,14 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
352
578
|
delay(100).then(() => {
|
|
353
579
|
this.marker.getPopup().update();
|
|
354
580
|
this.initializeHandlers(popup);
|
|
581
|
+
this.teach('firstConversation');
|
|
582
|
+
if (Agent.isMine(this.agent)) this.teach('firstPublish');
|
|
355
583
|
});
|
|
356
|
-
|
|
584
|
+
}
|
|
585
|
+
teach(classname) {
|
|
586
|
+
if (localStorage.getItem(classname)) return;
|
|
587
|
+
localStorage.setItem(classname, '1');
|
|
588
|
+
document.body.classList.toggle(classname, true);
|
|
357
589
|
}
|
|
358
590
|
clearAvatars(popup = this.marker?.getPopup()) {
|
|
359
591
|
popup?.getElement()?.querySelectorAll('.correspondent[data-tag]')
|
|
@@ -365,6 +597,7 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
365
597
|
const replyButton = replyInput.querySelector('md-filled-icon-button');
|
|
366
598
|
const replyAttachButton = replyInput.querySelector('md-tonal-icon-button');
|
|
367
599
|
const fileChooser = popupElement.querySelector('input[type="file"]');
|
|
600
|
+
replyInput.onfocus = event => resetInactivityTimer();
|
|
368
601
|
replyInput.oninput = event => {
|
|
369
602
|
replyButton.removeAttribute('disabled');
|
|
370
603
|
const input = event.currentTarget;
|
|
@@ -373,7 +606,7 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
373
606
|
input.rows = internalHighWater;
|
|
374
607
|
};
|
|
375
608
|
clickTip(replyButton, Int`Post your reply.`, event => this.postReply(event));
|
|
376
|
-
clickTip(replyAttachButton,
|
|
609
|
+
clickTip(replyAttachButton, getText('.teach.attach'), event => { resetInactivityTimer(); fileChooser.click(); });
|
|
377
610
|
fileChooser.onchange = event => {
|
|
378
611
|
resetInactivityTimer();
|
|
379
612
|
replyButton.removeAttribute('disabled');
|
|
@@ -388,9 +621,7 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
388
621
|
const isAvatar = correspondent.classList.contains('avatar');
|
|
389
622
|
if (agent.addElement(correspondent, 'mixed', isAvatar ? 'avatar' : 'handle')) {
|
|
390
623
|
const isMine = Agent.isMine(tag);
|
|
391
|
-
clickTip(correspondent, isMine ?
|
|
392
|
-
Int`Control how others see me.` :
|
|
393
|
-
Int`Control how this person is labeled on my device.`,
|
|
624
|
+
clickTip(correspondent, getText((isMine ? '.firstPublish' : '.firstConversation') + ' .teach.correspondent'),
|
|
394
625
|
event => {
|
|
395
626
|
if (isMine) openAbout(event);
|
|
396
627
|
else agent.describe(event);
|
|
@@ -409,20 +640,20 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
409
640
|
const shareable = popupElement.querySelectorAll('.share');
|
|
410
641
|
for (const element of shareable) clickTip(element, element.closest('.reply') ?
|
|
411
642
|
Int`Share though ${osName()} the text and attachments of this reply, with a link to open this alert.` :
|
|
412
|
-
|
|
643
|
+
getText('.teach.share'), event => this.share(event));
|
|
413
644
|
}
|
|
414
645
|
initChangeHashtag(someParent) { // Init handler on the menu button, if any, as (re-) init of menu for open popup
|
|
415
646
|
const changeHashtag = someParent.querySelector('.changeHashtag');
|
|
416
647
|
if (!changeHashtag) return;
|
|
417
648
|
const menu = document.getElementById('popoverMenu');
|
|
418
649
|
menu.anchorElement = changeHashtag;
|
|
419
|
-
clickTip(changeHashtag,
|
|
650
|
+
clickTip(changeHashtag, getText('.teach.changeHashtag'), event => {
|
|
420
651
|
consume(event);
|
|
421
652
|
menu.open = !menu.open;
|
|
422
653
|
menu.onclick = consume; // Must be onlick rather than addEventListener.
|
|
423
654
|
const handler = event => {
|
|
424
655
|
menu.removeEventListener('close-menu', handler);
|
|
425
|
-
this.updatePost(event.detail.initiator.dataset.tag);
|
|
656
|
+
this.updatePost(event.detail.initiator.dataset.tag); // initiator is a hashtag menu item and dataset.tag is a hashtag.
|
|
426
657
|
};
|
|
427
658
|
menu.addEventListener('close-menu', handler); // Must be addEventListener because there's no onclosemenu.
|
|
428
659
|
});
|
|
@@ -467,16 +698,16 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
467
698
|
${actions}
|
|
468
699
|
</div>`;
|
|
469
700
|
}
|
|
470
|
-
updatePost(
|
|
701
|
+
updatePost(newHashtag) { // Republish under a different hashtag, or cancel altogether if no newHashtag (which is not allowed as a hashtag).
|
|
471
702
|
resetInactivityTimer();
|
|
472
|
-
const {lat, lng, hashtag,
|
|
473
|
-
console.log("updatePost", {
|
|
474
|
-
if (!
|
|
475
|
-
if (
|
|
476
|
-
const cancel = {lat, lng,
|
|
477
|
-
Hashtags.setPublish(
|
|
703
|
+
const {lat, lng, hashtag, tag, issuedTime, originalPosting = issuedTime} = this;
|
|
704
|
+
console.log("updatePost", {newHashtag, lat, lng, hashtag, tag, issuedTime, originalPosting, self:this});
|
|
705
|
+
if (!newHashtag) return Alert.publish({lat, lng, tag, originalPosting, hashtag, payload: null, cancel: null}); // Remove post with null payload, cancel.
|
|
706
|
+
if (newHashtag === hashtag) return this.needsRedisplay = true;
|
|
707
|
+
const cancel = {lat, lng, tag, hashtag}; // Cancel old hashtag as we publish newHashtag, below.
|
|
708
|
+
Hashtags.setPublish(newHashtag);
|
|
478
709
|
Hashtags.onchange({redisplaySubscribers: false, resetSubscriptions: false});
|
|
479
|
-
return Alert.publish({lat, lng, hashtag:
|
|
710
|
+
return Alert.publish({lat, lng, hashtag: newHashtag, originalPosting, cancel}); // Publish new alert w/cancellation.
|
|
480
711
|
}
|
|
481
712
|
|
|
482
713
|
// Each reply is separately published by its author, and only they can modify/unpublish it.
|
|
@@ -484,7 +715,6 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
484
715
|
return AlertReply;
|
|
485
716
|
}
|
|
486
717
|
async ensure(data) { // Add or update reply for this reply.
|
|
487
|
-
data.subject = data.tag; //fixme
|
|
488
718
|
const remaining = data.issuedTime + ttl - Date.now();
|
|
489
719
|
if (remaining < 0) return null;
|
|
490
720
|
const reply = await super.ensure(data);
|
|
@@ -507,13 +737,13 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
507
737
|
this.ensureContent();
|
|
508
738
|
return reply;
|
|
509
739
|
}
|
|
510
|
-
async postReply(event) { // Post a reply to this marker's
|
|
740
|
+
async postReply(event) { // Post a reply to this marker's tag, in response to a text-field change event.
|
|
511
741
|
resetInactivityTimer();
|
|
512
742
|
event.stopPropagation();
|
|
513
743
|
const button = event.target;
|
|
514
744
|
const inputElement = button.parentElement;
|
|
515
745
|
let payload = inputElement.value.trim();
|
|
516
|
-
const {
|
|
746
|
+
const {tag, hashtag, lat, lng} = this;
|
|
517
747
|
const region = P2PWebNetwork.regionCode(lat, lng);
|
|
518
748
|
const files = inputElement.parentElement.querySelector('input[type="file"]').files;
|
|
519
749
|
if (!payload && !files.length) return;
|
|
@@ -524,17 +754,17 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
524
754
|
const {topic:file, msgIds} = await contact.chunkifyBlob({blob: files[0], region});
|
|
525
755
|
payload = {message: payload, file};
|
|
526
756
|
}
|
|
527
|
-
await contact.publish({eventName:
|
|
757
|
+
await contact.publish({eventName: tag, region, payload}); // Publish the new reply.
|
|
528
758
|
Agent.current.persistPublicMetadata();
|
|
529
759
|
}
|
|
530
760
|
deleteReply(replyElement) {
|
|
531
761
|
resetInactivityTimer();
|
|
532
|
-
const {lat, lng,
|
|
762
|
+
const {lat, lng, tag} = this;
|
|
533
763
|
const region = P2PWebNetwork.regionCode(lat, lng);
|
|
534
|
-
const killTag = replyElement.dataset.
|
|
764
|
+
const killTag = replyElement.dataset.tag;
|
|
535
765
|
networkPromise.then(async contact => {
|
|
536
766
|
// We won't be here unless we are the signer.
|
|
537
|
-
await contact.publish({eventName:
|
|
767
|
+
await contact.publish({eventName: tag, region, killTag, payload: null});
|
|
538
768
|
// IFF there's an attachment AND we're given msgIds by receiveChunkedBytes, then delete the attachment.
|
|
539
769
|
const reply = this.getItem(killTag);
|
|
540
770
|
const {attachmentTopic, msgIds = []} = reply?.payload || {};
|
|
@@ -546,7 +776,7 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
546
776
|
}
|
|
547
777
|
});
|
|
548
778
|
}
|
|
549
|
-
showNotification({issuedTime = this.issuedTime, body = '', agent = this.agent, alert = this.
|
|
779
|
+
showNotification({issuedTime = this.issuedTime, body = '', agent = this.agent, alert = this.tag, lat = this.lat, lng = this.lng, hashtag = this.hashtag}) {
|
|
550
780
|
// Give OS notification that comes back to here, unless act is us.
|
|
551
781
|
// All notifications on the same alert (e.g., the post and each reply) have the same tag, so OS can collapse them.
|
|
552
782
|
if (agent === Agent.tag || !notificationsAllowed()) return;
|
|
@@ -564,12 +794,12 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
564
794
|
registration.showNotification(hashtag, options);
|
|
565
795
|
});
|
|
566
796
|
}
|
|
567
|
-
// Each reply element is a DIV.reply with data-
|
|
797
|
+
// Each reply element is a DIV.reply with data-tag and data-text attributes that are used in sharing.
|
|
568
798
|
// It contains an attribution header with controls, zero or one attachments, and then the message text.
|
|
569
799
|
// If present the attachment will be an A element with download attribute, surrounding either an IMG, A/V player, or an attachment icon followed by the file name.
|
|
570
800
|
formatReplies() { // Answer HTML for the replies and input box.
|
|
571
801
|
const { items, agent, originalPosting } = this;
|
|
572
|
-
const formatReply = ({
|
|
802
|
+
const formatReply = ({tag, payload, ...rest}) => {
|
|
573
803
|
const {message = payload, file, name} = payload || {}; // Message text converts recognized urls to A/V players or links.
|
|
574
804
|
let text = message
|
|
575
805
|
.replace(/https?:\/\/\S+\.(mp3|aac|ogg|oga|opus|m4a|m3u8|m3u|mpu|mpd)$/ig, url => `<audio controls src="${url}" crossorigin="anonymous"></audio>`) // show audio urls as players
|
|
@@ -587,7 +817,7 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
587
817
|
</a>
|
|
588
818
|
</div>`;
|
|
589
819
|
const messageDisplay = message ? `<span class="message">${text}</span>` : '';
|
|
590
|
-
let dataAttributes = `data-
|
|
820
|
+
let dataAttributes = `data-tag="${tag}" data-text="${message}"`;
|
|
591
821
|
if (file) dataAttributes += ` data-file="${file}" data-name="${name}"`;
|
|
592
822
|
return `<div class="reply" ${dataAttributes}>${this.formatAttribution(rest)}${attachment}${messageDisplay}</div>`;
|
|
593
823
|
};
|
|
@@ -608,18 +838,23 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
608
838
|
|
|
609
839
|
async share(event) { // Share reply or post
|
|
610
840
|
resetInactivityTimer();
|
|
611
|
-
// TODO: Preserve attribution data. Maybe by including the
|
|
841
|
+
// TODO: Preserve attribution data. Maybe by including the tag reply tag in the url, and metadata in the text?
|
|
612
842
|
const shareable = event.currentTarget.closest('[data-text]');
|
|
613
843
|
const {text, file, name = 'unknown'} = shareable.dataset;
|
|
614
844
|
const {lat, lng} = this;
|
|
615
845
|
console.log('Share', shareable.dataset);
|
|
616
|
-
const url = getShareableURL(this.
|
|
846
|
+
const url = getShareableURL(this.tag, [this.hashtag]).href;
|
|
617
847
|
let textBase = `New CivilDefense.io alert @${lat},${lng}`;
|
|
618
848
|
const extendedText = text ? `${textBase}\n${text}` : textBase;
|
|
619
849
|
const data = {text: extendedText, url};
|
|
620
850
|
if (file) data.files = [await P2PWebNetwork.dataURL2blob(file, name)];
|
|
621
851
|
share(data);
|
|
622
852
|
}
|
|
853
|
+
startExpiration(selector, remaining) { // Setup or update fader and destroy-on-expiration.
|
|
854
|
+
clearTimeout(this.destroyer);
|
|
855
|
+
this.destroyer = setTimeout(() => this.destroy(), remaining);
|
|
856
|
+
this.startFader(selector, remaining);
|
|
857
|
+
}
|
|
623
858
|
startFader(selector, remaining) { // Set up or update fader on the specified marker element, returning that element.
|
|
624
859
|
const { marker } = this;
|
|
625
860
|
const markerElement = marker.getElement();
|
|
@@ -644,15 +879,5 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
644
879
|
}, interval);
|
|
645
880
|
return element;
|
|
646
881
|
}
|
|
647
|
-
destroy() { // Remove this Alert pin entirely.
|
|
648
|
-
clearInterval(this['.alert-pin']);
|
|
649
|
-
clearInterval(this['.alert-commented']);
|
|
650
|
-
clearInterval(this.destroyer);
|
|
651
|
-
this.clearAvatars();
|
|
652
|
-
// Unsubscribe from replies.
|
|
653
|
-
networkPromise?.then(async contact => contact.subscribe({eventName: this.subject, region: this.region, handler: null}));
|
|
654
|
-
this.marker.removeFrom(map);
|
|
655
|
-
super.destroy();
|
|
656
|
-
}
|
|
657
882
|
}
|
|
658
883
|
globalThis.Alert = Alert; // for debugging
|