Compare commits
482 Commits
@ -0,0 +1,3 @@ |
|||||
|
|
||||
|
# I tried and failed to set things here with vue-cli-service but |
||||
|
# things may be more reliable with vite so let's try again. |
@ -0,0 +1,6 @@ |
|||||
|
# Only the variables that start with VITE_ are seen in the application import.meta.env in Vue. |
||||
|
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 |
@ -0,0 +1,27 @@ |
|||||
|
name: Playwright Tests |
||||
|
on: |
||||
|
push: |
||||
|
branches: [ main, master ] |
||||
|
pull_request: |
||||
|
branches: [ main, master ] |
||||
|
jobs: |
||||
|
test: |
||||
|
timeout-minutes: 60 |
||||
|
runs-on: ubuntu-latest |
||||
|
steps: |
||||
|
- uses: actions/checkout@v4 |
||||
|
- uses: actions/setup-node@v4 |
||||
|
with: |
||||
|
node-version: lts/* |
||||
|
- name: Install dependencies |
||||
|
run: npm ci |
||||
|
- name: Install Playwright Browsers |
||||
|
run: npx playwright install --with-deps |
||||
|
- name: Run Playwright tests |
||||
|
run: npx playwright test |
||||
|
- uses: actions/upload-artifact@v4 |
||||
|
if: always() |
||||
|
with: |
||||
|
name: playwright-report |
||||
|
path: playwright-report/ |
||||
|
retention-days: 30 |
@ -0,0 +1,11 @@ |
|||||
|
# Contributing |
||||
|
|
||||
|
Welcome! We are happy to have your help with this project. |
||||
|
|
||||
|
We expect contributions to include automated tests and pass linting. Run the `test-all` task. |
||||
|
Note that some previous features don't have tests and adding more will make you friends quick. |
||||
|
|
||||
|
Note that all contributions will be under our [license, modeled after SQLite](https://github.com/trentlarson/endorser-ch/blob/master/LICENSE). |
||||
|
|
||||
|
If you want to see a code of conduct, we're probably not the people you want to hang with. |
||||
|
Basically, we'll work together as long as we both enjoy it, and we'll stop when that stops. |
@ -1,3 +0,0 @@ |
|||||
module.exports = { |
|
||||
presets: ["@vue/cli-plugin-babel/preset"], |
|
||||
}; |
|
@ -0,0 +1,76 @@ |
|||||
|
# TimeSafari Docs |
||||
|
|
||||
|
## Generating PDF from Markdown on OSx |
||||
|
|
||||
|
This uses Pandoc and BasicTex (LaTeX) Installed through Homebrew. |
||||
|
|
||||
|
### Set Up |
||||
|
|
||||
|
```bash |
||||
|
brew install pandoc |
||||
|
|
||||
|
brew install basictex |
||||
|
|
||||
|
# Setting up LaTex packages |
||||
|
|
||||
|
# First update tlmgr |
||||
|
sudo tlmgr update --self |
||||
|
|
||||
|
# Then install LaTex packages |
||||
|
sudo tlmgr install bbding |
||||
|
sudo tlmgr install enumitem |
||||
|
sudo tlmgr install environ |
||||
|
sudo tlmgr install fancyhdr |
||||
|
sudo tlmgr install framed |
||||
|
sudo tlmgr install import |
||||
|
sudo tlmgr install lastpage # Enables Page X of Y |
||||
|
sudo tlmgr install mdframed |
||||
|
sudo tlmgr install multirow |
||||
|
sudo tlmgr install needspace |
||||
|
sudo tlmgr install ntheorem |
||||
|
sudo tlmgr install tabu |
||||
|
sudo tlmgr install tcolorbox |
||||
|
sudo tlmgr install textpos |
||||
|
sudo tlmgr install titlesec |
||||
|
sudo tlmgr install titling # Required for the fancy headers used |
||||
|
sudo tlmgr install threeparttable |
||||
|
sudo tlmgr install trimspaces |
||||
|
sudo tlmgr install tocloft # Required for \tableofcontents generation |
||||
|
sudo tlmgr install varwidth |
||||
|
sudo tlmgr install wrapfig |
||||
|
|
||||
|
# Install fonts |
||||
|
sudo tlmgr install cmbright |
||||
|
sudo tlmgr install collection-fontsrecommended # And set up fonts |
||||
|
sudo tlmgr install fira |
||||
|
sudo tlmgr install fontaxes |
||||
|
sudo tlmgr install libertine # The main font the doc uses |
||||
|
sudo tlmgr install opensans |
||||
|
sudo tlmgr install sourceserifpro |
||||
|
|
||||
|
``` |
||||
|
|
||||
|
#### References |
||||
|
|
||||
|
The following guide was adapted to this project except that we install with Brew and have a few more packages. |
||||
|
|
||||
|
Guide: https://daniel.feldroy.com/posts/setting-up-latex-on-mac-os-x |
||||
|
|
||||
|
### Usage |
||||
|
|
||||
|
Use the `pandoc` command to generate a PDF. |
||||
|
|
||||
|
```bash |
||||
|
pandoc usage-guide.md -o usage-guide.pdf |
||||
|
``` |
||||
|
|
||||
|
And you can open the PDF with the `open` command. |
||||
|
|
||||
|
```bash |
||||
|
open usage-guide.pdf |
||||
|
``` |
||||
|
|
||||
|
Or use this one-liner |
||||
|
```bash |
||||
|
pandoc usage-guide.md -o usage-guide.pdf && open usage-guide.pdf |
||||
|
``` |
After Width: | Height: | Size: 61 KiB |
After Width: | Height: | Size: 40 KiB |
After Width: | Height: | Size: 77 KiB |
After Width: | Height: | Size: 140 KiB |
After Width: | Height: | Size: 4.6 KiB |
After Width: | Height: | Size: 62 KiB |
After Width: | Height: | Size: 12 KiB |
After Width: | Height: | Size: 40 KiB |
After Width: | Height: | Size: 40 KiB |
After Width: | Height: | Size: 46 KiB |
After Width: | Height: | Size: 32 KiB |
After Width: | Height: | Size: 53 KiB |
After Width: | Height: | Size: 23 KiB |
After Width: | Height: | Size: 19 KiB |
After Width: | Height: | Size: 34 KiB |
After Width: | Height: | Size: 463 KiB |
@ -0,0 +1,316 @@ |
|||||
|
--- |
||||
|
geometry: margin=1in |
||||
|
header-includes: |
||||
|
- \usepackage{graphicx} |
||||
|
- \usepackage{titling} |
||||
|
- \usepackage{fancyhdr} |
||||
|
- \usepackage{lastpage} |
||||
|
- \pagestyle{fancy} |
||||
|
- \fancyhead[L]{Time Safari Usage Guide} |
||||
|
- \fancyhead[C]{Page \thepage\ of \pageref{LastPage}} |
||||
|
- \fancyhead[R]{} |
||||
|
- \fancyfoot[L]{} |
||||
|
- \fancyfoot[C]{} |
||||
|
- \fancyfoot[R]{\includegraphics[width=1cm]{images/timesafari-logo-binoculars.png}} |
||||
|
- \usepackage{tocloft} |
||||
|
- \usepackage{libertine} |
||||
|
- \renewcommand{\familydefault}{\sfdefault} |
||||
|
- \fancypagestyle{tocstyle}{ |
||||
|
\fancyhead[L]{Time Safari Usage Guide} |
||||
|
\fancyhead[C]{Page \thepage\ of \pageref{LastPage}} |
||||
|
\fancyhead[R]{} |
||||
|
\fancyfoot[L]{} |
||||
|
\fancyfoot[C]{} |
||||
|
\fancyfoot[R]{\includegraphics[width=1cm]{images/timesafari-logo-binoculars.png}}} |
||||
|
--- |
||||
|
|
||||
|
\begin{titlepage} |
||||
|
\centering |
||||
|
\vspace*{\fill} |
||||
|
{\huge\textbf{TimeSafari Usage guide}} |
||||
|
|
||||
|
\vspace{1cm} |
||||
|
{\Large Signing up users, adding contacts, and adding gifts.} |
||||
|
|
||||
|
\vspace{1cm} |
||||
|
\includegraphics[width=0.5\textwidth]{images/timesafari-logo.png} |
||||
|
\vspace*{\fill} |
||||
|
|
||||
|
\vspace{1cm} |
||||
|
{\Large Trent Larson, Kent Bull} |
||||
|
|
||||
|
\vspace{0.5cm} |
||||
|
{\large 2024-06-25} |
||||
|
|
||||
|
\end{titlepage} |
||||
|
|
||||
|
\clearpage |
||||
|
|
||||
|
\begin{center} |
||||
|
\includegraphics[width=2cm]{images/timesafari-logo-binoculars.png} |
||||
|
\end{center} |
||||
|
\tableofcontents |
||||
|
|
||||
|
\clearpage |
||||
|
|
||||
|
|
||||
|
# Purpose of Document |
||||
|
|
||||
|
Both end-users and development team members need to know how to use TimeSafari. |
||||
|
This document serves to show how to use every feature of the TimeSafari platform. |
||||
|
|
||||
|
Sections of this document are geared specifically for software developers and quality assurance |
||||
|
team members. |
||||
|
|
||||
|
Companion videos will also describe end-to-end workflows for the end-user. |
||||
|
|
||||
|
# TimeSafari |
||||
|
|
||||
|
## Overview |
||||
|
|
||||
|
\pagebreak |
||||
|
|
||||
|
# 1 - End Users |
||||
|
|
||||
|
This section covers application usage for people who will use TimeSafari as intended. It is a |
||||
|
simplified guide illustrating how to gain value from using TimeSafari. |
||||
|
|
||||
|
\pagebreak |
||||
|
|
||||
|
# 2 - Software Developers |
||||
|
|
||||
|
This section is tailored for software developers seeking to use the application during development, |
||||
|
quality assurance, and testing. |
||||
|
|
||||
|
# Bootstrapping a local development environment |
||||
|
|
||||
|
The first concern a software developer has when working on TimeSafari is to set up a local |
||||
|
development environment. This section will guide you through the process. |
||||
|
|
||||
|
## Prerequisites |
||||
|
|
||||
|
1. Have the following installed on your local machine: |
||||
|
- Node.js and NPM |
||||
|
- A web browser. For this guide, we will use Google Chrome. |
||||
|
- Git |
||||
|
- A code editor |
||||
|
|
||||
|
2. Create an API key on Infura. This is necessary for the Endorser API to connect to the Ethereum |
||||
|
blockchain. |
||||
|
- You can create an account on Infura [here](https://infura.io/).\ |
||||
|
Click "CREATE NEW API KEY" and label the key. Then click "API Keys" in the top menu bar to |
||||
|
be taken back to the list of keys. |
||||
|
|
||||
|
Click "VIEW STATS" on the key you want to use. |
||||
|
|
||||
|
![](images/01_infura-api-keys.png){ width=550px } |
||||
|
|
||||
|
- Go to the key detail page. Then click "MANAGE API KEY". |
||||
|
|
||||
|
![](images/02-infura-key-detail.png){ width=550px } |
||||
|
|
||||
|
- Click the copy and paste button next to the string of alphanumeric characters.\ |
||||
|
This is your API, also known as your project ID. |
||||
|
|
||||
|
![](images/03-infura-api-key-id.png){width=550px } |
||||
|
|
||||
|
- Save this for later during the Endorser API setup. This will go in your `INFURA_PROJECT_ID` |
||||
|
environment variable. |
||||
|
|
||||
|
|
||||
|
## Setup steps |
||||
|
|
||||
|
### 1. Clone the following repositories from their respective Git hosts: |
||||
|
- [TimeSafari Frontend](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa)\ |
||||
|
This is a Progressive Web App (PWA) built with VueJS and TypeScript. |
||||
|
Note that the clone command here is different from the one you would use for GitHub. |
||||
|
|
||||
|
```bash |
||||
|
git clone git clone \ |
||||
|
ssh://git@gitea.anomalistdesign.com:222/trent_larson/crowd-funder-for-time-pwa.git |
||||
|
``` |
||||
|
|
||||
|
- [TimeSafari Backend - Endorser API](https://github.com/trentlarson/endorser-ch)\ |
||||
|
This is a NodeJS service providing the backend for TimeSafari. |
||||
|
|
||||
|
```bash |
||||
|
git clone git@github.com:trentlarson/endorser-ch.git |
||||
|
``` |
||||
|
|
||||
|
\pagebreak |
||||
|
|
||||
|
### 2. Database creation |
||||
|
|
||||
|
#### Alternative 1 - use test data |
||||
|
|
||||
|
To generate a development database and perform user setup you can run a local test with instructions |
||||
|
below to generate sample data. Then copy the test database, rename it to `-dev` as below:\ |
||||
|
`cp ../endorser-ch-test-local.sqlite3 ../endorser-ch-dev.sqlite3` \ |
||||
|
and rerun `npm run dev` to give yourself user #0 and others from the ETHR_CRED_DATA in [the endorser.ch test util file](https://github.com/trentlarson/endorser-ch/blob/master/test/util.js#L90) |
||||
|
|
||||
|
#### Alternative 2 - boostrap single seed user |
||||
|
|
||||
|
In this method you will end up with two accounts in the database, one for the first boostrap user, |
||||
|
and the second as the primary user you will use during testing. The first user will invite the |
||||
|
second user to the app. |
||||
|
|
||||
|
1. Install dependencies and environment variables.\ |
||||
|
In endorser-ch install dependencies and set up environment variables to allow starting it up in |
||||
|
development mode. |
||||
|
```bash |
||||
|
cd endorser-ch |
||||
|
npm clean install # or npm ci |
||||
|
cp .env.local .env |
||||
|
``` |
||||
|
Edit the .env file's INFURA_PROJECT_ID with the value you saved earlier in the |
||||
|
prerequisites.\ |
||||
|
Then create the SQLite database by running `npm run flyway migrate` with environment variables |
||||
|
set correctly to select the default SQLite development user as follows. |
||||
|
```bash |
||||
|
export NODE_ENV=dev |
||||
|
export DBUSER=sa |
||||
|
export DBPASS=sasa |
||||
|
npm run flyway migrate |
||||
|
``` |
||||
|
The first run of flyway migrate may take some time to complete because the entire Flyway |
||||
|
distribution must be downloaded prior to executing migrations. |
||||
|
|
||||
|
Successful output looks similar to the following: |
||||
|
|
||||
|
``` |
||||
|
Database: jdbc:sqlite:../endorser-ch-dev.sqlite3 (SQLite 3.41) |
||||
|
Schema history table "main"."flyway_schema_history" does not exist yet |
||||
|
Successfully validated 10 migrations (execution time 00:00.034s) |
||||
|
Creating Schema History table "main"."flyway_schema_history" ... |
||||
|
Current version of schema "main": << Empty Schema >> |
||||
|
Migrating schema "main" to version "1 - initial-anew" |
||||
|
Migrating schema "main" to version "2 - registration" |
||||
|
Migrating schema "main" to version "3 - plan project" |
||||
|
Migrating schema "main" to version "4 - offer gave" |
||||
|
Migrating schema "main" to version "5 - more confirmations" |
||||
|
Migrating schema "main" to version "6 - providers urls" |
||||
|
Migrating schema "main" to version "7 - hash nonce" |
||||
|
Migrating schema "main" to version "8 - project location" |
||||
|
Migrating schema "main" to version "9 - plan links" |
||||
|
Migrating schema "main" to version "10 - gift or trade" |
||||
|
Successfully applied 10 migrations to schema "main", now at version v10 (execution time 00:00.043s) |
||||
|
A Flyway report has been generated here: /Users/kbull/code/timesafari/endorser-ch/report.html |
||||
|
``` |
||||
|
|
||||
|
\pagebreak |
||||
|
|
||||
|
2. Generate the first user in TimeSafari PWA and bootstrap that user in Endorser's database.\ |
||||
|
As TimeSafari is an invite-only platform the first user must be manually bootstrapped since |
||||
|
no other users exist to be able to invite the first user. This first user must be added manually |
||||
|
to the SQLite database used by Endorser. In this setup you generate the first user from the PWA. |
||||
|
|
||||
|
This user is automatically generated on first usage of the TimeSafari PWA. Bootstrapping that |
||||
|
user is required so that this first user can register other users. |
||||
|
- Change directories into `crowd-funder-for-time-pwa` |
||||
|
|
||||
|
```bash |
||||
|
cd .. |
||||
|
cd crowd-funder-for-time-pwa |
||||
|
``` |
||||
|
|
||||
|
- Ensure the `.env.development` file exists and has the following values: |
||||
|
|
||||
|
```env |
||||
|
VITE_DEFAULT_ENDORSER_API_SERVER=http://127.0.0.1:3000 |
||||
|
``` |
||||
|
|
||||
|
- Install dependencies and run in dev mode. For now don't worry about configuring the app. All we |
||||
|
need is to generate the first root user and this happens automatically on app startup. |
||||
|
|
||||
|
```bash |
||||
|
npm clean install # or npm ci |
||||
|
npm run dev |
||||
|
``` |
||||
|
|
||||
|
- Open the app in a browser and go to the developer tools. It is recommended to use a completely |
||||
|
separate browser profile so you do not clear out your existing user account. We will be |
||||
|
completely resetting the PWA app state prior to generating the first user. |
||||
|
|
||||
|
In the Developer Tools go to the Application tab. |
||||
|
|
||||
|
![](images/04-pwa-chrome-devtools.png){width=350px} |
||||
|
|
||||
|
Click the "Clear site data" button and then refresh the page. |
||||
|
|
||||
|
- Click the account button in the bottom right corner of the page. |
||||
|
|
||||
|
![](images/05-pwa-account-button.png){width=150px} |
||||
|
|
||||
|
- This will take you to the account page titled "Your Identity" on which you can see your DID, |
||||
|
a `did:ethr` DID in this case. |
||||
|
|
||||
|
![](images/06-pwa-account-page.png){width=350px} |
||||
|
|
||||
|
- Copy the DID by selecting it and copying it to the clipboard or by clicking the copy and paste |
||||
|
button as shown in the image. |
||||
|
|
||||
|
![](images/07-pwa-did-copied.png){width=200px} |
||||
|
|
||||
|
In our case this DID is:\ |
||||
|
`did:ethr:0xe4B783c74c8B0e229524e44d0cD898D272E02CD6` |
||||
|
|
||||
|
- Add that DID to the following echoed SQL statement where it says `YOUR_DID` |
||||
|
|
||||
|
```bash |
||||
|
echo "INSERT INTO registration (did, maxClaims, maxRegs, epoch) |
||||
|
VALUES ('YOUR_DID', 100, 10000, 1719348718092);" |
||||
|
| sqlite3 ./endorser-ch-dev.sqlite3 |
||||
|
``` |
||||
|
|
||||
|
and run this command in the parent directory just above the `endorser-ch` directory. |
||||
|
|
||||
|
It needs to be the parent directory of your `endorser-ch` repository because when |
||||
|
`endorser-ch` creates the SQLite database it depends on it creates it in the parent directory |
||||
|
of `endorser-ch`. |
||||
|
|
||||
|
- You can verify with an SQL browser tool that your record has been added to the `registration` |
||||
|
table. |
||||
|
|
||||
|
![](images/08-endorser-sqlite-row-added.png){width=350px} |
||||
|
|
||||
|
3. Then start the Endorser service in development mode with the following commands. |
||||
|
|
||||
|
```bash |
||||
|
cd ./endorser-ch |
||||
|
export NODE_ENV=dev |
||||
|
npm run dev |
||||
|
``` |
||||
|
|
||||
|
This starts the Endorser service on port 3000. |
||||
|
4. Create the second user by opening up a separate browser profile or incognito session, opening the |
||||
|
TimeSafari PWA at `http://localhost:8080`. You will see the yellow banner stating "Someone must |
||||
|
register you before you can give or offer." |
||||
|
|
||||
|
![](images/09-pwa-second-profile-first-open.png){width=350px} |
||||
|
|
||||
|
- If you want to ensure you have a fresh user account then open the developer tools, clear the |
||||
|
Application data as before, and then refresh the page. This will generate a new user in the |
||||
|
browser's IndexedDB database. |
||||
|
5. Go to the second users' account page to copy the DID. |
||||
|
|
||||
|
![](images/10-pwa-second-user-did.png){width=350px} |
||||
|
|
||||
|
6. Copy the DID and put it in the text bar on the "Your Contacts" page for the first account |
||||
|
|
||||
|
![](images/11-pwa-first-user-add-contact.png){width=350px} |
||||
|
|
||||
|
7. Click the "+" plus icon to add the user. |
||||
|
|
||||
|
![](images/12-pwa-first-user-contact-added.png){width=350px} |
||||
|
|
||||
|
8. Then click the register button to register the second user. |
||||
|
|
||||
|
![](images/13-pwa-first-user-register-second-user-btn.png){width=350px} |
||||
|
|
||||
|
9. Click "YES" on the dialog that shows up. |
||||
|
|
||||
|
![](images/14-pwa-first-user-register-yes.png){width=350px} |
||||
|
|
||||
|
After this a notification will pop up indicating whether registration was successful or not. |
||||
|
|
||||
|
10. You have finished the initial set up of users. |
@ -0,0 +1,17 @@ |
|||||
|
<!DOCTYPE html> |
||||
|
<html lang=""> |
||||
|
<head> |
||||
|
<meta charset="utf-8"> |
||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge"> |
||||
|
<meta name="viewport" content="width=device-width,initial-scale=1.0"> |
||||
|
<link rel="icon" href="/favicon.ico"> |
||||
|
<title>TimeSafari</title> |
||||
|
</head> |
||||
|
<body> |
||||
|
<noscript> |
||||
|
<strong>We're sorry but TimeSafari doesn't work properly without JavaScript enabled. Please enable it to continue.</strong> |
||||
|
</noscript> |
||||
|
<div id="app"></div> |
||||
|
<script type="module" src="/src/main.ts"></script> |
||||
|
</body> |
||||
|
</html> |
@ -1,91 +1,103 @@ |
|||||
{ |
{ |
||||
"name": "TimeSafari_Test", |
"name": "TimeSafari", |
||||
"version": "0.2.4", |
"version": "0.3.33", |
||||
"private": true, |
|
||||
"scripts": { |
"scripts": { |
||||
"serve": "vue-cli-service serve", |
"dev": "vite", |
||||
"build": "vue-cli-service build", |
"serve": "vite preview", |
||||
"lint": "vue-cli-service lint" |
"build": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build", |
||||
|
"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", |
||||
|
"test-local": "npx playwright test -c playwright.config-local.ts --trace on", |
||||
|
"test-all": "npm run build && npx playwright test -c playwright.config-local.ts --trace on" |
||||
}, |
}, |
||||
"dependencies": { |
"dependencies": { |
||||
|
"@dicebear/collection": "^5.4.1", |
||||
|
"@dicebear/core": "^5.4.1", |
||||
"@ethersproject/hdnode": "^5.7.0", |
"@ethersproject/hdnode": "^5.7.0", |
||||
"@fortawesome/fontawesome-svg-core": "^6.4.2", |
"@fortawesome/fontawesome-svg-core": "^6.5.1", |
||||
"@fortawesome/free-solid-svg-icons": "^6.4.2", |
"@fortawesome/free-solid-svg-icons": "^6.5.1", |
||||
"@fortawesome/vue-fontawesome": "^3.0.3", |
"@fortawesome/vue-fontawesome": "^3.0.6", |
||||
|
"@peculiar/asn1-ecc": "^2.3.8", |
||||
|
"@peculiar/asn1-schema": "^2.3.8", |
||||
"@pvermeer/dexie-encrypted-addon": "^3.0.0", |
"@pvermeer/dexie-encrypted-addon": "^3.0.0", |
||||
"@tweenjs/tween.js": "^21.0.0", |
"@simplewebauthn/browser": "^10.0.0", |
||||
"@types/js-yaml": "^4.0.9", |
"@simplewebauthn/server": "^10.0.0", |
||||
"@veramo/core": "^5.4.1", |
"@tweenjs/tween.js": "^21.1.1", |
||||
"@veramo/credential-w3c": "^5.4.1", |
"@veramo/core": "^5.6.0", |
||||
"@veramo/data-store": "^5.4.1", |
"@veramo/credential-w3c": "^5.6.0", |
||||
"@veramo/did-manager": "^5.4.1", |
"@veramo/data-store": "^5.6.0", |
||||
"@veramo/did-provider-ethr": "^5.4.1", |
"@veramo/did-manager": "^5.6.0", |
||||
"@veramo/did-resolver": "^5.4.1", |
"@veramo/did-provider-ethr": "^5.6.0", |
||||
"@veramo/key-manager": "^5.4.1", |
"@veramo/did-provider-peer": "^6.0.0", |
||||
"@vueuse/core": "^10.4.1", |
"@veramo/did-resolver": "^5.6.0", |
||||
|
"@veramo/key-manager": "^5.6.0", |
||||
|
"@vueuse/core": "^10.9.0", |
||||
"@zxing/text-encoding": "^0.9.0", |
"@zxing/text-encoding": "^0.9.0", |
||||
"axios": "^1.5.0", |
"asn1-ber": "^1.2.2", |
||||
"buffer": "^6.0.3", |
"axios": "^1.6.8", |
||||
|
"cbor-x": "^1.5.9", |
||||
"class-transformer": "^0.5.1", |
"class-transformer": "^0.5.1", |
||||
"core-js": "^3.32.1", |
"dexie": "^3.2.7", |
||||
"dexie": "^3.2.4", |
"dexie-export-import": "^4.1.1", |
||||
"dexie-export-import": "^4.0.7", |
"did-jwt": "^7.4.7", |
||||
"did-jwt": "^7.2.7", |
"did-resolver": "^4.1.0", |
||||
"ethereum-cryptography": "^2.1.2", |
"ethereum-cryptography": "^2.1.3", |
||||
"ethereumjs-util": "^7.1.5", |
"ethereumjs-util": "^7.1.5", |
||||
"ethr-did-resolver": "^8.1.2", |
|
||||
"git-describe": "^4.1.1", |
|
||||
"jdenticon": "^3.2.0", |
"jdenticon": "^3.2.0", |
||||
"js-generate-password": "^0.1.9", |
"js-generate-password": "^0.1.9", |
||||
"js-yaml": "^4.1.0", |
"js-yaml": "^4.1.0", |
||||
"localstorage-slim": "^2.5.0", |
"localstorage-slim": "^2.7.0", |
||||
"luxon": "^3.4.3", |
"lru-cache": "^10.2.0", |
||||
|
"luxon": "^3.4.4", |
||||
"merkletreejs": "^0.3.11", |
"merkletreejs": "^0.3.11", |
||||
"moment": "^2.29.4", |
"nostr-tools": "^2.7.2", |
||||
"notiwind": "^2.0.2", |
"notiwind": "^2.0.2", |
||||
"papaparse": "^5.4.1", |
"papaparse": "^5.4.1", |
||||
"pina": "^0.20.2204228", |
"pina": "^0.20.2204228", |
||||
"pinia-plugin-persistedstate": "^3.2.0", |
"pinia-plugin-persistedstate": "^3.2.1", |
||||
"qr-code-generator-vue3": "^1.4.21", |
"qr-code-generator-vue3": "^1.4.21", |
||||
"ramda": "^0.29.0", |
"ramda": "^0.29.1", |
||||
"readable-stream": "^4.4.2", |
"readable-stream": "^4.5.2", |
||||
"reflect-metadata": "^0.1.13", |
"reflect-metadata": "^0.1.14", |
||||
"register-service-worker": "^1.7.2", |
"register-service-worker": "^1.7.2", |
||||
|
"simple-vue-camera": "^1.1.3", |
||||
"three": "^0.156.1", |
"three": "^0.156.1", |
||||
"ua-parser-js": "^1.0.37", |
"ua-parser-js": "^1.0.37", |
||||
"util": "^0.12.5", |
"util": "^0.12.5", |
||||
"vue": "^3.3.4", |
"vue": "^3.4.21", |
||||
"vue-axios": "^3.5.2", |
"vue-axios": "^3.5.2", |
||||
"vue-facing-decorator": "^3.0.2", |
"vue-facing-decorator": "^3.0.4", |
||||
"vue-qrcode-reader": "^5.4.1", |
"vue-picture-cropper": "^0.7.0", |
||||
"vue-router": "^4.2.4", |
"vue-qrcode-reader": "^5.5.3", |
||||
|
"vue-router": "^4.3.0", |
||||
"web-did-resolver": "^2.0.27" |
"web-did-resolver": "^2.0.27" |
||||
}, |
}, |
||||
"devDependencies": { |
"devDependencies": { |
||||
"@types/leaflet": "^1.9.4", |
"@playwright/test": "^1.45.2", |
||||
"@types/ramda": "^0.29.3", |
"@types/js-yaml": "^4.0.9", |
||||
|
"@types/leaflet": "^1.9.8", |
||||
|
"@types/luxon": "^3.4.2", |
||||
|
"@types/node": "^20.14.11", |
||||
|
"@types/ramda": "^0.29.11", |
||||
"@types/three": "^0.155.1", |
"@types/three": "^0.155.1", |
||||
"@types/ua-parser-js": "^0.7.39", |
"@types/ua-parser-js": "^0.7.39", |
||||
"@typescript-eslint/eslint-plugin": "^6.6.0", |
"@typescript-eslint/eslint-plugin": "^6.21.0", |
||||
"@typescript-eslint/parser": "^6.6.0", |
"@typescript-eslint/parser": "^6.21.0", |
||||
|
"@vitejs/plugin-vue": "^5.0.4", |
||||
"@vue-leaflet/vue-leaflet": "^0.10.1", |
"@vue-leaflet/vue-leaflet": "^0.10.1", |
||||
"@vue/cli-plugin-babel": "~5.0.8", |
|
||||
"@vue/cli-plugin-eslint": "~5.0.8", |
|
||||
"@vue/cli-plugin-pwa": "~5.0.8", |
|
||||
"@vue/cli-plugin-router": "~5.0.8", |
|
||||
"@vue/cli-plugin-typescript": "~5.0.8", |
|
||||
"@vue/cli-plugin-vuex": "~5.0.8", |
|
||||
"@vue/cli-service": "~5.0.8", |
|
||||
"@vue/eslint-config-typescript": "^11.0.3", |
"@vue/eslint-config-typescript": "^11.0.3", |
||||
"autoprefixer": "^10.4.15", |
"autoprefixer": "^10.4.19", |
||||
"eslint": "^8.53.0", |
"eslint": "^8.57.0", |
||||
"eslint-config-prettier": "^9.0.0", |
"eslint-config-prettier": "^9.1.0", |
||||
"eslint-plugin-prettier": "^5.0.0", |
"eslint-plugin-prettier": "^5.1.3", |
||||
"eslint-plugin-vue": "^9.17.0", |
"eslint-plugin-vue": "^9.23.0", |
||||
"leaflet": "^1.9.4", |
"leaflet": "^1.9.4", |
||||
"postcss": "^8.4.29", |
"postcss": "^8.4.38", |
||||
"prettier": "^3.1.0", |
"prettier": "^3.2.5", |
||||
"tailwindcss": "^3.3.3", |
"tailwindcss": "^3.4.1", |
||||
"typescript": "~5.2.2" |
"typescript": "~5.2.2", |
||||
|
"vite": "^5.2.0", |
||||
|
"vite-plugin-pwa": "^0.19.8" |
||||
} |
} |
||||
} |
} |
||||
|
@ -0,0 +1,98 @@ |
|||||
|
import { defineConfig, devices } from "@playwright/test"; |
||||
|
|
||||
|
/** |
||||
|
* Read environment variables from file. |
||||
|
* https://github.com/motdotla/dotenv
|
||||
|
*/ |
||||
|
// import dotenv from 'dotenv';
|
||||
|
// dotenv.config({ path: path.resolve(__dirname, '.env') });
|
||||
|
|
||||
|
/** |
||||
|
* See https://playwright.dev/docs/test-configuration.
|
||||
|
*/ |
||||
|
export default defineConfig({ |
||||
|
testDir: "./test-playwright", |
||||
|
/* Run tests in files in parallel */ |
||||
|
fullyParallel: true, |
||||
|
/* Fail the build on CI if you accidentally left test.only in the source code. */ |
||||
|
forbidOnly: !!process.env.CI, |
||||
|
/* Retry on CI only */ |
||||
|
retries: process.env.CI ? 2 : 0, |
||||
|
/* Opt out of parallel tests on CI. */ |
||||
|
workers: process.env.CI ? 1 : undefined, |
||||
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */ |
||||
|
reporter: "html", |
||||
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ |
||||
|
use: { |
||||
|
/* Base URL to use in actions like `await page.goto('/')`. */ |
||||
|
baseURL: "http://localhost:8080", |
||||
|
|
||||
|
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ |
||||
|
trace: "on-first-retry", |
||||
|
}, |
||||
|
|
||||
|
/* Configure projects for major browsers */ |
||||
|
projects: [ |
||||
|
{ |
||||
|
name: "chromium", |
||||
|
use: { |
||||
|
...devices["Desktop Chrome"], |
||||
|
permissions: ["clipboard-read"], |
||||
|
}, |
||||
|
}, |
||||
|
|
||||
|
{ |
||||
|
name: "firefox", |
||||
|
use: { ...devices["Desktop Firefox"] }, |
||||
|
}, |
||||
|
|
||||
|
{ |
||||
|
name: "webkit", |
||||
|
use: { ...devices["Desktop Safari"] }, |
||||
|
}, |
||||
|
|
||||
|
/* Test against mobile viewports. */ |
||||
|
{ |
||||
|
name: "Mobile Chrome", |
||||
|
use: { ...devices["Pixel 5"] }, |
||||
|
}, |
||||
|
{ |
||||
|
name: "Mobile Safari", |
||||
|
use: { ...devices["iPhone 12"] }, |
||||
|
}, |
||||
|
|
||||
|
/* Test against branded browsers. */ |
||||
|
// {
|
||||
|
// name: 'Microsoft Edge',
|
||||
|
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
|
||||
|
// },
|
||||
|
{ |
||||
|
name: "Google Chrome", |
||||
|
use: { ...devices["Desktop Chrome"], channel: "chrome" }, |
||||
|
}, |
||||
|
], |
||||
|
|
||||
|
/* Configure global timeout; default is 30000 milliseconds */ |
||||
|
// the image upload will often not succeed at 5 seconds
|
||||
|
// timeout: 10000,
|
||||
|
|
||||
|
/* Run your local dev server before starting the tests */ |
||||
|
/** |
||||
|
* This could be an array of servers, meaning we could start the Endorser server as well: |
||||
|
* { |
||||
|
* command: "cd ../endorser-ch; NODE_ENV=test-local npm run dev", |
||||
|
* url: 'http://localhost:3000', |
||||
|
* reuseExistingServer: !process.env.CI, |
||||
|
* }, |
||||
|
* |
||||
|
* But if we do then the testInfo.config.webServer is null and the API-setting test 00 fails. |
||||
|
* It is worth considering a change such that Time Safari's default Endorser API server is NOT set |
||||
|
* in the user's settings so that it can be blanked out and the default is used. |
||||
|
*/ |
||||
|
webServer: { |
||||
|
command: |
||||
|
"VITE_APP_SERVER=http://localhost:8080 VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000 VITE_PASSKEYS_ENABLED=true npm run dev", |
||||
|
url: "http://localhost:8080", |
||||
|
reuseExistingServer: !process.env.CI, |
||||
|
}, |
||||
|
}); |
@ -0,0 +1,82 @@ |
|||||
|
import { defineConfig, devices } from '@playwright/test'; |
||||
|
|
||||
|
/** |
||||
|
* Read environment variables from file. |
||||
|
* https://github.com/motdotla/dotenv
|
||||
|
*/ |
||||
|
// import dotenv from 'dotenv';
|
||||
|
// dotenv.config({ path: path.resolve(__dirname, '.env') });
|
||||
|
|
||||
|
/** |
||||
|
* See https://playwright.dev/docs/test-configuration.
|
||||
|
*/ |
||||
|
export default defineConfig({ |
||||
|
testDir: './test-playwright', |
||||
|
/* Run tests in files in parallel */ |
||||
|
fullyParallel: true, |
||||
|
/* Fail the build on CI if you accidentally left test.only in the source code. */ |
||||
|
forbidOnly: !!process.env.CI, |
||||
|
/* Retry on CI only */ |
||||
|
retries: process.env.CI ? 2 : 0, |
||||
|
/* Opt out of parallel tests on CI. */ |
||||
|
workers: process.env.CI ? 1 : undefined, |
||||
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */ |
||||
|
reporter: 'html', |
||||
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ |
||||
|
use: { |
||||
|
/* Base URL to use in actions like `await page.goto('/')`. */ |
||||
|
baseURL: 'https://test.timesafari.app', |
||||
|
|
||||
|
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ |
||||
|
trace: 'on-first-retry', |
||||
|
}, |
||||
|
|
||||
|
/* Configure projects for major browsers */ |
||||
|
projects: [ |
||||
|
{ |
||||
|
name: 'chromium', |
||||
|
use: { |
||||
|
...devices['Desktop Chrome'], |
||||
|
permissions: ["clipboard-read"], |
||||
|
}, |
||||
|
}, |
||||
|
|
||||
|
{ |
||||
|
name: 'firefox', |
||||
|
use: { ...devices['Desktop Firefox'] }, |
||||
|
}, |
||||
|
|
||||
|
{ |
||||
|
name: 'webkit', |
||||
|
use: { ...devices['Desktop Safari'] }, |
||||
|
}, |
||||
|
|
||||
|
/* Test against mobile viewports. */ |
||||
|
{ |
||||
|
name: 'Mobile Chrome', |
||||
|
use: { ...devices['Pixel 5'] }, |
||||
|
}, |
||||
|
{ |
||||
|
name: 'Mobile Safari', |
||||
|
use: { ...devices['iPhone 12'] }, |
||||
|
}, |
||||
|
|
||||
|
/* Test against branded browsers. */ |
||||
|
// {
|
||||
|
// name: 'Microsoft Edge',
|
||||
|
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
|
||||
|
// },
|
||||
|
// {
|
||||
|
// name: 'Google Chrome',
|
||||
|
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
|
||||
|
// },
|
||||
|
], |
||||
|
|
||||
|
/* Run your local dev server before starting the tests */ |
||||
|
// webServer: {
|
||||
|
// command:
|
||||
|
// "VITE_PASSKEYS_ENABLED=true VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000 npm run dev",
|
||||
|
// url: "http://localhost:8080",
|
||||
|
// reuseExistingServer: !process.env.CI,
|
||||
|
// },
|
||||
|
}); |
@ -1,122 +1,4 @@ |
|||||
|
|
||||
tasks: |
tasks : |
||||
|
|
||||
- add registration step to onboard help |
- This list has moved - see https://sharing.clickup.com/9014278710/l/h/6-901402735154-1/685f1be3f9b150d |
||||
- update dependencies, especially Veramo |
|
||||
|
|
||||
- record donations vs gives |
|
||||
- deploy & migrate |
|
||||
- in mobile - change give & fulfills to array of objects? |
|
||||
- update docs |
|
||||
|
|
||||
- show VC details... somehow: |
|
||||
- 01 show my VCs - most interesting, or via search |
|
||||
- 04 allow user to download & prove chains of VCs, mine + ones I can see about me from others |
|
||||
|
|
||||
- on gives feed - link to project |
|
||||
- show feed of offers, new projects, etc -- maybe limited to my search area |
|
||||
|
|
||||
- revenue |
|
||||
|
|
||||
- copy button for seed |
|
||||
- .5 If notifications are not enabled, add message to front page with link/button to enable |
|
||||
- make server endpoint for full English description of limits |
|
||||
- make identicons for contacts into more-memorable faces (and maybe change project identicons, too) |
|
||||
- create a help-desk document & add screenshots |
|
||||
|
|
||||
- 01 server - show all claim details when issued by the issuer |
|
||||
- .1 update "offer" units to have same functionality as "give" units |
|
||||
- on home page, prompt for install check in addition to "supports notifications" check (since they won't get notified if Chrome is closed) |
|
||||
- 01 on Mac (& Windows?) desktop, add a help blurb so that they can find it again (since it doesn't show in Application list) |
|
||||
- bug - got error adding on Firefox user #0 as contact for themselves |
|
||||
- bug (that is hard to reproduce) - back-and-forth on discovery & project pages led to "You need an identity to load your projects." error on product page when I had an identity |
|
||||
- bug (that is hard to reproduce) - in Chrome, install app then delete app and try from Chrome browser and see log errors "Uncaught TypeError: self.appendDailyLog is not a function" |
|
||||
- bug (that is hard to reproduce) - on the second 'give' recorded on prod it showed me as the agent |
|
||||
- 01 send visibility signal as a VC and store it |
|
||||
- 04 remove 'rowid' references (that are sqlite-specific); may involve server |
|
||||
- 04 look at other examples for better UI, eg friend.tech |
|
||||
- .5 Add inactive flag / end date, start date to project |
|
||||
- .3 check that Android shows "back" buttons on screens without bottom tray |
|
||||
- .1 Make give description text box into something that expands as they type? |
|
||||
- .2 Show a warning if both giver and recipient are the same (but still allow?) |
|
||||
- 01 Would it look better to shrink the buttons on many pages so they don't expand to the width of the screen? assignee-group:ui |
|
||||
- .5 Display a more appealing confirmation on the map when erasing the marker |
|
||||
- .5 remove references to localStorage for projectId (now that it's pulling from the path) |
|
||||
- switch some checks for activeDid to check for isRegistered |
|
||||
- .2 in SeedBackupView, don't load the mnemonic and keep it in memory; only load it when they click "show" |
|
||||
- .5 fix cert generation on server (since it didn't happen automatically for Nov 30) |
|
||||
- .1 remove 2 second setTimeout in NewEditProjectView.vue |
|
||||
- warn if they're using the web (android only?) |
|
||||
https://developer.mozilla.org/en-US/docs/Web/API/Navigator/getInstalledRelatedApps |
|
||||
https://web.dev/articles/get-installed-related-apps |
|
||||
- .5 fix masked icon (because some of the top-right of the binoculars is cut off) |
|
||||
|
|
||||
- contacts v+ : |
|
||||
- 01 Import all the non-sensitive data (ie. contacts & settings). |
|
||||
- .2 show error to user when adding a duplicate contact |
|
||||
- 01 parse input more robustly (with CSV lib and not commas) |
|
||||
|
|
||||
- stats v1 : |
|
||||
- 01 show numeric stats |
|
||||
- 04 show different graphic for projects vs people (gnome?) on world |
|
||||
- 01 link to world for specific stats |
|
||||
- .5 don't load another instance of a bush if it already exists |
|
||||
- maybe - allow type annotations in World.js & landmarks.js (since we get this error - "Types are not supported by current JavaScript version") |
|
||||
- 08 convert to cleaner implementation (maybe Drie -- https://github.com/janvorisek/drie) |
|
||||
|
|
||||
- .5 show seed phrase in a QR code for transfer to another device |
|
||||
- .5 on DiscoverView, switch to a filter UI (eg. just from friend |
|
||||
- .5 don't show "Offer" on project screen if they aren't registered |
|
||||
|
|
||||
- 24 Move to Vite |
|
||||
- 32 accept images for projects |
|
||||
- 32 accept images for contacts |
|
||||
- import project interactions from GitHub/GitLab and manage signing |
|
||||
|
|
||||
- show total time offered to & fulfilled to a project |
|
||||
- show total time offered by & fulfilled by a contact |
|
||||
|
|
||||
- linking between projects or plans : |
|
||||
- show total time given to & from a project |
|
||||
- terminology: |
|
||||
- for subtasks: fulfills (is it really the same?), feeds, contributes to, supplies, boosts, advances |
|
||||
- for blocking: blocks, precedes, comes before, is sought by -- vs follows, seeks, builds on ("contributes to" isn't specific enough, "succeeds" has different, possibly confusing meaning) |
|
||||
|
|
||||
- .5 add "back" button to all screens that aren't part of the bottom tray |
|
||||
- .5 fit as many icons as possible on home & project view screens but only going halfway down the page assignee-group:ui |
|
||||
- .5 Replace Gifted/Give in ContactsView with GiftedDialog |
|
||||
|
|
||||
- Stats : |
|
||||
- 01 point out user's location on the world |
|
||||
- 01 present a credential selected from the stats |
|
||||
- 04 show gives spreading to other places |
|
||||
- badge for most gives/receives/confirms per day/week/month |
|
||||
- badge for amount given/offered to your project |
|
||||
- set a goal of given/offers |
|
||||
|
|
||||
- automated tests, eg. cypress |
|
||||
|
|
||||
- Notifications (wake on the phone, push notifications) |
|
||||
- 02 change push server so that the web-push/subscribe call sets up a thread for the 10-seconds-later push notification, but returns immediately to the callee |
|
||||
- pull instead of push, maybe via scheduled runs |
|
||||
- have a notification pop-up on Mac screen |
|
||||
|
|
||||
- Connect with phone contacts |
|
||||
|
|
||||
- Multiple identities |
|
||||
|
|
||||
- Support KERI AIDs |
|
||||
- Support Peer DIDs |
|
||||
- Support messaging through DIDComm |
|
||||
- Write to or read from a different ledger (eg. private ACDC, EAS & attest.sh) |
|
||||
|
|
||||
- Do we want split first name & last name? |
|
||||
|
|
||||
- 01 On nearby search, if user starts changing their box but cancels and goes back to the map it is zoomed far out. Fix to fit the box better. |
|
||||
- 16 From the home screen, make the quick action even easier. |
|
||||
|
|
||||
- allow some gives even if they aren't registered - maybe someday as a gift to the world, but we really want this to be built via personal connections |
|
||||
|
|
||||
log: |
|
||||
- videos for multiple identities https://youtu.be/p8L87AeD76w and for adding time to contacts https://youtu.be/7Yylczevp10 done:2023-03-29 |
|
||||
- project lists, contact totals & actions, multiple identifiers, stats-world, activity feed, rename of this project file (use "--follow --") milestone:2 done:2023-06-27 |
|
||||
|
@ -1,17 +0,0 @@ |
|||||
<!DOCTYPE html> |
|
||||
<html lang=""> |
|
||||
<head> |
|
||||
<meta charset="utf-8"> |
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge"> |
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0"> |
|
||||
<link rel="icon" href="<%= BASE_URL %>favicon.ico"> |
|
||||
<title><%= htmlWebpackPlugin.options.title %></title> |
|
||||
</head> |
|
||||
<body> |
|
||||
<noscript> |
|
||||
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong> |
|
||||
</noscript> |
|
||||
<div id="app"></div> |
|
||||
<!-- built files will be auto injected --> |
|
||||
</body> |
|
||||
</html> |
|
After Width: | Height: | Size: 145 B |
@ -0,0 +1,99 @@ |
|||||
|
<!-- similar to UserNameDialog --> |
||||
|
<template> |
||||
|
<div v-if="visible" class="dialog-overlay"> |
||||
|
<div class="dialog"> |
||||
|
<h1 class="text-xl font-bold text-center mb-4">{{ title }}</h1> |
||||
|
{{ message }} |
||||
|
Note that their name is only stored on this device. |
||||
|
<input |
||||
|
type="text" |
||||
|
placeholder="Name" |
||||
|
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2" |
||||
|
v-model="newText" |
||||
|
/> |
||||
|
|
||||
|
<div class="mt-8"> |
||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2"> |
||||
|
<button |
||||
|
type="button" |
||||
|
class="block w-full text-center text-lg font-bold uppercase 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-3 rounded-md mb-2" |
||||
|
@click="onClickSaveChanges()" |
||||
|
> |
||||
|
Save |
||||
|
</button> |
||||
|
<button |
||||
|
type="button" |
||||
|
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md mb-2" |
||||
|
@click="onClickCancel()" |
||||
|
> |
||||
|
Cancel |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script lang="ts"> |
||||
|
import { Vue, Component } from "vue-facing-decorator"; |
||||
|
|
||||
|
@Component |
||||
|
export default class ContactNameDialog extends Vue { |
||||
|
cancelCallback: () => void = () => {}; |
||||
|
saveCallback: (name?: string) => void = () => {}; |
||||
|
message = ""; |
||||
|
newText = ""; |
||||
|
title = "Contact Name"; |
||||
|
visible = false; |
||||
|
|
||||
|
async open( |
||||
|
title?: string, |
||||
|
message?: string, |
||||
|
saveCallback?: (name: string) => void, |
||||
|
cancelCallback?: () => void, |
||||
|
) { |
||||
|
this.cancelCallback = cancelCallback || this.cancelCallback; |
||||
|
this.saveCallback = saveCallback || this.saveCallback; |
||||
|
this.message = message ?? this.message; |
||||
|
this.title = title ?? this.title; |
||||
|
this.visible = true; |
||||
|
} |
||||
|
|
||||
|
async onClickSaveChanges() { |
||||
|
this.visible = false; |
||||
|
if (this.saveCallback) { |
||||
|
this.saveCallback(this.newText); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
onClickCancel() { |
||||
|
this.visible = false; |
||||
|
if (this.cancelCallback) { |
||||
|
this.cancelCallback(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<style> |
||||
|
.dialog-overlay { |
||||
|
position: fixed; |
||||
|
top: 0; |
||||
|
left: 0; |
||||
|
right: 0; |
||||
|
bottom: 0; |
||||
|
background-color: rgba(0, 0, 0, 0.5); |
||||
|
display: flex; |
||||
|
justify-content: center; |
||||
|
align-items: center; |
||||
|
padding: 1.5rem; |
||||
|
} |
||||
|
|
||||
|
.dialog { |
||||
|
background-color: white; |
||||
|
padding: 1rem; |
||||
|
border-radius: 0.5rem; |
||||
|
width: 100%; |
||||
|
max-width: 500px; |
||||
|
} |
||||
|
</style> |
@ -1,32 +1,41 @@ |
|||||
<template> |
<template> |
||||
<div v-html="generateIdenticon()" class="w-fit"></div> |
<div v-html="generateIcon()" class="w-fit"></div> |
||||
</template> |
</template> |
||||
<script lang="ts"> |
<script lang="ts"> |
||||
|
import { createAvatar, StyleOptions } from "@dicebear/core"; |
||||
|
import { avataaars } from "@dicebear/collection"; |
||||
import { Vue, Component, Prop } from "vue-facing-decorator"; |
import { Vue, Component, Prop } from "vue-facing-decorator"; |
||||
import { toSvg } from "jdenticon"; |
import { Contact } from "@/db/tables/contacts"; |
||||
|
|
||||
const BLANK_CONFIG = { |
|
||||
lightness: { |
|
||||
color: [1.0, 1.0], |
|
||||
grayscale: [1.0, 1.0], |
|
||||
}, |
|
||||
saturation: { |
|
||||
color: 0.0, |
|
||||
grayscale: 0.0, |
|
||||
}, |
|
||||
backColor: "#0000", |
|
||||
}; |
|
||||
|
|
||||
@Component |
@Component |
||||
export default class EntityIcon extends Vue { |
export default class EntityIcon extends Vue { |
||||
@Prop entityId = ""; |
@Prop contact: Contact; |
||||
|
@Prop entityId = ""; // overridden by contact.did or profileImageUrl |
||||
@Prop iconSize = 0; |
@Prop iconSize = 0; |
||||
|
@Prop profileImageUrl = ""; // overridden by contact.profileImageUrl |
||||
|
|
||||
generateIdenticon() { |
generateIcon() { |
||||
const config = this.entityId ? undefined : BLANK_CONFIG; |
const imageUrl = this.contact?.profileImageUrl || this.profileImageUrl; |
||||
const svgString = toSvg(this.entityId, this.iconSize, config); |
if (imageUrl) { |
||||
|
return `<img src="${imageUrl}" class="rounded" width="${this.iconSize}" height="${this.iconSize}" />`; |
||||
|
} else { |
||||
|
const identifier = this.contact?.did || this.entityId; |
||||
|
if (!identifier) { |
||||
|
return `<img src="../src/assets/blank-square.svg" class="rounded" width="${this.iconSize}" height="${this.iconSize}" />`; |
||||
|
} |
||||
|
// https://api.dicebear.com/8.x/avataaars/svg?seed= |
||||
|
// ... does not render things with the same seed as this library. |
||||
|
// "did:ethr:0x222BB77E6Ff3774d34c751f3c1260866357B677b" yields a girl with flowers in her hair and a lightning earring |
||||
|
// ... which looks similar to '' at the dicebear site but which is different. |
||||
|
const options: StyleOptions<object> = { |
||||
|
seed: (identifier as string) || "", |
||||
|
size: this.iconSize, |
||||
|
}; |
||||
|
const avatar = createAvatar(avataaars, options); |
||||
|
const svgString = avatar.toString(); |
||||
return svgString; |
return svgString; |
||||
} |
} |
||||
|
} |
||||
} |
} |
||||
</script> |
</script> |
||||
<style scoped></style> |
<style scoped></style> |
||||
|
@ -0,0 +1,218 @@ |
|||||
|
<template> |
||||
|
<div v-if="visible" id="dialogFeedFilters" class="dialog-overlay"> |
||||
|
<div class="dialog"> |
||||
|
<h1 class="text-xl font-bold text-center mb-4">Feed Filters</h1> |
||||
|
|
||||
|
<p class="mb-4 font-bold">Show only activities that…</p> |
||||
|
|
||||
|
<div class="grid grid-cols-1 gap-2"> |
||||
|
<div |
||||
|
class="flex items-center justify-between cursor-pointer" |
||||
|
@click="toggleHasVisibleDid()" |
||||
|
> |
||||
|
<!-- label --> |
||||
|
<div>Include someone visible to me</div> |
||||
|
<!-- toggle --> |
||||
|
<div class="relative ml-2"> |
||||
|
<!-- input --> |
||||
|
<input |
||||
|
type="checkbox" |
||||
|
v-model="hasVisibleDid" |
||||
|
name="toggleFilterFromMyContacts" |
||||
|
class="sr-only" |
||||
|
/> |
||||
|
<!-- line --> |
||||
|
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div> |
||||
|
<!-- dot --> |
||||
|
<div |
||||
|
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition" |
||||
|
></div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<em>or</em> |
||||
|
|
||||
|
<div |
||||
|
class="flex items-center justify-between cursor-pointer" |
||||
|
@click=" |
||||
|
hasSearchBox |
||||
|
? toggleNearby() |
||||
|
: $router.push({ name: 'search-area' }) |
||||
|
" |
||||
|
> |
||||
|
<!-- label --> |
||||
|
<div>Are nearby</div> |
||||
|
<!-- toggle --> |
||||
|
<div v-if="hasSearchBox" class="relative ml-2"> |
||||
|
<!-- input --> |
||||
|
<input |
||||
|
type="checkbox" |
||||
|
v-model="isNearby" |
||||
|
name="toggleFilterNearby" |
||||
|
class="sr-only" |
||||
|
/> |
||||
|
<!-- line --> |
||||
|
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div> |
||||
|
<!-- dot --> |
||||
|
<div |
||||
|
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition" |
||||
|
></div> |
||||
|
</div> |
||||
|
<div v-else class="relative ml-2"> |
||||
|
<button class="ml-2 px-4 py-2 rounded-md bg-blue-200 text-blue-500"> |
||||
|
Select Location |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2 mt-4"> |
||||
|
<button |
||||
|
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md" |
||||
|
@click="setAll()" |
||||
|
> |
||||
|
Set All |
||||
|
</button> |
||||
|
<button |
||||
|
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md" |
||||
|
@click="clearAll()" |
||||
|
> |
||||
|
Clear All |
||||
|
</button> |
||||
|
<button |
||||
|
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md" |
||||
|
@click="done()" |
||||
|
> |
||||
|
Done |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script lang="ts"> |
||||
|
import { Vue, Component } from "vue-facing-decorator"; |
||||
|
import { |
||||
|
LMap, |
||||
|
LMarker, |
||||
|
LRectangle, |
||||
|
LTileLayer, |
||||
|
} from "@vue-leaflet/vue-leaflet"; |
||||
|
|
||||
|
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; |
||||
|
import { db, retrieveSettingsForActiveAccount } from "@/db/index"; |
||||
|
|
||||
|
@Component({ |
||||
|
components: { |
||||
|
LRectangle, |
||||
|
LMap, |
||||
|
LMarker, |
||||
|
LTileLayer, |
||||
|
}, |
||||
|
}) |
||||
|
export default class FeedFilters extends Vue { |
||||
|
onCloseIfChanged = () => {}; |
||||
|
hasSearchBox = false; |
||||
|
hasVisibleDid = false; |
||||
|
isNearby = false; |
||||
|
settingChanged = false; |
||||
|
visible = false; |
||||
|
|
||||
|
async open(onCloseIfChanged: () => void) { |
||||
|
this.onCloseIfChanged = onCloseIfChanged; |
||||
|
|
||||
|
const settings = await retrieveSettingsForActiveAccount(); |
||||
|
this.hasVisibleDid = !!settings.filterFeedByVisible; |
||||
|
this.isNearby = !!settings.filterFeedByNearby; |
||||
|
if (settings.searchBoxes && settings.searchBoxes.length > 0) { |
||||
|
this.hasSearchBox = true; |
||||
|
} |
||||
|
|
||||
|
this.settingChanged = false; |
||||
|
this.visible = true; |
||||
|
} |
||||
|
|
||||
|
async toggleHasVisibleDid() { |
||||
|
this.settingChanged = true; |
||||
|
this.hasVisibleDid = !this.hasVisibleDid; |
||||
|
await db.settings.update(MASTER_SETTINGS_KEY, { |
||||
|
filterFeedByVisible: this.hasVisibleDid, |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
async toggleNearby() { |
||||
|
this.settingChanged = true; |
||||
|
this.isNearby = !this.isNearby; |
||||
|
await db.settings.update(MASTER_SETTINGS_KEY, { |
||||
|
filterFeedByNearby: this.isNearby, |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
async clearAll() { |
||||
|
if (this.hasVisibleDid || this.isNearby) { |
||||
|
this.settingChanged = true; |
||||
|
} |
||||
|
|
||||
|
await db.settings.update(MASTER_SETTINGS_KEY, { |
||||
|
filterFeedByNearby: false, |
||||
|
filterFeedByVisible: false, |
||||
|
}); |
||||
|
|
||||
|
this.hasVisibleDid = false; |
||||
|
this.isNearby = false; |
||||
|
} |
||||
|
|
||||
|
async setAll() { |
||||
|
if (!this.hasVisibleDid || !this.isNearby) { |
||||
|
this.settingChanged = true; |
||||
|
} |
||||
|
|
||||
|
await db.settings.update(MASTER_SETTINGS_KEY, { |
||||
|
filterFeedByNearby: true, |
||||
|
filterFeedByVisible: true, |
||||
|
}); |
||||
|
|
||||
|
this.hasVisibleDid = true; |
||||
|
this.isNearby = true; |
||||
|
} |
||||
|
|
||||
|
close() { |
||||
|
if (this.settingChanged) { |
||||
|
this.onCloseIfChanged(); |
||||
|
} |
||||
|
this.visible = false; |
||||
|
} |
||||
|
|
||||
|
done() { |
||||
|
this.close(); |
||||
|
} |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<style> |
||||
|
.dialog-overlay { |
||||
|
position: fixed; |
||||
|
top: 0; |
||||
|
left: 0; |
||||
|
right: 0; |
||||
|
bottom: 0; |
||||
|
background-color: rgba(0, 0, 0, 0.5); |
||||
|
display: flex; |
||||
|
justify-content: center; |
||||
|
align-items: center; |
||||
|
padding: 1.5rem; |
||||
|
} |
||||
|
|
||||
|
#dialogFeedFilters.dialog-overlay { |
||||
|
z-index: 99999; |
||||
|
overflow: scroll; |
||||
|
} |
||||
|
|
||||
|
.dialog { |
||||
|
background-color: white; |
||||
|
padding: 1rem; |
||||
|
border-radius: 0.5rem; |
||||
|
width: 100%; |
||||
|
max-width: 500px; |
||||
|
} |
||||
|
</style> |
@ -0,0 +1,260 @@ |
|||||
|
<template> |
||||
|
<div v-if="visible" class="dialog-overlay"> |
||||
|
<div class="dialog"> |
||||
|
<h1 class="text-xl font-bold text-center mb-4 relative"> |
||||
|
Here's one: |
||||
|
<div |
||||
|
class="text-lg text-center p-2 leading-none absolute right-0 -top-1" |
||||
|
@click="cancel" |
||||
|
> |
||||
|
<fa icon="xmark" class="w-[1em]"></fa> |
||||
|
</div> |
||||
|
</h1> |
||||
|
<span class="flex justify-between"> |
||||
|
<span |
||||
|
class="rounded-l border border-slate-400 bg-slate-200 px-4 py-2 flex" |
||||
|
@click="prevIdea()" |
||||
|
> |
||||
|
<fa icon="chevron-left" class="m-auto" /> |
||||
|
</span> |
||||
|
|
||||
|
<div class="m-2"> |
||||
|
<span v-if="currentCategory === CATEGORY_IDEAS"> |
||||
|
<p class="text-center text-lg font-bold"> |
||||
|
{{ IDEAS[currentIdeaIndex] }} |
||||
|
</p> |
||||
|
</span> |
||||
|
<div v-if="currentCategory === CATEGORY_CONTACTS"> |
||||
|
<p class="text-center"> |
||||
|
<span |
||||
|
v-if="currentContact == null" |
||||
|
class="text-orange-500 text-lg font-bold" |
||||
|
> |
||||
|
That's all your contacts. |
||||
|
</span> |
||||
|
<span v-else> |
||||
|
<span class="text-lg font-bold"> |
||||
|
Did {{ currentContact.name || AppString.NO_CONTACT_NAME }} |
||||
|
<br /> |
||||
|
or someone near them do anything – maybe a while ago? |
||||
|
</span> |
||||
|
<span class="flex justify-between"> |
||||
|
<span /> |
||||
|
<button |
||||
|
class="text-center bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mt-4" |
||||
|
@click="nextIdeaPastContacts()" |
||||
|
> |
||||
|
Skip Contacts <fa icon="forward" /> |
||||
|
</button> |
||||
|
</span> |
||||
|
</span> |
||||
|
</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<span |
||||
|
class="rounded-r border border-slate-400 bg-slate-200 px-4 py-2 flex" |
||||
|
@click="nextIdea()" |
||||
|
> |
||||
|
<fa icon="chevron-right" class="m-auto" /> |
||||
|
</span> |
||||
|
</span> |
||||
|
<button |
||||
|
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mt-4" |
||||
|
@click="proceed" |
||||
|
> |
||||
|
That's it! |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script lang="ts"> |
||||
|
import { Vue, Component } from "vue-facing-decorator"; |
||||
|
import { Router } from "vue-router"; |
||||
|
|
||||
|
import { AppString, NotificationIface } from "@/constants/app"; |
||||
|
import { db } from "@/db/index"; |
||||
|
import { Contact } from "@/db/tables/contacts"; |
||||
|
import { GiverReceiverInputInfo } from "@/libs/util"; |
||||
|
|
||||
|
@Component |
||||
|
export default class GivenPrompts extends Vue { |
||||
|
$notify!: (notification: NotificationIface, timeout?: number) => void; |
||||
|
|
||||
|
CATEGORY_CONTACTS = 1; |
||||
|
CATEGORY_IDEAS = 0; |
||||
|
IDEAS = [ |
||||
|
"What food did someone fix for you?", |
||||
|
"What did a family member do for you?", |
||||
|
"What compliment did someone give you?", |
||||
|
"Who is someone you can always rely on, and how did they demonstrate that?", |
||||
|
"What did you see someone give to someone else?", |
||||
|
"What is a way that someone helped you even though you have never met?", |
||||
|
"How did a musician or author or artist inspire you?", |
||||
|
"What inspiration did you get from someone who handled tragedy well?", |
||||
|
"What is something worth respect that an organization gave you?", |
||||
|
"Who last gave you a good laugh?", |
||||
|
"What do you recall someone giving you while you were young?", |
||||
|
"Who forgave you or overlooked a mistake?", |
||||
|
"What is a way an ancestor contributed to your life?", |
||||
|
"What kind of help did someone at work give you?", |
||||
|
"How did a teacher or mentor or great example help you?", |
||||
|
]; |
||||
|
|
||||
|
callbackOnFullGiftInfo?: ( |
||||
|
contactInfo?: GiverReceiverInputInfo, |
||||
|
description?: string, |
||||
|
) => void; |
||||
|
currentCategory = this.CATEGORY_IDEAS; // 0 = IDEAS, 1 = CONTACTS |
||||
|
currentContact: Contact | undefined = undefined; |
||||
|
currentIdeaIndex = 0; |
||||
|
numContacts = 0; |
||||
|
shownContactDbIndices: Array<boolean> = []; |
||||
|
visible = false; |
||||
|
|
||||
|
AppString = AppString; |
||||
|
|
||||
|
async open( |
||||
|
callbackOnFullGiftInfo: ( |
||||
|
contactInfo: GiverReceiverInputInfo, |
||||
|
description: string, |
||||
|
) => void, |
||||
|
) { |
||||
|
this.visible = true; |
||||
|
this.callbackOnFullGiftInfo = callbackOnFullGiftInfo; |
||||
|
|
||||
|
await db.open(); |
||||
|
this.numContacts = await db.contacts.count(); |
||||
|
this.shownContactDbIndices = new Array<boolean>(this.numContacts); // all undefined to start |
||||
|
} |
||||
|
|
||||
|
cancel() { |
||||
|
this.currentCategory = this.CATEGORY_IDEAS; |
||||
|
this.currentContact = undefined; |
||||
|
this.currentIdeaIndex = 0; |
||||
|
this.numContacts = 0; |
||||
|
this.shownContactDbIndices = []; |
||||
|
|
||||
|
this.visible = false; |
||||
|
} |
||||
|
|
||||
|
proceed() { |
||||
|
// proceed with logic but don't change values (just in case some actions are added later) |
||||
|
this.visible = false; |
||||
|
if (this.currentCategory === this.CATEGORY_IDEAS) { |
||||
|
(this.$router as Router).push({ |
||||
|
name: "contact-gift", |
||||
|
query: { |
||||
|
prompt: this.IDEAS[this.currentIdeaIndex], |
||||
|
}, |
||||
|
}); |
||||
|
} else { |
||||
|
// must be this.CATEGORY_CONTACTS |
||||
|
this.callbackOnFullGiftInfo?.( |
||||
|
this.currentContact as GiverReceiverInputInfo, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Get the next idea. |
||||
|
* If it is a contact prompt, loop through. |
||||
|
*/ |
||||
|
async nextIdea() { |
||||
|
// check if the next one is an idea or a contact |
||||
|
if (this.currentCategory === this.CATEGORY_IDEAS) { |
||||
|
this.currentIdeaIndex++; |
||||
|
if (this.currentIdeaIndex === this.IDEAS.length) { |
||||
|
// must have just finished ideas so move to contacts |
||||
|
this.findNextUnshownContact(); |
||||
|
} |
||||
|
} else { |
||||
|
// must be this.CATEGORY_CONTACTS |
||||
|
this.findNextUnshownContact(); |
||||
|
// when that's finished, it'll reset to ideas |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Get the previous idea. |
||||
|
* If it is a contact prompt, loop through. |
||||
|
*/ |
||||
|
async prevIdea() { |
||||
|
// check if the next one is an idea or a contact |
||||
|
if (this.currentCategory === this.CATEGORY_IDEAS) { |
||||
|
this.currentIdeaIndex--; |
||||
|
if (this.currentIdeaIndex < 0) { |
||||
|
// must have just finished ideas so move to contacts |
||||
|
this.findNextUnshownContact(); |
||||
|
} |
||||
|
} else { |
||||
|
// must be this.CATEGORY_CONTACTS |
||||
|
this.findNextUnshownContact(); |
||||
|
// when that's finished, it'll reset to ideas |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
nextIdeaPastContacts() { |
||||
|
this.currentContact = undefined; |
||||
|
this.shownContactDbIndices = new Array<boolean>(this.numContacts); |
||||
|
|
||||
|
this.currentCategory = this.CATEGORY_IDEAS; |
||||
|
// look at the previous idea and switch to the other side of the list |
||||
|
this.currentIdeaIndex = |
||||
|
this.currentIdeaIndex >= this.IDEAS.length ? 0 : this.IDEAS.length - 1; |
||||
|
} |
||||
|
|
||||
|
async findNextUnshownContact() { |
||||
|
if (this.currentCategory === this.CATEGORY_IDEAS) { |
||||
|
// we're not in the contact prompts, so reset index array |
||||
|
this.shownContactDbIndices = new Array<boolean>(this.numContacts); |
||||
|
} |
||||
|
this.currentCategory = this.CATEGORY_CONTACTS; |
||||
|
|
||||
|
let someContactDbIndex = Math.floor(Math.random() * this.numContacts); |
||||
|
let count = 0; |
||||
|
// as long as the index has an entry, loop |
||||
|
while ( |
||||
|
this.shownContactDbIndices[someContactDbIndex] != null && |
||||
|
count++ < this.numContacts |
||||
|
) { |
||||
|
someContactDbIndex = (someContactDbIndex + 1) % this.numContacts; |
||||
|
} |
||||
|
if (count >= this.numContacts) { |
||||
|
// all contacts have been shown |
||||
|
this.nextIdeaPastContacts(); |
||||
|
} else { |
||||
|
// get the contact at that offset |
||||
|
await db.open(); |
||||
|
this.currentContact = await db.contacts |
||||
|
.offset(someContactDbIndex) |
||||
|
.first(); |
||||
|
this.shownContactDbIndices[someContactDbIndex] = true; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<style> |
||||
|
.dialog-overlay { |
||||
|
position: fixed; |
||||
|
top: 0; |
||||
|
left: 0; |
||||
|
right: 0; |
||||
|
bottom: 0; |
||||
|
background-color: rgba(0, 0, 0, 0.5); |
||||
|
display: flex; |
||||
|
justify-content: center; |
||||
|
align-items: center; |
||||
|
padding: 1.5rem; |
||||
|
} |
||||
|
|
||||
|
.dialog { |
||||
|
background-color: white; |
||||
|
padding: 1rem; |
||||
|
border-radius: 0.5rem; |
||||
|
width: 100%; |
||||
|
max-width: 500px; |
||||
|
} |
||||
|
</style> |
@ -0,0 +1,177 @@ |
|||||
|
<template> |
||||
|
<div v-if="visible" class="dialog-overlay z-[60]"> |
||||
|
<div class="dialog relative"> |
||||
|
<div class="text-lg text-center font-light relative z-50"> |
||||
|
<div |
||||
|
id="ViewHeading" |
||||
|
class="text-center font-bold absolute top-0 left-0 right-0 px-4 py-0.5 bg-black/50 text-white leading-none" |
||||
|
> |
||||
|
Add Photo |
||||
|
</div> |
||||
|
<div |
||||
|
class="text-lg text-center px-2 py-0.5 leading-none absolute right-0 top-0 text-white" |
||||
|
@click="close()" |
||||
|
> |
||||
|
<fa icon="xmark" class="w-[1em]"></fa> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div> |
||||
|
<div class="text-center mt-8"> |
||||
|
<div class> |
||||
|
<fa |
||||
|
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-md" |
||||
|
@click="openPhotoDialog()" |
||||
|
/> |
||||
|
</div> |
||||
|
<div class="mt-4"> |
||||
|
<input type="file" @change="uploadImageFile" /> |
||||
|
</div> |
||||
|
<div class="mt-4"> |
||||
|
<span class="mt-2"> |
||||
|
... or paste a URL: |
||||
|
<input type="text" v-model="imageUrl" class="border-2" /> |
||||
|
</span> |
||||
|
<span class="ml-2"> |
||||
|
<fa |
||||
|
v-if="imageUrl" |
||||
|
icon="check" |
||||
|
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-md cursor-pointer" |
||||
|
@click="acceptUrl" |
||||
|
/> |
||||
|
<!-- so that there's no shifting when it becomes visible --> |
||||
|
<fa v-else icon="check" class="text-white bg-white px-2 py-2" /> |
||||
|
</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
<PhotoDialog ref="photoDialog" /> |
||||
|
</template> |
||||
|
|
||||
|
<script lang="ts"> |
||||
|
import axios from "axios"; |
||||
|
import { ref } from "vue"; |
||||
|
import { Component, Vue } from "vue-facing-decorator"; |
||||
|
|
||||
|
import PhotoDialog from "@/components/PhotoDialog.vue"; |
||||
|
import { NotificationIface } from "@/constants/app"; |
||||
|
|
||||
|
const inputImageFileNameRef = ref<Blob>(); |
||||
|
|
||||
|
@Component({ |
||||
|
components: { PhotoDialog }, |
||||
|
}) |
||||
|
export default class ImageMethodDialog extends Vue { |
||||
|
$notify!: (notification: NotificationIface, timeout?: number) => void; |
||||
|
|
||||
|
claimType: string; |
||||
|
crop: boolean = false; |
||||
|
imageCallback: (imageUrl?: string) => void = () => {}; |
||||
|
imageUrl?: string; |
||||
|
visible = false; |
||||
|
|
||||
|
open(setImageFn: (arg: string) => void, claimType: string, crop?: boolean) { |
||||
|
this.claimType = claimType; |
||||
|
this.crop = !!crop; |
||||
|
this.imageCallback = setImageFn; |
||||
|
|
||||
|
this.visible = true; |
||||
|
} |
||||
|
|
||||
|
openPhotoDialog(blob?: Blob, fileName?: string) { |
||||
|
this.visible = false; |
||||
|
|
||||
|
(this.$refs.photoDialog as PhotoDialog).open( |
||||
|
this.imageCallback, |
||||
|
this.claimType, |
||||
|
this.crop, |
||||
|
blob, |
||||
|
fileName, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
async uploadImageFile(event: Event) { |
||||
|
this.visible = false; |
||||
|
|
||||
|
inputImageFileNameRef.value = event.target.files[0]; |
||||
|
// https://developer.mozilla.org/en-US/docs/Web/API/File |
||||
|
// ... plus it has a `type` property from my testing |
||||
|
const file = inputImageFileNameRef.value; |
||||
|
if (file != null) { |
||||
|
const reader = new FileReader(); |
||||
|
reader.onload = async (e) => { |
||||
|
const data = e.target?.result as ArrayBuffer; |
||||
|
if (data) { |
||||
|
const blob = new Blob([new Uint8Array(data)], { |
||||
|
type: file.type, |
||||
|
}); |
||||
|
this.openPhotoDialog(blob, file.name as string); |
||||
|
} |
||||
|
}; |
||||
|
reader.readAsArrayBuffer(file as Blob); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async acceptUrl() { |
||||
|
this.visible = false; |
||||
|
if (this.crop) { |
||||
|
try { |
||||
|
const urlBlobResponse: Blob = await axios.get(this.imageUrl as string, { |
||||
|
responseType: "blob", // This ensures the data is returned as a Blob |
||||
|
}); |
||||
|
const fullUrl = new URL(this.imageUrl as string); |
||||
|
const fileName = fullUrl.pathname.split("/").pop() as string; |
||||
|
(this.$refs.photoDialog as PhotoDialog).open( |
||||
|
this.imageCallback, |
||||
|
this.claimType, |
||||
|
this.crop, |
||||
|
urlBlobResponse.data as Blob, |
||||
|
fileName, |
||||
|
); |
||||
|
} catch (error) { |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Error", |
||||
|
text: "There was an error retrieving that image.", |
||||
|
}, |
||||
|
5000, |
||||
|
); |
||||
|
} |
||||
|
} else { |
||||
|
this.imageCallback(this.imageUrl); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
close() { |
||||
|
this.visible = false; |
||||
|
} |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<style> |
||||
|
.dialog-overlay { |
||||
|
position: fixed; |
||||
|
top: 0; |
||||
|
left: 0; |
||||
|
right: 0; |
||||
|
bottom: 0; |
||||
|
background-color: rgba(0, 0, 0, 0.5); |
||||
|
display: flex; |
||||
|
justify-content: center; |
||||
|
align-items: center; |
||||
|
padding: 1.5rem; |
||||
|
} |
||||
|
|
||||
|
.dialog { |
||||
|
background-color: white; |
||||
|
padding: 1rem; |
||||
|
border-radius: 0.5rem; |
||||
|
width: 100%; |
||||
|
max-width: 700px; |
||||
|
} |
||||
|
</style> |
@ -0,0 +1,118 @@ |
|||||
|
<template> |
||||
|
<div v-if="visible" class="dialog-overlay"> |
||||
|
<div class="dialog"> |
||||
|
<h1 class="text-xl font-bold text-center mb-4">Invitation & Notes</h1> |
||||
|
|
||||
|
These are optional notes for your use; they are comments to help you |
||||
|
recall who it is when they accept it. These notes are sent to the server. |
||||
|
If you want to store your own way, the invitation ID is: |
||||
|
{{ inviteIdentifier }} |
||||
|
<input |
||||
|
type="text" |
||||
|
placeholder="Notes" |
||||
|
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2" |
||||
|
v-model="text" |
||||
|
/> |
||||
|
|
||||
|
<!-- Add date selection element --> |
||||
|
Expiration |
||||
|
<input |
||||
|
type="date" |
||||
|
class="block rounded border border-slate-400 mb-4 px-3 py-2" |
||||
|
v-model="expiresAt" |
||||
|
/> |
||||
|
|
||||
|
<div class="mt-8"> |
||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2"> |
||||
|
<button |
||||
|
type="button" |
||||
|
class="block w-full text-center text-lg font-bold uppercase 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-3 rounded-md mb-2" |
||||
|
@click="onClickSaveChanges()" |
||||
|
> |
||||
|
Save |
||||
|
</button> |
||||
|
<!-- SHOW ME instead while processing saving changes --> |
||||
|
<button |
||||
|
type="button" |
||||
|
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md mb-2" |
||||
|
@click="onClickCancel()" |
||||
|
> |
||||
|
Cancel |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script lang="ts"> |
||||
|
import { Vue, Component } from "vue-facing-decorator"; |
||||
|
|
||||
|
import { NotificationIface } from "@/constants/app"; |
||||
|
|
||||
|
@Component |
||||
|
export default class InviteDialog extends Vue { |
||||
|
$notify!: (notification: NotificationIface, timeout?: number) => void; |
||||
|
|
||||
|
callback: (text: string, expiresAt: string) => void = () => {}; |
||||
|
inviteIdentifier = ""; |
||||
|
text = ""; |
||||
|
visible = false; |
||||
|
expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7) |
||||
|
.toISOString() |
||||
|
.substring(0, 10); |
||||
|
|
||||
|
async open( |
||||
|
inviteIdentifier: string, |
||||
|
aCallback: (text: string, expiresAt: string) => void, |
||||
|
) { |
||||
|
this.callback = aCallback; |
||||
|
this.inviteIdentifier = inviteIdentifier; |
||||
|
this.visible = true; |
||||
|
} |
||||
|
|
||||
|
async onClickSaveChanges() { |
||||
|
if (!this.expiresAt) { |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "warning", |
||||
|
title: "Needs Expiration", |
||||
|
text: "You must select an expiration date.", |
||||
|
}, |
||||
|
5000, |
||||
|
); |
||||
|
} else { |
||||
|
this.callback(this.text, this.expiresAt); |
||||
|
this.visible = false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
onClickCancel() { |
||||
|
this.visible = false; |
||||
|
} |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<style> |
||||
|
.dialog-overlay { |
||||
|
position: fixed; |
||||
|
top: 0; |
||||
|
left: 0; |
||||
|
right: 0; |
||||
|
bottom: 0; |
||||
|
background-color: rgba(0, 0, 0, 0.5); |
||||
|
display: flex; |
||||
|
justify-content: center; |
||||
|
align-items: center; |
||||
|
padding: 1.5rem; |
||||
|
} |
||||
|
|
||||
|
.dialog { |
||||
|
background-color: white; |
||||
|
padding: 1rem; |
||||
|
border-radius: 0.5rem; |
||||
|
width: 100%; |
||||
|
max-width: 500px; |
||||
|
} |
||||
|
</style> |
@ -0,0 +1,281 @@ |
|||||
|
<!-- similar to ContactNameDialog --> |
||||
|
<template> |
||||
|
<div v-if="visible" class="dialog-overlay"> |
||||
|
<div v-if="page === OnboardPage.Home" class="dialog"> |
||||
|
<h1 class="text-xl font-bold text-center mb-4 relative"> |
||||
|
Welcome to Time Safari |
||||
|
<br /> |
||||
|
- Showcasing Gratitude & Magnifing Time |
||||
|
<div |
||||
|
class="text-lg text-center leading-none absolute right-0 -top-1" |
||||
|
@click="onClickClose(true)" |
||||
|
> |
||||
|
<fa icon="xmark" class="w-[1em]"></fa> |
||||
|
</div> |
||||
|
</h1> |
||||
|
|
||||
|
<p v-if="isRegistered" class="mt-4"> |
||||
|
You can now log things that you've received or witnessed: |
||||
|
<span v-if="numContacts > 0"> |
||||
|
click on {{ firstContactName }}'s name or |
||||
|
</span> |
||||
|
click on "Unnamed" to express your appreciation for... whatever -- like |
||||
|
thanks for showing you all these fascinating stories of |
||||
|
<em>gratitude</em>. |
||||
|
</p> |
||||
|
<p v-else class="mt-4"> |
||||
|
The feed underneath this pop-up shows the latest gifts recognized by |
||||
|
others. Once someone registers you, you'll be able to log your |
||||
|
appreciation, too. |
||||
|
</p> |
||||
|
|
||||
|
<p class="mt-4"> |
||||
|
The more you illuminate cool things people are doing, the more you |
||||
|
attract people to work together with you. |
||||
|
</p> |
||||
|
|
||||
|
<p class="mt-4 flex items-center"> |
||||
|
The |
||||
|
<fa |
||||
|
icon="house-chimney" |
||||
|
class="ml-1 mr-1 text-lg text-white bg-slate-400 px-2 py-2 rounded" |
||||
|
/> |
||||
|
button below brings you back to this feed screen. |
||||
|
</p> |
||||
|
|
||||
|
<div class="mt-8"> |
||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2"> |
||||
|
<button |
||||
|
type="button" |
||||
|
data-testId="closeOnboardingAndFinish" |
||||
|
class="block w-full text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md mb-2" |
||||
|
@click="onClickClose(true)" |
||||
|
> |
||||
|
That's enough help, thanks. |
||||
|
</button> |
||||
|
<button |
||||
|
type="button" |
||||
|
class="block w-full text-center text-md 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-3 rounded-md mb-2" |
||||
|
@click="$router.push({ name: 'discover' })" |
||||
|
> |
||||
|
Show me more! |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<p class="mt-4 flex items-center"> |
||||
|
To see these instructions and more, click above on |
||||
|
<span |
||||
|
class="ml-1 mr-1 text-xs uppercase 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-1 rounded-md" |
||||
|
> |
||||
|
Help |
||||
|
</span> |
||||
|
</p> |
||||
|
</div> |
||||
|
|
||||
|
<div v-if="page === OnboardPage.Discover" class="dialog"> |
||||
|
<h1 class="text-xl font-bold text-center mb-4 relative"> |
||||
|
Offer to Interesting Events & People |
||||
|
<div |
||||
|
class="text-lg text-center leading-none absolute right-0 -top-1" |
||||
|
@click="onClickClose(true)" |
||||
|
> |
||||
|
<fa icon="xmark" class="w-[1em]"></fa> |
||||
|
</div> |
||||
|
</h1> |
||||
|
|
||||
|
<p> |
||||
|
Once you've seen things that others have given or done, you may find |
||||
|
ways you want to contribute, too. It turns out others have proposed |
||||
|
activities together, and this page is where you find projects. |
||||
|
</p> |
||||
|
|
||||
|
<p class="mt-4"> |
||||
|
Search for a topic, or search around your neighborhod under "Nearby". |
||||
|
</p> |
||||
|
|
||||
|
<p class="mt-4"> |
||||
|
When you find some that seem interesting, you can offer your help. You |
||||
|
are welcome to make your offer conditional, for example if they get 2 |
||||
|
other people, too. |
||||
|
</p> |
||||
|
|
||||
|
<p class="mt-4 flex items-center"> |
||||
|
The |
||||
|
<fa |
||||
|
icon="magnifying-glass" |
||||
|
class="ml-1 mr-1 text-lg text-white bg-slate-400 px-2 py-2 rounded" |
||||
|
/> |
||||
|
button below brings you to this discovery screen. |
||||
|
</p> |
||||
|
|
||||
|
<div class="mt-8"> |
||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2"> |
||||
|
<button |
||||
|
type="button" |
||||
|
data-testId="closeOnboardingAndFinish" |
||||
|
class="block w-full text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md mb-2" |
||||
|
@click="onClickClose(true)" |
||||
|
> |
||||
|
No more help, thanks. |
||||
|
</button> |
||||
|
<button |
||||
|
type="button" |
||||
|
class="block w-full text-center text-md 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-3 rounded-md mb-2" |
||||
|
@click="$router.push({ name: 'projects' })" |
||||
|
> |
||||
|
Show me even more. |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div v-if="page === OnboardPage.Create" class="dialog"> |
||||
|
<h1 class="text-xl font-bold text-center mb-4 relative"> |
||||
|
Fish for Others with Your Projects |
||||
|
<div |
||||
|
class="text-lg text-center leading-none absolute right-0 -top-1" |
||||
|
@click="onClickClose(true)" |
||||
|
> |
||||
|
<fa icon="xmark" class="w-[1em]"></fa> |
||||
|
</div> |
||||
|
</h1> |
||||
|
|
||||
|
<p class="relative"> |
||||
|
Now you can take a turn: click on the |
||||
|
<span class="bg-green-600 text-white rounded-full"> |
||||
|
<fa icon="plus" class="fa-fw"></fa> |
||||
|
</span> |
||||
|
button to throw out projects of your own... anything you'd like to see |
||||
|
happen. If your first idea doesn't catch anyone, try, try again... and |
||||
|
let others know that this is a good place to find help. |
||||
|
</p> |
||||
|
|
||||
|
<p class="mt-4 flex items-center"> |
||||
|
The |
||||
|
<fa |
||||
|
icon="hand" |
||||
|
class="ml-1 mr-1 text-lg text-white bg-slate-400 px-2 py-2 rounded" |
||||
|
/> |
||||
|
button below brings you here to see your ideas. |
||||
|
</p> |
||||
|
|
||||
|
<p class="mt-4"> |
||||
|
By the way, one good way to get to know your neighbors and their |
||||
|
interests is to offer time directly to them. You can do this on the |
||||
|
contacts screen |
||||
|
<fa icon="users" class="text-slate-500" /> |
||||
|
which is a great way to get to know a neighbor's interests. |
||||
|
</p> |
||||
|
|
||||
|
<div class="mt-8"> |
||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2"> |
||||
|
<button |
||||
|
type="button" |
||||
|
data-testId="closeOnboardingAndFinish" |
||||
|
class="block w-full text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md mb-2" |
||||
|
@click="onClickClose(true, true)" |
||||
|
> |
||||
|
Let's go! |
||||
|
<br /> |
||||
|
See & record gratitude. |
||||
|
</button> |
||||
|
<button |
||||
|
type="button" |
||||
|
class="block w-full text-center text-md 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-3 rounded-md mb-2" |
||||
|
@click="$router.push({ name: 'help' })" |
||||
|
> |
||||
|
I want to read more Help. |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script lang="ts"> |
||||
|
import { Component, Vue } from "vue-facing-decorator"; |
||||
|
import { Router } from "vue-router"; |
||||
|
|
||||
|
import { NotificationIface } from "@/constants/app"; |
||||
|
import { |
||||
|
db, |
||||
|
retrieveSettingsForActiveAccount, |
||||
|
updateAccountSettings, |
||||
|
} from "@/db/index"; |
||||
|
import { OnboardPage } from "@/libs/util"; |
||||
|
|
||||
|
@Component({ |
||||
|
computed: { |
||||
|
OnboardPage() { |
||||
|
return OnboardPage; |
||||
|
}, |
||||
|
}, |
||||
|
components: { OnboardPage }, |
||||
|
}) |
||||
|
export default class OnboardingDialog extends Vue { |
||||
|
$notify!: (notification: NotificationIface, timeout?: number) => void; |
||||
|
|
||||
|
activeDid = ""; |
||||
|
firstContactName = null; |
||||
|
givenName = ""; |
||||
|
isRegistered = false; |
||||
|
numContacts = 0; |
||||
|
page = OnboardPage.Home; |
||||
|
visible = false; |
||||
|
|
||||
|
async open(page: OnboardPage) { |
||||
|
this.page = page; |
||||
|
const settings = await retrieveSettingsForActiveAccount(); |
||||
|
this.activeDid = settings.activeDid || ""; |
||||
|
this.isRegistered = !!settings.isRegistered; |
||||
|
const contacts = await db.contacts.toArray(); |
||||
|
this.numContacts = contacts.length; |
||||
|
if (this.numContacts > 0) { |
||||
|
this.firstContactName = contacts[0].name; |
||||
|
} |
||||
|
this.visible = true; |
||||
|
if (this.page === OnboardPage.Create) { |
||||
|
// we'll assume that they've been through all the other pages |
||||
|
await updateAccountSettings(this.activeDid, { |
||||
|
finishedOnboarding: true, |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async onClickClose(done?: boolean, goHome?: boolean) { |
||||
|
this.visible = false; |
||||
|
if (done) { |
||||
|
await updateAccountSettings(this.activeDid, { |
||||
|
finishedOnboarding: true, |
||||
|
}); |
||||
|
if (goHome) { |
||||
|
(this.$router as Router).push({ name: "home" }); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<style> |
||||
|
.dialog-overlay { |
||||
|
position: fixed; |
||||
|
top: 0; |
||||
|
left: 0; |
||||
|
right: 0; |
||||
|
bottom: 0; |
||||
|
background-color: rgba(0, 0, 0, 0.5); |
||||
|
display: flex; |
||||
|
justify-content: center; |
||||
|
align-items: center; |
||||
|
padding: 1.5rem; |
||||
|
} |
||||
|
|
||||
|
.dialog { |
||||
|
background-color: white; |
||||
|
padding: 1rem; |
||||
|
border-radius: 0.5rem; |
||||
|
width: 100%; |
||||
|
max-width: 500px; |
||||
|
} |
||||
|
</style> |
@ -0,0 +1,439 @@ |
|||||
|
<template> |
||||
|
<div v-if="visible" class="dialog-overlay z-[60]"> |
||||
|
<div class="dialog relative"> |
||||
|
<div class="text-lg text-center font-light relative z-50"> |
||||
|
<div |
||||
|
id="ViewHeading" |
||||
|
class="text-center font-bold absolute top-0 left-0 right-0 px-4 py-0.5 bg-black/50 text-white leading-none" |
||||
|
> |
||||
|
<span v-if="uploading"> Uploading... </span> |
||||
|
<span v-else-if="blob"> Look Good? </span> |
||||
|
<span v-else> Say "Cheese"! </span> |
||||
|
</div> |
||||
|
|
||||
|
<div |
||||
|
class="text-lg text-center px-2 py-0.5 leading-none absolute right-0 top-0 text-white" |
||||
|
@click="close()" |
||||
|
> |
||||
|
<fa icon="xmark" class="w-[1em]"></fa> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div v-if="uploading" class="flex justify-center"> |
||||
|
<fa |
||||
|
icon="spinner" |
||||
|
class="fa-spin fa-3x text-center block px-12 py-12" |
||||
|
/> |
||||
|
</div> |
||||
|
<div v-else-if="blob"> |
||||
|
<div v-if="crop"> |
||||
|
<VuePictureCropper |
||||
|
:boxStyle="{ |
||||
|
backgroundColor: '#f8f8f8', |
||||
|
margin: 'auto', |
||||
|
}" |
||||
|
:img="createBlobURL(blob)" |
||||
|
:options="{ |
||||
|
viewMode: 1, |
||||
|
dragMode: 'crop', |
||||
|
aspectRatio: 9 / 9, |
||||
|
}" |
||||
|
class="max-h-[90vh] max-w-[90vw] object-contain" |
||||
|
/> |
||||
|
<!-- This gives a round cropper. |
||||
|
:presetMode="{ |
||||
|
mode: 'round', |
||||
|
}" |
||||
|
--> |
||||
|
</div> |
||||
|
<div v-else> |
||||
|
<div class="flex justify-center"> |
||||
|
<img |
||||
|
:src="createBlobURL(blob)" |
||||
|
class="mt-2 rounded max-h-[90vh] max-w-[90vw] object-contain" |
||||
|
/> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="absolute bottom-[1rem] left-[1rem] px-2 py-1"> |
||||
|
<button |
||||
|
@click="uploadImage" |
||||
|
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 py-1 px-2 rounded-md" |
||||
|
> |
||||
|
<span>Upload</span> |
||||
|
</button> |
||||
|
</div> |
||||
|
<div |
||||
|
v-if="showRetry" |
||||
|
class="absolute bottom-[1rem] right-[1rem] px-2 py-1" |
||||
|
> |
||||
|
<button |
||||
|
@click="retryImage" |
||||
|
class="bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white py-1 px-2 rounded-md" |
||||
|
> |
||||
|
<span>Retry</span> |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div v-else ref="cameraContainer"> |
||||
|
<!-- |
||||
|
Camera "resolution" doesn't change how it shows on screen but rather stretches the result, |
||||
|
eg. the following which just stretches it vertically: |
||||
|
:resolution="{ width: 375, height: 812 }" |
||||
|
--> |
||||
|
<camera |
||||
|
facingMode="environment" |
||||
|
autoplay |
||||
|
ref="camera" |
||||
|
@started="cameraStarted()" |
||||
|
> |
||||
|
<div |
||||
|
class="absolute portrait:bottom-0 portrait:left-0 portrait:right-0 portrait:pb-2 landscape:right-0 landscape:top-0 landscape:bottom-0 landscape:pr-4 flex landscape:flex-row justify-center items-center" |
||||
|
> |
||||
|
<button |
||||
|
@click="takeImage()" |
||||
|
class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none" |
||||
|
> |
||||
|
<fa icon="camera" class="w-[1em]"></fa> |
||||
|
</button> |
||||
|
</div> |
||||
|
<div |
||||
|
class="absolute portrait:bottom-2 portrait:right-16 landscape:right-0 landscape:bottom-16 landscape:pr-4 flex justify-center items-center" |
||||
|
> |
||||
|
<button |
||||
|
@click="swapMirrorClass()" |
||||
|
class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none" |
||||
|
> |
||||
|
<fa icon="left-right" class="w-[1em]"></fa> |
||||
|
</button> |
||||
|
</div> |
||||
|
<div v-if="numDevices > 1" class="absolute bottom-2 right-4"> |
||||
|
<button |
||||
|
@click="switchCamera()" |
||||
|
class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none" |
||||
|
> |
||||
|
<fa icon="rotate" class="w-[1em]"></fa> |
||||
|
</button> |
||||
|
</div> |
||||
|
</camera> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script lang="ts"> |
||||
|
import axios from "axios"; |
||||
|
import Camera from "simple-vue-camera"; |
||||
|
import { Component, Vue } from "vue-facing-decorator"; |
||||
|
import VuePictureCropper, { cropper } from "vue-picture-cropper"; |
||||
|
|
||||
|
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "@/constants/app"; |
||||
|
import { retrieveSettingsForActiveAccount } from "@/db/index"; |
||||
|
import { accessToken } from "@/libs/crypto"; |
||||
|
|
||||
|
@Component({ components: { Camera, VuePictureCropper } }) |
||||
|
export default class PhotoDialog extends Vue { |
||||
|
$notify!: (notification: NotificationIface, timeout?: number) => void; |
||||
|
|
||||
|
activeDeviceNumber = 0; |
||||
|
activeDid = ""; |
||||
|
blob?: Blob; |
||||
|
claimType = ""; |
||||
|
crop = false; |
||||
|
fileName?: string; |
||||
|
mirror = false; |
||||
|
numDevices = 0; |
||||
|
setImageCallback: (arg: string) => void = () => {}; |
||||
|
showRetry = true; |
||||
|
uploading = false; |
||||
|
visible = false; |
||||
|
|
||||
|
URL = window.URL || window.webkitURL; |
||||
|
|
||||
|
async mounted() { |
||||
|
try { |
||||
|
const settings = await retrieveSettingsForActiveAccount(); |
||||
|
this.activeDid = settings.activeDid || ""; |
||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any |
||||
|
} catch (err: any) { |
||||
|
console.error("Error retrieving settings from database:", err); |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Error", |
||||
|
text: err.message || "There was an error retrieving your settings.", |
||||
|
}, |
||||
|
-1, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
open( |
||||
|
setImageFn: (arg: string) => void, |
||||
|
claimType: string, |
||||
|
crop?: boolean, |
||||
|
blob?: Blob, // for image upload, just to use the cropping function |
||||
|
inputFileName?: string, |
||||
|
) { |
||||
|
this.visible = true; |
||||
|
this.claimType = claimType; |
||||
|
this.crop = !!crop; |
||||
|
const bottomNav = document.querySelector("#QuickNav") as HTMLElement; |
||||
|
if (bottomNav) { |
||||
|
bottomNav.style.display = "none"; |
||||
|
} |
||||
|
this.setImageCallback = setImageFn; |
||||
|
if (blob) { |
||||
|
this.blob = blob; |
||||
|
this.fileName = inputFileName; |
||||
|
// doesn't make sense to retry the file upload; they can cancel if they picked the wrong one |
||||
|
this.showRetry = false; |
||||
|
} else { |
||||
|
this.blob = undefined; |
||||
|
this.fileName = undefined; |
||||
|
this.showRetry = true; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
close() { |
||||
|
this.visible = false; |
||||
|
const bottomNav = document.querySelector("#QuickNav") as HTMLElement; |
||||
|
if (bottomNav) { |
||||
|
bottomNav.style.display = ""; |
||||
|
} |
||||
|
this.blob = undefined; |
||||
|
} |
||||
|
|
||||
|
async cameraStarted() { |
||||
|
const cameraComponent = this.$refs.camera as InstanceType<typeof Camera>; |
||||
|
if (cameraComponent) { |
||||
|
this.numDevices = (await cameraComponent.devices(["videoinput"])).length; |
||||
|
this.mirror = cameraComponent.facingMode === "user"; |
||||
|
// figure out which device is active |
||||
|
const currentDeviceId = cameraComponent.currentDeviceID(); |
||||
|
const devices = await cameraComponent.devices(["videoinput"]); |
||||
|
this.activeDeviceNumber = devices.findIndex( |
||||
|
(device) => device.deviceId === currentDeviceId, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async switchCamera() { |
||||
|
const cameraComponent = this.$refs.camera as InstanceType<typeof Camera>; |
||||
|
this.activeDeviceNumber = (this.activeDeviceNumber + 1) % this.numDevices; |
||||
|
const devices = await cameraComponent?.devices(["videoinput"]); |
||||
|
await cameraComponent?.changeCamera( |
||||
|
devices[this.activeDeviceNumber].deviceId, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
async takeImage(/* payload: MouseEvent */) { |
||||
|
const cameraComponent = this.$refs.camera as InstanceType<typeof Camera>; |
||||
|
|
||||
|
/** |
||||
|
* This logic to set the image height & width correctly. |
||||
|
* Without it, the portrait orientation ends up with an image that is stretched horizontally. |
||||
|
* Note that it's the same with raw browser Javascript; see the "drawImage" example below. |
||||
|
* Now that I've done it, I can't explain why it works. |
||||
|
*/ |
||||
|
let imageHeight = cameraComponent?.resolution?.height; |
||||
|
let imageWidth = cameraComponent?.resolution?.width; |
||||
|
const initialImageRatio = imageWidth / imageHeight; |
||||
|
const windowRatio = window.innerWidth / window.innerHeight; |
||||
|
if (initialImageRatio > 1 && windowRatio < 1) { |
||||
|
// the image is wider than it is tall, and the window is taller than it is wide |
||||
|
// For some reason, mobile in portrait orientation renders a horizontally-stretched image. |
||||
|
// We're gonna force it opposite. |
||||
|
imageHeight = cameraComponent?.resolution?.width; |
||||
|
imageWidth = cameraComponent?.resolution?.height; |
||||
|
} else if (initialImageRatio < 1 && windowRatio > 1) { |
||||
|
// the image is taller than it is wide, and the window is wider than it is tall |
||||
|
// Haven't seen this happen, but we'll do it just in case. |
||||
|
imageHeight = cameraComponent?.resolution?.width; |
||||
|
imageWidth = cameraComponent?.resolution?.height; |
||||
|
} |
||||
|
const newImageRatio = imageWidth / imageHeight; |
||||
|
if (newImageRatio < windowRatio) { |
||||
|
// the image is a taller ratio than the window, so fit the height first |
||||
|
imageHeight = window.innerHeight / 2; |
||||
|
imageWidth = imageHeight * newImageRatio; |
||||
|
} else { |
||||
|
// the image is a wider ratio than the window, so fit the width first |
||||
|
imageWidth = window.innerWidth / 2; |
||||
|
imageHeight = imageWidth / newImageRatio; |
||||
|
} |
||||
|
|
||||
|
// The resolution is only necessary because of that mobile portrait-orientation case. |
||||
|
// The mobile emulation in a browser shows something stretched vertically, but real devices work fine. |
||||
|
this.blob = |
||||
|
(await cameraComponent?.snapshot({ |
||||
|
height: imageHeight, |
||||
|
width: imageWidth, |
||||
|
})) || undefined; |
||||
|
// png is default |
||||
|
this.fileName = "snapshot.png"; |
||||
|
if (!this.blob) { |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Error", |
||||
|
text: "There was an error taking the picture. Please try again.", |
||||
|
}, |
||||
|
5000, |
||||
|
); |
||||
|
return; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private createBlobURL(blob: Blob): string { |
||||
|
return URL.createObjectURL(blob); |
||||
|
} |
||||
|
|
||||
|
async retryImage() { |
||||
|
this.blob = undefined; |
||||
|
} |
||||
|
|
||||
|
/**** |
||||
|
|
||||
|
Here's an approach to photo capture without a library. It has similar quirks. |
||||
|
Now that we've fixed styling for simple-vue-camera, it's not critical to refactor. Maybe someday. |
||||
|
|
||||
|
<button id="start-camera" @click="cameraClicked">Start Camera</button> |
||||
|
<video id="video" width="320" height="240" autoplay></video> |
||||
|
<button id="snap-photo" @click="photoSnapped">Snap Photo</button> |
||||
|
<canvas id="canvas" width="320" height="240"></canvas> |
||||
|
|
||||
|
async cameraClicked() { |
||||
|
const video = document.querySelector("#video"); |
||||
|
const stream = await navigator.mediaDevices.getUserMedia({ |
||||
|
video: true, |
||||
|
audio: false, |
||||
|
}); |
||||
|
if (video instanceof HTMLVideoElement) { |
||||
|
video.srcObject = stream; |
||||
|
} |
||||
|
} |
||||
|
photoSnapped() { |
||||
|
const video = document.querySelector("#video"); |
||||
|
const canvas = document.querySelector("#canvas"); |
||||
|
if ( |
||||
|
canvas instanceof HTMLCanvasElement && |
||||
|
video instanceof HTMLVideoElement |
||||
|
) { |
||||
|
canvas |
||||
|
?.getContext("2d") |
||||
|
?.drawImage(video, 0, 0, canvas.width, canvas.height); |
||||
|
// ... or set the blob: |
||||
|
// canvas?.toBlob( |
||||
|
// (blob) => { |
||||
|
// this.blob = blob; |
||||
|
// }, |
||||
|
// "image/jpeg", |
||||
|
// 1, |
||||
|
// ); |
||||
|
|
||||
|
// data url of the image |
||||
|
const image_data_url = canvas?.toDataURL("image/jpeg"); |
||||
|
} |
||||
|
} |
||||
|
****/ |
||||
|
|
||||
|
async uploadImage() { |
||||
|
this.uploading = true; |
||||
|
|
||||
|
if (this.crop) { |
||||
|
this.blob = (await cropper?.getBlob()) || undefined; |
||||
|
} |
||||
|
|
||||
|
const token = await accessToken(this.activeDid); |
||||
|
const headers = { |
||||
|
Authorization: "Bearer " + token, |
||||
|
// axios fills in Content-Type of multipart/form-data |
||||
|
}; |
||||
|
const formData = new FormData(); |
||||
|
if (!this.blob) { |
||||
|
// yeah, this should never happen, but it helps with subsequent type checking |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Error", |
||||
|
text: "There was an error finding the picture. Please try again.", |
||||
|
}, |
||||
|
5000, |
||||
|
); |
||||
|
this.uploading = false; |
||||
|
return; |
||||
|
} |
||||
|
formData.append("image", this.blob, this.fileName || "snapshot.png"); |
||||
|
formData.append("claimType", this.claimType); |
||||
|
try { |
||||
|
const response = await axios.post( |
||||
|
DEFAULT_IMAGE_API_SERVER + "/image", |
||||
|
formData, |
||||
|
{ headers }, |
||||
|
); |
||||
|
this.uploading = false; |
||||
|
|
||||
|
this.close(); |
||||
|
this.setImageCallback(response.data.url as string); |
||||
|
} catch (error) { |
||||
|
console.error("Error uploading the image", error); |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Error", |
||||
|
text: "There was an error saving the picture.", |
||||
|
}, |
||||
|
5000, |
||||
|
); |
||||
|
this.uploading = false; |
||||
|
this.blob = undefined; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
swapMirrorClass() { |
||||
|
this.mirror = !this.mirror; |
||||
|
if (this.mirror) { |
||||
|
(this.$refs.cameraContainer as HTMLElement).classList.add("mirror-video"); |
||||
|
} else { |
||||
|
(this.$refs.cameraContainer as HTMLElement).classList.remove( |
||||
|
"mirror-video", |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<style> |
||||
|
.dialog-overlay { |
||||
|
position: fixed; |
||||
|
top: 0; |
||||
|
left: 0; |
||||
|
right: 0; |
||||
|
bottom: 0; |
||||
|
background-color: rgba(0, 0, 0, 0.5); |
||||
|
display: flex; |
||||
|
justify-content: center; |
||||
|
align-items: center; |
||||
|
padding: 1.5rem; |
||||
|
} |
||||
|
|
||||
|
.dialog { |
||||
|
background-color: white; |
||||
|
padding: 1rem; |
||||
|
border-radius: 0.5rem; |
||||
|
width: 100%; |
||||
|
max-width: 700px; |
||||
|
} |
||||
|
|
||||
|
.mirror-video { |
||||
|
transform: scaleX(-1); |
||||
|
-webkit-transform: scaleX(-1); /* For Safari */ |
||||
|
-moz-transform: scaleX(-1); /* For Firefox */ |
||||
|
-ms-transform: scaleX(-1); /* For IE */ |
||||
|
-o-transform: scaleX(-1); /* For Opera */ |
||||
|
} |
||||
|
</style> |
@ -0,0 +1,50 @@ |
|||||
|
<template> |
||||
|
<a |
||||
|
v-if="linkToFull && imageUrl" |
||||
|
:href="imageUrl" |
||||
|
target="_blank" |
||||
|
class="h-full w-full object-contain" |
||||
|
> |
||||
|
<div v-html="generateIdenticon()" class="h-full w-full object-contain" /> |
||||
|
</a> |
||||
|
<div |
||||
|
v-else |
||||
|
v-html="generateIdenticon()" |
||||
|
class="h-full w-full object-contain" |
||||
|
/> |
||||
|
</template> |
||||
|
<script lang="ts"> |
||||
|
import { toSvg } from "jdenticon"; |
||||
|
import { Vue, Component, Prop } from "vue-facing-decorator"; |
||||
|
|
||||
|
const BLANK_CONFIG = { |
||||
|
lightness: { |
||||
|
color: [1.0, 1.0], |
||||
|
grayscale: [1.0, 1.0], |
||||
|
}, |
||||
|
saturation: { |
||||
|
color: 0.0, |
||||
|
grayscale: 0.0, |
||||
|
}, |
||||
|
backColor: "#0000", |
||||
|
}; |
||||
|
|
||||
|
@Component |
||||
|
export default class ProjectIcon extends Vue { |
||||
|
@Prop entityId = ""; |
||||
|
@Prop iconSize = 0; |
||||
|
@Prop imageUrl = ""; |
||||
|
@Prop linkToFull = false; |
||||
|
|
||||
|
generateIdenticon() { |
||||
|
if (this.imageUrl) { |
||||
|
return `<img src="${this.imageUrl}" class="w-full h-full object-contain" />`; |
||||
|
} else { |
||||
|
const config = this.entityId ? undefined : BLANK_CONFIG; |
||||
|
const svgString = toSvg(this.entityId, this.iconSize, config); |
||||
|
return svgString; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
</script> |
||||
|
<style scoped></style> |
@ -0,0 +1,95 @@ |
|||||
|
<!-- similar to ContactNameDialog --> |
||||
|
<template> |
||||
|
<div v-if="visible" class="dialog-overlay"> |
||||
|
<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. |
||||
|
<input |
||||
|
type="text" |
||||
|
placeholder="Name" |
||||
|
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2" |
||||
|
v-model="givenName" |
||||
|
/> |
||||
|
|
||||
|
<div class="mt-8"> |
||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2"> |
||||
|
<button |
||||
|
type="button" |
||||
|
class="block w-full text-center text-lg font-bold uppercase 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-3 rounded-md mb-2" |
||||
|
@click="onClickSaveChanges()" |
||||
|
> |
||||
|
Save |
||||
|
</button> |
||||
|
<button |
||||
|
type="button" |
||||
|
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md mb-2" |
||||
|
@click="onClickCancel()" |
||||
|
> |
||||
|
Cancel |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script lang="ts"> |
||||
|
import { Vue, Component } from "vue-facing-decorator"; |
||||
|
|
||||
|
import { NotificationIface } from "@/constants/app"; |
||||
|
import { db, retrieveSettingsForActiveAccount } from "@/db/index"; |
||||
|
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; |
||||
|
|
||||
|
@Component |
||||
|
export default class UserNameDialog extends Vue { |
||||
|
$notify!: (notification: NotificationIface, timeout?: number) => void; |
||||
|
|
||||
|
callback: (name?: string) => void = () => {}; |
||||
|
givenName = ""; |
||||
|
visible = false; |
||||
|
|
||||
|
async open(aCallback?: (name?: string) => void) { |
||||
|
this.callback = aCallback || this.callback; |
||||
|
const settings = await retrieveSettingsForActiveAccount(); |
||||
|
this.givenName = settings.firstName || ""; |
||||
|
this.visible = true; |
||||
|
} |
||||
|
|
||||
|
async onClickSaveChanges() { |
||||
|
await db.settings.update(MASTER_SETTINGS_KEY, { |
||||
|
firstName: this.givenName, |
||||
|
}); |
||||
|
this.visible = false; |
||||
|
this.callback(this.givenName); |
||||
|
} |
||||
|
|
||||
|
onClickCancel() { |
||||
|
this.visible = false; |
||||
|
} |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<style> |
||||
|
.dialog-overlay { |
||||
|
position: fixed; |
||||
|
top: 0; |
||||
|
left: 0; |
||||
|
right: 0; |
||||
|
bottom: 0; |
||||
|
background-color: rgba(0, 0, 0, 0.5); |
||||
|
display: flex; |
||||
|
justify-content: center; |
||||
|
align-items: center; |
||||
|
padding: 1.5rem; |
||||
|
} |
||||
|
|
||||
|
.dialog { |
||||
|
background-color: white; |
||||
|
padding: 1rem; |
||||
|
border-radius: 0.5rem; |
||||
|
width: 100%; |
||||
|
max-width: 500px; |
||||
|
} |
||||
|
</style> |
@ -0,0 +1 @@ |
|||||
|
Check the contact & settings export to see whether you want your new table to be included in it. |
@ -0,0 +1,14 @@ |
|||||
|
// for ephemeral uses, eg. passing a blob from the service worker to the main thread
|
||||
|
|
||||
|
export type Temp = { |
||||
|
id: string; |
||||
|
blob?: Blob; // deprecated because webkit (Safari) does not support Blob
|
||||
|
blobB64?: string; // base64-encoded blob
|
||||
|
}; |
||||
|
|
||||
|
/** |
||||
|
* Schema for the Temp table in the database. |
||||
|
*/ |
||||
|
export const TempSchema = { |
||||
|
temp: "id", |
||||
|
}; |
@ -0,0 +1,46 @@ |
|||||
|
/** |
||||
|
* This did:ethr resolver instructs the did-jwt machinery to use the |
||||
|
* EcdsaSecp256k1RecoveryMethod2020Uses verification method which adds the recovery bit to the |
||||
|
* signature to recover the DID's public key from a signature. |
||||
|
* |
||||
|
* This effectively hard codes the did:ethr DID resolver to use the address as the public key. |
||||
|
* @param did : string |
||||
|
* @returns {Promise<DIDResolutionResult>} |
||||
|
* |
||||
|
* Similar code resides in image-api |
||||
|
*/ |
||||
|
export const didEthLocalResolver = async (did: string) => { |
||||
|
const didRegex = /^did:ethr:(0x[0-9a-fA-F]{40})$/; |
||||
|
const match = did.match(didRegex); |
||||
|
|
||||
|
if (match) { |
||||
|
const address = match[1]; // Extract eth address: 0x...
|
||||
|
const publicKeyHex = address; // Use the address directly as a public key placeholder
|
||||
|
|
||||
|
return { |
||||
|
didDocumentMetadata: {}, |
||||
|
didResolutionMetadata: { |
||||
|
contentType: "application/did+ld+json", |
||||
|
}, |
||||
|
didDocument: { |
||||
|
"@context": [ |
||||
|
"https://www.w3.org/ns/did/v1", |
||||
|
"https://w3id.org/security/suites/secp256k1recovery-2020/v2", |
||||
|
], |
||||
|
id: did, |
||||
|
verificationMethod: [ |
||||
|
{ |
||||
|
id: `${did}#controller`, |
||||
|
type: "EcdsaSec256k1RecoveryMethod2020", |
||||
|
controller: did, |
||||
|
blockchainAccountId: "eip155:1:" + publicKeyHex, |
||||
|
}, |
||||
|
], |
||||
|
authentication: [`${did}#controller`], |
||||
|
assertionMethod: [`${did}#controller`], |
||||
|
}, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
throw new Error(`Unsupported DID format: ${did}`); |
||||
|
}; |
@ -0,0 +1,96 @@ |
|||||
|
import { Buffer } from "buffer/"; |
||||
|
import { decode as cborDecode } from "cbor-x"; |
||||
|
import { bytesToMultibase, multibaseToBytes } from "did-jwt"; |
||||
|
|
||||
|
import { getWebCrypto } from "@/libs/crypto/vc/passkeyHelpers"; |
||||
|
|
||||
|
export const PEER_DID_PREFIX = "did:peer:"; |
||||
|
const PEER_DID_MULTIBASE_PREFIX = PEER_DID_PREFIX + "0"; |
||||
|
|
||||
|
/** |
||||
|
* |
||||
|
* |
||||
|
* similar code is in crowd-funder-for-time-pwa libs/crypto/vc/passkeyDidPeer.ts verifyJwtWebCrypto |
||||
|
* |
||||
|
* @returns {Promise<boolean>} |
||||
|
*/ |
||||
|
export async function verifyPeerSignature( |
||||
|
payloadBytes: Buffer, |
||||
|
issuerDid: string, |
||||
|
signatureBytes: Uint8Array, |
||||
|
): Promise<boolean> { |
||||
|
const publicKeyBytes = peerDidToPublicKeyBytes(issuerDid); |
||||
|
|
||||
|
const WebCrypto = await getWebCrypto(); |
||||
|
const verifyAlgorithm = { |
||||
|
name: "ECDSA", |
||||
|
hash: { name: "SHA-256" }, |
||||
|
}; |
||||
|
const publicKeyJwk = cborToKeys(publicKeyBytes).publicKeyJwk; |
||||
|
const keyAlgorithm = { |
||||
|
name: "ECDSA", |
||||
|
namedCurve: publicKeyJwk.crv, |
||||
|
}; |
||||
|
const publicKeyCryptoKey = await WebCrypto.subtle.importKey( |
||||
|
"jwk", |
||||
|
publicKeyJwk, |
||||
|
keyAlgorithm, |
||||
|
false, |
||||
|
["verify"], |
||||
|
); |
||||
|
const verified = await WebCrypto.subtle.verify( |
||||
|
verifyAlgorithm, |
||||
|
publicKeyCryptoKey, |
||||
|
signatureBytes, |
||||
|
payloadBytes, |
||||
|
); |
||||
|
return verified; |
||||
|
} |
||||
|
|
||||
|
export function cborToKeys(publicKeyBytes: Uint8Array) { |
||||
|
const jwkObj = cborDecode(publicKeyBytes); |
||||
|
if ( |
||||
|
jwkObj[1] != 2 || // kty "EC"
|
||||
|
jwkObj[3] != -7 || // alg "ES256"
|
||||
|
jwkObj[-1] != 1 || // crv "P-256"
|
||||
|
jwkObj[-2].length != 32 || // x
|
||||
|
jwkObj[-3].length != 32 // y
|
||||
|
) { |
||||
|
throw new Error("Unable to extract key."); |
||||
|
} |
||||
|
const publicKeyJwk = { |
||||
|
alg: "ES256", |
||||
|
crv: "P-256", |
||||
|
kty: "EC", |
||||
|
x: arrayToBase64Url(jwkObj[-2]), |
||||
|
y: arrayToBase64Url(jwkObj[-3]), |
||||
|
}; |
||||
|
const publicKeyBuffer = Buffer.concat([ |
||||
|
Buffer.from(jwkObj[-2]), |
||||
|
Buffer.from(jwkObj[-3]), |
||||
|
]); |
||||
|
return { publicKeyJwk, publicKeyBuffer }; |
||||
|
} |
||||
|
|
||||
|
export function toBase64Url(anythingB64: string) { |
||||
|
return anythingB64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); |
||||
|
} |
||||
|
|
||||
|
export function arrayToBase64Url(anything: Uint8Array) { |
||||
|
return toBase64Url(Buffer.from(anything).toString("base64")); |
||||
|
} |
||||
|
|
||||
|
export function peerDidToPublicKeyBytes(did: string) { |
||||
|
return multibaseToBytes(did.substring(PEER_DID_MULTIBASE_PREFIX.length)); |
||||
|
} |
||||
|
|
||||
|
export function createPeerDid(publicKeyBytes: Uint8Array) { |
||||
|
// https://github.com/decentralized-identity/veramo/blob/next/packages/did-provider-peer/src/peer-did-provider.ts#L67
|
||||
|
//const provider = new PeerDIDProvider({ defaultKms: LOCAL_KMS_NAME });
|
||||
|
const methodSpecificId = bytesToMultibase( |
||||
|
publicKeyBytes, |
||||
|
"base58btc", |
||||
|
"p256-pub", |
||||
|
); |
||||
|
return PEER_DID_MULTIBASE_PREFIX + methodSpecificId; |
||||
|
} |
@ -0,0 +1,200 @@ |
|||||
|
/** |
||||
|
* Verifiable Credential & DID functions, specifically for EndorserSearch.org tools |
||||
|
* |
||||
|
* The goal is to make this folder similar across projects, then move it to a library. |
||||
|
* Other projects: endorser-ch, image-api |
||||
|
* |
||||
|
*/ |
||||
|
|
||||
|
import { Buffer } from "buffer/"; |
||||
|
import * as didJwt from "did-jwt"; |
||||
|
import { JWTVerified } from "did-jwt"; |
||||
|
import { JWTDecoded } from "did-jwt/lib/JWT"; |
||||
|
import { Resolver } from "did-resolver"; |
||||
|
import { IIdentifier } from "@veramo/core"; |
||||
|
import * as u8a from "uint8arrays"; |
||||
|
|
||||
|
import { didEthLocalResolver } from "./did-eth-local-resolver"; |
||||
|
import { PEER_DID_PREFIX, verifyPeerSignature } from "./didPeer"; |
||||
|
import { base64urlDecodeString, createDidPeerJwt } from "./passkeyDidPeer"; |
||||
|
import { urlBase64ToUint8Array } from "./util"; |
||||
|
|
||||
|
export const ETHR_DID_PREFIX = "did:ethr:"; |
||||
|
export const JWT_VERIFY_FAILED_CODE = "JWT_VERIFY_FAILED"; |
||||
|
export const UNSUPPORTED_DID_METHOD_CODE = "UNSUPPORTED_DID_METHOD"; |
||||
|
|
||||
|
/** |
||||
|
* Meta info about a key |
||||
|
*/ |
||||
|
export interface KeyMeta { |
||||
|
/** |
||||
|
* Decentralized ID for the key |
||||
|
*/ |
||||
|
did: string; |
||||
|
/** |
||||
|
* Stringified IIDentifier object from Veramo |
||||
|
*/ |
||||
|
identity?: string; |
||||
|
/** |
||||
|
* The Webauthn credential ID in hex, if this is from a passkey |
||||
|
*/ |
||||
|
passkeyCredIdHex?: string; |
||||
|
} |
||||
|
|
||||
|
const resolver = new Resolver({ ethr: didEthLocalResolver }); |
||||
|
|
||||
|
/** |
||||
|
* Tell whether a key is from a passkey |
||||
|
* @param keyMeta contains info about the key, whose passkeyCredIdHex determines if the key is from a passkey |
||||
|
*/ |
||||
|
export function isFromPasskey(keyMeta?: KeyMeta): boolean { |
||||
|
return !!keyMeta?.passkeyCredIdHex; |
||||
|
} |
||||
|
|
||||
|
export async function createEndorserJwtForKey( |
||||
|
account: KeyMeta, |
||||
|
payload: object, |
||||
|
expiresIn?: number, |
||||
|
) { |
||||
|
if (account?.identity) { |
||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
|
const identity: IIdentifier = JSON.parse(account.identity!); |
||||
|
const privateKeyHex = identity.keys[0].privateKeyHex; |
||||
|
const signer = await SimpleSigner(privateKeyHex as string); |
||||
|
const options = { |
||||
|
issuer: account.did, |
||||
|
signer: signer, |
||||
|
expiresIn: undefined as number | undefined, |
||||
|
}; |
||||
|
if (expiresIn) { |
||||
|
options.expiresIn = expiresIn; |
||||
|
} |
||||
|
return didJwt.createJWT(payload, options); |
||||
|
} else if (account?.passkeyCredIdHex) { |
||||
|
return createDidPeerJwt(account.did, account.passkeyCredIdHex, payload); |
||||
|
} else { |
||||
|
throw new Error("No identity data found to sign for DID " + account.did); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Copied out of did-jwt since it's deprecated in that library. |
||||
|
* |
||||
|
* The SimpleSigner returns a configured function for signing data. |
||||
|
* |
||||
|
* @example |
||||
|
* const signer = SimpleSigner(privateKeyHexString) |
||||
|
* signer(data, (err, signature) => { |
||||
|
* ... |
||||
|
* }) |
||||
|
* |
||||
|
* @param {String} hexPrivateKey a hex encoded private key |
||||
|
* @return {Function} a configured signer function |
||||
|
*/ |
||||
|
function SimpleSigner(hexPrivateKey: string): didJwt.Signer { |
||||
|
const signer = didJwt.ES256KSigner(didJwt.hexToBytes(hexPrivateKey), true); |
||||
|
return async (data) => { |
||||
|
const signature = (await signer(data)) as string; |
||||
|
return fromJose(signature); |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
// from did-jwt/util; see SimpleSigner above
|
||||
|
function fromJose(signature: string): { |
||||
|
r: string; |
||||
|
s: string; |
||||
|
recoveryParam?: number; |
||||
|
} { |
||||
|
const signatureBytes: Uint8Array = didJwt.base64ToBytes(signature); |
||||
|
if (signatureBytes.length < 64 || signatureBytes.length > 65) { |
||||
|
throw new TypeError( |
||||
|
`Wrong size for signature. Expected 64 or 65 bytes, but got ${signatureBytes.length}`, |
||||
|
); |
||||
|
} |
||||
|
const r = bytesToHex(signatureBytes.slice(0, 32)); |
||||
|
const s = bytesToHex(signatureBytes.slice(32, 64)); |
||||
|
const recoveryParam = |
||||
|
signatureBytes.length === 65 ? signatureBytes[64] : undefined; |
||||
|
return { r, s, recoveryParam }; |
||||
|
} |
||||
|
|
||||
|
// from did-jwt/util; see SimpleSigner above
|
||||
|
function bytesToHex(b: Uint8Array): string { |
||||
|
return u8a.toString(b, "base16"); |
||||
|
} |
||||
|
|
||||
|
// We should be calling 'verify' in more places, showing warnings if it fails.
|
||||
|
export function decodeEndorserJwt(jwt: string): JWTDecoded { |
||||
|
return didJwt.decodeJWT(jwt); |
||||
|
} |
||||
|
|
||||
|
// return Promise of at least { issuer, payload, verified boolean }
|
||||
|
// ... and also if successfully verified by did-jwt (not JWANT): data, doc, signature, signer
|
||||
|
export async function decodeAndVerifyJwt( |
||||
|
jwt: string, |
||||
|
): Promise<Omit<JWTVerified, "didResolutionResult" | "signer" | "jwt">> { |
||||
|
const pieces = jwt.split("."); |
||||
|
console.log("WTF decodeAndVerifyJwt", typeof jwt, jwt, pieces); |
||||
|
const header = JSON.parse(base64urlDecodeString(pieces[0])); |
||||
|
const payload = JSON.parse(base64urlDecodeString(pieces[1])); |
||||
|
console.log("WTF decodeAndVerifyJwt after", header, payload); |
||||
|
const issuerDid = payload.iss; |
||||
|
if (!issuerDid) { |
||||
|
return Promise.reject({ |
||||
|
clientError: { |
||||
|
message: `Missing "iss" field in JWT.`, |
||||
|
}, |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
if (issuerDid.startsWith(ETHR_DID_PREFIX)) { |
||||
|
try { |
||||
|
const verified = await didJwt.verifyJWT(jwt, { resolver }); |
||||
|
return verified; |
||||
|
} catch (e: unknown) { |
||||
|
return Promise.reject({ |
||||
|
clientError: { |
||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
|
// @ts-expect-error
|
||||
|
message: `JWT failed verification: ` + e.toString(), |
||||
|
code: JWT_VERIFY_FAILED_CODE, |
||||
|
}, |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (issuerDid.startsWith(PEER_DID_PREFIX) && header.typ === "JWANT") { |
||||
|
const verified = await verifyPeerSignature( |
||||
|
Buffer.from(payload), |
||||
|
issuerDid, |
||||
|
urlBase64ToUint8Array(pieces[2]), |
||||
|
); |
||||
|
if (!verified) { |
||||
|
return Promise.reject({ |
||||
|
clientError: { |
||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
|
// @ts-expect-error
|
||||
|
message: `JWT failed verification: ` + e.toString(), |
||||
|
code: JWT_VERIFY_FAILED_CODE, |
||||
|
}, |
||||
|
}); |
||||
|
} else { |
||||
|
return { issuer: issuerDid, payload: payload, verified: true }; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (issuerDid.startsWith(PEER_DID_PREFIX)) { |
||||
|
return Promise.reject({ |
||||
|
clientError: { |
||||
|
message: `JWT with a PEER DID currently only supported with typ == JWANT. Contact us us for JWT suport since it should be straightforward.`, |
||||
|
}, |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
return Promise.reject({ |
||||
|
clientError: { |
||||
|
message: `Unsupported DID method ${issuerDid}`, |
||||
|
code: UNSUPPORTED_DID_METHOD_CODE, |
||||
|
}, |
||||
|
}); |
||||
|
} |
@ -0,0 +1,549 @@ |
|||||
|
import { Buffer } from "buffer/"; |
||||
|
import { JWTPayload } from "did-jwt"; |
||||
|
import { DIDResolutionResult } from "did-resolver"; |
||||
|
import { sha256 } from "ethereum-cryptography/sha256.js"; |
||||
|
import { |
||||
|
startAuthentication, |
||||
|
startRegistration, |
||||
|
} from "@simplewebauthn/browser"; |
||||
|
import { |
||||
|
generateAuthenticationOptions, |
||||
|
generateRegistrationOptions, |
||||
|
verifyAuthenticationResponse, |
||||
|
verifyRegistrationResponse, |
||||
|
} from "@simplewebauthn/server"; |
||||
|
import { VerifyAuthenticationResponseOpts } from "@simplewebauthn/server/esm/authentication/verifyAuthenticationResponse"; |
||||
|
import { |
||||
|
Base64URLString, |
||||
|
PublicKeyCredentialCreationOptionsJSON, |
||||
|
PublicKeyCredentialRequestOptionsJSON, |
||||
|
} from "@simplewebauthn/types"; |
||||
|
|
||||
|
import { AppString } from "@/constants/app"; |
||||
|
import { unwrapEC2Signature } from "@/libs/crypto/vc/passkeyHelpers"; |
||||
|
import { |
||||
|
arrayToBase64Url, |
||||
|
cborToKeys, |
||||
|
peerDidToPublicKeyBytes, |
||||
|
verifyPeerSignature, |
||||
|
} from "@/libs/crypto/vc/didPeer"; |
||||
|
|
||||
|
export interface JWK { |
||||
|
kty: string; |
||||
|
crv: string; |
||||
|
x: string; |
||||
|
y: string; |
||||
|
} |
||||
|
|
||||
|
export async function registerCredential(passkeyName?: string) { |
||||
|
const options: PublicKeyCredentialCreationOptionsJSON = |
||||
|
await generateRegistrationOptions({ |
||||
|
rpName: AppString.APP_NAME, |
||||
|
rpID: window.location.hostname, |
||||
|
userName: passkeyName || AppString.APP_NAME + " User", |
||||
|
// Don't prompt users for additional information about the authenticator
|
||||
|
// (Recommended for smoother UX)
|
||||
|
attestationType: "none", |
||||
|
authenticatorSelection: { |
||||
|
// Defaults
|
||||
|
residentKey: "preferred", |
||||
|
userVerification: "preferred", |
||||
|
// Optional
|
||||
|
authenticatorAttachment: "platform", |
||||
|
}, |
||||
|
}); |
||||
|
// someday, instead of simplwebauthn, we'll go direct: navigator.credentials.create with PublicKeyCredentialCreationOptions
|
||||
|
// with pubKeyCredParams: { type: "public-key", alg: -7 }
|
||||
|
const attResp = await startRegistration(options); |
||||
|
const verification = await verifyRegistrationResponse({ |
||||
|
response: attResp, |
||||
|
expectedChallenge: options.challenge, |
||||
|
expectedOrigin: window.location.origin, |
||||
|
expectedRPID: window.location.hostname, |
||||
|
}); |
||||
|
|
||||
|
// references for parsing auth data and getting the public key
|
||||
|
// https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/parseAuthenticatorData.ts#L11
|
||||
|
// https://chatgpt.com/share/78a5c91d-099d-46dc-aa6d-fc0c916509fa
|
||||
|
// https://chatgpt.com/share/3c13f061-6031-45bc-a2d7-3347c1e7a2d7
|
||||
|
|
||||
|
const credIdBase64Url = verification.registrationInfo?.credentialID as string; |
||||
|
if (attResp.rawId !== credIdBase64Url) { |
||||
|
console.log("Warning! The raw ID does not match the credential ID."); |
||||
|
} |
||||
|
const credIdHex = Buffer.from( |
||||
|
base64URLStringToArrayBuffer(credIdBase64Url), |
||||
|
).toString("hex"); |
||||
|
const { publicKeyJwk } = cborToKeys( |
||||
|
verification.registrationInfo?.credentialPublicKey as Uint8Array, |
||||
|
); |
||||
|
|
||||
|
return { |
||||
|
authData: verification.registrationInfo?.attestationObject, |
||||
|
credIdHex: credIdHex, |
||||
|
publicKeyJwk: publicKeyJwk, |
||||
|
publicKeyBytes: verification.registrationInfo |
||||
|
?.credentialPublicKey as Uint8Array, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
export class PeerSetup { |
||||
|
public authenticatorData?: ArrayBuffer; |
||||
|
public challenge?: Uint8Array; |
||||
|
public clientDataJsonBase64Url?: Base64URLString; |
||||
|
public signature?: Base64URLString; |
||||
|
|
||||
|
public async createJwtSimplewebauthn( |
||||
|
issuerDid: string, |
||||
|
payload: object, |
||||
|
credIdHex: string, |
||||
|
expMinutes: number = 1, |
||||
|
) { |
||||
|
const credentialId = arrayBufferToBase64URLString( |
||||
|
Buffer.from(credIdHex, "hex").buffer, |
||||
|
); |
||||
|
const issuedAt = Math.floor(Date.now() / 1000); |
||||
|
const expiryTime = Math.floor(Date.now() / 1000) + expMinutes * 60; // some minutes from now
|
||||
|
const fullPayload = { |
||||
|
...payload, |
||||
|
exp: expiryTime, |
||||
|
iat: issuedAt, |
||||
|
iss: issuerDid, |
||||
|
}; |
||||
|
this.challenge = new Uint8Array(Buffer.from(JSON.stringify(fullPayload))); |
||||
|
// const payloadHash: Uint8Array = sha256(this.challenge);
|
||||
|
const options: PublicKeyCredentialRequestOptionsJSON = |
||||
|
await generateAuthenticationOptions({ |
||||
|
challenge: this.challenge, |
||||
|
rpID: window.location.hostname, |
||||
|
allowCredentials: [{ id: credentialId }], |
||||
|
}); |
||||
|
// console.log("simple authentication options", options);
|
||||
|
|
||||
|
const clientAuth = await startAuthentication(options); |
||||
|
// console.log("simple credential get", clientAuth);
|
||||
|
|
||||
|
const authenticatorDataBase64Url = clientAuth.response.authenticatorData; |
||||
|
this.authenticatorData = Buffer.from( |
||||
|
clientAuth.response.authenticatorData, |
||||
|
"base64", |
||||
|
).buffer; |
||||
|
this.clientDataJsonBase64Url = clientAuth.response.clientDataJSON; |
||||
|
// console.log("simple authenticatorData for signing", this.authenticatorData);
|
||||
|
this.signature = clientAuth.response.signature; |
||||
|
|
||||
|
// Our custom type of JWANT means the signature is based on a concatenation of the two Webauthn properties
|
||||
|
const header: JWTPayload = { typ: "JWANT", alg: "ES256" }; |
||||
|
const headerBase64 = Buffer.from(JSON.stringify(header)) |
||||
|
.toString("base64") |
||||
|
.replace(/\+/g, "-") |
||||
|
.replace(/\//g, "_") |
||||
|
.replace(/=+$/, ""); |
||||
|
|
||||
|
const dataInJwt = { |
||||
|
AuthenticationDataB64URL: authenticatorDataBase64Url, |
||||
|
ClientDataJSONB64URL: this.clientDataJsonBase64Url, |
||||
|
exp: expiryTime, |
||||
|
iat: issuedAt, |
||||
|
iss: issuerDid, |
||||
|
}; |
||||
|
const dataInJwtString = JSON.stringify(dataInJwt); |
||||
|
const payloadBase64 = Buffer.from(dataInJwtString) |
||||
|
.toString("base64") |
||||
|
.replace(/\+/g, "-") |
||||
|
.replace(/\//g, "_") |
||||
|
.replace(/=+$/, ""); |
||||
|
|
||||
|
const signature = clientAuth.response.signature; |
||||
|
|
||||
|
return headerBase64 + "." + payloadBase64 + "." + signature; |
||||
|
} |
||||
|
|
||||
|
public async createJwtNavigator( |
||||
|
issuerDid: string, |
||||
|
payload: object, |
||||
|
credIdHex: string, |
||||
|
expMinutes: number = 1, |
||||
|
) { |
||||
|
const issuedAt = Math.floor(Date.now() / 1000); |
||||
|
const expiryTime = Math.floor(Date.now() / 1000) + expMinutes * 60; // some minutes from now
|
||||
|
const fullPayload = { |
||||
|
...payload, |
||||
|
exp: expiryTime, |
||||
|
iat: issuedAt, |
||||
|
iss: issuerDid, |
||||
|
}; |
||||
|
const dataToSignString = JSON.stringify(fullPayload); |
||||
|
const dataToSignBuffer = Buffer.from(dataToSignString); |
||||
|
const credentialId = Buffer.from(credIdHex, "hex"); |
||||
|
|
||||
|
// console.log("lower credentialId", credentialId);
|
||||
|
this.challenge = new Uint8Array(dataToSignBuffer); |
||||
|
const options = { |
||||
|
publicKey: { |
||||
|
allowCredentials: [ |
||||
|
{ |
||||
|
id: credentialId, |
||||
|
type: "public-key" as const, |
||||
|
}, |
||||
|
], |
||||
|
challenge: this.challenge.buffer, |
||||
|
rpID: window.location.hostname, |
||||
|
userVerification: "preferred" as const, |
||||
|
}, |
||||
|
}; |
||||
|
|
||||
|
const credential = await navigator.credentials.get(options); |
||||
|
// console.log("nav credential get", credential);
|
||||
|
|
||||
|
this.authenticatorData = credential?.response.authenticatorData; |
||||
|
const authenticatorDataBase64Url = arrayBufferToBase64URLString( |
||||
|
this.authenticatorData as ArrayBuffer, |
||||
|
); |
||||
|
|
||||
|
this.clientDataJsonBase64Url = arrayBufferToBase64URLString( |
||||
|
credential?.response.clientDataJSON, |
||||
|
); |
||||
|
|
||||
|
// Our custom type of JWANT means the signature is based on a concatenation of the two Webauthn properties
|
||||
|
const header: JWTPayload = { typ: "JWANT", alg: "ES256" }; |
||||
|
const headerBase64 = Buffer.from(JSON.stringify(header)) |
||||
|
.toString("base64") |
||||
|
.replace(/\+/g, "-") |
||||
|
.replace(/\//g, "_") |
||||
|
.replace(/=+$/, ""); |
||||
|
|
||||
|
const dataInJwt = { |
||||
|
AuthenticationDataB64URL: authenticatorDataBase64Url, |
||||
|
ClientDataJSONB64URL: this.clientDataJsonBase64Url, |
||||
|
exp: expiryTime, |
||||
|
iat: issuedAt, |
||||
|
iss: issuerDid, |
||||
|
}; |
||||
|
const dataInJwtString = JSON.stringify(dataInJwt); |
||||
|
const payloadBase64 = Buffer.from(dataInJwtString) |
||||
|
.toString("base64") |
||||
|
.replace(/\+/g, "-") |
||||
|
.replace(/\//g, "_") |
||||
|
.replace(/=+$/, ""); |
||||
|
|
||||
|
const origSignature = Buffer.from(credential?.response.signature).toString( |
||||
|
"base64", |
||||
|
); |
||||
|
this.signature = origSignature |
||||
|
.replace(/\+/g, "-") |
||||
|
.replace(/\//g, "_") |
||||
|
.replace(/=+$/, ""); |
||||
|
|
||||
|
const jwt = headerBase64 + "." + payloadBase64 + "." + this.signature; |
||||
|
return jwt; |
||||
|
} |
||||
|
|
||||
|
// To use this, add the asn1-ber library and add this import:
|
||||
|
// import asn1 from "asn1-ber";
|
||||
|
//
|
||||
|
// return a low-level signing function, similar to createJWS approach
|
||||
|
// async webAuthnES256KSigner(credentialID: string) {
|
||||
|
// return async (data: string | Uint8Array) => {
|
||||
|
// // get signature from WebAuthn
|
||||
|
// const signature = await this.generateWebAuthnSignature(data);
|
||||
|
//
|
||||
|
// // This converts from the browser ArrayBuffer to a Node.js Buffer, which is a requirement for the asn1 library.
|
||||
|
// const signatureBuffer = Buffer.from(signature);
|
||||
|
// console.log("lower signature inside signer", signature);
|
||||
|
// console.log("lower buffer signature inside signer", signatureBuffer);
|
||||
|
// console.log("lower base64 buffer signature inside signer", signatureBuffer.toString("base64"));
|
||||
|
// // Decode the DER-encoded signature to extract R and S values
|
||||
|
// const reader = new asn1.BerReader(signatureBuffer);
|
||||
|
// console.log("lower after reader");
|
||||
|
// reader.readSequence();
|
||||
|
// console.log("lower after read sequence");
|
||||
|
// const r = reader.readString(asn1.Ber.Integer, true);
|
||||
|
// console.log("lower after r");
|
||||
|
// const s = reader.readString(asn1.Ber.Integer, true);
|
||||
|
// console.log("lower after r & s");
|
||||
|
//
|
||||
|
// // Ensure R and S are 32 bytes each
|
||||
|
// const rBuffer = Buffer.from(r);
|
||||
|
// const sBuffer = Buffer.from(s);
|
||||
|
// console.log("lower after rBuffer & sBuffer", rBuffer, sBuffer);
|
||||
|
// const rWithoutPrefix = rBuffer.length > 32 ? rBuffer.slice(1) : rBuffer;
|
||||
|
// const sWithoutPrefix = sBuffer.length > 32 ? sBuffer.slice(1) : sBuffer;
|
||||
|
// const rPadded =
|
||||
|
// rWithoutPrefix.length < 32
|
||||
|
// ? Buffer.concat([Buffer.alloc(32 - rWithoutPrefix.length), rBuffer])
|
||||
|
// : rWithoutPrefix;
|
||||
|
// const sPadded =
|
||||
|
// rWithoutPrefix.length < 32
|
||||
|
// ? Buffer.concat([Buffer.alloc(32 - sWithoutPrefix.length), sBuffer])
|
||||
|
// : sWithoutPrefix;
|
||||
|
//
|
||||
|
// // Concatenate R and S to form the 64-byte array (ECDSA signature format expected by JWT)
|
||||
|
// const combinedSignature = Buffer.concat([rPadded, sPadded]);
|
||||
|
// console.log(
|
||||
|
// "lower combinedSignature",
|
||||
|
// combinedSignature.length,
|
||||
|
// combinedSignature,
|
||||
|
// );
|
||||
|
//
|
||||
|
// const combSig64 = combinedSignature.toString("base64");
|
||||
|
// console.log("lower combSig64", combSig64);
|
||||
|
// const combSig64Url = combSig64
|
||||
|
// .replace(/\+/g, "-")
|
||||
|
// .replace(/\//g, "_")
|
||||
|
// .replace(/=+$/, "");
|
||||
|
// console.log("lower combSig64Url", combSig64Url);
|
||||
|
// return combSig64Url;
|
||||
|
// };
|
||||
|
// }
|
||||
|
} |
||||
|
|
||||
|
export async function createDidPeerJwt( |
||||
|
did: string, |
||||
|
credIdHex: string, |
||||
|
payload: object, |
||||
|
): Promise<string> { |
||||
|
const peerSetup = new PeerSetup(); |
||||
|
const jwt = await peerSetup.createJwtNavigator(did, payload, credIdHex); |
||||
|
return jwt; |
||||
|
} |
||||
|
|
||||
|
// I'd love to use this but it doesn't verify.
|
||||
|
// Requires:
|
||||
|
// npm install @noble/curves
|
||||
|
// ... and this import:
|
||||
|
// import { p256 } from "@noble/curves/p256";
|
||||
|
export async function verifyJwtP256( |
||||
|
credIdHex: string, |
||||
|
issuerDid: string, |
||||
|
authenticatorData: ArrayBuffer, |
||||
|
challenge: Uint8Array, |
||||
|
clientDataJsonBase64Url: Base64URLString, |
||||
|
signature: Base64URLString, |
||||
|
) { |
||||
|
const authDataFromBase = Buffer.from(authenticatorData); |
||||
|
const clientDataFromBase = Buffer.from(clientDataJsonBase64Url, "base64"); |
||||
|
const sigBuffer = Buffer.from(signature, "base64"); |
||||
|
const finalSigBuffer = unwrapEC2Signature(sigBuffer); |
||||
|
const publicKeyBytes = peerDidToPublicKeyBytes(issuerDid); |
||||
|
|
||||
|
// Hash the client data
|
||||
|
const hash = sha256(clientDataFromBase); |
||||
|
|
||||
|
// Construct the preimage
|
||||
|
const preimage = Buffer.concat([authDataFromBase, hash]); |
||||
|
|
||||
|
const isValid = p256.verify( |
||||
|
finalSigBuffer, |
||||
|
new Uint8Array(preimage), |
||||
|
publicKeyBytes, |
||||
|
); |
||||
|
return isValid; |
||||
|
} |
||||
|
|
||||
|
export async function verifyJwtSimplewebauthn( |
||||
|
credIdHex: string, |
||||
|
issuerDid: string, |
||||
|
authenticatorData: ArrayBuffer, |
||||
|
challenge: Uint8Array, |
||||
|
clientDataJsonBase64Url: Base64URLString, |
||||
|
signature: Base64URLString, |
||||
|
) { |
||||
|
const authData = arrayToBase64Url(Buffer.from(authenticatorData)); |
||||
|
const publicKeyBytes = peerDidToPublicKeyBytes(issuerDid); |
||||
|
const credId = arrayBufferToBase64URLString( |
||||
|
Buffer.from(credIdHex, "hex").buffer, |
||||
|
); |
||||
|
const authOpts: VerifyAuthenticationResponseOpts = { |
||||
|
authenticator: { |
||||
|
credentialID: credId, |
||||
|
credentialPublicKey: publicKeyBytes, |
||||
|
counter: 0, |
||||
|
}, |
||||
|
expectedChallenge: arrayToBase64Url(challenge), |
||||
|
expectedOrigin: window.location.origin, |
||||
|
expectedRPID: window.location.hostname, |
||||
|
response: { |
||||
|
authenticatorAttachment: "platform", |
||||
|
clientExtensionResults: {}, |
||||
|
id: credId, |
||||
|
rawId: credId, |
||||
|
response: { |
||||
|
authenticatorData: authData, |
||||
|
clientDataJSON: clientDataJsonBase64Url, |
||||
|
signature: signature, |
||||
|
}, |
||||
|
type: "public-key", |
||||
|
}, |
||||
|
}; |
||||
|
const verification = await verifyAuthenticationResponse(authOpts); |
||||
|
return verification.verified; |
||||
|
} |
||||
|
|
||||
|
// similar code is in endorser-ch util-crypto.ts verifyPeerSignature
|
||||
|
export async function verifyJwtWebCrypto( |
||||
|
credId: Base64URLString, |
||||
|
issuerDid: string, |
||||
|
authenticatorData: ArrayBuffer, |
||||
|
challenge: Uint8Array, |
||||
|
clientDataJsonBase64Url: Base64URLString, |
||||
|
signature: Base64URLString, |
||||
|
) { |
||||
|
const authDataFromBase = Buffer.from(authenticatorData); |
||||
|
const clientDataFromBase = Buffer.from(clientDataJsonBase64Url, "base64"); |
||||
|
const sigBuffer = Buffer.from(signature, "base64"); |
||||
|
const finalSigBuffer = unwrapEC2Signature(sigBuffer); |
||||
|
|
||||
|
// Hash the client data
|
||||
|
const hash = sha256(clientDataFromBase); |
||||
|
|
||||
|
// Construct the preimage
|
||||
|
const preimage = Buffer.concat([authDataFromBase, hash]); |
||||
|
return verifyPeerSignature(preimage, issuerDid, finalSigBuffer); |
||||
|
} |
||||
|
|
||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
|
async function peerDidToDidDocument(did: string): Promise<DIDResolutionResult> { |
||||
|
if (!did.startsWith("did:peer:0z")) { |
||||
|
throw new Error( |
||||
|
"This only verifies a peer DID, method 0, encoded base58btc.", |
||||
|
); |
||||
|
} |
||||
|
// this is basically hard-coded from https://www.w3.org/TR/did-core/#example-various-verification-method-types
|
||||
|
// (another reference is the @aviarytech/did-peer resolver)
|
||||
|
|
||||
|
/** |
||||
|
* Looks like JsonWebKey2020 isn't too difficult: |
||||
|
* - change context security/suites link to jws-2020/v1 |
||||
|
* - change publicKeyMultibase to publicKeyJwk generated with cborToKeys |
||||
|
* - change type to JsonWebKey2020 |
||||
|
*/ |
||||
|
|
||||
|
const id = did.split(":")[2]; |
||||
|
const multibase = id.slice(1); |
||||
|
const encnumbasis = multibase.slice(1); |
||||
|
const didDocument = { |
||||
|
"@context": [ |
||||
|
"https://www.w3.org/ns/did/v1", |
||||
|
"https://w3id.org/security/suites/secp256k1-2019/v1", |
||||
|
], |
||||
|
assertionMethod: [did + "#" + encnumbasis], |
||||
|
authentication: [did + "#" + encnumbasis], |
||||
|
capabilityDelegation: [did + "#" + encnumbasis], |
||||
|
capabilityInvocation: [did + "#" + encnumbasis], |
||||
|
id: did, |
||||
|
keyAgreement: undefined, |
||||
|
service: undefined, |
||||
|
verificationMethod: [ |
||||
|
{ |
||||
|
controller: did, |
||||
|
id: did + "#" + encnumbasis, |
||||
|
publicKeyMultibase: multibase, |
||||
|
type: "EcdsaSecp256k1VerificationKey2019", |
||||
|
}, |
||||
|
], |
||||
|
}; |
||||
|
return { |
||||
|
didDocument, |
||||
|
didDocumentMetadata: {}, |
||||
|
didResolutionMetadata: { contentType: "application/did+ld+json" }, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
// convert COSE public key to PEM format
|
||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
|
function COSEtoPEM(cose: Buffer) { |
||||
|
// const alg = cose.get(3); // Algorithm
|
||||
|
const x = cose[-2]; // x-coordinate
|
||||
|
const y = cose[-3]; // y-coordinate
|
||||
|
|
||||
|
// Ensure the coordinates are in the correct format
|
||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
|
// @ts-expect-error because it complains about the type of x and y
|
||||
|
const pubKeyBuffer = Buffer.concat([Buffer.from([0x04]), x, y]); |
||||
|
|
||||
|
// Convert to PEM format
|
||||
|
const pem = `-----BEGIN PUBLIC KEY-----
|
||||
|
${pubKeyBuffer.toString("base64")} |
||||
|
-----END PUBLIC KEY-----`;
|
||||
|
|
||||
|
return pem; |
||||
|
} |
||||
|
|
||||
|
// tried the base64url library but got an error using their Buffer
|
||||
|
export function base64urlDecodeString(input: string) { |
||||
|
return atob(input.replace(/-/g, "+").replace(/_/g, "/")); |
||||
|
} |
||||
|
|
||||
|
// tried the base64url library but got an error using their Buffer
|
||||
|
export function base64urlEncodeString(input: string) { |
||||
|
return btoa(input).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); |
||||
|
} |
||||
|
|
||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
|
function base64urlDecodeArrayBuffer(input: string) { |
||||
|
input = input.replace(/-/g, "+").replace(/_/g, "/"); |
||||
|
const pad = input.length % 4 === 0 ? "" : "====".slice(input.length % 4); |
||||
|
const str = atob(input + pad); |
||||
|
const bytes = new Uint8Array(str.length); |
||||
|
for (let i = 0; i < str.length; i++) { |
||||
|
bytes[i] = str.charCodeAt(i); |
||||
|
} |
||||
|
return bytes.buffer; |
||||
|
} |
||||
|
|
||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
|
function base64urlEncodeArrayBuffer(buffer: ArrayBuffer) { |
||||
|
const str = String.fromCharCode(...new Uint8Array(buffer)); |
||||
|
return base64urlEncodeString(str); |
||||
|
} |
||||
|
|
||||
|
// from @simplewebauthn/browser
|
||||
|
function arrayBufferToBase64URLString(buffer: ArrayBuffer) { |
||||
|
const bytes = new Uint8Array(buffer); |
||||
|
let str = ""; |
||||
|
for (const charCode of bytes) { |
||||
|
str += String.fromCharCode(charCode); |
||||
|
} |
||||
|
const base64String = btoa(str); |
||||
|
return base64String.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); |
||||
|
} |
||||
|
|
||||
|
// from @simplewebauthn/browser
|
||||
|
function base64URLStringToArrayBuffer(base64URLString: string) { |
||||
|
const base64 = base64URLString.replace(/-/g, "+").replace(/_/g, "/"); |
||||
|
const padLength = (4 - (base64.length % 4)) % 4; |
||||
|
const padded = base64.padEnd(base64.length + padLength, "="); |
||||
|
const binary = atob(padded); |
||||
|
const buffer = new ArrayBuffer(binary.length); |
||||
|
const bytes = new Uint8Array(buffer); |
||||
|
for (let i = 0; i < binary.length; i++) { |
||||
|
bytes[i] = binary.charCodeAt(i); |
||||
|
} |
||||
|
return buffer; |
||||
|
} |
||||
|
|
||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
|
async function pemToCryptoKey(pem: string) { |
||||
|
const binaryDerString = atob( |
||||
|
pem |
||||
|
.split("\n") |
||||
|
.filter((x) => !x.includes("-----")) |
||||
|
.join(""), |
||||
|
); |
||||
|
const binaryDer = new Uint8Array(binaryDerString.length); |
||||
|
for (let i = 0; i < binaryDerString.length; i++) { |
||||
|
binaryDer[i] = binaryDerString.charCodeAt(i); |
||||
|
} |
||||
|
// console.log("binaryDer", binaryDer.buffer);
|
||||
|
return await window.crypto.subtle.importKey( |
||||
|
"spki", |
||||
|
binaryDer.buffer, |
||||
|
{ |
||||
|
name: "RSASSA-PKCS1-v1_5", |
||||
|
hash: "SHA-256", |
||||
|
}, |
||||
|
true, |
||||
|
["verify"], |
||||
|
); |
||||
|
} |
@ -0,0 +1,105 @@ |
|||||
|
// from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/iso/isoCrypto/unwrapEC2Signature.ts
|
||||
|
import { AsnParser } from "@peculiar/asn1-schema"; |
||||
|
import { ECDSASigValue } from "@peculiar/asn1-ecc"; |
||||
|
|
||||
|
/** |
||||
|
* In WebAuthn, EC2 signatures are wrapped in ASN.1 structure so we need to peel r and s apart. |
||||
|
* |
||||
|
* See https://www.w3.org/TR/webauthn-2/#sctn-signature-attestation-types
|
||||
|
*/ |
||||
|
export function unwrapEC2Signature(signature: Uint8Array): Uint8Array { |
||||
|
const parsedSignature = AsnParser.parse(signature, ECDSASigValue); |
||||
|
let rBytes = new Uint8Array(parsedSignature.r); |
||||
|
let sBytes = new Uint8Array(parsedSignature.s); |
||||
|
|
||||
|
if (shouldRemoveLeadingZero(rBytes)) { |
||||
|
rBytes = rBytes.slice(1); |
||||
|
} |
||||
|
|
||||
|
if (shouldRemoveLeadingZero(sBytes)) { |
||||
|
sBytes = sBytes.slice(1); |
||||
|
} |
||||
|
|
||||
|
const finalSignature = isoUint8ArrayConcat([rBytes, sBytes]); |
||||
|
|
||||
|
return finalSignature; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Determine if the DER-specific `00` byte at the start of an ECDSA signature byte sequence |
||||
|
* should be removed based on the following logic: |
||||
|
* |
||||
|
* "If the leading byte is 0x0, and the the high order bit on the second byte is not set to 0, |
||||
|
* then remove the leading 0x0 byte" |
||||
|
*/ |
||||
|
function shouldRemoveLeadingZero(bytes: Uint8Array): boolean { |
||||
|
return bytes[0] === 0x0 && (bytes[1] & (1 << 7)) !== 0; |
||||
|
} |
||||
|
|
||||
|
// from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/iso/isoUint8Array.ts#L49
|
||||
|
/** |
||||
|
* Combine multiple Uint8Arrays into a single Uint8Array |
||||
|
*/ |
||||
|
export function isoUint8ArrayConcat(arrays: Uint8Array[]): Uint8Array { |
||||
|
let pointer = 0; |
||||
|
const totalLength = arrays.reduce((prev, curr) => prev + curr.length, 0); |
||||
|
|
||||
|
const toReturn = new Uint8Array(totalLength); |
||||
|
|
||||
|
arrays.forEach((arr) => { |
||||
|
toReturn.set(arr, pointer); |
||||
|
pointer += arr.length; |
||||
|
}); |
||||
|
|
||||
|
return toReturn; |
||||
|
} |
||||
|
|
||||
|
// from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/iso/isoCrypto/getWebCrypto.ts
|
||||
|
let webCrypto: { subtle: SubtleCrypto } | undefined = undefined; |
||||
|
export function getWebCrypto(): Promise<{ subtle: SubtleCrypto }> { |
||||
|
/** |
||||
|
* Hello there! If you came here wondering why this method is asynchronous when use of |
||||
|
* `globalThis.crypto` is not, it's to minimize a bunch of refactor related to making this |
||||
|
* synchronous. For example, `generateRegistrationOptions()` and `generateAuthenticationOptions()` |
||||
|
* become synchronous if we make this synchronous (since nothing else in that method is async) |
||||
|
* which represents a breaking API change in this library's core API. |
||||
|
* |
||||
|
* TODO: If it's after February 2025 when you read this then consider whether it still makes sense |
||||
|
* to keep this method asynchronous. |
||||
|
*/ |
||||
|
const toResolve: Promise<{ subtle: SubtleCrypto }> = new Promise( |
||||
|
(resolve, reject) => { |
||||
|
if (webCrypto) { |
||||
|
return resolve(webCrypto); |
||||
|
} |
||||
|
/** |
||||
|
* Naively attempt to access Crypto as a global object, which popular ESM-centric run-times |
||||
|
* support (and Node v20+) |
||||
|
*/ |
||||
|
const _globalThisCrypto = |
||||
|
_getWebCryptoInternals.stubThisGlobalThisCrypto(); |
||||
|
if (_globalThisCrypto) { |
||||
|
webCrypto = _globalThisCrypto; |
||||
|
return resolve(webCrypto); |
||||
|
} |
||||
|
// We tried to access it both in Node and globally, so bail out
|
||||
|
return reject(new MissingWebCrypto()); |
||||
|
}, |
||||
|
); |
||||
|
return toResolve; |
||||
|
} |
||||
|
class MissingWebCrypto extends Error { |
||||
|
constructor() { |
||||
|
const message = "An instance of the Crypto API could not be located"; |
||||
|
super(message); |
||||
|
this.name = "MissingWebCrypto"; |
||||
|
} |
||||
|
} |
||||
|
// Make it possible to stub return values during testing
|
||||
|
const _getWebCryptoInternals = { |
||||
|
stubThisGlobalThisCrypto: () => globalThis.crypto, |
||||
|
// Make it possible to reset the `webCrypto` at the top of the file
|
||||
|
setCachedCrypto: (newCrypto: { subtle: SubtleCrypto }) => { |
||||
|
webCrypto = newCrypto; |
||||
|
}, |
||||
|
}; |
@ -0,0 +1,11 @@ |
|||||
|
export function urlBase64ToUint8Array(base64String: string): Uint8Array { |
||||
|
const padding = "=".repeat((4 - (base64String.length % 4)) % 4); |
||||
|
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); |
||||
|
const rawData = window.atob(base64); |
||||
|
const outputArray = new Uint8Array(rawData.length); |
||||
|
|
||||
|
for (let i = 0; i < rawData.length; ++i) { |
||||
|
outputArray[i] = rawData.charCodeAt(i); |
||||
|
} |
||||
|
return outputArray; |
||||
|
} |
@ -1,20 +0,0 @@ |
|||||
// @ts-check
|
|
||||
import { defineStore } from "pinia"; |
|
||||
|
|
||||
export const useAppStore = defineStore({ |
|
||||
id: "app", |
|
||||
state: () => ({ |
|
||||
_projectId: |
|
||||
typeof localStorage.getItem("projectId") === "undefined" |
|
||||
? "" |
|
||||
: localStorage.getItem("projectId"), |
|
||||
}), |
|
||||
getters: { |
|
||||
projectId: (state): string => state._projectId as string, |
|
||||
}, |
|
||||
actions: { |
|
||||
async setProjectId(newProjectId: string) { |
|
||||
localStorage.setItem("projectId", newProjectId); |
|
||||
}, |
|
||||
}, |
|
||||
}); |
|
@ -0,0 +1,97 @@ |
|||||
|
<template> |
||||
|
<QuickNav /> |
||||
|
<!-- CONTENT --> |
||||
|
<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"> |
||||
|
<!-- 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" /> |
||||
|
</button> |
||||
|
Raw Claim |
||||
|
</h1> |
||||
|
</div> |
||||
|
|
||||
|
<div class="flex"> |
||||
|
<textarea rows="20" class="border-2 w-full" v-model="claimStr"></textarea> |
||||
|
</div> |
||||
|
<button |
||||
|
class="block w-full text-center text-lg font-bold uppercase 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-3 rounded-md" |
||||
|
@click="submitClaim()" |
||||
|
> |
||||
|
Sign & Send |
||||
|
</button> |
||||
|
</section> |
||||
|
</template> |
||||
|
|
||||
|
<script lang="ts"> |
||||
|
import { Component, Vue } from "vue-facing-decorator"; |
||||
|
import { Router } from "vue-router"; |
||||
|
|
||||
|
import { NotificationIface } from "@/constants/app"; |
||||
|
import { retrieveSettingsForActiveAccount } from "@/db/index"; |
||||
|
import * as serverUtil from "@/libs/endorserServer"; |
||||
|
import QuickNav from "@/components/QuickNav.vue"; |
||||
|
|
||||
|
@Component({ |
||||
|
components: { QuickNav }, |
||||
|
}) |
||||
|
export default class ClaimAddRawView extends Vue { |
||||
|
$notify!: (notification: NotificationIface, timeout?: number) => void; |
||||
|
|
||||
|
accountIdentityStr: string = "null"; |
||||
|
activeDid = ""; |
||||
|
apiServer = ""; |
||||
|
claimStr = ""; |
||||
|
|
||||
|
async mounted() { |
||||
|
const settings = await retrieveSettingsForActiveAccount(); |
||||
|
this.activeDid = settings.activeDid || ""; |
||||
|
this.apiServer = settings.apiServer || ""; |
||||
|
|
||||
|
this.claimStr = (this.$route as Router).query["claim"]; |
||||
|
try { |
||||
|
this.veriClaim = JSON.parse(this.claimStr); |
||||
|
this.claimStr = JSON.stringify(this.veriClaim, null, 2); |
||||
|
} catch (e) { |
||||
|
// ignore a parse |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async submitClaim() { |
||||
|
const fullClaim = JSON.parse(this.claimStr); |
||||
|
const result = await serverUtil.createAndSubmitClaim( |
||||
|
fullClaim, |
||||
|
this.activeDid, |
||||
|
this.apiServer, |
||||
|
this.axios, |
||||
|
); |
||||
|
if (result.type === "success") { |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "success", |
||||
|
title: "Success", |
||||
|
text: "Claim submitted.", |
||||
|
}, |
||||
|
5000, |
||||
|
); |
||||
|
} else { |
||||
|
console.error("Got error submitting the claim:", result); |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Error", |
||||
|
text: "There was a problem submitting the claim.", |
||||
|
}, |
||||
|
-1, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
</script> |
@ -0,0 +1,871 @@ |
|||||
|
<template> |
||||
|
<QuickNav /> |
||||
|
<TopMessage /> |
||||
|
<!-- CONTENT --> |
||||
|
<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"> |
||||
|
<!-- 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" /> |
||||
|
</button> |
||||
|
<span |
||||
|
v-if=" |
||||
|
libsUtil.isGiveRecordTheUserCanConfirm( |
||||
|
isRegistered, |
||||
|
veriClaim, |
||||
|
activeDid, |
||||
|
confirmerIdList, |
||||
|
) |
||||
|
" |
||||
|
> |
||||
|
Do you agree? |
||||
|
</span> |
||||
|
<span v-else> Confirmation Details </span> |
||||
|
</h1> |
||||
|
</div> |
||||
|
|
||||
|
<div v-if="giveDetails && !isLoading"> |
||||
|
<div class="flex justify-center"> |
||||
|
<button |
||||
|
class="col-span-1 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-4 py-2 rounded-md" |
||||
|
v-if=" |
||||
|
libsUtil.isGiveRecordTheUserCanConfirm( |
||||
|
isRegistered, |
||||
|
veriClaim, |
||||
|
activeDid, |
||||
|
confirmerIdList, |
||||
|
) |
||||
|
" |
||||
|
@click="confirmConfirmClaim()" |
||||
|
> |
||||
|
Confirm |
||||
|
<fa icon="circle-check" class="ml-2 text-white cursor-pointer" /> |
||||
|
</button> |
||||
|
<button |
||||
|
v-else |
||||
|
@click="notifyWhyCannotConfirm()" |
||||
|
class="col-span-1 bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md" |
||||
|
> |
||||
|
Confirm |
||||
|
<fa icon="circle-check" class="ml-2 text-white cursor-pointer" /> |
||||
|
</button> |
||||
|
<a |
||||
|
v-if="isRegistered" |
||||
|
class="col-span-1 bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-2 px-4 py-2 rounded-md" |
||||
|
:href="urlForNewGive" |
||||
|
> |
||||
|
Record a Similar One |
||||
|
</a> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Details --> |
||||
|
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mt-4"> |
||||
|
<div class="flex gap-4 overflow-hidden"> |
||||
|
<div class="overflow-hidden"> |
||||
|
<div class="text-sm"> |
||||
|
<div> |
||||
|
<fa icon="arrow-down" class="fa-fw text-slate-400" /> |
||||
|
{{ giverName }} |
||||
|
</div> |
||||
|
<div class="ml-6">gave</div> |
||||
|
<div v-if="giveDetails.amount"> |
||||
|
<fa icon="hand-holding-dollar" class="fa-fw text-slate-400" /> |
||||
|
{{ displayAmount(giveDetails.unit, giveDetails.amount) }} |
||||
|
</div> |
||||
|
<div v-if="giveDetails.description"> |
||||
|
<fa icon="message" class="fa-fw text-slate-400" /> |
||||
|
{{ giveDetails.amount ? "and:" : "" }} |
||||
|
{{ giveDetails.description }} |
||||
|
</div> |
||||
|
<div class="ml-6">to</div> |
||||
|
<div> |
||||
|
<fa icon="arrow-up" class="fa-fw text-slate-400" /> |
||||
|
{{ recipientName }} |
||||
|
</div> |
||||
|
<div> |
||||
|
<fa icon="calendar" class="fa-fw text-slate-400" /> |
||||
|
on |
||||
|
{{ giveDetails.issuedAt.substring(0, 10) }} |
||||
|
</div> |
||||
|
|
||||
|
<!-- Fullfills Links --> |
||||
|
|
||||
|
<!-- fullfills links for a give --> |
||||
|
<div class="mt-2" v-if="giveDetails?.fulfillsPlanHandleId"> |
||||
|
<router-link |
||||
|
:to=" |
||||
|
'/project/' + |
||||
|
encodeURIComponent(giveDetails?.fulfillsPlanHandleId || '') |
||||
|
" |
||||
|
class="text-blue-500 mt-2 cursor-pointer" |
||||
|
target="_blank" |
||||
|
> |
||||
|
This fulfills a bigger plan |
||||
|
<fa icon="arrow-up-right-from-square" class="fa-fw" /> |
||||
|
</router-link> |
||||
|
</div> |
||||
|
<!-- if there's another, it's probably fulfilling an offer, too --> |
||||
|
<div |
||||
|
v-if=" |
||||
|
giveDetails?.fulfillsType && |
||||
|
giveDetails?.fulfillsType !== 'PlanAction' && |
||||
|
giveDetails?.fulfillsHandleId |
||||
|
" |
||||
|
> |
||||
|
<!-- router-link to /claim/ only changes URL path --> |
||||
|
<router-link |
||||
|
:to=" |
||||
|
'/claim/' + |
||||
|
encodeURIComponent(giveDetails?.fulfillsHandleId || '') |
||||
|
" |
||||
|
class="text-blue-500 mt-2 cursor-pointer" |
||||
|
target="_blank" |
||||
|
> |
||||
|
This fulfills |
||||
|
{{ |
||||
|
capitalizeAndInsertSpacesBeforeCapsWithAPrefix( |
||||
|
giveDetails?.fulfillsType || "", |
||||
|
) |
||||
|
}} |
||||
|
<fa icon="arrow-up-right-from-square" class="fa-fw" /> |
||||
|
</router-link> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="mt-2"> |
||||
|
<fa icon="comment" class="text-slate-400" /> |
||||
|
{{ issuerName }} posted that. |
||||
|
</div> |
||||
|
|
||||
|
<div v-if="libsUtil.isGiveAction(veriClaim)" class="mt-4"> |
||||
|
<h2 class="font-bold uppercase text-xl mt-8 mb-2">Confirmations</h2> |
||||
|
|
||||
|
<span v-if="totalConfirmers() === 0">Nobody has confirmed this.</span> |
||||
|
<span v-else-if="totalConfirmers() === 1"> |
||||
|
One person confirmed this. |
||||
|
</span> |
||||
|
<span v-else> {{ totalConfirmers() }} people confirmed this. </span> |
||||
|
|
||||
|
<div v-if="totalConfirmers() > 0"> |
||||
|
<div |
||||
|
v-if=" |
||||
|
confirmerIdList.length === 0 && confsVisibleToIdList.length === 0 |
||||
|
" |
||||
|
> |
||||
|
Nobody that you know confirmed this claim, nor do they have any |
||||
|
confirmers in their network. |
||||
|
</div> |
||||
|
|
||||
|
<div |
||||
|
v-if=" |
||||
|
confirmerIdList.length === 0 && confsVisibleToIdList.length > 0 |
||||
|
" |
||||
|
> |
||||
|
<!-- Only show if this person has links to confirmers (below). --> |
||||
|
Nobody that you know issued or confirmed this claim. |
||||
|
</div> |
||||
|
<div v-if="confirmerIdList.length > 0"> |
||||
|
The following people issued or confirmed this claim. |
||||
|
<ul class="ml-4"> |
||||
|
<li |
||||
|
v-for="confirmerId in confirmerIdList" |
||||
|
:key="confirmerId" |
||||
|
class="list-disc ml-4" |
||||
|
> |
||||
|
<div class="flex gap-4"> |
||||
|
<div class="grow overflow-hidden"> |
||||
|
<div class="text-sm"> |
||||
|
{{ didInfo(confirmerId) }} |
||||
|
<span v-if="!serverUtil.isEmptyOrHiddenDid(confirmerId)"> |
||||
|
<button |
||||
|
@click=" |
||||
|
copyToClipboard( |
||||
|
'The DID of ' + confirmerId, |
||||
|
confirmerId, |
||||
|
) |
||||
|
" |
||||
|
> |
||||
|
<fa icon="copy" class="text-slate-400 fa-fw" /> |
||||
|
</button> |
||||
|
</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</li> |
||||
|
</ul> |
||||
|
</div> |
||||
|
|
||||
|
<!-- |
||||
|
Never need to show this message: |
||||
|
"Nobody that you know can see someone who confirmed this claim." |
||||
|
|
||||
|
If there is nobody in the confirmerIdList then we'll show the combined "nobody" message. |
||||
|
If there is somebody in the confirmerIdList then that's all they need to show. |
||||
|
--> |
||||
|
|
||||
|
<!-- Now show anyone linked to confirmers. --> |
||||
|
<div v-if="confsVisibleToIdList.length > 0"> |
||||
|
The following people can connect you with people who issued or |
||||
|
confirmed this claim. |
||||
|
<ul class="ml-4"> |
||||
|
<li |
||||
|
v-for="confsVisibleTo in confsVisibleToIdList" |
||||
|
:key="confsVisibleTo" |
||||
|
class="list-disc ml-4" |
||||
|
> |
||||
|
<div class="flex gap-4"> |
||||
|
<div class="grow overflow-hidden"> |
||||
|
<div class="text-sm"> |
||||
|
{{ didInfo(confsVisibleTo) }} |
||||
|
<span |
||||
|
v-if="!serverUtil.isEmptyOrHiddenDid(confsVisibleTo)" |
||||
|
> |
||||
|
<button |
||||
|
@click=" |
||||
|
copyToClipboard( |
||||
|
'The DID of ' + confsVisibleTo, |
||||
|
confsVisibleTo, |
||||
|
) |
||||
|
" |
||||
|
> |
||||
|
<fa icon="copy" class="text-slate-400 fa-fw" /> |
||||
|
</button> |
||||
|
</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</li> |
||||
|
</ul> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- explain if user cannot confirm --> |
||||
|
<!-- Note that these conditions are mirrored in userCanConfirm(). --> |
||||
|
<div v-if="!isRegistered"> |
||||
|
You cannot confirm this because you are not registered. Find someone |
||||
|
to register you, maybe on the Help page. |
||||
|
</div> |
||||
|
<div v-else-if="giveDetails.issuerDid == activeDid"> |
||||
|
You cannot confirm this because you issued this claim, so you already |
||||
|
count as confirming it. |
||||
|
</div> |
||||
|
<div v-else-if="serverUtil.containsHiddenDid(veriClaim.claim)"> |
||||
|
You cannot confirm this because some people are hidden. |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Note that a similar section is found in ClaimView.vue --> |
||||
|
<h2 |
||||
|
class="font-bold uppercase text-xl text-blue-500 mt-8 mb-2 cursor-pointer" |
||||
|
@click="showDetails = !showDetails" |
||||
|
> |
||||
|
{{ serverUtil.containsHiddenDid(veriClaim) ? "Visible " : "" }}Details |
||||
|
<span v-if="!showDetails"><fa icon="chevron-down" /></span> |
||||
|
<span v-else><fa icon="chevron-up" /></span> |
||||
|
</h2> |
||||
|
<div v-if="showDetails"> |
||||
|
<div |
||||
|
v-if=" |
||||
|
serverUtil.containsHiddenDid(veriClaim) && |
||||
|
R.isEmpty(veriClaimDidsVisible) |
||||
|
" |
||||
|
class="mb-2" |
||||
|
> |
||||
|
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, |
||||
|
<a @click="onClickShareClaim()" class="text-blue-500" |
||||
|
>click to send them this info</a |
||||
|
> |
||||
|
and see if they are willing to make an introduction. |
||||
|
</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, |
||||
|
<a |
||||
|
@click="copyToClipboard('Location', windowLocation.href)" |
||||
|
class="text-blue-500" |
||||
|
>share this page with them</a |
||||
|
> |
||||
|
and see if they are willing to make an introduction. |
||||
|
</span> |
||||
|
</div> |
||||
|
|
||||
|
<div v-if="!R.isEmpty(veriClaimDidsVisible)"> |
||||
|
Some of the details are not visible to you but they are visible to |
||||
|
some of your contacts. |
||||
|
<span v-if="canShare"> |
||||
|
If you'd like an introduction, |
||||
|
<a @click="onClickShareClaim()" class="text-blue-500" |
||||
|
>click to share the information with them and ask if they'll tell |
||||
|
you more about the participants.</a |
||||
|
> |
||||
|
</span> |
||||
|
<span v-else> |
||||
|
If you'd like an introduction, |
||||
|
<a |
||||
|
@click="copyToClipboard('Location', windowLocation.href)" |
||||
|
class="text-blue-500" |
||||
|
>share this page with them and ask if they'll tell you more about |
||||
|
about the participants.</a |
||||
|
> |
||||
|
</span> |
||||
|
|
||||
|
<div |
||||
|
v-for="(visibleDidPath, index) of Object.keys(veriClaimDidsVisible)" |
||||
|
:key="index" |
||||
|
class="list-disc p-4" |
||||
|
> |
||||
|
<div class="text-sm"> |
||||
|
<fa icon="minus" class="fa-fw" /> |
||||
|
The {{ visibleDidPath }} is visible to: |
||||
|
</div> |
||||
|
<div class="ml-12 p-1"> |
||||
|
<ul> |
||||
|
<li |
||||
|
v-for="(visDid, idx2) of veriClaimDidsVisible[visibleDidPath]" |
||||
|
:key="idx2" |
||||
|
class="list-disc" |
||||
|
> |
||||
|
<div class="text-sm mt-2"> |
||||
|
<span> |
||||
|
{{ didInfo(visDid) }} |
||||
|
<span v-if="!serverUtil.isEmptyOrHiddenDid(visDid)"> |
||||
|
<button |
||||
|
@click=" |
||||
|
copyToClipboard('The DID of ' + visDid, visDid) |
||||
|
" |
||||
|
> |
||||
|
<fa icon="copy" class="text-slate-400 fa-fw" /> |
||||
|
</button> |
||||
|
</span> |
||||
|
<span v-if="veriClaim.publicUrls?.[visDid]" |
||||
|
>, found at |
||||
|
<fa icon="globe" class="fa-fw text-slate-400" /> |
||||
|
<a |
||||
|
:href="veriClaim.publicUrls?.[visDid]" |
||||
|
class="text-blue-500" |
||||
|
>{{ |
||||
|
veriClaim.publicUrls[visDid].substring( |
||||
|
veriClaim.publicUrls[visDid].indexOf("//") + 2, |
||||
|
) |
||||
|
}} |
||||
|
</a> |
||||
|
</span> |
||||
|
</span> |
||||
|
</div> |
||||
|
</li> |
||||
|
</ul> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
<!-- Keep the dump contents directly between > and < to avoid weird spacing. --> |
||||
|
<pre |
||||
|
class="text-sm overflow-x-scroll px-4 py-3 bg-slate-100 rounded-md" |
||||
|
>{{ veriClaimDump }}</pre |
||||
|
> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div v-else-if="!isLoading">This does not have details to confirm.</div> |
||||
|
|
||||
|
<div class="mt-4" v-if="!isLoading"> |
||||
|
<a |
||||
|
@click="showClaimPage(veriClaim.id)" |
||||
|
class="text-blue-500 cursor-pointer" |
||||
|
> |
||||
|
<fa icon="file-lines" class="pl-2" /> |
||||
|
All Generic Info |
||||
|
</a> |
||||
|
</div> |
||||
|
|
||||
|
<div |
||||
|
class="fixed left-6 bottom-24 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> |
||||
|
</section> |
||||
|
</template> |
||||
|
|
||||
|
<script lang="ts"> |
||||
|
import { AxiosError } from "axios"; |
||||
|
import * as yaml from "js-yaml"; |
||||
|
import * as R from "ramda"; |
||||
|
import { Component, Vue } from "vue-facing-decorator"; |
||||
|
import { useClipboard } from "@vueuse/core"; |
||||
|
import { Router } from "vue-router"; |
||||
|
|
||||
|
import QuickNav from "@/components/QuickNav.vue"; |
||||
|
import { NotificationIface } from "@/constants/app"; |
||||
|
import { accountsDB, db, retrieveSettingsForActiveAccount } from "@/db/index"; |
||||
|
import { Account } from "@/db/tables/accounts"; |
||||
|
import { Contact } from "@/db/tables/contacts"; |
||||
|
import * as serverUtil from "@/libs/endorserServer"; |
||||
|
import { displayAmount, GiveSummaryRecord } from "@/libs/endorserServer"; |
||||
|
import * as libsUtil from "@/libs/util"; |
||||
|
import { isGiveAction } from "@/libs/util"; |
||||
|
import TopMessage from "@/components/TopMessage.vue"; |
||||
|
|
||||
|
@Component({ |
||||
|
methods: { displayAmount }, |
||||
|
components: { TopMessage, QuickNav }, |
||||
|
}) |
||||
|
export default class ClaimView extends Vue { |
||||
|
$notify!: (notification: NotificationIface, timeout?: number) => void; |
||||
|
|
||||
|
activeDid = ""; |
||||
|
allMyDids: Array<string> = []; |
||||
|
allContacts: Array<Contact> = []; |
||||
|
apiServer = ""; |
||||
|
|
||||
|
canShare = false; |
||||
|
confirmerIdList: string[] = []; // list of DIDs that have confirmed this claim excluding the issuer |
||||
|
confsVisibleErrorMessage = ""; |
||||
|
confsVisibleToIdList: string[] = []; // list of DIDs that can see any confirmer |
||||
|
giveDetails?: GiveSummaryRecord; |
||||
|
giverName = ""; |
||||
|
issuerName = ""; |
||||
|
isLoading = false; |
||||
|
isRegistered = false; |
||||
|
numConfsNotVisible = 0; // number of hidden DIDs in the confirmerIdList, minus the issuer if they aren't visible |
||||
|
recipientName = ""; |
||||
|
showDetails = false; |
||||
|
urlForNewGive = ""; |
||||
|
veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD; |
||||
|
veriClaimDump = ""; |
||||
|
veriClaimDidsVisible = {}; |
||||
|
windowLocation = window.location; |
||||
|
|
||||
|
R = R; |
||||
|
yaml = yaml; |
||||
|
libsUtil = libsUtil; |
||||
|
serverUtil = serverUtil; |
||||
|
|
||||
|
resetThisValues() { |
||||
|
this.confirmerIdList = []; |
||||
|
this.confsVisibleErrorMessage = ""; |
||||
|
this.confsVisibleToIdList = []; |
||||
|
this.giveDetails = undefined; |
||||
|
this.isRegistered = false; |
||||
|
this.numConfsNotVisible = 0; |
||||
|
this.urlForNewGive = ""; |
||||
|
this.veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD; |
||||
|
this.veriClaimDump = ""; |
||||
|
} |
||||
|
|
||||
|
async mounted() { |
||||
|
this.isLoading = true; |
||||
|
const settings = await retrieveSettingsForActiveAccount(); |
||||
|
this.activeDid = settings.activeDid || ""; |
||||
|
this.apiServer = settings.apiServer || ""; |
||||
|
this.allContacts = await db.contacts.toArray(); |
||||
|
this.isRegistered = settings.isRegistered || false; |
||||
|
|
||||
|
await accountsDB.open(); |
||||
|
const accounts = accountsDB.accounts; |
||||
|
const accountsArr: Array<Account> = await accounts?.toArray(); |
||||
|
this.allMyDids = accountsArr.map((acc) => acc.did); |
||||
|
|
||||
|
const pathParam = window.location.pathname.substring( |
||||
|
"/confirm-gift/".length, |
||||
|
); |
||||
|
let claimId; |
||||
|
if (pathParam) { |
||||
|
claimId = decodeURIComponent(pathParam); |
||||
|
await this.loadClaim(claimId, this.activeDid); |
||||
|
} else { |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Error", |
||||
|
text: "No claim ID was provided.", |
||||
|
}, |
||||
|
3000, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// 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; |
||||
|
|
||||
|
this.isLoading = false; |
||||
|
} |
||||
|
|
||||
|
// insert a space before any capital letters except the initial letter |
||||
|
// (and capitalize initial letter, just in case) |
||||
|
capitalizeAndInsertSpacesBeforeCaps(text: string) { |
||||
|
return !text |
||||
|
? "" |
||||
|
: text[0].toUpperCase() + text.substr(1).replace(/([A-Z])/g, " $1"); |
||||
|
} |
||||
|
|
||||
|
capitalizeAndInsertSpacesBeforeCapsWithAPrefix(text: string) { |
||||
|
const word = this.capitalizeAndInsertSpacesBeforeCaps(text); |
||||
|
if (word) { |
||||
|
// if the word starts with a vowel, use "an" instead of "a" |
||||
|
const firstLetter = word[0].toLowerCase(); |
||||
|
const vowels = ["a", "e", "i", "o", "u"]; |
||||
|
const particle = vowels.includes(firstLetter) ? "an" : "a"; |
||||
|
return particle + " " + word; |
||||
|
} else { |
||||
|
return ""; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
totalConfirmers() { |
||||
|
return ( |
||||
|
this.numConfsNotVisible + |
||||
|
this.confirmerIdList.length + |
||||
|
this.confsVisibleToIdList.length |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// Isn't there a better way to make this available to the template? |
||||
|
didInfo(did: string | undefined) { |
||||
|
return serverUtil.didInfo( |
||||
|
did, |
||||
|
this.activeDid, |
||||
|
this.allMyDids, |
||||
|
this.allContacts, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
async loadClaim(claimId: string, userDid: string) { |
||||
|
const urlPath = libsUtil.isGlobalUri(claimId) |
||||
|
? "/api/claim/byHandle/" |
||||
|
: "/api/claim/"; |
||||
|
const url = this.apiServer + urlPath + encodeURIComponent(claimId); |
||||
|
|
||||
|
try { |
||||
|
const headers = await serverUtil.getHeaders(userDid); |
||||
|
const resp = await this.axios.get(url, { headers }); |
||||
|
// resp.data is: |
||||
|
// - a Jwt from https://api.endorser.ch/api-docs/ |
||||
|
// - with a Give from https://endorser.ch/doc/html/transactions.html#id3 |
||||
|
if (resp.status === 200) { |
||||
|
this.veriClaim = resp.data; |
||||
|
this.veriClaimDump = yaml.dump(this.veriClaim); |
||||
|
this.veriClaimDidsVisible = libsUtil.findAllVisibleToDids( |
||||
|
this.veriClaim, |
||||
|
true, |
||||
|
); |
||||
|
} else { |
||||
|
// actually, axios typically throws an error so we never get here |
||||
|
console.error("Error getting claim:", resp); |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Error", |
||||
|
text: "There was a problem retrieving that claim.", |
||||
|
}, |
||||
|
3000, |
||||
|
); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// retrieve more details on Give, Offer, or Plan |
||||
|
if (this.veriClaim.claimType !== "GiveAction") { |
||||
|
// no need to go further... this page is for gifts |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
this.issuerName = this.didInfo(this.veriClaim.issuer); |
||||
|
|
||||
|
// use give record when possible since it may include edits |
||||
|
const giveUrl = |
||||
|
this.apiServer + |
||||
|
"/api/v2/report/gives?handleId=" + |
||||
|
encodeURIComponent(this.veriClaim.handleId as string); |
||||
|
const giveHeaders = await serverUtil.getHeaders(userDid); |
||||
|
const giveResp = await this.axios.get(giveUrl, { |
||||
|
headers: giveHeaders, |
||||
|
}); |
||||
|
// giveResp.data is a Give from https://api.endorser.ch/api-docs/ |
||||
|
if (giveResp.status === 200) { |
||||
|
this.giveDetails = giveResp.data.data[0]; |
||||
|
} else { |
||||
|
console.error("Error getting detailed give info:", giveResp); |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Error", |
||||
|
text: "Something went wrong retrieving gift data.", |
||||
|
}, |
||||
|
3000, |
||||
|
); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// the logic already stops earlier if the claim doesn't exist but this helps with typechecking |
||||
|
if (!this.giveDetails) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
this.urlForNewGive = "/gifted-details?"; |
||||
|
if (this.giveDetails.amount) { |
||||
|
this.urlForNewGive += |
||||
|
"&amountInput=" + encodeURIComponent(String(this.giveDetails.amount)); |
||||
|
} |
||||
|
if (this.giveDetails.unit) { |
||||
|
this.urlForNewGive += |
||||
|
"&unitCode=" + encodeURIComponent(this.giveDetails.unit); |
||||
|
} |
||||
|
if (this.giveDetails.description) { |
||||
|
this.urlForNewGive += |
||||
|
"&description=" + encodeURIComponent(this.giveDetails.description); |
||||
|
} |
||||
|
this.giverName = this.didInfo(this.giveDetails.agentDid); |
||||
|
if (this.giveDetails.agentDid) { |
||||
|
this.urlForNewGive += |
||||
|
"&giverDid=" + |
||||
|
encodeURIComponent(this.giveDetails.agentDid) + |
||||
|
"&giverName=" + |
||||
|
encodeURIComponent(this.giverName); |
||||
|
} |
||||
|
this.recipientName = this.didInfo(this.giveDetails.recipientDid); |
||||
|
if (this.giveDetails.recipientDid) { |
||||
|
this.urlForNewGive += |
||||
|
"&recipientDid=" + |
||||
|
encodeURIComponent(this.giveDetails.recipientDid) + |
||||
|
"&recipientName=" + |
||||
|
encodeURIComponent(this.recipientName); |
||||
|
} |
||||
|
if (this.giveDetails.fullClaim.image) { |
||||
|
this.urlForNewGive += |
||||
|
"&image=" + encodeURIComponent(this.giveDetails.fullClaim.image); |
||||
|
} |
||||
|
if ( |
||||
|
this.giveDetails.type == "Offer" && |
||||
|
this.giveDetails.fulfillsHandleId |
||||
|
) { |
||||
|
this.urlForNewGive += |
||||
|
"&offerId=" + |
||||
|
encodeURIComponent(this.giveDetails?.fulfillsHandleId as string); |
||||
|
} |
||||
|
if (this.giveDetails.fulfillsPlanHandleId) { |
||||
|
this.urlForNewGive += |
||||
|
"&fulfillsProjectId=" + |
||||
|
encodeURIComponent(this.giveDetails.fulfillsPlanHandleId); |
||||
|
} |
||||
|
|
||||
|
// retrieve the list of confirmers |
||||
|
const confirmUrl = |
||||
|
this.apiServer + |
||||
|
"/api/report/issuersWhoClaimedOrConfirmed?claimId=" + |
||||
|
encodeURIComponent(serverUtil.stripEndorserPrefix(claimId)); |
||||
|
const confirmHeaders = await serverUtil.getHeaders(userDid); |
||||
|
const response = await this.axios.get(confirmUrl, { |
||||
|
headers: confirmHeaders, |
||||
|
}); |
||||
|
if (response.status === 200) { |
||||
|
const resultList1 = response.data.result || []; |
||||
|
//const publicUrls = resultList.publicUrls || []; |
||||
|
delete resultList1.publicUrls; |
||||
|
// remove any hidden DIDs |
||||
|
const resultList2 = R.reject(serverUtil.isHiddenDid, resultList1); |
||||
|
// remove confirmations by this user |
||||
|
const resultList3 = R.reject( |
||||
|
(did: string) => did === this.giveDetails?.issuerDid, |
||||
|
resultList2, |
||||
|
); |
||||
|
this.confirmerIdList = resultList3; |
||||
|
this.numConfsNotVisible = resultList1.length - resultList2.length; |
||||
|
if (resultList3.length === resultList2.length) { |
||||
|
// the issuer was not in the "visible" list so they must be hidden |
||||
|
// so subtract them from the non-visible confirmers count |
||||
|
this.numConfsNotVisible = this.numConfsNotVisible - 1; |
||||
|
} |
||||
|
this.confsVisibleToIdList = |
||||
|
response.data.result.resultVisibleToDids || []; |
||||
|
} else { |
||||
|
this.confsVisibleErrorMessage = |
||||
|
"Had problems retrieving confirmations."; |
||||
|
} |
||||
|
} catch (error: unknown) { |
||||
|
const serverError = error as AxiosError; |
||||
|
console.error("Error retrieving claim:", serverError); |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Error", |
||||
|
text: "Something went wrong retrieving claim data.", |
||||
|
}, |
||||
|
3000, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
confirmConfirmClaim() { |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "modal", |
||||
|
type: "confirm", |
||||
|
title: "Confirm", |
||||
|
text: "Do you personally confirm that this is true?", |
||||
|
onYes: async () => { |
||||
|
await this.confirmClaim(); |
||||
|
}, |
||||
|
}, |
||||
|
-1, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// similar code is found in ProjectViewView |
||||
|
async confirmClaim() { |
||||
|
// similar logic is found in endorser-mobile |
||||
|
const goodClaim = serverUtil.removeSchemaContext( |
||||
|
serverUtil.removeVisibleToDids( |
||||
|
serverUtil.addLastClaimOrHandleAsIdIfMissing( |
||||
|
this.veriClaim.claim, |
||||
|
this.veriClaim.id, |
||||
|
this.veriClaim.handleId, |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
const confirmationClaim: serverUtil.GenericVerifiableCredential = { |
||||
|
"@context": "https://schema.org", |
||||
|
"@type": "AgreeAction", |
||||
|
object: goodClaim, |
||||
|
}; |
||||
|
const result = await serverUtil.createAndSubmitClaim( |
||||
|
confirmationClaim, |
||||
|
this.activeDid, |
||||
|
this.apiServer, |
||||
|
this.axios, |
||||
|
); |
||||
|
if (result.type === "success") { |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "success", |
||||
|
title: "Success", |
||||
|
text: "Confirmation submitted.", |
||||
|
}, |
||||
|
3000, |
||||
|
); |
||||
|
} else { |
||||
|
console.error("Got error submitting the confirmation:", result); |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Error", |
||||
|
text: "There was a problem submitting the confirmation.", |
||||
|
}, |
||||
|
5000, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
showClaimPage(claimId: string) { |
||||
|
const route = { |
||||
|
path: "/claim/" + encodeURIComponent(claimId), |
||||
|
}; |
||||
|
(this.$router as Router).push(route).then(async () => { |
||||
|
this.resetThisValues(); |
||||
|
await this.loadClaim(claimId, this.activeDid); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
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, |
||||
|
); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
notifyWhyCannotConfirm() { |
||||
|
if (!this.isRegistered) { |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "info", |
||||
|
title: "Not Registered", |
||||
|
text: "Someone needs to register you before you can contribute.", |
||||
|
}, |
||||
|
3000, |
||||
|
); |
||||
|
} else if (!isGiveAction(this.veriClaim)) { |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "info", |
||||
|
title: "Not A Give", |
||||
|
text: "This is not a giving action to confirm.", |
||||
|
}, |
||||
|
3000, |
||||
|
); |
||||
|
} else if (this.confirmerIdList.includes(this.activeDid)) { |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "info", |
||||
|
title: "Already Confirmed", |
||||
|
text: "You already confirmed this claim.", |
||||
|
}, |
||||
|
3000, |
||||
|
); |
||||
|
} else if (this.giveDetails?.issuerDid == this.activeDid) { |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "info", |
||||
|
title: "Cannot Confirm", |
||||
|
text: "You cannot confirm this because you issued this claim.", |
||||
|
}, |
||||
|
3000, |
||||
|
); |
||||
|
} else if (serverUtil.containsHiddenDid(this.giveDetails?.fullClaim)) { |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "info", |
||||
|
title: "Cannot Confirm", |
||||
|
text: "You cannot confirm this because some people are hidden.", |
||||
|
}, |
||||
|
3000, |
||||
|
); |
||||
|
} else { |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "info", |
||||
|
title: "Cannot Confirm", |
||||
|
text: "You cannot confirm this claim.", |
||||
|
}, |
||||
|
3000, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
onClickShareClaim() { |
||||
|
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, |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
</script> |
@ -0,0 +1,233 @@ |
|||||
|
<template> |
||||
|
<QuickNav selected="Contacts"></QuickNav> |
||||
|
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> |
||||
|
<!-- Back --> |
||||
|
<div class="text-lg text-center font-light relative px-7"> |
||||
|
<h1 |
||||
|
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1" |
||||
|
@click="$router.back()" |
||||
|
> |
||||
|
<fa icon="chevron-left" class="fa-fw"></fa> |
||||
|
</h1> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Heading --> |
||||
|
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8"> |
||||
|
Contact Import |
||||
|
</h1> |
||||
|
|
||||
|
<span class="flex justify-center"> |
||||
|
<input type="checkbox" v-model="makeVisible" class="mr-2" /> |
||||
|
Make my activity visible to these contacts. |
||||
|
</span> |
||||
|
<div v-if="sameCount > 0"> |
||||
|
<span v-if="sameCount == 1" |
||||
|
>One contact is the same as an existing contact</span |
||||
|
> |
||||
|
<span v-else |
||||
|
>{{ sameCount }} contacts are the same as existing contacts</span |
||||
|
> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Results List --> |
||||
|
<ul v-if="contactsImporting.length > 0" class="border-t border-slate-300"> |
||||
|
<li v-for="(contact, index) in contactsImporting" :key="contact.did"> |
||||
|
<div |
||||
|
v-if=" |
||||
|
!contactsExisting[contact.did] || |
||||
|
!R.isEmpty(contactDifferences[contact.did]) |
||||
|
" |
||||
|
class="grow overflow-hidden border-b border-slate-300 pt-2.5 pb-4" |
||||
|
> |
||||
|
<h2 class="text-base font-semibold"> |
||||
|
<input type="checkbox" v-model="contactsSelected[index]" /> |
||||
|
{{ contact.name || AppString.NO_CONTACT_NAME }} |
||||
|
- |
||||
|
<span v-if="contactsExisting[contact.did]" class="text-orange-500" |
||||
|
>Existing</span |
||||
|
> |
||||
|
<span v-else class="text-green-500">New</span> |
||||
|
</h2> |
||||
|
<div class="text-sm truncate"> |
||||
|
{{ contact.did }} |
||||
|
</div> |
||||
|
<div v-if="contactDifferences[contact.did]"> |
||||
|
<div> |
||||
|
<div class="grid grid-cols-3 gap-2"> |
||||
|
<div class="font-bold">Field</div> |
||||
|
<div class="font-bold">Old Value</div> |
||||
|
<div class="font-bold">New Value</div> |
||||
|
</div> |
||||
|
<div |
||||
|
v-for="(value, contactField) in contactDifferences[contact.did]" |
||||
|
:key="contactField" |
||||
|
class="grid grid-cols-3 border" |
||||
|
> |
||||
|
<div class="border p-1">{{ contactField }}</div> |
||||
|
<div class="border p-1">{{ value.old }}</div> |
||||
|
<div class="border p-1">{{ value.new }}</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</li> |
||||
|
<fa icon="spinner" v-if="importing" class="animate-spin" /> |
||||
|
<button |
||||
|
v-else |
||||
|
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-sm text-white mt-2 px-2 py-1.5 rounded" |
||||
|
@click="importContacts" |
||||
|
> |
||||
|
Import Selected Contacts |
||||
|
</button> |
||||
|
</ul> |
||||
|
<p v-else>There are no contacts to import.</p> |
||||
|
</section> |
||||
|
</template> |
||||
|
|
||||
|
<script lang="ts"> |
||||
|
import * as R from "ramda"; |
||||
|
import { Component, Vue } from "vue-facing-decorator"; |
||||
|
import { Router } from "vue-router"; |
||||
|
|
||||
|
import { AppString, NotificationIface } from "@/constants/app"; |
||||
|
import { db, retrieveSettingsForActiveAccount } from "@/db/index"; |
||||
|
import { Contact } from "@/db/tables/contacts"; |
||||
|
import * as libsUtil from "@/libs/util"; |
||||
|
import QuickNav from "@/components/QuickNav.vue"; |
||||
|
import EntityIcon from "@/components/EntityIcon.vue"; |
||||
|
import OfferDialog from "@/components/OfferDialog.vue"; |
||||
|
import { setVisibilityUtil } from "@/libs/endorserServer"; |
||||
|
|
||||
|
@Component({ |
||||
|
components: { EntityIcon, OfferDialog, QuickNav }, |
||||
|
}) |
||||
|
export default class ContactImportView extends Vue { |
||||
|
$notify!: (notification: NotificationIface, timeout?: number) => void; |
||||
|
|
||||
|
AppString = AppString; |
||||
|
libsUtil = libsUtil; |
||||
|
R = R; |
||||
|
|
||||
|
activeDid = ""; |
||||
|
apiServer = ""; |
||||
|
contactsExisting: Record<string, Contact> = {}; // user's contacts already in the system, keyed by DID |
||||
|
contactsImporting: Array<Contact> = []; // contacts from the import |
||||
|
contactsSelected: Array<boolean> = []; // whether each contact in contactsImporting is selected |
||||
|
contactDifferences: Record< |
||||
|
string, |
||||
|
Record<string, { new: string; old: string }> |
||||
|
> = {}; // for existing contacts, it shows the difference between imported and existing contacts for each key |
||||
|
importing = false; |
||||
|
makeVisible = true; |
||||
|
sameCount = 0; |
||||
|
|
||||
|
async created() { |
||||
|
const settings = await retrieveSettingsForActiveAccount(); |
||||
|
this.activeDid = settings.activeDid || ""; |
||||
|
this.apiServer = settings.apiServer || ""; |
||||
|
|
||||
|
// Retrieve the imported contacts from the query parameter |
||||
|
const importedContacts = |
||||
|
((this.$route as Router).query["contacts"] as string) || "[]"; |
||||
|
this.contactsImporting = JSON.parse(importedContacts); |
||||
|
this.contactsSelected = new Array(this.contactsImporting.length).fill(true); |
||||
|
|
||||
|
await db.open(); |
||||
|
const baseContacts = await db.contacts.toArray(); |
||||
|
// set the existing contacts, keyed by DID, if they exist in contactsImporting |
||||
|
for (let i = 0; i < this.contactsImporting.length; i++) { |
||||
|
const contactIn = this.contactsImporting[i]; |
||||
|
const existingContact = baseContacts.find( |
||||
|
(contact) => contact.did === contactIn.did, |
||||
|
); |
||||
|
if (existingContact) { |
||||
|
this.contactsExisting[contactIn.did] = existingContact; |
||||
|
|
||||
|
const differences: Record<string, { new: string; old: string }> = {}; |
||||
|
Object.keys(contactIn).forEach((key) => { |
||||
|
if (contactIn[key] !== existingContact[key]) { |
||||
|
differences[key] = { |
||||
|
old: existingContact[key], |
||||
|
new: contactIn[key], |
||||
|
}; |
||||
|
} |
||||
|
}); |
||||
|
this.contactDifferences[contactIn.did] = differences; |
||||
|
if (R.isEmpty(differences)) { |
||||
|
this.sameCount++; |
||||
|
} |
||||
|
|
||||
|
// don't automatically import previous data |
||||
|
this.contactsSelected[i] = false; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async importContacts() { |
||||
|
this.importing = true; |
||||
|
let importedCount = 0, |
||||
|
updatedCount = 0; |
||||
|
for (let i = 0; i < this.contactsImporting.length; i++) { |
||||
|
if (this.contactsSelected[i]) { |
||||
|
const contact = this.contactsImporting[i]; |
||||
|
const existingContact = this.contactsExisting[contact.did]; |
||||
|
if (existingContact) { |
||||
|
await db.contacts.update(contact.did, contact); |
||||
|
updatedCount++; |
||||
|
} else { |
||||
|
// without explicit clone on the Proxy, we get: DataCloneError: Failed to execute 'add' on 'IDBObjectStore': #<Object> could not be cloned. |
||||
|
await db.contacts.add(R.clone(contact)); |
||||
|
importedCount++; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
if (this.makeVisible) { |
||||
|
const failedVisibileToContacts = []; |
||||
|
for (let i = 0; i < this.contactsImporting.length; i++) { |
||||
|
const contact = this.contactsImporting[i]; |
||||
|
if (contact) { |
||||
|
const visResult = await setVisibilityUtil( |
||||
|
this.activeDid, |
||||
|
this.apiServer, |
||||
|
this.axios, |
||||
|
db, |
||||
|
contact, |
||||
|
true, |
||||
|
); |
||||
|
if (!visResult.success) { |
||||
|
failedVisibileToContacts.push(contact); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
if (failedVisibileToContacts.length) { |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Visibility Error", |
||||
|
text: `Failed to set visibility for ${failedVisibileToContacts.length} contact${ |
||||
|
failedVisibileToContacts.length == 1 ? "" : "s" |
||||
|
}. You must set them individually: ${failedVisibileToContacts.map((c) => c.name).join(", ")}`, |
||||
|
}, |
||||
|
-1, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
this.importing = false; |
||||
|
|
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "success", |
||||
|
title: "Imported", |
||||
|
text: |
||||
|
`${importedCount} contact${importedCount == 1 ? "" : "s"} imported.` + |
||||
|
(updatedCount ? ` ${updatedCount} updated.` : ""), |
||||
|
}, |
||||
|
3000, |
||||
|
); |
||||
|
(this.$router as Router).push({ name: "contacts" }); |
||||
|
} |
||||
|
} |
||||
|
</script> |
@ -0,0 +1,753 @@ |
|||||
|
<template> |
||||
|
<QuickNav selected="Contacts" /> |
||||
|
<TopMessage /> |
||||
|
|
||||
|
<!-- CONTENT --> |
||||
|
<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"> |
||||
|
<!-- 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> |
||||
|
Identifier Details |
||||
|
</h1> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Identity Details --> |
||||
|
<div |
||||
|
v-if="!!contactFromDid" |
||||
|
class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4" |
||||
|
> |
||||
|
<div> |
||||
|
<h2 class="text-xl font-semibold"> |
||||
|
{{ contactFromDid?.name || "(no name)" }} |
||||
|
<button |
||||
|
@click=" |
||||
|
contactEdit = true; |
||||
|
contactNewName = (contactFromDid?.name as string) || ''; |
||||
|
" |
||||
|
title="Edit" |
||||
|
> |
||||
|
<fa icon="pen" class="text-sm text-blue-500 ml-2 mb-1" /> |
||||
|
</button> |
||||
|
</h2> |
||||
|
<button |
||||
|
@click="showDidDetails = !showDidDetails" |
||||
|
class="ml-2 mr-2 mt-4" |
||||
|
> |
||||
|
Details |
||||
|
<fa v-if="showDidDetails" icon="chevron-down" class="text-blue-400" /> |
||||
|
<fa v-else icon="chevron-right" class="text-blue-400" /> |
||||
|
</button> |
||||
|
<!-- Keep the dump contents directly between > and < to avoid weird spacing. --> |
||||
|
<pre |
||||
|
v-if="showDidDetails" |
||||
|
class="text-sm overflow-x-scroll px-4 py-3 bg-slate-100 rounded-md" |
||||
|
>{{ contactYaml }}</pre |
||||
|
> |
||||
|
</div> |
||||
|
<div class="flex justify-center mt-4"> |
||||
|
<span |
||||
|
v-if="contactFromDid?.profileImageUrl" |
||||
|
class="flex justify-between" |
||||
|
> |
||||
|
<EntityIcon |
||||
|
:icon-size="96" |
||||
|
:profileImageUrl="contactFromDid?.profileImageUrl" |
||||
|
class="inline-block align-text-bottom border border-slate-300 rounded" |
||||
|
@click="showLargeIdenticonUrl = contactFromDid?.profileImageUrl" |
||||
|
/> |
||||
|
</span> |
||||
|
</div> |
||||
|
<div class="flex justify-between mt-4"> |
||||
|
<div class="flex items-center"> |
||||
|
<div v-if="activeDid" class="flex justify-between"> |
||||
|
<div> |
||||
|
<button |
||||
|
v-if=" |
||||
|
contactFromDid?.seesMe && contactFromDid.did !== activeDid |
||||
|
" |
||||
|
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md" |
||||
|
@click="confirmSetVisibility(contactFromDid, false)" |
||||
|
title="They can see you" |
||||
|
> |
||||
|
<fa icon="eye" class="fa-fw" /> |
||||
|
</button> |
||||
|
<button |
||||
|
v-else-if=" |
||||
|
!contactFromDid?.seesMe && contactFromDid?.did !== activeDid |
||||
|
" |
||||
|
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md" |
||||
|
@click="confirmSetVisibility(contactFromDid, true)" |
||||
|
title="They cannot see you" |
||||
|
> |
||||
|
<fa icon="eye-slash" class="fa-fw" /> |
||||
|
</button> |
||||
|
|
||||
|
<button |
||||
|
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md" |
||||
|
@click="checkVisibility(contactFromDid)" |
||||
|
title="Check Visibility" |
||||
|
v-if="contactFromDid?.did !== activeDid" |
||||
|
> |
||||
|
<fa icon="rotate" class="fa-fw" /> |
||||
|
</button> |
||||
|
</div> |
||||
|
|
||||
|
<button |
||||
|
@click="confirmRegister(contactFromDid)" |
||||
|
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-6 mx-0.5 my-0.5 px-2 py-1.5 rounded-md" |
||||
|
v-if="contactFromDid?.did !== activeDid" |
||||
|
title="Registration" |
||||
|
> |
||||
|
<fa |
||||
|
v-if="contactFromDid?.registered" |
||||
|
icon="person-circle-check" |
||||
|
class="fa-fw" |
||||
|
/> |
||||
|
<fa v-else icon="person-circle-question" class="fa-fw" /> |
||||
|
</button> |
||||
|
</div> |
||||
|
|
||||
|
<button |
||||
|
@click="confirmDeleteContact(contactFromDid)" |
||||
|
class="text-sm uppercase bg-gradient-to-b from-rose-500 to-rose-800 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-6 mx-0.5 my-0.5 px-2 py-1.5 rounded-md" |
||||
|
title="Delete" |
||||
|
> |
||||
|
<fa icon="trash-can" class="fa-fw" /> |
||||
|
</button> |
||||
|
</div> |
||||
|
<div v-if="!contactFromDid?.profileImageUrl"> |
||||
|
<div>Auto-Generated Icon</div> |
||||
|
<div class="flex justify-center"> |
||||
|
<EntityIcon |
||||
|
:entityId="viewingDid" |
||||
|
:iconSize="64" |
||||
|
class="inline-block align-middle border border-slate-300 rounded-md mr-1" |
||||
|
@click="showLargeIdenticonId = viewingDid" |
||||
|
/> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div |
||||
|
v-if="showLargeIdenticonId || showLargeIdenticonUrl" |
||||
|
class="fixed z-[100] top-0 inset-x-0 w-full" |
||||
|
> |
||||
|
<div |
||||
|
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50" |
||||
|
> |
||||
|
<EntityIcon |
||||
|
:entityId="showLargeIdenticonId" |
||||
|
:iconSize="512" |
||||
|
:profileImageUrl="showLargeIdenticonUrl" |
||||
|
class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg" |
||||
|
@click=" |
||||
|
showLargeIdenticonId = undefined; |
||||
|
showLargeIdenticonUrl = undefined; |
||||
|
" |
||||
|
/> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div v-else class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4"> |
||||
|
<!-- !contactFromDid --> |
||||
|
<div> |
||||
|
<h2 class="text-xl font-semibold"> |
||||
|
{{ isMyDid ? "You" : "(no name)" }} |
||||
|
</h2> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Edit Name Dialog, maybe should be replaced by ContactNameDialog --> |
||||
|
<div v-if="contactEdit" class="dialog-overlay"> |
||||
|
<div class="dialog"> |
||||
|
<h1 class="text-xl font-bold text-center mb-4">Edit Name</h1> |
||||
|
<input |
||||
|
type="text" |
||||
|
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2" |
||||
|
placeholder="Name" |
||||
|
v-model="contactNewName" |
||||
|
/> |
||||
|
<div class="flex justify-between"> |
||||
|
<button |
||||
|
class="text-sm bg-blue-600 text-white px-2 py-1.5 rounded -ml-1.5 border-l border-blue-400" |
||||
|
@click="onClickSaveName(contactNewName)" |
||||
|
> |
||||
|
<fa icon="save" /> |
||||
|
</button> |
||||
|
<span class="inline-block w-2" /> |
||||
|
<button |
||||
|
class="text-sm bg-blue-600 text-white px-2 py-1.5 rounded -ml-1.5 border-l border-blue-400" |
||||
|
@click="onClickCancelName()" |
||||
|
> |
||||
|
<fa icon="ban" /> |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Loading Animation --> |
||||
|
<div |
||||
|
class="fixed left-6 bottom-24 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> |
||||
|
<!-- Results List --> |
||||
|
<div v-if="claims.length > 0" class="mt-4"> |
||||
|
<div class="text-l font-bold text-center"> |
||||
|
Claims That Involve {{ isMyDid ? "You" : "Them" }} |
||||
|
</div> |
||||
|
</div> |
||||
|
<InfiniteScroll @reached-bottom="loadMoreData"> |
||||
|
<ul> |
||||
|
<li |
||||
|
class="border-b border-slate-300" |
||||
|
v-for="claim in claims" |
||||
|
:key="claim.handleId" |
||||
|
> |
||||
|
<div class="grid grid-cols-12 gap-4"> |
||||
|
<span class="col-span-2"> |
||||
|
{{ claim.issuedAt.substring(0, 10) }} |
||||
|
</span> |
||||
|
<span class="col-span-2"> |
||||
|
{{ capitalizeAndInsertSpacesBeforeCaps(claim.claimType) }} |
||||
|
</span> |
||||
|
<span class="col-span-2"> |
||||
|
{{ claimAmount(claim) }} |
||||
|
</span> |
||||
|
<span class="col-span-5"> |
||||
|
{{ claimDescription(claim) }} |
||||
|
</span> |
||||
|
<span class="col-span-1"> |
||||
|
<a @click="onClickLoadClaim(claim.id)" class="cursor-pointer"> |
||||
|
<fa icon="file-lines" class="pl-2 pt-1 text-blue-500" /> |
||||
|
</a> |
||||
|
</span> |
||||
|
</div> |
||||
|
</li> |
||||
|
</ul> |
||||
|
</InfiniteScroll> |
||||
|
|
||||
|
<div |
||||
|
v-if="!isLoading && claims.length === 0" |
||||
|
class="flex justify-center mt-4" |
||||
|
> |
||||
|
<span v-if="isMyDid">You have no claims yet.</span> |
||||
|
<span v-else>They are in no claims visible to you.</span> |
||||
|
</div> |
||||
|
</section> |
||||
|
</template> |
||||
|
|
||||
|
<script lang="ts"> |
||||
|
import { AxiosError } from "axios"; |
||||
|
import * as yaml from "js-yaml"; |
||||
|
import { Component, Vue } from "vue-facing-decorator"; |
||||
|
import { Router } from "vue-router"; |
||||
|
|
||||
|
import QuickNav from "@/components/QuickNav.vue"; |
||||
|
import InfiniteScroll from "@/components/InfiniteScroll.vue"; |
||||
|
import TopMessage from "@/components/TopMessage.vue"; |
||||
|
import { NotificationIface } from "@/constants/app"; |
||||
|
import { accountsDB, db, retrieveSettingsForActiveAccount } from "@/db/index"; |
||||
|
import { Contact } from "@/db/tables/contacts"; |
||||
|
import { BoundingBox } from "@/db/tables/settings"; |
||||
|
import { |
||||
|
capitalizeAndInsertSpacesBeforeCaps, |
||||
|
didInfoForContact, |
||||
|
displayAmount, |
||||
|
getHeaders, |
||||
|
GenericCredWrapper, |
||||
|
GenericVerifiableCredential, |
||||
|
GiveVerifiableCredential, |
||||
|
OfferVerifiableCredential, |
||||
|
register, |
||||
|
setVisibilityUtil, |
||||
|
} from "@/libs/endorserServer"; |
||||
|
import * as libsUtil from "@/libs/util"; |
||||
|
import EntityIcon from "@/components/EntityIcon.vue"; |
||||
|
|
||||
|
@Component({ |
||||
|
components: { |
||||
|
EntityIcon, |
||||
|
InfiniteScroll, |
||||
|
QuickNav, |
||||
|
TopMessage, |
||||
|
}, |
||||
|
}) |
||||
|
export default class DIDView extends Vue { |
||||
|
$notify!: (notification: NotificationIface, timeout?: number) => void; |
||||
|
|
||||
|
libsUtil = libsUtil; |
||||
|
yaml = yaml; |
||||
|
|
||||
|
activeDid = ""; |
||||
|
apiServer = ""; |
||||
|
claims: Array<GenericCredWrapper<GenericVerifiableCredential>> = []; |
||||
|
contactFromDid?: Contact; |
||||
|
contactEdit = false; |
||||
|
contactNewName: string = ""; |
||||
|
contactYaml = ""; |
||||
|
hitEnd = false; |
||||
|
isLoading = false; |
||||
|
isMyDid = false; |
||||
|
searchBox: { name: string; bbox: BoundingBox } | null = null; |
||||
|
showDidDetails = false; |
||||
|
showLargeIdenticonId?: string; |
||||
|
showLargeIdenticonUrl?: string; |
||||
|
viewingDid?: string; |
||||
|
|
||||
|
capitalizeAndInsertSpacesBeforeCaps = capitalizeAndInsertSpacesBeforeCaps; |
||||
|
didInfoForContact = didInfoForContact; |
||||
|
displayAmount = displayAmount; |
||||
|
|
||||
|
async mounted() { |
||||
|
const settings = await retrieveSettingsForActiveAccount(); |
||||
|
this.activeDid = settings.activeDid || ""; |
||||
|
this.apiServer = settings.apiServer || ""; |
||||
|
|
||||
|
const pathParam = window.location.pathname.substring("/did/".length); |
||||
|
if (pathParam) { |
||||
|
this.viewingDid = decodeURIComponent(pathParam); |
||||
|
this.contactFromDid = await db.contacts.get(this.viewingDid); |
||||
|
if (this.contactFromDid) { |
||||
|
this.contactYaml = yaml.dump(this.contactFromDid); |
||||
|
} |
||||
|
await this.loadClaimsAbout(); |
||||
|
|
||||
|
await accountsDB.open(); |
||||
|
const allAccounts = await accountsDB.accounts.toArray(); |
||||
|
for (const account of allAccounts) { |
||||
|
if (account.did === this.viewingDid) { |
||||
|
this.isMyDid = true; |
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Data loader used by infinite scroller |
||||
|
* @param payload is the flag from the InfiniteScroll indicating if it should load |
||||
|
**/ |
||||
|
async loadMoreData(payload: boolean) { |
||||
|
if (this.claims.length > 0 && !this.hitEnd && payload) { |
||||
|
this.loadClaimsAbout(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// prompt with confirmation if they want to delete a contact |
||||
|
confirmDeleteContact(contact: Contact) { |
||||
|
let message = |
||||
|
"Are you sure you want to remove " + |
||||
|
libsUtil.nameForContact(contact, false) + |
||||
|
" from your contact list?"; |
||||
|
if (contact.seesMe) { |
||||
|
message += |
||||
|
" Note that they can see your activity, so if you want to hide your activity from them then you should do that first."; |
||||
|
} |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "modal", |
||||
|
type: "confirm", |
||||
|
title: "Delete", |
||||
|
text: message, |
||||
|
onYes: async () => { |
||||
|
await this.deleteContact(contact); |
||||
|
}, |
||||
|
}, |
||||
|
-1, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
async deleteContact(contact: Contact) { |
||||
|
await db.open(); |
||||
|
await db.contacts.delete(contact.did); |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "success", |
||||
|
title: "Deleted", |
||||
|
text: "Contact has been removed.", |
||||
|
}, |
||||
|
3000, |
||||
|
); |
||||
|
(this.$router as Router).push({ name: "contacts" }); |
||||
|
} |
||||
|
|
||||
|
// confirm to register a new contact |
||||
|
async confirmRegister(contact: Contact) { |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "modal", |
||||
|
type: "confirm", |
||||
|
title: "Register", |
||||
|
text: |
||||
|
"Are you sure you want to register " + |
||||
|
libsUtil.nameForContact(this.contactFromDid, false) + |
||||
|
(contact.registered |
||||
|
? " -- especially since they are already marked as registered" |
||||
|
: "") + |
||||
|
"?", |
||||
|
onYes: async () => { |
||||
|
await this.register(contact); |
||||
|
}, |
||||
|
}, |
||||
|
-1, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// note that this is also in ContactView.vue |
||||
|
async register(contact: Contact) { |
||||
|
this.$notify({ group: "alert", type: "toast", title: "Sent..." }, 1000); |
||||
|
|
||||
|
try { |
||||
|
const regResult = await register( |
||||
|
this.activeDid, |
||||
|
this.apiServer, |
||||
|
this.axios, |
||||
|
contact, |
||||
|
); |
||||
|
if (regResult.success) { |
||||
|
contact.registered = true; |
||||
|
await db.contacts.update(contact.did, { registered: true }); |
||||
|
|
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "success", |
||||
|
title: "Registration Success", |
||||
|
text: |
||||
|
(contact.name || "That unnamed person") + " has been registered.", |
||||
|
}, |
||||
|
5000, |
||||
|
); |
||||
|
} else { |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Registration Error", |
||||
|
text: |
||||
|
(regResult.error as string) || |
||||
|
"Something went wrong during registration.", |
||||
|
}, |
||||
|
5000, |
||||
|
); |
||||
|
} |
||||
|
} catch (error) { |
||||
|
console.error("Error when registering:", error); |
||||
|
let userMessage = "There was an error."; |
||||
|
const serverError = error as AxiosError; |
||||
|
if (serverError) { |
||||
|
if (serverError.response?.data?.error?.message) { |
||||
|
userMessage = serverError.response.data.error.message; |
||||
|
} else if (serverError.message) { |
||||
|
userMessage = serverError.message; // Info for the user |
||||
|
} else { |
||||
|
userMessage = JSON.stringify(serverError.toJSON()); |
||||
|
} |
||||
|
} else { |
||||
|
userMessage = error as string; |
||||
|
} |
||||
|
// Now set that error for the user to see. |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Registration Error", |
||||
|
text: userMessage, |
||||
|
}, |
||||
|
5000, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public async loadClaimsAbout() { |
||||
|
if (!this.viewingDid) { |
||||
|
console.error("This should never be called without a DID."); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const queryParams = "claimContents=" + encodeURIComponent(this.viewingDid); |
||||
|
let postfix = ""; |
||||
|
if (this.claims.length > 0) { |
||||
|
postfix = "&beforeId=" + this.claims[this.claims.length - 1].id; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
this.isLoading = true; |
||||
|
const response = await fetch( |
||||
|
this.apiServer + "/api/v2/report/claims?" + queryParams + postfix, |
||||
|
{ |
||||
|
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. Try again later.`, |
||||
|
}, |
||||
|
5000, |
||||
|
); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const results = await response.json(); |
||||
|
this.claims = this.claims.concat(results.data); |
||||
|
this.hitEnd = !results.hitLimit; |
||||
|
|
||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any |
||||
|
} catch (e: any) { |
||||
|
console.error("Error with feed load:", e); |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Error", |
||||
|
text: e.userMessage || "There was a problem retrieving claims.", |
||||
|
}, |
||||
|
-1, |
||||
|
); |
||||
|
} finally { |
||||
|
this.isLoading = false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
onClickLoadClaim(jwtId: string) { |
||||
|
const route = { |
||||
|
path: "/claim/" + encodeURIComponent(jwtId), |
||||
|
}; |
||||
|
(this.$router as Router).push(route); |
||||
|
} |
||||
|
|
||||
|
public claimAmount(claim: GenericVerifiableCredential) { |
||||
|
if (claim.claimType === "GiveAction") { |
||||
|
const giveClaim = claim.claim as GiveVerifiableCredential; |
||||
|
if (giveClaim.object?.unitCode && giveClaim.object?.amountOfThisGood) { |
||||
|
return displayAmount( |
||||
|
giveClaim.object.unitCode, |
||||
|
giveClaim.object.amountOfThisGood, |
||||
|
); |
||||
|
} else { |
||||
|
return ""; |
||||
|
} |
||||
|
} else if (claim.claimType === "Offer") { |
||||
|
const offerClaim = claim.claim as OfferVerifiableCredential; |
||||
|
if ( |
||||
|
offerClaim.includesObject?.unitCode && |
||||
|
offerClaim.includesObject?.amountOfThisGood |
||||
|
) { |
||||
|
return displayAmount( |
||||
|
offerClaim.includesObject.unitCode, |
||||
|
offerClaim.includesObject.amountOfThisGood, |
||||
|
); |
||||
|
} else { |
||||
|
return ""; |
||||
|
} |
||||
|
} |
||||
|
return ""; |
||||
|
} |
||||
|
|
||||
|
claimDescription(claim: GenericVerifiableCredential) { |
||||
|
return claim.claim.name || claim.claim.description || ""; |
||||
|
} |
||||
|
|
||||
|
private async onClickCancelName() { |
||||
|
this.contactEdit = false; |
||||
|
} |
||||
|
|
||||
|
private async onClickSaveName(newName: string) { |
||||
|
if (!this.contactFromDid) { |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Not A Contact", |
||||
|
text: "First add this on the contact page, then you can edit here.", |
||||
|
}, |
||||
|
5000, |
||||
|
); |
||||
|
return; |
||||
|
} |
||||
|
this.contactFromDid.name = newName; |
||||
|
return db.contacts |
||||
|
.update(this.contactFromDid.did, { name: newName }) |
||||
|
.then(() => (this.contactEdit = false)); |
||||
|
} |
||||
|
|
||||
|
// note that this is also in ContactView.vue |
||||
|
async confirmSetVisibility(contact: Contact, visibility: boolean) { |
||||
|
const visibilityPrompt = visibility |
||||
|
? "Are you sure you want to make your activity visible to them?" |
||||
|
: "Are you sure you want to hide all your activity from them?"; |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "modal", |
||||
|
type: "confirm", |
||||
|
title: "Set Visibility", |
||||
|
text: visibilityPrompt, |
||||
|
onYes: async () => { |
||||
|
const success = await this.setVisibility(contact, visibility, true); |
||||
|
if (success) { |
||||
|
contact.seesMe = visibility; // didn't work inside setVisibility |
||||
|
} |
||||
|
}, |
||||
|
}, |
||||
|
-1, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// note that this is also in ContactView.vue |
||||
|
async setVisibility( |
||||
|
contact: Contact, |
||||
|
visibility: boolean, |
||||
|
showSuccessAlert: boolean, |
||||
|
) { |
||||
|
const result = await setVisibilityUtil( |
||||
|
this.activeDid, |
||||
|
this.apiServer, |
||||
|
this.axios, |
||||
|
db, |
||||
|
contact, |
||||
|
visibility, |
||||
|
); |
||||
|
if (result.success) { |
||||
|
//contact.seesMe = visibility; // why doesn't it affect the UI from here? |
||||
|
//console.log("Set result & seesMe", result, contact.seesMe, contact.did); |
||||
|
if (showSuccessAlert) { |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "success", |
||||
|
title: "Visibility Set", |
||||
|
text: |
||||
|
(contact.name || "That user") + |
||||
|
" can " + |
||||
|
(visibility ? "" : "not ") + |
||||
|
"see your activity.", |
||||
|
}, |
||||
|
3000, |
||||
|
); |
||||
|
} |
||||
|
return true; |
||||
|
} else { |
||||
|
console.error("Got strange result from setting visibility:", result); |
||||
|
const message = |
||||
|
(result.error as string) || "Could not set visibility on the server."; |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Error Setting Visibility", |
||||
|
text: message, |
||||
|
}, |
||||
|
5000, |
||||
|
); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// note that this is also in ContactView.vue |
||||
|
async checkVisibility(contact: Contact) { |
||||
|
const url = |
||||
|
this.apiServer + |
||||
|
"/api/report/canDidExplicitlySeeMe?did=" + |
||||
|
encodeURIComponent(contact.did); |
||||
|
const headers = await getHeaders(this.activeDid); |
||||
|
if (!headers["Authorization"]) { |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "No Identity", |
||||
|
text: "There is no identity to use to check visibility.", |
||||
|
}, |
||||
|
3000, |
||||
|
); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
const resp = await this.axios.get(url, { headers }); |
||||
|
if (resp.status === 200) { |
||||
|
const visibility = resp.data; |
||||
|
contact.seesMe = visibility; |
||||
|
//console.log("Visi check:", visibility, contact.seesMe, contact.did); |
||||
|
await db.contacts.update(contact.did, { seesMe: visibility }); |
||||
|
|
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "info", |
||||
|
title: "Visibility Refreshed", |
||||
|
text: |
||||
|
libsUtil.nameForContact(contact, true) + |
||||
|
" can " + |
||||
|
(visibility ? "" : "not ") + |
||||
|
"see your activity.", |
||||
|
}, |
||||
|
3000, |
||||
|
); |
||||
|
} else { |
||||
|
console.error("Got bad server response checking visibility:", resp); |
||||
|
const message = resp.data.error?.message || "Got bad server response."; |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Error Checking Visibility", |
||||
|
text: message, |
||||
|
}, |
||||
|
5000, |
||||
|
); |
||||
|
} |
||||
|
} catch (err) { |
||||
|
console.error("Caught error from request to check visibility:", err); |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Error Checking Visibility", |
||||
|
text: "Check connectivity and try again.", |
||||
|
}, |
||||
|
3000, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<style> |
||||
|
.dialog-overlay { |
||||
|
position: fixed; |
||||
|
top: 0; |
||||
|
left: 0; |
||||
|
right: 0; |
||||
|
bottom: 0; |
||||
|
background-color: rgba(0, 0, 0, 0.5); |
||||
|
display: flex; |
||||
|
justify-content: center; |
||||
|
align-items: center; |
||||
|
padding: 1.5rem; |
||||
|
} |
||||
|
.dialog { |
||||
|
background-color: white; |
||||
|
padding: 1rem; |
||||
|
border-radius: 0.5rem; |
||||
|
width: 100%; |
||||
|
max-width: 500px; |
||||
|
} |
||||
|
</style> |
@ -0,0 +1,844 @@ |
|||||
|
<template> |
||||
|
<QuickNav /> |
||||
|
<TopMessage /> |
||||
|
|
||||
|
<!-- CONTENT --> |
||||
|
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> |
||||
|
<!-- Back --> |
||||
|
<div |
||||
|
v-if="!hideBackButton" |
||||
|
class="text-lg text-center font-light relative px-7" |
||||
|
> |
||||
|
<h1 |
||||
|
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1" |
||||
|
@click="cancelBack()" |
||||
|
> |
||||
|
<fa icon="chevron-left" class="fa-fw"></fa> |
||||
|
</h1> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Heading --> |
||||
|
<h1 class="text-4xl text-center font-light px-4 mb-4">What Was Given</h1> |
||||
|
|
||||
|
<h1 class="text-xl font-bold text-center mb-4"> |
||||
|
<span> |
||||
|
From |
||||
|
{{ |
||||
|
providedByProject |
||||
|
? providerProjectName |
||||
|
: providedByGiver |
||||
|
? giverName |
||||
|
: "someone not named" |
||||
|
}} |
||||
|
</span> |
||||
|
<br /> |
||||
|
<span> |
||||
|
to |
||||
|
{{ |
||||
|
givenToProject |
||||
|
? fulfillsProjectName |
||||
|
: givenToRecipient |
||||
|
? recipientName |
||||
|
: "someone unidentified" |
||||
|
}}</span |
||||
|
> |
||||
|
</h1> |
||||
|
<textarea |
||||
|
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2" |
||||
|
placeholder="What was received" |
||||
|
v-model="description" |
||||
|
/> |
||||
|
<div class="flex flex-row justify-center"> |
||||
|
<span |
||||
|
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center text-blue-500 px-2 py-2 w-20" |
||||
|
@click="changeUnitCode()" |
||||
|
> |
||||
|
{{ libsUtil.UNIT_SHORT[unitCode] || unitCode }} |
||||
|
</span> |
||||
|
<div |
||||
|
class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2" |
||||
|
@click="amountInput === '0' ? null : decrement()" |
||||
|
> |
||||
|
<fa icon="chevron-left" /> |
||||
|
</div> |
||||
|
<input |
||||
|
type="number" |
||||
|
class="border border-r-0 border-slate-400 px-2 py-2 text-center w-20" |
||||
|
v-model="amountInput" |
||||
|
/> |
||||
|
<div |
||||
|
class="rounded-r border border-slate-400 bg-slate-200 px-4 py-2" |
||||
|
@click="increment()" |
||||
|
> |
||||
|
<fa icon="chevron-right" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="flex justify-center mt-4" data-testId="imagery"> |
||||
|
<span v-if="imageUrl" class="flex justify-between"> |
||||
|
<a :href="imageUrl" target="_blank"> |
||||
|
<img :src="imageUrl" class="h-24 rounded-xl" /> |
||||
|
</a> |
||||
|
<fa |
||||
|
icon="trash-can" |
||||
|
@click="confirmDeleteImage" |
||||
|
class="text-red-500 fa-fw ml-8 mt-10" |
||||
|
/> |
||||
|
</span> |
||||
|
<span v-else> |
||||
|
<fa |
||||
|
icon="camera" |
||||
|
class="bg-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-md" |
||||
|
@click="openImageDialog" |
||||
|
/> |
||||
|
</span> |
||||
|
</div> |
||||
|
<ImageMethodDialog ref="imageDialog" /> |
||||
|
|
||||
|
<div class="h-7 mt-4 flex"> |
||||
|
<input |
||||
|
v-if="providerProjectId && !providedByGiver" |
||||
|
type="checkbox" |
||||
|
class="h-6 w-6 mr-2" |
||||
|
v-model="providedByProject" |
||||
|
/> |
||||
|
<fa |
||||
|
v-else |
||||
|
icon="square" |
||||
|
class="bg-slate-500 text-slate-500 h-5 w-5 px-0.5 py-0.5 mr-2 rounded" |
||||
|
@click="notifyUserOfProvidingProject()" |
||||
|
/> |
||||
|
<label class="text-sm mt-1"> |
||||
|
{{ |
||||
|
providerProjectId |
||||
|
? "This was provided by " + providerProjectName |
||||
|
: "This was not provided by a project" |
||||
|
}} |
||||
|
</label> |
||||
|
</div> |
||||
|
|
||||
|
<div class="h-7 mt-4 flex"> |
||||
|
<input |
||||
|
v-if="fulfillsProjectId && !givenToRecipient" |
||||
|
type="checkbox" |
||||
|
class="h-6 w-6 mr-2" |
||||
|
v-model="givenToProject" |
||||
|
/> |
||||
|
<fa |
||||
|
v-else |
||||
|
icon="square" |
||||
|
class="bg-slate-500 text-slate-500 h-5 w-5 px-0.5 py-0.5 mr-2 rounded" |
||||
|
@click="notifyUserFulfillsProject()" |
||||
|
/> |
||||
|
<label class="text-sm mt-1"> |
||||
|
{{ |
||||
|
fulfillsProjectId |
||||
|
? "This was given to " + fulfillsProjectName |
||||
|
: "No recipient project was chosen" |
||||
|
}} |
||||
|
</label> |
||||
|
</div> |
||||
|
|
||||
|
<div class="h-7 mt-4 flex"> |
||||
|
<input |
||||
|
v-if="recipientDid && !givenToProject" |
||||
|
type="checkbox" |
||||
|
class="h-6 w-6 mr-2" |
||||
|
v-model="givenToRecipient" |
||||
|
/> |
||||
|
<fa |
||||
|
v-else |
||||
|
icon="square" |
||||
|
class="bg-slate-500 text-slate-500 h-5 w-5 px-0.5 py-0.5 mr-2 rounded" |
||||
|
@click="notifyUserOfRecipient()" |
||||
|
/> |
||||
|
<label class="text-sm mt-1"> |
||||
|
{{ |
||||
|
recipientDid |
||||
|
? "This was given to " + recipientName |
||||
|
: "No recipient was chosen." |
||||
|
}} |
||||
|
</label> |
||||
|
</div> |
||||
|
|
||||
|
<div class="mt-4 flex"> |
||||
|
<input type="checkbox" class="h-6 w-6 mr-2" v-model="isTrade" /> |
||||
|
<label class="text-sm mt-1">This was a trade (not a gift)</label> |
||||
|
</div> |
||||
|
|
||||
|
<div v-if="showGeneralAdvanced" class="mt-4 flex"> |
||||
|
<router-link |
||||
|
:to="{ |
||||
|
name: 'claim-add-raw', |
||||
|
query: { |
||||
|
claim: constructGiveParam(), |
||||
|
}, |
||||
|
}" |
||||
|
class="text-blue-500" |
||||
|
> |
||||
|
Edit & Submit Raw |
||||
|
</router-link> |
||||
|
</div> |
||||
|
|
||||
|
<p class="text-center mb-2 mt-6 italic"> |
||||
|
Sign & Send to publish to the world |
||||
|
<fa |
||||
|
icon="circle-info" |
||||
|
class="pl-2 text-blue-500 cursor-pointer" |
||||
|
@click="explainData()" |
||||
|
/> |
||||
|
</p> |
||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2"> |
||||
|
<button |
||||
|
class="block w-full text-center text-lg font-bold uppercase 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-3 rounded-md" |
||||
|
@click="confirm" |
||||
|
> |
||||
|
Sign & Send |
||||
|
</button> |
||||
|
<button |
||||
|
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md" |
||||
|
@click="cancel" |
||||
|
> |
||||
|
Cancel |
||||
|
</button> |
||||
|
</div> |
||||
|
</section> |
||||
|
</template> |
||||
|
|
||||
|
<script lang="ts"> |
||||
|
import { Component, Vue } from "vue-facing-decorator"; |
||||
|
import { Router } from "vue-router"; |
||||
|
|
||||
|
import ImageMethodDialog from "@/components/ImageMethodDialog.vue"; |
||||
|
import QuickNav from "@/components/QuickNav.vue"; |
||||
|
import TopMessage from "@/components/TopMessage.vue"; |
||||
|
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "@/constants/app"; |
||||
|
import { accountsDB, db, retrieveSettingsForActiveAccount } from "@/db/index"; |
||||
|
import { |
||||
|
createAndSubmitGive, |
||||
|
didInfo, |
||||
|
editAndSubmitGive, |
||||
|
GenericCredWrapper, |
||||
|
getHeaders, |
||||
|
getPlanFromCache, |
||||
|
GiveVerifiableCredential, |
||||
|
hydrateGive, |
||||
|
} from "@/libs/endorserServer"; |
||||
|
import * as libsUtil from "@/libs/util"; |
||||
|
import { Contact } from "@/db/tables/contacts"; |
||||
|
|
||||
|
@Component({ |
||||
|
components: { |
||||
|
ImageMethodDialog, |
||||
|
QuickNav, |
||||
|
TopMessage, |
||||
|
}, |
||||
|
}) |
||||
|
export default class GiftedDetails extends Vue { |
||||
|
$notify!: (notification: NotificationIface, timeout?: number) => void; |
||||
|
|
||||
|
activeDid = ""; |
||||
|
apiServer = ""; |
||||
|
|
||||
|
amountInput = "0"; |
||||
|
description = ""; |
||||
|
destinationPathAfter = ""; |
||||
|
fulfillsProjectId = ""; |
||||
|
fulfillsProjectName = "a project"; |
||||
|
givenToProject = false; // basically static, based on input; if we allow changing then let's fix things (see below) |
||||
|
givenToRecipient = false; // basically static, based on input; if we allow changing then let's fix things (see below) |
||||
|
giverDid: string | undefined; |
||||
|
giverName = ""; |
||||
|
hideBackButton = false; |
||||
|
imageUrl = ""; |
||||
|
isTrade = false; |
||||
|
message = ""; |
||||
|
offerId = ""; |
||||
|
prevCredToEdit?: GenericCredWrapper<GiveVerifiableCredential>; |
||||
|
providerProjectId = ""; |
||||
|
providerProjectName = "a project"; |
||||
|
providedByProject = false; // basically static, based on input; if we allow changing then let's fix things (see below) |
||||
|
providedByGiver = false; // basically static, based on input; if we allow changing then let's fix things (see below) |
||||
|
recipientDid = ""; |
||||
|
recipientName = ""; |
||||
|
showGeneralAdvanced = false; |
||||
|
unitCode = "HUR"; |
||||
|
|
||||
|
libsUtil = libsUtil; |
||||
|
|
||||
|
async mounted() { |
||||
|
try { |
||||
|
this.prevCredToEdit = (this.$route as Router).query["prevCredToEdit"] |
||||
|
? (JSON.parse( |
||||
|
(this.$route as Router).query["prevCredToEdit"], |
||||
|
) as GenericCredWrapper<GiveVerifiableCredential>) |
||||
|
: undefined; |
||||
|
} catch (error) { |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Retrieval Error", |
||||
|
text: "The previous record isn't available for editing. If you submit, you'll create a new record.", |
||||
|
}, |
||||
|
6000, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const prevAmount = this.prevCredToEdit?.claim?.object?.amountOfThisGood; |
||||
|
this.amountInput = |
||||
|
(this.$route as Router).query["amountInput"] || |
||||
|
(prevAmount ? String(prevAmount) : "") || |
||||
|
this.amountInput; |
||||
|
this.description = |
||||
|
(this.$route as Router).query["description"] || |
||||
|
this.prevCredToEdit?.claim?.description || |
||||
|
this.description; |
||||
|
this.destinationPathAfter = (this.$route as Router).query[ |
||||
|
"destinationPathAfter" |
||||
|
]; |
||||
|
this.giverDid = ((this.$route as Router).query["giverDid"] || |
||||
|
this.prevCredToEdit?.claim?.agent?.identifier || |
||||
|
this.giverDid) as string; |
||||
|
this.giverName = |
||||
|
((this.$route as Router).query["giverName"] as string) || ""; |
||||
|
this.hideBackButton = |
||||
|
(this.$route as Router).query["hideBackButton"] === "true"; |
||||
|
this.message = ((this.$route as Router).query["message"] as string) || ""; |
||||
|
|
||||
|
// find any offer ID |
||||
|
const fulfills = this.prevCredToEdit?.claim?.fulfills; |
||||
|
const fulfillsArray = Array.isArray(fulfills) |
||||
|
? fulfills |
||||
|
: fulfills |
||||
|
? [fulfills] |
||||
|
: []; |
||||
|
const offer = fulfillsArray.find((rec) => rec["@type"] === "Offer"); |
||||
|
this.offerId = ((this.$route as Router).query["offerId"] || |
||||
|
offer?.identifier || |
||||
|
this.offerId) as string; |
||||
|
|
||||
|
// find any fulfills project ID |
||||
|
const fulfillsProject = fulfillsArray.find( |
||||
|
(rec) => rec["@type"] === "PlanAction", |
||||
|
); |
||||
|
// eslint-disable-next-line prettier/prettier |
||||
|
this.fulfillsProjectId = |
||||
|
((this.$route as Router).query["fulfillsProjectId"] || |
||||
|
fulfillsProject?.identifier || |
||||
|
this.fulfillsProjectId) as string; |
||||
|
|
||||
|
// find any provider project ID |
||||
|
const provider = this.prevCredToEdit?.claim?.provider; |
||||
|
const providerArray = Array.isArray(provider) |
||||
|
? provider |
||||
|
: provider |
||||
|
? [provider] |
||||
|
: []; |
||||
|
const providerProject = providerArray.find( |
||||
|
(rec) => rec["@type"] === "PlanAction", |
||||
|
); |
||||
|
this.providerProjectId = ((this.$route as Router).query[ |
||||
|
"providerProjectId" |
||||
|
] || |
||||
|
providerProject?.identifier || |
||||
|
this.providerProjectId) as string; |
||||
|
|
||||
|
this.recipientDid = ((this.$route as Router).query["recipientDid"] || |
||||
|
this.prevCredToEdit?.claim?.recipient?.identifier) as string; |
||||
|
this.recipientName = |
||||
|
((this.$route as Router).query["recipientName"] as string) || ""; |
||||
|
this.unitCode = ((this.$route as Router).query["unitCode"] || |
||||
|
this.prevCredToEdit?.claim?.object?.unitCode || |
||||
|
this.unitCode) as string; |
||||
|
|
||||
|
this.imageUrl = |
||||
|
((this.$route as Router).query["imageUrl"] as string) || |
||||
|
this.prevCredToEdit?.claim?.image || |
||||
|
localStorage.getItem("imageUrl") || |
||||
|
this.imageUrl; |
||||
|
|
||||
|
// this is an endpoint for sharing project info to highlight something given |
||||
|
// https://developer.mozilla.org/en-US/docs/Web/Manifest/share_target |
||||
|
if ((this.$route as Router).query["shareTitle"]) { |
||||
|
this.description = |
||||
|
((this.$route as Router).query["shareTitle"] as string) + |
||||
|
(this.description ? "\n" + this.description : ""); |
||||
|
} |
||||
|
if ((this.$route as Router).query["shareText"]) { |
||||
|
this.description = |
||||
|
(this.description ? this.description + "\n" : "") + |
||||
|
((this.$route as Router).query["shareText"] as string); |
||||
|
} |
||||
|
if ((this.$route as Router).query["shareUrl"]) { |
||||
|
this.imageUrl = (this.$route as Router).query["shareUrl"] as string; |
||||
|
} |
||||
|
|
||||
|
const settings = await retrieveSettingsForActiveAccount(); |
||||
|
this.apiServer = settings.apiServer || ""; |
||||
|
this.activeDid = settings.activeDid || ""; |
||||
|
|
||||
|
let allContacts: Contact[] = []; |
||||
|
let allMyDids: string[] = []; |
||||
|
if ( |
||||
|
(this.giverDid && !this.giverName) || |
||||
|
(this.recipientDid && !this.recipientName) |
||||
|
) { |
||||
|
allContacts = await db.contacts.toArray(); |
||||
|
|
||||
|
await accountsDB.open(); |
||||
|
const allAccounts = await accountsDB.accounts.toArray(); |
||||
|
allMyDids = allAccounts.map((acc) => acc.did); |
||||
|
if (this.giverDid && !this.giverName) { |
||||
|
this.giverName = didInfo( |
||||
|
this.giverDid, |
||||
|
this.activeDid, |
||||
|
allMyDids, |
||||
|
allContacts, |
||||
|
); |
||||
|
} |
||||
|
if (this.recipientDid && !this.recipientName) { |
||||
|
this.recipientName = didInfo( |
||||
|
this.recipientDid, |
||||
|
this.activeDid, |
||||
|
allMyDids, |
||||
|
allContacts, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
// these should be functions but something's wrong with the syntax in the <> conditional |
||||
|
this.givenToProject = !!this.fulfillsProjectId; |
||||
|
this.givenToRecipient = !this.givenToProject && !!this.recipientDid; |
||||
|
|
||||
|
// these should be functions but something's wrong with the syntax in the <> conditional |
||||
|
this.providedByProject = !!this.providerProjectId; |
||||
|
this.providedByGiver = !this.providedByProject && !!this.giverDid; |
||||
|
|
||||
|
this.showGeneralAdvanced = !!settings.showGeneralAdvanced; |
||||
|
|
||||
|
if (this.fulfillsProjectId) { |
||||
|
// console.log("Getting project name from cache", this.fulfillsProjectId); |
||||
|
const fulfillsProject = await getPlanFromCache( |
||||
|
this.fulfillsProjectId, |
||||
|
this.axios, |
||||
|
this.apiServer, |
||||
|
this.activeDid, |
||||
|
); |
||||
|
this.fulfillsProjectName = fulfillsProject?.name |
||||
|
? `the project "${fulfillsProject.name}"` |
||||
|
: "a project"; |
||||
|
} |
||||
|
if (this.providerProjectId) { |
||||
|
// console.log("Getting project name from cache", this.providerProjectId); |
||||
|
const providerProject = await getPlanFromCache( |
||||
|
this.providerProjectId, |
||||
|
this.axios, |
||||
|
this.apiServer, |
||||
|
this.activeDid, |
||||
|
); |
||||
|
this.providerProjectName = providerProject?.name |
||||
|
? `the project "${providerProject.name}"` |
||||
|
: "a project"; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
changeUnitCode() { |
||||
|
const units = Object.keys(this.libsUtil.UNIT_SHORT); |
||||
|
const index = units.indexOf(this.unitCode); |
||||
|
this.unitCode = units[(index + 1) % units.length]; |
||||
|
} |
||||
|
|
||||
|
increment() { |
||||
|
this.amountInput = `${(parseFloat(this.amountInput) || 0) + 1}`; |
||||
|
} |
||||
|
|
||||
|
decrement() { |
||||
|
this.amountInput = `${Math.max( |
||||
|
0, |
||||
|
(parseFloat(this.amountInput) || 1) - 1, |
||||
|
)}`; |
||||
|
} |
||||
|
|
||||
|
cancel() { |
||||
|
this.deleteImage(); // not awaiting, so they'll go back immediately |
||||
|
if (this.destinationPathAfter) { |
||||
|
(this.$router as Router).push({ path: this.destinationPathAfter }); |
||||
|
} else { |
||||
|
(this.$router as Router).back(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
cancelBack() { |
||||
|
this.deleteImage(); // not awaiting, so they'll go back immediately |
||||
|
(this.$router as Router).back(); |
||||
|
} |
||||
|
|
||||
|
openImageDialog() { |
||||
|
(this.$refs.imageDialog as ImageMethodDialog).open((imgUrl) => { |
||||
|
this.imageUrl = imgUrl; |
||||
|
}, "GiveAction"); |
||||
|
} |
||||
|
|
||||
|
confirmDeleteImage() { |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "modal", |
||||
|
type: "confirm", |
||||
|
title: "Are you sure you want to delete the image?", |
||||
|
text: "", |
||||
|
onYes: this.deleteImage, |
||||
|
}, |
||||
|
-1, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
async deleteImage() { |
||||
|
if (!this.imageUrl) { |
||||
|
return; |
||||
|
} |
||||
|
try { |
||||
|
const headers = await getHeaders(this.activeDid); |
||||
|
const response = await this.axios.delete( |
||||
|
DEFAULT_IMAGE_API_SERVER + |
||||
|
"/image/" + |
||||
|
encodeURIComponent(this.imageUrl), |
||||
|
{ headers }, |
||||
|
); |
||||
|
if (response.status === 204) { |
||||
|
// don't bother with a notification |
||||
|
// (either they'll simply continue or they're canceling and going back) |
||||
|
} else { |
||||
|
console.error("Problem deleting image:", response); |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Error", |
||||
|
text: "There was a problem deleting the image.", |
||||
|
}, |
||||
|
5000, |
||||
|
); |
||||
|
// keep the imageUrl in localStorage so the user can try again if they want |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
localStorage.removeItem("imageUrl"); |
||||
|
this.imageUrl = ""; |
||||
|
} catch (error) { |
||||
|
console.error("Error deleting image:", error); |
||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any |
||||
|
if ((error as any).response.status === 404) { |
||||
|
console.log("Weird: the image was already deleted.", error); |
||||
|
|
||||
|
localStorage.removeItem("imageUrl"); |
||||
|
this.imageUrl = ""; |
||||
|
|
||||
|
// it already doesn't exist so we won't say anything to the user |
||||
|
} else { |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Error", |
||||
|
text: "There was an error deleting the image.", |
||||
|
}, |
||||
|
5000, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async confirm() { |
||||
|
if (!this.activeDid) { |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Error", |
||||
|
text: "You must select an identifier before you can record a give.", |
||||
|
}, |
||||
|
2000, |
||||
|
); |
||||
|
return; |
||||
|
} |
||||
|
if (parseFloat(this.amountInput) < 0) { |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
text: "You may not send a negative number.", |
||||
|
title: "", |
||||
|
}, |
||||
|
2000, |
||||
|
); |
||||
|
return; |
||||
|
} |
||||
|
if (!this.description && !parseFloat(this.amountInput)) { |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Error", |
||||
|
text: `You must enter a description or some number of ${ |
||||
|
this.libsUtil.UNIT_LONG[this.unitCode] |
||||
|
}.`, |
||||
|
}, |
||||
|
2000, |
||||
|
); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "toast", |
||||
|
text: "Recording the give...", |
||||
|
title: "", |
||||
|
}, |
||||
|
1000, |
||||
|
); |
||||
|
|
||||
|
// this is asynchronous, but we don't need to wait for it to complete |
||||
|
await this.recordGive(); |
||||
|
} |
||||
|
|
||||
|
notifyUserOfProvidingProject() { |
||||
|
// we're here because they clicked and either there is no provider project or there is a giver chosen |
||||
|
if (!this.providerProjectId) { |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "warning", |
||||
|
title: "Error", |
||||
|
text: "To select a project as a provider, you must open this page through a project.", |
||||
|
}, |
||||
|
3000, |
||||
|
); |
||||
|
} else { |
||||
|
// no providing project was chosen |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "warning", |
||||
|
title: "Error", |
||||
|
text: "You cannot select both a giving project and person.", |
||||
|
}, |
||||
|
3000, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
notifyUserFulfillsProject() { |
||||
|
// we're here because they clicked and either there is no fulfills project or there is a recipient chosen |
||||
|
if (!this.fulfillsProjectId) { |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "warning", |
||||
|
title: "Error", |
||||
|
text: "To assign to a project, you must open this page through a project.", |
||||
|
}, |
||||
|
3000, |
||||
|
); |
||||
|
} else { |
||||
|
// no fulfills project was chosen |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "warning", |
||||
|
title: "Error", |
||||
|
text: "You cannot assign both to a project and to a recipient.", |
||||
|
}, |
||||
|
3000, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
notifyUserOfRecipient() { |
||||
|
if (!this.recipientDid) { |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "warning", |
||||
|
title: "Error", |
||||
|
text: "To assign to a recipient, you must open this page from a contact.", |
||||
|
}, |
||||
|
3000, |
||||
|
); |
||||
|
} else { |
||||
|
// must be because givenToProject is true |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "warning", |
||||
|
title: "Error", |
||||
|
text: "You cannot assign both to a recipient and to a project.", |
||||
|
}, |
||||
|
3000, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* |
||||
|
* @param giverDid may be null |
||||
|
* @param description may be an empty string |
||||
|
* @param amountInput may be 0 |
||||
|
* @param unitCode may be omitted, defaults to "HUR" |
||||
|
*/ |
||||
|
public async recordGive() { |
||||
|
try { |
||||
|
const recipientDid = this.givenToRecipient |
||||
|
? this.recipientDid |
||||
|
: undefined; |
||||
|
const fulfillsProjectId = this.givenToProject |
||||
|
? this.fulfillsProjectId |
||||
|
: undefined; |
||||
|
let result; |
||||
|
if (this.prevCredToEdit) { |
||||
|
// don't create from a blank one in case some properties were set from a different interface |
||||
|
result = await editAndSubmitGive( |
||||
|
this.axios, |
||||
|
this.apiServer, |
||||
|
this.prevCredToEdit, |
||||
|
this.activeDid, |
||||
|
this.giverDid, |
||||
|
recipientDid, |
||||
|
this.description, |
||||
|
parseFloat(this.amountInput), |
||||
|
this.unitCode, |
||||
|
fulfillsProjectId, |
||||
|
this.offerId, |
||||
|
this.isTrade, |
||||
|
this.imageUrl, |
||||
|
this.providerProjectId, |
||||
|
); |
||||
|
} else { |
||||
|
result = await createAndSubmitGive( |
||||
|
this.axios, |
||||
|
this.apiServer, |
||||
|
this.activeDid, |
||||
|
this.giverDid, |
||||
|
recipientDid, |
||||
|
this.description, |
||||
|
parseFloat(this.amountInput), |
||||
|
this.unitCode, |
||||
|
fulfillsProjectId, |
||||
|
this.offerId, |
||||
|
this.isTrade, |
||||
|
this.imageUrl, |
||||
|
this.providerProjectId, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
if ( |
||||
|
result.type === "error" || |
||||
|
this.isGiveCreationError(result.response) |
||||
|
) { |
||||
|
const errorMessage = this.getGiveCreationErrorMessage(result); |
||||
|
console.error("Error with give creation result:", result); |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Error", |
||||
|
text: errorMessage || "There was an error creating the give.", |
||||
|
}, |
||||
|
-1, |
||||
|
); |
||||
|
} else { |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "success", |
||||
|
title: "Success", |
||||
|
text: `That ${this.isTrade ? "trade" : "gift"} was recorded.`, |
||||
|
}, |
||||
|
5000, |
||||
|
); |
||||
|
localStorage.removeItem("imageUrl"); |
||||
|
if (this.destinationPathAfter) { |
||||
|
(this.$router as Router).push({ path: this.destinationPathAfter }); |
||||
|
} else { |
||||
|
(this.$router as Router).back(); |
||||
|
} |
||||
|
} |
||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any |
||||
|
} catch (error: any) { |
||||
|
console.error("Error with give recordation caught:", error); |
||||
|
const errorMessage = |
||||
|
error.userMessage || |
||||
|
error.response?.data?.error?.message || |
||||
|
"There was an error recording the give."; |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Error", |
||||
|
text: errorMessage, |
||||
|
}, |
||||
|
-1, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
constructGiveParam() { |
||||
|
const recipientDid = this.givenToRecipient ? this.recipientDid : undefined; |
||||
|
const fulfillsProjectId = this.givenToProject |
||||
|
? this.fulfillsProjectId |
||||
|
: undefined; |
||||
|
const giveClaim = hydrateGive( |
||||
|
this.prevCredToEdit?.claim as GiveVerifiableCredential, |
||||
|
this.giverDid, |
||||
|
recipientDid, |
||||
|
this.description, |
||||
|
parseFloat(this.amountInput), |
||||
|
this.unitCode, |
||||
|
fulfillsProjectId, |
||||
|
this.offerId, |
||||
|
this.isTrade, |
||||
|
this.imageUrl, |
||||
|
this.providerProjectId, |
||||
|
this.prevCredToEdit?.id as string, |
||||
|
); |
||||
|
const claimStr = JSON.stringify(giveClaim); |
||||
|
return claimStr; |
||||
|
} |
||||
|
|
||||
|
// Helper functions for readability |
||||
|
|
||||
|
/** |
||||
|
* @param result response "data" from the server |
||||
|
* @returns true if the result indicates an error |
||||
|
*/ |
||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any |
||||
|
isGiveCreationError(result: any) { |
||||
|
return result.status !== 201 || result.data?.error; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data") |
||||
|
* @returns best guess at an error message |
||||
|
*/ |
||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any |
||||
|
getGiveCreationErrorMessage(result: any) { |
||||
|
return ( |
||||
|
result.error?.userMessage || |
||||
|
result.error?.error || |
||||
|
result.response?.data?.error?.message |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
explainData() { |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "success", |
||||
|
title: "Data Sharing", |
||||
|
text: libsUtil.PRIVACY_MESSAGE, |
||||
|
}, |
||||
|
-1, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
</script> |
@ -0,0 +1,115 @@ |
|||||
|
<template> |
||||
|
<!-- Don't include nav buttons since this is shown in a different window. --> |
||||
|
|
||||
|
<!-- CONTENT --> |
||||
|
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> |
||||
|
<!-- Breadcrumb --> |
||||
|
<div class="mb-8"> |
||||
|
<!-- Don't include 'back' button since this is shown in a different window. --> |
||||
|
<!-- Heading --> |
||||
|
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8"> |
||||
|
Time Safari Onboarding Instructions |
||||
|
</h1> |
||||
|
</div> |
||||
|
|
||||
|
<p> |
||||
|
To invite someone the easiest way, send them a link that you generate from |
||||
|
this page: |
||||
|
<router-link |
||||
|
:to="{ name: 'invite-one' }" |
||||
|
class="bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md" |
||||
|
> |
||||
|
<fa icon="envelope-open-text" class="fa-fw text-xl" |
||||
|
/></router-link> |
||||
|
</p> |
||||
|
<p>Then watch that page to see when they accept their invite.</p> |
||||
|
<p> |
||||
|
(That page is also reachable from the Contacts <fa icon="users" /> page |
||||
|
though the invitation <fa icon="envelope-open-text" /> icon.) |
||||
|
</p> |
||||
|
|
||||
|
<h1 class="mt-4 font-bold text-xl">Next Steps</h1> |
||||
|
Although not totally necessary, backups are important to understand. |
||||
|
|
||||
|
<div class="ml-4"> |
||||
|
<h1 class="font-bold text-xl">Without a backup, you can lose data.</h1> |
||||
|
<div> |
||||
|
<p> |
||||
|
Exporting backups (from the Account <fa icon="circle-user" /> screen) |
||||
|
is important for the case where they lose their device. This is |
||||
|
especially true for the Identifier Seed: that is theirs and and theirs |
||||
|
alone, and currently nobody else can recover it if they lose it. The |
||||
|
good thing is that anyone can create a new account and simply inform |
||||
|
their network of their new ID. |
||||
|
</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<h1 class="mt-4 font-bold text-xl">Advanced</h1> |
||||
|
The following are optional steps for even more functionality. |
||||
|
|
||||
|
<!-- eslint-disable prettier/prettier --> |
||||
|
<div class="ml-4"> |
||||
|
|
||||
|
<h1 class="font-bold text-xl">Add Contact & Register</h1> |
||||
|
<p> |
||||
|
You share even more information such as your picture and name when |
||||
|
you share with your QR code at these links: <fa icon="qrcode" /> |
||||
|
</p> |
||||
|
<p> |
||||
|
Scanning |
||||
|
those with your cameras will automatically register people and add them |
||||
|
to each other's contact lists. |
||||
|
</p> |
||||
|
<p> |
||||
|
The following are more detailed manual steps: |
||||
|
</p> |
||||
|
<div> |
||||
|
<p> |
||||
|
1) Have them follow their yellow prompts. |
||||
|
</p> |
||||
|
<p> |
||||
|
2) Scan their QR, or have them tap on it to copy their info and send it to you. |
||||
|
Then you can add them to your Contacts <fa icon="users" /> |
||||
|
</p> |
||||
|
<p> |
||||
|
3) You can register them at their info page <fa icon="circle-info" /> |
||||
|
and click on the register button <fa icon="person-circle-question" /> |
||||
|
</p> |
||||
|
<p> |
||||
|
4) Add yourself to their Contacts <fa icon="users" /> |
||||
|
</p> |
||||
|
</div> |
||||
|
|
||||
|
<h1 class="font-bold text-xl">Install</h1> |
||||
|
<div> |
||||
|
<p> |
||||
|
Have them visit TimeSafari.app in a browser, preferably Chrome or Safari, |
||||
|
and then look for the "Install" selection which adds this app to their desktop. |
||||
|
This enables other things, like the ability to "share" a photo from their |
||||
|
device directly to Time Safari, and it makes notifications more reliable. |
||||
|
</p> |
||||
|
</div> |
||||
|
|
||||
|
<h1 class="font-bold text-xl">Enable Notifications</h1> |
||||
|
<div> |
||||
|
<p> |
||||
|
Enable notifications from the Account page <fa icon="circle-user" />. |
||||
|
Those notifications might show up on the device depending on your settings. |
||||
|
For the most reliable habits, people should own alarm or some other ritual to look every day. |
||||
|
</p> |
||||
|
</div> |
||||
|
|
||||
|
</div> |
||||
|
<!-- eslint enable --> |
||||
|
</section> |
||||
|
</template> |
||||
|
|
||||
|
<script lang="ts"> |
||||
|
import { Component, Vue } from "vue-facing-decorator"; |
||||
|
|
||||
|
import QuickNav from "@/components/QuickNav.vue"; |
||||
|
|
||||
|
@Component({ components: { QuickNav } }) |
||||
|
export default class Help extends Vue {} |
||||
|
</script> |
@ -0,0 +1,392 @@ |
|||||
|
<template> |
||||
|
<QuickNav selected="Invite" /> |
||||
|
<TopMessage /> |
||||
|
|
||||
|
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> |
||||
|
<!-- Back --> |
||||
|
<div class="text-lg text-center font-light relative px-7"> |
||||
|
<h1 |
||||
|
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1" |
||||
|
@click="$router.back()" |
||||
|
> |
||||
|
<fa icon="chevron-left" class="fa-fw"></fa> |
||||
|
</h1> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Heading --> |
||||
|
<h1 class="text-4xl text-center font-light">Invitations</h1> |
||||
|
|
||||
|
<ul class="ml-8 mt-4 list-outside list-disc w-5/6"> |
||||
|
<li> |
||||
|
Note when sending |
||||
|
<span |
||||
|
v-if="!showAppleWarning" |
||||
|
class="text-blue-500 cursor-pointer" |
||||
|
@click="showAppleWarning = !showAppleWarning" |
||||
|
> |
||||
|
to Apple users... |
||||
|
</span> |
||||
|
<span v-else> |
||||
|
to Apple users: their links often fail because their device cuts off |
||||
|
part of the link. You might need to send it to them some other way, |
||||
|
like in an email. |
||||
|
</span> |
||||
|
</li> |
||||
|
</ul> |
||||
|
|
||||
|
<!-- New Project --> |
||||
|
<button |
||||
|
v-if="isRegistered" |
||||
|
class="fixed right-6 top-12 text-center text-4xl leading-none bg-green-600 text-white w-14 py-2.5 rounded-full" |
||||
|
@click="createInvite()" |
||||
|
> |
||||
|
<fa icon="plus" class="fa-fw"></fa> |
||||
|
</button> |
||||
|
|
||||
|
<InviteDialog ref="inviteDialog" /> |
||||
|
|
||||
|
<!-- Invites Table --> |
||||
|
<div v-if="invites.length" class="mt-6"> |
||||
|
<table class="min-w-full bg-white"> |
||||
|
<thead> |
||||
|
<tr> |
||||
|
<th class="py-2"> |
||||
|
ID |
||||
|
<br /> |
||||
|
(click for link) |
||||
|
</th> |
||||
|
<th class="py-2">Notes</th> |
||||
|
<th class="py-2">Expires At</th> |
||||
|
<th class="py-2">Redeemed</th> |
||||
|
</tr> |
||||
|
</thead> |
||||
|
<tbody> |
||||
|
<tr |
||||
|
v-for="invite in invites" |
||||
|
:key="invite.inviteIdentifier" |
||||
|
class="border-t py-2" |
||||
|
> |
||||
|
<td> |
||||
|
<span |
||||
|
v-if=" |
||||
|
!invite.redeemedAt && |
||||
|
invite.expiresAt > new Date().toISOString() |
||||
|
" |
||||
|
@click=" |
||||
|
copyInviteAndNotify(invite.inviteIdentifier, invite.jwt) |
||||
|
" |
||||
|
class="text-center text-blue-500 cursor-pointer" |
||||
|
:title="inviteLink(invite.jwt)" |
||||
|
> |
||||
|
{{ getTruncatedInviteId(invite.inviteIdentifier) }} |
||||
|
</span> |
||||
|
<span |
||||
|
v-else |
||||
|
@click=" |
||||
|
showInvite( |
||||
|
invite.inviteIdentifier, |
||||
|
!!invite.redeemedAt, |
||||
|
invite.expiresAt < new Date().toISOString(), |
||||
|
) |
||||
|
" |
||||
|
class="text-center text-slate-500 cursor-pointer" |
||||
|
:title="inviteLink(invite.jwt)" |
||||
|
> |
||||
|
{{ getTruncatedInviteId(invite.inviteIdentifier) }} |
||||
|
</span> |
||||
|
</td> |
||||
|
<td class="text-left" :data-testId="inviteLink(invite.jwt)"> |
||||
|
{{ invite.notes }} |
||||
|
</td> |
||||
|
<td class="text-center"> |
||||
|
{{ invite.expiresAt.substring(0, 10) }} |
||||
|
</td> |
||||
|
<td class="text-center"> |
||||
|
{{ invite.redeemedAt?.substring(0, 10) }} |
||||
|
<br /> |
||||
|
{{ getTruncatedRedeemedBy(invite.redeemedBy) }} |
||||
|
<br /> |
||||
|
<fa |
||||
|
v-if="invite.redeemedBy && !contactsRedeemed[invite.redeemedBy]" |
||||
|
icon="plus" |
||||
|
class="bg-green-600 text-white px-1 py-1 rounded-full cursor-pointer" |
||||
|
@click="addNewContact(invite.redeemedBy)" |
||||
|
/> |
||||
|
</td> |
||||
|
<td> |
||||
|
<fa |
||||
|
icon="trash-can" |
||||
|
class="text-red-600 text-xl ml-2 mr-2 cursor-pointer" |
||||
|
@click="deleteInvite(invite.inviteIdentifier, invite.notes)" |
||||
|
/> |
||||
|
</td> |
||||
|
</tr> |
||||
|
</tbody> |
||||
|
</table> |
||||
|
<ContactNameDialog ref="contactNameDialog" /> |
||||
|
</div> |
||||
|
<p v-else class="mt-6 text-center">No invites found.</p> |
||||
|
</section> |
||||
|
</template> |
||||
|
<script lang="ts"> |
||||
|
import axios from "axios"; |
||||
|
import { Component, Vue } from "vue-facing-decorator"; |
||||
|
import { useClipboard } from "@vueuse/core"; |
||||
|
|
||||
|
import ContactNameDialog from "@/components/ContactNameDialog.vue"; |
||||
|
import QuickNav from "@/components/QuickNav.vue"; |
||||
|
import TopMessage from "@/components/TopMessage.vue"; |
||||
|
import InviteDialog from "@/components/InviteDialog.vue"; |
||||
|
import { APP_SERVER, NotificationIface } from "@/constants/app"; |
||||
|
import { db, retrieveSettingsForActiveAccount } from "@/db"; |
||||
|
import { createInviteJwt, getHeaders } from "@/libs/endorserServer"; |
||||
|
|
||||
|
interface Invite { |
||||
|
inviteIdentifier: string; |
||||
|
expiresAt: string; |
||||
|
jwt: string; |
||||
|
notes: string; |
||||
|
redeemedAt: string | null; |
||||
|
redeemedBy: string | null; |
||||
|
} |
||||
|
|
||||
|
@Component({ |
||||
|
components: { ContactNameDialog, QuickNav, TopMessage, InviteDialog }, |
||||
|
}) |
||||
|
export default class InviteOneView extends Vue { |
||||
|
$notify!: (notification: NotificationIface, timeout?: number) => void; |
||||
|
|
||||
|
invites: Invite[] = []; |
||||
|
activeDid: string = ""; |
||||
|
apiServer: string = ""; |
||||
|
contactsRedeemed = {}; |
||||
|
isRegistered: boolean = false; |
||||
|
showAppleWarning = false; |
||||
|
|
||||
|
async mounted() { |
||||
|
try { |
||||
|
await db.open(); |
||||
|
const settings = await retrieveSettingsForActiveAccount(); |
||||
|
this.activeDid = settings.activeDid || ""; |
||||
|
this.apiServer = settings.apiServer || ""; |
||||
|
this.isRegistered = !!settings.isRegistered; |
||||
|
|
||||
|
const headers = await getHeaders(this.activeDid); |
||||
|
const response = await axios.get( |
||||
|
this.apiServer + "/api/userUtil/invite", |
||||
|
{ headers }, |
||||
|
); |
||||
|
this.invites = response.data.data; |
||||
|
|
||||
|
const baseContacts = await db.contacts.toArray(); |
||||
|
for (const invite of this.invites) { |
||||
|
const contact = baseContacts.find( |
||||
|
(contact) => contact.did === invite.redeemedBy, |
||||
|
); |
||||
|
if (contact) { |
||||
|
this.contactsRedeemed[invite.redeemedBy] = contact; |
||||
|
} |
||||
|
} |
||||
|
} catch (error) { |
||||
|
console.error("Error fetching invites:", error); |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Load Error", |
||||
|
text: "Got an error loading your invites.", |
||||
|
}, |
||||
|
5000, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
getTruncatedInviteId(inviteId: string): string { |
||||
|
if (inviteId.length <= 9) return inviteId; |
||||
|
return `${inviteId.slice(0, 6)}...`; |
||||
|
} |
||||
|
|
||||
|
getTruncatedRedeemedBy(redeemedBy: string | null): string { |
||||
|
if (!redeemedBy) return ""; |
||||
|
if (this.contactsRedeemed[redeemedBy]) { |
||||
|
return this.contactsRedeemed[redeemedBy].name; |
||||
|
} |
||||
|
if (redeemedBy.length <= 19) return redeemedBy; |
||||
|
return `${redeemedBy.slice(0, 13)}...${redeemedBy.slice(-3)}`; |
||||
|
} |
||||
|
|
||||
|
inviteLink(jwt: string): string { |
||||
|
return APP_SERVER + "/contacts?inviteJwt=" + jwt; |
||||
|
} |
||||
|
|
||||
|
copyInviteAndNotify(inviteId: string, jwt: string) { |
||||
|
useClipboard().copy(this.inviteLink(jwt)); |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "success", |
||||
|
title: "Copied", |
||||
|
text: "Your clipboard now contains the link for invite " + inviteId, |
||||
|
}, |
||||
|
5000, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
showInvite(inviteId: string, redeemed: boolean, expired: boolean) { |
||||
|
let message = `Your clipboard now contains the invite ID ${inviteId}`; |
||||
|
if (redeemed) { |
||||
|
message += " (This invite has been used.)"; |
||||
|
} else if (expired) { |
||||
|
message += " (This invite has expired.)"; |
||||
|
} |
||||
|
useClipboard().copy(inviteId); |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "success", |
||||
|
title: "Copied", |
||||
|
text: message, |
||||
|
}, |
||||
|
5000, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
lookForErrorAndNotify(error, title: string, defaultMessage: string) { |
||||
|
console.error(title, "-", error); |
||||
|
let message = defaultMessage; |
||||
|
if (error.response && error.response.data && error.response.data.error) { |
||||
|
if (error.response.data.error.message) { |
||||
|
message = error.response.data.error.message; |
||||
|
} else { |
||||
|
message = error.response.data.error; |
||||
|
} |
||||
|
} |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: title, |
||||
|
text: message, |
||||
|
}, |
||||
|
5000, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
async createInvite() { |
||||
|
const inviteIdentifier = |
||||
|
Math.random().toString(36).substring(2) + |
||||
|
Math.random().toString(36).substring(2) + |
||||
|
Math.random().toString(36).substring(2); |
||||
|
(this.$refs.inviteDialog as InviteDialog).open( |
||||
|
inviteIdentifier, |
||||
|
async (notes, expiresAt) => { |
||||
|
try { |
||||
|
const headers = await getHeaders(this.activeDid); |
||||
|
if (!expiresAt) { |
||||
|
throw { |
||||
|
response: { |
||||
|
data: { error: "You must select an expiration date." }, |
||||
|
}, |
||||
|
}; |
||||
|
} |
||||
|
const expiresIn = (new Date(expiresAt).getTime() - Date.now()) / 1000; |
||||
|
const inviteJwt = await createInviteJwt( |
||||
|
this.activeDid, |
||||
|
undefined, |
||||
|
inviteIdentifier, |
||||
|
expiresIn, |
||||
|
); |
||||
|
await axios.post( |
||||
|
this.apiServer + "/api/userUtil/invite", |
||||
|
{ inviteJwt: inviteJwt, notes: notes }, |
||||
|
{ headers }, |
||||
|
); |
||||
|
this.invites.push({ |
||||
|
inviteIdentifier: inviteIdentifier, |
||||
|
expiresAt: expiresAt, |
||||
|
jwt: inviteJwt, |
||||
|
notes: notes, |
||||
|
redeemedAt: null, |
||||
|
redeemedBy: null, |
||||
|
}); |
||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any |
||||
|
} catch (error: any) { |
||||
|
this.lookForErrorAndNotify( |
||||
|
error, |
||||
|
"Error Creating Invite", |
||||
|
"Got an error creating your invite.", |
||||
|
); |
||||
|
} |
||||
|
}, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
addNewContact(did) { |
||||
|
(this.$refs.contactNameDialog as ContactNameDialog).open( |
||||
|
"Who Sent You The Invite?", |
||||
|
"Their name will be added to your contact list.", |
||||
|
(name) => { |
||||
|
// the person obviously registered themselves and this user already granted visibility, so we just add them |
||||
|
const contact = { |
||||
|
did: did, |
||||
|
name: name, |
||||
|
registered: true, |
||||
|
}; |
||||
|
db.contacts.add(contact); |
||||
|
this.contactsRedeemed[did] = contact; |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "success", |
||||
|
title: "Contact Added", |
||||
|
text: `${name} has been added to your contacts.`, |
||||
|
}, |
||||
|
3000, |
||||
|
); |
||||
|
}, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
deleteInvite(inviteId: string, notes: string) { |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "modal", |
||||
|
type: "confirm", |
||||
|
title: "Delete Invite?", |
||||
|
text: `Are you sure you want to erase the invite for "${notes}"? (There is no undo.)`, |
||||
|
onYes: async () => { |
||||
|
const headers = await getHeaders(this.activeDid); |
||||
|
try { |
||||
|
const result = await axios.delete( |
||||
|
this.apiServer + "/api/userUtil/invite/" + inviteId, |
||||
|
{ headers }, |
||||
|
); |
||||
|
if (result.status !== 204) { |
||||
|
throw result.data; |
||||
|
} |
||||
|
this.invites = this.invites.filter( |
||||
|
(invite) => invite.inviteIdentifier !== inviteId, |
||||
|
); |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "success", |
||||
|
title: "Deleted", |
||||
|
text: "Invite deleted.", |
||||
|
}, |
||||
|
3000, |
||||
|
); |
||||
|
} catch (e) { |
||||
|
this.lookForErrorAndNotify( |
||||
|
e, |
||||
|
"Error Deleting Invite", |
||||
|
"Got an error deleting your invite.", |
||||
|
); |
||||
|
} |
||||
|
}, |
||||
|
}, |
||||
|
-1, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
</script> |
@ -0,0 +1,335 @@ |
|||||
|
<template> |
||||
|
<QuickNav selected="Home"></QuickNav> |
||||
|
<!-- CONTENT --> |
||||
|
<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"> |
||||
|
<!-- Back --> |
||||
|
<fa |
||||
|
icon="chevron-left" |
||||
|
@click="$router.back()" |
||||
|
class="fa-fw text-lg text-center px-2 py-1 absolute -left-2 -top-1" |
||||
|
/> |
||||
|
New Activity For You |
||||
|
</h1> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Display a single row with the name of "New Offers To You" with a count. --> |
||||
|
<div class="flex justify-between" data-testId="showOffersToUser"> |
||||
|
<div> |
||||
|
<span class="text-lg font-medium" |
||||
|
>{{ newOffersToUser.length |
||||
|
}}{{ newOffersToUserHitLimit ? "+" : "" }}</span |
||||
|
> |
||||
|
<span class="text-lg font-medium ml-4" |
||||
|
>New Offer{{ newOffersToUser.length === 1 ? "" : "s" }} To You</span |
||||
|
> |
||||
|
<fa |
||||
|
v-if="newOffersToUser.length > 0" |
||||
|
:icon="showOffersDetails ? 'chevron-down' : 'chevron-right'" |
||||
|
class="cursor-pointer ml-4 mr-4 text-lg" |
||||
|
@click="expandOffersToUserAndMarkRead()" |
||||
|
/> |
||||
|
</div> |
||||
|
<router-link to="/recent-offers-to-user" class="text-blue-500"> |
||||
|
See all |
||||
|
</router-link> |
||||
|
</div> |
||||
|
|
||||
|
<div v-if="showOffersDetails" class="ml-4 mt-4"> |
||||
|
<ul class="list-disc ml-4"> |
||||
|
<li |
||||
|
v-for="offer in newOffersToUser" |
||||
|
:key="offer.jwtId" |
||||
|
class="mt-4 relative group" |
||||
|
> |
||||
|
<span>{{ |
||||
|
didInfo(offer.offeredByDid, activeDid, allMyDids, allContacts) |
||||
|
}}</span> |
||||
|
offered |
||||
|
<span v-if="offer.objectDescription">{{ |
||||
|
offer.objectDescription |
||||
|
}}</span |
||||
|
>{{ offer.objectDescription && offer.amount ? ", and " : "" }} |
||||
|
<span v-if="offer.amount">{{ |
||||
|
displayAmount(offer.unit, offer.amount) |
||||
|
}}</span> |
||||
|
<router-link |
||||
|
:to="{ path: '/claim/' + encodeURIComponent(offer.jwtId) }" |
||||
|
class="text-blue-500" |
||||
|
> |
||||
|
<fa icon="file-lines" class="pl-2 text-blue-500 cursor-pointer" /> |
||||
|
</router-link> |
||||
|
<!-- New line that appears on hover --> |
||||
|
<div |
||||
|
@click="markOffersAsReadStartingWith(offer.jwtId)" |
||||
|
class="absolute left-0 w-full text-left text-gray-500 text-sm hidden group-hover:flex cursor-pointer items-center" |
||||
|
> |
||||
|
<span class="inline-block w-8 h-px bg-gray-500 mr-2" /> |
||||
|
Click to keep all above as new offers |
||||
|
</div> |
||||
|
</li> |
||||
|
</ul> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Display a single row with the name of "New Offers To Your Projects" with a count. --> |
||||
|
<div |
||||
|
class="mt-4 flex justify-between" |
||||
|
data-testId="showOffersToUserProjects" |
||||
|
> |
||||
|
<div> |
||||
|
<span class="text-lg font-medium" |
||||
|
>{{ newOffersToUserProjects.length |
||||
|
}}{{ newOffersToUserProjectsHitLimit ? "+" : "" }}</span |
||||
|
> |
||||
|
<span class="text-lg font-medium ml-4" |
||||
|
>New Offer{{ newOffersToUserProjects.length === 1 ? "" : "s" }} To |
||||
|
Your Projects</span |
||||
|
> |
||||
|
<fa |
||||
|
v-if="newOffersToUserProjects.length > 0" |
||||
|
:icon=" |
||||
|
showOffersToUserProjectsDetails ? 'chevron-down' : 'chevron-right' |
||||
|
" |
||||
|
class="cursor-pointer ml-4 mr-4 text-lg" |
||||
|
@click="expandOffersToUserProjectsAndMarkRead()" |
||||
|
/> |
||||
|
</div> |
||||
|
<router-link to="/recent-offers-to-user-projects" class="text-blue-500"> |
||||
|
See all |
||||
|
</router-link> |
||||
|
</div> |
||||
|
|
||||
|
<div v-if="showOffersToUserProjectsDetails" class="ml-4 mt-4"> |
||||
|
<ul class="list-disc ml-4"> |
||||
|
<li |
||||
|
v-for="offer in newOffersToUserProjects" |
||||
|
:key="offer.jwtId" |
||||
|
class="mt-4 relative group" |
||||
|
> |
||||
|
<span>{{ |
||||
|
didInfo(offer.offeredByDid, activeDid, allMyDids, allContacts) |
||||
|
}}</span> |
||||
|
offered |
||||
|
<span v-if="offer.objectDescription">{{ |
||||
|
offer.objectDescription |
||||
|
}}</span |
||||
|
>{{ offer.objectDescription && offer.amount ? ", and " : "" }} |
||||
|
<span v-if="offer.amount">{{ |
||||
|
displayAmount(offer.unit, offer.amount) |
||||
|
}}</span> |
||||
|
to |
||||
|
<span>{{ offer.planName }}</span> |
||||
|
<router-link |
||||
|
:to="{ path: '/claim/' + encodeURIComponent(offer.jwtId) }" |
||||
|
class="text-blue-500" |
||||
|
> |
||||
|
<fa icon="file-lines" class="pl-2 text-blue-500 cursor-pointer" /> |
||||
|
</router-link> |
||||
|
<!-- New line that appears on hover --> |
||||
|
<div |
||||
|
@click="markOffersToUserProjectsAsReadStartingWith(offer.jwtId)" |
||||
|
class="absolute left-0 w-full text-left text-gray-500 text-sm hidden group-hover:flex cursor-pointer items-center" |
||||
|
> |
||||
|
<span class="inline-block w-8 h-px bg-gray-500 mr-2" /> |
||||
|
Click to keep all above as new offers |
||||
|
</div> |
||||
|
</li> |
||||
|
</ul> |
||||
|
</div> |
||||
|
</section> |
||||
|
</template> |
||||
|
|
||||
|
<script lang="ts"> |
||||
|
import { Component, Vue } from "vue-facing-decorator"; |
||||
|
|
||||
|
import GiftedDialog from "@/components/GiftedDialog.vue"; |
||||
|
import QuickNav from "@/components/QuickNav.vue"; |
||||
|
import EntityIcon from "@/components/EntityIcon.vue"; |
||||
|
import { NotificationIface } from "@/constants/app"; |
||||
|
import { |
||||
|
accountsDB, |
||||
|
db, |
||||
|
retrieveSettingsForActiveAccount, |
||||
|
updateAccountSettings, |
||||
|
} from "@/db/index"; |
||||
|
import { Contact } from "@/db/tables/contacts"; |
||||
|
import { |
||||
|
didInfo, |
||||
|
displayAmount, |
||||
|
getNewOffersToUser, |
||||
|
getNewOffersToUserProjects, |
||||
|
OfferSummaryRecord, |
||||
|
OfferToPlanSummaryRecord, |
||||
|
} from "@/libs/endorserServer"; |
||||
|
|
||||
|
@Component({ |
||||
|
components: { GiftedDialog, QuickNav, EntityIcon }, |
||||
|
}) |
||||
|
export default class NewActivityView extends Vue { |
||||
|
$notify!: (notification: NotificationIface, timeout?: number) => void; |
||||
|
|
||||
|
activeDid = ""; |
||||
|
allContacts: Array<Contact> = []; |
||||
|
allMyDids: string[] = []; |
||||
|
apiServer = ""; |
||||
|
lastAckedOfferToUserJwtId = ""; |
||||
|
lastAckedOfferToUserProjectsJwtId = ""; |
||||
|
newOffersToUser: Array<OfferSummaryRecord> = []; |
||||
|
newOffersToUserHitLimit = false; |
||||
|
newOffersToUserProjects: Array<OfferToPlanSummaryRecord> = []; |
||||
|
newOffersToUserProjectsHitLimit = false; |
||||
|
|
||||
|
showOffersDetails = false; |
||||
|
showOffersToUserProjectsDetails = false; |
||||
|
didInfo = didInfo; |
||||
|
displayAmount = displayAmount; |
||||
|
|
||||
|
async created() { |
||||
|
try { |
||||
|
const settings = await retrieveSettingsForActiveAccount(); |
||||
|
this.apiServer = settings.apiServer || ""; |
||||
|
this.activeDid = settings.activeDid || ""; |
||||
|
this.lastAckedOfferToUserJwtId = settings.lastAckedOfferToUserJwtId || ""; |
||||
|
this.lastAckedOfferToUserProjectsJwtId = |
||||
|
settings.lastAckedOfferToUserProjectsJwtId || ""; |
||||
|
|
||||
|
this.allContacts = await db.contacts.toArray(); |
||||
|
|
||||
|
await accountsDB.open(); |
||||
|
const allAccounts = await accountsDB.accounts.toArray(); |
||||
|
if (allAccounts.length > 0) { |
||||
|
this.allMyDids = allAccounts.map((acc) => acc.did); |
||||
|
} |
||||
|
|
||||
|
const offersToUserData = await getNewOffersToUser( |
||||
|
this.axios, |
||||
|
this.apiServer, |
||||
|
this.activeDid, |
||||
|
this.lastAckedOfferToUserJwtId, |
||||
|
); |
||||
|
this.newOffersToUser = offersToUserData.data; |
||||
|
this.newOffersToUserHitLimit = offersToUserData.hitLimit; |
||||
|
|
||||
|
const offersToUserProjectsData = await getNewOffersToUserProjects( |
||||
|
this.axios, |
||||
|
this.apiServer, |
||||
|
this.activeDid, |
||||
|
this.lastAckedOfferToUserProjectsJwtId, |
||||
|
); |
||||
|
this.newOffersToUserProjects = offersToUserProjectsData.data; |
||||
|
this.newOffersToUserProjectsHitLimit = offersToUserProjectsData.hitLimit; |
||||
|
|
||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any |
||||
|
} catch (err: any) { |
||||
|
console.error("Error retrieving settings & contacts:", err); |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Error", |
||||
|
text: err.message || "There was an error retrieving your activity.", |
||||
|
}, |
||||
|
5000, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async expandOffersToUserAndMarkRead() { |
||||
|
this.showOffersDetails = !this.showOffersDetails; |
||||
|
if (this.showOffersDetails) { |
||||
|
await updateAccountSettings(this.activeDid, { |
||||
|
lastAckedOfferToUserJwtId: this.newOffersToUser[0].jwtId, |
||||
|
}); |
||||
|
// note that we don't update this.lastAckedOfferToUserJwtId in case they |
||||
|
// later choose the last one to keep the offers as new |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "info", |
||||
|
title: "Marked as Read", |
||||
|
text: "The offers are marked as viewed. Click in the list to keep them as new.", |
||||
|
}, |
||||
|
5000, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async markOffersAsReadStartingWith(jwtId: string) { |
||||
|
const index = this.newOffersToUser.findIndex( |
||||
|
(offer) => offer.jwtId === jwtId, |
||||
|
); |
||||
|
if (index !== -1 && index < this.newOffersToUser.length - 1) { |
||||
|
// Set to the next offer's jwtId |
||||
|
await updateAccountSettings(this.activeDid, { |
||||
|
lastAckedOfferToUserJwtId: this.newOffersToUser[index + 1].jwtId, |
||||
|
}); |
||||
|
} else { |
||||
|
// it's the last entry (or not found), so just keep it the same |
||||
|
await updateAccountSettings(this.activeDid, { |
||||
|
lastAckedOfferToUserJwtId: this.lastAckedOfferToUserJwtId, |
||||
|
}); |
||||
|
} |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "info", |
||||
|
title: "Marked as Unread", |
||||
|
text: "All offers above that one are marked as unread.", |
||||
|
}, |
||||
|
3000, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
async expandOffersToUserProjectsAndMarkRead() { |
||||
|
this.showOffersToUserProjectsDetails = |
||||
|
!this.showOffersToUserProjectsDetails; |
||||
|
if (this.showOffersToUserProjectsDetails) { |
||||
|
await updateAccountSettings(this.activeDid, { |
||||
|
lastAckedOfferToUserProjectsJwtId: |
||||
|
this.newOffersToUserProjects[0].jwtId, |
||||
|
}); |
||||
|
// note that we don't update this.lastAckedOfferToUserProjectsJwtId in case |
||||
|
// they later choose the last one to keep the offers as new |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "info", |
||||
|
title: "Marked as Read", |
||||
|
text: "The offers are marked as viewed. Click in the list to keep them as new.", |
||||
|
}, |
||||
|
5000, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async markOffersToUserProjectsAsReadStartingWith(jwtId: string) { |
||||
|
const index = this.newOffersToUserProjects.findIndex( |
||||
|
(offer) => offer.jwtId === jwtId, |
||||
|
); |
||||
|
if (index !== -1 && index < this.newOffersToUserProjects.length - 1) { |
||||
|
// Set to the next offer's jwtId |
||||
|
await updateAccountSettings(this.activeDid, { |
||||
|
lastAckedOfferToUserProjectsJwtId: |
||||
|
this.newOffersToUserProjects[index + 1].jwtId, |
||||
|
}); |
||||
|
} else { |
||||
|
// it's the last entry (or not found), so just keep it the same |
||||
|
await updateAccountSettings(this.activeDid, { |
||||
|
lastAckedOfferToUserProjectsJwtId: |
||||
|
this.lastAckedOfferToUserProjectsJwtId, |
||||
|
}); |
||||
|
} |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "info", |
||||
|
title: "Marked as Unread", |
||||
|
text: "All offers above that one are marked as unread.", |
||||
|
}, |
||||
|
3000, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
</script> |
@ -1,67 +0,0 @@ |
|||||
<template> |
|
||||
<!-- CONTENT --> |
|
||||
<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"> |
|
||||
<!-- Cancel --> |
|
||||
<router-link |
|
||||
:to="{ name: 'project' }" |
|
||||
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1" |
|
||||
><fa icon="chevron-left" class="fa-fw"></fa> |
|
||||
</router-link> |
|
||||
|
|
||||
Make Commitment |
|
||||
</h1> |
|
||||
</div> |
|
||||
|
|
||||
<!-- Project Details --> |
|
||||
|
|
||||
<select class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"> |
|
||||
<option disabled>Choose a commitment type…</option> |
|
||||
<option selected>Time</option> |
|
||||
<option>Cryptocurrency</option> |
|
||||
<option>Money</option> |
|
||||
</select> |
|
||||
|
|
||||
<!-- Time amount --> |
|
||||
<div class="mb-4 flex items-stretch"> |
|
||||
<input |
|
||||
type="number" |
|
||||
placeholder="0.0" |
|
||||
class="block w-full rounded-l border border-slate-400 px-3 py-2" |
|
||||
/> |
|
||||
<span |
|
||||
class="px-4 py-2 rounded-r bg-slate-200 border border-l-0 border-slate-400" |
|
||||
>hours</span |
|
||||
> |
|
||||
</div> |
|
||||
|
|
||||
<!-- Crypto amount --> |
|
||||
|
|
||||
<!-- Money amount --> |
|
||||
|
|
||||
<div class="mt-8"> |
|
||||
<input |
|
||||
type="submit" |
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2" |
|
||||
value="Commit" |
|
||||
/> |
|
||||
<button |
|
||||
type="button" |
|
||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md" |
|
||||
> |
|
||||
Maybe Later |
|
||||
</button> |
|
||||
</div> |
|
||||
</section> |
|
||||
</template> |
|
||||
|
|
||||
<script lang="ts"> |
|
||||
import { Component, Vue } from "vue-facing-decorator"; |
|
||||
|
|
||||
@Component({ |
|
||||
components: {}, |
|
||||
}) |
|
||||
export default class NewEditCommitmentView extends Vue {} |
|
||||
</script> |
|