Browse Source

Merge changes

split_build_process
Matthew Raymer 5 days ago
parent
commit
59bbd9ef94
  1. 2
      .env.production
  2. 20
      CHANGELOG.md
  3. 33
      README.md
  4. 54307
      package-lock.json
  5. 264
      package.json
  6. 182
      src/components/HiddenDidDialog.vue
  7. 2
      src/components/OnboardingDialog.vue
  8. 6
      src/db/tables/settings.ts
  9. 30
      src/libs/endorserServer.ts
  10. 9
      src/libs/partnerServer.ts
  11. 9
      src/router/index.ts
  12. 386
      src/views/AccountViewView.vue
  13. 44
      src/views/ClaimCertificateView.vue
  14. 74
      src/views/ClaimView.vue
  15. 35
      src/views/ConfirmGiftView.vue
  16. 2
      src/views/DIDView.vue
  17. 470
      src/views/DiscoverView.vue
  18. 2
      src/views/ImportDerivedAccountView.vue
  19. 212
      src/views/NewEditProjectView.vue
  20. 116
      src/views/ProjectViewView.vue
  21. 6
      src/views/ProjectsView.vue
  22. 184
      src/views/UserProfileView.vue
  23. 2
      test-playwright/10-check-usage-limits.spec.ts
  24. 2
      test-playwright/25-create-project-x10.spec.ts

2
.env.production

@ -3,4 +3,4 @@ VITE_APP_SERVER=https://timesafari.app
VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01GXYPFF7FA03NXKPYY142PY4H VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01GXYPFF7FA03NXKPYY142PY4H
VITE_DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch VITE_DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch
VITE_DEFAULT_IMAGE_API_SERVER=https://image-api.timesafari.app VITE_DEFAULT_IMAGE_API_SERVER=https://image-api.timesafari.app
VITE_DEFAULT_PARTNER_API_SERVER=https://partner-api.timesafari.app VITE_DEFAULT_PARTNER_API_SERVER=https://partner-api.endorser.ch

20
CHANGELOG.md

@ -6,6 +6,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.3.53] - 2025.01.30
### Added
- Hints for contacting the creator of a project
## [0.3.52] - 2025.01.22
### Fixed
- User profile endpoint server for map was broken.
## [0.3.51] - 2025.01.22
### Fixed
- User profile map jumped on first zoom.
## [0.3.50] - 2025.01.20 - b9fedcd3fd3e34c3fb0fc79150d1a81a76eaeb40
### Added
- User public profiles
## [0.3.49] - 2025.01.09 - 36301ed238ff84df25bb11a8d44a295ee7eaf0f8 ## [0.3.49] - 2025.01.09 - 36301ed238ff84df25bb11a8d44a295ee7eaf0f8
### Changed ### Changed
- Make all external contact links direct to the contact-import page. - Make all external contact links direct to the contact-import page.

33
README.md

@ -50,35 +50,34 @@ Look below for the "test-all" instructions.
* Put the commit hash in the changelog (which will help you remember to bump the version later). * Put the commit hash in the changelog (which will help you remember to bump the version later).
* Record what version is currently on production in docs. * Tag with the new version, [online](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/releases) or `git tag 0.3.36` && `git push origin 0.3.36`.
* Get on the server and run the correct build: * For test, build the app (because test server is not yet set up to build):
* `cd crowd-funder-for-time-pwa && git pull` ```
TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_APP_SERVER=https://test.timesafari.app VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app VITE_DEFAULT_PARTNER_API_SERVER=https://test-partner-api.endorser.ch VITE_PASSKEYS_ENABLED=true npm run build
```
* Previous command: `rsync -azvu -e "ssh -i ~/.ssh/..." dist ubuntutest@test.timesafari.app:time-safari` ... and transfer to the test server: `rsync -azvu -e "ssh -i ~/.ssh/..." dist ubuntutest@test.timesafari.app:time-safari`
* Staging (Let's replace that with a .env.development or .env.staging file.)
(Let's replace this with a .env.development or .env.staging file.) (Note: 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.)
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. * For prod, get on the server and run the correct build:
``` ... and log onto the server:
TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_APP_SERVER=https://test.timesafari.app VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app VITE_DEFAULT_PARTNER_API_SERVER=https://test-partner-api.endorser.ch VITE_PASSKEYS_ENABLED=true npm run build
```
* Production * `pkgx +npm sh`
```
# This picks up values from .env.production
npm run build
```
* Back up the time-safari/dist folder. * `cd crowd-funder-for-time-pwa && git checkout master && git pull && git checkout 0.3.36 && npm install && npm run build && cd -`
(The plain `npm run build` uses the .env.production file.)
* Back up the time-safari/dist folder, then `mv time-safari/dist time-safari-dist-prev.0 && mv crowd-funder-for-time-pwa/dist time-safari/`
* 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. * 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 with the new version, [online](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/releases) or `git tag 0.3.36` && `git push origin 0.3.36`.

54307
package-lock.json

File diff suppressed because it is too large

264
package.json

@ -1,141 +1,133 @@
{ {
"name": "TimeSafari", "name": "TimeSafari",
"version": "0.3.51-beta", "version": "0.3.54-beta",
"description": "A cross-platform app for managing time-based crowdfunding.", "scripts": {
"author": "Your Name <your.email@example.com>", "dev": "vite",
"main": "src/electron/main.js", "serve": "vite preview",
"scripts": { "build": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build",
"dev": "vite", "lint": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src",
"serve": "vite preview", "lint-fix": "eslint --ext .js,.ts,.vue --ignore-path .gitignore --fix src",
"build": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build", "prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node sw_combine.js",
"build:capacitor": "vite build --mode capacitor", "test-local": "npx playwright test -c playwright.config-local.ts --trace on",
"build:electron": "vite build --mode electron", "test-all": "npm run build && npx playwright test -c playwright.config-local.ts --trace on"
"electron:dev": "vite build --mode electron && electron .", },
"electron:build": "electron-builder", "dependencies": {
"capacitor:sync": "npx cap copy", "@capacitor/android": "^6.2.0",
"lint": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src", "@capacitor/cli": "^6.2.0",
"lint-fix": "eslint --ext .js,.ts,.vue --ignore-path .gitignore --fix src", "@capacitor/core": "^6.2.0",
"prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node sw_combine.js", "@capacitor/ios": "^6.2.0",
"test-local": "npx playwright test -c playwright.config-local.ts --trace on", "@dicebear/collection": "^5.4.1",
"test-all": "npm run build && npx playwright test -c playwright.config-local.ts --trace on" "@dicebear/core": "^5.4.1",
"@ethersproject/hdnode": "^5.7.0",
"@fortawesome/fontawesome-svg-core": "^6.5.1",
"@fortawesome/free-solid-svg-icons": "^6.5.1",
"@fortawesome/vue-fontawesome": "^3.0.6",
"@peculiar/asn1-ecc": "^2.3.8",
"@peculiar/asn1-schema": "^2.3.8",
"@pvermeer/dexie-encrypted-addon": "^3.0.0",
"@simplewebauthn/browser": "^10.0.0",
"@simplewebauthn/server": "^10.0.0",
"@tweenjs/tween.js": "^21.1.1",
"@types/qrcode": "^1.5.5",
"@veramo/core": "^5.6.0",
"@veramo/credential-w3c": "^5.6.0",
"@veramo/data-store": "^5.6.0",
"@veramo/did-manager": "^5.6.0",
"@veramo/did-provider-ethr": "^5.6.0",
"@veramo/did-provider-peer": "^6.0.0",
"@veramo/did-resolver": "^5.6.0",
"@veramo/key-manager": "^5.6.0",
"@vue-leaflet/vue-leaflet": "^0.10.1",
"@vueuse/core": "^12.3.0",
"@zxing/text-encoding": "^0.9.0",
"asn1-ber": "^1.2.2",
"axios": "^1.6.8",
"cbor-x": "^1.5.9",
"class-transformer": "^0.5.1",
"dexie": "^3.2.7",
"dexie-export-import": "^4.1.1",
"did-jwt": "^7.4.7",
"did-resolver": "^4.1.0",
"ethereum-cryptography": "^2.1.3",
"ethereumjs-util": "^7.1.5",
"jdenticon": "^3.2.0",
"js-generate-password": "^0.1.9",
"js-yaml": "^4.1.0",
"leaflet": "^1.9.4",
"localstorage-slim": "^2.7.0",
"lru-cache": "^10.2.0",
"luxon": "^3.4.4",
"merkletreejs": "^0.3.11",
"nostr-tools": "^2.7.2",
"notiwind": "^2.0.2",
"papaparse": "^5.4.1",
"pina": "^0.20.2204228",
"pinia-plugin-persistedstate": "^3.2.1",
"qr-code-generator-vue3": "^1.4.21",
"qrcode": "^1.5.4",
"ramda": "^0.29.1",
"readable-stream": "^4.5.2",
"reflect-metadata": "^0.1.14",
"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",
"vue": "^3.5.13",
"vue-axios": "^3.5.2",
"vue-facing-decorator": "^3.0.4",
"vue-picture-cropper": "^0.7.0",
"vue-qrcode-reader": "^5.5.3",
"vue-router": "^4.5.0",
"web-did-resolver": "^2.0.27"
},
"devDependencies": {
"@playwright/test": "^1.45.2",
"@types/js-yaml": "^4.0.9",
"@types/leaflet": "^1.9.8",
"@types/luxon": "^3.4.2",
"@types/node": "^20.14.11",
"@types/ramda": "^0.29.11",
"@types/three": "^0.155.1",
"@types/ua-parser-js": "^0.7.39",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"@vitejs/plugin-vue": "^5.2.1",
"@vue/eslint-config-typescript": "^11.0.3",
"autoprefixer": "^10.4.19",
"electron": "^33.2.1",
"electron-builder": "^25.1.8",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-vue": "^9.32.0",
"npm-check-updates": "^17.1.13",
"postcss": "^8.4.38",
"prettier": "^3.2.5",
"tailwindcss": "^3.4.1",
"typescript": "~5.2.2",
"vite": "^5.2.0",
"vite-plugin-pwa": "^0.19.8"
},
"build": {
"appId": "com.example.app",
"productName": "TimeSafari",
"directories": {
"output": "dist-electron-build"
}, },
"dependencies": { "files": [
"@capacitor/android": "^6.2.0", "dist-electron/**",
"@capacitor/cli": "^6.2.0", "src/electron/**"
"@capacitor/core": "^6.2.0", ],
"@capacitor/ios": "^6.2.0", "mac": {
"@dicebear/collection": "^5.4.1", "target": "dmg"
"@dicebear/core": "^5.4.1",
"@ethersproject/hdnode": "^5.7.0",
"@fortawesome/fontawesome-svg-core": "^6.5.1",
"@fortawesome/free-solid-svg-icons": "^6.5.1",
"@fortawesome/vue-fontawesome": "^3.0.6",
"@peculiar/asn1-ecc": "^2.3.8",
"@peculiar/asn1-schema": "^2.3.8",
"@pvermeer/dexie-encrypted-addon": "^3.0.0",
"@simplewebauthn/browser": "^10.0.0",
"@simplewebauthn/server": "^10.0.0",
"@tweenjs/tween.js": "^21.1.1",
"@types/qrcode": "^1.5.5",
"@veramo/core": "^5.6.0",
"@veramo/credential-w3c": "^5.6.0",
"@veramo/data-store": "^5.6.0",
"@veramo/did-manager": "^5.6.0",
"@veramo/did-provider-ethr": "^5.6.0",
"@veramo/did-provider-peer": "^6.0.0",
"@veramo/did-resolver": "^5.6.0",
"@veramo/key-manager": "^5.6.0",
"@vue-leaflet/vue-leaflet": "^0.10.1",
"@vueuse/core": "^12.3.0",
"@zxing/text-encoding": "^0.9.0",
"asn1-ber": "^1.2.2",
"axios": "^1.6.8",
"cbor-x": "^1.5.9",
"class-transformer": "^0.5.1",
"dexie": "^3.2.7",
"dexie-export-import": "^4.1.1",
"did-jwt": "^7.4.7",
"did-resolver": "^4.1.0",
"ethereum-cryptography": "^2.1.3",
"ethereumjs-util": "^7.1.5",
"jdenticon": "^3.2.0",
"js-generate-password": "^0.1.9",
"js-yaml": "^4.1.0",
"leaflet": "^1.9.4",
"localstorage-slim": "^2.7.0",
"lru-cache": "^10.2.0",
"luxon": "^3.4.4",
"merkletreejs": "^0.3.11",
"nostr-tools": "^2.7.2",
"notiwind": "^2.0.2",
"papaparse": "^5.4.1",
"pina": "^0.20.2204228",
"pinia-plugin-persistedstate": "^3.2.1",
"qr-code-generator-vue3": "^1.4.21",
"qrcode": "^1.5.4",
"ramda": "^0.29.1",
"readable-stream": "^4.5.2",
"reflect-metadata": "^0.1.14",
"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",
"vue": "^3.5.13",
"vue-axios": "^3.5.2",
"vue-facing-decorator": "^3.0.4",
"vue-picture-cropper": "^0.7.0",
"vue-qrcode-reader": "^5.5.3",
"vue-router": "^4.5.0",
"web-did-resolver": "^2.0.27"
}, },
"devDependencies": { "win": {
"@playwright/test": "^1.45.2", "target": "nsis"
"@types/js-yaml": "^4.0.9",
"@types/leaflet": "^1.9.8",
"@types/luxon": "^3.4.2",
"@types/node": "^20.14.11",
"@types/ramda": "^0.29.11",
"@types/three": "^0.155.1",
"@types/ua-parser-js": "^0.7.39",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"@vitejs/plugin-vue": "^5.2.1",
"@vue/eslint-config-typescript": "^11.0.0",
"autoprefixer": "^10.4.19",
"electron": "^33.2.1",
"electron-builder": "^25.1.8",
"eslint": "^8.0.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-vue": "^9.32.0",
"npm-check-updates": "^17.1.13",
"postcss": "^8.4.38",
"prettier": "^3.2.5",
"tailwindcss": "^3.4.1",
"typescript": "~5.2.2",
"vite": "^6.0.7",
"vite-plugin-pwa": "^0.21.1"
}, },
"build": { "linux": {
"appId": "com.example.app", "target": "AppImage"
"productName": "TimeSafari", },
"directories": { "asar": false
"output": "dist-electron-build" }
},
"files": [
"dist-electron/**",
"src/electron/**"
],
"mac": {
"target": "dmg"
},
"win": {
"target": "nsis"
},
"linux": {
"target": "AppImage"
},
"asar": false
}
} }

182
src/components/HiddenDidDialog.vue

@ -0,0 +1,182 @@
<template>
<div
v-if="isOpen"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
>
<div class="bg-white rounded-lg p-6 max-w-2xl w-full mx-4">
<!-- Header -->
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold capitalize">{{ roleName }} Details</h2>
<button @click="close" class="text-gray-500 hover:text-gray-700">
<fa icon="times" />
</button>
</div>
<!-- Content -->
<!-- This is somewhat similar to ClaimView.vue and ConfirmGiftView.vue -->
<div class="mb-4">
<p class="mb-4">
<span v-if="R.isEmpty(visibleToDids)">
The {{ roleName }} is not visible to you or any of your contacts.
</span>
<span v-else> The {{ roleName }} is not visible to you. </span>
</p>
<div v-if="R.isEmpty(visibleToDids)">
<p class="mt-2">
You can ask one of your contacts to take a look and see if their
contacts can see more details. Someone is connected to people closer
to them; if you don't know who to ask, try the person who registered
you.
</p>
</div>
<div v-else>
<p class="mb-2">
They are visible to some of your contacts. If you'd like an
introduction, ask them if they'll tell you more.
</p>
<div class="ml-4">
<ul>
<li
v-for="(visDid, idx) of visibleToDids"
:key="idx"
class="list-disc ml-4 mb-2"
>
<div class="text-sm">
<span>
{{ didInfo(visDid) }}
<span v-if="!serverUtil.isEmptyOrHiddenDid(visDid)">
<a
:href="`/did/${visDid}`"
target="_blank"
class="text-blue-500"
>
<fa icon="arrow-up-right-from-square" class="fa-fw" />
</a>
</span>
</span>
</div>
</li>
</ul>
</div>
</div>
<div class="mt-4">
<span v-if="canShare">
If you'd like an introduction,
<a @click="onClickShareClaim()" class="text-blue-500"
>click here to share the information with them and ask if they'll
tell you more about the {{ roleName }}.</a
>
</span>
<span v-else>
If you'd like an introduction,
<a
@click="copyToClipboard('A link to this page', windowLocation)"
class="text-blue-500"
>click here to copy this page, paste it into a message, and ask if
they'll tell you more about the {{ roleName }}.</a
>
</span>
</div>
</div>
<!-- Footer -->
<div class="flex justify-end">
<button
@click="close"
class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
>
Close
</button>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import * as R from "ramda";
import { useClipboard } from "@vueuse/core";
import { Contact } from "@/db/tables/contacts";
import * as serverUtil from "@/libs/endorserServer";
import { NotificationIface } from "@/constants/app";
@Component
export default class HiddenDidDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
isOpen = false;
roleName = "";
visibleToDids: string[] = [];
allContacts: Array<Contact> = [];
activeDid = "";
allMyDids: Array<string> = [];
canShare = false;
windowLocation = window.location.href;
R = R;
serverUtil = serverUtil;
created() {
// When Chrome compatibility is fixed https://developer.mozilla.org/en-US/docs/Web/API/Web_Share_API#api.navigator.canshare
// then use this truer check: navigator.canShare && navigator.canShare()
this.canShare = !!navigator.share;
}
open(
roleName: string,
visibleToDids: string[],
allContacts: Array<Contact>,
activeDid: string,
allMyDids: Array<string>,
) {
this.roleName = roleName;
this.visibleToDids = visibleToDids;
this.allContacts = allContacts;
this.activeDid = activeDid;
this.allMyDids = allMyDids;
this.isOpen = true;
}
close() {
this.isOpen = false;
}
didInfo(did: string) {
return serverUtil.didInfo(
did,
this.activeDid,
this.allMyDids,
this.allContacts,
);
}
copyToClipboard(name: string, text: string) {
useClipboard()
.copy(text)
.then(() => {
this.$notify(
{
group: "alert",
type: "toast",
title: "Copied",
text: (name || "That") + " was copied to the clipboard.",
},
2000,
);
});
}
onClickShareClaim() {
this.copyToClipboard("A link to this page", this.windowLocation);
window.navigator.share({
title: "Help Connect Me",
text: "I'm trying to find the people who recorded this. Can you help me?",
url: this.windowLocation,
});
}
}
</script>

2
src/components/OnboardingDialog.vue

@ -5,7 +5,7 @@
<h1 class="text-xl font-bold text-center mb-4 relative"> <h1 class="text-xl font-bold text-center mb-4 relative">
Welcome to Time Safari Welcome to Time Safari
<br /> <br />
- Showcasing Gratitude & Magnifing Time - Showcasing Gratitude & Magnifying Time
<div <div
class="text-lg text-center leading-none absolute right-0 -top-1" class="text-lg text-center leading-none absolute right-0 -top-1"
@click="onClickClose(true)" @click="onClickClose(true)"

6
src/db/tables/settings.ts

@ -13,14 +13,14 @@ export type BoundingBox = {
*/ */
export type Settings = { export type Settings = {
// default entry is keyed with MASTER_SETTINGS_KEY; other entries are linked to an account with account ID // default entry is keyed with MASTER_SETTINGS_KEY; other entries are linked to an account with account ID
id?: number; // this is only blank on input, when the database assigns it id?: number; // this is erased for all those entries that are keyed with accountDid
// if supplied, this settings record overrides the master record when the user switches to this account // if supplied, this settings record overrides the master record when the user switches to this account
accountDid?: string; // not used in the MASTER_SETTINGS_KEY entry accountDid?: string; // not used in the MASTER_SETTINGS_KEY entry
// active Decentralized ID // active Decentralized ID
activeDid?: string; // only used in the MASTER_SETTINGS_KEY entry activeDid?: string; // only used in the MASTER_SETTINGS_KEY entry
apiServer?: string; // API server URL apiServer: string; // API server URL
filterFeedByNearby?: boolean; // filter by nearby filterFeedByNearby?: boolean; // filter by nearby
filterFeedByVisible?: boolean; // filter by visible users ie. anyone not hidden filterFeedByVisible?: boolean; // filter by visible users ie. anyone not hidden
@ -29,7 +29,7 @@ export type Settings = {
firstName?: string; // user's full name, may be null if unwanted for a particular account firstName?: string; // user's full name, may be null if unwanted for a particular account
hideRegisterPromptOnNewContact?: boolean; hideRegisterPromptOnNewContact?: boolean;
isRegistered?: boolean; isRegistered?: boolean;
imageServer?: string; // imageServer?: string; // if we want to allow modification then we should make image functionality optional -- or at least customizable
lastName?: string; // deprecated - put all names in firstName lastName?: string; // deprecated - put all names in firstName
lastAckedOfferToUserJwtId?: string; // the last JWT ID for offer-to-user that they've acknowledged seeing lastAckedOfferToUserJwtId?: string; // the last JWT ID for offer-to-user that they've acknowledged seeing

30
src/libs/endorserServer.ts

@ -191,13 +191,9 @@ export interface PlanVerifiableCredential extends GenericVerifiableCredential {
* Represents data about a project * Represents data about a project
* *
* @deprecated * @deprecated
* We should use PlanSummaryRecord instead. * (Maybe we should use PlanSummaryRecord instead, either by adding rowId or by iterating with jwtId.)
**/ **/
export interface PlanData { export interface PlanData {
/**
* Name of the project
**/
name: string;
/** /**
* Description of the project * Description of the project
**/ **/
@ -212,9 +208,14 @@ export interface PlanData {
*/ */
issuerDid: string; issuerDid: string;
/** /**
* The identifier of the project -- different from jwtId, needs to be fixed * Name of the project
**/ **/
rowid?: string; name: string;
/**
* The identifier of the project record -- different from jwtId
* (Maybe we should use the jwtId to iterate through the records instead.)
**/
rowId?: string;
} }
export interface EndorserRateLimits { export interface EndorserRateLimits {
@ -459,7 +460,7 @@ export function didInfoForContact(
} else if (contact) { } else if (contact) {
return { return {
displayName: contact.name || "Contact With No Name", displayName: contact.name || "Contact With No Name",
known: !!contact, known: true,
profileImageUrl: contact.profileImageUrl, profileImageUrl: contact.profileImageUrl,
}; };
} else { } else {
@ -477,6 +478,19 @@ export function didInfoForContact(
} }
} }
/**
* @returns full contact info object (never undefined), where did is searched in contacts and allMyDids
*/
export function didInfoObject(
did: string | undefined,
activeDid: string | undefined,
allMyDids: string[],
contacts: Contact[],
): { known: boolean; displayName: string; profileImageUrl?: string } {
const contact = contactForDid(did, contacts);
return didInfoForContact(did, activeDid, contact, allMyDids);
}
/** /**
always returns text, maybe something like "unnamed" or "unknown" always returns text, maybe something like "unnamed" or "unknown"

9
src/libs/partnerServer.ts

@ -0,0 +1,9 @@
export interface UserProfile {
description: string;
locLat?: number;
locLon?: number;
locLat2?: number;
locLon2?: number;
issuerDid: string;
rowId?: string; // set on profile retrieved from server
}

9
src/router/index.ts

@ -262,6 +262,11 @@ const routes: Array<RouteRecordRaw> = [
name: "test", name: "test",
component: () => import("../views/TestView.vue"), component: () => import("../views/TestView.vue"),
}, },
{
path: "/userProfile/:id?",
name: "userProfile",
component: () => import("../views/UserProfileView.vue"),
},
]; ];
const isElectron = window.location.protocol === "file:"; // Check if running in Electron const isElectron = window.location.protocol === "file:"; // Check if running in Electron
@ -292,9 +297,7 @@ const errorHandler = (
) => { ) => {
// Handle the error here // Handle the error here
console.error("Caught in top level error handler:", error, to, from); console.error("Caught in top level error handler:", error, to, from);
alert( alert("Something is very wrong. Try reloading or restarting the app.");
"Something is very wrong. We'd love if you contacted us and let us know how you got here. Thank you!",
);
// You can also perform additional actions, such as displaying an error message or redirecting the user to a specific page // You can also perform additional actions, such as displaying an error message or redirecting the user to a specific page
}; };

386
src/views/AccountViewView.vue

@ -82,13 +82,12 @@
<div v-else class="text-center"> <div v-else class="text-center">
<div class @click="openImageDialog()"> <div class @click="openImageDialog()">
<fa <fa
icon="camera" icon="image-portrait"
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-l" class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-l"
/> />
<fa <fa
icon="image-portrait" icon="camera"
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-r" class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-r"
@click="openImageDialog()"
/> />
</div> </div>
</div> </div>
@ -159,7 +158,7 @@
We'll just pop the message in only if we discover that they need it. We'll just pop the message in only if we discover that they need it.
--> -->
<div <div
v-if="!loadingLimits && !endorserLimits?.nextWeekBeginDateTime" v-if="!isRegistered"
id="noticeBeforeAnnounce" id="noticeBeforeAnnounce"
class="bg-amber-200 text-amber-900 border-amber-500 border-dashed border text-center rounded-md overflow-hidden px-4 py-3 mt-4" class="bg-amber-200 text-amber-900 border-amber-500 border-dashed border text-center rounded-md overflow-hidden px-4 py-3 mt-4"
> >
@ -176,6 +175,7 @@
</div> </div>
<div <div
v-if="isRegistered"
id="sectionNotifications" id="sectionNotifications"
class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8" class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"
> >
@ -250,19 +250,111 @@
<div <div
id="sectionSearchLocation" id="sectionSearchLocation"
class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8" class="flex justify-between bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"
> >
<!-- label --> <!-- label -->
<div class="mb-2 font-bold">Location for Searches</div> <span class="mb-2 font-bold">Location for Searches</span>
<router-link <router-link
:to="{ name: 'search-area' }" :to="{ name: 'search-area' }"
class="block w-full text-center text-m bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-2 mt-6" class="text-m bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-2"
> >
Set Search Area {{ isSearchAreasSet ? "Change" : "Set" }} Search Area
<!-- If already set, change button label to "Change Search Area" -->
</router-link> </router-link>
</div> </div>
<!-- User Profile -->
<div
v-if="isRegistered"
class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"
>
<div v-if="loadingProfile" class="text-center mb-2">
<fa icon="spinner" class="fa-spin text-slate-400"></fa> Loading
profile...
</div>
<div v-else class="flex items-center mb-2">
<span class="font-bold">Public Profile</span>
<fa
icon="circle-info"
class="text-slate-400 fa-fw ml-2 cursor-pointer"
@click="showProfileInfo"
/>
</div>
<textarea
v-model="userProfileDesc"
class="w-full h-32 p-2 border border-slate-300 rounded-md"
placeholder="Write something about yourself for the public..."
:readonly="loadingProfile || savingProfile"
:class="{ 'bg-slate-100': loadingProfile || savingProfile }"
></textarea>
<div class="flex items-center mb-4" @click="toggleUserProfileLocation">
<input
type="checkbox"
class="mr-2"
v-model="includeUserProfileLocation"
/>
<label for="includeUserProfileLocation">Include Location</label>
</div>
<div v-if="includeUserProfileLocation" class="mb-4 aspect-video">
<p class="text-sm mb-2 text-slate-500">
For your security, choose a location nearby but not exactly at your
place.
</p>
<l-map
ref="profileMap"
class="!z-40 rounded-md"
@click="
(event: LeafletMouseEvent) => {
userProfileLatitude = event.latlng.lat;
userProfileLongitude = event.latlng.lng;
}
"
@ready="onMapReady"
>
<l-tile-layer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
layer-type="base"
name="OpenStreetMap"
/>
<l-marker
v-if="userProfileLatitude && userProfileLongitude"
:lat-lng="[userProfileLatitude, userProfileLongitude]"
@click="confirmEraseLatLong()"
/>
</l-map>
</div>
<div v-if="!loadingProfile && !savingProfile">
<div class="flex justify-between items-center">
<button
@click="saveProfile"
class="mt-2 px-4 py-2 bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white rounded-md"
:disabled="loadingProfile || savingProfile"
:class="{
'opacity-50 cursor-not-allowed': loadingProfile || savingProfile,
}"
>
Save Profile
</button>
<button
@click="confirmDeleteProfile"
class="mt-2 px-4 py-2 bg-gradient-to-b from-red-400 to-red-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white rounded-md"
:disabled="loadingProfile || savingProfile"
:class="{
'opacity-50 cursor-not-allowed':
loadingProfile ||
savingProfile ||
(!userProfileDesc && !includeUserProfileLocation),
}"
>
Delete Profile
</button>
</div>
</div>
<div v-else-if="loadingProfile">Loading...</div>
<div v-else>Saving...</div>
</div>
<div <div
v-if="activeDid" v-if="activeDid"
id="sectionUsageLimits" id="sectionUsageLimits"
@ -599,7 +691,7 @@
<h2 class="text-slate-500 text-sm font-bold mb-2"> <h2 class="text-slate-500 text-sm font-bold mb-2">
Notification Push Server Notification Push Server
</h2> </h2>
<div id="sectionNotificationPushServer" class="px-3 py-4"> <div class="px-3 py-4">
<input <input
type="text" type="text"
class="block w-full rounded border border-slate-400 px-3 py-2" class="block w-full rounded border border-slate-400 px-3 py-2"
@ -676,7 +768,7 @@
{{ DEFAULT_PARTNER_API_SERVER }} {{ DEFAULT_PARTNER_API_SERVER }}
</span> </span>
<div id="sectionImageServerURL" class="mt-2"> <div class="mt-2">
<span class="text-slate-500 text-sm font-bold">Image Server URL</span> <span class="text-slate-500 text-sm font-bold">Image Server URL</span>
&nbsp; &nbsp;
<span class="text-sm">{{ DEFAULT_IMAGE_API_SERVER }}</span> <span class="text-sm">{{ DEFAULT_IMAGE_API_SERVER }}</span>
@ -791,17 +883,21 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import "leaflet/dist/leaflet.css";
import { AxiosError } from "axios"; import { AxiosError } from "axios";
import { Buffer } from "buffer/"; import { Buffer } from "buffer/";
import Dexie from "dexie"; import Dexie from "dexie";
import "dexie-export-import"; import "dexie-export-import";
import { ImportProgress } from "dexie-export-import/dist/import"; import { ImportProgress } from "dexie-export-import/dist/import";
import { LeafletMouseEvent } from "leaflet";
import * as R from "ramda"; import * as R from "ramda";
import { IIdentifier } from "@veramo/core"; import { IIdentifier } from "@veramo/core";
import { ref } from "vue"; import { ref } from "vue";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router"; import { Router } from "vue-router";
import { useClipboard } from "@vueuse/core"; import { useClipboard } from "@vueuse/core";
import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
import EntityIcon from "../components/EntityIcon.vue"; import EntityIcon from "../components/EntityIcon.vue";
import ImageMethodDialog from "../components/ImageMethodDialog.vue"; import ImageMethodDialog from "../components/ImageMethodDialog.vue";
@ -819,6 +915,7 @@ import {
} from "../constants/app"; } from "../constants/app";
import { import {
db, db,
logConsoleAndDb,
retrieveSettingsForActiveAccount, retrieveSettingsForActiveAccount,
updateAccountSettings, updateAccountSettings,
} from "../db/index"; } from "../db/index";
@ -830,8 +927,9 @@ import {
} from "../db/tables/settings"; } from "../db/tables/settings";
import { import {
clearPasskeyToken, clearPasskeyToken,
ErrorResponse,
EndorserRateLimits, EndorserRateLimits,
ErrorResponse,
errorStringForLog,
fetchEndorserRateLimits, fetchEndorserRateLimits,
fetchImageRateLimits, fetchImageRateLimits,
getHeaders, getHeaders,
@ -850,6 +948,10 @@ const inputImportFileNameRef = ref<Blob>();
components: { components: {
EntityIcon, EntityIcon,
ImageMethodDialog, ImageMethodDialog,
LeafletMouseEvent,
LMap,
LMarker,
LTileLayer,
PushNotificationPermission, PushNotificationPermission,
QuickNav, QuickNav,
TopMessage, TopMessage,
@ -873,23 +975,26 @@ export default class AccountViewView extends Vue {
givenName = ""; givenName = "";
hideRegisterPromptOnNewContact = false; hideRegisterPromptOnNewContact = false;
imageLimits: ImageRateLimits | null = null; imageLimits: ImageRateLimits | null = null;
imageServer = ""; includeUserProfileLocation = false;
isRegistered = false; isRegistered = false;
isSearchAreasSet = false;
limitsMessage = ""; limitsMessage = "";
loadingLimits = false; loadingLimits = false;
loadingProfile = true;
notifyingNewActivity = false; notifyingNewActivity = false;
notifyingNewActivityTime = ""; notifyingNewActivityTime = "";
notifyingReminder = false; notifyingReminder = false;
notifyingReminderMessage = ""; notifyingReminderMessage = "";
notifyingReminderTime = ""; notifyingReminderTime = "";
partnerApiServer = ""; partnerApiServer = DEFAULT_PARTNER_API_SERVER;
partnerApiServerInput = ""; partnerApiServerInput = DEFAULT_PARTNER_API_SERVER;
passkeyExpirationDescription = ""; passkeyExpirationDescription = "";
passkeyExpirationMinutes = DEFAULT_PASSKEY_EXPIRATION_MINUTES; passkeyExpirationMinutes = DEFAULT_PASSKEY_EXPIRATION_MINUTES;
previousPasskeyExpirationMinutes = DEFAULT_PASSKEY_EXPIRATION_MINUTES; previousPasskeyExpirationMinutes = DEFAULT_PASSKEY_EXPIRATION_MINUTES;
profileImageUrl?: string; profileImageUrl?: string;
publicHex = ""; publicHex = "";
publicBase64 = ""; publicBase64 = "";
savingProfile = false;
showAdvanced = false; showAdvanced = false;
showB64Copy = false; showB64Copy = false;
showContactGives = false; showContactGives = false;
@ -903,8 +1008,12 @@ export default class AccountViewView extends Vue {
subscription: PushSubscription | null = null; subscription: PushSubscription | null = null;
warnIfProdServer = false; warnIfProdServer = false;
warnIfTestServer = false; warnIfTestServer = false;
webPushServer = ""; webPushServer = DEFAULT_PUSH_SERVER;
webPushServerInput = ""; webPushServerInput = DEFAULT_PUSH_SERVER;
userProfileDesc = "";
userProfileLatitude = 0;
userProfileLongitude = 0;
zoom = 2;
/** /**
* Async function executed when the component is mounted. * Async function executed when the component is mounted.
@ -918,6 +1027,49 @@ export default class AccountViewView extends Vue {
// Initialize component state with values from the database or defaults // Initialize component state with values from the database or defaults
await this.initializeState(); await this.initializeState();
await this.processIdentity(); await this.processIdentity();
// Load the user profile
if (this.isRegistered) {
try {
const headers = await getHeaders(this.activeDid);
const response = await this.axios.get(
this.apiServer +
"/api/partner/userProfileForIssuer/" +
this.activeDid,
{ headers },
);
if (response.status === 200) {
this.userProfileDesc = response.data.data.description || "";
this.userProfileLatitude = response.data.data.locLat || 0;
this.userProfileLongitude = response.data.data.locLon || 0;
if (this.userProfileLatitude && this.userProfileLongitude) {
this.includeUserProfileLocation = true;
}
} else {
// won't get here because axios throws an error instead
throw Error("Unable to load profile.");
}
} catch (error) {
if (error.status === 404) {
// this is ok: the profile is not yet created
} else {
logConsoleAndDb(
"Error loading profile: " + errorStringForLog(error),
);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Loading Profile",
text: "Your server profile is not available.",
},
5000,
);
}
} finally {
this.loadingProfile = false;
}
}
} catch (error) { } catch (error) {
// this can happen when running automated tests in dev mode because notifications don't work // this can happen when running automated tests in dev mode because notifications don't work
console.error( console.error(
@ -992,14 +1144,15 @@ export default class AccountViewView extends Vue {
this.hideRegisterPromptOnNewContact = this.hideRegisterPromptOnNewContact =
!!settings.hideRegisterPromptOnNewContact; !!settings.hideRegisterPromptOnNewContact;
this.isRegistered = !!settings?.isRegistered; this.isRegistered = !!settings?.isRegistered;
this.imageServer = settings.imageServer || ""; this.isSearchAreasSet = !!settings.searchBoxes;
this.notifyingNewActivity = !!settings.notifyingNewActivityTime; this.notifyingNewActivity = !!settings.notifyingNewActivityTime;
this.notifyingNewActivityTime = settings.notifyingNewActivityTime || ""; this.notifyingNewActivityTime = settings.notifyingNewActivityTime || "";
this.notifyingReminder = !!settings.notifyingReminderTime; this.notifyingReminder = !!settings.notifyingReminderTime;
this.notifyingReminderMessage = settings.notifyingReminderMessage || ""; this.notifyingReminderMessage = settings.notifyingReminderMessage || "";
this.notifyingReminderTime = settings.notifyingReminderTime || ""; this.notifyingReminderTime = settings.notifyingReminderTime || "";
this.partnerApiServer = settings.partnerApiServer || ""; this.partnerApiServer = settings.partnerApiServer || this.partnerApiServer;
this.partnerApiServerInput = settings.partnerApiServer || ""; this.partnerApiServerInput =
settings.partnerApiServer || this.partnerApiServerInput;
this.profileImageUrl = settings.profileImageUrl; this.profileImageUrl = settings.profileImageUrl;
this.showContactGives = !!settings.showContactGivesInline; this.showContactGives = !!settings.showContactGivesInline;
this.passkeyExpirationMinutes = this.passkeyExpirationMinutes =
@ -1009,8 +1162,8 @@ export default class AccountViewView extends Vue {
this.showShortcutBvc = !!settings.showShortcutBvc; this.showShortcutBvc = !!settings.showShortcutBvc;
this.warnIfProdServer = !!settings.warnIfProdServer; this.warnIfProdServer = !!settings.warnIfProdServer;
this.warnIfTestServer = !!settings.warnIfTestServer; this.warnIfTestServer = !!settings.warnIfTestServer;
this.webPushServer = settings.webPushServer || ""; this.webPushServer = settings.webPushServer || this.webPushServer;
this.webPushServerInput = settings.webPushServer || ""; this.webPushServerInput = settings.webPushServer || this.webPushServerInput;
} }
// call fn, copy text to the clipboard, then redo fn after 2 seconds // call fn, copy text to the clipboard, then redo fn after 2 seconds
@ -1498,13 +1651,19 @@ export default class AccountViewView extends Vue {
*/ */
private handleRateLimitsError(error: unknown) { private handleRateLimitsError(error: unknown) {
if (error instanceof AxiosError) { if (error instanceof AxiosError) {
const data = error.response?.data as ErrorResponse; if (error.status == 400 || error.status == 404) {
this.limitsMessage = // no worries: they probably just aren't registered and don't have any limits
(data?.error?.message as string) || "Bad server response."; console.log(
console.error( "Got 400 or 404 response retrieving limits which probably means they're not registered:",
"Got bad response retrieving limits, which usually means user isn't registered.", error,
error, );
); this.limitsMessage = "No limits were found, so no actions are allowed.";
} else {
const data = error.response?.data as ErrorResponse;
this.limitsMessage =
(data?.error?.message as string) || "Bad server response.";
console.error("Got bad response retrieving limits:", error);
}
} else { } else {
this.limitsMessage = "Got an error retrieving limits."; this.limitsMessage = "Got an error retrieving limits.";
console.error("Got some error retrieving limits:", error); console.error("Got some error retrieving limits:", error);
@ -1635,5 +1794,174 @@ export default class AccountViewView extends Vue {
} }
} }
} }
onMapReady(map: L.Map) {
// doing this here instead of on the l-map element avoids a recentering after a drag then zoom at startup
const zoom = this.userProfileLatitude && this.userProfileLongitude ? 12 : 2;
map.setView([this.userProfileLatitude, this.userProfileLongitude], zoom);
}
showProfileInfo() {
this.$notify(
{
group: "alert",
type: "info",
title: "Public Profile Information",
text: "This data will be published for all to see, so be careful what your write. Your ID will only be shared with people who you allow to see your activity.",
},
7000,
);
}
async saveProfile() {
this.savingProfile = true;
try {
const headers = await getHeaders(this.activeDid);
const payload: UserProfile = {
description: this.userProfileDesc,
};
if (this.userProfileLatitude && this.userProfileLongitude) {
payload.locLat = this.userProfileLatitude;
payload.locLon = this.userProfileLongitude;
} else if (this.includeUserProfileLocation) {
this.$notify(
{
group: "alert",
type: "toast",
title: "",
text: "No profile location is saved.",
},
3000,
);
}
const response = await this.axios.post(
this.apiServer + "/api/partner/userProfile",
payload,
{ headers },
);
if (response.status === 201) {
this.$notify(
{
group: "alert",
type: "success",
title: "Profile Saved",
text: "Your profile has been updated successfully.",
},
3000,
);
} else {
// won't get here because axios throws an error on non-success
throw Error("Profile not saved");
}
} catch (error) {
logConsoleAndDb("Error saving profile: " + errorStringForLog(error));
const errorMessage: string =
error.response?.data?.error?.message ||
error.response?.data?.error ||
error.message ||
"There was an error saving your profile.";
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Saving Profile",
text: errorMessage,
},
3000,
);
} finally {
this.savingProfile = false;
}
}
toggleUserProfileLocation() {
this.includeUserProfileLocation = !this.includeUserProfileLocation;
if (!this.includeUserProfileLocation) {
this.userProfileLatitude = 0;
this.userProfileLongitude = 0;
this.zoom = 2;
}
}
confirmEraseLatLong() {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Erase Marker",
text: "Are you sure you don't want to mark a location? This will erase the current location.",
onYes: async () => {
this.eraseLatLong();
},
},
-1,
);
}
eraseLatLong() {
this.userProfileLatitude = 0;
this.userProfileLongitude = 0;
this.zoom = 2;
this.includeUserProfileLocation = false;
}
async confirmDeleteProfile() {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Delete Profile",
text: "Are you sure you want to delete your public profile? This will remove your description and location from the server, and it cannot be undone.",
onYes: this.deleteProfile,
},
-1,
);
}
async deleteProfile() {
this.savingProfile = true;
try {
const headers = await getHeaders(this.activeDid);
const response = await this.axios.delete(
this.apiServer + "/api/partner/userProfile",
{ headers },
);
if (response.status === 204) {
this.userProfileDesc = "";
this.userProfileLatitude = 0;
this.userProfileLongitude = 0;
this.includeUserProfileLocation = false;
this.$notify(
{
group: "alert",
type: "success",
title: "Profile Deleted",
text: "Your profile has been deleted successfully.",
},
3000,
);
} else {
throw Error("Profile not deleted");
}
} catch (error) {
logConsoleAndDb("Error deleting profile: " + errorStringForLog(error));
const errorMessage: string =
error.response?.data?.error?.message ||
error.response?.data?.error ||
error.message ||
"There was an error deleting your profile.";
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Deleting Profile",
text: errorMessage,
},
3000,
);
} finally {
this.savingProfile = false;
}
}
} }
</script> </script>

44
src/views/ClaimCertificateView.vue

@ -125,7 +125,7 @@ export default class ClaimCertificateView extends Vue {
); );
if (claimData.claimType === "GiveAction" && claimData.claim.agent) { if (claimData.claimType === "GiveAction" && claimData.claim.agent) {
const presentedText = "Thanks To "; const presentedText = "Thanks To";
ctx.font = "14px Arial"; ctx.font = "14px Arial";
const presentedWidth = ctx.measureText(presentedText).width; const presentedWidth = ctx.measureText(presentedText).width;
ctx.fillText( ctx.fillText(
@ -148,8 +148,36 @@ export default class ClaimCertificateView extends Vue {
); );
} }
// alternatively, show some offer details
if (claimData.claimType === "Offer") {
const presentedText = "To";
ctx.font = "14px Arial";
const presentedWidth = ctx.measureText(presentedText).width;
ctx.fillText(
presentedText,
(CANVAS_WIDTH - presentedWidth) / 2, // Center horizontally
CANVAS_HEIGHT * 0.37,
);
// fulfills
const agentDid =
claimData.claim.agent.identifier || claimData.claim.agent;
const agentText = serverUtil.didInfoForCertificate(
agentDid,
allContacts,
);
ctx.font = "bold 20px Arial";
const agentWidth = ctx.measureText(agentText).width;
ctx.fillText(
agentText,
(CANVAS_WIDTH - agentWidth) / 2, // Center horizontally
CANVAS_HEIGHT * 0.41,
);
}
const descriptionText = const descriptionText =
claimData.claim.name || claimData.claim.description; claimData.claim.name ||
claimData.claim.description ||
claimData.claim.itemOffered?.description; // for Offers
if (descriptionText) { if (descriptionText) {
const descriptionLine = const descriptionLine =
descriptionText.length > 50 descriptionText.length > 50
@ -164,12 +192,12 @@ export default class ClaimCertificateView extends Vue {
); );
} }
if ( const possibleObject =
claimData.claim.object?.amountOfThisGood && claimData.claim.object || // for GiveActions
claimData.claim.object?.unitCode claimData.claim.includesObject; // for Offers
) { if (possibleObject?.amountOfThisGood && possibleObject?.unitCode) {
const amount = claimData.claim.object.amountOfThisGood; const amount = possibleObject.amountOfThisGood;
const unit = claimData.claim.object.unitCode; const unit = possibleObject.unitCode;
const amountText = serverUtil.displayAmount(unit, amount); const amountText = serverUtil.displayAmount(unit, amount);
const amountWidth = ctx.measureText(amountText).width; const amountWidth = ctx.measureText(amountText).width;
// if there was no description then put this in that spot, otherwise put it below the description // if there was no description then put this in that spot, otherwise put it below the description

74
src/views/ClaimView.vue

@ -53,7 +53,7 @@
<button <button
title="Copy Link" title="Copy Link"
@click=" @click="
copyToClipboard('Current page link', window.location.href) copyToClipboard('A link to this page', window.location.href)
" "
> >
<fa icon="link" class="text-slate-500" /> <fa icon="link" class="text-slate-500" />
@ -270,16 +270,13 @@
<div class="text-sm"> <div class="text-sm">
{{ didInfo(confirmerId) }} {{ didInfo(confirmerId) }}
<span v-if="!serverUtil.isEmptyOrHiddenDid(confirmerId)"> <span v-if="!serverUtil.isEmptyOrHiddenDid(confirmerId)">
<button <a
@click=" :href="`/did/${confirmerId}`"
copyToClipboard( target="_blank"
'The DID of ' + confirmerId, class="text-blue-500"
confirmerId,
)
"
> >
<fa icon="copy" class="text-slate-400 fa-fw" /> <fa icon="arrow-up-right-from-square" class="fa-fw" />
</button> </a>
</span> </span>
</div> </div>
</div> </div>
@ -311,16 +308,13 @@
<div class="text-sm"> <div class="text-sm">
{{ didInfo(confsVisibleTo) }} {{ didInfo(confsVisibleTo) }}
<span v-if="!serverUtil.isEmptyOrHiddenDid(confsVisibleTo)"> <span v-if="!serverUtil.isEmptyOrHiddenDid(confsVisibleTo)">
<button <a
@click=" :href="`/did/${confsVisibleTo}`"
copyToClipboard( target="_blank"
'The DID of ' + confsVisibleTo, class="text-blue-500"
confsVisibleTo,
)
"
> >
<fa icon="copy" class="text-slate-400 fa-fw" /> <fa icon="arrow-up-right-from-square" class="fa-fw" />
</button> </a>
</span> </span>
</div> </div>
</div> </div>
@ -344,7 +338,7 @@
</div> </div>
</div> </div>
<!-- Note that a similar section is found in ConfirmGiftView.vue --> <!-- Note that a similar section is found in ConfirmGiftView.vue, and kinda in HiddenDidDialog.vue -->
<h2 <h2
class="font-bold uppercase text-xl text-blue-500 mt-8 cursor-pointer" class="font-bold uppercase text-xl text-blue-500 mt-8 cursor-pointer"
@click="showVeriClaimDump = !showVeriClaimDump" @click="showVeriClaimDump = !showVeriClaimDump"
@ -364,24 +358,26 @@
Some of the details are not visible to you; they show as "HIDDEN". They Some of the details are not visible to you; they show as "HIDDEN". They
are not visible to any of your direct contacts, either. are not visible to any of your direct contacts, either.
<span v-if="canShare"> <span v-if="canShare">
If you'd like to ask any of your contacts to take a look and see if You can ask one of your contacts to take a look and see if their
their contacts can see more details, contacts can see more details:
<a @click="onClickShareClaim()" class="text-blue-500" <a @click="onClickShareClaim()" class="text-blue-500"
>click to send them this info</a >click to send them this page info</a
> >
and see if they are willing to make an introduction. They are surely and see if they can make an introduction. Someone is connected to
connected to someone; if you don't know who to ask, you might try the people closer to them; if you don't know who to ask, try the person
person who registered you. who registered you.
</span> </span>
<span v-else> <span v-else>
If you'd like to ask any of your contacts to take a look and see if You can ask one of your contacts to take a look and see if their
their contacts can see more details, contacts can see more details:
<a <a
@click="copyToClipboard('A link to this page', windowLocation)" @click="copyToClipboard('A link to this page', windowLocation)"
class="text-blue-500" class="text-blue-500"
>share this page with them</a >click to copy this page info</a
> >
and see if they are willing to make an introduction. and see if they can make an introduction. Someone is connected to
people closer to them; if you don't know who to ask, try the person
who registered you.
</span> </span>
</div> </div>
@ -425,18 +421,21 @@
<span> <span>
{{ didInfo(visDid) }} {{ didInfo(visDid) }}
<span v-if="!serverUtil.isEmptyOrHiddenDid(visDid)"> <span v-if="!serverUtil.isEmptyOrHiddenDid(visDid)">
<button <a
@click="copyToClipboard('The DID of ' + visDid, visDid)" :href="`/did/${visDid}`"
target="_blank"
class="text-blue-500"
> >
<fa icon="copy" class="text-slate-400 fa-fw" /> <fa icon="arrow-up-right-from-square" class="fa-fw" />
</button> </a>
</span> </span>
<span v-if="veriClaim.publicUrls?.[visDid]" <span v-if="veriClaim.publicUrls?.[visDid]"
>, found at&nbsp;<a >, found at&nbsp;<a
:href="veriClaim.publicUrls?.[visDid]" :href="veriClaim.publicUrls?.[visDid]"
target="_blank"
class="text-blue-500" class="text-blue-500"
> >
<fa icon="globe" class="fa-fw text-slate-400" /> <fa icon="globe" class="fa-fw" />
{{ {{
veriClaim.publicUrls[visDid].substring( veriClaim.publicUrls[visDid].substring(
veriClaim.publicUrls[visDid].indexOf("//") + 2, veriClaim.publicUrls[visDid].indexOf("//") + 2,
@ -452,7 +451,7 @@
</div> </div>
</div> </div>
<span v-if="isEditedGlobalId" class="mt-2"> <span v-if="isEditedGlobalId" class="mt-2">
This record is an edited version. The latest version is here. This record is an edited version. The latest version is shown.
</span> </span>
<br /> <br />
<!-- Keep the dump contents directly between > and < to avoid weird spacing. --> <!-- Keep the dump contents directly between > and < to avoid weird spacing. -->
@ -963,9 +962,10 @@ export default class ClaimView extends Vue {
} }
onClickShareClaim() { onClickShareClaim() {
this.copyToClipboard("A link to this page", this.windowLocation);
window.navigator.share({ window.navigator.share({
title: "Help Connect Me", title: "Help Connect Me",
text: "I'm trying to find the full details of this claim. Can you help me?", text: "I'm trying to find the people who recorded this. Can you help me?",
url: this.windowLocation, url: this.windowLocation,
}); });
} }

35
src/views/ConfirmGiftView.vue

@ -254,7 +254,7 @@
</div> </div>
</div> </div>
<!-- Note that a similar section is found in ClaimView.vue --> <!-- Note that a similar section is found in ClaimView.vue, and kinda in HiddenDidDialog.vue -->
<h2 <h2
class="font-bold uppercase text-xl text-blue-500 mt-8 cursor-pointer" class="font-bold uppercase text-xl text-blue-500 mt-8 cursor-pointer"
@click="showVeriClaimDump = !showVeriClaimDump" @click="showVeriClaimDump = !showVeriClaimDump"
@ -274,24 +274,26 @@
Some of the details are not visible to you; they show as "HIDDEN". Some of the details are not visible to you; they show as "HIDDEN".
They are not visible to any of your direct contacts, either. They are not visible to any of your direct contacts, either.
<span v-if="canShare"> <span v-if="canShare">
If you'd like to ask any of your contacts to take a look and see if You can ask one of your contacts to take a look and see if their
their contacts can see more details, contacts can see more details:
<a @click="onClickShareClaim()" class="text-blue-500" <a @click="onClickShareClaim()" class="text-blue-500"
>click to send them this info</a >click to send them this page info</a
> >
and see if they are willing to make an introduction. They are surely and see if they can make an introduction. Someone is connected to
connected to someone; if you don't know who to ask, you might try people closer to them; if you don't know who to ask, try the person
the person who registered you. who registered you.
</span> </span>
<span v-else> <span v-else>
If you'd like to ask any of your contacts to take a look and see if You can ask one of your contacts to take a look and see if their
their contacts can see more details, contacts can see more details:
<a <a
@click="copyToClipboard('Location', windowLocation.href)" @click="copyToClipboard('A link to this page', windowLocation)"
class="text-blue-500" class="text-blue-500"
>share this page with them</a >click to copy this page info</a
> >
and see if they are willing to make an introduction. and see if they can make an introduction. Someone is connected to
people closer to them; if you don't know who to ask, try the person
who registered you.
</span> </span>
</div> </div>
@ -308,9 +310,7 @@
<span v-else> <span v-else>
If you'd like an introduction, If you'd like an introduction,
<a <a
@click=" @click="copyToClipboard('A link to this page', windowLocation)"
copyToClipboard('A link to this page', windowLocation.href)
"
class="text-blue-500" class="text-blue-500"
>share this page with them and ask if they'll tell you more about >share this page with them and ask if they'll tell you more about
about the participants.</a about the participants.</a
@ -448,7 +448,7 @@ export default class ClaimView extends Vue {
veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD; veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
veriClaimDump = ""; veriClaimDump = "";
veriClaimDidsVisible = {}; veriClaimDidsVisible = {};
windowLocation = window.location; windowLocation = window.location.href;
R = R; R = R;
yaml = yaml; yaml = yaml;
@ -856,10 +856,11 @@ export default class ClaimView extends Vue {
} }
onClickShareClaim() { onClickShareClaim() {
this.copyToClipboard("A link to this page", this.windowLocation);
window.navigator.share({ window.navigator.share({
title: "Help Connect Me", title: "Help Connect Me",
text: "I'm trying to find the full details of this claim. Can you help me?", text: "I'm trying to find the full details of this claim. Can you help me?",
url: this.windowLocation.href, url: this.windowLocation,
}); });
} }
} }

2
src/views/DIDView.vue

@ -6,7 +6,7 @@
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb --> <!-- Breadcrumb -->
<div id="ViewBreadcrumb" class="mb-8"> <div id="ViewBreadcrumb" class="mb-8">
<h1 class="text-lg text-center font-light relative px-7"> <h1 id="ViewHeading" class="text-lg text-center font-light relative px-7">
<!-- Back --> <!-- Back -->
<button <button
@click="$router.go(-1)" @click="$router.go(-1)"

470
src/views/DiscoverView.vue

@ -6,7 +6,7 @@
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Heading --> <!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light"> <h1 id="ViewHeading" class="text-4xl text-center font-light">
Discover Projects Discover Projects & People
</h1> </h1>
<OnboardingDialog ref="onboardingDialog" /> <OnboardingDialog ref="onboardingDialog" />
@ -15,7 +15,6 @@
<div <div
id="QuickSearch" id="QuickSearch"
class="mt-8 mb-4 flex" class="mt-8 mb-4 flex"
v-on:keyup.enter="searchSelected()"
:style="{ visibility: isSearchVisible ? 'visible' : 'hidden' }" :style="{ visibility: isSearchVisible ? 'visible' : 'hidden' }"
> >
<input <input
@ -23,16 +22,54 @@
v-model="searchTerms" v-model="searchTerms"
placeholder="Search…" placeholder="Search…"
class="block w-full rounded-l border border-r-0 border-slate-400 px-3 py-2" class="block w-full rounded-l border border-r-0 border-slate-400 px-3 py-2"
v-on:keyup.enter="searchSelected()"
/> />
<button <button
@click="searchSelected()"
class="px-4 rounded-r bg-slate-200 border border-l-0 border-slate-400" class="px-4 rounded-r bg-slate-200 border border-l-0 border-slate-400"
@click="searchSelected()"
> >
<fa icon="magnifying-glass" class="fa-fw"></fa> <fa icon="magnifying-glass" class="fa-fw"></fa>
</button> </button>
</div> </div>
<!-- Result Tabs --> <!-- Result Tabs -->
<!-- Top Level Selection -->
<div class="text-center text-slate-500 border-b border-slate-300 mb-4">
<ul class="flex flex-wrap justify-center gap-4 -mb-px">
<li>
<a
href="#"
@click="
projects = [];
userProfiles = [];
isProjectsActive = true;
isPeopleActive = false;
searchSelected();
"
v-bind:class="computedProjectsTabStyleClassNames()"
>
Projects
</a>
</li>
<li>
<a
href="#"
@click="
projects = [];
userProfiles = [];
isProjectsActive = false;
isPeopleActive = true;
searchSelected();
"
v-bind:class="computedPeopleTabStyleClassNames()"
>
People
</a>
</li>
</ul>
</div>
<!-- Secondary Tabs -->
<div class="text-center text-slate-500 border-b border-slate-300"> <div class="text-center text-slate-500 border-b border-slate-300">
<ul class="flex flex-wrap justify-center gap-4 -mb-px"> <ul class="flex flex-wrap justify-center gap-4 -mb-px">
<li> <li>
@ -40,9 +77,10 @@
href="#" href="#"
@click=" @click="
projects = []; projects = [];
userProfiles = [];
isLocalActive = true; isLocalActive = true;
isMappedActive = false; isMappedActive = false;
isRemoteActive = false; isAnywhereActive = false;
isSearchVisible = true; isSearchVisible = true;
tempSearchBox = null; tempSearchBox = null;
searchLocal(); searchLocal();
@ -65,15 +103,17 @@
href="#" href="#"
@click=" @click="
projects = []; projects = [];
userProfiles = [];
isLocalActive = false; isLocalActive = false;
isMappedActive = true; isMappedActive = true;
isRemoteActive = false; isAnywhereActive = false;
isSearchVisible = false; isSearchVisible = false;
searchTerms = ''; searchTerms = '';
tempSearchBox = null; tempSearchBox = null;
" "
v-bind:class="computedMappedTabStyleClassNames()" v-bind:class="computedMappedTabStyleClassNames()"
> >
<!-- search is triggered when map component gets to "ready" state -->
Mapped Mapped
</a> </a>
</li> </li>
@ -82,9 +122,10 @@
href="#" href="#"
@click=" @click="
projects = []; projects = [];
userProfiles = [];
isLocalActive = false; isLocalActive = false;
isMappedActive = false; isMappedActive = false;
isRemoteActive = true; isAnywhereActive = true;
isSearchVisible = true; isSearchVisible = true;
tempSearchBox = null; tempSearchBox = null;
searchAll(); searchAll();
@ -95,7 +136,7 @@
<!-- restore when the links don't jump around for different numbers <!-- restore when the links don't jump around for different numbers
<span <span
class="font-semibold text-sm bg-slate-200 px-1.5 py-0.5 rounded-md" class="font-semibold text-sm bg-slate-200 px-1.5 py-0.5 rounded-md"
v-if="isRemoteActive" v-if="isAnywhereActive"
> >
{{ remoteCount > -1 ? remoteCount : "?" }} {{ remoteCount > -1 ? remoteCount : "?" }}
</span> </span>
@ -143,13 +184,16 @@
> >
<fa icon="spinner" class="fa-spin-pulse"></fa> <fa icon="spinner" class="fa-spin-pulse"></fa>
</div> </div>
<div v-else-if="projects.length === 0" class="text-center mt-8"> <div
v-else-if="projects.length === 0 && userProfiles.length === 0"
class="text-center mt-8"
>
<p class="text-lg text-slate-500"> <p class="text-lg text-slate-500">
<span v-if="isLocalActive"> <span v-if="isLocalActive">
<span v-if="searchBox"> None found in the selected area. </span> <span v-if="searchBox"> None found in the selected area. </span>
<!-- Otherwise there's no search area selected so we'll just leave the search box for them to click. --> <!-- Otherwise there's no search area selected so we'll just leave the search box for them to click. -->
</span> </span>
<span v-else-if="isRemoteActive" <span v-else-if="isAnywhereActive"
>No projects were found with that search.</span >No projects were found with that search.</span
> >
</p> </p>
@ -158,35 +202,89 @@
<!-- Results List --> <!-- Results List -->
<InfiniteScroll @reached-bottom="loadMoreData"> <InfiniteScroll @reached-bottom="loadMoreData">
<ul id="listDiscoverResults"> <ul id="listDiscoverResults">
<li <!-- Projects List -->
class="border-b border-slate-300" <template v-if="isProjectsActive">
v-for="project in projects" <li
:key="project.handleId" class="border-b border-slate-300"
> v-for="project in projects"
<a :key="project.handleId"
@click="onClickLoadProject(project.handleId)"
class="block py-4 flex gap-4 cursor-pointer"
> >
<div> <a
<ProjectIcon @click="onClickLoadItem(project.handleId)"
:entityId="project.handleId" class="block py-4 flex gap-4 cursor-pointer"
:iconSize="48" >
:imageUrl="project.image" <div>
class="block border border-slate-300 rounded-md max-h-12 max-w-12" <ProjectIcon
/> :entityId="project.handleId"
</div> :iconSize="48"
:imageUrl="project.image"
<div class="grow"> class="block border border-slate-300 rounded-md max-h-12 max-w-12"
<h2 class="text-base font-semibold">{{ project.name }}</h2> />
<div class="text-sm">
<fa icon="user" class="fa-fw text-slate-400"></fa>
{{
didInfo(project.issuerDid, activeDid, allMyDids, allContacts)
}}
</div> </div>
</div>
</a> <div class="grow">
</li> <h2 class="text-base font-semibold">{{ project.name }}</h2>
<div class="text-sm">
<fa icon="user" class="fa-fw text-slate-400"></fa>
{{
didInfo(
project.issuerDid,
activeDid,
allMyDids,
allContacts,
)
}}
</div>
</div>
</a>
</li>
</template>
<!-- Profiles List -->
<template v-else>
<li
class="border-b border-slate-300"
v-for="profile in userProfiles"
:key="profile.issuerDid"
>
<a
@click="onClickLoadItem(profile?.rowId || '')"
class="block py-4 flex gap-4 cursor-pointer"
>
<div class="grow">
<div class="text-sm">
<fa icon="user" class="fa-fw text-slate-400"></fa>
{{
didInfo(
profile.issuerDid,
activeDid,
allMyDids,
allContacts,
)
}}
</div>
<p
v-if="profile.description"
class="mt-1 text-sm text-slate-600"
>
{{ profile.description }}
</p>
<div
v-if="isAnywhereActive && profile.locLat && profile.locLon"
class="mt-1 text-xs text-slate-500"
>
<fa icon="location-dot" class="fa-fw"></fa>
{{
(profile.locLat > 0 ? "North" : "South") +
" in " +
(profile.locLon > 0 ? "Eastern" : "Western") +
" Hemisphere"
}}
</div>
</div>
</a>
</li>
</template>
</ul> </ul>
</InfiniteScroll> </InfiniteScroll>
</section> </section>
@ -197,7 +295,7 @@ import "leaflet/dist/leaflet.css";
import * as L from "leaflet"; import * as L from "leaflet";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { LMap, LTileLayer } from "@vue-leaflet/vue-leaflet"; import { LMap, LTileLayer } from "@vue-leaflet/vue-leaflet";
import { Router } from "vue-router"; import { Router, RouteLocationNormalizedLoaded } from "vue-router";
import QuickNav from "../components/QuickNav.vue"; import QuickNav from "../components/QuickNav.vue";
import InfiniteScroll from "../components/InfiniteScroll.vue"; import InfiniteScroll from "../components/InfiniteScroll.vue";
@ -220,6 +318,16 @@ import {
} from "../libs/endorserServer"; } from "../libs/endorserServer";
import { OnboardPage, retrieveAccountDids } from "../libs/util"; import { OnboardPage, retrieveAccountDids } from "../libs/util";
interface Tile {
indexLat: number;
indexLon: number;
minFoundLat: number;
maxFoundLat: number;
minFoundLon: number;
maxFoundLon: number;
recordCount: number;
}
@Component({ @Component({
components: { components: {
InfiniteScroll, InfiniteScroll,
@ -233,25 +341,31 @@ import { OnboardPage, retrieveAccountDids } from "../libs/util";
}) })
export default class DiscoverView extends Vue { export default class DiscoverView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
$router!: Router;
$route!: RouteLocationNormalizedLoaded;
activeDid = ""; activeDid = "";
allContacts: Array<Contact> = []; allContacts: Array<Contact> = [];
allMyDids: Array<string> = []; allMyDids: Array<string> = [];
apiServer = ""; apiServer = "";
searchTerms = "";
projects: PlanData[] = [];
isLoading = false; isLoading = false;
isLocalActive = true; isLocalActive = true;
isMappedActive = false; isMappedActive = false;
isRemoteActive = false; isAnywhereActive = false;
isProjectsActive = true;
isPeopleActive = false;
isSearchVisible = true; isSearchVisible = true;
localCenterLat = 0; localCenterLat = 0;
localCenterLong = 0; localCenterLong = 0;
localCount = -1; localCount = -1;
markers: { [key: string]: L.Marker } = {}; markers: { [key: string]: L.Marker } = {};
partnerApiServer = DEFAULT_PARTNER_API_SERVER;
projects: PlanData[] = [];
remoteCount = -1; remoteCount = -1;
searchBox: { name: string; bbox: BoundingBox } | null = null; searchBox: { name: string; bbox: BoundingBox } | null = null;
searchTerms = "";
tempSearchBox: BoundingBox | null = null; tempSearchBox: BoundingBox | null = null;
userProfiles: UserProfile[] = [];
zoomedSoDoNotMove = false; zoomedSoDoNotMove = false;
// make this function available to the Vue template // make this function available to the Vue template
@ -261,13 +375,15 @@ export default class DiscoverView extends Vue {
const settings = await retrieveSettingsForActiveAccount(); const settings = await retrieveSettingsForActiveAccount();
this.activeDid = (settings.activeDid as string) || ""; this.activeDid = (settings.activeDid as string) || "";
this.apiServer = (settings.apiServer as string) || ""; this.apiServer = (settings.apiServer as string) || "";
this.partnerApiServer =
(settings.partnerApiServer as string) || this.partnerApiServer;
this.searchBox = settings.searchBoxes?.[0] || null; this.searchBox = settings.searchBoxes?.[0] || null;
this.allContacts = await db.contacts.toArray(); this.allContacts = await db.contacts.toArray();
this.allMyDids = await retrieveAccountDids(); this.allMyDids = await retrieveAccountDids();
this.searchTerms = (this.$route as Router).query["searchText"] || ""; this.searchTerms = this.$route.query["searchText"]?.toString() || "";
if (!settings.finishedOnboarding) { if (!settings.finishedOnboarding) {
(this.$refs.onboardingDialog as OnboardingDialog).open( (this.$refs.onboardingDialog as OnboardingDialog).open(
@ -284,7 +400,7 @@ export default class DiscoverView extends Vue {
} else { } else {
this.isLocalActive = false; this.isLocalActive = false;
this.isMappedActive = false; this.isMappedActive = false;
this.isRemoteActive = true; this.isAnywhereActive = true;
await this.searchAll(); await this.searchAll();
} }
} }
@ -298,8 +414,8 @@ export default class DiscoverView extends Vue {
if (this.isLocalActive) { if (this.isLocalActive) {
await this.searchLocal(); await this.searchLocal();
} else if (this.isMappedActive) { } else if (this.isMappedActive) {
this.isRemoteActive = true; const mapRef = this.$refs.projectMap as L.Map;
await this.searchAll(); this.requestTiles(mapRef.leafletObject); // not ideal because I found this from experimentation, not documentation
} else { } else {
await this.searchAll(); await this.searchAll();
} }
@ -311,6 +427,7 @@ export default class DiscoverView extends Vue {
if (!beforeId) { if (!beforeId) {
// this was an initial search so clear any previous results // this was an initial search so clear any previous results
this.projects = []; this.projects = [];
this.userProfiles = [];
} }
let queryParams = "claimContents=" + encodeURIComponent(this.searchTerms); let queryParams = "claimContents=" + encodeURIComponent(this.searchTerms);
@ -319,62 +436,58 @@ export default class DiscoverView extends Vue {
queryParams = queryParams + `&beforeId=${beforeId}`; queryParams = queryParams + `&beforeId=${beforeId}`;
} }
const endpoint = this.isProjectsActive
? this.apiServer + "/api/v2/report/plans"
: this.partnerApiServer + "/api/partner/userProfile";
try { try {
this.isLoading = true; this.isLoading = true;
const response = await fetch( const response = await fetch(endpoint + "?" + queryParams, {
this.apiServer + "/api/v2/report/plans?" + queryParams, method: "GET",
{ headers: await getHeaders(this.activeDid),
method: "GET", });
headers: await getHeaders(this.activeDid),
},
);
if (response.status !== 200) { if (response.status !== 200) {
const details = await response.text(); const details = await response.text();
console.error("Problem with full search:", details);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: `There was a problem accessing the server.`,
},
3000,
);
throw details; throw details;
} }
const results = await response.json(); const results = await response.json();
const plans: PlanData[] = results.data; if (this.isProjectsActive) {
if (plans) { this.userProfiles = [];
for (const plan of plans) { const plans: PlanData[] = results.data;
const { name, description, handleId, image, issuerDid, rowid } = plan; if (plans) {
this.projects.push({ this.projects.push(...plans);
name, this.remoteCount = this.projects.length;
description, } else {
handleId, throw JSON.stringify(results);
image,
issuerDid,
rowid,
});
} }
this.remoteCount = this.projects.length;
} else { } else {
throw JSON.stringify(results); this.projects = [];
const profiles: UserProfile[] = results.data;
if (profiles) {
this.userProfiles.push(...profiles);
this.remoteCount = this.userProfiles.length;
} else {
throw JSON.stringify(results);
}
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) { } catch (e: any) {
console.error("Error with feed load:", e); console.error("Error with search all:", e);
// this sometimes gives different information // this sometimes gives different information
console.error("Error with feed load (error added): " + e); console.error("Error with search all (error added): " + e);
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
type: "danger", type: "danger",
title: "Error", title: "Error Searching",
text: e.userMessage || "There was a problem retrieving projects.", text:
e.userMessage ||
"There was a problem retrieving " +
(this.isProjectsActive ? "projects" : "profiles") +
".",
}, },
5000, 5000,
); );
@ -392,12 +505,14 @@ export default class DiscoverView extends Vue {
if (!searchBox) { if (!searchBox) {
this.projects = []; this.projects = [];
this.userProfiles = [];
return; return;
} }
if (!beforeId) { if (!beforeId) {
// this was an initial search so clear any previous results // this was an initial search so clear any previous results
this.projects = []; this.projects = [];
this.userProfiles = [];
} }
const claimContents = const claimContents =
@ -407,70 +522,64 @@ export default class DiscoverView extends Vue {
claimContents, claimContents,
"minLocLat=" + searchBox.minLat, "minLocLat=" + searchBox.minLat,
"maxLocLat=" + searchBox.maxLat, "maxLocLat=" + searchBox.maxLat,
"westLocLon=" + searchBox.westLong, "minLocLon=" + searchBox.westLong,
"eastLocLon=" + searchBox.eastLong, "maxLocLon=" + searchBox.eastLong,
].join("&"); ].join("&");
if (beforeId) { if (beforeId) {
queryParams = queryParams + `&beforeId=${beforeId}`; queryParams = queryParams + `&beforeId=${beforeId}`;
} }
const endpoint = this.isProjectsActive
? this.apiServer + "/api/v2/report/plansByLocation"
: this.partnerApiServer + "/api/partner/userProfile";
try { try {
this.isLoading = true; this.isLoading = true;
const response = await fetch( const response = await fetch(endpoint + "?" + queryParams, {
this.apiServer + "/api/v2/report/plansByLocation?" + queryParams, method: "GET",
{ headers: await getHeaders(this.activeDid),
method: "GET", });
headers: await getHeaders(this.activeDid),
},
);
if (response.status !== 200) { if (response.status !== 200) {
const details = await response.text(); const details = await response.text();
console.error("Problem with nearby search:", details); throw details;
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem accessing the server.",
},
3000,
);
throw await response.text();
} }
const results = await response.json(); const results = await response.json();
if (results.data) { if (this.isProjectsActive) {
if (beforeId) { this.userProfiles = [];
const plans: PlanData[] = results.data; const plans: PlanData[] = results.data;
for (const plan of plans) { if (plans) {
const { name, description, handleId, issuerDid, rowid } = plan; this.projects.push(...plans);
this.projects.push({ this.localCount = this.projects.length;
name,
description,
handleId,
issuerDid,
rowid,
});
}
} else { } else {
this.projects = results.data; throw JSON.stringify(results);
} }
this.localCount = this.projects.length;
} else { } else {
throw JSON.stringify(results); this.projects = [];
const profiles: UserProfile[] = results.data;
if (profiles) {
this.userProfiles.push(...profiles);
this.localCount = this.userProfiles.length;
} else {
throw JSON.stringify(results);
}
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) { } catch (e: any) {
console.error("Error with feed load:", e); console.error("Error with search local:", e);
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
type: "danger", type: "danger",
title: "Error", title: "Error",
text: e.userMessage || "There was a problem retrieving projects.", text:
e.userMessage ||
"There was a problem retrieving " +
(this.isProjectsActive ? "projects" : "profiles") +
".",
}, },
5000, 5000,
); );
@ -484,26 +593,38 @@ export default class DiscoverView extends Vue {
* @param payload is the flag from the InfiniteScroll indicating if it should load * @param payload is the flag from the InfiniteScroll indicating if it should load
**/ **/
async loadMoreData(payload: boolean) { async loadMoreData(payload: boolean) {
if (this.projects.length > 0 && payload) { if (payload) {
const latestProject = this.projects[this.projects.length - 1]; if (this.isProjectsActive && this.projects.length > 0) {
if (this.isLocalActive) { const latestProject = this.projects[this.projects.length - 1];
this.searchLocal(latestProject["rowid"]); if (this.isLocalActive || this.isMappedActive) {
} else if (this.isMappedActive) { this.searchLocal(latestProject.rowId);
this.searchLocal(latestProject["rowid"]); } else if (this.isAnywhereActive) {
} else if (this.isRemoteActive) { this.searchAll(latestProject.rowId);
this.searchAll(latestProject["rowid"]); }
} else if (this.isPeopleActive && this.userProfiles.length > 0) {
const latestProfile = this.userProfiles[this.userProfiles.length - 1];
if (this.isLocalActive || this.isMappedActive) {
this.searchLocal(latestProfile.rowId || "");
} else if (this.isAnywhereActive) {
this.searchAll(latestProfile.rowId || "");
}
} }
} }
} }
clearMarkers() {
Object.values(this.markers).forEach((marker) => marker.remove());
this.markers = {};
}
async onMapReady(map: L.Map) { async onMapReady(map: L.Map) {
// doing this here instead of the l-map element avoids a recentering after the first drag // doing this here instead of on the l-map element avoids a recentering after a drag then zoom at startup
map.setView([this.localCenterLat, this.localCenterLong], 2); map.setView([this.localCenterLat, this.localCenterLong], 2);
this.requestTiles(map); this.requestTiles(map);
} }
// Tried but failed to use other vue-leaflet methods update:zoom and update:bounds // Tried but failed to use other vue-leaflet methods update:zoom and update:bounds
// To access the from this.$refs, use this.$refs.projectMap.mapObject // To access the from this.$refs, use this.$refs.projectMap.leafletObject (or maybe mapObject)
onMoveStart(/* event: L.LocationEvent */) { onMoveStart(/* event: L.LocationEvent */) {
// don't remove markers because they follow the map when moving (and the experience is jarring) // don't remove markers because they follow the map when moving (and the experience is jarring)
@ -521,8 +642,7 @@ export default class DiscoverView extends Vue {
onZoomStart(/* event: L.LocationEvent */) { onZoomStart(/* event: L.LocationEvent */) {
// remove markers because otherwise they jump around at zoom end // remove markers because otherwise they jump around at zoom end
Object.values(this.markers).forEach((marker) => marker.remove()); this.clearMarkers();
this.markers = {};
this.zoomedSoDoNotMove = true; this.zoomedSoDoNotMove = true;
} }
@ -540,15 +660,15 @@ export default class DiscoverView extends Vue {
"westLocLon=" + bounds?.getSouthWest().lng, "westLocLon=" + bounds?.getSouthWest().lng,
"eastLocLon=" + bounds?.getNorthEast().lng, "eastLocLon=" + bounds?.getNorthEast().lng,
].join("&"); ].join("&");
const response = await fetch( const endpoint = this.isProjectsActive
this.apiServer + "/api/v2/report/planCountsByBBox?" + queryParams, ? this.apiServer + "/api/v2/report/planCountsByBBox"
); : this.partnerApiServer + "/api/partner/userProfileCountsByBBox";
const response = await fetch(endpoint + "?" + queryParams);
if (response.status === 200) { if (response.status === 200) {
Object.values(this.markers).forEach((marker) => marker.remove()); this.clearMarkers();
this.markers = {};
const results = await response.json(); const results = await response.json();
if (results.data?.tiles?.length > 0) { if (results.data?.tiles?.length > 0) {
for (const tile of results.data.tiles) { for (const tile: Tile of results.data.tiles) {
const pinLat = (tile.minFoundLat + tile.maxFoundLat) / 2; const pinLat = (tile.minFoundLat + tile.maxFoundLat) / 2;
const pinLon = (tile.minFoundLon + tile.maxFoundLon) / 2; const pinLon = (tile.minFoundLon + tile.maxFoundLon) / 2;
const numberIcon = L.divIcon({ const numberIcon = L.divIcon({
@ -569,10 +689,22 @@ export default class DiscoverView extends Vue {
}; };
this.searchLocal(); this.searchLocal();
}); });
this.markers["" + tile.indexLat + "X" + tile.indexLon] = marker; this.markers[
"" +
tile.indexLat +
"X" +
tile.indexLon +
"_" +
tile.minFoundLat +
"X" +
tile.minFoundLon +
"-" +
tile.maxFoundLat +
"X" +
tile.maxFoundLon
] = marker;
} }
} }
await this.searchLocal();
} else { } else {
throw { throw {
message: "Got an error loading projects on the map.", message: "Got an error loading projects on the map.",
@ -601,14 +733,16 @@ export default class DiscoverView extends Vue {
} }
/** /**
* Handle clicking on a project entry found in the list * Handle clicking on a project or profile entry found in the list
* @param id of the project * @param id of the project or profile
**/ **/
onClickLoadProject(id: string) { onClickLoadItem(id: string) {
const route = { const route = {
path: "/project/" + encodeURIComponent(id), path: this.isProjectsActive
? "/project/" + encodeURIComponent(id)
: "/userProfile/" + encodeURIComponent(id),
}; };
(this.$router as Router).push(route); this.$router.push(route);
} }
public computedLocalTabStyleClassNames() { public computedLocalTabStyleClassNames() {
@ -654,14 +788,50 @@ export default class DiscoverView extends Vue {
"rounded-t-lg": true, "rounded-t-lg": true,
"border-b-2": true, "border-b-2": true,
active: this.isRemoteActive, active: this.isAnywhereActive,
"text-black": this.isRemoteActive, "text-black": this.isAnywhereActive,
"border-black": this.isRemoteActive, "border-black": this.isAnywhereActive,
"font-semibold": this.isRemoteActive, "font-semibold": this.isAnywhereActive,
"text-blue-600": !this.isAnywhereActive,
"border-transparent": !this.isAnywhereActive,
"hover:border-slate-400": !this.isAnywhereActive,
};
}
public computedProjectsTabStyleClassNames() {
return {
"inline-block": true,
"py-3": true,
"rounded-t-lg": true,
"border-b-2": true,
active: this.isProjectsActive,
"text-black": this.isProjectsActive,
"border-black": this.isProjectsActive,
"font-semibold": this.isProjectsActive,
"text-blue-600": !this.isProjectsActive,
"border-transparent": !this.isProjectsActive,
"hover:border-slate-400": !this.isProjectsActive,
};
}
public computedPeopleTabStyleClassNames() {
return {
"inline-block": true,
"py-3": true,
"rounded-t-lg": true,
"border-b-2": true,
active: this.isPeopleActive,
"text-black": this.isPeopleActive,
"border-black": this.isPeopleActive,
"font-semibold": this.isPeopleActive,
"text-blue-600": !this.isRemoteActive, "text-blue-600": !this.isPeopleActive,
"border-transparent": !this.isRemoteActive, "border-transparent": !this.isPeopleActive,
"hover:border-slate-400": !this.isRemoteActive, "hover:border-slate-400": !this.isPeopleActive,
}; };
} }
} }

2
src/views/ImportDerivedAccountView.vue

@ -33,7 +33,7 @@
<fa <fa
v-if="dids[0] == selectedArrayFirstDid" v-if="dids[0] == selectedArrayFirstDid"
icon="circle" icon="circle"
class="fa-fw text-blue-400 text-xl mr-3" class="fa-fw text-blue-500 text-xl mr-3"
></fa> ></fa>
<fa <fa
v-else v-else

212
src/views/NewEditProjectView.vue

@ -77,7 +77,9 @@
maxlength="5000" maxlength="5000"
></textarea> ></textarea>
<div class="text-xs text-slate-500 italic -mt-3 mb-4"> <div class="text-xs text-slate-500 italic -mt-3 mb-4">
If you want to be contacted, be sure to include your contact information. If you want to be contacted, be sure to include your contact information
-- just remember that this information is public and saved in a public
history.
</div> </div>
<div class="text-xs text-slate-500 italic -mt-3 mb-4"> <div class="text-xs text-slate-500 italic -mt-3 mb-4">
{{ fullClaim.description?.length }}/5000 max. characters {{ fullClaim.description?.length }}/5000 max. characters
@ -152,11 +154,17 @@
<div class="flex" @click="sendToTrustroots = !sendToTrustroots"> <div class="flex" @click="sendToTrustroots = !sendToTrustroots">
<input type="checkbox" class="mr-2" v-model="sendToTrustroots" /> <input type="checkbox" class="mr-2" v-model="sendToTrustroots" />
<label>Send to Trustroots</label> <label>Send to Trustroots</label>
<fa
icon="circle-info"
class="text-blue-500 ml-2 cursor-pointer"
@click.stop="showNostrPartnerInfo"
/>
</div> </div>
<!-- <!--
<div class="flex" @click="sendToTripHopping = !sendToTripHopping"> <div class="flex" @click="sendToTripHopping = !sendToTripHopping">
<input type="checkbox" class="mr-2" v-model="sendToTripHopping" /> <input type="checkbox" class="mr-2" v-model="sendToTripHopping" />
<label>Send to TripHopping</label> <label>Send to TripHopping</label>
<fa icon="circle-info" class="text-blue-500 ml-2 cursor-pointer" @click.stop="showNostrPartnerInfo" />
</div> </div>
--> -->
</div> </div>
@ -195,8 +203,12 @@ import "leaflet/dist/leaflet.css";
import { AxiosError, AxiosRequestHeaders } from "axios"; import { AxiosError, AxiosRequestHeaders } from "axios";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import { hexToBytes } from "@noble/hashes/utils"; import { hexToBytes } from "@noble/hashes/utils";
import type { EventTemplate, VerifiedEvent } from "nostr-tools/lib/types/core"; // these core imports could also be included as "import type ..."
import { accountFromSeedWords } from "nostr-tools/nip06"; import { EventTemplate, UnsignedEvent, VerifiedEvent } from "nostr-tools/core";
import {
accountFromExtendedKey,
extendedKeysFromSeedWords,
} from "nostr-tools/nip06";
import { finalizeEvent, serializeEvent } from "nostr-tools/pure"; import { finalizeEvent, serializeEvent } from "nostr-tools/pure";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet"; import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
@ -217,7 +229,6 @@ import {
} from "../libs/endorserServer"; } from "../libs/endorserServer";
import { import {
retrieveAccountCount, retrieveAccountCount,
retrieveAccountMetadata,
retrieveFullyDecryptedAccount, retrieveFullyDecryptedAccount,
} from "../libs/util"; } from "../libs/util";
@ -408,13 +419,26 @@ export default class NewEditProjectView extends Vue {
delete vcClaim.image; delete vcClaim.image;
} }
if (this.includeLocation) { if (this.includeLocation) {
vcClaim.location = { if (!this.latitude || !this.longitude) {
geo: { this.$notify(
"@type": "GeoCoordinates", {
latitude: this.latitude, group: "alert",
longitude: this.longitude, type: "danger",
}, title: "Location Error",
}; text: "The location was invalid so it was not set.",
},
5000,
);
delete vcClaim.location;
} else {
vcClaim.location = {
geo: {
"@type": "GeoCoordinates",
latitude: this.latitude,
longitude: this.longitude,
},
};
}
} else { } else {
delete vcClaim.location; delete vcClaim.location;
} }
@ -431,7 +455,7 @@ export default class NewEditProjectView extends Vue {
{ {
group: "alert", group: "alert",
type: "danger", type: "danger",
title: "Error", title: "Date Error",
text: "The date was invalid so it was not set.", text: "The date was invalid so it was not set.",
}, },
5000, 5000,
@ -451,30 +475,58 @@ export default class NewEditProjectView extends Vue {
try { try {
const resp = await this.axios.post(url, payload, { headers }); const resp = await this.axios.post(url, payload, { headers });
if (resp.data?.success?.handleId) { if (resp.data?.success?.handleId) {
this.$notify(
{
group: "alert",
type: "success",
title: "Saved",
text: "The project was saved successfully.",
},
3000,
);
this.errorMessage = ""; this.errorMessage = "";
const projectPath = encodeURIComponent(resp.data.success.handleId); const projectPath = encodeURIComponent(resp.data.success.handleId);
let signedPayload: VerifiedEvent | undefined; // sign something to prove ownership of pubkey if (this.sendToTrustroots || this.sendToTripHopping) {
if (this.sendToTrustroots) { if (this.latitude && this.longitude) {
signedPayload = await this.signPayload(); let payloadAndKey; // sign something to prove ownership of pubkey
this.sendToNostrPartner( if (this.sendToTrustroots) {
"NOSTR-EVENT-TRUSTROOTS", payloadAndKey = await this.signSomePayload();
"Trustroots", // not going to await... the save was successful, so we'll continue to the next page
resp.data.success.claimId, this.sendToNostrPartner(
signedPayload, "NOSTR-EVENT-TRUSTROOTS",
); "Trustroots",
} resp.data.success.claimId,
if (this.sendToTripHopping) { payloadAndKey.signedEvent,
if (!signedPayload) { payloadAndKey.publicExtendedKey,
signedPayload = await this.signPayload(); );
}
if (this.sendToTripHopping) {
if (!payloadAndKey) {
payloadAndKey = await this.signSomePayload();
}
// not going to await... the save was successful, so we'll continue to the next page
this.sendToNostrPartner(
"NOSTR-EVENT-TRIPHOPPING",
"TripHopping",
resp.data.success.claimId,
payloadAndKey.signedEvent,
payloadAndKey.publicExtendedKey,
);
}
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Partner Error",
text: "A partner was selected but the location was not set, so it was not sent to any partner.",
},
5000,
);
} }
this.sendToNostrPartner(
"NOSTR-EVENT-TRIPHOPPING",
"TripHopping",
resp.data.success.claimId,
signedPayload,
);
} }
(this.$router as Router).push({ path: "/project/" + projectPath }); (this.$router as Router).push({ path: "/project/" + projectPath });
@ -541,19 +593,28 @@ export default class NewEditProjectView extends Vue {
} }
} }
private async signPayload(): Promise<VerifiedEvent> { /**
* @return a signed payload and an extended public key for later transmission
*/
private async signSomePayload(): Promise<{
signedEvent: VerifiedEvent;
publicExtendedKey: string;
}> {
const account = await retrieveFullyDecryptedAccount(this.activeDid); const account = await retrieveFullyDecryptedAccount(this.activeDid);
// get the last number of the derivationPath // get the last number of the derivationPath
const finalDerNum = account?.derivationPath?.split?.("/")?.reverse()[0]; const finalDerNum = account?.derivationPath?.split?.("/")?.reverse()[0];
// remove any trailing ' // remove any trailing '
const finalDerNumNoApostrophe = finalDerNum?.replace(/'/g, ""); const finalDerNumNoApostrophe = finalDerNum?.replace(/'/g, "");
const accountNum = Number(finalDerNumNoApostrophe || 0); const accountNum = Number(finalDerNumNoApostrophe || 0);
const pubPri = accountFromSeedWords( const extPubPri = extendedKeysFromSeedWords(
account?.mnemonic as string, account?.mnemonic as string,
"", "",
accountNum, accountNum,
); );
const privateBytes = hexToBytes(pubPri?.privateKey); const publicExtendedKey: string = extPubPri?.publicExtendedKey;
const privateExtendedKey = extPubPri?.privateExtendedKey;
const privateKey = accountFromExtendedKey(privateExtendedKey).privateKey;
const privateBytes = hexToBytes(privateKey);
// No real content is necessary, we just want something signed, // No real content is necessary, we just want something signed,
// so we might as well use nostr libs for nostr functions. // so we might as well use nostr libs for nostr functions.
// Besides: someday we may create real content that we can relay. // Besides: someday we may create real content that we can relay.
@ -563,9 +624,12 @@ export default class NewEditProjectView extends Vue {
content: "", content: "",
created_at: 0, created_at: 0,
}; };
// Why does IntelliJ not see matching types? const signedEvent: VerifiedEvent = finalizeEvent(
const signedEvent = finalizeEvent(event, privateBytes); // Why does IntelliJ not see matching types?
return signedEvent; event as EventTemplate,
privateBytes,
) as VerifiedEvent;
return { signedEvent, publicExtendedKey };
} }
private async sendToNostrPartner( private async sendToNostrPartner(
@ -573,41 +637,37 @@ export default class NewEditProjectView extends Vue {
serviceName: string, serviceName: string,
jwtId: string, jwtId: string,
signedPayload: VerifiedEvent, signedPayload: VerifiedEvent,
publicExtendedKey: string,
) { ) {
// first, get the public key for nostr
const account = await retrieveAccountMetadata(this.activeDid);
// get the last number of the derivationPath
const finalDerNum = account?.derivationPath?.split?.("/")?.reverse()[0];
// remove any trailing '
const finalDerNumNoApostrophe = finalDerNum?.replace(/'/g, "");
const accountNum = Number(finalDerNumNoApostrophe || 0);
const pubPri = accountFromSeedWords(
account?.mnemonic as string,
"",
accountNum,
);
const nostrPubKey = pubPri?.publicKey;
let partnerServer = DEFAULT_PARTNER_API_SERVER;
const settings = await retrieveSettingsForActiveAccount();
if (settings.partnerApiServer) {
partnerServer = settings.partnerApiServer;
}
const endorserPartnerUrl = partnerServer + "/api/partner/link";
const timeSafariUrl = window.location.origin + "/claim/" + jwtId;
const content = this.fullClaim.name + " - see " + timeSafariUrl;
// Why does IntelliJ not see matching types?
const payload = serializeEvent(signedPayload);
const partnerParams = {
jwtId: jwtId,
linkCode: linkCode,
inputJson: JSON.stringify(content),
pubKeyHex: nostrPubKey,
pubKeyImage: payload,
pubKeySigHex: signedPayload.sig,
};
const headers = await getHeaders(this.activeDid);
try { try {
let partnerServer = DEFAULT_PARTNER_API_SERVER;
const settings = await retrieveSettingsForActiveAccount();
if (settings.partnerApiServer) {
partnerServer = settings.partnerApiServer;
}
const endorserPartnerUrl = partnerServer + "/api/partner/link";
const timeSafariUrl = window.location.origin + "/claim/" + jwtId;
const content = this.fullClaim.name + " - see " + timeSafariUrl;
const publicKeyHex = accountFromExtendedKey(publicExtendedKey).publicKey;
const unsignedPayload: UnsignedEvent = {
// why doesn't "...signedPayload" work?
kind: signedPayload.kind,
tags: signedPayload.tags,
content: signedPayload.content,
created_at: signedPayload.created_at,
pubkey: publicKeyHex,
};
// Why does IntelliJ not see matching types?
const payload = serializeEvent(unsignedPayload as UnsignedEvent);
const partnerParams = {
jwtId: jwtId,
linkCode: linkCode,
inputJson: JSON.stringify(content),
pubKeyHex: publicKeyHex,
pubKeyImage: payload,
pubKeySigHex: signedPayload.sig,
};
const headers = await getHeaders(this.activeDid);
const linkResp = await this.axios.post( const linkResp = await this.axios.post(
endorserPartnerUrl, endorserPartnerUrl,
partnerParams, partnerParams,
@ -689,5 +749,17 @@ export default class NewEditProjectView extends Vue {
public onCancelClick() { public onCancelClick() {
(this.$router as Router).back(); (this.$router as Router).back();
} }
public showNostrPartnerInfo() {
this.$notify(
{
group: "alert",
type: "info",
title: "About Nostr Events",
text: "This will submit this project to a partner on the nostr network. It will contain your public key data which may allow correlation, so don't allow this if you're not comfortable with that.",
},
7000,
);
}
} }
</script> </script>

116
src/views/ProjectViewView.vue

@ -6,27 +6,29 @@
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb --> <!-- Breadcrumb -->
<div id="ViewBreadcrumb"> <div id="ViewBreadcrumb">
<h1 class="text-lg text-center font-light relative px-7"> <div>
<!-- Back --> <h1 class="text-center text-lg font-light relative px-7">
<button <!-- Back -->
@click="$router.go(-1)" <button
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1" @click="$router.go(-1)"
> class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
<fa icon="chevron-left" class="fa-fw"></fa> >
</button> <fa icon="chevron-left" class="fa-fw"></fa>
Project Idea </button>
</h1> Project Idea
<h2 class="text-xl font-semibold"> </h1>
{{ name }} <h2 class="text-center text-xl font-semibold">
<button {{ name }}
v-if="activeDid === issuer || activeDid === agentDid" <button
@click="onEditClick()" v-if="activeDid === issuer || activeDid === agentDid"
title="Edit" @click="onEditClick()"
data-testId="editClaimButton" title="Edit"
> data-testId="editClaimButton"
<fa icon="pen" class="text-sm text-blue-500 ml-2 mb-1" /> >
</button> <fa icon="pen" class="text-sm text-blue-500 ml-2 mb-1" />
</h2> </button>
</h2>
</div>
</div> </div>
<!-- Project Details --> <!-- Project Details -->
@ -47,22 +49,22 @@
<div class="text-sm mb-3"> <div class="text-sm mb-3">
<div class="truncate"> <div class="truncate">
<fa icon="user" class="fa-fw text-slate-400"></fa> <fa icon="user" class="fa-fw text-slate-400"></fa>
{{ {{ issuerInfoObject?.displayName }}
serverUtil.didInfo(issuer, activeDid, allMyDids, allContacts)
}}
<span v-if="!serverUtil.isEmptyOrHiddenDid(issuer)"> <span v-if="!serverUtil.isEmptyOrHiddenDid(issuer)">
<button <a
@click=" :href="`/did/${issuer}`"
libsUtil.doCopyTwoSecRedo( target="_blank"
issuer, class="text-blue-500"
() => (showDidCopy = !showDidCopy),
)
"
class="ml-2 mr-2"
> >
<fa icon="copy" class="text-slate-400 fa-fw"></fa> <fa icon="arrow-up-right-from-square" class="fa-fw" />
</button> </a>
<span v-show="showDidCopy">Copied DID</span> </span>
<span v-else-if="serverUtil.isHiddenDid(issuer)">
<fa
icon="info-circle"
class="fa-fw text-blue-500 cursor-pointer"
@click="openHiddenDidDialog()"
/>
</span> </span>
</div> </div>
<div v-if="startTime"> <div v-if="startTime">
@ -74,14 +76,21 @@
<a <a
:href="getOpenStreetMapUrl()" :href="getOpenStreetMapUrl()"
target="_blank" target="_blank"
class="underline" class="underline text-blue-500"
>Map View >Map View
<fa icon="arrow-up-right-from-square" class="fa-fw" /> <fa
icon="arrow-up-right-from-square"
class="fa-fw text-blue-500"
/>
</a> </a>
</div> </div>
<div v-if="url"> <div v-if="url">
<fa icon="globe" class="fa-fw text-slate-400"></fa> <fa icon="globe" class="fa-fw text-slate-400"></fa>
<a :href="addScheme(url)" target="_blank" class="underline"> <a
:href="addScheme(url)"
target="_blank"
class="underline text-blue-500"
>
{{ domainForWebsite(this.url) }} {{ domainForWebsite(this.url) }}
<fa icon="arrow-up-right-from-square" class="fa-fw" /> <fa icon="arrow-up-right-from-square" class="fa-fw" />
</a> </a>
@ -475,6 +484,8 @@
</div> </div>
</div> </div>
</section> </section>
<HiddenDidDialog ref="hiddenDidDialog" />
</template> </template>
<script lang="ts"> <script lang="ts">
@ -506,11 +517,13 @@ import {
} from "../libs/endorserServer"; } from "../libs/endorserServer";
import * as serverUtil from "../libs/endorserServer"; import * as serverUtil from "../libs/endorserServer";
import { retrieveAccountDids } from "../libs/util"; import { retrieveAccountDids } from "../libs/util";
import HiddenDidDialog from "../components/HiddenDidDialog.vue";
@Component({ @Component({
components: { components: {
EntityIcon, EntityIcon,
GiftedDialog, GiftedDialog,
HiddenDidDialog,
OfferDialog, OfferDialog,
ProjectIcon, ProjectIcon,
QuickNav, QuickNav,
@ -522,6 +535,7 @@ export default class ProjectViewView extends Vue {
activeDid = ""; activeDid = "";
agentDid = ""; agentDid = "";
agentDidVisibleToDids: Array<string> = [];
allMyDids: Array<string> = []; allMyDids: Array<string> = [];
allContacts: Array<Contact> = []; allContacts: Array<Contact> = [];
apiServer = ""; apiServer = "";
@ -538,6 +552,12 @@ export default class ProjectViewView extends Vue {
imageUrl = ""; imageUrl = "";
isRegistered = false; isRegistered = false;
issuer = ""; issuer = "";
issuerInfoObject: {
known: boolean;
displayName: string;
profileImageUrl?: string;
} | null = null;
issuerVisibleToDids: Array<string> = [];
latitude = 0; latitude = 0;
longitude = 0; longitude = 0;
name = ""; name = "";
@ -545,7 +565,6 @@ export default class ProjectViewView extends Vue {
offersHitLimit = false; offersHitLimit = false;
projectId = ""; // handle ID projectId = ""; // handle ID
recentlyCheckedAndUnconfirmableJwts: string[] = []; recentlyCheckedAndUnconfirmableJwts: string[] = [];
showDidCopy = false;
startTime = ""; startTime = "";
truncatedDesc = ""; truncatedDesc = "";
truncateLength = 40; truncateLength = 40;
@ -623,8 +642,17 @@ export default class ProjectViewView extends Vue {
startDateTime.toLocaleTimeString(); startDateTime.toLocaleTimeString();
} }
this.agentDid = resp.data.claim?.agent?.identifier; this.agentDid = resp.data.claim?.agent?.identifier;
this.agentDidVisibleToDids =
resp.data.claim?.agent?.identifierVisibleToDids || [];
this.imageUrl = resp.data.claim?.image; this.imageUrl = resp.data.claim?.image;
this.issuer = resp.data.issuer; this.issuer = resp.data.issuer;
this.issuerInfoObject = serverUtil.didInfoObject(
this.issuer,
this.activeDid,
this.allMyDids,
this.allContacts,
);
this.issuerVisibleToDids = resp.data.issuerVisibleToDids || [];
this.name = resp.data.claim?.name || "(no name)"; this.name = resp.data.claim?.name || "(no name)";
this.description = resp.data.claim?.description || "(no description)"; this.description = resp.data.claim?.description || "(no description)";
this.truncatedDesc = this.description.slice(0, this.truncateLength); this.truncatedDesc = this.description.slice(0, this.truncateLength);
@ -1156,5 +1184,15 @@ export default class ProjectViewView extends Vue {
); );
} }
} }
openHiddenDidDialog() {
(this.$refs.hiddenDidDialog as HiddenDidDialog).open(
"creator",
this.issuerVisibleToDids,
this.allContacts,
this.activeDid,
this.allMyDids,
);
}
} }
</script> </script>

6
src/views/ProjectsView.vue

@ -361,14 +361,14 @@ export default class ProjectsView extends Vue {
if (resp.status === 200 && resp.data.data) { if (resp.status === 200 && resp.data.data) {
const plans: PlanData[] = resp.data.data; const plans: PlanData[] = resp.data.data;
for (const plan of plans) { for (const plan of plans) {
const { name, description, handleId, image, issuerDid, rowid } = plan; const { name, description, handleId, image, issuerDid, rowId } = plan;
this.projects.push({ this.projects.push({
name, name,
description, description,
image, image,
handleId, handleId,
issuerDid, issuerDid,
rowid, rowId,
}); });
} }
} else { } else {
@ -395,7 +395,7 @@ export default class ProjectsView extends Vue {
async loadMoreProjectData(payload: boolean) { async loadMoreProjectData(payload: boolean) {
if (this.projects.length > 0 && payload) { if (this.projects.length > 0 && payload) {
const latestProject = this.projects[this.projects.length - 1]; const latestProject = this.projects[this.projects.length - 1];
await this.loadProjects(`beforeId=${latestProject.rowid}`); await this.loadProjects(`beforeId=${latestProject.rowId}`);
} }
} }

184
src/views/UserProfileView.vue

@ -0,0 +1,184 @@
<template>
<QuickNav selected="Discover" />
<TopMessage />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb -->
<div id="ViewBreadcrumb" class="mb-8">
<h1 id="ViewHeading" class="text-lg text-center font-light relative px-7">
<!-- Back -->
<button
@click="$router.go(-1)"
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
>
<fa icon="chevron-left" class="fa-fw"></fa>
</button>
Individual Profile
</h1>
</div>
<!-- Loading Animation -->
<div
class="fixed left-6 mt-16 text-center text-4xl leading-none bg-slate-400 text-white w-14 py-2.5 rounded-full"
v-if="isLoading"
>
<fa icon="spinner" class="fa-spin-pulse"></fa>
</div>
<div v-else-if="profile">
<!-- Profile Info -->
<div class="mt-8">
<div class="text-sm">
<fa icon="user" class="fa-fw text-slate-400"></fa>
{{ didInfo(profile.issuerDid, activeDid, allMyDids, allContacts) }}
</div>
<p v-if="profile.description" class="mt-4 text-slate-600">
{{ profile.description }}
</p>
</div>
<!-- Map for first coordinates -->
<div v-if="profile?.locLat && profile?.locLon" class="mt-4">
<h2 class="text-lg font-semibold">Location</h2>
<div class="h-96 mt-2 w-full">
<l-map
ref="profileMap"
:center="[profile.locLat, profile.locLon]"
:zoom="12"
>
<l-tile-layer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
layer-type="base"
name="OpenStreetMap"
/>
<l-marker :lat-lng="[profile.locLat, profile.locLon]">
<l-popup>{{
didInfo(profile.issuerDid, activeDid, allMyDids, allContacts)
}}</l-popup>
</l-marker>
</l-map>
</div>
</div>
<!-- Map for second coordinates -->
<div v-if="profile?.locLat2 && profile?.locLon2" class="mt-4">
<h2 class="text-lg font-semibold">Second Location</h2>
<div class="h-96 mt-2 w-full">
<l-map
ref="profileMap"
:center="[profile.locLat2, profile.locLon2]"
:zoom="12"
>
<l-tile-layer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
layer-type="base"
name="OpenStreetMap"
/>
<l-marker :lat-lng="[profile.locLat2, profile.locLon2]">
<l-popup>{{
didInfo(profile.issuerDid, activeDid, allMyDids, allContacts)
}}</l-popup>
</l-marker>
</l-map>
</div>
</div>
</div>
<div v-else class="text-center mt-8">
<p class="text-lg text-slate-500">Profile not found.</p>
</div>
</section>
</template>
<script lang="ts">
import "leaflet/dist/leaflet.css";
import { Component, Vue } from "vue-facing-decorator";
import { LMap, LTileLayer, LMarker, LPopup } from "@vue-leaflet/vue-leaflet";
import { Router, RouteLocationNormalizedLoaded } from "vue-router";
import QuickNav from "@/components/QuickNav.vue";
import TopMessage from "@/components/TopMessage.vue";
import { DEFAULT_PARTNER_API_SERVER, NotificationIface } from "@/constants/app";
import { db } from "@/db/index";
import { Contact } from "@/db/tables/contacts";
import { didInfo, getHeaders } from "@/libs/endorserServer";
import { UserProfile } from "@/libs/partnerServer";
import { retrieveAccountDids } from "@/libs/util";
@Component({
components: {
LMap,
LMarker,
LPopup,
LTileLayer,
QuickNav,
TopMessage,
},
})
export default class UserProfileView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$router!: Router;
$route!: RouteLocationNormalizedLoaded;
activeDid = "";
allContacts: Array<Contact> = [];
allMyDids: Array<string> = [];
isLoading = true;
partnerApiServer = DEFAULT_PARTNER_API_SERVER;
profile: UserProfile | null = null;
// make this function available to the Vue template
didInfo = didInfo;
async mounted() {
const settings = await db.settings.toArray();
this.activeDid = settings[0]?.activeDid || "";
this.partnerApiServer =
settings[0]?.partnerApiServer || this.partnerApiServer;
this.allContacts = await db.contacts.toArray();
this.allMyDids = await retrieveAccountDids();
await this.loadProfile();
}
async loadProfile() {
const profileId: string = this.$route.params.id as string;
if (!profileId) {
this.isLoading = false;
return;
}
try {
const response = await fetch(
`${this.partnerApiServer}/api/partner/userProfile/${encodeURIComponent(profileId)}`,
{
method: "GET",
headers: await getHeaders(this.activeDid),
},
);
if (response.status === 200) {
const result = await response.json();
this.profile = result.data;
} else {
throw new Error("Failed to load profile");
}
} catch (error) {
console.error("Error loading profile:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem loading the profile.",
},
5000,
);
} finally {
this.isLoading = false;
}
}
}
</script>

2
test-playwright/10-check-usage-limits.spec.ts

@ -23,6 +23,6 @@ test('Check usage limits', async ({ page }) => {
await page.getByRole('button', { name: 'Set Your Name' }).click(); await page.getByRole('button', { name: 'Set Your Name' }).click();
const name = 'User ' + did.slice(11, 14); const name = 'User ' + did.slice(11, 14);
await page.getByPlaceholder('Name').fill(name); await page.getByPlaceholder('Name').fill(name);
await page.getByRole('button', { name: 'Save' }).click(); await page.getByRole('button', { name: 'Save', exact: true }).click();
}); });

2
test-playwright/25-create-project-x10.spec.ts

@ -2,6 +2,8 @@ import { test, expect } from '@playwright/test';
import { importUser, createUniqueStringsArray } from './testUtils'; import { importUser, createUniqueStringsArray } from './testUtils';
test('Create 10 new projects', async ({ page }) => { test('Create 10 new projects', async ({ page }) => {
test.setTimeout(40000); // Set timeout longer since it often fails at 30 seconds
const projectCount = 10; const projectCount = 10;
// Standard texts // Standard texts

Loading…
Cancel
Save