Browse Source

Merge branch 'master' into button-visual-enhancement

kb/add-usage-guide
Jose Olarte III 6 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. 87
      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).
## [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
- Shortcut page for Bountiful Voluntaryist Community
### 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`.
* 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`
* 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",
"version": "0.2.18-beta",
"name": "TimeSafari",
"version": "0.3.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "TimeSafari_Test",
"version": "0.2.18-beta",
"name": "TimeSafari",
"version": "0.3.3",
"dependencies": {
"@dicebear/collection": "^5.3.5",
"@dicebear/core": "^5.3.5",
@ -54,6 +54,7 @@
"readable-stream": "^4.4.2",
"reflect-metadata": "^0.1.13",
"register-service-worker": "^1.7.2",
"simple-vue-camera": "^1.1.3",
"three": "^0.156.1",
"ua-parser-js": "^1.0.37",
"util": "^0.12.5",
@ -25438,6 +25439,11 @@
"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": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.3.tgz",

5
package.json

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

30
project.task.yaml

@ -1,6 +1,17 @@
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 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)
@ -8,15 +19,12 @@ tasks :
- .2 anchor hash into BTC
- .2 list the "show more" contacts alphabetically
- 32 image on give :
- Show a camera to take a picture
- Scale the image to a reasonable size
- Upload to a public readable place
- check the rate limits
- use CID (hash?)
- put the image URL in the claim
- Rates - images erased?
- image not associated with JWT ULID since that's assigned later
- .5 make Time Safari a share_target for images
- 08 add image on profile
- ask to detect location & record it in settings
- if personal location is set, show potential local affiliations
- 24 compelling UI for credential presentations
- discover who in my network has activity on a project
@ -37,6 +45,7 @@ tasks :
- randomize (not show in order)
- 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
- .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 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
- .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.)
- 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 on DiscoverView, switch to a filter UI (eg. just from friend
- .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
- 32 accept images for projects

31
src/App.vue

@ -148,6 +148,37 @@
class="w-full"
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
v-if="notification.type === 'notification-permission'"
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"
v-model="description"
/>
<div class="flex flex-row">
<div class="flex flex-row justify-center">
<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()"
>
{{ libsUtil.UNIT_SHORT[unitCode] }}
</span>
<div
class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2"
@click="decrement()"
v-if="amountInput !== '0'"
@click="amountInput === '0' ? null : decrement()"
>
<fa icon="chevron-left" />
</div>
<input
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"
/>
<div
@ -36,14 +35,26 @@
<fa icon="chevron-right" />
</div>
</div>
<div class="mt-2 text-right">
<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>
<div class="mt-4 flex justify-center">
<span>
<input type="checkbox" class="mr-2" v-model="isTrade" />
<label class="text-sm">Trade (not a gift)</label>
<router-link
: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>
</div>
<p class="text-center mb-2 mt-6 italic">
@ -93,9 +104,9 @@ export default class GiftedDialog extends Vue {
apiServer = "";
amountInput = "0";
giver?: GiverInputInfo; // undefined means no identified giver agent
description = "";
givenToUser = false;
giver?: GiverInputInfo; // undefined means no identified giver agent
isTrade = false;
offerId = "";
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",
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)",
}
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 =
window.location.protocol + "//" + window.location.host;
@ -29,4 +39,5 @@ export interface NotificationIface {
type: string; // "toast" | "info" | "success" | "warning" | "danger"
title: string;
text: string;
onYes?: () => Promise<void>;
}

22
src/libs/endorserServer.ts

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

6
src/main.ts

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

8
src/router/index.ts

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

71
src/views/AccountViewView.vue

@ -96,7 +96,7 @@
<!-- 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. -->
<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"
>
<p class="mb-4">
@ -157,28 +157,39 @@
<div>
{{ limitsMessage }}
</div>
<div v-if="!!limits?.nextWeekBeginDateTime">
<p class="mb-3 text-sm">
You have done <b>{{ limits.doneClaimsThisWeek }}</b> claims out of
<b>{{ limits.maxClaimsPerWeek }}</b> for this week. Your claims
counter resets at
<div v-if="!!endorserLimits?.nextWeekBeginDateTime">
<p class="text-sm">
You have done
<b>{{ endorserLimits.doneClaimsThisWeek }} claims</b> out of
<b>{{ endorserLimits.maxClaimsPerWeek }}</b> for this week. Your
claims counter resets at
<b class="whitespace-nowrap">{{
readableTime(limits.nextWeekBeginDateTime)
readableDate(endorserLimits.nextWeekBeginDateTime)
}}</b>
</p>
<p class="text-sm">
<p class="mt-3 text-sm">
You have done
<b>{{ limits.doneRegistrationsThisMonth }}</b> registrations out of
<b>{{ limits.maxRegistrationsPerMonth }}</b> for this month.
<b>{{ endorserLimits.doneRegistrationsThisMonth }} registrations</b>
out of <b>{{ endorserLimits.maxRegistrationsPerMonth }}</b> for this
month.
<i
>(You can register nobody on your first day, and after that only one
a day in your first month.)</i
>
Your registration counter resets at
<b class="whitespace-nowrap">
{{ readableTime(limits.nextMonthBeginDateTime) }}
{{ readableDate(endorserLimits.nextMonthBeginDateTime) }}
</b>
</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>
<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"
@ -509,6 +520,7 @@ import QuickNav from "@/components/QuickNav.vue";
import TopMessage from "@/components/TopMessage.vue";
import {
AppString,
DEFAULT_IMAGE_API_SERVER,
DEFAULT_PUSH_SERVER,
NotificationIface,
} from "@/constants/app";
@ -516,7 +528,11 @@ import { db, accountsDB } from "@/db/index";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto";
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
const Buffer = require("buffer/").Buffer;
@ -542,7 +558,9 @@ export default class AccountViewView extends Vue {
apiServerInput = "";
derivationPath = "";
downloadUrl = ""; // because DuckDuckGo doesn't download on automated call to "click" on the anchor
endorserLimits: EndorserRateLimits | null = null;
givenName = "";
imageLimits: ImageRateLimits | null = null;
isRegistered = false;
isSubscribed = false;
notificationMaybeChanged = false;
@ -550,7 +568,6 @@ export default class AccountViewView extends Vue {
publicBase64 = "";
webPushServer = "";
webPushServerInput = "";
limits: RateLimits | null = null;
limitsMessage = "";
loadingLimits = false;
showContactGives = false;
@ -697,7 +714,7 @@ export default class AccountViewView extends Vue {
this.updateShowShortcutBvc(this.showShortcutBvc);
}
readableTime(timeStr: string) {
readableDate(timeStr: string) {
return timeStr.substring(0, timeStr.indexOf("T"));
}
@ -1038,11 +1055,11 @@ export default class AccountViewView extends Vue {
this.limitsMessage = "";
try {
const resp = await this.fetchRateLimits(identity);
const resp = await this.fetchEndorserRateLimits(identity);
if (resp.status === 200) {
this.limits = resp.data;
this.endorserLimits = resp.data;
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 {
await db.open();
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) {
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.
* @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 headers = await this.getHeaders(identity);
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.
*

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>

87
src/views/HelpView.vue

@ -11,7 +11,7 @@
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>
<fa icon="chevron-left" class="fa-fw" />
</h1>
</div>
@ -217,6 +217,28 @@
</ul>
<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">
I know there is a record from someone, so why can't I see that info?
</h2>
@ -240,35 +262,64 @@
</h2>
<p>
<router-link class="text-blue-500" to="/help-notifications"
>Here.</router-link
>Here.</router-link
>
</p>
<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>
<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.
First, note that clearing the cache will clear all your identity and contact info,
so we recommend doing other things first (unless you know you have your backups ready).
</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>
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>
If you still have problems, you can clear the cache and even uninstall
and reinstall the app, but be sure to have your backups ready or be
prepared to restart with a new identity and recreate your network.
Nobody else has access to your identity or contact information because
this app is designed to give you full control over your data.
</p>
<h2 class="text-xl font-semibold">What are the terms & conditions and the privacy policy?</h2>
<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">
<span class="text-blue-500 mr-1">CC0 1.0</span>
<img

7
src/views/HomeView.vue

@ -225,6 +225,11 @@
</router-link>
</span>
</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>
</ul>
</InfiniteScroll>
@ -267,6 +272,7 @@ interface GiveRecordWithContactInfo extends GiveServerRecord {
displayName: string;
known: boolean;
};
image: string;
receiver: {
displayName: string;
known: boolean;
@ -427,6 +433,7 @@ export default class HomeView extends Vue {
contactForDid(giverDid, this.allContacts),
this.allMyDids,
),
image: claim.image,
receiver: didInfoForContact(
recipientDid,
this.activeDid,

9
src/views/ProjectViewView.vue

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

3
vue.config.js

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

Loading…
Cancel
Save