Browse Source

Merge branch 'master' into button-visual-enhancement

kb/add-usage-guide
Jose Olarte III 8 months ago
parent
commit
3d1c46aef8
  1. 7
      .env.development
  2. 4
      .env.production
  3. 17
      CHANGELOG.md
  4. 23
      README.md
  5. 14
      package-lock.json
  6. 5
      package.json
  7. 30
      project.task.yaml
  8. 31
      src/App.vue
  9. 37
      src/components/GiftedDialog.vue
  10. 314
      src/components/GiftedPhotoDialog.vue
  11. 13
      src/constants/app.ts
  12. 22
      src/libs/endorserServer.ts
  13. 6
      src/main.ts
  14. 8
      src/router/index.ts
  15. 71
      src/views/AccountViewView.vue
  16. 428
      src/views/GiftedDetails.vue
  17. 85
      src/views/HelpView.vue
  18. 7
      src/views/HomeView.vue
  19. 9
      src/views/ProjectViewView.vue
  20. 3
      vue.config.js

7
.env.development

@ -0,0 +1,7 @@
# I tried setting values here and using `vue-cli-service build --mode development`
# but it didn't create some things in "dist":
# - the "css" directory with the CSS extracted from Vue files
# - the sw_scripts-combined* files
#
# ¯\_(ツ)_/¯

4
.env.production

@ -0,0 +1,4 @@
# Only the variables that start with VUE_APP_ are seen in the application process.env in Vue.
VUE_APP_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01GXYPFF7FA03NXKPYY142PY4H
VUE_APP_DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch
VUE_APP_DEFAULT_IMAGE_API_SERVER=https://image-api.timesafari.app

17
CHANGELOG.md

@ -6,11 +6,22 @@ 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).
## [Unreleased] ## [Unreleased]
### Changed in DB ### Changed in DB or environment
- ? - Nothing
## [0.3.3] - 2024.03.18
### Added
- Photo on gift record
### Fixed
- Environment variable for BVC meetings project
### Changed in DB or environment
- New environment variable for image API server
- Test that a new browser session will get the right default API.
- Test that a new browser session will send the right BVC meetings project.
## [0.2.17] - 2024.03.01- 3612ea42240c5e1b7d7eff29a39ff18f1b869b36 ## [0.2.17] - 2024.03.01 - 3612ea42240c5e1b7d7eff29a39ff18f1b869b36
### Added ### Added
- Shortcut page for Bountiful Voluntaryist Community - Shortcut page for Bountiful Voluntaryist Community
### Changed ### Changed

23
README.md

@ -34,19 +34,32 @@ npm run lint
* Update the project.task.yaml & CHANGELOG.md & the version in package.json, run `npm install`. * Update the project.task.yaml & CHANGELOG.md & the version in package.json, run `npm install`.
* If production: change src/constants/app.ts DEFAULT_*_SERVER to be "PROD" and package.json to remove "_Test". Also record what version is on production. * Record what version is currently on production.
* `npm run build` * Run the correct build
* Get on the server and back up the time-safari folder. * Test
```
# (See .env.development for more details.)
# The test BVC_MEETUPS_PROJECT_CLAIM_ID does not resolve as a URL because it's only in the test DB and the prod redirect won't redirect there.
TIME_SAFARI_APP_TITLE="TimeSafari_Test" VUE_APP_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HNTZYJJXTGT0EZS3VEJGX7AK VUE_APP_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VUE_APP_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app npm run build
```
* Production
```
# This picks up values from .env.production
npm run build
```
* Get on the server and back up the DB and the time-safari folder.
* `rsync -azvu -e "ssh -i ~/.ssh/..." dist ubuntutest@test.timesafari.app:time-safari` * `rsync -azvu -e "ssh -i ~/.ssh/..." dist ubuntutest@test.timesafari.app:time-safari`
* Revert src/constants/app.ts and package.json (if that was prod). * Revert src/constants/app.ts and package.json (if that was prod).
* Commit changes. Record the new hash in the changelog. Edit package.json to increment version & add "-beta", `npm install`, and commit. Tag if you didn't before. Also record what version is on production. * Commit changes. Record the new hash in the changelog. Edit package.json to increment version & add "-beta", `npm install`, and commit. Also record what version is on production.
* [Tag wth the new version.](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/releases) * [Tag with the new version.](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/releases)

14
package-lock.json

@ -1,12 +1,12 @@
{ {
"name": "TimeSafari_Test", "name": "TimeSafari",
"version": "0.2.18-beta", "version": "0.3.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "TimeSafari_Test", "name": "TimeSafari",
"version": "0.2.18-beta", "version": "0.3.3",
"dependencies": { "dependencies": {
"@dicebear/collection": "^5.3.5", "@dicebear/collection": "^5.3.5",
"@dicebear/core": "^5.3.5", "@dicebear/core": "^5.3.5",
@ -54,6 +54,7 @@
"readable-stream": "^4.4.2", "readable-stream": "^4.4.2",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"register-service-worker": "^1.7.2", "register-service-worker": "^1.7.2",
"simple-vue-camera": "^1.1.3",
"three": "^0.156.1", "three": "^0.156.1",
"ua-parser-js": "^1.0.37", "ua-parser-js": "^1.0.37",
"util": "^0.12.5", "util": "^0.12.5",
@ -25438,6 +25439,11 @@
"node": ">= 5.10.0" "node": ">= 5.10.0"
} }
}, },
"node_modules/simple-vue-camera": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/simple-vue-camera/-/simple-vue-camera-1.1.3.tgz",
"integrity": "sha512-GVAYq1BMI9cHt+h24tu2dfIFFvhjVQ1M8IkK5LmrKcYoBA8FZlLNlhrHC2NnTPbMAXIvJn1Bqx8X6Q31+Y2+jA=="
},
"node_modules/sirv": { "node_modules/sirv": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.3.tgz", "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.3.tgz",

5
package.json

@ -1,6 +1,6 @@
{ {
"name": "TimeSafari_Test", "name": "TimeSafari",
"version": "0.2.18-beta", "version": "0.3.3",
"private": true, "private": true,
"scripts": { "scripts": {
"serve": "vue-cli-service serve", "serve": "vue-cli-service serve",
@ -54,6 +54,7 @@
"readable-stream": "^4.4.2", "readable-stream": "^4.4.2",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"register-service-worker": "^1.7.2", "register-service-worker": "^1.7.2",
"simple-vue-camera": "^1.1.3",
"three": "^0.156.1", "three": "^0.156.1",
"ua-parser-js": "^1.0.37", "ua-parser-js": "^1.0.37",
"util": "^0.12.5", "util": "^0.12.5",

30
project.task.yaml

@ -1,6 +1,17 @@
tasks : tasks :
- bug - landscape doesn't show full camera
- but - portrait stretches pic
- add to readme - check version, close tabs & restart phone if necessary
- bug maybe - a new give remembers the previous project
- alert & stop if give amount < 0
- add warning that all data (except ID) is public
- onboarding video
- .1 on feed, don't show "to someone anonymous" if it's to a project
- .1 on ideas, put an "x" to close it
- .2 fix give dialog from "more contacts" off home page to allow giving to this user - .2 fix give dialog from "more contacts" off home page to allow giving to this user
- .2 fix bottom of project selection map, where the icons are hidden but a tap goes to the icon's page - .2 fix bottom of project selection map, where the icons are hidden but a tap goes to the icon's page
- .5 stop from seeing an error on the first page when browser doesn't support service workers (which I've seen on iPhone; visible in Firefox private window) - .5 stop from seeing an error on the first page when browser doesn't support service workers (which I've seen on iPhone; visible in Firefox private window)
@ -8,15 +19,12 @@ tasks :
- .2 anchor hash into BTC - .2 anchor hash into BTC
- .2 list the "show more" contacts alphabetically - .2 list the "show more" contacts alphabetically
- 32 image on give : - .5 make Time Safari a share_target for images
- Show a camera to take a picture
- Scale the image to a reasonable size - 08 add image on profile
- Upload to a public readable place
- check the rate limits - ask to detect location & record it in settings
- use CID (hash?) - if personal location is set, show potential local affiliations
- put the image URL in the claim
- Rates - images erased?
- image not associated with JWT ULID since that's assigned later
- 24 compelling UI for credential presentations - 24 compelling UI for credential presentations
- discover who in my network has activity on a project - discover who in my network has activity on a project
@ -37,6 +45,7 @@ tasks :
- randomize (not show in order) - randomize (not show in order)
- checkboxes - show non-person-oriented messages, show only contacts, show only projects - checkboxes - show non-person-oriented messages, show only contacts, show only projects
- .5 add a notice on the front page if their notifications are off
- 08 allow user to add a time when they want their daily notification - 08 allow user to add a time when they want their daily notification
- .5 prompt for the name directly when they visit the QR scan page - .5 prompt for the name directly when they visit the QR scan page
@ -50,7 +59,7 @@ tasks :
- .1 hide project-create button on project page if not registered - .1 hide project-create button on project page if not registered
- .1 hide offer & give buttons on project list page if not registered - .1 hide offer & give buttons on project list page if not registered
- .1 add cursor-pointer on the icons for giving on the project page, and on the list of projects on the discover page - .1 add cursor-pointer on the icons for giving on the project page, and on the list of projects on the discover page
- .2 record when InfiniteScroll hits the end of the list and don't trigger any more loads - .2 record when InfiniteScroll hits the end of the list and don't trigger any more loads (feed, project list, give & offer lists)
- bug - turning notifications on from the help screen did not stay, though account screen toggle did stay (From Jason on iPhone.) - bug - turning notifications on from the help screen did not stay, though account screen toggle did stay (From Jason on iPhone.)
- refactor - supply the projectId to the OfferDialog just like we do with the GiftedDialog offerId (in the "open" method, maybe as well as an attribute) - refactor - supply the projectId to the OfferDialog just like we do with the GiftedDialog offerId (in the "open" method, maybe as well as an attribute)
@ -114,6 +123,7 @@ tasks :
- .5 show seed phrase in a QR code for transfer to another device - .5 show seed phrase in a QR code for transfer to another device
- .5 on DiscoverView, switch to a filter UI (eg. just from friend - .5 on DiscoverView, switch to a filter UI (eg. just from friend
- .5 don't show "Offer" on project screen if they aren't registered - .5 don't show "Offer" on project screen if they aren't registered
- 01 especially for iOS, check for new version & update, eg. https://stackoverflow.com/questions/52221805/any-way-yet-to-auto-update-or-just-clear-the-cache-on-a-pwa-on-ios
- 24 Move to Vite - 24 Move to Vite
- 32 accept images for projects - 32 accept images for projects

31
src/App.vue

@ -148,6 +148,37 @@
class="w-full" class="w-full"
role="alert" role="alert"
> >
<div
v-if="notification.type === 'confirm'"
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
>
<div
class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg"
>
<div class="w-full px-6 py-6 text-slate-900 text-center">
<p class="text-lg mb-4">
{{ notification.title }}
</p>
<button
@click="
notification.onYes();
close(notification.id);
"
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
>
Yes
</button>
<button
@click="close(notification.id)"
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md"
>
Cancel
</button>
</div>
</div>
</div>
<div <div
v-if="notification.type === 'notification-permission'" v-if="notification.type === 'notification-permission'"
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"

37
src/components/GiftedDialog.vue

@ -10,23 +10,22 @@
placeholder="What was received" placeholder="What was received"
v-model="description" v-model="description"
/> />
<div class="flex flex-row"> <div class="flex flex-row justify-center">
<span <span
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 w-1/3 text-center text-blue-500 px-2 py-2" 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()" @click="changeUnitCode()"
> >
{{ libsUtil.UNIT_SHORT[unitCode] }} {{ libsUtil.UNIT_SHORT[unitCode] }}
</span> </span>
<div <div
class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2" class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2"
@click="decrement()" @click="amountInput === '0' ? null : decrement()"
v-if="amountInput !== '0'"
> >
<fa icon="chevron-left" /> <fa icon="chevron-left" />
</div> </div>
<input <input
type="number" type="number"
class="w-full border border-r-0 border-slate-400 px-2 py-2 text-center" class="border border-r-0 border-slate-400 px-2 py-2 text-center w-20"
v-model="amountInput" v-model="amountInput"
/> />
<div <div
@ -36,14 +35,26 @@
<fa icon="chevron-right" /> <fa icon="chevron-right" />
</div> </div>
</div> </div>
<div class="mt-2 text-right"> <div class="mt-4 flex justify-center">
<span v-if="showGivenToUser" class="mr-16">
<input type="checkbox" class="mr-2" v-model="givenToUser" />
<label class="text-sm">Given to you</label>
</span>
<span> <span>
<input type="checkbox" class="mr-2" v-model="isTrade" /> <router-link
<label class="text-sm">Trade (not a gift)</label> :to="{
name: 'gifted-details',
query: {
amountInput,
description,
giverDid: giver?.did,
giverName: giver?.name,
message,
offerId,
projectId,
unitCode,
},
}"
class="text-blue-500"
>
More Options
</router-link>
</span> </span>
</div> </div>
<p class="text-center mb-2 mt-6 italic"> <p class="text-center mb-2 mt-6 italic">
@ -93,9 +104,9 @@ export default class GiftedDialog extends Vue {
apiServer = ""; apiServer = "";
amountInput = "0"; amountInput = "0";
giver?: GiverInputInfo; // undefined means no identified giver agent
description = ""; description = "";
givenToUser = false; givenToUser = false;
giver?: GiverInputInfo; // undefined means no identified giver agent
isTrade = false; isTrade = false;
offerId = ""; offerId = "";
unitCode = "HUR"; unitCode = "HUR";

314
src/components/GiftedPhotoDialog.vue

@ -0,0 +1,314 @@
<template>
<div v-if="visible" class="dialog-overlay z-[60]">
<div class="dialog relative">
<div class="text-lg text-center font-light relative z-50">
<div
id="ViewHeading"
class="text-center font-bold absolute top-0 left-0 right-0 px-4 py-2 bg-black/50 text-white leading-none"
>
<span v-if="uploading"> Uploading... </span>
<span v-else-if="blob"> Look Good? </span>
<span v-else> Say "Cheese"! </span>
</div>
<div
class="text-lg text-center p-2 leading-none absolute right-0 top-0 text-white"
@click="close()"
>
<fa icon="xmark" class="w-[1em]"></fa>
</div>
</div>
<div v-if="uploading" class="flex justify-center">
<fa icon="spinner" class="fa-spin fa-3x text-center block" />
</div>
<div v-else-if="blob">
<div
class="flex justify-center gap-2 absolute bottom-[1rem] left-[1rem] right-[1rem] bg-black/50 px-4 py-2"
>
<button
@click="uploadImage"
class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-full"
>
<span>Upload</span>
</button>
<button
@click="retryImage"
class="bg-slate-500 hover:bg-slate-700 text-white font-bold py-2 px-4 rounded-full"
>
<span>Retry</span>
</button>
</div>
<div class="flex justify-center">
<img :src="URL.createObjectURL(blob)" class="mt-2 rounded" />
</div>
</div>
<div v-else>
<!--
Camera "resolution" doesn't change how it shows on screen but rather stretches the result, eg the following which just stretches it vertically:
:resolution="{ width: 375, height: 812 }"
-->
<camera facingMode="environment" autoplay ref="camera">
<div
class="absolute portrait:bottom-0 portrait:left-0 portrait:right-0 landscape:right-0 landscape:top-0 landscape:bottom-0 flex landscape:flex-row justify-center items-center portrait:pb-2 landscape:pr-4"
>
<button
@click="takeImage"
class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none"
>
<fa icon="camera" class="w-[1em]"></fa>
</button>
</div>
</camera>
</div>
</div>
</div>
</template>
<script lang="ts">
import axios from "axios";
import Camera from "simple-vue-camera";
import { Component, Vue } from "vue-facing-decorator";
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "@/constants/app";
import { getIdentity } from "@/libs/util";
import { db } from "@/db/index";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto";
@Component({ components: { Camera } })
export default class GiftedPhotoDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid = "";
blob: Blob | null = null;
setImage: (arg: string) => void = () => {};
imageHeight?: number = window.innerHeight / 2;
imageWidth?: number = window.innerWidth / 2;
imageWarning = ".";
uploading = false;
visible = false;
URL = window.URL || window.webkitURL;
async mounted() {
try {
await db.open();
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
this.activeDid = settings?.activeDid || "";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
console.error("Error retrieving settings from database:", err);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: err.message || "There was an error retrieving your settings.",
},
-1,
);
}
}
open(setImageFn: (arg: string) => void) {
this.visible = true;
const bottomNav = document.querySelector("#QuickNav") as HTMLElement;
if (bottomNav) {
bottomNav.style.display = "none";
}
this.setImage = setImageFn;
}
close() {
this.visible = false;
const bottomNav = document.querySelector("#QuickNav") as HTMLElement;
if (bottomNav) {
bottomNav.style.display = "";
}
this.blob = null;
}
async takeImage(/* payload: MouseEvent */) {
const cameraComponent = this.$refs.camera as InstanceType<typeof Camera>;
/**
* This logic to set the image height & width correctly.
* Without it, the portrait orientation ends up with an image that is stretched horizontally.
* Note that it's the same with raw browser Javascript; see the "drawImage" example below.
* Now that I've done it, I can't explain why it works.
*/
let imageHeight = cameraComponent?.resolution?.height;
let imageWidth = cameraComponent?.resolution?.width;
const initialImageRatio = imageWidth / imageHeight;
const windowRatio = window.innerWidth / window.innerHeight;
if (initialImageRatio > 1 && windowRatio < 1) {
// the image is wider than it is tall, and the window is taller than it is wide
// For some reason, mobile in portrait orientation renders a horizontally-stretched image.
// We're gonna force it opposite.
imageHeight = cameraComponent?.resolution?.width;
imageWidth = cameraComponent?.resolution?.height;
} else if (initialImageRatio < 1 && windowRatio > 1) {
// the image is taller than it is wide, and the window is wider than it is tall
// Haven't seen this happen, but we'll do it just in case.
imageHeight = cameraComponent?.resolution?.width;
imageWidth = cameraComponent?.resolution?.height;
}
const newImageRatio = imageWidth / imageHeight;
if (newImageRatio < windowRatio) {
// the image is a taller ratio than the window, so fit the height first
imageHeight = window.innerHeight / 2;
imageWidth = imageHeight * newImageRatio;
} else {
// the image is a wider ratio than the window, so fit the width first
imageWidth = window.innerWidth / 2;
imageHeight = imageWidth / newImageRatio;
}
// 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
if (!this.blob) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was an error taking the picture. Please try again.",
},
5000,
);
return;
}
}
async retryImage() {
this.blob = null;
}
/****
Here's an approach to photo capture without a library. It has similar quirks.
Now that we've fixed styling for simple-vue-camera, it's not critical to refactor. Maybe someday.
<button id="start-camera" @click="cameraClicked">Start Camera</button>
<video id="video" width="320" height="240" autoplay></video>
<button id="snap-photo" @click="photoSnapped">Snap Photo</button>
<canvas id="canvas" width="320" height="240"></canvas>
async cameraClicked() {
console.log("camera_button clicked");
const video = document.querySelector("#video");
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: false,
});
if (video instanceof HTMLVideoElement) {
video.srcObject = stream;
}
}
photoSnapped() {
console.log("snap_photo clicked");
const video = document.querySelector("#video");
const canvas = document.querySelector("#canvas");
if (
canvas instanceof HTMLCanvasElement &&
video instanceof HTMLVideoElement
) {
canvas
?.getContext("2d")
?.drawImage(video, 0, 0, canvas.width, canvas.height);
// ... or set the blob:
// canvas?.toBlob(
// (blob) => {
// this.blob = blob;
// },
// "image/jpeg",
// 1,
// );
// data url of the image
const image_data_url = canvas?.toDataURL("image/jpeg");
console.log(image_data_url);
}
}
****/
async uploadImage() {
this.uploading = true;
const identifier = await getIdentity(this.activeDid);
const token = await accessToken(identifier);
const headers = {
Authorization: "Bearer " + token,
};
const formData = new FormData();
if (!this.blob) {
// yeah, this should never happen, but it helps with subsequent type checking
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was an error finding the picture. Please try again.",
},
5000,
);
this.uploading = false;
return;
}
formData.append("image", this.blob, "snapshot.png"); // png is set in snapshot()
formData.append("claimType", "GiveAction");
try {
const response = await axios.post(
DEFAULT_IMAGE_API_SERVER + "/image",
formData,
{ headers },
);
this.uploading = false;
this.visible = false;
this.blob = null;
this.setImage(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;
this.blob = null;
}
}
}
</script>
<style>
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem;
}
.dialog {
background-color: white;
padding: 1rem;
border-radius: 0.5rem;
width: 100%;
max-width: 700px;
}
</style>

13
src/constants/app.ts

@ -12,10 +12,20 @@ export enum AppString {
TEST1_PUSH_SERVER = "https://test.timesafari.app", TEST1_PUSH_SERVER = "https://test.timesafari.app",
TEST2_PUSH_SERVER = "https://timesafari-pwa.anomalistlabs.com", TEST2_PUSH_SERVER = "https://timesafari-pwa.anomalistlabs.com",
PROD_IMAGE_API_SERVER = "https://image-api.timesafari.app",
TEST_IMAGE_API_SERVER = "https://test-image-api.timesafari.app",
LOCAL_IMAGE_API_SERVER = "http://localhost:3001",
NO_CONTACT_NAME = "(no name)", NO_CONTACT_NAME = "(no name)",
} }
export const DEFAULT_ENDORSER_API_SERVER = AppString.TEST_ENDORSER_API_SERVER; export const DEFAULT_ENDORSER_API_SERVER =
process.env.VUE_APP_DEFAULT_ENDORSER_API_SERVER ||
AppString.TEST_ENDORSER_API_SERVER;
export const DEFAULT_IMAGE_API_SERVER =
process.env.VUE_APP_DEFAULT_IMAGE_API_SERVER ||
AppString.TEST_IMAGE_API_SERVER;
export const DEFAULT_PUSH_SERVER = export const DEFAULT_PUSH_SERVER =
window.location.protocol + "//" + window.location.host; window.location.protocol + "//" + window.location.host;
@ -29,4 +39,5 @@ export interface NotificationIface {
type: string; // "toast" | "info" | "success" | "warning" | "danger" type: string; // "toast" | "info" | "success" | "warning" | "danger"
title: string; title: string;
text: string; text: string;
onYes?: () => Promise<void>;
} }

22
src/libs/endorserServer.ts

@ -67,6 +67,7 @@ export const BLANK_GENERIC_SERVER_RECORD: GenericServerRecord = {
issuer: "", issuer: "",
}; };
// a summary record; the VC is found the fullClaim field
export interface GiveServerRecord { export interface GiveServerRecord {
agentDid: string; agentDid: string;
amount: number; amount: number;
@ -81,6 +82,7 @@ export interface GiveServerRecord {
unit: string; unit: string;
} }
// a summary record; the VC is found the fullClaim field
export interface OfferServerRecord { export interface OfferServerRecord {
amount: number; amount: number;
amountGiven: number; amountGiven: number;
@ -98,13 +100,14 @@ export interface OfferServerRecord {
validThrough: string; validThrough: string;
} }
// a summary record; the VC is not currently part of this record
export interface PlanServerRecord { export interface PlanServerRecord {
agentDid?: string; // optional, if the issuer wants someone else to manage as well agentDid?: string; // optional, if the issuer wants someone else to manage as well
description: string; description: string;
endTime?: string; endTime?: string;
fulfillsPlanHandleId: string; fulfillsPlanHandleId: string;
issuerDid: string;
handleId: string; handleId: string;
issuerDid: string;
locLat?: number; locLat?: number;
locLon?: number; locLon?: number;
startTime?: string; startTime?: string;
@ -120,6 +123,7 @@ export interface GiveVerifiableCredential {
description?: string; description?: string;
fulfills?: { "@type": string; identifier?: string; lastClaimId?: string }[]; fulfills?: { "@type": string; identifier?: string; lastClaimId?: string }[];
identifier?: string; identifier?: string;
image?: string;
object?: { amountOfThisGood: number; unitCode: string }; object?: { amountOfThisGood: number; unitCode: string };
recipient?: { identifier: string }; recipient?: { identifier: string };
} }
@ -183,7 +187,7 @@ export interface PlanData {
rowid?: string; rowid?: string;
} }
export interface RateLimits { export interface EndorserRateLimits {
doneClaimsThisWeek: string; doneClaimsThisWeek: string;
doneRegistrationsThisMonth: string; doneRegistrationsThisMonth: string;
maxClaimsPerWeek: string; maxClaimsPerWeek: string;
@ -192,6 +196,12 @@ export interface RateLimits {
nextWeekBeginDateTime: string; nextWeekBeginDateTime: string;
} }
export interface ImageRateLimits {
doneImagesThisWeek: string;
maxImagesPerWeek: string;
nextWeekBeginDateTime: string;
}
export interface VerifiableCredential { export interface VerifiableCredential {
"@context": string; "@context": string;
"@type": string; "@type": string;
@ -434,6 +444,7 @@ export async function createAndSubmitGive(
fulfillsProjectHandleId?: string, fulfillsProjectHandleId?: string,
fulfillsOfferHandleId?: string, fulfillsOfferHandleId?: string,
isTrade: boolean = false, isTrade: boolean = false,
imageUrl?: string,
): Promise<CreateAndSubmitClaimResult> { ): Promise<CreateAndSubmitClaimResult> {
const vcClaim: GiveVerifiableCredential = { const vcClaim: GiveVerifiableCredential = {
"@context": "https://schema.org", "@context": "https://schema.org",
@ -460,6 +471,9 @@ export async function createAndSubmitGive(
identifier: fulfillsOfferHandleId, identifier: fulfillsOfferHandleId,
}); });
} }
if (imageUrl) {
vcClaim.image = imageUrl;
}
return createAndSubmitClaim( return createAndSubmitClaim(
vcClaim as GenericServerRecord, vcClaim as GenericServerRecord,
identity, identity,
@ -780,8 +794,8 @@ export const claimSpecialDescription = (
}; };
export const BVC_MEETUPS_PROJECT_CLAIM_ID = export const BVC_MEETUPS_PROJECT_CLAIM_ID =
//"https://endorser.ch/entity/01GXYPFF7FA03NXKPYY142PY4H"; process.env.VUE_APP_BVC_MEETUPS_PROJECT_CLAIM_ID ||
"https://endorser.ch/entity/01HNTZYJJXTGT0EZS3VEJGX7AK"; "https://endorser.ch/entity/01HNTZYJJXTGT0EZS3VEJGX7AK"; // this won't resolve as a URL on production; it's a URN only found in the test system
export const bvcMeetingJoinClaim = (did: string, startTime: string) => { export const bvcMeetingJoinClaim = (did: string, startTime: string) => {
return { return {

6
src/main.ts

@ -18,6 +18,8 @@ import {
faBitcoinSign, faBitcoinSign,
faBurst, faBurst,
faCalendar, faCalendar,
faCamera,
faCheck,
faChevronLeft, faChevronLeft,
faChevronRight, faChevronRight,
faCircle, faCircle,
@ -76,6 +78,8 @@ library.add(
faBitcoinSign, faBitcoinSign,
faBurst, faBurst,
faCalendar, faCalendar,
faCamera,
faCheck,
faChevronLeft, faChevronLeft,
faChevronRight, faChevronRight,
faCircle, faCircle,
@ -127,9 +131,11 @@ library.add(
); );
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import Camera from "simple-vue-camera";
createApp(App) createApp(App)
.component("fa", FontAwesomeIcon) .component("fa", FontAwesomeIcon)
.component("camera", Camera)
.use(createPinia()) .use(createPinia())
.use(VueAxios, axios) .use(VueAxios, axios)
.use(router) .use(router)

8
src/router/index.ts

@ -84,6 +84,14 @@ const routes: Array<RouteRecordRaw> = [
component: () => component: () =>
import(/* webpackChunkName: "discover" */ "../views/DiscoverView.vue"), import(/* webpackChunkName: "discover" */ "../views/DiscoverView.vue"),
}, },
{
path: "/gifted-details",
name: "gifted-details",
component: () =>
import(
/* webpackChunkName: "gifted-details" */ "../views/GiftedDetails.vue"
),
},
{ {
path: "/help", path: "/help",
name: "help", name: "help",

71
src/views/AccountViewView.vue

@ -96,7 +96,7 @@
<!-- Registration notice --> <!-- Registration notice -->
<!-- We won't show any loading indicator because it usually doesn't change anything. We'll just pop the message in only if we discover that they need it. --> <!-- We won't show any loading indicator because it usually doesn't change anything. We'll just pop the message in only if we discover that they need it. -->
<div <div
v-if="!loadingLimits && !limits?.nextWeekBeginDateTime" v-if="!loadingLimits && !endorserLimits?.nextWeekBeginDateTime"
class="bg-amber-200 text-amber-900 border-amber-500 border-dashed border text-center rounded-md overflow-hidden px-4 py-3 mb-4" class="bg-amber-200 text-amber-900 border-amber-500 border-dashed border text-center rounded-md overflow-hidden px-4 py-3 mb-4"
> >
<p class="mb-4"> <p class="mb-4">
@ -157,28 +157,39 @@
<div> <div>
{{ limitsMessage }} {{ limitsMessage }}
</div> </div>
<div v-if="!!limits?.nextWeekBeginDateTime"> <div v-if="!!endorserLimits?.nextWeekBeginDateTime">
<p class="mb-3 text-sm"> <p class="text-sm">
You have done <b>{{ limits.doneClaimsThisWeek }}</b> claims out of You have done
<b>{{ limits.maxClaimsPerWeek }}</b> for this week. Your claims <b>{{ endorserLimits.doneClaimsThisWeek }} claims</b> out of
counter resets at <b>{{ endorserLimits.maxClaimsPerWeek }}</b> for this week. Your
claims counter resets at
<b class="whitespace-nowrap">{{ <b class="whitespace-nowrap">{{
readableTime(limits.nextWeekBeginDateTime) readableDate(endorserLimits.nextWeekBeginDateTime)
}}</b> }}</b>
</p> </p>
<p class="text-sm"> <p class="mt-3 text-sm">
You have done You have done
<b>{{ limits.doneRegistrationsThisMonth }}</b> registrations out of <b>{{ endorserLimits.doneRegistrationsThisMonth }} registrations</b>
<b>{{ limits.maxRegistrationsPerMonth }}</b> for this month. out of <b>{{ endorserLimits.maxRegistrationsPerMonth }}</b> for this
month.
<i <i
>(You can register nobody on your first day, and after that only one >(You can register nobody on your first day, and after that only one
a day in your first month.)</i a day in your first month.)</i
> >
Your registration counter resets at Your registration counter resets at
<b class="whitespace-nowrap"> <b class="whitespace-nowrap">
{{ readableTime(limits.nextMonthBeginDateTime) }} {{ readableDate(endorserLimits.nextMonthBeginDateTime) }}
</b> </b>
</p> </p>
<p class="mt-3 text-sm" v-if="!!imageLimits">
You have uploaded
<b>{{ imageLimits?.doneImagesThisWeek }} images</b> out of
<b>{{ imageLimits?.maxImagesPerWeek }}</b> for this week. Your image
counter resets at
<b class="whitespace-nowrap">{{
readableDate(imageLimits?.nextWeekBeginDateTime)
}}</b>
</p>
</div> </div>
<button <button
class="block float-right w-fit text-center text-md uppercase 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-4 py-2 rounded-md mt-2" class="block float-right w-fit text-center text-md uppercase 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-4 py-2 rounded-md mt-2"
@ -509,6 +520,7 @@ import QuickNav from "@/components/QuickNav.vue";
import TopMessage from "@/components/TopMessage.vue"; import TopMessage from "@/components/TopMessage.vue";
import { import {
AppString, AppString,
DEFAULT_IMAGE_API_SERVER,
DEFAULT_PUSH_SERVER, DEFAULT_PUSH_SERVER,
NotificationIface, NotificationIface,
} from "@/constants/app"; } from "@/constants/app";
@ -516,7 +528,11 @@ import { db, accountsDB } from "@/db/index";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings"; import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto"; import { accessToken } from "@/libs/crypto";
import { IIdentifier } from "@veramo/core"; import { IIdentifier } from "@veramo/core";
import { ErrorResponse, RateLimits } from "@/libs/endorserServer"; import {
ErrorResponse,
EndorserRateLimits,
ImageRateLimits,
} from "@/libs/endorserServer";
// eslint-disable-next-line @typescript-eslint/no-var-requires // eslint-disable-next-line @typescript-eslint/no-var-requires
const Buffer = require("buffer/").Buffer; const Buffer = require("buffer/").Buffer;
@ -542,7 +558,9 @@ export default class AccountViewView extends Vue {
apiServerInput = ""; apiServerInput = "";
derivationPath = ""; derivationPath = "";
downloadUrl = ""; // because DuckDuckGo doesn't download on automated call to "click" on the anchor downloadUrl = ""; // because DuckDuckGo doesn't download on automated call to "click" on the anchor
endorserLimits: EndorserRateLimits | null = null;
givenName = ""; givenName = "";
imageLimits: ImageRateLimits | null = null;
isRegistered = false; isRegistered = false;
isSubscribed = false; isSubscribed = false;
notificationMaybeChanged = false; notificationMaybeChanged = false;
@ -550,7 +568,6 @@ export default class AccountViewView extends Vue {
publicBase64 = ""; publicBase64 = "";
webPushServer = ""; webPushServer = "";
webPushServerInput = ""; webPushServerInput = "";
limits: RateLimits | null = null;
limitsMessage = ""; limitsMessage = "";
loadingLimits = false; loadingLimits = false;
showContactGives = false; showContactGives = false;
@ -697,7 +714,7 @@ export default class AccountViewView extends Vue {
this.updateShowShortcutBvc(this.showShortcutBvc); this.updateShowShortcutBvc(this.showShortcutBvc);
} }
readableTime(timeStr: string) { readableDate(timeStr: string) {
return timeStr.substring(0, timeStr.indexOf("T")); return timeStr.substring(0, timeStr.indexOf("T"));
} }
@ -1038,11 +1055,11 @@ export default class AccountViewView extends Vue {
this.limitsMessage = ""; this.limitsMessage = "";
try { try {
const resp = await this.fetchRateLimits(identity); const resp = await this.fetchEndorserRateLimits(identity);
if (resp.status === 200) { if (resp.status === 200) {
this.limits = resp.data; this.endorserLimits = resp.data;
if (!this.isRegistered) { if (!this.isRegistered) {
// the user is not known to be registered, but they are so let's record it // the user was not known to be registered, but now they are (because we got no error) so let's record it
try { try {
await db.open(); await db.open();
db.settings.update(MASTER_SETTINGS_KEY, { db.settings.update(MASTER_SETTINGS_KEY, {
@ -1062,6 +1079,10 @@ export default class AccountViewView extends Vue {
); );
} }
} }
const imageResp = await this.fetchImageRateLimits(identity);
if (imageResp.status === 200) {
this.imageLimits = imageResp.data;
}
} }
} catch (error) { } catch (error) {
this.handleRateLimitsError(error); this.handleRateLimitsError(error);
@ -1082,17 +1103,29 @@ export default class AccountViewView extends Vue {
} }
/** /**
* Fetches rate limits from the server. * Fetches rate limits from the Endorser server.
* *
* @param {IIdentifier} identity - The identity object to check rate limits for. * @param {IIdentifier} identity - The identity object to check rate limits for.
* @returns {Promise<AxiosResponse>} The Axios response object. * @returns {Promise<AxiosResponse>} The Axios response object.
*/ */
private async fetchRateLimits(identity: IIdentifier) { private async fetchEndorserRateLimits(identity: IIdentifier) {
const url = `${this.apiServer}/api/report/rateLimits`; const url = `${this.apiServer}/api/report/rateLimits`;
const headers = await this.getHeaders(identity); const headers = await this.getHeaders(identity);
return await this.axios.get(url, { headers } as AxiosRequestConfig); return await this.axios.get(url, { headers } as AxiosRequestConfig);
} }
/**
* Fetches rate limits from the image server.
*
* @param {IIdentifier} identity - The identity object to check rate limits for.
* @returns {Promise<AxiosResponse>} The Axios response object.
*/
private async fetchImageRateLimits(identity: IIdentifier) {
const url = DEFAULT_IMAGE_API_SERVER + "/image-limits";
const headers = await this.getHeaders(identity);
return await this.axios.get(url, { headers } as AxiosRequestConfig);
}
/** /**
* Handles errors that occur while fetching rate limits. * Handles errors that occur while fetching rate limits.
* *

428
src/views/GiftedDetails.vue

@ -0,0 +1,428 @@
<template>
<QuickNav />
<TopMessage />
<!-- 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="cancel()"
>
<fa icon="chevron-left" class="fa-fw"></fa>
</h1>
</div>
<!-- Heading -->
<h1 class="text-4xl text-center font-light px-4 mb-4">What Was Given</h1>
<h1 class="text-xl font-bold text-center mb-4">
{{ message }} {{ giverName || "somebody not named" }}
</h1>
<textarea
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
placeholder="What was received"
v-model="description"
/>
<div class="flex flex-row justify-center">
<span
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] }}
</span>
<div
class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2"
@click="amountInput === '0' ? null : decrement()"
>
<fa icon="chevron-left" />
</div>
<input
type="number"
class="border border-r-0 border-slate-400 px-2 py-2 text-center w-20"
v-model="amountInput"
/>
<div
class="rounded-r border border-slate-400 bg-slate-200 px-4 py-2"
@click="increment()"
>
<fa icon="chevron-right" />
</div>
</div>
<div class="flex justify-center mt-4">
<span v-if="imageUrl" class="flex justify-between">
<a :href="imageUrl" target="_blank" class="text-blue-500 ml-4">
<img :src="imageUrl" class="h-24 rounded-xl" />
</a>
<fa
icon="trash-can"
@click="confirmDeleteImage"
class="text-red-500 fa-fw ml-8 mt-10"
/>
</span>
<span v-else>
<fa
icon="camera"
class="bg-blue-500 text-white px-2 py-2 rounded-md"
@click="openPhotoDialog"
/>
</span>
</div>
<GiftedPhotoDialog ref="photoDialog" />
<div v-if="projectId" class="mt-4">
<fa
icon="check"
class="bg-slate-500 text-white h-5 w-5 px-0.5 py-0.5 mr-2 rounded"
/>
<label class="text-sm">This is given to a project</label>
</div>
<div v-if="!projectId" class="mt-4">
<input type="checkbox" class="h-6 w-6 mr-2" v-model="givenToUser" />
<label class="text-sm">Given to you</label>
</div>
<div class="mt-4">
<input type="checkbox" class="h-6 w-6 mr-2" v-model="isTrade" />
<label class="text-sm">Trade (not a gift)</label>
</div>
<p class="text-center mb-2 mt-6 italic">
Sign & Send to publish to the world
</p>
<button
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
@click="confirm"
>
Sign &amp; Send
</button>
<button
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
@click="cancel"
>
Cancel
</button>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "@/constants/app";
import QuickNav from "@/components/QuickNav.vue";
import TopMessage from "@/components/TopMessage.vue";
import { db } from "@/db/index";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import { createAndSubmitGive } from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util";
import { accessToken } from "@/libs/crypto";
import GiftedDialog from "@/components/GiftedDialog.vue";
import GiftedPhotoDialog from "@/components/GiftedPhotoDialog.vue";
@Component({
components: {
GiftedDialog,
GiftedPhotoDialog,
QuickNav,
TopMessage,
},
})
export default class GiftedDetails extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid = "";
apiServer = "";
amountInput = "0";
description = "";
givenToUser = false;
giverDid: string | undefined;
giverName = "";
imageUrl = "";
isTrade = false;
message = "";
offerId = "";
projectId = "";
unitCode = "HUR";
libsUtil = libsUtil;
async mounted() {
this.amountInput = this.$route.query.amountInput as string;
this.description = this.$route.query.description as string;
this.giverDid = this.$route.query.giverDid as string;
this.giverName = this.$route.query.giverName as string;
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.unitCode = this.$route.query.unitCode as string;
this.imageUrl = localStorage.getItem("imageUrl") || "";
this.givenToUser = !this.projectId;
try {
await db.open();
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
this.apiServer = settings?.apiServer || "";
this.activeDid = settings?.activeDid || "";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
console.error("Error retrieving settings from database:", err);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: err.message || "There was an error retrieving your settings.",
},
-1,
);
}
}
changeUnitCode() {
const units = Object.keys(this.libsUtil.UNIT_SHORT);
const index = units.indexOf(this.unitCode);
this.unitCode = units[(index + 1) % units.length];
}
increment() {
this.amountInput = `${(parseFloat(this.amountInput) || 0) + 1}`;
}
decrement() {
this.amountInput = `${Math.max(
0,
(parseFloat(this.amountInput) || 1) - 1,
)}`;
}
cancel() {
this.deleteImage(); // not awaiting, so they'll go back immediately
this.$router.back();
}
openPhotoDialog() {
(this.$refs.photoDialog as GiftedPhotoDialog).open((imgUrl) => {
this.imageUrl = imgUrl;
});
}
confirmDeleteImage() {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Are you sure you want to delete the image?",
text: "",
onYes: this.deleteImage,
},
-1,
);
}
async deleteImage() {
if (!this.imageUrl) {
return;
}
try {
const identity = await libsUtil.getIdentity(this.activeDid);
const token = await accessToken(identity);
const response = await this.axios.delete(
DEFAULT_IMAGE_API_SERVER +
"/image/" +
encodeURIComponent(this.imageUrl),
{
headers: {
Authorization: `Bearer ${token}`,
},
},
);
if (response.status === 204) {
// 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);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem deleting the image.",
},
5000,
);
// keep the imageUrl in localStorage so the user can try again if they want
return;
}
localStorage.removeItem("imageUrl");
this.imageUrl = "";
} catch (error) {
console.error("Error deleting image:", error);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((error as any).response.status === 404) {
console.log("The image was already deleted:", error);
localStorage.removeItem("imageUrl");
this.imageUrl = "";
// it already doesn't exist so we won't say anything to the user
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was an error deleting the image.",
},
5000,
);
}
}
}
async confirm() {
this.$notify(
{
group: "alert",
type: "toast",
text: "Recording the give...",
title: "",
},
1000,
);
// this is asynchronous, but we don't need to wait for it to complete
await this.recordGive();
}
/**
*
* @param giverDid may be null
* @param description may be an empty string
* @param amountInput may be 0
* @param unitCode may be omitted, defaults to "HUR"
*/
public async recordGive() {
if (!this.activeDid) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "You must select an identifier before you can record a give.",
},
-1,
);
return;
}
if (!this.description && !this.amountInput) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: `You must enter a description or some number of ${
this.libsUtil.UNIT_LONG[this.unitCode]
}.`,
},
-1,
);
return;
}
try {
const identity = await libsUtil.getIdentity(this.activeDid);
const result = await createAndSubmitGive(
this.axios,
this.apiServer,
identity,
this.giverDid,
this.givenToUser ? this.activeDid : undefined,
this.description,
parseFloat(this.amountInput),
this.unitCode,
this.projectId,
this.offerId,
this.isTrade,
this.imageUrl,
);
if (
result.type === "error" ||
this.isGiveCreationError(result.response)
) {
const errorMessage = this.getGiveCreationErrorMessage(result);
console.error("Error with give creation result:", result);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: errorMessage || "There was an error creating the give.",
},
-1,
);
} else {
this.$notify(
{
group: "alert",
type: "success",
title: "Success",
text: `That ${this.isTrade ? "trade" : "gift"} was recorded.`,
},
5000,
);
localStorage.removeItem("imageUrl");
this.$router.back();
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
console.error("Error with give recordation caught:", error);
const message =
error.userMessage ||
error.response?.data?.error?.message ||
"There was an error recording the give.";
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: message,
},
-1,
);
}
}
// Helper functions for readability
/**
* @param result response "data" from the server
* @returns true if the result indicates an error
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
isGiveCreationError(result: any) {
return result.status !== 201 || result.data?.error;
}
/**
* @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data")
* @returns best guess at an error message
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getGiveCreationErrorMessage(result: any) {
return (
result.error?.userMessage ||
result.error?.error ||
result.response?.data?.error?.message
);
}
}
</script>

85
src/views/HelpView.vue

@ -11,7 +11,7 @@
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1" class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()" @click="$router.back()"
> >
<fa icon="chevron-left" class="fa-fw"></fa> <fa icon="chevron-left" class="fa-fw" />
</h1> </h1>
</div> </div>
@ -217,6 +217,28 @@
</ul> </ul>
<p>To erase your data from our servers, contact us (below).</p> <p>To erase your data from our servers, contact us (below).</p>
<h2 class="text-xl font-semibold">
How do I get higher limits?
</h2>
<p>
Let's talk. Contact us (below).
</p>
<h2 class="text-xl font-semibold">
How do I access even more functionality?
</h2>
<p>
There is an "Advanced" section at the bottom of the Account
<fa icon="circle-user" /> page.
</p>
<p>
There is a even more functionality in a mobile app (and more
documentation) at
<a href="https://endorser.ch" class="text-blue-500">
EndorserSearch.com
</a>
</p>
<h2 class="text-xl font-semibold"> <h2 class="text-xl font-semibold">
I know there is a record from someone, so why can't I see that info? I know there is a record from someone, so why can't I see that info?
</h2> </h2>
@ -245,30 +267,59 @@
</p> </p>
<h2 class="text-xl font-semibold"> <h2 class="text-xl font-semibold">
How do I get higher limits? My app is misbehaving, like showing me a blank screen or failing to show a feed.
What can I do?
</h2> </h2>
<p> <p>
Let's talk. Contact us (below). First, note that clearing the cache will clear all your identity and contact info,
</p> so we recommend doing other things first (unless you know you have your backups ready).
<h2 class="text-xl font-semibold">
How do I access even more functionality?
</h2>
<p>
There is an "Advanced" section at the bottom of the Account
<fa icon="circle-user" /> page.
</p> </p>
<ul class="list-disc list-outside ml-4">
<li>
Drag down on the screen to refresh it; do that multiple times, because
it sometimes takes multiple tries for the app to refresh to the current version.
You can see the version information at the bottom of this page; the best
way to determine the current version is to open this page in an incognito
browser window and look at the version there.
</li>
<li>
Close all tabs that have Time Safari open; it can be difficult to find them all,
and you may have to close all your tabs. In addition, it may be running as an
installed app, so look for any Time Safari app that may be running outside a browser.
</li>
<li>
It can help to reregister the service worker:
<ul>
<li>
In Chrome, open a tab to
"chrome://serviceworker-internals",
find "timesafari.app", and click "Unregister".</li>
<li>
In Firefox,
open a tab to "about:serviceworkers",
find "timesafari.app", and click "Unregister".
</li>
<li>
<a href="https://duckduckgo.com/?q=unregister+service+worker" class="text-blue-500">Search</a>
for instructions for other browsers.</li>
</ul>
Then reload Time Safari.
</li>
<li>
Restart your device.
</li>
</ul>
<p> <p>
There is a even more functionality in a mobile app (and more If you still have problems, you can clear the cache and even uninstall
documentation) at and reinstall the app, but be sure to have your backups ready or be
<a href="https://endorser.ch" class="text-blue-500"> prepared to restart with a new identity and recreate your network.
EndorserSearch.com Nobody else has access to your identity or contact information because
</a> this app is designed to give you full control over your data.
</p> </p>
<h2 class="text-xl font-semibold">What are the terms & conditions and the privacy policy?</h2> <h2 class="text-xl font-semibold">What are the terms & conditions and the privacy policy?</h2>
<p style="display:inline; align-items: center"> <p style="display:inline; align-items: center">
This work is marked with This work is public domain, governed by
<a href="http://creativecommons.org/publicdomain/zero/1.0?ref=chooser-v1" target="_blank" rel="license noopener noreferrer"> <a href="http://creativecommons.org/publicdomain/zero/1.0?ref=chooser-v1" target="_blank" rel="license noopener noreferrer">
<span class="text-blue-500 mr-1">CC0 1.0</span> <span class="text-blue-500 mr-1">CC0 1.0</span>
<img <img

7
src/views/HomeView.vue

@ -225,6 +225,11 @@
</router-link> </router-link>
</span> </span>
</div> </div>
<div v-if="record.image" class="flex justify-center">
<a :href="record.image" target="_blank">
<img :src="record.image" class="h-24 mt-2 rounded-xl" />
</a>
</div>
</li> </li>
</ul> </ul>
</InfiniteScroll> </InfiniteScroll>
@ -267,6 +272,7 @@ interface GiveRecordWithContactInfo extends GiveServerRecord {
displayName: string; displayName: string;
known: boolean; known: boolean;
}; };
image: string;
receiver: { receiver: {
displayName: string; displayName: string;
known: boolean; known: boolean;
@ -427,6 +433,7 @@ export default class HomeView extends Vue {
contactForDid(giverDid, this.allContacts), contactForDid(giverDid, this.allContacts),
this.allMyDids, this.allMyDids,
), ),
image: claim.image,
receiver: didInfoForContact( receiver: didInfoForContact(
recipientDid, recipientDid,
this.activeDid, this.activeDid,

9
src/views/ProjectViewView.vue

@ -437,15 +437,6 @@ export default class ProjectViewView extends Vue {
return identity; return identity;
} }
public async getHeaders(identity: IIdentifier) {
const token = await accessToken(identity);
const headers = {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
};
return headers;
}
onEditClick() { onEditClick() {
localStorage.setItem("projectId", this.projectId as string); localStorage.setItem("projectId", this.projectId as string);
const route = { const route = {

3
vue.config.js

@ -3,6 +3,8 @@ const { gitDescribeSync } = require("git-describe");
const { exec } = require("child_process"); const { exec } = require("child_process");
process.env.VUE_APP_GIT_HASH = gitDescribeSync().hash; process.env.VUE_APP_GIT_HASH = gitDescribeSync().hash;
const TIME_SAFARI_APP_TITLE =
process.env.TIME_SAFARI_APP_TITLE || require("./package.json").name;
module.exports = defineConfig({ module.exports = defineConfig({
transpileDependencies: true, transpileDependencies: true,
@ -30,6 +32,7 @@ module.exports = defineConfig({
], ],
}, },
pwa: { pwa: {
name: TIME_SAFARI_APP_TITLE,
iconPaths: { iconPaths: {
faviconSVG: "img/icons/safari-pinned-tab.svg", faviconSVG: "img/icons/safari-pinned-tab.svg",
}, },

Loading…
Cancel
Save