Browse Source

add a share_target for people to add a photo

kb/add-usage-guide
Trent Larson 4 months ago
parent
commit
aa7d82c531
  1. 2
      src/components/GiftedPhotoDialog.vue
  2. 2
      src/constants/app.ts
  3. 26
      src/db/index.ts
  4. 13
      src/db/tables/temp.ts
  5. 5
      src/router/index.ts
  6. 18
      src/views/AccountViewView.vue
  7. 19
      src/views/GiftedDetails.vue
  8. 45
      src/views/HelpView.vue
  9. 2
      src/views/NewEditProjectView.vue
  10. 24
      src/views/ProjectViewView.vue
  11. 5
      src/views/SeedBackupView.vue
  12. 164
      src/views/SharedPhotoView.vue
  13. 55
      src/views/TestView.vue
  14. 21
      sw_scripts/additional-scripts.js
  15. 14
      sw_scripts/safari-notifications.js
  16. 21
      vite.config.mjs

2
src/components/GiftedPhotoDialog.vue

@ -330,7 +330,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, "snapshot.png");
formData.append("claimType", this.claimType);
try {
const response = await axios.post(

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",

18
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
@ -631,6 +621,7 @@ import {
AppString,
DEFAULT_IMAGE_API_SERVER,
DEFAULT_PUSH_SERVER,
IMAGE_TYPE_PROFILE,
NotificationIface,
} from "@/constants/app";
import { db, accountsDB } from "@/db/index";
@ -1129,8 +1120,7 @@ export default class AccountViewView extends Vue {
console.error("Export Error:", error);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async uploadFile(event: any) {
async uploadFile(event: Event) {
inputFileNameRef.value = event.target.files[0];
}
@ -1163,7 +1153,7 @@ export default class AccountViewView extends Vue {
async submitFile() {
if (inputFileNameRef.value != null) {
await db.delete();
await Dexie.import(inputFileNameRef.value, {
await Dexie.import(inputFileNameRef.value as Blob, {
progressCallback: this.progressCallback,
});
}
@ -1387,7 +1377,7 @@ export default class AccountViewView extends Vue {
//console.log("Got image URL:", imgUrl);
},
true,
"profile",
IMAGE_TYPE_PROFILE,
);
}

19
src/views/GiftedDetails.vue

@ -180,7 +180,24 @@ export default class GiftedDetails extends Vue {
}
this.unitCode = this.$route.query.unitCode as string;
this.imageUrl = localStorage.getItem("imageUrl") || "";
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;
}
try {
await db.open();

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;

164
src/views/SharedPhotoView.vue

@ -0,0 +1,164 @@
<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">
<fa icon="spinner" class="fa-spin-pulse" />
</div>
<div v-else>
Choose the purpose of this image:
<br />
<button @click="recordGift">Record a Gift</button>
<br />
<button @click="recordProfile">Save as Profile Image</button>
<br />
<button @click="cancel">Cancel</button>
</div>
<img
:src="URL.createObjectURL(imageBlob)"
alt="Shared Image"
class="rounded"
/>
</div>
<div v-else>
<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: { imageUrl: url },
});
}
});
}
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