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. 1981
      package-lock.json
  5. 18
      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. 380
      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. 400
      src/views/DiscoverView.vue
  18. 2
      src/views/ImportDerivedAccountView.vue
  19. 138
      src/views/NewEditProjectView.vue
  20. 78
      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_DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch
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).
## [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
### Changed
- 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).
* 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:
```
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
```
... and log onto the server:
* Production
```
# This picks up values from .env.production
npm run build
```
* `pkgx +npm sh`
* 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.
* 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`.

1981
package-lock.json

File diff suppressed because it is too large

18
package.json

@ -1,18 +1,10 @@
{
"name": "TimeSafari",
"version": "0.3.51-beta",
"description": "A cross-platform app for managing time-based crowdfunding.",
"author": "Your Name <your.email@example.com>",
"main": "src/electron/main.js",
"version": "0.3.54-beta",
"scripts": {
"dev": "vite",
"serve": "vite preview",
"build": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build",
"build:capacitor": "vite build --mode capacitor",
"build:electron": "vite build --mode electron",
"electron:dev": "vite build --mode electron && electron .",
"electron:build": "electron-builder",
"capacitor:sync": "npx cap copy",
"lint": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src",
"lint-fix": "eslint --ext .js,.ts,.vue --ignore-path .gitignore --fix src",
"prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node sw_combine.js",
@ -101,11 +93,11 @@
"@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",
"@vue/eslint-config-typescript": "^11.0.3",
"autoprefixer": "^10.4.19",
"electron": "^33.2.1",
"electron-builder": "^25.1.8",
"eslint": "^8.0.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-vue": "^9.32.0",
@ -114,8 +106,8 @@
"prettier": "^3.2.5",
"tailwindcss": "^3.4.1",
"typescript": "~5.2.2",
"vite": "^6.0.7",
"vite-plugin-pwa": "^0.21.1"
"vite": "^5.2.0",
"vite-plugin-pwa": "^0.19.8"
},
"build": {
"appId": "com.example.app",

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">
Welcome to Time Safari
<br />
- Showcasing Gratitude & Magnifing Time
- Showcasing Gratitude & Magnifying Time
<div
class="text-lg text-center leading-none absolute right-0 -top-1"
@click="onClickClose(true)"

6
src/db/tables/settings.ts

@ -13,14 +13,14 @@ export type BoundingBox = {
*/
export type Settings = {
// 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
accountDid?: string; // not used in the MASTER_SETTINGS_KEY entry
// active Decentralized ID
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
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
hideRegisterPromptOnNewContact?: 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
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
*
* @deprecated
* We should use PlanSummaryRecord instead.
* (Maybe we should use PlanSummaryRecord instead, either by adding rowId or by iterating with jwtId.)
**/
export interface PlanData {
/**
* Name of the project
**/
name: string;
/**
* Description of the project
**/
@ -212,9 +208,14 @@ export interface PlanData {
*/
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 {
@ -459,7 +460,7 @@ export function didInfoForContact(
} else if (contact) {
return {
displayName: contact.name || "Contact With No Name",
known: !!contact,
known: true,
profileImageUrl: contact.profileImageUrl,
};
} 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"

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",
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
@ -292,9 +297,7 @@ const errorHandler = (
) => {
// Handle the error here
console.error("Caught in top level error handler:", error, to, from);
alert(
"Something is very wrong. We'd love if you contacted us and let us know how you got here. Thank you!",
);
alert("Something is very wrong. Try reloading or restarting the app.");
// You can also perform additional actions, such as displaying an error message or redirecting the user to a specific page
};

380
src/views/AccountViewView.vue

@ -82,13 +82,12 @@
<div v-else class="text-center">
<div class @click="openImageDialog()">
<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"
/>
<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"
@click="openImageDialog()"
/>
</div>
</div>
@ -159,7 +158,7 @@
We'll just pop the message in only if we discover that they need it.
-->
<div
v-if="!loadingLimits && !endorserLimits?.nextWeekBeginDateTime"
v-if="!isRegistered"
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"
>
@ -176,6 +175,7 @@
</div>
<div
v-if="isRegistered"
id="sectionNotifications"
class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"
>
@ -250,19 +250,111 @@
<div
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 -->
<div class="mb-2 font-bold">Location for Searches</div>
<span class="mb-2 font-bold">Location for Searches</span>
<router-link
: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
<!-- If already set, change button label to "Change Search Area" -->
{{ isSearchAreasSet ? "Change" : "Set" }} Search Area
</router-link>
</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
v-if="activeDid"
id="sectionUsageLimits"
@ -599,7 +691,7 @@
<h2 class="text-slate-500 text-sm font-bold mb-2">
Notification Push Server
</h2>
<div id="sectionNotificationPushServer" class="px-3 py-4">
<div class="px-3 py-4">
<input
type="text"
class="block w-full rounded border border-slate-400 px-3 py-2"
@ -676,7 +768,7 @@
{{ DEFAULT_PARTNER_API_SERVER }}
</span>
<div id="sectionImageServerURL" class="mt-2">
<div class="mt-2">
<span class="text-slate-500 text-sm font-bold">Image Server URL</span>
&nbsp;
<span class="text-sm">{{ DEFAULT_IMAGE_API_SERVER }}</span>
@ -791,17 +883,21 @@
</template>
<script lang="ts">
import "leaflet/dist/leaflet.css";
import { AxiosError } from "axios";
import { Buffer } from "buffer/";
import Dexie from "dexie";
import "dexie-export-import";
import { ImportProgress } from "dexie-export-import/dist/import";
import { LeafletMouseEvent } from "leaflet";
import * as R from "ramda";
import { IIdentifier } from "@veramo/core";
import { ref } from "vue";
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { useClipboard } from "@vueuse/core";
import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
import EntityIcon from "../components/EntityIcon.vue";
import ImageMethodDialog from "../components/ImageMethodDialog.vue";
@ -819,6 +915,7 @@ import {
} from "../constants/app";
import {
db,
logConsoleAndDb,
retrieveSettingsForActiveAccount,
updateAccountSettings,
} from "../db/index";
@ -830,8 +927,9 @@ import {
} from "../db/tables/settings";
import {
clearPasskeyToken,
ErrorResponse,
EndorserRateLimits,
ErrorResponse,
errorStringForLog,
fetchEndorserRateLimits,
fetchImageRateLimits,
getHeaders,
@ -850,6 +948,10 @@ const inputImportFileNameRef = ref<Blob>();
components: {
EntityIcon,
ImageMethodDialog,
LeafletMouseEvent,
LMap,
LMarker,
LTileLayer,
PushNotificationPermission,
QuickNav,
TopMessage,
@ -873,23 +975,26 @@ export default class AccountViewView extends Vue {
givenName = "";
hideRegisterPromptOnNewContact = false;
imageLimits: ImageRateLimits | null = null;
imageServer = "";
includeUserProfileLocation = false;
isRegistered = false;
isSearchAreasSet = false;
limitsMessage = "";
loadingLimits = false;
loadingProfile = true;
notifyingNewActivity = false;
notifyingNewActivityTime = "";
notifyingReminder = false;
notifyingReminderMessage = "";
notifyingReminderTime = "";
partnerApiServer = "";
partnerApiServerInput = "";
partnerApiServer = DEFAULT_PARTNER_API_SERVER;
partnerApiServerInput = DEFAULT_PARTNER_API_SERVER;
passkeyExpirationDescription = "";
passkeyExpirationMinutes = DEFAULT_PASSKEY_EXPIRATION_MINUTES;
previousPasskeyExpirationMinutes = DEFAULT_PASSKEY_EXPIRATION_MINUTES;
profileImageUrl?: string;
publicHex = "";
publicBase64 = "";
savingProfile = false;
showAdvanced = false;
showB64Copy = false;
showContactGives = false;
@ -903,8 +1008,12 @@ export default class AccountViewView extends Vue {
subscription: PushSubscription | null = null;
warnIfProdServer = false;
warnIfTestServer = false;
webPushServer = "";
webPushServerInput = "";
webPushServer = DEFAULT_PUSH_SERVER;
webPushServerInput = DEFAULT_PUSH_SERVER;
userProfileDesc = "";
userProfileLatitude = 0;
userProfileLongitude = 0;
zoom = 2;
/**
* 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
await this.initializeState();
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) {
// this can happen when running automated tests in dev mode because notifications don't work
console.error(
@ -992,14 +1144,15 @@ export default class AccountViewView extends Vue {
this.hideRegisterPromptOnNewContact =
!!settings.hideRegisterPromptOnNewContact;
this.isRegistered = !!settings?.isRegistered;
this.imageServer = settings.imageServer || "";
this.isSearchAreasSet = !!settings.searchBoxes;
this.notifyingNewActivity = !!settings.notifyingNewActivityTime;
this.notifyingNewActivityTime = settings.notifyingNewActivityTime || "";
this.notifyingReminder = !!settings.notifyingReminderTime;
this.notifyingReminderMessage = settings.notifyingReminderMessage || "";
this.notifyingReminderTime = settings.notifyingReminderTime || "";
this.partnerApiServer = settings.partnerApiServer || "";
this.partnerApiServerInput = settings.partnerApiServer || "";
this.partnerApiServer = settings.partnerApiServer || this.partnerApiServer;
this.partnerApiServerInput =
settings.partnerApiServer || this.partnerApiServerInput;
this.profileImageUrl = settings.profileImageUrl;
this.showContactGives = !!settings.showContactGivesInline;
this.passkeyExpirationMinutes =
@ -1009,8 +1162,8 @@ export default class AccountViewView extends Vue {
this.showShortcutBvc = !!settings.showShortcutBvc;
this.warnIfProdServer = !!settings.warnIfProdServer;
this.warnIfTestServer = !!settings.warnIfTestServer;
this.webPushServer = settings.webPushServer || "";
this.webPushServerInput = settings.webPushServer || "";
this.webPushServer = settings.webPushServer || this.webPushServer;
this.webPushServerInput = settings.webPushServer || this.webPushServerInput;
}
// 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) {
if (error instanceof AxiosError) {
if (error.status == 400 || error.status == 404) {
// no worries: they probably just aren't registered and don't have any limits
console.log(
"Got 400 or 404 response retrieving limits which probably means they're not registered:",
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, which usually means user isn't registered.",
error,
);
console.error("Got bad response retrieving limits:", error);
}
} else {
this.limitsMessage = "Got an error retrieving limits.";
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>

44
src/views/ClaimCertificateView.vue

@ -125,7 +125,7 @@ export default class ClaimCertificateView extends Vue {
);
if (claimData.claimType === "GiveAction" && claimData.claim.agent) {
const presentedText = "Thanks To ";
const presentedText = "Thanks To";
ctx.font = "14px Arial";
const presentedWidth = ctx.measureText(presentedText).width;
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 =
claimData.claim.name || claimData.claim.description;
claimData.claim.name ||
claimData.claim.description ||
claimData.claim.itemOffered?.description; // for Offers
if (descriptionText) {
const descriptionLine =
descriptionText.length > 50
@ -164,12 +192,12 @@ export default class ClaimCertificateView extends Vue {
);
}
if (
claimData.claim.object?.amountOfThisGood &&
claimData.claim.object?.unitCode
) {
const amount = claimData.claim.object.amountOfThisGood;
const unit = claimData.claim.object.unitCode;
const possibleObject =
claimData.claim.object || // for GiveActions
claimData.claim.includesObject; // for Offers
if (possibleObject?.amountOfThisGood && possibleObject?.unitCode) {
const amount = possibleObject.amountOfThisGood;
const unit = possibleObject.unitCode;
const amountText = serverUtil.displayAmount(unit, amount);
const amountWidth = ctx.measureText(amountText).width;
// 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
title="Copy Link"
@click="
copyToClipboard('Current page link', window.location.href)
copyToClipboard('A link to this page', window.location.href)
"
>
<fa icon="link" class="text-slate-500" />
@ -270,16 +270,13 @@
<div class="text-sm">
{{ didInfo(confirmerId) }}
<span v-if="!serverUtil.isEmptyOrHiddenDid(confirmerId)">
<button
@click="
copyToClipboard(
'The DID of ' + confirmerId,
confirmerId,
)
"
<a
:href="`/did/${confirmerId}`"
target="_blank"
class="text-blue-500"
>
<fa icon="copy" class="text-slate-400 fa-fw" />
</button>
<fa icon="arrow-up-right-from-square" class="fa-fw" />
</a>
</span>
</div>
</div>
@ -311,16 +308,13 @@
<div class="text-sm">
{{ didInfo(confsVisibleTo) }}
<span v-if="!serverUtil.isEmptyOrHiddenDid(confsVisibleTo)">
<button
@click="
copyToClipboard(
'The DID of ' + confsVisibleTo,
confsVisibleTo,
)
"
<a
:href="`/did/${confsVisibleTo}`"
target="_blank"
class="text-blue-500"
>
<fa icon="copy" class="text-slate-400 fa-fw" />
</button>
<fa icon="arrow-up-right-from-square" class="fa-fw" />
</a>
</span>
</div>
</div>
@ -344,7 +338,7 @@
</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
class="font-bold uppercase text-xl text-blue-500 mt-8 cursor-pointer"
@click="showVeriClaimDump = !showVeriClaimDump"
@ -364,24 +358,26 @@
Some of the details are not visible to you; they show as "HIDDEN". They
are not visible to any of your direct contacts, either.
<span v-if="canShare">
If you'd like to ask any of your contacts to take a look and see if
their contacts can see more details,
You can ask one of your contacts to take a look and see if their
contacts can see more details:
<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
connected to someone; if you don't know who to ask, you might try the
person who registered you.
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 v-else>
If you'd like to ask any of your contacts to take a look and see if
their contacts can see more details,
You can ask one of your contacts to take a look and see if their
contacts can see more details:
<a
@click="copyToClipboard('A link to this page', windowLocation)"
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>
</div>
@ -425,18 +421,21 @@
<span>
{{ didInfo(visDid) }}
<span v-if="!serverUtil.isEmptyOrHiddenDid(visDid)">
<button
@click="copyToClipboard('The DID of ' + visDid, visDid)"
<a
:href="`/did/${visDid}`"
target="_blank"
class="text-blue-500"
>
<fa icon="copy" class="text-slate-400 fa-fw" />
</button>
<fa icon="arrow-up-right-from-square" class="fa-fw" />
</a>
</span>
<span v-if="veriClaim.publicUrls?.[visDid]"
>, found at&nbsp;<a
:href="veriClaim.publicUrls?.[visDid]"
target="_blank"
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].indexOf("//") + 2,
@ -452,7 +451,7 @@
</div>
</div>
<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>
<br />
<!-- Keep the dump contents directly between > and < to avoid weird spacing. -->
@ -963,9 +962,10 @@ export default class ClaimView extends Vue {
}
onClickShareClaim() {
this.copyToClipboard("A link to this page", this.windowLocation);
window.navigator.share({
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,
});
}

35
src/views/ConfirmGiftView.vue

@ -254,7 +254,7 @@
</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
class="font-bold uppercase text-xl text-blue-500 mt-8 cursor-pointer"
@click="showVeriClaimDump = !showVeriClaimDump"
@ -274,24 +274,26 @@
Some of the details are not visible to you; they show as "HIDDEN".
They are not visible to any of your direct contacts, either.
<span v-if="canShare">
If you'd like to ask any of your contacts to take a look and see if
their contacts can see more details,
You can ask one of your contacts to take a look and see if their
contacts can see more details:
<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
connected to someone; if you don't know who to ask, you might try
the person who registered you.
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 v-else>
If you'd like to ask any of your contacts to take a look and see if
their contacts can see more details,
You can ask one of your contacts to take a look and see if their
contacts can see more details:
<a
@click="copyToClipboard('Location', windowLocation.href)"
@click="copyToClipboard('A link to this page', windowLocation)"
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>
</div>
@ -308,9 +310,7 @@
<span v-else>
If you'd like an introduction,
<a
@click="
copyToClipboard('A link to this page', windowLocation.href)
"
@click="copyToClipboard('A link to this page', windowLocation)"
class="text-blue-500"
>share this page with them and ask if they'll tell you more about
about the participants.</a
@ -448,7 +448,7 @@ export default class ClaimView extends Vue {
veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
veriClaimDump = "";
veriClaimDidsVisible = {};
windowLocation = window.location;
windowLocation = window.location.href;
R = R;
yaml = yaml;
@ -856,10 +856,11 @@ export default class ClaimView extends Vue {
}
onClickShareClaim() {
this.copyToClipboard("A link to this page", this.windowLocation);
window.navigator.share({
title: "Help Connect 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">
<!-- Breadcrumb -->
<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 -->
<button
@click="$router.go(-1)"

400
src/views/DiscoverView.vue

@ -6,7 +6,7 @@
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light">
Discover Projects
Discover Projects & People
</h1>
<OnboardingDialog ref="onboardingDialog" />
@ -15,7 +15,6 @@
<div
id="QuickSearch"
class="mt-8 mb-4 flex"
v-on:keyup.enter="searchSelected()"
:style="{ visibility: isSearchVisible ? 'visible' : 'hidden' }"
>
<input
@ -23,16 +22,54 @@
v-model="searchTerms"
placeholder="Search…"
class="block w-full rounded-l border border-r-0 border-slate-400 px-3 py-2"
v-on:keyup.enter="searchSelected()"
/>
<button
@click="searchSelected()"
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>
</button>
</div>
<!-- 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">
<ul class="flex flex-wrap justify-center gap-4 -mb-px">
<li>
@ -40,9 +77,10 @@
href="#"
@click="
projects = [];
userProfiles = [];
isLocalActive = true;
isMappedActive = false;
isRemoteActive = false;
isAnywhereActive = false;
isSearchVisible = true;
tempSearchBox = null;
searchLocal();
@ -65,15 +103,17 @@
href="#"
@click="
projects = [];
userProfiles = [];
isLocalActive = false;
isMappedActive = true;
isRemoteActive = false;
isAnywhereActive = false;
isSearchVisible = false;
searchTerms = '';
tempSearchBox = null;
"
v-bind:class="computedMappedTabStyleClassNames()"
>
<!-- search is triggered when map component gets to "ready" state -->
Mapped
</a>
</li>
@ -82,9 +122,10 @@
href="#"
@click="
projects = [];
userProfiles = [];
isLocalActive = false;
isMappedActive = false;
isRemoteActive = true;
isAnywhereActive = true;
isSearchVisible = true;
tempSearchBox = null;
searchAll();
@ -95,7 +136,7 @@
<!-- restore when the links don't jump around for different numbers
<span
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 : "?" }}
</span>
@ -143,13 +184,16 @@
>
<fa icon="spinner" class="fa-spin-pulse"></fa>
</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">
<span v-if="isLocalActive">
<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. -->
</span>
<span v-else-if="isRemoteActive"
<span v-else-if="isAnywhereActive"
>No projects were found with that search.</span
>
</p>
@ -158,13 +202,15 @@
<!-- Results List -->
<InfiniteScroll @reached-bottom="loadMoreData">
<ul id="listDiscoverResults">
<!-- Projects List -->
<template v-if="isProjectsActive">
<li
class="border-b border-slate-300"
v-for="project in projects"
:key="project.handleId"
>
<a
@click="onClickLoadProject(project.handleId)"
@click="onClickLoadItem(project.handleId)"
class="block py-4 flex gap-4 cursor-pointer"
>
<div>
@ -181,12 +227,64 @@
<div class="text-sm">
<fa icon="user" class="fa-fw text-slate-400"></fa>
{{
didInfo(project.issuerDid, activeDid, allMyDids, allContacts)
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>
</InfiniteScroll>
</section>
@ -197,7 +295,7 @@ import "leaflet/dist/leaflet.css";
import * as L from "leaflet";
import { Component, Vue } from "vue-facing-decorator";
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 InfiniteScroll from "../components/InfiniteScroll.vue";
@ -220,6 +318,16 @@ import {
} from "../libs/endorserServer";
import { OnboardPage, retrieveAccountDids } from "../libs/util";
interface Tile {
indexLat: number;
indexLon: number;
minFoundLat: number;
maxFoundLat: number;
minFoundLon: number;
maxFoundLon: number;
recordCount: number;
}
@Component({
components: {
InfiniteScroll,
@ -233,25 +341,31 @@ import { OnboardPage, retrieveAccountDids } from "../libs/util";
})
export default class DiscoverView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$router!: Router;
$route!: RouteLocationNormalizedLoaded;
activeDid = "";
allContacts: Array<Contact> = [];
allMyDids: Array<string> = [];
apiServer = "";
searchTerms = "";
projects: PlanData[] = [];
isLoading = false;
isLocalActive = true;
isMappedActive = false;
isRemoteActive = false;
isAnywhereActive = false;
isProjectsActive = true;
isPeopleActive = false;
isSearchVisible = true;
localCenterLat = 0;
localCenterLong = 0;
localCount = -1;
markers: { [key: string]: L.Marker } = {};
partnerApiServer = DEFAULT_PARTNER_API_SERVER;
projects: PlanData[] = [];
remoteCount = -1;
searchBox: { name: string; bbox: BoundingBox } | null = null;
searchTerms = "";
tempSearchBox: BoundingBox | null = null;
userProfiles: UserProfile[] = [];
zoomedSoDoNotMove = false;
// make this function available to the Vue template
@ -261,13 +375,15 @@ export default class DiscoverView extends Vue {
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = (settings.activeDid as string) || "";
this.apiServer = (settings.apiServer as string) || "";
this.partnerApiServer =
(settings.partnerApiServer as string) || this.partnerApiServer;
this.searchBox = settings.searchBoxes?.[0] || null;
this.allContacts = await db.contacts.toArray();
this.allMyDids = await retrieveAccountDids();
this.searchTerms = (this.$route as Router).query["searchText"] || "";
this.searchTerms = this.$route.query["searchText"]?.toString() || "";
if (!settings.finishedOnboarding) {
(this.$refs.onboardingDialog as OnboardingDialog).open(
@ -284,7 +400,7 @@ export default class DiscoverView extends Vue {
} else {
this.isLocalActive = false;
this.isMappedActive = false;
this.isRemoteActive = true;
this.isAnywhereActive = true;
await this.searchAll();
}
}
@ -298,8 +414,8 @@ export default class DiscoverView extends Vue {
if (this.isLocalActive) {
await this.searchLocal();
} else if (this.isMappedActive) {
this.isRemoteActive = true;
await this.searchAll();
const mapRef = this.$refs.projectMap as L.Map;
this.requestTiles(mapRef.leafletObject); // not ideal because I found this from experimentation, not documentation
} else {
await this.searchAll();
}
@ -311,6 +427,7 @@ export default class DiscoverView extends Vue {
if (!beforeId) {
// this was an initial search so clear any previous results
this.projects = [];
this.userProfiles = [];
}
let queryParams = "claimContents=" + encodeURIComponent(this.searchTerms);
@ -319,62 +436,58 @@ export default class DiscoverView extends Vue {
queryParams = queryParams + `&beforeId=${beforeId}`;
}
const endpoint = this.isProjectsActive
? this.apiServer + "/api/v2/report/plans"
: this.partnerApiServer + "/api/partner/userProfile";
try {
this.isLoading = true;
const response = await fetch(
this.apiServer + "/api/v2/report/plans?" + queryParams,
{
const response = await fetch(endpoint + "?" + queryParams, {
method: "GET",
headers: await getHeaders(this.activeDid),
},
);
});
if (response.status !== 200) {
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;
}
const results = await response.json();
if (this.isProjectsActive) {
this.userProfiles = [];
const plans: PlanData[] = results.data;
if (plans) {
for (const plan of plans) {
const { name, description, handleId, image, issuerDid, rowid } = plan;
this.projects.push({
name,
description,
handleId,
image,
issuerDid,
rowid,
});
}
this.projects.push(...plans);
this.remoteCount = this.projects.length;
} else {
throw JSON.stringify(results);
}
} else {
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
} catch (e: any) {
console.error("Error with feed load:", e);
console.error("Error with search all:", e);
// 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(
{
group: "alert",
type: "danger",
title: "Error",
text: e.userMessage || "There was a problem retrieving projects.",
title: "Error Searching",
text:
e.userMessage ||
"There was a problem retrieving " +
(this.isProjectsActive ? "projects" : "profiles") +
".",
},
5000,
);
@ -392,12 +505,14 @@ export default class DiscoverView extends Vue {
if (!searchBox) {
this.projects = [];
this.userProfiles = [];
return;
}
if (!beforeId) {
// this was an initial search so clear any previous results
this.projects = [];
this.userProfiles = [];
}
const claimContents =
@ -407,70 +522,64 @@ export default class DiscoverView extends Vue {
claimContents,
"minLocLat=" + searchBox.minLat,
"maxLocLat=" + searchBox.maxLat,
"westLocLon=" + searchBox.westLong,
"eastLocLon=" + searchBox.eastLong,
"minLocLon=" + searchBox.westLong,
"maxLocLon=" + searchBox.eastLong,
].join("&");
if (beforeId) {
queryParams = queryParams + `&beforeId=${beforeId}`;
}
const endpoint = this.isProjectsActive
? this.apiServer + "/api/v2/report/plansByLocation"
: this.partnerApiServer + "/api/partner/userProfile";
try {
this.isLoading = true;
const response = await fetch(
this.apiServer + "/api/v2/report/plansByLocation?" + queryParams,
{
const response = await fetch(endpoint + "?" + queryParams, {
method: "GET",
headers: await getHeaders(this.activeDid),
},
);
});
if (response.status !== 200) {
const details = await response.text();
console.error("Problem with nearby search:", details);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem accessing the server.",
},
3000,
);
throw await response.text();
throw details;
}
const results = await response.json();
if (results.data) {
if (beforeId) {
if (this.isProjectsActive) {
this.userProfiles = [];
const plans: PlanData[] = results.data;
for (const plan of plans) {
const { name, description, handleId, issuerDid, rowid } = plan;
this.projects.push({
name,
description,
handleId,
issuerDid,
rowid,
});
}
if (plans) {
this.projects.push(...plans);
this.localCount = this.projects.length;
} else {
this.projects = results.data;
throw JSON.stringify(results);
}
this.localCount = this.projects.length;
} else {
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
} catch (e: any) {
console.error("Error with feed load:", e);
console.error("Error with search local:", e);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: e.userMessage || "There was a problem retrieving projects.",
text:
e.userMessage ||
"There was a problem retrieving " +
(this.isProjectsActive ? "projects" : "profiles") +
".",
},
5000,
);
@ -484,26 +593,38 @@ export default class DiscoverView extends Vue {
* @param payload is the flag from the InfiniteScroll indicating if it should load
**/
async loadMoreData(payload: boolean) {
if (this.projects.length > 0 && payload) {
if (payload) {
if (this.isProjectsActive && this.projects.length > 0) {
const latestProject = this.projects[this.projects.length - 1];
if (this.isLocalActive) {
this.searchLocal(latestProject["rowid"]);
} else if (this.isMappedActive) {
this.searchLocal(latestProject["rowid"]);
} else if (this.isRemoteActive) {
this.searchAll(latestProject["rowid"]);
if (this.isLocalActive || this.isMappedActive) {
this.searchLocal(latestProject.rowId);
} else if (this.isAnywhereActive) {
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) {
// 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);
this.requestTiles(map);
}
// 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 */) {
// 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 */) {
// remove markers because otherwise they jump around at zoom end
Object.values(this.markers).forEach((marker) => marker.remove());
this.markers = {};
this.clearMarkers();
this.zoomedSoDoNotMove = true;
}
@ -540,15 +660,15 @@ export default class DiscoverView extends Vue {
"westLocLon=" + bounds?.getSouthWest().lng,
"eastLocLon=" + bounds?.getNorthEast().lng,
].join("&");
const response = await fetch(
this.apiServer + "/api/v2/report/planCountsByBBox?" + queryParams,
);
const endpoint = this.isProjectsActive
? this.apiServer + "/api/v2/report/planCountsByBBox"
: this.partnerApiServer + "/api/partner/userProfileCountsByBBox";
const response = await fetch(endpoint + "?" + queryParams);
if (response.status === 200) {
Object.values(this.markers).forEach((marker) => marker.remove());
this.markers = {};
this.clearMarkers();
const results = await response.json();
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 pinLon = (tile.minFoundLon + tile.maxFoundLon) / 2;
const numberIcon = L.divIcon({
@ -569,10 +689,22 @@ export default class DiscoverView extends Vue {
};
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 {
throw {
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
* @param id of the project
* Handle clicking on a project or profile entry found in the list
* @param id of the project or profile
**/
onClickLoadProject(id: string) {
onClickLoadItem(id: string) {
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() {
@ -654,14 +788,50 @@ export default class DiscoverView extends Vue {
"rounded-t-lg": true,
"border-b-2": true,
active: this.isRemoteActive,
"text-black": this.isRemoteActive,
"border-black": this.isRemoteActive,
"font-semibold": this.isRemoteActive,
active: this.isAnywhereActive,
"text-black": this.isAnywhereActive,
"border-black": this.isAnywhereActive,
"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,
"border-transparent": !this.isRemoteActive,
"hover:border-slate-400": !this.isRemoteActive,
"text-blue-600": !this.isPeopleActive,
"border-transparent": !this.isPeopleActive,
"hover:border-slate-400": !this.isPeopleActive,
};
}
}

2
src/views/ImportDerivedAccountView.vue

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

138
src/views/NewEditProjectView.vue

@ -77,7 +77,9 @@
maxlength="5000"
></textarea>
<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 class="text-xs text-slate-500 italic -mt-3 mb-4">
{{ fullClaim.description?.length }}/5000 max. characters
@ -152,11 +154,17 @@
<div class="flex" @click="sendToTrustroots = !sendToTrustroots">
<input type="checkbox" class="mr-2" v-model="sendToTrustroots" />
<label>Send to Trustroots</label>
<fa
icon="circle-info"
class="text-blue-500 ml-2 cursor-pointer"
@click.stop="showNostrPartnerInfo"
/>
</div>
<!--
<div class="flex" @click="sendToTripHopping = !sendToTripHopping">
<input type="checkbox" class="mr-2" v-model="sendToTripHopping" />
<label>Send to TripHopping</label>
<fa icon="circle-info" class="text-blue-500 ml-2 cursor-pointer" @click.stop="showNostrPartnerInfo" />
</div>
-->
</div>
@ -195,8 +203,12 @@ import "leaflet/dist/leaflet.css";
import { AxiosError, AxiosRequestHeaders } from "axios";
import { DateTime } from "luxon";
import { hexToBytes } from "@noble/hashes/utils";
import type { EventTemplate, VerifiedEvent } from "nostr-tools/lib/types/core";
import { accountFromSeedWords } from "nostr-tools/nip06";
// these core imports could also be included as "import type ..."
import { EventTemplate, UnsignedEvent, VerifiedEvent } from "nostr-tools/core";
import {
accountFromExtendedKey,
extendedKeysFromSeedWords,
} from "nostr-tools/nip06";
import { finalizeEvent, serializeEvent } from "nostr-tools/pure";
import { Component, Vue } from "vue-facing-decorator";
import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
@ -217,7 +229,6 @@ import {
} from "../libs/endorserServer";
import {
retrieveAccountCount,
retrieveAccountMetadata,
retrieveFullyDecryptedAccount,
} from "../libs/util";
@ -408,6 +419,18 @@ export default class NewEditProjectView extends Vue {
delete vcClaim.image;
}
if (this.includeLocation) {
if (!this.latitude || !this.longitude) {
this.$notify(
{
group: "alert",
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",
@ -415,6 +438,7 @@ export default class NewEditProjectView extends Vue {
longitude: this.longitude,
},
};
}
} else {
delete vcClaim.location;
}
@ -431,7 +455,7 @@ export default class NewEditProjectView extends Vue {
{
group: "alert",
type: "danger",
title: "Error",
title: "Date Error",
text: "The date was invalid so it was not set.",
},
5000,
@ -451,31 +475,59 @@ export default class NewEditProjectView extends Vue {
try {
const resp = await this.axios.post(url, payload, { headers });
if (resp.data?.success?.handleId) {
this.$notify(
{
group: "alert",
type: "success",
title: "Saved",
text: "The project was saved successfully.",
},
3000,
);
this.errorMessage = "";
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.latitude && this.longitude) {
let payloadAndKey; // sign something to prove ownership of pubkey
if (this.sendToTrustroots) {
signedPayload = await this.signPayload();
payloadAndKey = await this.signSomePayload();
// not going to await... the save was successful, so we'll continue to the next page
this.sendToNostrPartner(
"NOSTR-EVENT-TRUSTROOTS",
"Trustroots",
resp.data.success.claimId,
signedPayload,
payloadAndKey.signedEvent,
payloadAndKey.publicExtendedKey,
);
}
if (this.sendToTripHopping) {
if (!signedPayload) {
signedPayload = await this.signPayload();
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,
signedPayload,
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.$router as Router).push({ path: "/project/" + projectPath });
} else {
@ -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);
// 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(
const extPubPri = extendedKeysFromSeedWords(
account?.mnemonic as string,
"",
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,
// so we might as well use nostr libs for nostr functions.
// Besides: someday we may create real content that we can relay.
@ -563,9 +624,12 @@ export default class NewEditProjectView extends Vue {
content: "",
created_at: 0,
};
const signedEvent: VerifiedEvent = finalizeEvent(
// Why does IntelliJ not see matching types?
const signedEvent = finalizeEvent(event, privateBytes);
return signedEvent;
event as EventTemplate,
privateBytes,
) as VerifiedEvent;
return { signedEvent, publicExtendedKey };
}
private async sendToNostrPartner(
@ -573,21 +637,9 @@ export default class NewEditProjectView extends Vue {
serviceName: string,
jwtId: string,
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;
try {
let partnerServer = DEFAULT_PARTNER_API_SERVER;
const settings = await retrieveSettingsForActiveAccount();
if (settings.partnerApiServer) {
@ -596,18 +648,26 @@ export default class NewEditProjectView extends Vue {
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(signedPayload);
const payload = serializeEvent(unsignedPayload as UnsignedEvent);
const partnerParams = {
jwtId: jwtId,
linkCode: linkCode,
inputJson: JSON.stringify(content),
pubKeyHex: nostrPubKey,
pubKeyHex: publicKeyHex,
pubKeyImage: payload,
pubKeySigHex: signedPayload.sig,
};
const headers = await getHeaders(this.activeDid);
try {
const linkResp = await this.axios.post(
endorserPartnerUrl,
partnerParams,
@ -689,5 +749,17 @@ export default class NewEditProjectView extends Vue {
public onCancelClick() {
(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>

78
src/views/ProjectViewView.vue

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

6
src/views/ProjectsView.vue

@ -361,14 +361,14 @@ export default class ProjectsView extends Vue {
if (resp.status === 200 && resp.data.data) {
const plans: PlanData[] = resp.data.data;
for (const plan of plans) {
const { name, description, handleId, image, issuerDid, rowid } = plan;
const { name, description, handleId, image, issuerDid, rowId } = plan;
this.projects.push({
name,
description,
image,
handleId,
issuerDid,
rowid,
rowId,
});
}
} else {
@ -395,7 +395,7 @@ export default class ProjectsView extends Vue {
async loadMoreProjectData(payload: boolean) {
if (this.projects.length > 0 && payload) {
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();
const name = 'User ' + did.slice(11, 14);
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';
test('Create 10 new projects', async ({ page }) => {
test.setTimeout(40000); // Set timeout longer since it often fails at 30 seconds
const projectCount = 10;
// Standard texts

Loading…
Cancel
Save