Browse Source

Merge branch 'master' into split_build_process

fix: image server references and test configurations

- Update image server references to use test server by default for local dev
- Fix registration status checks in tests
- Remove verbose console logging
- Update environment configurations for consistent image server usage
- Fix alert handling in contact registration tests
- Clean up component lifecycle logging
- Add clarifying comments about shared image server usage
- Update playwright test configurations for better reliability

This commit ensures consistent image server behavior across environments
and improves test reliability by properly handling registration status
checks and alerts.
Matthew Raymer 5 days ago
parent
commit
caabaa3257
  1. 38
      CHANGELOG.md
  2. 59
      README.md
  3. 2
      android/build.gradle
  4. 6
      package-lock.json
  5. 6
      package.json
  6. 2
      src/components/ChoiceButtonDialog.vue
  7. 94
      src/components/ImageViewer.vue
  8. 139
      src/components/MembersList.vue
  9. 19
      src/components/UserNameDialog.vue
  10. 6
      src/test/index.ts
  11. 6
      src/views/ClaimReportCertificateView.vue
  12. 1
      src/views/ClaimView.vue
  13. 4
      src/views/ContactsView.vue
  14. 48
      src/views/HomeView.vue
  15. 101
      src/views/NewEditProjectView.vue
  16. 3
      src/views/OnboardMeetingListView.vue
  17. 158
      src/views/OnboardMeetingMembersView.vue
  18. 13
      src/views/OnboardMeetingSetupView.vue
  19. 15
      src/views/ProjectViewView.vue

38
CHANGELOG.md

@ -6,6 +6,44 @@ 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.4.2] - 2025.02.17
### Merged
- Master to split_process_build
- fixed path issues
- all tests passing
- capacitor build to Android working
## [0.4.1] - 2025.02.16
### Fixed
- nostr build issue
- Linting
## [0.4.0] - 2025.02.14
### Changed
- Images in the home feed now take up the full width of the card.
- Clicking the image previously, would open the image in a new tab. Now, clicking the image opens the image in a lightbox view.
### Added
- Clicking an image also now displays an in-app lightbox view of the image.
- The lightbox view includes a download button for the image in mobile view.
## [0.3.57] - 2025.02.11
### Added
- Automatic user creation in onboarding meetings
## [0.3.55] - 2025.02.07
### Added
- End time for projects
## [0.3.54] - 2025.02.06
### Added
- Group onboarding meetings
## [0.3.53] - 2025.01.30 ## [0.3.53] - 2025.01.30
### Added ### Added
- Hints for contacting the creator of a project - Hints for contacting the creator of a project

59
README.md

@ -19,7 +19,64 @@ npm install
npm run dev npm run dev
``` ```
See [BUILDING.md](BUILDING.md) for detailed build and setup instructions. See the test locations for "IMAGE_API_SERVER" or "PARTNER_API_SERVER" below, or use http://localhost:3000 for local endorser.ch
### Build the test & production app
```
npm run serve
```
### Lint and fix files
```
npm run lint
```
### Run all UI tests
Look below for the "test-all" instructions.
### Compile and minify for test & production
* If there are DB changes: before updating the test server, open browser(s) with current version to test DB migrations.
* `npx prettier --write ./sw_scripts/`
* Update the ClickUp tasks & CHANGELOG.md & the version in package.json, run `npm install`.
* Commit everything (since the commit hash is used the app).
* Put the commit hash in the changelog (which will help you remember to bump the version later).
* Tag with the new version, [online](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/releases) or `git tag 0.3.55 && git push origin 0.3.55`.
* For test, build the app (because test server is not yet set up to 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 transfer to the test server: `rsync -azvu -e "ssh -i ~/.ssh/..." dist ubuntutest@test.timesafari.app:time-safari`
(Let's replace that 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.)
* For prod, get on the server and run the correct build:
... and log onto the server:
* `pkgx +npm sh`
* `cd crowd-funder-for-time-pwa && git checkout master && git pull && git checkout 0.3.55 && npm install && npm run build && cd -`
(The plain `npm run build` uses the .env.production file.)
* Back up the time-safari/dist folder & deploy: `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.

2
android/build.gradle

@ -7,7 +7,7 @@ buildscript {
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:8.8.0' classpath 'com.android.tools.build:gradle:8.8.1'
classpath 'com.google.gms:google-services:4.4.0' classpath 'com.google.gms:google-services:4.4.0'
// NOTE: Do not place your application dependencies here; they belong // NOTE: Do not place your application dependencies here; they belong

6
package-lock.json

@ -1,12 +1,12 @@
{ {
"name": "TimeSafari", "name": "TimeSafari",
"version": "0.3.54-beta", "version": "0.4.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "TimeSafari", "name": "TimeSafari",
"version": "0.3.54-beta", "version": "0.4.1",
"dependencies": { "dependencies": {
"@capacitor/android": "^6.2.0", "@capacitor/android": "^6.2.0",
"@capacitor/cli": "^6.2.0", "@capacitor/cli": "^6.2.0",
@ -54,7 +54,7 @@
"lru-cache": "^10.2.0", "lru-cache": "^10.2.0",
"luxon": "^3.4.4", "luxon": "^3.4.4",
"merkletreejs": "^0.3.11", "merkletreejs": "^0.3.11",
"nostr-tools": "^2.7.2", "nostr-tools": "^2.10.4",
"notiwind": "^2.0.2", "notiwind": "^2.0.2",
"papaparse": "^5.4.1", "papaparse": "^5.4.1",
"pina": "^0.20.2204228", "pina": "^0.20.2204228",

6
package.json

@ -1,6 +1,6 @@
{ {
"name": "TimeSafari", "name": "timesafari",
"version": "0.3.54-beta", "version": "0.4.2",
"description": "TimeSafari Desktop Application", "description": "TimeSafari Desktop Application",
"author": { "author": {
"name": "TimeSafari Team" "name": "TimeSafari Team"
@ -77,7 +77,7 @@
"lru-cache": "^10.2.0", "lru-cache": "^10.2.0",
"luxon": "^3.4.4", "luxon": "^3.4.4",
"merkletreejs": "^0.3.11", "merkletreejs": "^0.3.11",
"nostr-tools": "^2.7.2", "nostr-tools": "^2.10.4",
"notiwind": "^2.0.2", "notiwind": "^2.0.2",
"papaparse": "^5.4.1", "papaparse": "^5.4.1",
"pina": "^0.20.2204228", "pina": "^0.20.2204228",

2
src/components/ChoiceButtonDialog.vue

@ -66,7 +66,7 @@
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { NotificationIface } from "@/constants/app"; import { NotificationIface } from "../constants/app";
@Component @Component
export default class PromptDialog extends Vue { export default class PromptDialog extends Vue {

94
src/components/ImageViewer.vue

@ -0,0 +1,94 @@
<template>
<Teleport to="body">
<Transition name="fade">
<div v-if="isOpen" class="fixed inset-0 z-50 flex flex-col bg-black/90">
<!-- Header bar - fixed height to prevent overlap -->
<div class="h-16 flex justify-between items-center px-4 bg-black">
<button
class="text-white text-2xl p-2 rounded-full hover:bg-white/10"
@click="close"
>
<fa icon="xmark" />
</button>
<!-- Mobile share button -->
<button
v-if="isMobile"
class="text-white text-xl p-2 rounded-full hover:bg-white/10"
@click="handleShare"
>
<fa icon="ellipsis" />
</button>
</div>
<!-- Image container - fill remaining space -->
<div class="flex-1 flex items-center justify-center p-2">
<div class="w-full h-full flex items-center justify-center">
<img
:src="imageUrl"
class="max-h-[calc(100vh-5rem)] w-full h-full object-contain"
@click.stop
alt="expanded shared content"
/>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script lang="ts">
import { Component, Vue, Prop } from "vue-facing-decorator";
import { UAParser } from "ua-parser-js";
@Component({ emits: ["update:isOpen"] })
export default class ImageViewer extends Vue {
@Prop() imageUrl!: string;
@Prop() imageData!: Blob | null;
@Prop() isOpen!: boolean;
userAgent = new UAParser();
get isMobile() {
const os = this.userAgent.getOS().name;
return os === "iOS" || os === "Android";
}
close() {
this.$emit("update:isOpen", false);
}
async handleShare() {
const os = this.userAgent.getOS().name;
try {
if (os === "iOS" || os === "Android") {
if (navigator.share) {
// Always share the URL since it's more reliable across platforms
await navigator.share({
url: this.imageUrl,
});
} else {
// Fallback for browsers without share API
window.open(this.imageUrl, "_blank");
}
}
} catch (error) {
console.warn("Share failed, opening in new tab:", error);
window.open(this.imageUrl, "_blank");
}
}
}
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

139
src/components/MembersList.vue

@ -1,40 +1,41 @@
<template> <template>
<div class="space-y-4"> <div class="space-y-4">
<!-- Loading State --> <!-- Loading State -->
<div v-if="isLoading" class="flex justify-center items-center py-8"> <div
v-if="isLoading"
class="mt-16 text-center text-4xl bg-slate-400 text-white w-14 py-2.5 rounded-full mx-auto"
>
<fa icon="spinner" class="fa-spin-pulse" /> <fa icon="spinner" class="fa-spin-pulse" />
</div> </div>
<!-- Members List --> <!-- Members List -->
<p <div v-else>
v-if="decryptedMembers.length < members.length" <div class="text-center text-red-600 py-4">
class="text-center text-red-600 py-4" {{ decryptionErrorMessage() }}
> </div>
{{
decryptFailureMessage ||
"Your password failed. Please go back and try again."
}}
</p>
<div v-else class="space-y-4">
<div v-if="missingMyself" class="py-4 text-red-600"> <div v-if="missingMyself" class="py-4 text-red-600">
You are not admitted. The organizer will admit you. You are not currently admitted by the organizer.
</div>
<div v-if="!firstName" class="py-4 text-red-600">
Your name is not set, so others may not recognize you. Reload this page
to set it.
</div> </div>
<div> <div>
<span <span
v-if="showOrganizerTools && isOrganizer" v-if="membersToShow().length > 0 && showOrganizerTools && isOrganizer"
class="inline-flex items-center flex-wrap" class="inline-flex items-center flex-wrap"
> >
<span class="inline-flex items-center"> <span class="inline-flex items-center">
Use &bull; Click
<span <span
class="mx-2 min-w-[24px] min-h-[24px] w-6 h-6 flex items-center justify-center rounded-full bg-blue-100 text-blue-600" class="mx-2 min-w-[24px] min-h-[24px] w-6 h-6 flex items-center justify-center rounded-full bg-blue-100 text-blue-600"
> >
<fa icon="plus" class="text-sm" /> <fa icon="plus" class="text-sm" />
</span> </span>
and /
<span <span
class="mx-2 min-w-[24px] min-h-[24px] w-6 h-6 flex items-center justify-center rounded-full bg-blue-100 text-blue-600" class="mx-2 min-w-[24px] min-h-[24px] w-6 h-6 flex items-center justify-center rounded-full bg-blue-100 text-blue-600"
> >
@ -45,8 +46,11 @@
</span> </span>
</div> </div>
<div> <div>
<span class="inline-flex items-center"> <span
Use v-if="membersToShow().length > 0"
class="inline-flex items-center"
>
&bull; Click
<span <span
class="mx-2 w-8 h-8 flex items-center justify-center rounded-full bg-green-100 text-green-600" class="mx-2 w-8 h-8 flex items-center justify-center rounded-full bg-green-100 text-green-600"
> >
@ -56,7 +60,8 @@
</span> </span>
</div> </div>
<div v-if="members.length > 0" class="flex justify-center"> <div class="flex justify-center">
<!-- always have at least one refresh button even without members in case the organizer changes the password -->
<button <button
@click="fetchMembers" @click="fetchMembers"
class="w-8 h-8 flex items-center justify-center rounded-full bg-blue-100 text-blue-600 hover:bg-blue-200 hover:text-blue-800 transition-colors" class="w-8 h-8 flex items-center justify-center rounded-full bg-blue-100 text-blue-600 hover:bg-blue-200 hover:text-blue-800 transition-colors"
@ -68,7 +73,7 @@
<div <div
v-for="member in membersToShow()" v-for="member in membersToShow()"
:key="member.member.memberId" :key="member.member.memberId"
class="p-4 bg-gray-50 rounded-lg" class="mt-2 p-4 bg-gray-50 rounded-lg"
> >
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center"> <div class="flex items-center">
@ -131,7 +136,7 @@
{{ member.did }} {{ member.did }}
</p> </p>
</div> </div>
<div v-if="members.length > 0" class="flex justify-center mt-4"> <div v-if="membersToShow().length > 0" class="flex justify-center mt-4">
<button <button
@click="fetchMembers" @click="fetchMembers"
class="w-8 h-8 flex items-center justify-center rounded-full bg-blue-100 text-blue-600 hover:bg-blue-200 hover:text-blue-800 transition-colors" class="w-8 h-8 flex items-center justify-center rounded-full bg-blue-100 text-blue-600 hover:bg-blue-200 hover:text-blue-800 transition-colors"
@ -171,13 +176,13 @@ interface Member {
admitted: boolean; admitted: boolean;
content: string; content: string;
memberId: number; memberId: number;
registered: boolean;
} }
interface DecryptedMember { interface DecryptedMember {
member: Member; member: Member;
name: string; name: string;
did: string; did: string;
isRegistered: boolean;
} }
@Component @Component
@ -187,16 +192,15 @@ export default class MembersList extends Vue {
libsUtil = libsUtil; libsUtil = libsUtil;
@Prop({ required: true }) password!: string; @Prop({ required: true }) password!: string;
@Prop({ default: "Your password failed. Please go back and try again." })
decryptFailureMessage!: string;
@Prop({ default: false }) showOrganizerTools!: boolean; @Prop({ default: false }) showOrganizerTools!: boolean;
decryptedMembers: DecryptedMember[] = []; decryptedMembers: DecryptedMember[] = [];
missingPassword = false; firstName = "";
missingMyself = false; isLoading = true;
isLoading = false;
isOrganizer = false; isOrganizer = false;
members: Member[] = []; members: Member[] = [];
missingPassword = false;
missingMyself = false;
activeDid = ""; activeDid = "";
apiServer = ""; apiServer = "";
contacts: Array<Contact> = []; contacts: Array<Contact> = [];
@ -205,13 +209,14 @@ export default class MembersList extends Vue {
const settings = await retrieveSettingsForActiveAccount(); const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || ""; this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || ""; this.apiServer = settings.apiServer || "";
this.firstName = settings.firstName || "";
await this.fetchMembers(); await this.fetchMembers();
await this.loadContacts(); await this.loadContacts();
} }
async fetchMembers() { async fetchMembers() {
this.isLoading = true;
try { try {
this.isLoading = true;
const headers = await getHeaders(this.activeDid); const headers = await getHeaders(this.activeDid);
const response = await this.axios.get( const response = await this.axios.get(
`${this.apiServer}/api/partner/groupOnboardMembers`, `${this.apiServer}/api/partner/groupOnboardMembers`,
@ -258,6 +263,7 @@ export default class MembersList extends Vue {
member: member, member: member,
name: content.name, name: content.name,
did: content.did, did: content.did,
isRegistered: !!content.isRegistered,
}); });
if (isFirstEntry && content.did === this.activeDid) { if (isFirstEntry && content.did === this.activeDid) {
this.isOrganizer = true; this.isOrganizer = true;
@ -273,6 +279,28 @@ export default class MembersList extends Vue {
this.missingMyself = !foundMyself; this.missingMyself = !foundMyself;
} }
decryptionErrorMessage(): string {
if (this.isOrganizer) {
if (this.decryptedMembers.length < this.members.length) {
return "Some members have data that cannot be decrypted with that password.";
} else {
// the lists must be equal
return "";
}
} else {
// non-organizers should only see problems if the first (organizer) member is not decrypted
if (
this.decryptedMembers.length === 0 ||
this.decryptedMembers[0].member.memberId !== this.members[0].memberId
) {
return "Your password is not the same as the organizer. Reload or have them check their password.";
} else {
// the first (organizer) member was decrypted OK
return "";
}
}
}
membersToShow(): DecryptedMember[] { membersToShow(): DecryptedMember[] {
if (this.isOrganizer) { if (this.isOrganizer) {
if (this.showOrganizerTools) { if (this.showOrganizerTools) {
@ -293,7 +321,7 @@ export default class MembersList extends Vue {
group: "alert", group: "alert",
type: "info", type: "info",
title: "Admission info", title: "Admission info",
text: "This is to register people and admit them to the meeting. A '+' symbol means they are not yet admitted and you can register and admit them. A '-' means you can remove them, but they will stay registered.", text: "This is to register people in Time Safari and to admit them to the meeting. A '+' symbol means they are not yet admitted and you can register and admit them. A '-' means you can remove them, but they will stay registered.",
}, },
10000, 10000,
); );
@ -331,9 +359,9 @@ export default class MembersList extends Vue {
return this.contacts.find((contact) => contact.did === did); return this.contacts.find((contact) => contact.did === did);
} }
checkWhetherContactBeforeAdmitting(member: DecryptedMember) { checkWhetherContactBeforeAdmitting(decrMember: DecryptedMember) {
const contact = this.getContactFor(member.did); const contact = this.getContactFor(decrMember.did);
if (!member.member.admitted && !contact) { if (!decrMember.member.admitted && !contact) {
// If not a contact, show confirmation dialog // If not a contact, show confirmation dialog
this.$notify( this.$notify(
{ {
@ -344,9 +372,9 @@ export default class MembersList extends Vue {
yesText: "Add as Contact", yesText: "Add as Contact",
noText: "Skip Adding Contact", noText: "Skip Adding Contact",
onYes: async () => { onYes: async () => {
await this.addAsContact(member); await this.addAsContact(decrMember);
// After adding as contact, proceed with admission // After adding as contact, proceed with admission
await this.toggleAdmission(member); await this.toggleAdmission(decrMember);
}, },
onNo: async () => { onNo: async () => {
// If they choose not to add as contact, show second confirmation // If they choose not to add as contact, show second confirmation
@ -355,10 +383,10 @@ export default class MembersList extends Vue {
group: "modal", group: "modal",
type: "confirm", type: "confirm",
title: "Continue Without Adding?", title: "Continue Without Adding?",
text: "Are you sure you want to proceed with admission even though they are not a contact?", text: "Are you sure you want to proceed with admission? If they are not a contact, you will not know their name after this meeting.",
yesText: "Continue", yesText: "Continue",
onYes: async () => { onYes: async () => {
await this.toggleAdmission(member); await this.toggleAdmission(decrMember);
}, },
onCancel: async () => { onCancel: async () => {
// Do nothing, effectively canceling the operation // Do nothing, effectively canceling the operation
@ -372,28 +400,33 @@ export default class MembersList extends Vue {
); );
} else { } else {
// If already a contact, proceed directly with admission // If already a contact, proceed directly with admission
this.toggleAdmission(member); this.toggleAdmission(decrMember);
} }
} }
async toggleAdmission(member: DecryptedMember) { async toggleAdmission(decrMember: DecryptedMember) {
try { try {
const headers = await getHeaders(this.activeDid); const headers = await getHeaders(this.activeDid);
await this.axios.put( await this.axios.put(
`${this.apiServer}/api/partner/groupOnboardMember/${member.member.memberId}`, `${this.apiServer}/api/partner/groupOnboardMember/${decrMember.member.memberId}`,
{ admitted: !member.member.admitted }, { admitted: !decrMember.member.admitted },
{ headers }, { headers },
); );
// Update local state // Update local state
member.member.admitted = !member.member.admitted; decrMember.member.admitted = !decrMember.member.admitted;
const oldContact = this.getContactFor(member.did); const oldContact = this.getContactFor(decrMember.did);
// if admitted, now register that user if they are not registered // if admitted, now register that user if they are not registered
if (member.member.admitted && !oldContact?.registered) { if (
decrMember.member.admitted &&
!decrMember.isRegistered &&
!oldContact?.registered
) {
const contactOldOrNew: Contact = oldContact || { const contactOldOrNew: Contact = oldContact || {
did: member.did, did: decrMember.did,
name: member.name, name: decrMember.name,
}; };
try {
const result = await register( const result = await register(
this.activeDid, this.activeDid,
this.apiServer, this.apiServer,
@ -401,9 +434,9 @@ export default class MembersList extends Vue {
contactOldOrNew, contactOldOrNew,
); );
if (result.success) { if (result.success) {
member.member.registered = true; decrMember.isRegistered = true;
if (oldContact) { if (oldContact) {
await db.contacts.update(member.did, { registered: true }); await db.contacts.update(decrMember.did, { registered: true });
oldContact.registered = true; oldContact.registered = true;
} }
this.$notify( this.$notify(
@ -416,17 +449,23 @@ export default class MembersList extends Vue {
3000, 3000,
); );
} else { } else {
const additionalInfo = result.error || ""; throw result;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
// registration failure is likely explained by a message from the server
const additionalInfo =
serverMessageForUser(error) || error?.error || "";
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
type: "danger", type: "warning",
title: "Registration failed", title: "Registration failed",
text: text:
"They were admitted, but registration failed. You can try again, or register from your contacts screen. " + "They were admitted to the meeting. However, registration failed. You can register them from the contacts screen. " +
additionalInfo, additionalInfo,
}, },
10000, 12000,
); );
} }
} }

19
src/components/UserNameDialog.vue

@ -4,8 +4,7 @@
<div class="dialog"> <div class="dialog">
<h1 class="text-xl font-bold text-center mb-4">Set Your Name</h1> <h1 class="text-xl font-bold text-center mb-4">Set Your Name</h1>
This is not sent to servers. It is only shared with people when you send {{ sharingExplanation }}
it to them.
<input <input
type="text" type="text"
placeholder="Name" placeholder="Name"
@ -36,7 +35,7 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Vue, Component } from "vue-facing-decorator"; import { Vue, Component, Prop } from "vue-facing-decorator";
import { NotificationIface } from "../constants/app"; import { NotificationIface } from "../constants/app";
import { db, retrieveSettingsForActiveAccount } from "../db/index"; import { db, retrieveSettingsForActiveAccount } from "../db/index";
@ -46,14 +45,21 @@ import { MASTER_SETTINGS_KEY } from "../db/tables/settings";
export default class UserNameDialog extends Vue { export default class UserNameDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
callback: (name: string) => void = () => {}; @Prop({
default:
"This is not sent to servers. It is only shared with people when you send it to them.",
})
sharingExplanation!: string;
@Prop({ default: false }) callbackOnCancel!: boolean;
callback: (name?: string) => void = () => {};
givenName = ""; givenName = "";
visible = false; visible = false;
/** /**
* @param aCallback - callback function for name, which may be "" * @param aCallback - callback function for name, which may be ""
*/ */
async open(aCallback?: (name: string) => void) { async open(aCallback?: (name?: string) => void) {
this.callback = aCallback || this.callback; this.callback = aCallback || this.callback;
const settings = await retrieveSettingsForActiveAccount(); const settings = await retrieveSettingsForActiveAccount();
this.givenName = settings.firstName || ""; this.givenName = settings.firstName || "";
@ -70,6 +76,9 @@ export default class UserNameDialog extends Vue {
onClickCancel() { onClickCancel() {
this.visible = false; this.visible = false;
if (this.callbackOnCancel) {
this.callback();
}
} }
} }
</script> </script>

6
src/test/index.ts

@ -1,9 +1,9 @@
import axios from "axios"; import axios from "axios";
import * as didJwt from "did-jwt"; import * as didJwt from "did-jwt";
import { AppString } from "@/constants/app"; import { AppString } from "../constants/app";
import { retrieveSettingsForActiveAccount } from "../db"; import { retrieveSettingsForActiveAccount } from "../db";
import { SERVICE_ID } from "@/libs/endorserServer"; import { SERVICE_ID } from "../libs/endorserServer";
import { deriveAddress, newIdentifier } from "@/libs/crypto"; import { deriveAddress, newIdentifier } from "../libs/crypto";
/** /**
* Get User #0 to sign & submit a RegisterAction for the user's activeDid. * Get User #0 to sign & submit a RegisterAction for the user's activeDid.

6
src/views/ClaimReportCertificateView.vue

@ -21,9 +21,9 @@ import { Component, Vue } from "vue-facing-decorator";
import { nextTick } from "vue"; import { nextTick } from "vue";
import QRCode from "qrcode"; import QRCode from "qrcode";
import { APP_SERVER, NotificationIface } from "@/constants/app"; import { APP_SERVER, NotificationIface } from "../constants/app";
import { db, retrieveSettingsForActiveAccount } from "@/db/index"; import { db, retrieveSettingsForActiveAccount } from "../db/index";
import * as endorserServer from "@/libs/endorserServer"; import * as endorserServer from "../libs/endorserServer";
@Component @Component
export default class ClaimReportCertificateView extends Vue { export default class ClaimReportCertificateView extends Vue {

1
src/views/ClaimView.vue

@ -74,6 +74,7 @@
</div> </div>
<div> <div>
<fa icon="calendar" class="fa-fw text-slate-400" /> <fa icon="calendar" class="fa-fw text-slate-400" />
Recorded
{{ veriClaim.issuedAt?.replace(/T/, " ").replace(/Z/, " UTC") }} {{ veriClaim.issuedAt?.replace(/T/, " ").replace(/Z/, " UTC") }}
</div> </div>
<div v-if="veriClaim.claim.image" class="flex justify-center"> <div v-if="veriClaim.claim.image" class="flex justify-center">

4
src/views/ContactsView.vue

@ -1416,11 +1416,11 @@ export default class ContactsView extends Vue {
type: "confirm", type: "confirm",
title: "Onboarding Meeting", title: "Onboarding Meeting",
text: "Would you like to start a new meeting?", text: "Would you like to start a new meeting?",
onYes: () => { onYes: async () => {
(this.$router as Router).push({ name: "onboard-meeting-setup" }); (this.$router as Router).push({ name: "onboard-meeting-setup" });
}, },
yesText: "Start New Meeting", yesText: "Start New Meeting",
onNo: () => { onNo: async () => {
(this.$router as Router).push({ name: "onboard-meeting-list" }); (this.$router as Router).push({ name: "onboard-meeting-list" });
}, },
noText: "Join Existing Meeting", noText: "Join Existing Meeting",

48
src/views/HomeView.vue

@ -281,7 +281,7 @@
/> />
</span> </span>
</span> </span>
<span class="col-span-10 justify-self-stretch"> <span class="col-span-10 justify-self-stretch overflow-hidden">
<!-- show giver and/or receiver profiles... which seemed like a good idea but actually adds clutter <!-- show giver and/or receiver profiles... which seemed like a good idea but actually adds clutter
<span <span
v-if=" v-if="
@ -315,7 +315,7 @@
/> />
</span> </span>
--> -->
<span class="pl-2"> <span class="pl-2 block break-words">
{{ giveDescription(record) }} {{ giveDescription(record) }}
</span> </span>
<a @click="onClickLoadClaim(record.jwtId)"> <a @click="onClickLoadClaim(record.jwtId)">
@ -346,10 +346,18 @@
</router-link> </router-link>
</span> </span>
</div> </div>
<div v-if="record.image" class="flex justify-center"> <div v-if="record.image" class="w-full">
<a :href="record.image" target="_blank"> <div
<img :src="record.image" class="h-48 mt-2 rounded-xl" /> class="cursor-pointer"
</a> @click="openImageViewer(record.image)"
>
<img
:src="record.image"
class="w-full aspect-[3/2] object-cover rounded-xl mt-2"
alt="shared content"
@load="cacheImageData($event, record.image)"
/>
</div>
</div> </div>
</li> </li>
</ul> </ul>
@ -368,6 +376,12 @@
</section> </section>
<ChoiceButtonDialog ref="choiceButtonDialog" /> <ChoiceButtonDialog ref="choiceButtonDialog" />
<ImageViewer
:image-url="selectedImage"
:image-data="selectedImageData"
v-model:is-open="isImageViewerOpen"
/>
</template> </template>
<script lang="ts"> <script lang="ts">
@ -386,6 +400,7 @@ import QuickNav from "../components/QuickNav.vue";
import TopMessage from "../components/TopMessage.vue"; import TopMessage from "../components/TopMessage.vue";
import UserNameDialog from "../components/UserNameDialog.vue"; import UserNameDialog from "../components/UserNameDialog.vue";
import ChoiceButtonDialog from "../components/ChoiceButtonDialog.vue"; import ChoiceButtonDialog from "../components/ChoiceButtonDialog.vue";
import ImageViewer from "../components/ImageViewer.vue";
import { import {
AppString, AppString,
NotificationIface, NotificationIface,
@ -450,6 +465,7 @@ interface GiveRecordWithContactInfo extends GiveSummaryRecord {
QuickNav, QuickNav,
TopMessage, TopMessage,
UserNameDialog, UserNameDialog,
ImageViewer,
}, },
}) })
export default class HomeView extends Vue { export default class HomeView extends Vue {
@ -485,6 +501,10 @@ export default class HomeView extends Vue {
}> = []; }> = [];
showShortcutBvc = false; showShortcutBvc = false;
userAgentInfo = new UAParser(); // see https://docs.uaparser.js.org/v2/api/ua-parser-js/get-os.html userAgentInfo = new UAParser(); // see https://docs.uaparser.js.org/v2/api/ua-parser-js/get-os.html
selectedImage = "";
selectedImageData: Blob | null = null;
isImageViewerOpen = false;
imageCache: Map<string, Blob | null> = new Map();
async mounted() { async mounted() {
try { try {
@ -966,5 +986,21 @@ export default class HomeView extends Vue {
}, },
}); });
} }
async cacheImageData(event: Event, imageUrl: string) {
try {
// For images that might fail CORS, just store the URL
// The Web Share API will handle sharing the URL appropriately
this.imageCache.set(imageUrl, null);
} catch (error) {
console.warn("Failed to cache image:", error);
}
}
async openImageViewer(imageUrl: string) {
this.selectedImageData = this.imageCache.get(imageUrl) ?? null;
this.selectedImage = imageUrl;
this.isImageViewerOpen = true;
}
} }
</script> </script>

101
src/views/NewEditProjectView.vue

@ -71,17 +71,17 @@
<textarea <textarea
placeholder="Description" placeholder="Description"
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2" class="block w-full rounded border border-slate-400 px-3 py-2"
rows="5" rows="5"
v-model="fullClaim.description" v-model="fullClaim.description"
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">
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 -- just remember that this information is public and saved in a public
history. history.
</div> </div>
<div class="text-xs text-slate-500 italic -mt-3 mb-4"> <div class="text-xs text-slate-500 italic">
{{ fullClaim.description?.length }}/5000 max. characters {{ fullClaim.description?.length }}/5000 max. characters
</div> </div>
@ -89,28 +89,55 @@
v-model="fullClaim.url" v-model="fullClaim.url"
placeholder="Website" placeholder="Website"
autocapitalize="none" autocapitalize="none"
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2" class="block w-full rounded border border-slate-400 mt-4 px-3 py-2"
/> />
<div class="flex mb-4 columns-3 w-full"> <div>
<div class="flex items-center mt-4">
<span class="mr-2">Starts At</span>
<input <input
v-model="startDateInput" v-model="startDateInput"
placeholder="Start Date" placeholder="Start Date"
type="date" type="date"
class="col-span-1 w-full rounded border border-slate-400 px-3 py-2" class="rounded border border-slate-400 px-3 py-2"
/> />
<input <input
:disabled="!startDateInput" :disabled="!startDateInput"
placeholder="Start Time" placeholder="Start Time"
v-model="startTimeInput" v-model="startTimeInput"
type="time" type="time"
class="col-span-1 w-full rounded border border-slate-400 ml-2 px-3 py-2" class="rounded border border-slate-400 ml-2 px-3 py-2"
/>
</div>
<div class="flex w-full justify-end items-center">
<span class="w-full flex justify-end items-center">
{{ zoneName }} time zone
</span>
</div>
<div class="flex items-center">
<div class="mr-2">
<span>Ends at</span>
</div>
<input
v-model="endDateInput"
placeholder="End Date"
type="date"
class="ml-2 rounded border border-slate-400 px-3 py-2"
/>
<input
:disabled="!endDateInput"
placeholder="End Time"
v-model="endTimeInput"
type="time"
class="rounded border border-slate-400 ml-2 px-3 py-2"
/> />
<span class="col-span-1 w-full flex justify-center">{{ zoneName }}</span> </div>
</div> </div>
<div <div
class="flex items-center mb-4" class="flex items-center mt-4"
@click="includeLocation = !includeLocation" @click="includeLocation = !includeLocation"
> >
<input type="checkbox" class="mr-2" v-model="includeLocation" /> <input type="checkbox" class="mr-2" v-model="includeLocation" />
@ -202,18 +229,10 @@
import "leaflet/dist/leaflet.css"; 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 { finalizeEvent, serializeEvent } from "nostr-tools";
// these core imports could also be included as "import type ..." // these core imports could also be included as "import type ..."
import { import { EventTemplate, UnsignedEvent, VerifiedEvent } from "nostr-tools/core";
EventTemplate, import * as nip06 from "nostr-tools/nip06";
UnsignedEvent,
VerifiedEvent,
} from "nostr-tools/lib/types/core";
import {
accountFromExtendedKey,
extendedKeysFromSeedWords,
} from "nostr-tools/nip06";
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";
import { RouteLocationNormalizedLoaded, Router } from "vue-router"; import { RouteLocationNormalizedLoaded, Router } from "vue-router";
@ -251,6 +270,8 @@ export default class NewEditProjectView extends Vue {
activeDid = ""; activeDid = "";
agentDid = ""; agentDid = "";
apiServer = ""; apiServer = "";
endDateInput?: string;
endTimeInput?: string;
errorMessage = ""; errorMessage = "";
fullClaim: PlanVerifiableCredential = { fullClaim: PlanVerifiableCredential = {
"@context": "https://schema.org", "@context": "https://schema.org",
@ -325,6 +346,13 @@ export default class NewEditProjectView extends Vue {
this.startDateInput = localDateTime.toFormat("yyyy-MM-dd"); this.startDateInput = localDateTime.toFormat("yyyy-MM-dd");
this.startTimeInput = localDateTime.toFormat("HH:mm"); this.startTimeInput = localDateTime.toFormat("HH:mm");
} }
if (this.fullClaim.endTime) {
const localDateTime = DateTime.fromISO(
this.fullClaim.endTime as string,
).toLocal();
this.endDateInput = localDateTime.toFormat("yyyy-MM-dd");
this.endTimeInput = localDateTime.toFormat("HH:mm");
}
} }
} catch (error) { } catch (error) {
console.error("Got error retrieving that project", error); console.error("Got error retrieving that project", error);
@ -468,7 +496,7 @@ export default class NewEditProjectView extends Vue {
group: "alert", group: "alert",
type: "danger", type: "danger",
title: "Date Error", title: "Date Error",
text: "The date was invalid so it was not set.", text: "The start date was invalid so it was not set.",
}, },
5000, 5000,
); );
@ -476,6 +504,28 @@ export default class NewEditProjectView extends Vue {
} else { } else {
delete vcClaim.startTime; delete vcClaim.startTime;
} }
if (this.endDateInput) {
try {
const endTimeFull = this.endTimeInput || "23:59:59";
const fullTimeString = this.endDateInput + " " + endTimeFull;
// throw an error on an invalid date or time string
vcClaim.endTime = new Date(fullTimeString).toISOString(); // ensure timezone is part of it
} catch {
// it's not a valid date so erase it and tell the user
delete vcClaim.endTime;
this.$notify(
{
group: "alert",
type: "danger",
title: "Date Error",
text: "The end date was invalid so it was not set.",
},
5000,
);
}
} else {
delete vcClaim.endTime;
}
const vcJwt = await createEndorserJwtVcFromClaim(this.activeDid, vcClaim); const vcJwt = await createEndorserJwtVcFromClaim(this.activeDid, vcClaim);
// Make the xhr request payload // Make the xhr request payload
@ -618,15 +668,15 @@ export default class NewEditProjectView extends Vue {
// 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 extPubPri = extendedKeysFromSeedWords( const extPubPri = nip06.extendedKeysFromSeedWords(
account?.mnemonic as string, account?.mnemonic as string,
"", "",
accountNum, accountNum,
); );
const publicExtendedKey: string = extPubPri?.publicExtendedKey; const publicExtendedKey: string = extPubPri?.publicExtendedKey;
const privateExtendedKey = extPubPri?.privateExtendedKey; const privateExtendedKey = extPubPri?.privateExtendedKey;
const privateKey = accountFromExtendedKey(privateExtendedKey).privateKey; const privateBytes: Uint8Array =
const privateBytes = hexToBytes(privateKey); nip06.accountFromExtendedKey(privateExtendedKey).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.
@ -660,7 +710,8 @@ export default class NewEditProjectView extends Vue {
const endorserPartnerUrl = partnerServer + "/api/partner/link"; const endorserPartnerUrl = partnerServer + "/api/partner/link";
const timeSafariUrl = window.location.origin + "/claim/" + jwtId; const timeSafariUrl = window.location.origin + "/claim/" + jwtId;
const content = this.fullClaim.name + " - see " + timeSafariUrl; const content = this.fullClaim.name + " - see " + timeSafariUrl;
const publicKeyHex = accountFromExtendedKey(publicExtendedKey).publicKey; const publicKeyHex =
nip06.accountFromExtendedKey(publicExtendedKey).publicKey;
const unsignedPayload: UnsignedEvent = { const unsignedPayload: UnsignedEvent = {
// why doesn't "...signedPayload" work? // why doesn't "...signedPayload" work?
kind: signedPayload.kind, kind: signedPayload.kind,

3
src/views/OnboardMeetingListView.vue

@ -126,6 +126,7 @@ export default class OnboardMeetingListView extends Vue {
attendingMeeting: Meeting | null = null; attendingMeeting: Meeting | null = null;
firstName = ""; firstName = "";
isLoading = false; isLoading = false;
isRegistered = false;
meetings: Meeting[] = []; meetings: Meeting[] = [];
password = ""; password = "";
selectedMeeting: Meeting | null = null; selectedMeeting: Meeting | null = null;
@ -136,6 +137,7 @@ export default class OnboardMeetingListView extends Vue {
this.activeDid = settings.activeDid || ""; this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || ""; this.apiServer = settings.apiServer || "";
this.firstName = settings.firstName || ""; this.firstName = settings.firstName || "";
this.isRegistered = !!settings.isRegistered;
await this.fetchMeetings(); await this.fetchMeetings();
} }
@ -232,6 +234,7 @@ export default class OnboardMeetingListView extends Vue {
const memberData = { const memberData = {
name: this.firstName, name: this.firstName,
did: this.activeDid, did: this.activeDid,
isRegistered: this.isRegistered,
}; };
const memberDataString = JSON.stringify(memberData); const memberDataString = JSON.stringify(memberData);
const encryptedMemberData = await encryptMessage( const encryptedMemberData = await encryptMessage(

158
src/views/OnboardMeetingMembersView.vue

@ -8,8 +8,16 @@
Meeting Members Meeting Members
</h1> </h1>
<!-- Loading Animation -->
<div
class="mt-16 text-center text-4xl bg-slate-400 text-white w-14 py-2.5 rounded-full mx-auto"
v-if="isLoading"
>
<fa icon="spinner" class="fa-spin-pulse"></fa>
</div>
<!-- Error State --> <!-- Error State -->
<div v-if="errorMessage"> <div v-else-if="errorMessage">
<div class="text-center text-red-600 py-8"> <div class="text-center text-red-600 py-8">
{{ errorMessage }} {{ errorMessage }}
</div> </div>
@ -19,13 +27,14 @@
</div> </div>
<!-- Members List --> <!-- Members List -->
<MembersList <MembersList v-else :password="password" @error="handleError" />
v-else
:password="password"
:decrypt-failure-message="'That password failed. You may be in the wrong meeting. Go back and try again.'"
@error="handleError"
/>
</section> </section>
<UserNameDialog
ref="userNameDialog"
:callback-on-cancel="true"
sharing-explanation="This is encrypted and shared only with people in this meeting."
/>
</template> </template>
<script lang="ts"> <script lang="ts">
@ -35,16 +44,35 @@ import { RouteLocation } from "vue-router";
import QuickNav from "../components/QuickNav.vue"; import QuickNav from "../components/QuickNav.vue";
import TopMessage from "../components/TopMessage.vue"; import TopMessage from "../components/TopMessage.vue";
import MembersList from "../components/MembersList.vue"; import MembersList from "../components/MembersList.vue";
import UserNameDialog from "../components/UserNameDialog.vue";
import { logConsoleAndDb, retrieveSettingsForActiveAccount } from "../db/index";
import { encryptMessage } from "../libs/crypto";
import {
errorStringForLog,
getHeaders,
serverMessageForUser,
} from "../libs/endorserServer";
import { generateSaveAndActivateIdentity } from "../libs/util";
@Component({ @Component({
components: { components: {
QuickNav, QuickNav,
TopMessage, TopMessage,
MembersList, MembersList,
UserNameDialog,
}, },
}) })
export default class OnboardMeetingMembersView extends Vue { export default class OnboardMeetingMembersView extends Vue {
activeDid = "";
apiServer = "";
errorMessage = ""; errorMessage = "";
firstName = "";
isRegistered = false;
isLoading = true;
$refs!: {
userNameDialog: InstanceType<typeof UserNameDialog>;
};
get groupId(): string { get groupId(): string {
return (this.$route as RouteLocation).params.groupId as string; return (this.$route as RouteLocation).params.groupId as string;
@ -63,6 +91,122 @@ export default class OnboardMeetingMembersView extends Vue {
this.errorMessage = "The password is missing. Go back and try again."; this.errorMessage = "The password is missing. Go back and try again.";
return; return;
} }
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
this.firstName = settings.firstName || "";
this.isRegistered = settings.isRegistered || false;
try {
if (!this.activeDid) {
this.activeDid = await generateSaveAndActivateIdentity();
this.isRegistered = false;
}
const headers = await getHeaders(this.activeDid);
const response = await this.axios.get(
`${this.apiServer}/api/partner/groupOnboardMember`,
{ headers },
);
const member = response.data?.data;
if (!member) {
if (!this.firstName) {
this.$refs.userNameDialog.open(this.addMemberToMeeting);
// addMemberToMeeting sets isLoading to false
} else {
await this.addMemberToMeeting(this.firstName);
// addMemberToMeeting sets isLoading to false
}
} else if (String(member.groupId) !== this.groupId) {
this.errorMessage =
"You are already in a different meeting. Reload or go back and try again.";
this.isLoading = false;
} else {
// must be already in the right meeting
if (!this.firstName) {
this.$refs.userNameDialog.open(this.updateMemberInMeeting);
// updateMemberInMeeting sets isLoading to false
} else {
await this.updateMemberInMeeting(this.firstName);
// updateMemberInMeeting sets isLoading to false
}
}
} catch (error) {
this.errorMessage =
serverMessageForUser(error) ||
"There was an error checking for that meeting. Reload or go back and try again.";
logConsoleAndDb(
"Error checking meeting: " + errorStringForLog(error),
true,
);
this.isLoading = false;
}
}
async addMemberToMeeting(name?: string) {
if (name != null) {
this.firstName = name;
}
const memberData = {
name: this.firstName,
did: this.activeDid,
isRegistered: this.isRegistered,
};
const memberDataString = JSON.stringify(memberData);
const encryptedMemberData = await encryptMessage(
memberDataString,
this.password,
);
const headers = await getHeaders(this.activeDid);
try {
await this.axios.post(
`${this.apiServer}/api/partner/groupOnboardMember`,
{ groupId: this.groupId, content: encryptedMemberData },
{ headers },
);
} catch (error) {
logConsoleAndDb(
"Error adding member to meeting: " + errorStringForLog(error),
true,
);
this.errorMessage =
serverMessageForUser(error) ||
"You're not in a meeting and couldn't be added to this one. Reload or go back and try again.";
}
this.isLoading = false;
}
async updateMemberInMeeting(name?: string) {
if (name != null) {
this.firstName = name;
}
const memberData = {
name: this.firstName,
did: this.activeDid,
isRegistered: this.isRegistered,
};
const memberDataString = JSON.stringify(memberData);
const encryptedMemberData = await encryptMessage(
memberDataString,
this.password,
);
const headers = await getHeaders(this.activeDid);
try {
await this.axios.put(
`${this.apiServer}/api/partner/groupOnboardMember`,
{ content: encryptedMemberData },
{ headers },
);
} catch (error) {
logConsoleAndDb(
"Error updating member in meeting: " + errorStringForLog(error),
true,
);
this.errorMessage =
serverMessageForUser(error) ||
"There was an error updating your name. Reload or go back and try again.";
}
this.isLoading = false;
} }
handleError(message: string) { handleError(message: string) {

13
src/views/OnboardMeetingSetupView.vue

@ -192,23 +192,23 @@
<!-- Members Section --> <!-- Members Section -->
<div <div
v-if="!isLoading && currentMeeting != null" v-if="!isLoading && currentMeeting != null && !!currentMeeting.password"
class="mt-8 p-4 border rounded-lg bg-white shadow" class="mt-8 p-4 border rounded-lg bg-white shadow"
> >
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h2 class="text-2xl">Meeting Members</h2> <h2 class="text-2xl">Meeting Members</h2>
</div> </div>
<router-link <router-link
v-if="!!currentMeeting.password"
:to="onboardMeetingMembersLink()" :to="onboardMeetingMembersLink()"
class="inline-block text-blue-600" class="inline-block text-blue-600"
target="_blank" target="_blank"
> >
Open shortcut page for members <fa icon="external-link" /> &bull; Open shortcut page for members <fa icon="external-link" />
</router-link> </router-link>
<MembersList <MembersList
:password="currentMeeting.password || ''" :password="currentMeeting.password || ''"
:decrypt-failure-message="DECRYPT_FAILURE_MESSAGE"
:show-organizer-tools="true" :show-organizer-tools="true"
@error="handleMembersError" @error="handleMembersError"
class="mt-4" class="mt-4"
@ -264,15 +264,13 @@ export default class OnboardMeetingView extends Vue {
timeout?: number, timeout?: number,
) => void; ) => void;
DECRYPT_FAILURE_MESSAGE =
"Unable to decrypt some member information. Check your password, or have them reset theirs if they don't show here.";
currentMeeting: ServerMeeting | null = null; currentMeeting: ServerMeeting | null = null;
newOrUpdatedMeeting: MeetingSetupInfo | null = null; newOrUpdatedMeeting: MeetingSetupInfo | null = null;
activeDid = ""; activeDid = "";
apiServer = ""; apiServer = "";
isDeleting = false; isDeleting = false;
isLoading = true; isLoading = true;
isRegistered = false;
showDeleteConfirm = false; showDeleteConfirm = false;
fullName = ""; fullName = "";
get minDateTime() { get minDateTime() {
@ -286,6 +284,7 @@ export default class OnboardMeetingView extends Vue {
this.activeDid = settings.activeDid || ""; this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || ""; this.apiServer = settings.apiServer || "";
this.fullName = settings.firstName || ""; this.fullName = settings.firstName || "";
this.isRegistered = !!settings.isRegistered;
await this.fetchCurrentMeeting(); await this.fetchCurrentMeeting();
this.isLoading = false; this.isLoading = false;
@ -409,6 +408,7 @@ export default class OnboardMeetingView extends Vue {
const content = { const content = {
name: this.newOrUpdatedMeeting.userFullName, name: this.newOrUpdatedMeeting.userFullName,
did: this.activeDid, did: this.activeDid,
isRegistered: this.isRegistered,
}; };
const encryptedContent = await encryptMessage( const encryptedContent = await encryptMessage(
JSON.stringify(content), JSON.stringify(content),
@ -598,6 +598,7 @@ export default class OnboardMeetingView extends Vue {
const content = { const content = {
name: this.newOrUpdatedMeeting.userFullName, name: this.newOrUpdatedMeeting.userFullName,
did: this.activeDid, did: this.activeDid,
isRegistered: this.isRegistered,
}; };
const encryptedContent = await encryptMessage( const encryptedContent = await encryptMessage(
JSON.stringify(content), JSON.stringify(content),

15
src/views/ProjectViewView.vue

@ -69,7 +69,11 @@
</div> </div>
<div v-if="startTime"> <div v-if="startTime">
<fa icon="calendar" class="fa-fw text-slate-400"></fa> <fa icon="calendar" class="fa-fw text-slate-400"></fa>
{{ startTime }} Starts {{ startTime }}
</div>
<div v-if="endTime">
<fa icon="calendar" class="fa-fw text-slate-400"></fa>
Ends {{ endTime }}
</div> </div>
<div v-if="latitude || longitude"> <div v-if="latitude || longitude">
<fa icon="location-dot" class="fa-fw text-slate-400"></fa> <fa icon="location-dot" class="fa-fw text-slate-400"></fa>
@ -541,6 +545,7 @@ export default class ProjectViewView extends Vue {
apiServer = ""; apiServer = "";
checkingConfirmationForJwtId = ""; checkingConfirmationForJwtId = "";
description = ""; description = "";
endTime = "";
expanded = false; expanded = false;
fulfilledByThis: PlanSummaryRecord | null = null; fulfilledByThis: PlanSummaryRecord | null = null;
fulfillersToThis: Array<PlanSummaryRecord> = []; fulfillersToThis: Array<PlanSummaryRecord> = [];
@ -641,6 +646,14 @@ export default class ProjectViewView extends Vue {
" " + " " +
startDateTime.toLocaleTimeString(); startDateTime.toLocaleTimeString();
} }
const endTime = resp.data.claim?.endTime;
if (endTime != null) {
const endDateTime = new Date(endTime);
this.endTime =
endDateTime.toLocaleDateString() +
" " +
endDateTime.toLocaleTimeString();
}
this.agentDid = resp.data.claim?.agent?.identifier; this.agentDid = resp.data.claim?.agent?.identifier;
this.agentDidVisibleToDids = this.agentDidVisibleToDids =
resp.data.claim?.agent?.identifierVisibleToDids || []; resp.data.claim?.agent?.identifierVisibleToDids || [];

Loading…
Cancel
Save