Browse Source

finish separation of daily reminder message, bump version to 0.3.34

master
Trent Larson 3 days ago
parent
commit
2171846f50
  1. 6
      CHANGELOG.md
  2. 4
      package-lock.json
  3. 2
      package.json
  4. 86
      public/img/icons/safari-pinned-tab-512x512.svg
  5. 83
      src/App.vue
  6. 148
      src/components/PushNotificationPermission.vue
  7. 4
      src/constants/app.ts
  8. 5
      src/db/tables/settings.ts
  9. 13
      src/libs/util.ts
  10. 193
      src/views/AccountViewView.vue
  11. 4
      src/views/ConfirmGiftView.vue
  12. 9
      src/views/HelpNotificationsView.vue
  13. 9
      src/views/NewEditProjectView.vue
  14. 2
      sw_scripts/additional-scripts.js
  15. 8
      test-playwright/LICENSE

6
CHANGELOG.md

@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.3.34] - 2024.11
### Added
- Daily reliable, hard-coded notification message
- Setting to change the partner API server
## [0.3.33] - 2024.11.07 - adb7b16ecf1343c39cba71a7d6bb0e7a973e1102 ## [0.3.33] - 2024.11.07 - adb7b16ecf1343c39cba71a7d6bb0e7a973e1102
### Fixed ### Fixed
- Affirm Delivery button on offer claim page didn't work. - Affirm Delivery button on offer claim page didn't work.

4
package-lock.json

@ -1,12 +1,12 @@
{ {
"name": "TimeSafari", "name": "TimeSafari",
"version": "0.3.34-beta", "version": "0.3.34",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "TimeSafari", "name": "TimeSafari",
"version": "0.3.34-beta", "version": "0.3.34",
"dependencies": { "dependencies": {
"@capacitor/android": "^6.1.2", "@capacitor/android": "^6.1.2",
"@capacitor/cli": "^6.1.2", "@capacitor/cli": "^6.1.2",

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "TimeSafari", "name": "TimeSafari",
"version": "0.3.34-beta", "version": "0.3.34",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"serve": "vite preview", "serve": "vite preview",

86
public/img/icons/safari-pinned-tab-512x512.svg

@ -0,0 +1,86 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
preserveAspectRatio="xMidYMid meet">
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M2480 4005 c-25 -7 -58 -20 -75 -29 -16 -9 -40 -16 -52 -16 -17 0
-24 -7 -28 -27 -3 -16 -14 -45 -24 -65 -21 -41 -13 -55 18 -38 25 13 67 13 92
-1 15 -8 35 -4 87 17 99 39 130 41 197 10 64 -29 77 -31 107 -15 20 11 20 11
-3 35 -12 13 -30 24 -38 24 -24 1 -132 38 -148 51 -8 7 -11 20 -7 32 12 37
-40 47 -126 22z"/>
<path d="M1450 3775 c-7 -8 -18 -15 -24 -15 -7 0 -31 -14 -54 -32 -29 -22 -38
-34 -29 -40 17 -11 77 -10 77 1 0 5 16 16 35 25 60 29 220 19 290 -18 17 -9
33 -16 37 -16 4 0 31 -15 60 -34 108 -70 224 -215 282 -353 30 -71 53 -190 42
-218 -10 -27 -23 -8 -52 75 -30 90 -88 188 -120 202 -13 6 -26 9 -29 6 -3 -2
11 -51 30 -108 28 -83 35 -119 35 -179 0 -120 -22 -127 -54 -17 -11 37 -13 21
-18 -154 -5 -180 -8 -200 -32 -264 -51 -132 -129 -245 -199 -288 -21 -12 -79
-49 -129 -80 -161 -102 -294 -141 -473 -141 -228 0 -384 76 -535 259 -81 99
-118 174 -154 312 -31 121 -35 273 -11 437 19 127 19 125 -4 125 -23 0 -51
-34 -87 -104 -14 -28 -33 -64 -41 -81 -19 -34 -22 -253 -7 -445 9 -106 12
-119 44 -170 19 -30 42 -67 50 -81 64 -113 85 -140 130 -169 28 -18 53 -44 61
-62 8 -20 36 -45 83 -76 62 -39 80 -46 151 -54 44 -5 96 -13 115 -18 78 -20
238 -31 282 -19 24 6 66 8 95 5 76 -9 169 24 319 114 32 19 80 56 106 82 27
26 52 48 58 48 5 0 27 26 50 58 48 66 56 70 132 71 62 1 165 29 238 64 112 55
177 121 239 245 37 76 39 113 10 267 -12 61 -23 131 -26 156 -5 46 -5 47 46
87 92 73 182 70 263 -8 l51 -49 -6 -61 c-4 -34 -13 -85 -21 -113 -28 -103 -30
-161 -4 -228 16 -44 32 -67 55 -83 18 -11 39 -37 47 -58 10 -23 37 -53 73 -81
32 -25 69 -57 82 -71 14 -14 34 -26 47 -26 12 0 37 -7 56 -15 20 -8 66 -17
104 -20 107 -10 110 -11 150 -71 50 -75 157 -177 197 -187 18 -5 53 -24 78
-42 71 -51 176 -82 304 -89 61 -4 127 -12 147 -18 29 -9 45 -8 77 6 23 9 50
16 60 16 31 0 163 46 216 76 28 15 75 46 105 69 30 23 69 49 85 58 17 8 46 31
64 51 19 20 40 36 47 36 18 0 77 70 100 120 32 66 45 108 55 173 5 32 16 71
24 87 43 84 43 376 0 549 -27 105 -43 127 -135 188 -30 21 -65 46 -77 57 -13
11 -23 17 -23 14 0 -3 21 -46 47 -94 79 -151 85 -166 115 -263 25 -83 28 -110
28 -226 0 -144 -17 -221 -75 -335 -39 -77 -208 -244 -304 -299 -451 -263 -975
-67 -1138 426 -23 70 -26 95 -28 254 -1 108 -7 183 -14 196 -6 12 -11 31 -11
43 0 32 31 122 52 149 10 13 18 28 18 34 0 5 25 40 56 78 60 73 172 170 219
190 30 12 30 13 6 17 -15 2 -29 -2 -37 -12 -6 -9 -16 -16 -22 -16 -6 0 -23
-11 -39 -24 -15 -12 -33 -25 -40 -27 -17 -6 -82 -60 -117 -97 -65 -70 -75 -82
-107 -133 -23 -34 -35 -46 -37 -35 -3 16 20 87 44 134 6 12 9 34 6 48 -4 22
-8 25 -31 19 -14 -3 -38 -15 -53 -26 -34 -24 -34 -21 -6 28 65 112 184 206
291 227 15 3 39 9 55 12 l27 6 -24 9 c-90 35 -304 -66 -478 -225 -39 -36 -74
-66 -77 -66 -22 0 18 82 72 148 19 23 32 46 28 49 -4 4 -26 13 -49 19 -73 21
-161 54 -171 64 -6 6 -20 10 -32 10 -21 0 -21 -1 -8 -40 45 -130 8 -247 -93
-299 -25 -13 -31 0 -14 29 15 22 1 33 -22 17 -56 -36 -117 -22 -117 28 0 13
-16 47 -35 76 -22 34 -33 60 -29 73 4 16 -3 26 -26 39 -16 10 -30 21 -30 25 1
18 54 64 87 76 l38 13 -33 5 c-30 4 -115 -18 -154 -42 -13 -7 -20 -5 -27 8 -9
16 -12 16 -53 1 -160 -61 -258 -104 -258 -114 0 -7 10 -20 21 -31 103 -91 217
-297 249 -449 28 -135 41 -237 35 -276 -14 -91 -48 -170 -97 -220 -44 -47 -68
-60 -68 -40 0 6 4 12 8 15 5 3 24 35 42 72 l33 67 -6 141 c-4 103 -11 158 -26
205 -12 35 -21 70 -21 77 0 7 -20 56 -45 108 -82 173 -227 322 -392 401 -67
33 -90 39 -163 42 -108 5 -130 10 -130 28 0 20 -63 20 -80 0z"/>
<path d="M3710 3765 c0 -20 8 -28 39 -41 22 -8 42 -22 45 -30 5 -14 42 -19 70
-8 10 4 -7 21 -58 55 -41 27 -79 49 -85 49 -6 0 -11 -11 -11 -25z"/>
<path d="M3173 3734 c-9 -25 10 -36 35 -18 12 8 22 19 22 25 0 16 -50 10 -57
-7z"/>
<path d="M1982 3728 c6 -16 36 -34 44 -26 3 4 4 14 1 23 -7 17 -51 21 -45 3z"/>
<path d="M1540 3620 c0 -5 7 -10 16 -10 8 0 12 5 9 10 -3 6 -10 10 -16 10 -5
0 -9 -4 -9 -10z"/>
<path d="M4467 3624 c-4 -4 23 -27 60 -50 84 -56 99 -58 67 -9 -28 43 -107 79
-127 59z"/>
<path d="M655 3552 c-11 -2 -26 -9 -33 -14 -7 -6 -27 -18 -45 -27 -36 -18 -58
-64 -39 -83 9 -9 25 1 70 43 53 48 78 78 70 84 -2 1 -12 -1 -23 -3z"/>
<path d="M1015 3460 c-112 -24 -247 -98 -303 -165 -53 -65 -118 -214 -136
-311 -20 -113 -20 -145 -1 -231 20 -88 49 -153 102 -230 79 -113 186 -182 331
-214 108 -24 141 -24 247 1 130 30 202 72 316 181 102 100 153 227 152 384 0
142 -58 293 -150 395 -60 67 -180 145 -261 171 -75 23 -232 34 -297 19z m340
-214 c91 -43 174 -154 175 -234 0 -18 -9 -51 -21 -73 -19 -37 -19 -42 -5 -64
35 -54 12 -121 -48 -142 -22 -7 -47 -19 -55 -27 -9 -8 -41 -27 -71 -42 -50
-26 -64 -29 -155 -29 -111 0 -152 14 -206 68 -49 49 -63 85 -64 162 0 59 4 78
28 118 31 52 96 105 141 114 23 5 33 17 56 68 46 103 121 130 225 81z"/>
<path d="M3985 3464 c-44 -7 -154 -44 -200 -67 -55 -28 -138 -96 -162 -132
-10 -16 -39 -75 -64 -130 l-44 -100 0 -160 0 -160 45 -90 c53 -108 152 -214
245 -264 59 -31 215 -71 281 -71 53 0 206 40 255 67 98 53 203 161 247 253 53
113 74 193 74 280 -1 304 -253 564 -557 575 -49 2 -103 1 -120 -1z m311 -220
c129 -68 202 -209 160 -309 -15 -35 -15 -42 -1 -72 26 -55 -3 -118 -59 -129
-19 -3 -43 -15 -53 -26 -26 -29 -99 -64 -165 -78 -45 -10 -69 -10 -120 -1 -74
15 -113 37 -161 91 -110 120 -50 331 109 385 24 8 44 23 52 39 6 14 18 38 25
53 33 72 127 93 213 47z"/>
<path d="M487 3394 c-21 -12 -27 -21 -25 -40 2 -14 7 -26 12 -27 14 -3 48 48
44 66 -3 14 -6 14 -31 1z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.6 KiB

83
src/App.vue

@ -238,6 +238,7 @@
</div> </div>
</div> </div>
</div> </div>
<div <div
v-if="notification.type === 'notification-mute'" v-if="notification.type === 'notification-mute'"
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50" class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
@ -277,6 +278,7 @@
</div> </div>
</div> </div>
</div> </div>
<div <div
v-if="notification.type === 'notification-off'" v-if="notification.type === 'notification-off'"
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50" class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
@ -286,7 +288,7 @@
> >
<div class="w-full px-6 py-6 text-slate-900 text-center"> <div class="w-full px-6 py-6 text-slate-900 text-center">
<p class="text-lg mb-4"> <p class="text-lg mb-4">
Would you like to <b>turn off</b> notifications for this app? Would you like to <b>turn off</b> this notification?
</p> </p>
<button <button
@ -296,7 +298,7 @@
" "
class="block w-full text-center text-md font-bold uppercase bg-rose-600 text-white px-2 py-2 rounded-md mb-2" class="block w-full text-center text-md font-bold uppercase bg-rose-600 text-white px-2 py-2 rounded-md mb-2"
> >
Turn Off Notifications Turn Off Notification
</button> </button>
<button <button
@click="close(notification.id)" @click="close(notification.id)"
@ -318,9 +320,8 @@
<script lang="ts"> <script lang="ts">
import { Vue, Component } from "vue-facing-decorator"; import { Vue, Component } from "vue-facing-decorator";
import { db, logConsoleAndDb } from "@/db/index"; import { logConsoleAndDb, retrieveSettingsForActiveAccount } from "@/db/index";
import { NotificationIface } from "./constants/app"; import { NotificationIface } from "./constants/app";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
@Component @Component
export default class App extends Vue { export default class App extends Vue {
@ -329,18 +330,29 @@ export default class App extends Vue {
stopAsking = false; stopAsking = false;
async turnOffNotifications(notification: NotificationIface) { async turnOffNotifications(notification: NotificationIface) {
let subscription; let subscription: object | null = null;
const pushProviderSuccess: boolean = await navigator.serviceWorker?.ready
let allGoingOff = false;
const settings = await retrieveSettingsForActiveAccount();
const notifyingNewActivity = !!settings?.notifyingNewActivityTime;
const notifyingReminder = !!settings?.notifyingReminderTime;
if (!notifyingNewActivity || !notifyingReminder) {
// the other notification is already off, so fully unsubscribe now
allGoingOff = true;
}
await navigator.serviceWorker?.ready
.then((registration) => { .then((registration) => {
return registration.pushManager.getSubscription(); return registration.pushManager.getSubscription();
}) })
.then((subscript: PushSubscription | null) => { .then(async (subscript: PushSubscription | null) => {
subscription = subscript;
if (subscript) { if (subscript) {
return subscript.unsubscribe(); subscription = subscript.toJSON();
if (allGoingOff) {
await subscript.unsubscribe();
}
} else { } else {
logConsoleAndDb("Subscription object is not available."); logConsoleAndDb("Subscription object is not available.");
return false;
} }
}) })
.catch((error) => { .catch((error) => {
@ -348,15 +360,20 @@ export default class App extends Vue {
"Push provider server communication failed: " + JSON.stringify(error), "Push provider server communication failed: " + JSON.stringify(error),
true, true,
); );
return false;
}); });
const pushServerSuccess: boolean = await fetch("/web-push/unsubscribe", { const serverSubscription = {
...subscription,
};
if (!allGoingOff) {
serverSubscription["notifyType"] = notification.title;
}
const pushServerSuccess = await fetch("/web-push/unsubscribe", {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify(subscription), body: JSON.stringify(serverSubscription),
}) })
.then((response) => { .then((response) => {
return response.ok; return response.ok;
@ -370,34 +387,24 @@ export default class App extends Vue {
}); });
let message; let message;
if (pushProviderSuccess === pushServerSuccess) { if (pushServerSuccess) {
message = "Both local and server notifications "; message = "Notification is off.";
if (pushProviderSuccess) {
message += "are off.";
} else {
message += "failed to turn off.";
}
} else { } else {
message = message = "Notification is still on. Try to turn it off again.";
"Local unsubscribe " +
(pushProviderSuccess ? "succeeded" : "failed") +
" but server unsubscribe " +
(pushServerSuccess ? "succeeded" : "failed") +
".";
} }
this.$notify({ this.$notify(
group: "alert", {
type: "info", group: "alert",
title: "Finished", type: "info",
text: message, title: "Finished",
}); text: message,
},
await db.open(); 5000,
await db.settings.update(MASTER_SETTINGS_KEY, { );
notifyingNewActivity: false,
});
if (notification.callback) { if (notification.callback) {
notification.callback(pushProviderSuccess && pushServerSuccess); // it's OK if the local notifications are still on (especially if the other notification is on)
notification.callback(pushServerSuccess);
} }
} }
} }

148
src/components/PushNotificationPermission.vue

@ -16,7 +16,12 @@
> >
<div class="w-full px-6 py-6 text-slate-900 text-center"> <div class="w-full px-6 py-6 text-slate-900 text-center">
<p v-if="serviceWorkerReady && vapidKey" class="text-lg mb-4"> <p v-if="serviceWorkerReady && vapidKey" class="text-lg mb-4">
Would you like to be notified of new activity once a day? <span v-if="pushType === DAILY_CHECK_TITLE">
Would you like to be notified of new activity, up to once a day?
</span>
<span v-else>
Would you like to get a reminder message once a day?
</span>
</p> </p>
<p v-else class="text-lg mb-4"> <p v-else class="text-lg mb-4">
Waiting for system initialization, which may take up to 5 seconds... Waiting for system initialization, which may take up to 5 seconds...
@ -24,28 +29,51 @@
</p> </p>
<div v-if="serviceWorkerReady && vapidKey"> <div v-if="serviceWorkerReady && vapidKey">
<span class="flex flex-row justify-center"> <div v-if="pushType === DAILY_CHECK_TITLE">
<span class="mt-2">Yes, tell me at: </span> <span>Yes, send me a message when there is new data for me</span>
<input </div>
type="number" <div v-else>
@change="checkHourInput" <span>Yes, send me this message:</span>
class="rounded-l border border-r-0 border-slate-400 ml-2 mt-2 px-2 py-2 text-center w-20" <!-- eslint-disable -->
v-model="hourInput" <textarea
/> type="text"
<input id="push-message"
type="number" v-model="messageInput"
@change="checkMinuteInput" class="rounded border border-slate-400 mt-2 px-2 py-2 w-full"
class="border border-slate-400 mt-2 px-2 py-2 text-center w-20" maxlength="100"
v-model="minuteInput" ></textarea
/>
<span
class="rounded-r border border-slate-400 bg-slate-200 text-center text-blue-500 mt-2 px-2 py-2 w-20"
@click="hourAm = !hourAm"
> >
<span v-if="hourAm"> AM <fa icon="chevron-down" /> </span> <!-- eslint-enable -->
<span v-else> PM <fa icon="chevron-up" /> </span> <span class="w-full flex justify-between text-xs text-slate-500">
<span></span>
<span>(100 characters max)</span>
</span> </span>
</span> </div>
<div>
<span class="flex flex-row justify-center">
<span class="mt-2">... at: </span>
<input
type="number"
@change="checkHourInput"
class="rounded-l border border-r-0 border-slate-400 ml-2 mt-2 px-2 py-2 text-center w-20"
v-model="hourInput"
/>
<input
type="number"
@change="checkMinuteInput"
class="border border-slate-400 mt-2 px-2 py-2 text-center w-20"
v-model="minuteInput"
/>
<span
class="rounded-r border border-slate-400 bg-slate-200 text-center text-blue-500 mt-2 px-2 py-2 w-20"
@click="hourAm = !hourAm"
>
<span v-if="hourAm"> AM <fa icon="chevron-down" /> </span>
<span v-else> PM <fa icon="chevron-up" /> </span>
</span>
</span>
</div>
<button <button
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white mt-2 px-2 py-2 rounded-md" class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white mt-2 px-2 py-2 rounded-md"
@click=" @click="
@ -73,12 +101,7 @@
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { DEFAULT_PUSH_SERVER, NotificationIface } from "@/constants/app"; import { DEFAULT_PUSH_SERVER, NotificationIface } from "@/constants/app";
import { import { logConsoleAndDb, retrieveSettingsForActiveAccount } from "@/db/index";
db,
logConsoleAndDb,
retrieveSettingsForActiveAccount,
} from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { urlBase64ToUint8Array } from "@/libs/crypto/vc/util"; import { urlBase64ToUint8Array } from "@/libs/crypto/vc/util";
import * as libsUtil from "@/libs/util"; import * as libsUtil from "@/libs/util";
@ -89,6 +112,7 @@ interface ErrorResponse {
// PushSubscriptionJSON is defined in the Push API https://www.w3.org/TR/push-api/#dom-pushsubscriptionjson // PushSubscriptionJSON is defined in the Push API https://www.w3.org/TR/push-api/#dom-pushsubscriptionjson
interface PushSubscriptionWithTime extends PushSubscriptionJSON { interface PushSubscriptionWithTime extends PushSubscriptionJSON {
message?: string;
notifyTime: { utcHour: number; minute: number }; notifyTime: { utcHour: number; minute: number };
notifyType: string; notifyType: string;
} }
@ -115,17 +139,27 @@ export default class PushNotificationPermission extends Vue {
// eslint-disable-next-line // eslint-disable-next-line
$notify!: (notification: NotificationIface, timeout?: number) => Promise<() => void>; $notify!: (notification: NotificationIface, timeout?: number) => Promise<() => void>;
callback: (success: boolean, time: string) => void = () => {}; DAILY_CHECK_TITLE = libsUtil.DAILY_CHECK_TITLE;
DIRECT_PUSH_TITLE = libsUtil.DIRECT_PUSH_TITLE;
callback: (success: boolean, time: string, message?: string) => void =
() => {};
hourAm = true; hourAm = true;
hourInput = "8"; hourInput = "8";
isVisible = false; isVisible = false;
messageInput = "";
minuteInput = "00"; minuteInput = "00";
pushType = "";
serviceWorkerReady = false; serviceWorkerReady = false;
vapidKey = ""; vapidKey = "";
async open(callback?: (success: boolean, time: string) => void) { async open(
this.isVisible = true; pushType: string,
callback?: (success: boolean, time: string, message?: string) => void,
) {
this.callback = callback || this.callback; this.callback = callback || this.callback;
this.isVisible = true;
this.pushType = pushType;
try { try {
const settings = await retrieveSettingsForActiveAccount(); const settings = await retrieveSettingsForActiveAccount();
let pushUrl = DEFAULT_PUSH_SERVER; let pushUrl = DEFAULT_PUSH_SERVER;
@ -194,6 +228,18 @@ export default class PushNotificationPermission extends Vue {
navigator.serviceWorker?.ready.then(() => { navigator.serviceWorker?.ready.then(() => {
this.serviceWorkerReady = true; this.serviceWorkerReady = true;
}); });
if (this.pushType === this.DIRECT_PUSH_TITLE) {
this.messageInput =
"Just a friendly reminder: click and share some gratitude with the world.";
// focus on the message input
setTimeout(function () {
document.getElementById("push-message")?.focus();
}, 100);
} else {
// not critical but doesn't make sense in a daily check
this.messageInput = "";
}
} }
private close() { private close() {
@ -264,7 +310,7 @@ export default class PushNotificationPermission extends Vue {
{ {
group: "alert", group: "alert",
type: "danger", type: "danger",
title: "Browser Notifications Not Supported", title: "Browser Notifications Are Not Supported",
text: "This browser does not support notifications.", text: "This browser does not support notifications.",
}, },
3000, 3000,
@ -329,7 +375,7 @@ export default class PushNotificationPermission extends Vue {
} }
} }
public async turnOnNotifications() { private async turnOnNotifications() {
let notifyCloser = () => {}; let notifyCloser = () => {};
return this.askPermission() return this.askPermission()
.then((permission) => { .then((permission) => {
@ -378,7 +424,8 @@ export default class PushNotificationPermission extends Vue {
const subscriptionWithTime: PushSubscriptionWithTime = { const subscriptionWithTime: PushSubscriptionWithTime = {
notifyTime: { utcHour: finalUtcHour, minute: finalUtcMinute }, notifyTime: { utcHour: finalUtcHour, minute: finalUtcMinute },
notifyType: "DAILY_CHECK", notifyType: this.pushType,
message: this.messageInput,
...subscription.toJSON(), ...subscription.toJSON(),
}; };
await this.sendSubscriptionToServer(subscriptionWithTime); await this.sendSubscriptionToServer(subscriptionWithTime);
@ -398,23 +445,21 @@ export default class PushNotificationPermission extends Vue {
); );
await libsUtil.sendTestThroughPushServer(subscription, true); await libsUtil.sendTestThroughPushServer(subscription, true);
notifyCloser(); notifyCloser();
this.$notify( setTimeout(() => {
{ this.$notify(
group: "alert", {
type: "success", group: "alert",
title: "Notifications Turned On", type: "success",
text: "Notifications are on. You should see at least one on your device; if not, check the 'Troubleshoot' page.", title: "Notification Is On",
}, text: "You should see at least one on your device; if not, check the 'Troubleshoot' link.",
-1, },
); 7000,
);
}, 500);
const timeText = const timeText =
// eslint-disable-next-line // eslint-disable-next-line
this.hourInput + ":" + this.minuteInput + " " + (this.hourAm ? "AM" : "PM"); this.hourInput + ":" + this.minuteInput + " " + (this.hourAm ? "AM" : "PM");
await db.settings.update(MASTER_SETTINGS_KEY, { this.callback(true, timeText, this.messageInput);
notifyingNewActivity: true,
notifyingNewActivityTime: timeText,
});
this.callback(true, timeText);
}) })
.catch((error) => { .catch((error) => {
logConsoleAndDb( logConsoleAndDb(
@ -434,14 +479,7 @@ export default class PushNotificationPermission extends Vue {
}, },
3000, 3000,
); );
// unsubscribe just in case we failed after getting a subscription // if we want to also unsubscribe, be sure to do that only if no other notification is active
navigator.serviceWorker?.ready
.then((registration) => registration.pushManager.getSubscription())
.then((subscription) => {
if (subscription) {
subscription.unsubscribe();
}
});
}); });
} }

4
src/constants/app.ts

@ -16,8 +16,8 @@ export enum AppString {
TEST_IMAGE_API_SERVER = "https://test-image-api.timesafari.app", TEST_IMAGE_API_SERVER = "https://test-image-api.timesafari.app",
LOCAL_IMAGE_API_SERVER = "http://localhost:3001", LOCAL_IMAGE_API_SERVER = "http://localhost:3001",
PROD_PARTNER_API_SERVER = "https://endorser.ch", PROD_PARTNER_API_SERVER = "https://partner-api.endorser.ch",
TEST_PARTNER_API_SERVER = "https://test-partner.endorser.ch", TEST_PARTNER_API_SERVER = "https://test-partner-api.endorser.ch",
LOCAL_PARTNER_API_SERVER = LOCAL_ENDORSER_API_SERVER, LOCAL_PARTNER_API_SERVER = LOCAL_ENDORSER_API_SERVER,
PROD_PUSH_SERVER = "https://timesafari.app", PROD_PUSH_SERVER = "https://timesafari.app",

5
src/db/tables/settings.ts

@ -39,8 +39,11 @@ export type Settings = {
lastNotifiedClaimId?: string; lastNotifiedClaimId?: string;
lastViewedClaimId?: string; lastViewedClaimId?: string;
notifyingNewActivity?: boolean; // set if they have turned on daily check for new activity via the push server
notifyingNewActivityTime?: string; // set to their chosen time if they have turned on daily check for new activity via the push server notifyingNewActivityTime?: string; // set to their chosen time if they have turned on daily check for new activity via the push server
notifyingReminderMessage?: string; // set to their chosen message for a daily reminder
notifyingReminderTime?: string; // set to their chosen time for a daily reminder
partnerApiServer?: string; // partner server API URL
passkeyExpirationMinutes?: number; // passkey access token time-to-live in minutes passkeyExpirationMinutes?: number; // passkey access token time-to-live in minutes

13
src/libs/util.ts

@ -364,6 +364,11 @@ export const getPasskeyExpirationSeconds = async (): Promise<number> => {
); );
}; };
// These are shared with the service worker and should be a constant. Look for the same name in additional-scripts.js
export const DAILY_CHECK_TITLE = "DAILY_CHECK";
// This is a special value that tells the service worker to send a direct notification to the device, skipping filters.
export const DIRECT_PUSH_TITLE = "DIRECT_NOTIFICATION";
export const sendTestThroughPushServer = async ( export const sendTestThroughPushServer = async (
subscriptionJSON: PushSubscriptionJSON, subscriptionJSON: PushSubscriptionJSON,
skipFilter: boolean, skipFilter: boolean,
@ -374,16 +379,12 @@ export const sendTestThroughPushServer = async (
pushUrl = settings.webPushServer; pushUrl = settings.webPushServer;
} }
// This is shared with the service worker and should be a constant. Look for the same name in additional-scripts.js
// This is a special value that tells the service worker to send a direct notification to the device, skipping filters.
// Use something other than "Daily Update" https://gitea.anomalistdesign.com/trent_larson/py-push-server/src/commit/3c0e196c11bc98060ec5934e99e7dbd591b5da4d/app.py#L213
const DIRECT_PUSH_TITLE = "DIRECT_NOTIFICATION";
const newPayload = { const newPayload = {
...subscriptionJSON,
// ... overridden with the following
// eslint-disable-next-line prettier/prettier // eslint-disable-next-line prettier/prettier
message: `Test, where you will see this message ${ skipFilter ? "un" : "" }filtered.`, message: `Test, where you will see this message ${ skipFilter ? "un" : "" }filtered.`,
title: skipFilter ? DIRECT_PUSH_TITLE : "Your Web Push", title: skipFilter ? DIRECT_PUSH_TITLE : "Your Web Push",
...subscriptionJSON,
}; };
console.log("Sending a test web push message:", newPayload); console.log("Sending a test web push message:", newPayload);
const payloadStr = JSON.stringify(newPayload); const payloadStr = JSON.stringify(newPayload);

193
src/views/AccountViewView.vue

@ -184,7 +184,36 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<!-- label --> <!-- label -->
<div> <div>
New Activity Notifications Reminder Notification
<fa
icon="question-circle"
class="text-slate-400 fa-fw ml-2 cursor-pointer"
@click.stop="showReminderNotificationInfo"
/>
</div>
<!-- toggle -->
<div
class="relative ml-2 cursor-pointer"
@click="showReminderNotificationChoice()"
>
<!-- input -->
<input type="checkbox" v-model="notifyingReminder" class="sr-only" />
<!-- line -->
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
<!-- dot -->
<div
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
></div>
</div>
</div>
<div v-if="notifyingReminder" class="w-full flex justify-between">
<span class="ml-8 mr-8">Message: "{{ notifyingReminderMessage }}"</span>
<span>{{ notifyingReminderTime.replace(" ", "&nbsp;") }}</span>
</div>
<div class="mt-2 flex items-center justify-between">
<!-- label -->
<div>
New Activity Notification
<fa <fa
icon="question-circle" icon="question-circle"
class="text-slate-400 fa-fw ml-2 cursor-pointer" class="text-slate-400 fa-fw ml-2 cursor-pointer"
@ -200,7 +229,6 @@
<input <input
type="checkbox" type="checkbox"
v-model="notifyingNewActivity" v-model="notifyingNewActivity"
name="toggleNotificationsInput"
class="sr-only" class="sr-only"
/> />
<!-- line --> <!-- line -->
@ -212,7 +240,7 @@
</div> </div>
</div> </div>
<div v-if="notifyingNewActivityTime" class="w-full text-right"> <div v-if="notifyingNewActivityTime" class="w-full text-right">
{{ notifyingNewActivityTime }} {{ notifyingNewActivityTime.replace(" ", "&nbsp;") }}
</div> </div>
<router-link class="pl-4 text-sm text-blue-500" to="/help-notifications"> <router-link class="pl-4 text-sm text-blue-500" to="/help-notifications">
Troubleshoot your notification setup. Troubleshoot your notification setup.
@ -609,18 +637,51 @@
{{ DEFAULT_PUSH_SERVER }} {{ DEFAULT_PUSH_SERVER }}
</span> </span>
<h2 class="text-slate-500 text-sm font-bold mb-2">Partner Server URL</h2>
<div class="px-3 py-4">
<input
type="text"
class="block w-full rounded border border-slate-400 px-3 py-2"
v-model="partnerApiServerInput"
/>
<button
v-if="partnerApiServerInput != partnerApiServer"
class="w-full px-4 rounded bg-yellow-500 border border-slate-400"
@click="onClickSavePartnerServer()"
>
<fa icon="floppy-disk" class="fa-fw" color="white"></fa>
</button>
<button
class="px-3 rounded bg-slate-200 border border-slate-400"
@click="partnerApiServerInput = AppConstants.PROD_PARTNER_API_SERVER"
>
Use Prod
</button>
<button
class="px-3 rounded bg-slate-200 border border-slate-400"
@click="partnerApiServerInput = AppConstants.TEST_PARTNER_API_SERVER"
>
Use Test
</button>
<button
class="px-3 rounded bg-slate-200 border border-slate-400"
@click="partnerApiServerInput = AppConstants.LOCAL_PARTNER_API_SERVER"
>
Use Local
</button>
</div>
<span class="px-4 text-sm" v-if="!partnerApiServerInput">
When that setting is blank, this app will use the default partner server
URL:
{{ DEFAULT_PARTNER_API_SERVER }}
</span>
<div id="sectionImageServerURL" class="mt-2"> <div id="sectionImageServerURL" class="mt-2">
<span class="text-slate-500 text-sm font-bold">Image Server URL</span> <span class="text-slate-500 text-sm font-bold">Image Server URL</span>
&nbsp; &nbsp;
<span class="text-sm">{{ DEFAULT_IMAGE_API_SERVER }}</span> <span class="text-sm">{{ DEFAULT_IMAGE_API_SERVER }}</span>
</div> </div>
<div id="sectionPartnerServerURL" class="mt-2">
<span class="text-slate-500 text-sm font-bold">Partner Server URL</span>
&nbsp;
<span class="text-sm">{{ DEFAULT_PARTNER_API_SERVER }}</span>
</div>
<label <label
for="toggleHideRegisterPromptOnNewContact" for="toggleHideRegisterPromptOnNewContact"
class="flex items-center justify-between cursor-pointer mt-4" class="flex items-center justify-between cursor-pointer mt-4"
@ -778,7 +839,7 @@ import {
ImageRateLimits, ImageRateLimits,
tokenExpiryTimeDescription, tokenExpiryTimeDescription,
} from "@/libs/endorserServer"; } from "@/libs/endorserServer";
import { getAccount } from "@/libs/util"; import { DAILY_CHECK_TITLE, DIRECT_PUSH_TITLE, getAccount } from "@/libs/util";
const inputImportFileNameRef = ref<Blob>(); const inputImportFileNameRef = ref<Blob>();
@ -815,6 +876,11 @@ export default class AccountViewView extends Vue {
loadingLimits = false; loadingLimits = false;
notifyingNewActivity = false; notifyingNewActivity = false;
notifyingNewActivityTime = ""; notifyingNewActivityTime = "";
notifyingReminder = false;
notifyingReminderMessage = "";
notifyingReminderTime = "";
partnerApiServer = "";
partnerApiServerInput = "";
passkeyExpirationDescription = ""; passkeyExpirationDescription = "";
passkeyExpirationMinutes = DEFAULT_PASSKEY_EXPIRATION_MINUTES; passkeyExpirationMinutes = DEFAULT_PASSKEY_EXPIRATION_MINUTES;
previousPasskeyExpirationMinutes = DEFAULT_PASSKEY_EXPIRATION_MINUTES; previousPasskeyExpirationMinutes = DEFAULT_PASSKEY_EXPIRATION_MINUTES;
@ -858,7 +924,7 @@ export default class AccountViewView extends Vue {
const registration = await navigator.serviceWorker?.ready; const registration = await navigator.serviceWorker?.ready;
this.subscription = await registration.pushManager.getSubscription(); this.subscription = await registration.pushManager.getSubscription();
if (!this.subscription) { if (!this.subscription) {
if (this.notifyingNewActivity) { if (this.notifyingNewActivity || this.notifyingReminder) {
// the app thought there was a subscription but there isn't, so fix the settings // the app thought there was a subscription but there isn't, so fix the settings
this.turnOffNotifyingFlags(); this.turnOffNotifyingFlags();
} }
@ -909,14 +975,19 @@ export default class AccountViewView extends Vue {
this.givenName = this.givenName =
(settings?.firstName || "") + (settings?.firstName || "") +
(settings?.lastName ? ` ${settings.lastName}` : ""); // pre v 0.1.3 (settings?.lastName ? ` ${settings.lastName}` : ""); // pre v 0.1.3
this.hideRegisterPromptOnNewContact =
!!settings.hideRegisterPromptOnNewContact;
this.isRegistered = !!settings?.isRegistered; this.isRegistered = !!settings?.isRegistered;
this.imageServer = settings.imageServer || ""; this.imageServer = settings.imageServer || "";
this.notifyingNewActivity = !!settings.notifyingNewActivityTime;
this.notifyingNewActivityTime = settings.notifyingNewActivityTime || "";
this.notifyingReminder = !!settings.notifyingReminderTime;
this.notifyingReminderMessage = settings.notifyingReminderMessage || "";
this.notifyingReminderTime = settings.notifyingReminderTime || "";
this.partnerApiServer = settings.partnerApiServer || "";
this.partnerApiServerInput = settings.partnerApiServer || "";
this.profileImageUrl = settings.profileImageUrl; this.profileImageUrl = settings.profileImageUrl;
this.showContactGives = !!settings.showContactGivesInline; this.showContactGives = !!settings.showContactGivesInline;
this.hideRegisterPromptOnNewContact =
!!settings.hideRegisterPromptOnNewContact;
this.notifyingNewActivity = !!settings.notifyingNewActivity;
this.notifyingNewActivityTime = settings.notifyingNewActivityTime || "";
this.passkeyExpirationMinutes = this.passkeyExpirationMinutes =
settings.passkeyExpirationMinutes ?? DEFAULT_PASSKEY_EXPIRATION_MINUTES; settings.passkeyExpirationMinutes ?? DEFAULT_PASSKEY_EXPIRATION_MINUTES;
this.previousPasskeyExpirationMinutes = this.passkeyExpirationMinutes; this.previousPasskeyExpirationMinutes = this.passkeyExpirationMinutes;
@ -1025,10 +1096,13 @@ export default class AccountViewView extends Vue {
if (!this.notifyingNewActivity) { if (!this.notifyingNewActivity) {
( (
this.$refs.pushNotificationPermission as PushNotificationPermission this.$refs.pushNotificationPermission as PushNotificationPermission
).open((success: boolean, time: string) => { ).open(DAILY_CHECK_TITLE, async (success: boolean, timeText: string) => {
if (success) { if (success) {
await db.settings.update(MASTER_SETTINGS_KEY, {
notifyingNewActivityTime: timeText,
});
this.notifyingNewActivity = true; this.notifyingNewActivity = true;
this.notifyingNewActivityTime = time; this.notifyingNewActivityTime = timeText;
} }
}); });
} else { } else {
@ -1036,10 +1110,13 @@ export default class AccountViewView extends Vue {
{ {
group: "modal", group: "modal",
type: "notification-off", type: "notification-off",
title: "", // unused, only here to satisfy type check title: DAILY_CHECK_TITLE, // repurposed to indicate the type of notification
text: "", // unused, only here to satisfy type check text: "", // unused, only here to satisfy type check
callback: async (success) => { callback: async (success) => {
if (success) { if (success) {
await db.settings.update(MASTER_SETTINGS_KEY, {
notifyingNewActivityTime: "",
});
this.notifyingNewActivity = false; this.notifyingNewActivity = false;
this.notifyingNewActivityTime = ""; this.notifyingNewActivityTime = "";
} }
@ -1050,6 +1127,71 @@ export default class AccountViewView extends Vue {
} }
} }
async showReminderNotificationInfo() {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Reminder Notification",
text: `
This will notify you at a specific time each day.
Note that it does not give you personalized notifications,
so if you want less reliable but personalized notification then choose a 'New Activity' Notification.
Do you want more details?
`,
onYes: async () => {
await (this.$router as Router).push({
name: "help-notification-types",
});
},
yesText: "tell me more.",
},
-1,
);
}
async showReminderNotificationChoice() {
if (!this.notifyingReminder) {
(
this.$refs.pushNotificationPermission as PushNotificationPermission
).open(
DIRECT_PUSH_TITLE,
async (success: boolean, timeText: string, message?: string) => {
if (success) {
await db.settings.update(MASTER_SETTINGS_KEY, {
notifyingReminderMessage: message,
notifyingReminderTime: timeText,
});
this.notifyingReminder = true;
this.notifyingReminderMessage = message || "";
this.notifyingReminderTime = timeText;
}
},
);
} else {
this.$notify(
{
group: "modal",
type: "notification-off",
title: DIRECT_PUSH_TITLE, // repurposed to indicate the type of notification
text: "", // unused, only here to satisfy type check
callback: async (success) => {
if (success) {
await db.settings.update(MASTER_SETTINGS_KEY, {
notifyingReminderMessage: "",
notifyingReminderTime: "",
});
this.notifyingReminder = false;
this.notifyingReminderMessage = "";
this.notifyingReminderTime = "";
}
},
},
-1,
);
}
}
public async toggleHideRegisterPromptOnNewContact() { public async toggleHideRegisterPromptOnNewContact() {
const newSetting = !this.hideRegisterPromptOnNewContact; const newSetting = !this.hideRegisterPromptOnNewContact;
await db.open(); await db.open();
@ -1069,13 +1211,18 @@ export default class AccountViewView extends Vue {
} }
public async turnOffNotifyingFlags() { public async turnOffNotifyingFlags() {
// should tell the push server as well
await db.open(); await db.open();
await db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
notifyingNewActivity: false,
notifyingNewActivityTime: "", notifyingNewActivityTime: "",
notifyingReminderMessage: "",
notifyingReminderTime: "",
}); });
this.notifyingNewActivity = false; this.notifyingNewActivity = false;
this.notifyingNewActivityTime = ""; this.notifyingNewActivityTime = "";
this.notifyingReminder = false;
this.notifyingReminderMessage = "";
this.notifyingReminderTime = "";
} }
/** /**
@ -1424,6 +1571,14 @@ export default class AccountViewView extends Vue {
this.apiServer = this.apiServerInput; this.apiServer = this.apiServerInput;
} }
async onClickSavePartnerServer() {
await db.open();
await db.settings.update(MASTER_SETTINGS_KEY, {
partnerApiServer: this.partnerApiServerInput,
});
this.partnerApiServer = this.partnerApiServerInput;
}
async onClickSavePushServer() { async onClickSavePushServer() {
await db.open(); await db.open();
await db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {

4
src/views/ConfirmGiftView.vue

@ -69,7 +69,7 @@
<div class="overflow-hidden"> <div class="overflow-hidden">
<div class="text-sm"> <div class="text-sm">
<div> <div>
<fa icon="arrow-down" class="fa-fw text-slate-400" /> <fa icon="arrow-left" class="fa-fw text-slate-400" />
{{ giverName }} {{ giverName }}
</div> </div>
<div class="ml-6">gave</div> <div class="ml-6">gave</div>
@ -84,7 +84,7 @@
</div> </div>
<div class="ml-6">to</div> <div class="ml-6">to</div>
<div> <div>
<fa icon="arrow-up" class="fa-fw text-slate-400" /> <fa icon="arrow-right" class="fa-fw text-slate-400" />
{{ recipientName }} {{ recipientName }}
</div> </div>
<div> <div>

9
src/views/HelpNotificationsView.vue

@ -39,6 +39,15 @@
</p> </p>
</div> </div>
<h2 class="text-xl font-semibold mt-4">Android Users</h2>
<div>
<p>
Note that you may not receive notifications when the app is in the
background. When you're done working, close the app, and then you'll
get the reminder notifications.
</p>
</div>
<h2 class="text-xl font-semibold mt-4"> <h2 class="text-xl font-semibold mt-4">
If this app doesn't support notifications... If this app doesn't support notifications...
<!-- Note that that exact verbiage shows in a message elsewhere. --> <!-- Note that that exact verbiage shows in a message elsewhere. -->

9
src/views/NewEditProjectView.vue

@ -151,10 +151,12 @@
<input type="checkbox" class="mr-2" v-model="sendToTrustroots" /> <input type="checkbox" class="mr-2" v-model="sendToTrustroots" />
<label>Send to Trustroots</label> <label>Send to Trustroots</label>
</div> </div>
<!--
<div class="flex" @click="sendToTripHopping = !sendToTripHopping"> <div class="flex" @click="sendToTripHopping = !sendToTripHopping">
<input type="checkbox" class="mr-2" v-model="sendToTripHopping" /> <input type="checkbox" class="mr-2" v-model="sendToTripHopping" />
<label>Send to TripHopping</label> <label>Send to TripHopping</label>
</div> </div>
-->
</div> </div>
<div class="mt-8"> <div class="mt-8">
@ -581,7 +583,12 @@ export default class NewEditProjectView extends Vue {
); );
const nostrPubKey = pubPri?.publicKey; const nostrPubKey = pubPri?.publicKey;
const trustrootsUrl = DEFAULT_PARTNER_API_SERVER + "/api/partner/link"; let partnerServer = DEFAULT_PARTNER_API_SERVER;
const settings = await retrieveSettingsForActiveAccount();
if (settings.partnerApiServer) {
partnerServer = settings.partnerApiServer;
}
const trustrootsUrl = partnerServer + "/api/partner/link";
const timeSafariUrl = window.location.origin + "/claim/" + jwtId; const timeSafariUrl = window.location.origin + "/claim/" + jwtId;
const content = this.fullClaim.name + " - see " + timeSafariUrl; const content = this.fullClaim.name + " - see " + timeSafariUrl;
// Why does IntelliJ not see matching types? // Why does IntelliJ not see matching types?

2
sw_scripts/additional-scripts.js

@ -81,7 +81,7 @@ self.addEventListener("push", function (event) {
let message = "Got some empty message."; let message = "Got some empty message.";
if (payload && payload.title == DIRECT_PUSH_TITLE) { if (payload && payload.title == DIRECT_PUSH_TITLE) {
// skip any search logic and show the message directly // skip any search logic and show the message directly
title = "Direct Notification"; title = "Direct Message";
message = payload.message || "No details were provided."; message = payload.message || "No details were provided.";
} else { } else {
// any other title will run through regular filtering logic // any other title will run through regular filtering logic

8
test-playwright/LICENSE

@ -0,0 +1,8 @@
The author disclaims copyright to this source code. In place of a legal notice, here is a blessing:
May you do good and not evil.
May you find forgiveness for yourself and forgive others.
May you share freely, never taking more than you give.
________________________________________________________________
from https://www.sqlite.org/src/info/689401a6cfb4c234 and memorialized here https://spdx.org/licenses/blessing.html
Loading…
Cancel
Save