@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.
- package/README.md +68 -0
- package/announce.js +24 -0
- package/docs/YZ-Brief.pdf +0 -0
- package/index.js +2 -0
- package/movie/camera.jpg +0 -0
- package/movie/movie.js +151 -0
- package/movie/script.js +272 -0
- package/movie/test.js +2 -0
- package/nginx/nginx.conf +83 -0
- package/nginx/yz.social +81 -0
- package/package.json +34 -0
- package/public/.well-known/appspecific/com.chrome.devtools.json +1 -0
- package/public/about/CivilDefense.mp4 +0 -0
- package/public/about/In case of Nazis, use CivilDefense.io.png +0 -0
- package/public/about/broadcast.png +0 -0
- package/public/about/civil-defense.png +0 -0
- package/public/about/conversation-and-image-high.png +0 -0
- package/public/about/conversation-and-image.png +0 -0
- package/public/about/en.html +157 -0
- package/public/about/es.html +142 -0
- package/public/about/flood-fire-ice-high.png +0 -0
- package/public/about/flood-fire-ice.png +0 -0
- package/public/about/hero-high.png +0 -0
- package/public/about/hero.png +0 -0
- package/public/about/index.html +8 -0
- package/public/about/nazis.png +0 -0
- package/public/about/script.js +69 -0
- package/public/about/streaming-radio-high.png +0 -0
- package/public/about/streaming-radio.png +0 -0
- package/public/about/style.css +306 -0
- package/public/control-safe-rectangle.html +40 -0
- package/public/favicon.ico +0 -0
- package/public/images/Achtung.png +0 -0
- package/public/images/YZ Owl.png +0 -0
- package/public/images/civil-defense-122.png +0 -0
- package/public/images/civil-defense-192.png +0 -0
- package/public/images/civil-defense-240.png +0 -0
- package/public/images/civil-defense-512.png +0 -0
- package/public/images/civil-defense.png +0 -0
- package/public/images/hero-small.png +0 -0
- package/public/images/qr-scan.svg +2 -0
- package/public/images/qr.png +0 -0
- package/public/images/qr.svg +155 -0
- package/public/images/recenter.svg +5 -0
- package/public/images/share.png +0 -0
- package/public/images/share.svg +2 -0
- package/public/index.html +170 -0
- package/public/javascripts/agent.js +324 -0
- package/public/javascripts/display.js +25 -0
- package/public/javascripts/hashtags.js +205 -0
- package/public/javascripts/main.js +394 -0
- package/public/javascripts/map.js +725 -0
- package/public/javascripts/p2pWebNetwork.js +245 -0
- package/public/javascripts/s2.js +77 -0
- package/public/javascripts/scripting.js +112 -0
- package/public/javascripts/service-manager.js +149 -0
- package/public/javascripts/translations.js +139 -0
- package/public/javascripts/versions.js +23 -0
- package/public/manifest.json +25 -0
- package/public/owl.ico +0 -0
- package/public/platformer.html +52 -0
- package/public/robots.txt +6 -0
- package/public/service-worker.js +218 -0
- package/public/stylesheets/style.css +442 -0
- package/routes/index.js +123 -0
- package/server/app.js +116 -0
- package/server/bridge.js +397 -0
- package/server/dirname.js +15 -0
- package/server/getLocation.js +18 -0
- package/server/identity.js +111 -0
- package/server/location.json +12 -0
- package/spec/axonSpec.gratuitousNameChangeForSignal +246 -0
- package/spec/axonSpec.js +280 -0
- package/spec/axonSpec.jsRemoveThePartAfterJS +252 -0
- package/spec/civildefenseSpec.js +61 -0
- package/spec/pubsubSpec.js +128 -0
- package/spec/support/jasmine.mjs +14 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
const { localStorage } = globalThis; // For linters.
|
|
2
|
+
import { stripLeadingEmoji, canonicalTag } from './versions.js';
|
|
3
|
+
import { Int } from './translations.js';
|
|
4
|
+
import { updateSubscriptions, Marker, showMessage } from './map.js';
|
|
5
|
+
import { resetInactivityTimer, clickTip } from './main.js';
|
|
6
|
+
|
|
7
|
+
// We subscribe to the cartesian product of the list of non-overlapping cells and all hashes.
|
|
8
|
+
// We publish to just the first of these.
|
|
9
|
+
export const Hashtags = {
|
|
10
|
+
|
|
11
|
+
// Unless otherwise noted, a `hashtag` is an extended hashtag that contains a leading emoji if known.
|
|
12
|
+
// That emoji is used as the marker on the map.
|
|
13
|
+
// This full string is what is displayed, and shared with others through publishing and urls.
|
|
14
|
+
//
|
|
15
|
+
// However, there is also a `canonical` form that is used in forming the eventName for pub/sub, in which the leading emoji is stripped.
|
|
16
|
+
// A user might manually type in either form to add a new subscription, and they will receive publications that carry the full string.
|
|
17
|
+
// Any user that does not yet have a leading emoji will display an identicon of the canonical part as the marker,
|
|
18
|
+
// but the first time they get an extended string whose canonical part matches, they will start using that emoji going forward.
|
|
19
|
+
// Thus:
|
|
20
|
+
// 1. A user does not have to type an emoji to add a hashtag subscription.
|
|
21
|
+
// 2. Once a user has an emoji for a given hashtag - either entered by them or learned from others - it will "stick" and not change for this user.
|
|
22
|
+
// 3. But until then, we still have a marker that will automatically pick up the first one it sees from someone else.
|
|
23
|
+
// (The already displayed identicons do not change until the user "opens" a post, and then they all change.
|
|
24
|
+
// Later, we will allow a user to change what they see on their own device.)
|
|
25
|
+
// 4. While an identicon is displayed, it is the same for all markers of this hashtag, regardless of whether the original posters
|
|
26
|
+
// were using the same emoji as each other.
|
|
27
|
+
hashtags: {},
|
|
28
|
+
canonical2extended: {},
|
|
29
|
+
add(label, active = true, updateMarkers = true) { // Ensure label is a hashtag, initialized to active, and if existing, forcing it active.
|
|
30
|
+
// Return our (possibly new) understanding of the extended hashtag.
|
|
31
|
+
// Note that only startup-population of tags from persistence would ever specify active=false.
|
|
32
|
+
// Here we accept a canonical or extended label, updating our records keyed by the canonical part,
|
|
33
|
+
// but if we currently have just a canonical part we update our records to capture the extended.
|
|
34
|
+
// (We do not change the emoji of an existing extended.)
|
|
35
|
+
const canonical = canonicalTag(label); // no emoji, lower case.
|
|
36
|
+
const ourExtended = this.canonical2extended[canonical]; // our current version, if any
|
|
37
|
+
const oursHasEmoji = this.firstEmoji(ourExtended);
|
|
38
|
+
const extended = oursHasEmoji ? ourExtended : label; // full emoji form to use
|
|
39
|
+
if (!oursHasEmoji) {
|
|
40
|
+
active = this.hashtags[canonical] || active;
|
|
41
|
+
delete this.hashtags[ourExtended];
|
|
42
|
+
}
|
|
43
|
+
this.hashtags[extended] ||= active; // If it's 'pub', let it remain so.
|
|
44
|
+
this.canonical2extended[canonical] = extended;
|
|
45
|
+
if (!oursHasEmoji) {
|
|
46
|
+
this.onchange({resetSubscriptions: false});
|
|
47
|
+
if (updateMarkers) Marker.updateMarkers(canonical, extended);
|
|
48
|
+
}
|
|
49
|
+
return extended;
|
|
50
|
+
},
|
|
51
|
+
getAll() { // List of all the user's hashtags.
|
|
52
|
+
return Object.keys(this.hashtags);
|
|
53
|
+
},
|
|
54
|
+
getSubscribe() { // Return a list of the hashtags to which the user intendeds to subscribe.
|
|
55
|
+
return this.getAll().filter(tag => this.hashtags[tag]);
|
|
56
|
+
},
|
|
57
|
+
isPublish(key) {
|
|
58
|
+
return this.hashtags[key] === 'pub';
|
|
59
|
+
},
|
|
60
|
+
getPublish() { // Return the one hashtag to which the user intends to publish.
|
|
61
|
+
return this.getAll().find(key => this.isPublish(key));
|
|
62
|
+
},
|
|
63
|
+
firstEmoji(tag) { // First emoji that appears in string, else falsy.
|
|
64
|
+
// I would prefer that it take just the first emoji, but that doesn't grab double-wide ones
|
|
65
|
+
// such as flags. So instead this will return any leading emoji ending with a space, terminator, or normal character.
|
|
66
|
+
return tag && tag.match(/\p{Emoji}+/u)?.[0];
|
|
67
|
+
|
|
68
|
+
},
|
|
69
|
+
identicon(tag, slot = '') { // HTML for an identicon representing tag.
|
|
70
|
+
// Unneeded and not necessarilly meaningful if tag has emoji.
|
|
71
|
+
return `<minidenticon-svg ${slot ? `slot="${slot}"` : ''} username="${tag}"></minidenticon-svg>`;
|
|
72
|
+
},
|
|
73
|
+
formatMarker(tag) { // HTML (possibly text) to represent tag as a marker on map.
|
|
74
|
+
return this.firstEmoji(tag) || this.identicon(tag);
|
|
75
|
+
},
|
|
76
|
+
formatPubtag(tag) { // HTML (possibly text) to represent tag with defaulted icon.
|
|
77
|
+
const emoji = this.firstEmoji(tag);
|
|
78
|
+
return emoji ? tag : this.identicon(tag) + tag;
|
|
79
|
+
},
|
|
80
|
+
onchange({redisplaySubscribers = true, highlightPublish = false, resetSubscriptions = true} = {}) { // Update and persist internal data, and update visuals.
|
|
81
|
+
// If redisplaySubscribers, the presence/order may have changed.
|
|
82
|
+
if (redisplaySubscribers) this.resetSubscriberDisplay();
|
|
83
|
+
localStorage.setItem('hashtags', JSON.stringify(this.hashtags));
|
|
84
|
+
if (resetSubscriptions) {
|
|
85
|
+
updateSubscriptions();
|
|
86
|
+
Object.values(Marker.markers).forEach(wrapper => this.hashtags[wrapper.hashtag] || wrapper.destroy());
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
chipset: document.body.querySelector('.watching-hashtags'), // Element containing the user's chips.
|
|
90
|
+
chipHTML(label) { // Answer an HTML string to represent label in the chipset.
|
|
91
|
+
const active = this.hashtags[label];
|
|
92
|
+
return `<md-filter-chip label="${label}" elevated removable
|
|
93
|
+
${active === 'pub' ? 'class="pub"' : ''}
|
|
94
|
+
${active ? ' selected' : ''}
|
|
95
|
+
>${this.firstEmoji(label) ? '' : this.identicon(label, 'selected-icon')}
|
|
96
|
+
<md-icon-button slot="remove-trailing-icon" title="fixme help"><md-icon class="material-icons"></md-icon></md-icon-button>
|
|
97
|
+
</md-filter-chip>`;
|
|
98
|
+
},
|
|
99
|
+
resetSubscriberDisplay() { // Lay out all the hashtag chips display, including the input for adding new ones.
|
|
100
|
+
this.chipset.innerHTML = '';
|
|
101
|
+
const tags = this.getAll();
|
|
102
|
+
|
|
103
|
+
// Sort alphabetically, ignoring any leading emoji, as these have unexpected orderings.
|
|
104
|
+
tags.sort((a, b) => stripLeadingEmoji(a).localeCompare(stripLeadingEmoji(b)));
|
|
105
|
+
const reordered = {};
|
|
106
|
+
tags.forEach(tag => reordered[tag] = this.hashtags[tag]);
|
|
107
|
+
this.hashtags = reordered;
|
|
108
|
+
|
|
109
|
+
// Add a chip for each hashtag.
|
|
110
|
+
tags.forEach(label => { // Elements are displayed from the bottom up.
|
|
111
|
+
this.chipset.insertAdjacentHTML("afterbegin", this.chipHTML(label));
|
|
112
|
+
});
|
|
113
|
+
// IWBNI we just added handlers once to the chipset and relied on bubbling up, but there's something not working about that.
|
|
114
|
+
[...this.chipset.children].forEach(element => {
|
|
115
|
+
// Material design will update the displays. We have to handle the data changes.
|
|
116
|
+
element.addEventListener('remove', event => { // Clicking the button WITHIN the chip (which is the material design 'remove' event).
|
|
117
|
+
resetInactivityTimer();
|
|
118
|
+
const chip = event.target;
|
|
119
|
+
if (!chip.selected) { // 'x' icon. Not currently selected. Go ahead and remove it.
|
|
120
|
+
showMessage(Int`Topic "${chip.label}" has been removed. You can add it back with "add topic".`, 'instructions');
|
|
121
|
+
return this.remove(chip);
|
|
122
|
+
} // radio button icon. Chip is already selected. We are setting the publishing tag.
|
|
123
|
+
event.preventDefault();
|
|
124
|
+
showMessage(Int`Tapping the map will now produce an alert for the "${chip.label}" topic.`, 'instructions');
|
|
125
|
+
if (chip.classList.contains('pub')) return false;
|
|
126
|
+
return this.setPublish(chip.label);
|
|
127
|
+
});
|
|
128
|
+
clickTip(element, Int`Toggle whether alerts for this topic shown on the map. Separately, a radio button is shown when selected and sets this as the initial topic of the next alert you make, while an x button is shown when deselected and removes the topic.`, event => { // Toggle action on whole chip.
|
|
129
|
+
event.stopPropagation();
|
|
130
|
+
resetInactivityTimer();
|
|
131
|
+
const chip = event.target;
|
|
132
|
+
if (chip.selected) showMessage(Int`Turning "${chip.label}" alerts back on in the map.`, 'instructions');
|
|
133
|
+
else showMessage(Int`Turning off "${chip.label}" alerts in the map. You can delete the topic altogether with the X.`, 'instructions');
|
|
134
|
+
this.toggleChip(chip);
|
|
135
|
+
Marker.closePopup();
|
|
136
|
+
if (chip.selected) this.setPublish(chip.label);
|
|
137
|
+
this.onchange({redisplaySubscribers: false});
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
this.chipset.insertAdjacentHTML("afterbegin", // Chip to add a new hashtag.
|
|
141
|
+
`<md-filled-text-field class="newtag" placeholder="➕${Int`add topic`}"></md-filled-text-field>`);
|
|
142
|
+
clickTip(this.chipset.firstChild, Int`Add a new topic for which the map should show any alerts.`, event => { // Focusing "add topic".
|
|
143
|
+
event.stopPropagation();
|
|
144
|
+
Marker.closePopup();
|
|
145
|
+
showMessage(Int`Type a new topic name to see any alerts on the map with this topic.`, 'instructions');
|
|
146
|
+
});
|
|
147
|
+
this.chipset.firstChild.onchange = event => { // Add the new hashtag.
|
|
148
|
+
resetInactivityTimer();
|
|
149
|
+
let tag = event.target.value.trim() // Get into standard form, but do not strip emoji or case into canonical yet.
|
|
150
|
+
.replace(/^#/, '') // No leading hash
|
|
151
|
+
.replace(/\s+/g, ' ') // Replace multiple spaces with a single space
|
|
152
|
+
.normalize('NFD'); // Standardize different ways of making accents into decomposed form - but do not remove them.
|
|
153
|
+
if (!tag) return;
|
|
154
|
+
Marker.closePopup();
|
|
155
|
+
tag = this.add(tag); // Might exist, in which case tag might now be extended.
|
|
156
|
+
this.setPublish(tag);
|
|
157
|
+
this.onchange({highlightPublish: true});
|
|
158
|
+
};
|
|
159
|
+
},
|
|
160
|
+
remove(chip, redisplaySubscribers = false) { // Remove this topic, persistently.
|
|
161
|
+
delete this.hashtags[chip.label];
|
|
162
|
+
delete this.canonical2extended[canonicalTag(chip.label)];
|
|
163
|
+
this.onchange({redisplaySubscribers, resetSubscriptions: false});
|
|
164
|
+
},
|
|
165
|
+
toggleChip(chip) { // Switch whether the topic is or is not subscribed.
|
|
166
|
+
const label = chip.label;
|
|
167
|
+
const isPub = label === this.getPublish();
|
|
168
|
+
let altPub;
|
|
169
|
+
if (isPub) { // Find the next alternative, if possible.
|
|
170
|
+
let subs = this.getSubscribe();
|
|
171
|
+
if (subs.length > 1) {
|
|
172
|
+
let pubIndex = subs.indexOf(label);
|
|
173
|
+
let index = (pubIndex + 1) % subs.length;
|
|
174
|
+
altPub = subs[index];
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (altPub) this.setPublish(altPub);
|
|
178
|
+
else if (isPub && !chip.selected) { chip.selected = true; return; } // Don't allow deselecting the only pub tag.
|
|
179
|
+
this.hashtags[label] = chip.selected;
|
|
180
|
+
},
|
|
181
|
+
getChip(label) { // Handy for scripting, but not otherwise used in app.
|
|
182
|
+
for (const chip of this.chipset.children) {
|
|
183
|
+
if (chip.label === label) return chip;
|
|
184
|
+
}
|
|
185
|
+
return null;
|
|
186
|
+
},
|
|
187
|
+
setPublish(newTag) { // Make this topic be the one to be used when we next publish an alert.
|
|
188
|
+
const oldTag = this.getPublish();
|
|
189
|
+
this.hashtags[oldTag] = true;
|
|
190
|
+
this.hashtags[newTag] = 'pub';
|
|
191
|
+
for (const chip of this.chipset.children) {
|
|
192
|
+
if (chip.label === oldTag) chip.classList.remove('pub');
|
|
193
|
+
else if (chip.label === newTag) chip.classList.add('pub');
|
|
194
|
+
}
|
|
195
|
+
return oldTag;
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// Populate hashtags data and display.
|
|
200
|
+
// First the persisted/default data:
|
|
201
|
+
const persisted = JSON.parse(localStorage.getItem('hashtags') ||
|
|
202
|
+
`{"🍰 ${Int`cake`}": true, "🔥 ${Int`fire`}": true, "🌊 ${Int`flood`}": true, "🆘 ${Int`help`}": "pub", "🧊 ${Int`ice`}": true}`);
|
|
203
|
+
//`{"🎙️ news":"pub", "🎸 classic rock":true, "🎷 jazz":true, "🎵 pop":true, "🎤 hiphop":true, "🎧 edm":true, "🤠 country":true, "🎼 classical":true}`);
|
|
204
|
+
//`{"🍰 ${Int`cake`}": true, "🔥 ${Int`fire`}": true, "🌊 ${Int`flood`}": true, "🆘 ${Int`help`}": "pub", "🧊 ${Int`ice`}": true, "🎙️ news":true, "🎸 classic rock":true, "🎷 jazz":true, "🎵 pop":true, "🎤 hiphop":true, "🎧 edm":true, "🤠 country":true, "🎼 classical":true}`);
|
|
205
|
+
Object.entries(persisted).forEach(([tag, active]) => Hashtags.add(tag, active, false));
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
const { QRCodeStyling, GeolocationPositionError, localStorage, BigInt, URL, Notification } = globalThis; // For linters.
|
|
2
|
+
import { appVersion } from './versions.js';
|
|
3
|
+
import { Int } from './translations.js';
|
|
4
|
+
import { openDisplay } from './display.js';
|
|
5
|
+
import { Agent} from './agent.js';
|
|
6
|
+
import { P2PWebNetwork } from './p2pWebNetwork.js';
|
|
7
|
+
import { getPointInCell } from './s2.js';
|
|
8
|
+
import { Marker, map, getShareableURL, showMessage, updateLocation, updateSubscriptions, recenterMap, share } from './map.js';
|
|
9
|
+
import './service-manager.js'; // Comment this out and kill service-workers for reload-to-get-latest behavior during development.
|
|
10
|
+
|
|
11
|
+
document.getElementById('appVersion').textContent = appVersion;
|
|
12
|
+
document.getElementById('kernelVersion').textContent = P2PWebNetwork.kernelVersion;
|
|
13
|
+
|
|
14
|
+
const RETRY_SECONDS = 90;
|
|
15
|
+
const INACTIVITY_SECONDS = 5 * 60; // five minutes
|
|
16
|
+
|
|
17
|
+
export function delay(ms = 800, value = undefined) { // Promise resolves to value after specified milliseconds.
|
|
18
|
+
return new Promise(resolve => setTimeout(resolve, ms, value));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
var inactivityTimer = null, reconnectCountdown, networkPromise = null;
|
|
22
|
+
export { networkPromise };
|
|
23
|
+
export async function resetInactivityTimer(clearMessage = true) { // if !network, initialize(false), else disconnect after INACTIVITY_SECONDSif not restarted
|
|
24
|
+
//console.log('resetInactivityTimer, networkPromise:', networkPromise);
|
|
25
|
+
if (clearMessage) showMessage('');
|
|
26
|
+
clearTimeout(inactivityTimer);
|
|
27
|
+
clearInterval(reconnectCountdown);
|
|
28
|
+
if (!networkPromise) return initialize(false);
|
|
29
|
+
// return inactivityTimer = setTimeout(() => {
|
|
30
|
+
// networkPromise?.then(contact => contact.disconnect());
|
|
31
|
+
// }, INACTIVITY_SECONDS * 1e3);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function asElement(elementOrQuerySelector) { // Return an element - treating a string arg as a query selector, or just return the arg.
|
|
35
|
+
if (typeof(elementOrQuerySelector) === 'string') return document.querySelector(elementOrQuerySelector);
|
|
36
|
+
return elementOrQuerySelector;
|
|
37
|
+
}
|
|
38
|
+
export function tooltip(elementOrQuerySelector, text) { // Add plain tooltip text to element.
|
|
39
|
+
const element = asElement(elementOrQuerySelector);
|
|
40
|
+
element.title = text; // TODO: try via aria-describedby pointing to separate role="tooltip" div
|
|
41
|
+
}
|
|
42
|
+
export function clickTip(elementOrQuerySelector, text, clickHandler) { // tooltip and onclick in one step.
|
|
43
|
+
const element = asElement(elementOrQuerySelector);
|
|
44
|
+
tooltip(element, text);
|
|
45
|
+
element.onclick = clickHandler;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isWebView() { return /CriOS|(WebView|wv|(iPhone|iPod|iPad)(?!.*Safari))/.test(navigator.userAgent); }
|
|
49
|
+
function isApple() { return navigator.platform.startsWith('Mac') || ['iPhone', 'iPad'].includes(navigator.platform); }
|
|
50
|
+
function isMobile() { return navigator.userAgentData?.mobile || /iPhone|iPad|iPod|Mobile/.test(navigator.userAgent); }
|
|
51
|
+
function isStandalone() { return window.matchMedia('(display-mode: standalone)').matches; }
|
|
52
|
+
export function osName() { return navigator.userAgentData?.platform || navigator.userAgent.match(/Android/)?.[0] || navigator.platform; }
|
|
53
|
+
function mobilePlatformName() { return isMobile() && (isApple() ? 'iOS' : 'Android'); }
|
|
54
|
+
function mobileVendorName() { return isMobile() && (isApple() ? 'Apple' : 'Android'); }
|
|
55
|
+
function mobileBrowserName() { return isApple() ? 'Safari' : 'Chrome'; }
|
|
56
|
+
function browserName() {
|
|
57
|
+
if (navigator.userAgent.includes("Firefox")) return "Firefox";
|
|
58
|
+
if (navigator.userAgent.includes("Edg")) return "Edge";
|
|
59
|
+
if (navigator.userAgent.includes("Chrome")) return "Chrome";
|
|
60
|
+
if (navigator.userAgent.includes("CriOS")) return "Chrome";
|
|
61
|
+
if (navigator.userAgent.includes("Safari")) return "Safari";
|
|
62
|
+
return '';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
var showNotifications = document.getElementById('showNotifications');
|
|
66
|
+
var showNotificationsLabel = document.getElementById('showNotificationsLabel');
|
|
67
|
+
function disabledNotifications() { return localStorage.getItem('disabledNotifications'); }
|
|
68
|
+
export function disableNotifications(force) { localStorage.setItem('disabledNotifications', force ? '1' : ''); }
|
|
69
|
+
export function notificationsAllowed() { return (Notification?.permission === 'granted') && !disabledNotifications(); }
|
|
70
|
+
function noteNotificationPermission(permission) {
|
|
71
|
+
if (isWebView()) {
|
|
72
|
+
showNotifications.indeterminate = true;
|
|
73
|
+
showNotifications.toggleAttribute('disabled', true);
|
|
74
|
+
showNotificationsLabel.innerHTML = `${mobileVendorName()} ${Int`does not support notifications on WebViews embedded in other programs. Please use CivilDefense.io in native`} ${mobileBrowserName()}.`;
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (isMobile() && isApple() && !isStandalone()) {
|
|
78
|
+
showNotifications.indeterminate = true;
|
|
79
|
+
showNotifications.toggleAttribute('disabled', true);
|
|
80
|
+
showNotificationsLabel.innerHTML = `${Int`Apple only supports mobile notifications for web pages that have been`} <a href="https://www.google.com/search?q=iphone+install+web+page+to+home+screen" target="yz.sidebar">${Int`installed to the home screen`}</a>.`;
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
switch (permission) {
|
|
84
|
+
case 'default':
|
|
85
|
+
showNotifications.indeterminate = true;
|
|
86
|
+
showNotifications.toggleAttribute('disabled', false);
|
|
87
|
+
showNotificationsLabel.innerHTML = Int`Enable notifications`;
|
|
88
|
+
break;
|
|
89
|
+
case 'granted':
|
|
90
|
+
showNotifications.checked = !disabledNotifications();
|
|
91
|
+
showNotifications.toggleAttribute('disabled', false);
|
|
92
|
+
showNotificationsLabel.innerHTML = Int`Allow notifications`;
|
|
93
|
+
break;
|
|
94
|
+
default:
|
|
95
|
+
showNotifications.checked = false;
|
|
96
|
+
showNotifications.toggleAttribute('disabled', true);
|
|
97
|
+
const isApp = isStandalone();
|
|
98
|
+
const search = isApp ?
|
|
99
|
+
`https://www.google.com/search?q=${osName()}+open+app+settings` :
|
|
100
|
+
`https://www.google.com/search?q=open+site+settings+${browserName() || `"${navigator.userAgent}"`}`;
|
|
101
|
+
const label = isApp ? `CivilDefense.io ${Int`app`}` : Int`browser site settings`;
|
|
102
|
+
showNotificationsLabel.innerHTML = `${Int`Permissions can be re-enabled through the`} <a href="${search}" target="yz.sidebar">${label}</a>.`;
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
clickTip(showNotifications.parentElement, Int`Enable local ${osName()} notifcations for map alerts, without going through any servers. Requires that the app be running.`, event => {
|
|
107
|
+
resetInactivityTimer();
|
|
108
|
+
event.stopPropagation();
|
|
109
|
+
});
|
|
110
|
+
showNotifications.onchange = () => {
|
|
111
|
+
if (window.Notification?.permission === 'granted') {
|
|
112
|
+
disableNotifications(!showNotifications.checked);
|
|
113
|
+
} else {
|
|
114
|
+
window.Notification?.requestPermission().then(noteNotificationPermission);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
// Safari never fires 'change': https://webkit.org/b/259432
|
|
118
|
+
navigator.permissions.query({ name: 'notifications'})
|
|
119
|
+
.then(status => status.onchange = () => noteNotificationPermission(window.Notification?.permission));
|
|
120
|
+
|
|
121
|
+
export function openAbout(event) {
|
|
122
|
+
openDisplay('aboutContainer', event);
|
|
123
|
+
noteNotificationPermission(window.Notification?.permission);
|
|
124
|
+
}
|
|
125
|
+
clickTip('#aboutButton', Int`Information about this app, and options to change how you appear to others.`, event => { // open about
|
|
126
|
+
Marker.closePopup();
|
|
127
|
+
openAbout(event);
|
|
128
|
+
});
|
|
129
|
+
clickTip('#wipe', Int`Wipe from ${osName()} all personal data and source files for this app.`, async event => {
|
|
130
|
+
await networkPromise?.then(contact => contact.disconnect());
|
|
131
|
+
localStorage.clear();
|
|
132
|
+
await caches.keys().then(keys => Promise.all(keys.map(key => caches.delete(key))));
|
|
133
|
+
await navigator.serviceWorker.getRegistrations().then(registrations => Promise.all(registrations.map(r => r.unregister())));
|
|
134
|
+
window.location.replace('about/index.html');
|
|
135
|
+
});
|
|
136
|
+
document.getElementById('scriptChooser').onchange = event => { // Run a script module chosen by the user. e.g., for testing.
|
|
137
|
+
const file = event.currentTarget.files[0];
|
|
138
|
+
if (!file) return;
|
|
139
|
+
const script = document.createElement('script');
|
|
140
|
+
script.type = "module";
|
|
141
|
+
script.src = URL.createObjectURL(file);
|
|
142
|
+
document.body.appendChild(script);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
clickTip('#qrButton', Int`Display QR code that can be scanned by a friend to show the same map on their device.`, event => { // generate (and display) qr code on-demand (in case url changes)
|
|
146
|
+
const content = openDisplay('qrContainer', event, '');
|
|
147
|
+
const qr = new QRCodeStyling({
|
|
148
|
+
width: 300,
|
|
149
|
+
height: 300,
|
|
150
|
+
margin: 0,
|
|
151
|
+
type: "svg",
|
|
152
|
+
data: getShareableURL().href,
|
|
153
|
+
dotsOptions: {
|
|
154
|
+
color: "#0A2E7C",
|
|
155
|
+
type: "rounded"
|
|
156
|
+
},
|
|
157
|
+
cornersSquareOptions: {type: "dot"},
|
|
158
|
+
cornersDotOptions: {type: "dot"},
|
|
159
|
+
backgroundOptions: {
|
|
160
|
+
color: "white",
|
|
161
|
+
},
|
|
162
|
+
image: "images/civil-defense-240.png",
|
|
163
|
+
imageOptions: {
|
|
164
|
+
crossOrigin: "anonymous",
|
|
165
|
+
margin:2
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
qr.append(content);
|
|
169
|
+
});
|
|
170
|
+
document.querySelector('#correspondentContainer md-outlined-text-field').onclick = event => {
|
|
171
|
+
resetInactivityTimer();
|
|
172
|
+
event.stopPropagation();
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
clickTip('#share', Int`${osName()} share a link to this map and topics, with picture of map.`, event => {
|
|
176
|
+
event.stopPropagation();
|
|
177
|
+
share({text: "CivilDefense.io", url: getShareableURL().href });
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
clickTip('#recenterButton', Int`Recenter the map to where you are in the world.`, recenterMap);
|
|
181
|
+
|
|
182
|
+
function checkOnline() { //true if online and visible, else cancel reconnectCountdown and inactivityTimeout, and show "offline"
|
|
183
|
+
//console.log('checkOnline', navigator.onLine && !document.hidden);
|
|
184
|
+
if (navigator.onLine && !document.hidden) return true;
|
|
185
|
+
clearTimeout(inactivityTimer);
|
|
186
|
+
clearInterval(reconnectCountdown);
|
|
187
|
+
if (!navigator.onLine) showMessage(Int`No network connection.`, 'error');
|
|
188
|
+
else console.warn('hidden');
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
function resetReconnectCountdown() { // if !checkOnline each second, show time remaining; at expiration initialize(false)
|
|
192
|
+
console.log('resetReconnectCountdown');
|
|
193
|
+
clearInterval(reconnectCountdown);
|
|
194
|
+
let counter = RETRY_SECONDS;
|
|
195
|
+
reconnectCountdown = setInterval(() => {
|
|
196
|
+
if (!checkOnline()) return null;
|
|
197
|
+
if (counter > 1) return showMessage(Int`Disconnected. Retrying in ` + counter-- + Int` seconds.`, 'error');
|
|
198
|
+
showMessage('');
|
|
199
|
+
console.log('countdown timer expired');
|
|
200
|
+
clearInterval(reconnectCountdown);
|
|
201
|
+
return initialize(false);
|
|
202
|
+
}, 1e3);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export let positionWatch;
|
|
206
|
+
let subscribeOneShot;
|
|
207
|
+
const goodPositionLabel = Int`Your Location`;
|
|
208
|
+
|
|
209
|
+
function initializeGeolocation(subscribe = false) { // Arrange to constantly updateLocation, but:
|
|
210
|
+
// Arrange to constantly updateLocation when geolocation updates (or a default init if that's not possible).
|
|
211
|
+
// Once updateLocation has been called, updateSubscriptions() if the subscribe arg is true.
|
|
212
|
+
//
|
|
213
|
+
// Non-geo cases:
|
|
214
|
+
// if no support, message and defaultInit
|
|
215
|
+
// if no permission, message and defaultInit
|
|
216
|
+
// if !checkOnline, nothing further - we'll be called again later.
|
|
217
|
+
// if other error; delay and try again
|
|
218
|
+
//
|
|
219
|
+
// Subscription:
|
|
220
|
+
// If this was for a new node, we will be told to subscribe. That cannot meaningfully happen
|
|
221
|
+
// until we get our first position, so we do so ONCE in the watch position handler.
|
|
222
|
+
// Any subsequent location updates will update position in the map, but not move the map
|
|
223
|
+
// nor change the subscriptions.
|
|
224
|
+
const {geolocation} = navigator; // Get user's geolocation
|
|
225
|
+
console.log('Initializing geolocation.', subscribe ? 'Will subscribe.' : 'Has subscriptions.');
|
|
226
|
+
subscribeOneShot = subscribe;
|
|
227
|
+
const initMap = (lat, lng) => {
|
|
228
|
+
let zoom = 14, positionLabel = goodPositionLabel;
|
|
229
|
+
if (lat === undefined) {
|
|
230
|
+
positionLabel = Int`Default location. Geolocation unavailable.`;
|
|
231
|
+
const level9Cell = localStorage.getItem('level9Cell');
|
|
232
|
+
if (level9Cell) { // Zoomed out near where we last where, but not too exact for security.
|
|
233
|
+
zoom = 12;
|
|
234
|
+
[lat, lng] = getPointInCell(BigInt(level9Cell));
|
|
235
|
+
} else {
|
|
236
|
+
zoom = 13;
|
|
237
|
+
[lat, lng] = [37.7749, -122.4194]; // San Fransisco
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
console.log('initializeGeolocation updateLocation');
|
|
241
|
+
updateLocation(lat, lng, zoom, positionLabel);
|
|
242
|
+
if (!subscribeOneShot) return;
|
|
243
|
+
subscribeOneShot = false;
|
|
244
|
+
resetInactivityTimer(false);
|
|
245
|
+
updateSubscriptions([]); // This was for a new node, so supply an empty oldSubscriptions.
|
|
246
|
+
};
|
|
247
|
+
if (!geolocation) {
|
|
248
|
+
showMessage(Int`Geolocation not supported. Using default location.`, 'error', 'fail');
|
|
249
|
+
delay(2e3).then(() => initMap());
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
geolocation.clearWatch(positionWatch);
|
|
253
|
+
positionWatch = geolocation.watchPosition(
|
|
254
|
+
position => {
|
|
255
|
+
const {latitude, longitude} = position.coords;
|
|
256
|
+
console.log('Location update.', map ? 'Map exists.' : 'Will create map.', subscribeOneShot ? 'Will subscribe fresh.' : 'Has subscriptions.');
|
|
257
|
+
initMap(latitude, longitude);
|
|
258
|
+
}, error => {
|
|
259
|
+
geolocation.clearWatch(positionWatch);
|
|
260
|
+
console.warn(`Geolocation code ${error.code}. online:`, navigator.onLine, 'code:', error.code);
|
|
261
|
+
if (navigator.onLine) {
|
|
262
|
+
if (error.code === GeolocationPositionError.PERMISSION_DENIED) {
|
|
263
|
+
showMessage(Int`Location access denied. Using default location.`, 'error', error);
|
|
264
|
+
delay(2e3).then(() => initMap());
|
|
265
|
+
} else {
|
|
266
|
+
showMessage(Int`Unable to get location.`, 'error', error);
|
|
267
|
+
delay(4e3).then(() => initializeGeolocation(subscribe));
|
|
268
|
+
}
|
|
269
|
+
} else {
|
|
270
|
+
showMessage(Int`No network connection.`, 'error');
|
|
271
|
+
}
|
|
272
|
+
}, {
|
|
273
|
+
enableHighAccuracy: true,
|
|
274
|
+
timeout: 10000,
|
|
275
|
+
maximumAge: 0
|
|
276
|
+
}
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
let checking = false; // For debouncing.
|
|
281
|
+
// On startup, get last persisted portals list, else the portal we came in on.
|
|
282
|
+
//fixme let portals = new Set(JSON.parse(localStorage.getItem('portals') || `["${new URL('/kdht', window.location).href}"]`));
|
|
283
|
+
async function initialize(event) { // Ensure there is a network promise and map, and reset geolocation:
|
|
284
|
+
// debounce
|
|
285
|
+
// if !checkOnline(), return
|
|
286
|
+
// set network to promise a new Contact, set ondisconnect, and connect.
|
|
287
|
+
// delay if asked
|
|
288
|
+
// initializeGeolocation
|
|
289
|
+
if (checking) return;
|
|
290
|
+
checking = true;
|
|
291
|
+
try {
|
|
292
|
+
// Always close about display, because notification permissions and the like can change in the OS while we're hidden, and safari and mobile chrome don't issue change events for them.
|
|
293
|
+
document.getElementById('aboutContainer').classList.toggle('hidden', true);
|
|
294
|
+
|
|
295
|
+
// If networkPromise has not yet been set (or cleared by disconnect), we will be subscribing.
|
|
296
|
+
const needsConnection = !networkPromise;
|
|
297
|
+
const couldConnect = checkOnline(); // Meaning online AND visble (could be hidden)
|
|
298
|
+
console.log('Initialize', appVersion, 'from', event ? event.type : 'reset', networkPromise ? 'Has network.' : 'Needs network.', couldConnect ? 'Is online+visible.' : 'Is not online+visible.');
|
|
299
|
+
if (!couldConnect) {
|
|
300
|
+
navigator?.geolocation.clearWatch(positionWatch);
|
|
301
|
+
if (navigator.onLine) networkPromise?.then(contact => contact.replicateStorage()); // Hidden. Replicate in case we get shut down.
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
showMessage('');
|
|
305
|
+
initializeGeolocation(needsConnection);
|
|
306
|
+
if (!networkPromise) {
|
|
307
|
+
showMessage(Int`Connecting to network...`);
|
|
308
|
+
const {promise, resolve} = Promise.withResolvers();
|
|
309
|
+
networkPromise = promise;
|
|
310
|
+
console.log('Creating node.');
|
|
311
|
+
resolve(P2PWebNetwork.create({}));
|
|
312
|
+
networkPromise.then(contact => {
|
|
313
|
+
globalThis.contact = contact; // For debugging.
|
|
314
|
+
// On leaving, we would like to copy stored data and politely say 'bye' (so others can clean up their connections). Alas:
|
|
315
|
+
// - The events we get will be, in order: beforeunload (except iOS Safari), pagehide, visibilitychange, unload
|
|
316
|
+
// - None of these will wait for any asynchronous operation.
|
|
317
|
+
// - The two unload events are not fired when mobile background tabs are killed.
|
|
318
|
+
// - visibilitychange is also fired when tab is switched.
|
|
319
|
+
// And so the best we can do is:
|
|
320
|
+
// - When tabs are switched, we try to replicate storage.
|
|
321
|
+
// I think we will always get time to complete this.
|
|
322
|
+
// But we will stay online as long as we're allowed, at which point we may be asked to store more that will not get replicated if killed.
|
|
323
|
+
// - On pagehide and unload, we synchronously say 'bye' with disconnectTransports.
|
|
324
|
+
// These two are assigned now with a resolved networkPromise => contact so that we don't have to await.
|
|
325
|
+
window.onpagehide = () => contact.fastDisconnect();
|
|
326
|
+
// causes errors in some browsers: window.onunload = () => contact.fastDisconnect();
|
|
327
|
+
|
|
328
|
+
showMessage(Int`Tap anywhere to mark a concern. Markers fade after 24 hours.`, 'instructions');
|
|
329
|
+
contact.detachment.then(onPurpose => { // On disconnect (whether initiated by us or not), message user and set up for reconnection.
|
|
330
|
+
networkPromise = null;
|
|
331
|
+
const message = onPurpose ? Int`Connection closed. Will reconnect on use.` :
|
|
332
|
+
(navigator.onLine ? Int`The service connection has closed. Please reload.` : Int`No network connection.`);
|
|
333
|
+
console.log('contact detached', {onPurpose, onLine: navigator.onLine});
|
|
334
|
+
// If/when we reconnect, we will make a new network object with a new GUID,
|
|
335
|
+
// so as not to confuse other nodes that have given up on the unresponsive old GUID.
|
|
336
|
+
showMessage(message, 'error');
|
|
337
|
+
});
|
|
338
|
+
//contact.connect(/*fixme ...portals*/)
|
|
339
|
+
// .then(() => contact.subscribe({ // Add and persist any new portals we haven't heard about.
|
|
340
|
+
// eventName: 'sys:portals',
|
|
341
|
+
// handler: data => {
|
|
342
|
+
// const operation = data.payload ? 'add' : 'delete';
|
|
343
|
+
// const resultSet = portals[operation](data.subject);
|
|
344
|
+
// localStorage.setItem('portals', JSON.stringify([...resultSet]));
|
|
345
|
+
// }
|
|
346
|
+
// }))
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
await Agent.initialize();
|
|
350
|
+
if (event) await delay();
|
|
351
|
+
} finally {
|
|
352
|
+
checking = false;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
document.addEventListener('visibilitychange', initialize);
|
|
356
|
+
window.addEventListener('online', initialize);
|
|
357
|
+
|
|
358
|
+
const titleElement = document.querySelector('#aboutContainer h1');
|
|
359
|
+
titleElement.textContent = Int([titleElement.textContent]);
|
|
360
|
+
titleElement.textContent += ` @${location.hostname}`;
|
|
361
|
+
document.querySelector('head > title').innerHTML = titleElement.textContent;
|
|
362
|
+
// Set up text for the browser language.
|
|
363
|
+
function initText(selector, content = selector) {
|
|
364
|
+
const element = document.querySelector(selector);
|
|
365
|
+
const text = Int([content]);
|
|
366
|
+
element.textContent = text;
|
|
367
|
+
}
|
|
368
|
+
initText('#aboutReport');
|
|
369
|
+
initText('#aboutShared');
|
|
370
|
+
initText('#aboutFade');
|
|
371
|
+
initText('#aboutAnyone1');
|
|
372
|
+
initText('#aboutAnyone2');
|
|
373
|
+
initText('#aboutAnyone3');
|
|
374
|
+
initText('#learnMore');
|
|
375
|
+
initText('#version');
|
|
376
|
+
initText('#checkForUpdates');
|
|
377
|
+
initText('#downloadUpdates');
|
|
378
|
+
initText('#newVersionHeader');
|
|
379
|
+
initText('#updateNowQuestion');
|
|
380
|
+
initText('#updateReload');
|
|
381
|
+
initText('#updateDefer');
|
|
382
|
+
initText('#downloadUpdates2');
|
|
383
|
+
initText('#downloadDefer');
|
|
384
|
+
initText('#describePrivate1');
|
|
385
|
+
initText('#describePrivate2');
|
|
386
|
+
initText('#describePublic');
|
|
387
|
+
initText('#describeSystem');
|
|
388
|
+
initText('#pickLabels');
|
|
389
|
+
initText('#wipe');
|
|
390
|
+
|
|
391
|
+
tooltip('#learnMore', Int`Click to see more information in another tab about what you can do with this app and how it is resistant to tracking, censorship, and takedown.`);
|
|
392
|
+
tooltip('#aboutAnyone2', Int`Click to see the source code and documentation for serving copies of this app in another tab.`);
|
|
393
|
+
|
|
394
|
+
initialize(false);
|