@yz-social/civildefense.io 4.4.3 → 4.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/public/index.html +2 -5
- package/public/javascripts/alert.js +556 -0
- package/public/javascripts/conversation.js +100 -0
- package/public/javascripts/hashtags.js +10 -9
- package/public/javascripts/main.js +4 -3
- package/public/javascripts/map.js +8 -572
- package/public/javascripts/p2pWebNetwork.js +36 -57
- package/public/javascripts/service-manager.js +8 -7
- package/public/javascripts/translations.js +1 -1
- package/public/service-worker.js +5 -2
- package/server/app.js +10 -10
- package/server/getLocation.js +1 -1
- package/server/location.json +2 -2
- package/spec/axonSpec.js +23 -68
- package/spec/conversationSpec.js +116 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// TODO:
|
|
2
|
+
// - clarify common lifecycle management, including existing reply to not get re-initialized
|
|
3
|
+
// - In Alert, get rid of subject (just use tag)
|
|
4
|
+
// - key off 'deleted' instead of payload:null, and bring handling here
|
|
5
|
+
// - Bring pub/sub here, customized for SpatialConversation subclass
|
|
6
|
+
// - Agents
|
|
7
|
+
// - Document lifecycle and subclass requirements
|
|
8
|
+
|
|
9
|
+
export class Tagged { // Maintains cached existence within a (possibly instance-specific) container
|
|
10
|
+
|
|
11
|
+
// These next three instance methods are not generally called directly, but are here for extending by subclasses.
|
|
12
|
+
initialize({...properties} = {}) { // Initialization of a new object. (Includes tag.) Must return this, or null to not cache.
|
|
13
|
+
// Application subclass will typically extend this with UI initialization
|
|
14
|
+
Object.assign(this, properties);
|
|
15
|
+
return this;
|
|
16
|
+
}
|
|
17
|
+
update({...properties} = {}) { // Re-initialize an existing object with properties. Must return this, or null to remove item.
|
|
18
|
+
// This version gives error when specified values (if any) do not match existing.
|
|
19
|
+
// Since axona publications are immutable, an update with different values would generally be meaningless.
|
|
20
|
+
for (const key in properties) {
|
|
21
|
+
const existing = this[key];
|
|
22
|
+
const proposed = properties[key];
|
|
23
|
+
if (JSON.stringify(existing) !== JSON.stringify(proposed)) throw new Error(`Cannot update ${key} ${existing} to ${proposed}.`);
|
|
24
|
+
}
|
|
25
|
+
return this;
|
|
26
|
+
}
|
|
27
|
+
destroy() { // Subclasses extend to remove UI.
|
|
28
|
+
// Subclass extensions of ensure may expect this answer falsy, indicating that an item was removed.
|
|
29
|
+
// NOTE: Does not destroy replies, as these may have been published by others.
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
static ensureIn(data, container, kind = container.itemKind) { // update() or initialize() item and remember what those answer. (Falsy is deleted).
|
|
34
|
+
const {tag, payload, ...rest} = data;
|
|
35
|
+
let item = container.getItem(tag);
|
|
36
|
+
if (!payload) return item && container.removeItem(tag)?.destroy();
|
|
37
|
+
if (item) item = item.update(data);
|
|
38
|
+
else item = new kind().initialize(data);
|
|
39
|
+
|
|
40
|
+
if (!item) return container.removeItem(tag)?.destroy();
|
|
41
|
+
return container.setItem(tag, item);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class Reply extends Tagged { // An individual reply to a conversation.
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class Conversation extends Tagged { // A conversation with replies.
|
|
49
|
+
|
|
50
|
+
// get/set/removeItem for replies. The instance maintains the collection as a list.
|
|
51
|
+
items = []; // List of replies, in increasing time order.
|
|
52
|
+
getItem(tag) { // Find the reply if known, else falsy.
|
|
53
|
+
const { items } = this;
|
|
54
|
+
return items.find(reply => reply.tag === tag);
|
|
55
|
+
}
|
|
56
|
+
setItem(tag, item) { // Adds reply to cache, maintaining order. Ignores tag.
|
|
57
|
+
const { items } = this;
|
|
58
|
+
items.push(item);
|
|
59
|
+
items.sort((a, b) => a.issuedTime - b.issuedTime); // In case they arrive out of order. Typically just a check.
|
|
60
|
+
return item;
|
|
61
|
+
}
|
|
62
|
+
removeItem(tag) { // Remove reply from cache.
|
|
63
|
+
const { items } = this;
|
|
64
|
+
return items.splice(items.findIndex(reply => reply.tag === tag), 1)?.[0];
|
|
65
|
+
}
|
|
66
|
+
get itemKind() { // Answer class of reply items.
|
|
67
|
+
return Reply;
|
|
68
|
+
}
|
|
69
|
+
ensure(data) { // Initialize or update Reply from data.
|
|
70
|
+
return this.constructor.ensureIn(data, this);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// get/set/removeItem for conversations. The class maintains the collection as a dictionary.
|
|
74
|
+
// Multiton pattern, with each subclass getting its own dictionary.
|
|
75
|
+
static _conversations = {}; // Maps tag => conversation
|
|
76
|
+
static get conversations() { // Answer conversation dictionary for this specific class.
|
|
77
|
+
if (!Object.hasOwn(this, '_conversations')) this._conversations = {};
|
|
78
|
+
return this._conversations;
|
|
79
|
+
}
|
|
80
|
+
static items() {
|
|
81
|
+
return Object.values(this.conversations);
|
|
82
|
+
}
|
|
83
|
+
static getItem(tag) { // Get conversation if known, else falsy.
|
|
84
|
+
return this.conversations[tag];
|
|
85
|
+
}
|
|
86
|
+
static setItem(tag, item) { // Cache conversation at tag.
|
|
87
|
+
return this.conversations[tag] = item;
|
|
88
|
+
}
|
|
89
|
+
static removeItem(tag) { // Remove conversation from cache.
|
|
90
|
+
let existing = this.getItem(tag);
|
|
91
|
+
delete this._conversations[tag];
|
|
92
|
+
return existing;
|
|
93
|
+
}
|
|
94
|
+
static get itemKind() { // Answer class of Converstion items.
|
|
95
|
+
return this;
|
|
96
|
+
}
|
|
97
|
+
static ensure(data) { // Initialize or update Conversation from data.
|
|
98
|
+
return this.ensureIn(data, this);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
const { localStorage } = globalThis; // For linters.
|
|
2
2
|
import { stripLeadingEmoji, canonicalTag } from './versions.js';
|
|
3
3
|
import { Int } from './translations.js';
|
|
4
|
-
import {
|
|
4
|
+
import { showMessage } from './map.js';
|
|
5
|
+
import { Alert } from './alert.js';
|
|
5
6
|
import { resetInactivityTimer, clickTip } from './main.js';
|
|
6
7
|
|
|
7
8
|
// We subscribe to the cartesian product of the list of non-overlapping cells and all hashes.
|
|
@@ -26,7 +27,7 @@ export const Hashtags = {
|
|
|
26
27
|
// were using the same emoji as each other.
|
|
27
28
|
hashtags: {},
|
|
28
29
|
canonical2extended: {},
|
|
29
|
-
add(label, active = true,
|
|
30
|
+
add(label, active = true, updateAlerts = true) { // Ensure label is a hashtag, initialized to active, and if existing, forcing it active.
|
|
30
31
|
// Return our (possibly new) understanding of the extended hashtag.
|
|
31
32
|
// Note that only startup-population of tags from persistence would ever specify active=false.
|
|
32
33
|
// Here we accept a canonical or extended label, updating our records keyed by the canonical part,
|
|
@@ -44,7 +45,7 @@ export const Hashtags = {
|
|
|
44
45
|
this.canonical2extended[canonical] = extended;
|
|
45
46
|
if (!oursHasEmoji) {
|
|
46
47
|
this.onchange({resetSubscriptions: false});
|
|
47
|
-
if (
|
|
48
|
+
if (updateAlerts) Alert.updateAlerts(canonical, extended);
|
|
48
49
|
}
|
|
49
50
|
return extended;
|
|
50
51
|
},
|
|
@@ -70,7 +71,7 @@ export const Hashtags = {
|
|
|
70
71
|
// Unneeded and not necessarilly meaningful if tag has emoji.
|
|
71
72
|
return `<minidenticon-svg ${slot ? `slot="${slot}"` : ''} username="${tag}"></minidenticon-svg>`;
|
|
72
73
|
},
|
|
73
|
-
|
|
74
|
+
formatAlert(tag) { // HTML (possibly text) to represent tag as a marker on map.
|
|
74
75
|
return this.firstEmoji(tag) || this.identicon(tag);
|
|
75
76
|
},
|
|
76
77
|
formatPubtag(tag) { // HTML (possibly text) to represent tag with defaulted icon.
|
|
@@ -82,8 +83,8 @@ export const Hashtags = {
|
|
|
82
83
|
if (redisplaySubscribers) this.resetSubscriberDisplay();
|
|
83
84
|
localStorage.setItem('hashtags', JSON.stringify(this.hashtags));
|
|
84
85
|
if (resetSubscriptions) {
|
|
85
|
-
updateSubscriptions();
|
|
86
|
-
|
|
86
|
+
Alert.updateSubscriptions();
|
|
87
|
+
Alert.items.forEach(wrapper => this.hashtags[wrapper.hashtag] || wrapper.destroy());
|
|
87
88
|
}
|
|
88
89
|
},
|
|
89
90
|
chipset: document.body.querySelector('.watching-hashtags'), // Element containing the user's chips.
|
|
@@ -132,7 +133,7 @@ export const Hashtags = {
|
|
|
132
133
|
if (chip.selected) showMessage(Int`Turning "${chip.label}" alerts back on in the map.`, 'instructions');
|
|
133
134
|
else showMessage(Int`Turning off "${chip.label}" alerts in the map. You can delete the topic altogether with the X.`, 'instructions');
|
|
134
135
|
this.toggleChip(chip);
|
|
135
|
-
|
|
136
|
+
Alert.closePopup();
|
|
136
137
|
if (chip.selected) this.setPublish(chip.label);
|
|
137
138
|
this.onchange({redisplaySubscribers: false});
|
|
138
139
|
});
|
|
@@ -141,7 +142,7 @@ export const Hashtags = {
|
|
|
141
142
|
`<md-filled-text-field class="newtag" placeholder="➕${Int`add topic`}"></md-filled-text-field>`);
|
|
142
143
|
clickTip(this.chipset.firstChild, Int`Add a new topic for which the map should show any alerts.`, event => { // Focusing "add topic".
|
|
143
144
|
event.stopPropagation();
|
|
144
|
-
|
|
145
|
+
Alert.closePopup();
|
|
145
146
|
showMessage(Int`Type a new topic name to see any alerts on the map with this topic.`, 'instructions');
|
|
146
147
|
});
|
|
147
148
|
this.chipset.firstChild.onchange = event => { // Add the new hashtag.
|
|
@@ -151,7 +152,7 @@ export const Hashtags = {
|
|
|
151
152
|
.replace(/\s+/g, ' ') // Replace multiple spaces with a single space
|
|
152
153
|
.normalize('NFD'); // Standardize different ways of making accents into decomposed form - but do not remove them.
|
|
153
154
|
if (!tag) return;
|
|
154
|
-
|
|
155
|
+
Alert.closePopup();
|
|
155
156
|
tag = this.add(tag); // Might exist, in which case tag might now be extended.
|
|
156
157
|
this.setPublish(tag);
|
|
157
158
|
this.onchange({highlightPublish: true});
|
|
@@ -5,7 +5,8 @@ import { openDisplay } from './display.js';
|
|
|
5
5
|
import { Agent} from './agent.js';
|
|
6
6
|
import { P2PWebNetwork } from './p2pWebNetwork.js';
|
|
7
7
|
import { getPointInCell } from './s2.js';
|
|
8
|
-
import {
|
|
8
|
+
import { Alert, getShareableURL, share } from './alert.js';
|
|
9
|
+
import { map, showMessage, updateLocation, recenterMap } from './map.js';
|
|
9
10
|
import './service-manager.js'; // Comment this out and kill service-workers for reload-to-get-latest behavior during development.
|
|
10
11
|
|
|
11
12
|
document.getElementById('appVersion').textContent = appVersion;
|
|
@@ -123,7 +124,7 @@ export function openAbout(event) {
|
|
|
123
124
|
noteNotificationPermission(window.Notification?.permission);
|
|
124
125
|
}
|
|
125
126
|
clickTip('#aboutButton', Int`Information about this app, and options to change how you appear to others.`, event => { // open about
|
|
126
|
-
|
|
127
|
+
Alert.closePopup();
|
|
127
128
|
openAbout(event);
|
|
128
129
|
});
|
|
129
130
|
clickTip('#wipe', Int`Wipe from ${osName()} all personal data and source files for this app.`, async event => {
|
|
@@ -242,7 +243,7 @@ function initializeGeolocation(subscribe = false) { // Arrange to constantly upd
|
|
|
242
243
|
if (!subscribeOneShot) return;
|
|
243
244
|
subscribeOneShot = false;
|
|
244
245
|
resetInactivityTimer(false);
|
|
245
|
-
updateSubscriptions([]); // This was for a new node, so supply an empty oldSubscriptions.
|
|
246
|
+
Alert.updateSubscriptions([]); // This was for a new node, so supply an empty oldSubscriptions.
|
|
246
247
|
};
|
|
247
248
|
if (!geolocation) {
|
|
248
249
|
showMessage(Int`Geolocation not supported. Using default location.`, 'error', 'fail');
|