Browse Source

Merge pull request 'add a share_target for people to add a photo' (#115) from share-photo into master

Reviewed-on: https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/pulls/115
kb/add-usage-guide
trentlarson 4 months ago
parent
commit
03ac31d981
  1. 2
      src/components/GiftedDialog.vue
  2. 66
      src/components/PhotoDialog.vue
  3. 10
      src/components/QuickNav.vue
  4. 2
      src/constants/app.ts
  5. 26
      src/db/index.ts
  6. 13
      src/db/tables/temp.ts
  7. 5
      src/router/index.ts
  8. 99
      src/views/AccountViewView.vue
  9. 87
      src/views/GiftedDetails.vue
  10. 45
      src/views/HelpView.vue
  11. 2
      src/views/NewEditProjectView.vue
  12. 24
      src/views/ProjectViewView.vue
  13. 5
      src/views/SeedBackupView.vue
  14. 188
      src/views/SharedPhotoView.vue
  15. 55
      src/views/TestView.vue
  16. 21
      sw_scripts/additional-scripts.js
  17. 14
      sw_scripts/safari-notifications.js
  18. 21
      vite.config.mjs

2
src/components/GiftedDialog.vue

@ -15,7 +15,7 @@
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center text-blue-500 px-2 py-2 w-20"
@click="changeUnitCode()"
>
{{ libsUtil.UNIT_SHORT[unitCode] }}
{{ libsUtil.UNIT_SHORT[unitCode] || unitCode }}
</span>
<div
class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2"

66
src/components/GiftedPhotoDialog.vue → src/components/PhotoDialog.vue

@ -34,7 +34,7 @@
backgroundColor: '#f8f8f8',
margin: 'auto',
}"
:img="URL.createObjectURL(blob)"
:img="createBlobURL(blob)"
:options="{
viewMode: 1,
dragMode: 'crop',
@ -49,7 +49,7 @@
</div>
<div v-else>
<div class="flex justify-center">
<img :src="URL.createObjectURL(blob)" class="mt-2 rounded" />
<img :src="createBlobURL(blob)" class="mt-2 rounded" />
</div>
</div>
<div class="absolute bottom-[1rem] left-[1rem] px-2 py-1">
@ -60,7 +60,10 @@
<span>Upload</span>
</button>
</div>
<div class="absolute bottom-[1rem] right-[1rem] px-2 py-1">
<div
v-if="showRetry"
class="absolute bottom-[1rem] right-[1rem] px-2 py-1"
>
<button
@click="retryImage"
class="bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white py-1 px-2 rounded-md"
@ -127,17 +130,19 @@ import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto";
@Component({ components: { Camera, VuePictureCropper } })
export default class GiftedPhotoDialog extends Vue {
export default class PhotoDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDeviceNumber = 0;
activeDid = "";
blob: Blob | null = null;
blob?: Blob;
claimType = "GiveAction";
crop = false;
fileName?: string;
mirror = false;
numDevices = 0;
setImage: (arg: string) => void = () => {};
setImageCallback: (arg: string) => void = () => {};
showRetry = true;
uploading = false;
visible = false;
@ -163,7 +168,13 @@ export default class GiftedPhotoDialog extends Vue {
}
}
open(setImageFn: (arg: string) => void, crop?: boolean, claimType?: string) {
open(
setImageFn: (arg: string) => void,
crop?: boolean,
claimType?: string,
blob?: Blob, // for image upload, just to use the cropping function
inputFileName?: string,
) {
this.visible = true;
this.crop = !!crop;
this.claimType = claimType || "GiveAction";
@ -171,7 +182,16 @@ export default class GiftedPhotoDialog extends Vue {
if (bottomNav) {
bottomNav.style.display = "none";
}
this.setImage = setImageFn;
this.setImageCallback = setImageFn;
if (blob) {
this.blob = blob;
this.fileName = inputFileName;
this.showRetry = false;
} else {
this.blob = undefined;
this.fileName = undefined;
this.showRetry = true;
}
}
close() {
@ -180,7 +200,7 @@ export default class GiftedPhotoDialog extends Vue {
if (bottomNav) {
bottomNav.style.display = "";
}
this.blob = null;
this.blob = undefined;
}
async cameraStarted() {
@ -236,10 +256,13 @@ export default class GiftedPhotoDialog extends Vue {
// The resolution is only necessary because of that mobile portrait-orientation case.
// The mobile emulation in a browser shows something stretched vertically, but real devices work fine.
this.blob = await cameraComponent?.snapshot({
height: imageHeight,
width: imageWidth,
}); // png is default; if that changes, change extension in formData.append
this.blob =
(await cameraComponent?.snapshot({
height: imageHeight,
width: imageWidth,
})) || undefined;
// png is default
this.fileName = "snapshot.png";
if (!this.blob) {
this.$notify(
{
@ -254,8 +277,13 @@ export default class GiftedPhotoDialog extends Vue {
}
}
private createBlobURL(blob: Blob): string {
console.log("blob", blob);
return URL.createObjectURL(blob);
}
async retryImage() {
this.blob = null;
this.blob = undefined;
}
/****
@ -307,7 +335,7 @@ export default class GiftedPhotoDialog extends Vue {
this.uploading = true;
if (this.crop) {
this.blob = await cropper?.getBlob();
this.blob = (await cropper?.getBlob()) || undefined;
}
const identifier = await getIdentity(this.activeDid);
@ -330,7 +358,7 @@ export default class GiftedPhotoDialog extends Vue {
this.uploading = false;
return;
}
formData.append("image", this.blob, "snapshot.png"); // png is set in snapshot()
formData.append("image", this.blob, this.fileName || "snapshot.png");
formData.append("claimType", this.claimType);
try {
const response = await axios.post(
@ -341,8 +369,8 @@ export default class GiftedPhotoDialog extends Vue {
this.uploading = false;
this.visible = false;
this.blob = null;
this.setImage(response.data.url as string);
this.blob = undefined;
this.setImageCallback(response.data.url as string);
} catch (error) {
console.error("Error uploading the image", error);
this.$notify(
@ -355,7 +383,7 @@ export default class GiftedPhotoDialog extends Vue {
5000,
);
this.uploading = false;
this.blob = null;
this.blob = undefined;
}
}

10
src/components/QuickNav.vue

@ -12,7 +12,7 @@
}"
>
<router-link :to="{ name: 'home' }" class="block text-center py-3 px-1">
<fa icon="house-chimney" class="fa-fw"></fa>
<fa icon="house-chimney" class="fa-fw" />
</router-link>
</li>
<!-- Search -->
@ -28,7 +28,7 @@
:to="{ name: 'discover' }"
class="block text-center py-3 px-1"
>
<fa icon="magnifying-glass" class="fa-fw"></fa>
<fa icon="magnifying-glass" class="fa-fw" />
</router-link>
</li>
<!-- Projects -->
@ -44,7 +44,7 @@
:to="{ name: 'projects' }"
class="block text-center py-3 px-1"
>
<fa icon="hand" class="fa-fw"></fa>
<fa icon="hand" class="fa-fw" />
</router-link>
</li>
<!-- Contacts -->
@ -60,7 +60,7 @@
:to="{ name: 'contacts' }"
class="block text-center py-3 px-1"
>
<fa icon="users" class="fa-fw"></fa>
<fa icon="users" class="fa-fw" />
</router-link>
</li>
<!-- Profile -->
@ -76,7 +76,7 @@
:to="{ name: 'account' }"
class="block text-center py-3 px-1"
>
<fa icon="circle-user" class="fa-fw"></fa>
<fa icon="circle-user" class="fa-fw" />
</router-link>
</li>
</ul>

2
src/constants/app.ts

@ -30,6 +30,8 @@ export const DEFAULT_IMAGE_API_SERVER =
export const DEFAULT_PUSH_SERVER =
window.location.protocol + "//" + window.location.host;
export const IMAGE_TYPE_PROFILE = "profile";
/**
* The possible values for "group" and "type" are in App.vue.
* From the notiwind package

26
src/db/index.ts

@ -8,6 +8,7 @@ import {
Settings,
SettingsSchema,
} from "./tables/settings";
import { Temp, TempSchema } from "./tables/temp";
import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app";
// Define types for tables that hold sensitive and non-sensitive data
@ -16,6 +17,7 @@ type NonsensitiveTables = {
contacts: Table<Contact>;
logs: Table<Log>;
settings: Table<Settings>;
temp: Table<Temp>;
};
// Using 'unknown' instead of 'any' for stricter typing and to avoid TypeScript warnings
@ -25,14 +27,7 @@ export type NonsensitiveDexie<T extends unknown = NonsensitiveTables> =
// Initialize Dexie databases for sensitive and non-sensitive data
export const accountsDB = new BaseDexie("TimeSafariAccounts") as SensitiveDexie;
const SensitiveSchemas = { ...AccountsSchema };
export const db = new BaseDexie("TimeSafari") as NonsensitiveDexie;
const NonsensitiveSchemas = {
...ContactSchema,
...LogSchema,
...SettingsSchema,
};
// Manage the encryption key. If not present in localStorage, create and store it.
const secret =
@ -42,11 +37,18 @@ if (!localStorage.getItem("secret")) localStorage.setItem("secret", secret);
// Apply encryption to the sensitive database using the secret key
encrypted(accountsDB, { secretKey: secret });
// Define the schema for our databases
accountsDB.version(1).stores(SensitiveSchemas);
// v1 was contacts & settings
// v2 added logs
db.version(2).stores(NonsensitiveSchemas);
// Define the schemas for our databases
// Only the tables with index modifications need listing. https://dexie.org/docs/Tutorial/Design#database-versioning
accountsDB.version(1).stores(AccountsSchema);
// v1 also had contacts & settings
// v2 added Log
db.version(2).stores({
...ContactSchema,
...LogSchema,
...SettingsSchema,
});
// v3 added Temp
db.version(3).stores(TempSchema);
// Event handler to initialize the non-sensitive database with default settings
db.on("populate", () => {

13
src/db/tables/temp.ts

@ -0,0 +1,13 @@
// for ephemeral uses, eg. passing a blob from the service worker to the main thread
export type Temp = {
id: string;
blob?: Blob;
};
/**
* Schema for the Temp table in the database.
*/
export const TempSchema = {
temp: "id",
};

5
src/router/index.ts

@ -169,6 +169,11 @@ const routes: Array<RouteRecordRaw> = [
name: "seed-backup",
component: () => import("../views/SeedBackupView.vue"),
},
{
path: "/shared-photo",
name: "shared-photo",
component: () => import("@/views/SharedPhotoView.vue"),
},
{
path: "/start",
name: "start",

99
src/views/AccountViewView.vue

@ -4,16 +4,6 @@
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Back -->
<div class="text-lg text-center font-light relative px-7">
<h1
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
>
<fa icon="chevron-left" class="fa-fw"></fa>
</h1>
</div>
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-4">
Your Identity
@ -96,19 +86,25 @@
class="text-red-500 fa-fw ml-8 mt-8 w-12 h-12"
/>
</span>
<span v-else>
<fa
icon="camera"
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md"
@click="openPhotoDialog"
/>
</span>
<GiftedPhotoDialog ref="photoDialog" />
<div v-else class="text-center">
<div class>
<fa
icon="camera"
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md"
@click="openPhotoDialog(undefined, undefined)"
/>
</div>
<div>
<input type="file" @change="uploadPhotoFile" />
</div>
</div>
<PhotoDialog ref="photoDialog" />
</div>
<div class="mt-4">
<div class="flex justify-center">
... and those without your image see this (if you let them see your
activity):
<div class="mt-6">
<div class="flex justify-center text-center">
People without your image see this:
<br />
(if you've let them see your activity)
</div>
<div class="flex justify-center">
<EntityIcon
@ -587,11 +583,11 @@
<div class="ml-4 mt-2">
Import
<input type="file" @change="uploadFile" class="ml-2" />
<input type="file" @change="uploadImportFile" class="ml-2" />
<div v-if="showContactImport()">
<button
class="block text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-6"
@click="confirmSubmitFile()"
@click="confirmSubmitImportFile()"
>
Import Settings & Contacts
<br />
@ -624,13 +620,14 @@ import { ref } from "vue";
import { Component, Vue } from "vue-facing-decorator";
import { useClipboard } from "@vueuse/core";
import GiftedPhotoDialog from "@/components/GiftedPhotoDialog.vue";
import PhotoDialog from "@/components/PhotoDialog.vue";
import QuickNav from "@/components/QuickNav.vue";
import TopMessage from "@/components/TopMessage.vue";
import {
AppString,
DEFAULT_IMAGE_API_SERVER,
DEFAULT_PUSH_SERVER,
IMAGE_TYPE_PROFILE,
NotificationIface,
} from "@/constants/app";
import { db, accountsDB } from "@/db/index";
@ -654,10 +651,11 @@ interface IAccount {
derivationPath: string;
}
const inputFileNameRef = ref<Blob>();
const inputImportFileNameRef = ref<Blob>();
const inputPhotoFileNameRef = ref<Blob>();
@Component({
components: { EntityIcon, GiftedPhotoDialog, QuickNav, TopMessage },
components: { EntityIcon, PhotoDialog, QuickNav, TopMessage },
})
export default class AccountViewView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
@ -1129,17 +1127,36 @@ export default class AccountViewView extends Vue {
console.error("Export Error:", error);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async uploadFile(event: any) {
inputFileNameRef.value = event.target.files[0];
async uploadPhotoFile(event: Event) {
inputPhotoFileNameRef.value = event.target.files[0];
// https://developer.mozilla.org/en-US/docs/Web/API/File
// ... plus it has a `type` property from my testing
const file = inputPhotoFileNameRef.value;
if (file != null) {
const reader = new FileReader();
reader.onload = async (e) => {
const data = e.target?.result as ArrayBuffer;
if (data) {
const blob = new Blob([new Uint8Array(data)], {
type: file.type,
});
this.openPhotoDialog(blob, file.name as string);
}
};
reader.readAsArrayBuffer(file as Blob);
}
}
async uploadImportFile(event: Event) {
inputImportFileNameRef.value = event.target.files[0];
}
showContactImport() {
return !!inputFileNameRef.value;
return !!inputImportFileNameRef.value;
}
confirmSubmitFile() {
if (inputFileNameRef.value != null) {
confirmSubmitImportFile() {
if (inputImportFileNameRef.value != null) {
this.$notify(
{
group: "modal",
@ -1148,7 +1165,7 @@ export default class AccountViewView extends Vue {
text:
"This will replace all settings and contacts, so we recommend you first do the backup step above." +
" Are you sure you want to import and replace all contacts and settings?",
onYes: this.submitFile,
onYes: this.submitImportFile,
},
-1,
);
@ -1160,10 +1177,10 @@ export default class AccountViewView extends Vue {
*
* @throws Will notify the user if there is an export error.
*/
async submitFile() {
if (inputFileNameRef.value != null) {
async submitImportFile() {
if (inputImportFileNameRef.value != null) {
await db.delete();
await Dexie.import(inputFileNameRef.value, {
await Dexie.import(inputImportFileNameRef.value as Blob, {
progressCallback: this.progressCallback,
});
}
@ -1376,8 +1393,8 @@ export default class AccountViewView extends Vue {
);
}
openPhotoDialog() {
(this.$refs.photoDialog as GiftedPhotoDialog).open(
openPhotoDialog(blob?: Blob, fileName?: string) {
(this.$refs.photoDialog as PhotoDialog).open(
async (imgUrl) => {
await db.open();
db.settings.update(MASTER_SETTINGS_KEY, {
@ -1387,7 +1404,9 @@ export default class AccountViewView extends Vue {
//console.log("Got image URL:", imgUrl);
},
true,
"profile",
IMAGE_TYPE_PROFILE,
blob,
fileName,
);
}

87
src/views/GiftedDetails.vue

@ -5,10 +5,13 @@
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Back -->
<div class="text-lg text-center font-light relative px-7">
<div
v-if="!hideBackButton"
class="text-lg text-center font-light relative px-7"
>
<h1
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="cancel()"
@click="cancelBack()"
>
<fa icon="chevron-left" class="fa-fw"></fa>
</h1>
@ -31,7 +34,7 @@
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center text-blue-500 px-2 py-2 w-20"
@click="changeUnitCode()"
>
{{ libsUtil.UNIT_SHORT[unitCode] }}
{{ libsUtil.UNIT_SHORT[unitCode] || unitCode }}
</span>
<div
class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2"
@ -71,7 +74,7 @@
/>
</span>
</div>
<GiftedPhotoDialog ref="photoDialog" />
<PhotoDialog ref="photoDialog" />
<div v-if="projectId" class="mt-4">
<fa
@ -127,11 +130,11 @@ import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import { createAndSubmitGive, getPlanFromCache } from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util";
import { accessToken } from "@/libs/crypto";
import GiftedPhotoDialog from "@/components/GiftedPhotoDialog.vue";
import PhotoDialog from "@/components/PhotoDialog.vue";
@Component({
components: {
GiftedPhotoDialog,
PhotoDialog,
QuickNav,
TopMessage,
},
@ -144,9 +147,11 @@ export default class GiftedDetails extends Vue {
amountInput = "0";
description = "";
destinationNameAfter = "";
givenToUser = false;
giverDid: string | undefined;
giverName = "";
hideBackButton = false;
imageUrl = "";
isTrade = false;
message = "";
@ -161,26 +166,39 @@ export default class GiftedDetails extends Vue {
libsUtil = libsUtil;
async mounted() {
this.amountInput = this.$route.query.amountInput as string;
this.description = this.$route.query.description as string;
this.amountInput =
(this.$route.query.amountInput as string) || this.amountInput;
this.description = (this.$route.query.description as string) || "";
this.destinationNameAfter = this.$route.query
.destinationNameAfter as string;
this.giverDid = this.$route.query.giverDid as string;
this.giverName = this.$route.query.giverName as string;
if (this.giverDid && !this.giverName) {
this.giverName =
this.giverDid === this.activeDid ? "you" : "someone not named";
}
this.message = this.$route.query.message as string;
this.giverName = (this.$route.query.giverName as string) || "";
this.hideBackButton = this.$route.query.hideBackButton === "true";
this.message = (this.$route.query.message as string) || "";
this.offerId = this.$route.query.offerId as string;
this.projectId = this.$route.query.projectId as string;
this.recipientDid = this.$route.query.recipientDid as string;
this.recipientName = this.$route.query.recipientName as string;
if (this.recipientDid && !this.recipientName) {
this.recipientName =
this.recipientDid === this.activeDid ? "you" : "someone not named";
this.recipientName = (this.$route.query.recipientName as string) || "";
this.unitCode = (this.$route.query.unitCode as string) || this.unitCode;
this.imageUrl =
(this.$route.query.imageUrl as string) ||
localStorage.getItem("imageUrl") ||
"";
// this is an endpoint for sharing project info to highlight something given
// https://developer.mozilla.org/en-US/docs/Web/Manifest/share_target
if (this.$route.query.shareTitle) {
this.description = this.$route.query.shareTitle as string;
}
if (this.$route.query.shareText) {
this.description =
(this.description ? this.description + " " : "") +
(this.$route.query.shareText as string);
}
if (this.$route.query.shareUrl) {
this.imageUrl = this.$route.query.shareUrl as string;
}
this.unitCode = this.$route.query.unitCode as string;
this.imageUrl = localStorage.getItem("imageUrl") || "";
try {
await db.open();
@ -188,7 +206,15 @@ export default class GiftedDetails extends Vue {
this.apiServer = settings?.apiServer || "";
this.activeDid = settings?.activeDid || "";
if (this.giverDid && !this.giverName) {
this.giverName =
this.giverDid === this.activeDid ? "you" : "someone not named";
}
this.givenToUser = this.recipientDid === this.activeDid;
if (this.recipientDid && !this.recipientName) {
this.recipientName =
this.recipientDid === this.activeDid ? "you" : "someone not named";
}
this.showGivenToUser =
!this.projectId && this.recipientDid === this.activeDid;
@ -240,12 +266,21 @@ export default class GiftedDetails extends Vue {
}
cancel() {
this.deleteImage(); // not awaiting, so they'll go back immediately
if (this.destinationNameAfter) {
this.$router.push({ name: this.destinationNameAfter });
} else {
this.$router.back();
}
}
cancelBack() {
this.deleteImage(); // not awaiting, so they'll go back immediately
this.$router.back();
}
openPhotoDialog() {
(this.$refs.photoDialog as GiftedPhotoDialog).open((imgUrl) => {
(this.$refs.photoDialog as PhotoDialog).open((imgUrl) => {
this.imageUrl = imgUrl;
});
}
@ -284,7 +319,7 @@ export default class GiftedDetails extends Vue {
// don't bother with a notification
// (either they'll simply continue or they're canceling and going back)
} else {
console.error("Non-success deleting image:", response);
console.error("Problem deleting image:", response);
this.$notify(
{
group: "alert",
@ -435,7 +470,11 @@ export default class GiftedDetails extends Vue {
5000,
);
localStorage.removeItem("imageUrl");
this.$router.back();
if (this.destinationNameAfter) {
this.$router.push({ name: this.destinationNameAfter });
} else {
this.$router.back();
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {

45
src/views/HelpView.vue

@ -100,8 +100,9 @@
<h2 class="text-xl font-semibold">How do I backup all my data?</h2>
<p>
There are two sets of data to backup: the identifier secrets and the
other data that isn't quite a secret such as settings, contacts, etc.
There are three sets of data to backup: the identifier secrets;
the non-public textual data that isn't quite a secret such as settings and contacts;
the non-public image for yourself; and the data that you have sent to the public.
</p>
<div class="px-4">
@ -122,7 +123,7 @@
</ul>
<h2 class="text-xl font-semibold">
How do I backup my other (non-identifier-secret) data?
How do I backup my non-secret, non-public text data?
</h2>
<ul class="list-disc list-outside ml-4">
<li>
@ -134,6 +135,27 @@
won't lose it.
</li>
</ul>
<h2 class="text-xl font-semibold">
How do I backup my non-secret, non-public image?
</h2>
<ul class="list-disc list-outside ml-4">
<li>
Go to Your Identity <fa icon="circle-user" class="fa-fw" /> page,
tap on your image, and save it.
</li>
</ul>
<h2 class="text-xl font-semibold">
How do I backup my public data?
</h2>
<ul class="list-disc list-outside ml-4">
<li>
This requires use of the API, so investigate the endpoints
<a href="https://api.endorser.ch/" target="_blank" class="text-blue-500">here</a>
(particularly the "claim" endpoints).
</li>
</ul>
</div>
<h2 class="text-xl font-semibold">How do I restore my data?</h2>
@ -178,8 +200,7 @@
<h2 class="text-xl font-semibold">How do I erase my data?</h2>
<p>
Before doing this, note the two kinds of data to backup: identity data,
and other data for contacts and settings (see instructions above).
Before doing this, you may want to back up your data with the instructions above.
</p>
<ul>
<li class="list-disc list-outside ml-4">
@ -198,11 +219,11 @@
<ul>
<li class="list-disc list-outside ml-4">
Chrome:
Clear at chrome://settings/content/all and
Clear at "chrome://settings/content/all" and
also clear under dev tools Application
</li>
<li class="list-disc list-outside ml-4">
Firefox: <a href="about:preferences">go here</a>, Manage Data,
Firefox: Navigate to "about:preferences", Manage Data,
find timesafari.app and select, hit Remove Selected, then Save
Changes
</li>
@ -232,7 +253,7 @@
<p>
There is a even more functionality in a mobile app (and more
documentation) at
<a href="https://endorser.ch" class="text-blue-500">
<a href="https://endorser.ch" target="_blank" class="text-blue-500">
EndorserSearch.com
</a>
</p>
@ -303,7 +324,7 @@
find "timesafari.app", and click "Unregister".
</li>
<li>
<a href="https://duckduckgo.com/?q=unregister+service+worker" class="text-blue-500">Search</a>
<a href="https://duckduckgo.com/?q=unregister+service+worker" target="_blank" class="text-blue-500">Search</a>
for instructions for other browsers.</li>
</ul>
Then reload Time Safari.
@ -344,7 +365,7 @@
by disabling notifications on the Account <fa icon="circle-user" class="fa-fw" /> page.
<br />
For all other claim data,
<a href="https://endorser.ch/privacy-policy" class="text-blue-500">
<a href="https://endorser.ch/privacy-policy" target="_blank" class="text-blue-500">
the Endorser Service has this Privacy Policy.
</a>
</p>
@ -352,7 +373,7 @@
<h2 class="text-xl font-semibold">Where can I read more?</h2>
<p>
This is part of the
<a href="https://livesofgiving.org" class="text-blue-500">
<a href="https://livesofgiving.org" target="_blank" class="text-blue-500">
Lives of Giving
</a>
initiative.
@ -362,7 +383,7 @@
<p>{{ package.version }} ({{ commitHash }})</p>
<h2 class="text-xl font-semibold">
For any other questions, including removing your data:
For any other questions, including removing all your data from the public ledger:
</h2>
<p>
Contact us at

2
src/views/NewEditProjectView.vue

@ -227,7 +227,7 @@ export default class NewEditProjectView extends Vue {
return headers;
}
async created() {
async mounted() {
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
this.activeDid = (settings?.activeDid as string) || "";

24
src/views/ProjectViewView.vue

@ -505,7 +505,7 @@ export default class ProjectViewView extends Vue {
title: "Error",
text: "There was a problem getting that project. See logs for more info.",
},
-1,
5000,
);
}
} catch (error: unknown) {
@ -519,7 +519,7 @@ export default class ProjectViewView extends Vue {
title: "Error",
text: "That project does not exist.",
},
-1,
5000,
);
} else {
this.$notify(
@ -529,7 +529,7 @@ export default class ProjectViewView extends Vue {
title: "Error",
text: "Something went wrong retrieving that project. See logs for more info.",
},
-1,
5000,
);
}
}
@ -561,7 +561,7 @@ export default class ProjectViewView extends Vue {
title: "Error",
text: "Failed to retrieve plans fulfilled by this project.",
},
-1,
5000,
);
}
} catch (error: unknown) {
@ -573,7 +573,7 @@ export default class ProjectViewView extends Vue {
title: "Error",
text: "Something went wrong retrieving plans fulfilled by this project.",
},
-1,
5000,
);
console.error(
"Error retrieving plans fulfilled by this project:",
@ -616,7 +616,7 @@ export default class ProjectViewView extends Vue {
title: "Error",
text: "Failed to retrieve more gives to this project.",
},
-1,
5000,
);
}
} catch (error: unknown) {
@ -628,7 +628,7 @@ export default class ProjectViewView extends Vue {
title: "Error",
text: "Something went wrong retrieving more gives to this project.",
},
-1,
5000,
);
console.error(
"Something went wrong retrieving more gives to this project:",
@ -671,7 +671,7 @@ export default class ProjectViewView extends Vue {
title: "Error",
text: "Failed to retrieve more offers to this project.",
},
-1,
5000,
);
}
} catch (error: unknown) {
@ -683,7 +683,7 @@ export default class ProjectViewView extends Vue {
title: "Error",
text: "Something went wrong retrieving more offers to this project.",
},
-1,
5000,
);
console.error(
"Something went wrong retrieving more offers to this project:",
@ -727,7 +727,7 @@ export default class ProjectViewView extends Vue {
title: "Error",
text: "Failed to retrieve more plans that fullfill this project.",
},
-1,
5000,
);
}
} catch (error: unknown) {
@ -739,7 +739,7 @@ export default class ProjectViewView extends Vue {
title: "Error",
text: "Something went wrong retrieving more plans that fulfull this project.",
},
-1,
5000,
);
console.error(
"Something went wrong retrieving more plans that fulfill this project:",
@ -928,7 +928,7 @@ export default class ProjectViewView extends Vue {
title: "Error",
text: message,
},
-1,
5000,
);
}
}

5
src/views/SeedBackupView.vue

@ -70,12 +70,9 @@ import * as R from "ramda";
import QuickNav from "@/components/QuickNav.vue";
import { NotificationIface } from "@/constants/app";
import { accountsDB, db } from "@/db/index";
import { Account } from "@/db/tables/accounts";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
interface Account {
mnemonic: string;
}
@Component({ components: { QuickNav } })
export default class SeedBackupView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;

188
src/views/SharedPhotoView.vue

@ -0,0 +1,188 @@
<template>
<QuickNav />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Image
</h1>
<div v-if="imageBlob">
<div v-if="uploading" class="text-center mb-4">
<fa icon="spinner" class="fa-spin-pulse" />
</div>
<div v-else>
<div class="text-center mb-4">Choose how to use this image</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<button
@click="recordGift"
class="text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
>
<fa icon="gift" class="fa-fw" />
Record a Gift
</button>
<button
@click="recordProfile"
class="text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
>
<fa icon="circle-user" class="fa-fw" />
Save as Profile Image
</button>
<button
@click="cancel"
class="text-center text-md font-bold bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
>
<fa icon="ban" class="fa-fw" />
Cancel
</button>
</div>
</div>
<div class="flex justify-center">
<img
:src="URL.createObjectURL(imageBlob)"
alt="Shared Image"
class="rounded mt-4"
/>
</div>
</div>
<div v-else class="text-center mb-4">
<p>No image found.</p>
</div>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import QuickNav from "@/components/QuickNav.vue";
import {
DEFAULT_IMAGE_API_SERVER,
IMAGE_TYPE_PROFILE,
NotificationIface,
} from "@/constants/app";
import { db } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { getIdentity } from "@/libs/util";
import { accessToken } from "@/libs/crypto";
import axios from "axios";
@Component({ components: { QuickNav } })
export default class SharedPhotoView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid: string | undefined = undefined;
imageBlob: Blob | undefined = undefined;
imageFileName: string | undefined = undefined;
uploading = false;
URL = window.URL || window.webkitURL;
// 'created' hook runs when the Vue instance is first created
async mounted() {
try {
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
this.activeDid = settings?.activeDid as string;
const temp = await db.temp.get("shared-photo");
if (temp) {
this.imageBlob = temp.blob;
// clear the temp image
db.temp.delete("shared-photo");
this.imageFileName = this.$route.query.fileName as string;
}
} catch (err: unknown) {
console.error("Got an error loading an identifier:", err);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Got an error loading this data.",
},
-1,
);
}
}
async recordGift() {
await this.sendToImageServer("GiveAction").then((url) => {
if (url) {
this.$router.push({
name: "gifted-details",
query: {
destinationNameAfter: "home",
hideBackButton: true,
imageUrl: url,
recipientDid: this.activeDid,
},
});
}
});
}
async recordProfile() {
await this.sendToImageServer(IMAGE_TYPE_PROFILE).then((url) => {
if (url) {
db.settings.update(MASTER_SETTINGS_KEY, {
profileImageUrl: url,
});
this.$router.push({ name: "account" });
}
});
}
async cancel() {
this.imageBlob = undefined;
this.imageFileName = undefined;
this.$router.push({ name: "home" });
}
async sendToImageServer(imageType: string) {
this.uploading = true;
let result;
try {
// send the image to the server
const identifier = await getIdentity(this.activeDid as string);
const token = await accessToken(identifier);
const headers = {
Authorization: "Bearer " + token,
};
const formData = new FormData();
formData.append(
"image",
this.imageBlob as Blob,
this.imageFileName as string,
);
formData.append("claimType", imageType);
const response = await axios.post(
DEFAULT_IMAGE_API_SERVER + "/image",
formData,
{ headers },
);
this.imageBlob = undefined;
this.imageFileName = undefined;
this.uploading = false;
result = response.data.url as string;
} catch (error) {
console.error("Error uploading the image", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was an error saving the picture. Please try again.",
},
5000,
);
this.uploading = false;
}
return result;
}
}
</script>

55
src/views/TestView.vue

@ -153,13 +153,66 @@
Notif OFF
</button>
</div>
<div>
<h2 class="text-xl font-bold mb-4">Share Image</h2>
Populates the "shared-photo" view as if they used "share_target".
<input type="file" @change="uploadFile" />
<router-link
v-if="showFileNextStep()"
:to="{
name: 'shared-photo',
query: { fileName },
}"
class="block w-full text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-2 mt-2"
>
Go to Shared Page
</router-link>
</div>
</section>
</template>
<script lang="ts">
import { ref } from "vue";
import { Component, Vue } from "vue-facing-decorator";
import QuickNav from "@/components/QuickNav.vue";
import { db } from "@/db/index";
const inputFileNameRef = ref<Blob>();
@Component({ components: { QuickNav } })
export default class Help extends Vue {}
export default class Help extends Vue {
fileName?: string;
async uploadFile(event: Event) {
inputFileNameRef.value = event.target.files[0];
// https://developer.mozilla.org/en-US/docs/Web/API/File
// ... plus it has a `type` property from my testing
const file = inputFileNameRef.value;
if (file != null) {
const reader = new FileReader();
reader.onload = async (e) => {
const data = e.target?.result as ArrayBuffer;
if (data) {
const blob = new Blob([new Uint8Array(data)], {
type: file.type,
});
this.fileName = file.name as string;
const temp = await db.temp.get("shared-photo");
if (temp) {
await db.temp.update("shared-photo", { blob });
} else {
await db.temp.add({ id: "shared-photo", blob });
}
}
};
reader.readAsArrayBuffer(file as Blob);
}
}
showFileNextStep() {
return !!inputFileNameRef.value;
}
}
</script>

21
sw_scripts/additional-scripts.js

@ -81,7 +81,7 @@ self.addEventListener("push", function (event) {
} else {
title = payload.title || "Update";
}
// getNotificationCount is injected from safari-notifications.js at build time by the vue.config.js configureWebpack apply plugin
// getNotificationCount is injected from safari-notifications.js at build time by the sw_combine.js script
// eslint-disable-next-line no-undef
message = await getNotificationCount();
}
@ -131,8 +131,27 @@ self.addEventListener("notificationclick", (event) => {
);
});
// This is invoked when the user chooses this as a share_target, mapped to share-target in the manifest.
self.addEventListener("fetch", (event) => {
logConsoleAndDb("Service worker got fetch event.", event);
// Regular requests not related to Web Share Target.
if (event.request.method !== "POST") {
event.respondWith(fetch(event.request));
return;
}
// Requests related to Web Share share-target Target.
event.respondWith(
(async () => {
const formData = await event.request.formData();
const photo = formData.get("photo");
// savePhoto is injected from safari-notifications.js at build time by the sw_combine.js script
// eslint-disable-next-line no-undef
await savePhoto(photo);
return Response.redirect("/shared-photo", 303);
})(),
);
});
self.addEventListener("error", (event) => {

14
sw_scripts/safari-notifications.js

@ -566,6 +566,20 @@ async function getNotificationCount() {
return result;
}
// Store the image blob and go immediate to a page to upload it.
// @param photo - image Blob to store for later retrieval after redirect
async function savePhoto(photo) {
try {
const db = await openIndexedDB("TimeSafari");
const transaction = db.transaction("temp", "readwrite");
const store = transaction.objectStore("temp");
await updateRecord(store, { id: "shared-photo", blob: photo });
transaction.oncomplete = () => db.close();
} catch (error) {
console.error("safari-notifications logMessage IndexedDB error", error);
}
}
self.appendDailyLog = appendDailyLog;
self.getNotificationCount = getNotificationCount;
self.decodeBase64 = decodeBase64;

21
vite.config.mjs

@ -17,6 +17,27 @@ export default defineConfig({
filename: 'sw_scripts-combined.js',
manifest: {
name: process.env.TIME_SAFARI_APP_TITLE || require('./package.json').name,
short_name: process.env.TIME_SAFARI_APP_TITLE || require('./package.json').name,
// 192x192 and 512x512 are important for Chrome to show that it's installable
"icons":[
{"src":"./img/icons/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},
{"src":"./img/icons/android-chrome-512x512.png","sizes":"512x512","type":"image/png"},
{"src":"./img/icons/android-chrome-maskable-192x192.png","sizes":"192x192","type":"image/png","purpose":"maskable"},
{"src":"./img/icons/android-chrome-maskable-512x512.png","sizes":"512x512","type":"image/png","purpose":"maskable"}
],
share_target: {
action: '/share-target',
method: 'POST',
enctype: 'multipart/form-data',
params: {
files: [
{
name: 'photo',
accept: ['image/jpg', 'image/jpeg', 'image/png'],
},
],
},
},
},
}),
],

Loading…
Cancel
Save