Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b14afc66d5 | |||
| e95b67b6db | |||
| 8e46c38008 | |||
| 62def44ebb | |||
| f53248cae1 | |||
| f4155e557a | |||
| 130d7ecb01 | |||
| 6fc416d759 | |||
| aa0156cdf2 | |||
| e86cf3c9f2 | |||
| f4c7805266 | |||
| 0511bbc17b | |||
| be1146c7df | |||
| 77ebd32956 | |||
| 781afd8954 | |||
| 53a06be734 | |||
| cc14b9e0ce | |||
| 9a6b2fcf0d | |||
| 2b78307a51 | |||
| ae12f243ec | |||
| 85c93c060a | |||
| da0f9e7581 |
26
CHANGELOG.md
26
CHANGELOG.md
@@ -6,6 +6,32 @@ 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.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
|
||||
|
||||
@@ -50,7 +50,7 @@ Look below for the "test-all" instructions.
|
||||
|
||||
* 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.36` && `git push origin 0.3.36`.
|
||||
* 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):
|
||||
|
||||
@@ -70,11 +70,11 @@ TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_APP_SERVER=https://test.timesafari.
|
||||
|
||||
* `pkgx +npm sh`
|
||||
|
||||
* `cd crowd-funder-for-time-pwa && git checkout master && git pull && git checkout 0.3.36 && npm install && npm run build && cd -`
|
||||
* `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, then `mv time-safari/dist time-safari-dist-prev.0 && mv crowd-funder-for-time-pwa/dist time-safari/`
|
||||
* 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.
|
||||
|
||||
|
||||
15
package-lock.json
generated
15
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "TimeSafari",
|
||||
"version": "0.3.54",
|
||||
"version": "0.4.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "TimeSafari",
|
||||
"version": "0.3.54",
|
||||
"version": "0.4.1",
|
||||
"dependencies": {
|
||||
"@capacitor/android": "^6.1.2",
|
||||
"@capacitor/cli": "^6.1.2",
|
||||
@@ -54,7 +54,7 @@
|
||||
"lru-cache": "^10.2.0",
|
||||
"luxon": "^3.4.4",
|
||||
"merkletreejs": "^0.3.11",
|
||||
"nostr-tools": "^2.7.2",
|
||||
"nostr-tools": "^2.10.4",
|
||||
"notiwind": "^2.0.2",
|
||||
"papaparse": "^5.4.1",
|
||||
"pina": "^0.20.2204228",
|
||||
@@ -18905,9 +18905,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nostr-tools": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.7.2.tgz",
|
||||
"integrity": "sha512-Bq3Ug0SZFtgtL1+0wCnAe8AJtI7yx/00/a2nUug9SkhfOwlKS92Tef12iCK9FdwXw+oFZWMtRnSwcLayQso+xA==",
|
||||
"version": "2.10.4",
|
||||
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.10.4.tgz",
|
||||
"integrity": "sha512-biU7sk+jxHgVASfobg2T5ttxOGGSt69wEVBC51sHHOEaKAAdzHBLV/I2l9Rf61UzClhliZwNouYhqIso4a3HYg==",
|
||||
"license": "Unlicense",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^0.5.1",
|
||||
"@noble/curves": "1.2.0",
|
||||
@@ -18917,7 +18918,7 @@
|
||||
"@scure/bip39": "1.2.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"nostr-wasm": "v0.1.0"
|
||||
"nostr-wasm": "0.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=5.0.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "TimeSafari",
|
||||
"version": "0.3.54",
|
||||
"version": "0.4.1",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"serve": "vite preview",
|
||||
@@ -58,7 +58,7 @@
|
||||
"lru-cache": "^10.2.0",
|
||||
"luxon": "^3.4.4",
|
||||
"merkletreejs": "^0.3.11",
|
||||
"nostr-tools": "^2.7.2",
|
||||
"nostr-tools": "^2.10.4",
|
||||
"notiwind": "^2.0.2",
|
||||
"papaparse": "^5.4.1",
|
||||
"pina": "^0.20.2204228",
|
||||
|
||||
94
src/components/ImageViewer.vue
Normal file
94
src/components/ImageViewer.vue
Normal file
@@ -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>
|
||||
@@ -1,40 +1,41 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- 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" />
|
||||
</div>
|
||||
|
||||
<!-- Members List -->
|
||||
|
||||
<p
|
||||
v-if="decryptedMembers.length < members.length"
|
||||
class="text-center text-red-600 py-4"
|
||||
>
|
||||
{{
|
||||
decryptFailureMessage ||
|
||||
"Your password failed. Please go back and try again."
|
||||
}}
|
||||
</p>
|
||||
<div v-else>
|
||||
<div class="text-center text-red-600 py-4">
|
||||
{{ decryptionErrorMessage() }}
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<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>
|
||||
<span
|
||||
v-if="showOrganizerTools && isOrganizer"
|
||||
v-if="membersToShow().length > 0 && showOrganizerTools && isOrganizer"
|
||||
class="inline-flex items-center flex-wrap"
|
||||
>
|
||||
<span class="inline-flex items-center">
|
||||
Use
|
||||
• Click
|
||||
<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"
|
||||
>
|
||||
<fa icon="plus" class="text-sm" />
|
||||
</span>
|
||||
and
|
||||
/
|
||||
<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"
|
||||
>
|
||||
@@ -45,8 +46,11 @@
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="inline-flex items-center">
|
||||
Use
|
||||
<span
|
||||
v-if="membersToShow().length > 0"
|
||||
class="inline-flex items-center"
|
||||
>
|
||||
• Click
|
||||
<span
|
||||
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>
|
||||
</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
|
||||
@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"
|
||||
@@ -68,7 +73,7 @@
|
||||
<div
|
||||
v-for="member in membersToShow()"
|
||||
: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">
|
||||
@@ -131,7 +136,7 @@
|
||||
{{ member.did }}
|
||||
</p>
|
||||
</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
|
||||
@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"
|
||||
@@ -187,16 +192,15 @@ export default class MembersList extends Vue {
|
||||
libsUtil = libsUtil;
|
||||
|
||||
@Prop({ required: true }) password!: string;
|
||||
@Prop({ default: "Your password failed. Please go back and try again." })
|
||||
decryptFailureMessage!: string;
|
||||
@Prop({ default: false }) showOrganizerTools!: boolean;
|
||||
|
||||
decryptedMembers: DecryptedMember[] = [];
|
||||
missingPassword = false;
|
||||
missingMyself = false;
|
||||
isLoading = false;
|
||||
firstName = "";
|
||||
isLoading = true;
|
||||
isOrganizer = false;
|
||||
members: Member[] = [];
|
||||
missingPassword = false;
|
||||
missingMyself = false;
|
||||
activeDid = "";
|
||||
apiServer = "";
|
||||
contacts: Array<Contact> = [];
|
||||
@@ -205,13 +209,14 @@ export default class MembersList extends Vue {
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
this.activeDid = settings.activeDid || "";
|
||||
this.apiServer = settings.apiServer || "";
|
||||
this.firstName = settings.firstName || "";
|
||||
await this.fetchMembers();
|
||||
await this.loadContacts();
|
||||
}
|
||||
|
||||
async fetchMembers() {
|
||||
this.isLoading = true;
|
||||
try {
|
||||
this.isLoading = true;
|
||||
const headers = await getHeaders(this.activeDid);
|
||||
const response = await this.axios.get(
|
||||
`${this.apiServer}/api/partner/groupOnboardMembers`,
|
||||
@@ -274,6 +279,28 @@ export default class MembersList extends Vue {
|
||||
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[] {
|
||||
if (this.isOrganizer) {
|
||||
if (this.showOrganizerTools) {
|
||||
@@ -356,7 +383,7 @@ export default class MembersList extends Vue {
|
||||
group: "modal",
|
||||
type: "confirm",
|
||||
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",
|
||||
onYes: async () => {
|
||||
await this.toggleAdmission(decrMember);
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
<div class="dialog">
|
||||
<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
|
||||
it to them.
|
||||
{{ sharingExplanation }}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Name"
|
||||
@@ -36,7 +35,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Vue, Component } from "vue-facing-decorator";
|
||||
import { Vue, Component, Prop } from "vue-facing-decorator";
|
||||
|
||||
import { NotificationIface } from "@/constants/app";
|
||||
import { db, retrieveSettingsForActiveAccount } from "@/db/index";
|
||||
@@ -46,14 +45,21 @@ import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
export default class UserNameDialog extends Vue {
|
||||
$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 = "";
|
||||
visible = false;
|
||||
|
||||
/**
|
||||
* @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;
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
this.givenName = settings.firstName || "";
|
||||
@@ -70,6 +76,9 @@ export default class UserNameDialog extends Vue {
|
||||
|
||||
onClickCancel() {
|
||||
this.visible = false;
|
||||
if (this.callbackOnCancel) {
|
||||
this.callback();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -74,6 +74,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<fa icon="calendar" class="fa-fw text-slate-400" />
|
||||
Recorded
|
||||
{{ veriClaim.issuedAt?.replace(/T/, " ").replace(/Z/, " UTC") }}
|
||||
</div>
|
||||
<div v-if="veriClaim.claim.image" class="flex justify-center">
|
||||
|
||||
@@ -281,7 +281,7 @@
|
||||
/>
|
||||
</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
|
||||
<span
|
||||
v-if="
|
||||
@@ -315,7 +315,7 @@
|
||||
/>
|
||||
</span>
|
||||
-->
|
||||
<span class="pl-2">
|
||||
<span class="pl-2 block break-words">
|
||||
{{ giveDescription(record) }}
|
||||
</span>
|
||||
<a @click="onClickLoadClaim(record.jwtId)">
|
||||
@@ -346,10 +346,18 @@
|
||||
</router-link>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="record.image" class="flex justify-center">
|
||||
<a :href="record.image" target="_blank">
|
||||
<img :src="record.image" class="h-48 mt-2 rounded-xl" />
|
||||
</a>
|
||||
<div v-if="record.image" class="w-full">
|
||||
<div
|
||||
class="cursor-pointer"
|
||||
@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>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -368,6 +376,12 @@
|
||||
</section>
|
||||
|
||||
<ChoiceButtonDialog ref="choiceButtonDialog" />
|
||||
|
||||
<ImageViewer
|
||||
:image-url="selectedImage"
|
||||
:image-data="selectedImageData"
|
||||
v-model:is-open="isImageViewerOpen"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
@@ -386,6 +400,7 @@ import QuickNav from "@/components/QuickNav.vue";
|
||||
import TopMessage from "@/components/TopMessage.vue";
|
||||
import UserNameDialog from "@/components/UserNameDialog.vue";
|
||||
import ChoiceButtonDialog from "@/components/ChoiceButtonDialog.vue";
|
||||
import ImageViewer from "@/components/ImageViewer.vue";
|
||||
import {
|
||||
AppString,
|
||||
NotificationIface,
|
||||
@@ -455,6 +470,7 @@ interface GiveRecordWithContactInfo extends GiveSummaryRecord {
|
||||
QuickNav,
|
||||
TopMessage,
|
||||
UserNameDialog,
|
||||
ImageViewer,
|
||||
},
|
||||
})
|
||||
export default class HomeView extends Vue {
|
||||
@@ -489,6 +505,10 @@ export default class HomeView extends Vue {
|
||||
}> = [];
|
||||
showShortcutBvc = false;
|
||||
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() {
|
||||
try {
|
||||
@@ -970,5 +990,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>
|
||||
|
||||
@@ -71,17 +71,17 @@
|
||||
|
||||
<textarea
|
||||
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"
|
||||
v-model="fullClaim.description"
|
||||
maxlength="5000"
|
||||
></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
|
||||
-- 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">
|
||||
<div class="text-xs text-slate-500 italic">
|
||||
{{ fullClaim.description?.length }}/5000 max. characters
|
||||
</div>
|
||||
|
||||
@@ -89,28 +89,55 @@
|
||||
v-model="fullClaim.url"
|
||||
placeholder="Website"
|
||||
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">
|
||||
<input
|
||||
v-model="startDateInput"
|
||||
placeholder="Start Date"
|
||||
type="date"
|
||||
class="col-span-1 w-full rounded border border-slate-400 px-3 py-2"
|
||||
/>
|
||||
<input
|
||||
:disabled="!startDateInput"
|
||||
placeholder="Start Time"
|
||||
v-model="startTimeInput"
|
||||
type="time"
|
||||
class="col-span-1 w-full 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 class="flex items-center mt-4">
|
||||
<span class="mr-2">Starts At</span>
|
||||
<input
|
||||
v-model="startDateInput"
|
||||
placeholder="Start Date"
|
||||
type="date"
|
||||
class="rounded border border-slate-400 px-3 py-2"
|
||||
/>
|
||||
<input
|
||||
:disabled="!startDateInput"
|
||||
placeholder="Start Time"
|
||||
v-model="startTimeInput"
|
||||
type="time"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center mb-4"
|
||||
class="flex items-center mt-4"
|
||||
@click="includeLocation = !includeLocation"
|
||||
>
|
||||
<input type="checkbox" class="mr-2" v-model="includeLocation" />
|
||||
@@ -202,18 +229,10 @@
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import { AxiosError, AxiosRequestHeaders } from "axios";
|
||||
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 ..."
|
||||
import {
|
||||
EventTemplate,
|
||||
UnsignedEvent,
|
||||
VerifiedEvent,
|
||||
} from "nostr-tools/lib/types/core";
|
||||
import {
|
||||
accountFromExtendedKey,
|
||||
extendedKeysFromSeedWords,
|
||||
} from "nostr-tools/nip06";
|
||||
import { finalizeEvent, serializeEvent } from "nostr-tools/pure";
|
||||
import { EventTemplate, UnsignedEvent, VerifiedEvent } from "nostr-tools/core";
|
||||
import * as nip06 from "nostr-tools/nip06";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
|
||||
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
|
||||
@@ -251,6 +270,8 @@ export default class NewEditProjectView extends Vue {
|
||||
activeDid = "";
|
||||
agentDid = "";
|
||||
apiServer = "";
|
||||
endDateInput?: string;
|
||||
endTimeInput?: string;
|
||||
errorMessage = "";
|
||||
fullClaim: PlanVerifiableCredential = {
|
||||
"@context": "https://schema.org",
|
||||
@@ -325,6 +346,13 @@ export default class NewEditProjectView extends Vue {
|
||||
this.startDateInput = localDateTime.toFormat("yyyy-MM-dd");
|
||||
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) {
|
||||
console.error("Got error retrieving that project", error);
|
||||
@@ -460,7 +488,7 @@ export default class NewEditProjectView extends Vue {
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
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,
|
||||
);
|
||||
@@ -468,6 +496,28 @@ export default class NewEditProjectView extends Vue {
|
||||
} else {
|
||||
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);
|
||||
|
||||
// Make the xhr request payload
|
||||
@@ -610,15 +660,15 @@ export default class NewEditProjectView extends Vue {
|
||||
// remove any trailing '
|
||||
const finalDerNumNoApostrophe = finalDerNum?.replace(/'/g, "");
|
||||
const accountNum = Number(finalDerNumNoApostrophe || 0);
|
||||
const extPubPri = extendedKeysFromSeedWords(
|
||||
const extPubPri = nip06.extendedKeysFromSeedWords(
|
||||
account?.mnemonic as string,
|
||||
"",
|
||||
accountNum,
|
||||
);
|
||||
const publicExtendedKey: string = extPubPri?.publicExtendedKey;
|
||||
const privateExtendedKey = extPubPri?.privateExtendedKey;
|
||||
const privateKey = accountFromExtendedKey(privateExtendedKey).privateKey;
|
||||
const privateBytes = hexToBytes(privateKey);
|
||||
const privateBytes: Uint8Array =
|
||||
nip06.accountFromExtendedKey(privateExtendedKey).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.
|
||||
@@ -652,7 +702,8 @@ 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 publicKeyHex =
|
||||
nip06.accountFromExtendedKey(publicExtendedKey).publicKey;
|
||||
const unsignedPayload: UnsignedEvent = {
|
||||
// why doesn't "...signedPayload" work?
|
||||
kind: signedPayload.kind,
|
||||
|
||||
@@ -8,8 +8,16 @@
|
||||
Meeting Members
|
||||
</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 -->
|
||||
<div v-if="errorMessage">
|
||||
<div v-else-if="errorMessage">
|
||||
<div class="text-center text-red-600 py-8">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
@@ -19,13 +27,14 @@
|
||||
</div>
|
||||
|
||||
<!-- Members List -->
|
||||
<MembersList
|
||||
v-else
|
||||
:password="password"
|
||||
:decrypt-failure-message="'That password failed. You may be in the wrong meeting. Go back and try again.'"
|
||||
@error="handleError"
|
||||
/>
|
||||
<MembersList v-else :password="password" @error="handleError" />
|
||||
</section>
|
||||
|
||||
<UserNameDialog
|
||||
ref="userNameDialog"
|
||||
:callback-on-cancel="true"
|
||||
sharing-explanation="This is encrypted and shared only with people in this meeting."
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
@@ -35,16 +44,35 @@ import { RouteLocation } from "vue-router";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import TopMessage from "@/components/TopMessage.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({
|
||||
components: {
|
||||
QuickNav,
|
||||
TopMessage,
|
||||
MembersList,
|
||||
UserNameDialog,
|
||||
},
|
||||
})
|
||||
export default class OnboardMeetingMembersView extends Vue {
|
||||
activeDid = "";
|
||||
apiServer = "";
|
||||
errorMessage = "";
|
||||
firstName = "";
|
||||
isRegistered = false;
|
||||
isLoading = true;
|
||||
|
||||
$refs!: {
|
||||
userNameDialog: InstanceType<typeof UserNameDialog>;
|
||||
};
|
||||
|
||||
get groupId(): 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.";
|
||||
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) {
|
||||
|
||||
@@ -192,23 +192,23 @@
|
||||
|
||||
<!-- Members Section -->
|
||||
<div
|
||||
v-if="!isLoading && currentMeeting != null"
|
||||
v-if="!isLoading && currentMeeting != null && !!currentMeeting.password"
|
||||
class="mt-8 p-4 border rounded-lg bg-white shadow"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-2xl">Meeting Members</h2>
|
||||
</div>
|
||||
<router-link
|
||||
v-if="!!currentMeeting.password"
|
||||
:to="onboardMeetingMembersLink()"
|
||||
class="inline-block text-blue-600"
|
||||
target="_blank"
|
||||
>
|
||||
Open shortcut page for members <fa icon="external-link" />
|
||||
• Open shortcut page for members <fa icon="external-link" />
|
||||
</router-link>
|
||||
|
||||
<MembersList
|
||||
:password="currentMeeting.password || ''"
|
||||
:decrypt-failure-message="DECRYPT_FAILURE_MESSAGE"
|
||||
:show-organizer-tools="true"
|
||||
@error="handleMembersError"
|
||||
class="mt-4"
|
||||
@@ -264,9 +264,6 @@ export default class OnboardMeetingView extends Vue {
|
||||
timeout?: number,
|
||||
) => 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;
|
||||
newOrUpdatedMeeting: MeetingSetupInfo | null = null;
|
||||
activeDid = "";
|
||||
|
||||
@@ -69,7 +69,11 @@
|
||||
</div>
|
||||
<div v-if="startTime">
|
||||
<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 v-if="latitude || longitude">
|
||||
<fa icon="location-dot" class="fa-fw text-slate-400"></fa>
|
||||
@@ -541,6 +545,7 @@ export default class ProjectViewView extends Vue {
|
||||
apiServer = "";
|
||||
checkingConfirmationForJwtId = "";
|
||||
description = "";
|
||||
endTime = "";
|
||||
expanded = false;
|
||||
fulfilledByThis: PlanSummaryRecord | null = null;
|
||||
fulfillersToThis: Array<PlanSummaryRecord> = [];
|
||||
@@ -641,6 +646,14 @@ export default class ProjectViewView extends Vue {
|
||||
" " +
|
||||
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.agentDidVisibleToDids =
|
||||
resp.data.claim?.agent?.identifierVisibleToDids || [];
|
||||
|
||||
@@ -2,7 +2,6 @@ import { test, expect } from '@playwright/test';
|
||||
import { importUser } from './testUtils';
|
||||
|
||||
test('Create new project, then search for it', async ({ page }) => {
|
||||
test.slow();
|
||||
|
||||
// Generate a random string of 16 characters
|
||||
let randomString = Math.random().toString(36).substring(2, 18);
|
||||
|
||||
@@ -2,7 +2,7 @@ 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
|
||||
test.slow(); // Set timeout longer since it often fails at 30 seconds
|
||||
|
||||
const projectCount = 10;
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import { test, expect } from '@playwright/test';
|
||||
import { importUser } from './testUtils';
|
||||
|
||||
test('Record an offer', async ({ page }) => {
|
||||
test.setTimeout(45000);
|
||||
|
||||
// Generate a random string of 3 characters, skipping the "0." at the beginning
|
||||
const randomString = Math.random().toString(36).substring(2, 5);
|
||||
// Standard title prefix
|
||||
|
||||
Reference in New Issue
Block a user