Compare commits
28 Commits
capacitor-
...
0.5.8
| Author | SHA1 | Date | |
|---|---|---|---|
| 3118f71320 | |||
| d12f23aa81 | |||
| e9a8a3c1e7 | |||
| 1e0efe6011 | |||
| 16557f1e4b | |||
| c4a54967bc | |||
| 20ade415dc | |||
| 6689520270 | |||
| 3fd6c2b80d | |||
| a5c5c2b9dd | |||
| cf33a39fbc | |||
| 8629cefa13 | |||
| 5e851e442f | |||
| 4a43bc9c6c | |||
| 60de8cee62 | |||
|
|
bb2a4ab76e | ||
|
|
048dded278 | ||
| e240c2940a | |||
| 54dca9e745 | |||
| 9f0fed0a60 | |||
| 0d152adbf2 | |||
| cead308800 | |||
| 676a301331 | |||
| d6db81cc36 | |||
|
|
f2ddcd2541 | ||
| fb81f7b96e | |||
| a23416ead1 | |||
| 530c7c1a13 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -21,7 +21,6 @@ npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
android/app/src/main/res/
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
@@ -56,3 +55,4 @@ build_logs/
|
||||
icons
|
||||
|
||||
|
||||
android/app/src/main/res/
|
||||
38
BUILDING.md
38
BUILDING.md
@@ -41,6 +41,7 @@ Install dependencies:
|
||||
1. Run the production build:
|
||||
|
||||
```bash
|
||||
rm -rf dist
|
||||
npm run build:web
|
||||
```
|
||||
|
||||
@@ -64,6 +65,8 @@ Install dependencies:
|
||||
|
||||
* Commit everything (since the commit hash is used the app).
|
||||
|
||||
* Run a build to make sure package-lock version is updated, linting works, etc: `npm install && npm run build`
|
||||
|
||||
* Put the commit hash in the changelog (which will help you remember to bump the version later).
|
||||
|
||||
* Tag with the new version, [online](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/releases) or `git tag 0.3.55 && git push origin 0.3.55`.
|
||||
@@ -71,7 +74,7 @@ Install dependencies:
|
||||
* For test, build the app (because test server is not yet set up to build):
|
||||
|
||||
```bash
|
||||
TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_APP_SERVER=https://test.timesafari.app VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app VITE_DEFAULT_PARTNER_API_SERVER=https://test-partner-api.endorser.ch VITE_DEFAULT_PUSH_SERVER=https://test.timesafari.app VITE_PASSKEYS_ENABLED=true npm run build
|
||||
TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_APP_SERVER=https://test.timesafari.app VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app VITE_DEFAULT_PARTNER_API_SERVER=https://test-partner-api.endorser.ch VITE_DEFAULT_PUSH_SERVER=https://test.timesafari.app VITE_PASSKEYS_ENABLED=true npm run build:web
|
||||
```
|
||||
|
||||
... and transfer to the test server:
|
||||
@@ -321,11 +324,11 @@ Prerequisites: macOS with Xcode installed
|
||||
|
||||
#### Each Release
|
||||
|
||||
0. First time (or if XCode dependencies change):
|
||||
0. First time (or if dependencies change):
|
||||
|
||||
- `pkgx +rubygems.org sh`
|
||||
|
||||
- ... and you may have to fix these, especially with pkgx
|
||||
- ... and you may have to fix these, especially with pkgx:
|
||||
|
||||
```bash
|
||||
gem_path=$(which gem)
|
||||
@@ -334,23 +337,12 @@ Prerequisites: macOS with Xcode installed
|
||||
export GEM_PATH=$shortened_path
|
||||
```
|
||||
|
||||
```bash
|
||||
cd ios/App
|
||||
pod install
|
||||
```
|
||||
|
||||
1. Build the web assets:
|
||||
1. Build the web assets & update ios:
|
||||
|
||||
```bash
|
||||
rm -rf dist
|
||||
npm run build:web
|
||||
npm run build:capacitor
|
||||
```
|
||||
|
||||
|
||||
2. Update iOS project with latest build:
|
||||
|
||||
```bash
|
||||
npx cap sync ios
|
||||
```
|
||||
|
||||
@@ -367,15 +359,14 @@ Prerequisites: macOS with Xcode installed
|
||||
npx capacitor-assets generate --ios
|
||||
```
|
||||
|
||||
4. Bump the version to match Android:
|
||||
4. Bump the version to match Android & package.json:
|
||||
|
||||
```
|
||||
cd ios/App
|
||||
xcrun agvtool new-version 25
|
||||
xcrun agvtool new-version 34
|
||||
# Unfortunately this edits Info.plist directly.
|
||||
#xcrun agvtool new-marketing-version 0.4.5
|
||||
cat App.xcodeproj/project.pbxproj | sed "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 0.5.1;/g" > temp
|
||||
mv temp App.xcodeproj/project.pbxproj
|
||||
cat App.xcodeproj/project.pbxproj | sed "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 0.5.8;/g" > temp && mv temp App.xcodeproj/project.pbxproj
|
||||
cd -
|
||||
```
|
||||
|
||||
@@ -427,7 +418,7 @@ Prerequisites: Android Studio with Java SDK installed
|
||||
npx capacitor-assets generate --android
|
||||
```
|
||||
|
||||
4. Bump version to match iOS: android/app/build.gradle
|
||||
4. Bump version to match iOS & package.json: android/app/build.gradle
|
||||
|
||||
5. Open the project in Android Studio:
|
||||
|
||||
@@ -444,7 +435,6 @@ Prerequisites: Android Studio with Java SDK installed
|
||||
./gradlew clean
|
||||
./gradlew build -Dlint.baselines.continue=true
|
||||
cd -
|
||||
npx cap run android
|
||||
```
|
||||
|
||||
... or, to create the `aab` file, `bundle` instead of `build`:
|
||||
@@ -478,7 +468,7 @@ At play.google.com/console:
|
||||
- Note that if you add testers, you have to go to "Publishing Overview" and send those changes or your (closed) testers won't see it.
|
||||
|
||||
|
||||
## First-time Android Configuration for deep links
|
||||
## Android Configuration for deep links
|
||||
|
||||
You must add the following intent filter to the `android/app/src/main/AndroidManifest.xml` file:
|
||||
|
||||
@@ -489,4 +479,6 @@ You must add the following intent filter to the `android/app/src/main/AndroidMan
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="timesafari" />
|
||||
</intent-filter>
|
||||
```
|
||||
```
|
||||
|
||||
... though when we tried that most recently it failed to 'build' the APK with: http(s) scheme and host attribute are missing, but are required for Android App Links [AppLinkUrlError]
|
||||
|
||||
@@ -6,6 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
|
||||
## [0.5.8]
|
||||
### Added
|
||||
- /deep-link/ path for URLs that are shared with people
|
||||
### Changed
|
||||
- External links now go to /deep-link/...
|
||||
- Feed visuals now have arrow imagery from giver to receiver
|
||||
|
||||
|
||||
## [0.4.7]
|
||||
### Fixed
|
||||
|
||||
@@ -1,533 +0,0 @@
|
||||
# TimeSafari Contact Backup System
|
||||
|
||||
## Overview
|
||||
|
||||
The TimeSafari application implements a comprehensive contact backup and listing system that works across multiple platforms (Web, iOS, Android, Desktop). This document breaks down how contacts are saved, exported, and listed as backups.
|
||||
|
||||
## Architecture Components
|
||||
|
||||
### 1. Database Layer
|
||||
|
||||
#### Contact Data Structure
|
||||
```typescript
|
||||
interface Contact {
|
||||
did: string; // Decentralized Identifier (primary key)
|
||||
contactMethods?: ContactMethod[]; // Array of contact methods (EMAIL, SMS, etc.)
|
||||
name?: string; // Display name
|
||||
nextPubKeyHashB64?: string; // Base64 hash of next public key
|
||||
notes?: string; // User notes
|
||||
profileImageUrl?: string; // Profile image URL
|
||||
publicKeyBase64?: string; // Base64 encoded public key
|
||||
seesMe?: boolean; // Visibility setting
|
||||
registered?: boolean; // Registration status
|
||||
}
|
||||
|
||||
interface ContactMethod {
|
||||
label: string; // Display label
|
||||
type: string; // Type (EMAIL, SMS, WHATSAPP, etc.)
|
||||
value: string; // Contact value
|
||||
}
|
||||
```
|
||||
|
||||
#### Database Schema
|
||||
```sql
|
||||
CREATE TABLE contacts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
did TEXT NOT NULL, -- Decentralized Identifier
|
||||
name TEXT, -- Display name
|
||||
contactMethods TEXT, -- JSON string of contact methods
|
||||
nextPubKeyHashB64 TEXT, -- Next public key hash
|
||||
notes TEXT, -- User notes
|
||||
profileImageUrl TEXT, -- Profile image URL
|
||||
publicKeyBase64 TEXT, -- Public key
|
||||
seesMe BOOLEAN, -- Visibility flag
|
||||
registered BOOLEAN -- Registration status
|
||||
);
|
||||
|
||||
CREATE INDEX idx_contacts_did ON contacts(did);
|
||||
CREATE INDEX idx_contacts_name ON contacts(name);
|
||||
```
|
||||
|
||||
### 2. Contact Saving Operations
|
||||
|
||||
#### A. Adding New Contacts
|
||||
|
||||
**1. QR Code Scanning (`ContactQRScanFullView.vue`)**
|
||||
```typescript
|
||||
async addNewContact(contact: Contact) {
|
||||
// Check for existing contact
|
||||
const existingContacts = await platformService.dbQuery(
|
||||
"SELECT * FROM contacts WHERE did = ?", [contact.did]
|
||||
);
|
||||
|
||||
if (existingContact) {
|
||||
// Handle duplicate
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert contactMethods to JSON string for storage
|
||||
contact.contactMethods = JSON.stringify(
|
||||
parseJsonField(contact.contactMethods, [])
|
||||
);
|
||||
|
||||
// Insert into database
|
||||
const { sql, params } = databaseUtil.generateInsertStatement(
|
||||
contact as unknown as Record<string, unknown>, "contacts"
|
||||
);
|
||||
await platformService.dbExec(sql, params);
|
||||
}
|
||||
```
|
||||
|
||||
**2. Manual Contact Addition (`ContactsView.vue`)**
|
||||
```typescript
|
||||
private async addContact(newContact: Contact) {
|
||||
// Validate DID format
|
||||
if (!isDid(newContact.did)) {
|
||||
throw new Error("Invalid DID format");
|
||||
}
|
||||
|
||||
// Generate and execute INSERT statement
|
||||
const { sql, params } = databaseUtil.generateInsertStatement(
|
||||
newContact as unknown as Record<string, unknown>, "contacts"
|
||||
);
|
||||
await platformService.dbExec(sql, params);
|
||||
}
|
||||
```
|
||||
|
||||
**3. Contact Import (`ContactImportView.vue`)**
|
||||
```typescript
|
||||
async importContacts() {
|
||||
for (const contact of selectedContacts) {
|
||||
const contactToStore = contactToDbRecord(contact);
|
||||
|
||||
if (existingContact) {
|
||||
// Update existing contact
|
||||
const { sql, params } = databaseUtil.generateUpdateStatement(
|
||||
contactToStore, "contacts", "did = ?", [contact.did]
|
||||
);
|
||||
await platformService.dbExec(sql, params);
|
||||
} else {
|
||||
// Add new contact
|
||||
const { sql, params } = databaseUtil.generateInsertStatement(
|
||||
contactToStore, "contacts"
|
||||
);
|
||||
await platformService.dbExec(sql, params);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### B. Updating Existing Contacts
|
||||
|
||||
**Contact Editing (`ContactEditView.vue`)**
|
||||
```typescript
|
||||
async saveEdit() {
|
||||
// Normalize contact methods
|
||||
const contactMethods = this.contactMethods.map(method => ({
|
||||
...method,
|
||||
type: method.type.toUpperCase()
|
||||
}));
|
||||
|
||||
// Update database
|
||||
const contactMethodsString = JSON.stringify(contactMethods);
|
||||
await platformService.dbExec(
|
||||
"UPDATE contacts SET name = ?, notes = ?, contactMethods = ? WHERE did = ?",
|
||||
[this.contactName, this.contactNotes, contactMethodsString, this.contact?.did]
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Contact Export/Backup System
|
||||
|
||||
#### A. Export Process (`DataExportSection.vue`)
|
||||
|
||||
#### 1. Data Retrieval
|
||||
|
||||
```typescript
|
||||
async exportDatabase() {
|
||||
// Query all contacts from database
|
||||
const result = await platformService.dbQuery("SELECT * FROM contacts");
|
||||
const allContacts = databaseUtil.mapQueryResultToValues(result) as Contact[];
|
||||
|
||||
// Convert to export format
|
||||
const exportData = contactsToExportJson(allContacts);
|
||||
const jsonStr = JSON.stringify(exportData, null, 2);
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Export Format Conversion (`libs/util.ts`)
|
||||
|
||||
```typescript
|
||||
export const contactsToExportJson = (contacts: Contact[]): DatabaseExport => {
|
||||
const rows = contacts.map((contact) => ({
|
||||
did: contact.did,
|
||||
name: contact.name || null,
|
||||
contactMethods: contact.contactMethods
|
||||
? JSON.stringify(parseJsonField(contact.contactMethods, []))
|
||||
: null,
|
||||
nextPubKeyHashB64: contact.nextPubKeyHashB64 || null,
|
||||
notes: contact.notes || null,
|
||||
profileImageUrl: contact.profileImageUrl || null,
|
||||
publicKeyBase64: contact.publicKeyBase64 || null,
|
||||
seesMe: contact.seesMe || false,
|
||||
registered: contact.registered || false,
|
||||
}));
|
||||
|
||||
return {
|
||||
data: {
|
||||
data: [{ tableName: "contacts", rows }]
|
||||
}
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
#### 3. File Generation
|
||||
|
||||
```typescript
|
||||
// Create timestamped filename
|
||||
const timestamp = getTimestampForFilename();
|
||||
const fileName = `${AppString.APP_NAME_NO_SPACES}-backup-contacts-${timestamp}.json`;
|
||||
|
||||
// Create blob and save
|
||||
const blob = new Blob([jsonStr], { type: "application/json" });
|
||||
```
|
||||
|
||||
#### B. Platform-Specific File Saving
|
||||
|
||||
##### 1. Web Platform (`WebPlatformService.ts`)**
|
||||
|
||||
```typescript
|
||||
// Uses browser download API
|
||||
const downloadUrl = URL.createObjectURL(blob);
|
||||
const downloadAnchor = this.$refs.downloadLink as HTMLAnchorElement;
|
||||
downloadAnchor.href = downloadUrl;
|
||||
downloadAnchor.download = fileName;
|
||||
downloadAnchor.click();
|
||||
```
|
||||
|
||||
##### 2. Mobile Platforms (`CapacitorPlatformService.ts`)
|
||||
|
||||
```typescript
|
||||
async writeAndShareFile(fileName: string, content: string, options = {}) {
|
||||
let fileUri: string;
|
||||
|
||||
if (options.allowLocationSelection) {
|
||||
// User chooses location
|
||||
fileUri = await this.saveWithUserChoice(fileName, content, options.mimeType);
|
||||
} else if (options.saveToPrivateStorage) {
|
||||
// Save to app-private storage
|
||||
const result = await Filesystem.writeFile({
|
||||
path: fileName,
|
||||
data: content,
|
||||
directory: Directory.Data,
|
||||
encoding: Encoding.UTF8,
|
||||
recursive: true,
|
||||
});
|
||||
fileUri = result.uri;
|
||||
} else {
|
||||
// Save to user-accessible location (Downloads/Documents)
|
||||
fileUri = await this.saveToDownloads(fileName, content);
|
||||
}
|
||||
|
||||
// Share the file
|
||||
return await this.shareFile(fileUri, fileName);
|
||||
}
|
||||
```
|
||||
|
||||
##### 3. Desktop Platforms (`ElectronPlatformService.ts`, `PyWebViewPlatformService.ts`)
|
||||
|
||||
```typescript
|
||||
// Not implemented - returns empty results
|
||||
async listBackupFiles(): Promise<Array<{name: string, uri: string, size?: number, type: 'contacts' | 'seed' | 'other', path?: string}>> {
|
||||
return [];
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Backup File Listing System
|
||||
|
||||
#### A. File Discovery (`CapacitorPlatformService.ts`)
|
||||
|
||||
##### 1. Enhanced File Discovery
|
||||
|
||||
```typescript
|
||||
async listUserAccessibleFilesEnhanced(): Promise<Array<{name: string, uri: string, size?: number, path?: string}>> {
|
||||
const allFiles: Array<{name: string, uri: string, size?: number, path?: string}> = [];
|
||||
|
||||
if (this.getCapabilities().isIOS) {
|
||||
// iOS: Documents directory
|
||||
const result = await Filesystem.readdir({
|
||||
path: ".",
|
||||
directory: Directory.Documents,
|
||||
});
|
||||
const files = result.files.map((file) => ({
|
||||
name: typeof file === "string" ? file : file.name,
|
||||
uri: `file://${file.uri || file}`,
|
||||
size: typeof file === "string" ? undefined : file.size,
|
||||
path: "Documents"
|
||||
}));
|
||||
allFiles.push(...files);
|
||||
} else {
|
||||
// Android: Multiple locations
|
||||
const commonPaths = ["Download", "Documents", "Backups", "TimeSafari", "Data"];
|
||||
|
||||
for (const path of commonPaths) {
|
||||
try {
|
||||
const result = await Filesystem.readdir({
|
||||
path: path,
|
||||
directory: Directory.ExternalStorage,
|
||||
});
|
||||
|
||||
// Filter for TimeSafari-related files
|
||||
const relevantFiles = result.files
|
||||
.filter(file => {
|
||||
const fileName = typeof file === "string" ? file : file.name;
|
||||
const name = fileName.toLowerCase();
|
||||
return name.includes('timesafari') ||
|
||||
name.includes('backup') ||
|
||||
name.includes('contacts') ||
|
||||
name.endsWith('.json');
|
||||
})
|
||||
.map((file) => ({
|
||||
name: typeof file === "string" ? file : file.name,
|
||||
uri: `file://${file.uri || file}`,
|
||||
size: typeof file === "string" ? undefined : file.size,
|
||||
path: path
|
||||
}));
|
||||
|
||||
allFiles.push(...relevantFiles);
|
||||
} catch (error) {
|
||||
// Silently skip inaccessible directories
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return allFiles;
|
||||
}
|
||||
```
|
||||
|
||||
**2. Backup File Filtering**
|
||||
```typescript
|
||||
async listBackupFiles(): Promise<Array<{name: string, uri: string, size?: number, type: 'contacts' | 'seed' | 'other', path?: string}>> {
|
||||
const allFiles = await this.listUserAccessibleFilesEnhanced();
|
||||
|
||||
const backupFiles = allFiles
|
||||
.filter(file => {
|
||||
const name = file.name.toLowerCase();
|
||||
|
||||
// Exclude directory-access notification files
|
||||
if (name.startsWith('timesafari-directory-access-') && name.endsWith('.txt')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check backup criteria
|
||||
const isJson = name.endsWith('.json');
|
||||
const hasTimeSafari = name.includes('timesafari');
|
||||
const hasBackup = name.includes('backup');
|
||||
const hasContacts = name.includes('contacts');
|
||||
const hasSeed = name.includes('seed');
|
||||
const hasExport = name.includes('export');
|
||||
const hasData = name.includes('data');
|
||||
|
||||
return isJson || hasTimeSafari || hasBackup || hasContacts || hasSeed || hasExport || hasData;
|
||||
})
|
||||
.map(file => {
|
||||
const name = file.name.toLowerCase();
|
||||
let type: 'contacts' | 'seed' | 'other' = 'other';
|
||||
|
||||
// Categorize files
|
||||
if (name.includes('contacts') || (name.includes('timesafari') && name.includes('backup'))) {
|
||||
type = 'contacts';
|
||||
} else if (name.includes('seed') || name.includes('mnemonic') || name.includes('private')) {
|
||||
type = 'seed';
|
||||
} else if (name.endsWith('.json')) {
|
||||
type = 'other';
|
||||
}
|
||||
|
||||
return { ...file, type };
|
||||
});
|
||||
|
||||
return backupFiles;
|
||||
}
|
||||
```
|
||||
|
||||
#### B. UI Components (`BackupFilesList.vue`)
|
||||
|
||||
**1. File Display**
|
||||
```typescript
|
||||
@Component
|
||||
export default class BackupFilesList extends Vue {
|
||||
backupFiles: Array<{name: string, uri: string, size?: number, type: 'contacts' | 'seed' | 'other', path?: string}> = [];
|
||||
selectedType: 'all' | 'contacts' | 'seed' | 'other' = 'all';
|
||||
isLoading = false;
|
||||
|
||||
async refreshFiles() {
|
||||
this.isLoading = true;
|
||||
try {
|
||||
this.backupFiles = await this.platformService.listBackupFiles();
|
||||
|
||||
// Log file type distribution
|
||||
const typeCounts = {
|
||||
contacts: this.backupFiles.filter(f => f.type === 'contacts').length,
|
||||
seed: this.backupFiles.filter(f => f.type === 'seed').length,
|
||||
other: this.backupFiles.filter(f => f.type === 'other').length,
|
||||
total: this.backupFiles.length
|
||||
};
|
||||
} catch (error) {
|
||||
// Handle error
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**2. File Operations**
|
||||
```typescript
|
||||
async openFile(fileUri: string, fileName: string) {
|
||||
const result = await this.platformService.openFile(fileUri, fileName);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "Failed to open file");
|
||||
}
|
||||
}
|
||||
|
||||
async openBackupDirectory() {
|
||||
const result = await this.platformService.openBackupDirectory();
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "Failed to open backup directory");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Platform-Specific Storage Locations
|
||||
|
||||
#### A. iOS Platform
|
||||
- **Primary Location**: Documents folder (accessible via Files app)
|
||||
- **Persistence**: Survives app installations
|
||||
- **Access**: Through iOS Files app
|
||||
- **File Format**: JSON with timestamped filenames
|
||||
|
||||
#### B. Android Platform
|
||||
- **Primary Locations**:
|
||||
- `Download/TimeSafari/` (external storage)
|
||||
- `TimeSafari/` (external storage)
|
||||
- User-chosen locations via file picker
|
||||
- **Persistence**: Survives app installations
|
||||
- **Access**: Through file managers
|
||||
- **File Format**: JSON with timestamped filenames
|
||||
|
||||
#### C. Web Platform
|
||||
- **Primary Location**: Browser downloads folder
|
||||
- **Persistence**: Depends on browser settings
|
||||
- **Access**: Through browser download manager
|
||||
- **File Format**: JSON with timestamped filenames
|
||||
|
||||
#### D. Desktop Platforms (Electron/PyWebView)
|
||||
- **Status**: Not implemented
|
||||
- **Fallback**: Returns empty arrays for file operations
|
||||
|
||||
### 6. File Naming Convention
|
||||
|
||||
#### A. Contact Backup Files
|
||||
```
|
||||
TimeSafari-backup-contacts-YYYY-MM-DD-HH-MM-SS.json
|
||||
```
|
||||
|
||||
#### B. File Content Structure
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"data": [
|
||||
{
|
||||
"tableName": "contacts",
|
||||
"rows": [
|
||||
{
|
||||
"did": "did:ethr:0x...",
|
||||
"name": "Contact Name",
|
||||
"contactMethods": "[{\"type\":\"EMAIL\",\"value\":\"email@example.com\"}]",
|
||||
"notes": "User notes",
|
||||
"profileImageUrl": "https://...",
|
||||
"publicKeyBase64": "base64...",
|
||||
"seesMe": true,
|
||||
"registered": false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Error Handling and Logging
|
||||
|
||||
#### A. Comprehensive Logging
|
||||
```typescript
|
||||
logger.log("[CapacitorPlatformService] File write successful:", {
|
||||
uri: fileUri,
|
||||
saved,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.log("[BackupFilesList] Refreshed backup files:", {
|
||||
count: this.backupFiles.length,
|
||||
files: this.backupFiles.map(f => ({
|
||||
name: f.name,
|
||||
type: f.type,
|
||||
path: f.path,
|
||||
size: f.size
|
||||
})),
|
||||
platform: this.platformCapabilities.isIOS ? "iOS" : "Android",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
```
|
||||
|
||||
#### B. Error Recovery
|
||||
```typescript
|
||||
try {
|
||||
// File operations
|
||||
} catch (error) {
|
||||
logger.error("[CapacitorPlatformService] Failed to list backup files:", error);
|
||||
return [];
|
||||
}
|
||||
```
|
||||
|
||||
### 8. Security Considerations
|
||||
|
||||
#### A. Data Privacy
|
||||
- Contact data is stored locally on device
|
||||
- No cloud synchronization of contact data
|
||||
- User controls visibility settings per contact
|
||||
- Backup files contain only user-authorized data
|
||||
|
||||
#### B. File Access
|
||||
- Platform-specific permission handling
|
||||
- User choice for file locations
|
||||
- Secure storage options for sensitive data
|
||||
- Proper error handling for access failures
|
||||
|
||||
### 9. Performance Optimizations
|
||||
|
||||
#### A. Database Operations
|
||||
- Indexed queries on `did` and `name` fields
|
||||
- Batch operations for multiple contacts
|
||||
- Efficient JSON serialization/deserialization
|
||||
- Connection pooling and reuse
|
||||
|
||||
#### B. File Operations
|
||||
- Asynchronous file I/O
|
||||
- Efficient file discovery algorithms
|
||||
- Caching of file lists
|
||||
- Background refresh operations
|
||||
|
||||
## Summary
|
||||
|
||||
The TimeSafari contact backup system provides:
|
||||
|
||||
1. **Robust Data Storage**: SQLite-based contact storage with proper indexing
|
||||
2. **Cross-Platform Compatibility**: Works on web, iOS, Android, and desktop
|
||||
3. **Flexible Export Options**: Multiple file formats and storage locations
|
||||
4. **Intelligent File Discovery**: Finds backup files regardless of user-chosen locations
|
||||
5. **User-Friendly Interface**: Clear categorization and easy file management
|
||||
6. **Comprehensive Logging**: Detailed tracking for debugging and monitoring
|
||||
7. **Security-First Design**: Privacy-preserving with user-controlled data access
|
||||
|
||||
The system ensures that users can reliably backup and restore their contact data across different platforms while maintaining data integrity and user privacy.
|
||||
@@ -31,8 +31,8 @@ android {
|
||||
applicationId "app.timesafari.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 26
|
||||
versionName "0.5.1"
|
||||
versionCode 34
|
||||
versionName "0.5.8"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -100,6 +100,7 @@ try {
|
||||
- `src/interfaces/deepLinks.ts`: Type definitions and validation schemas
|
||||
- `src/services/deepLinks.ts`: Deep link processing service
|
||||
- `src/main.capacitor.ts`: Capacitor integration
|
||||
- `src/views/DeepLinkRedirectView.vue`: Page to handle links to both mobile and web
|
||||
|
||||
## Type Safety Examples
|
||||
|
||||
|
||||
@@ -403,7 +403,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 26;
|
||||
CURRENT_PROJECT_VERSION = 34;
|
||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
@@ -413,7 +413,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.5.1;
|
||||
MARKETING_VERSION = 0.5.8;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -430,7 +430,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 26;
|
||||
CURRENT_PROJECT_VERSION = 34;
|
||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
@@ -440,7 +440,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.5.1;
|
||||
MARKETING_VERSION = 0.5.8;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
|
||||
@@ -49,5 +49,16 @@
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true/>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>app.timesafari</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>timesafari</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
16
package-lock.json
generated
16
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "timesafari",
|
||||
"version": "0.5.1",
|
||||
"version": "0.5.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "timesafari",
|
||||
"version": "0.5.1",
|
||||
"version": "0.5.8",
|
||||
"dependencies": {
|
||||
"@capacitor-community/sqlite": "6.0.2",
|
||||
"@capacitor-mlkit/barcode-scanning": "^6.0.0",
|
||||
@@ -11707,9 +11707,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz",
|
||||
"integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==",
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz",
|
||||
"integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
@@ -14602,9 +14602,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/decode-named-character-reference": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.1.0.tgz",
|
||||
"integrity": "sha512-Wy+JTSbFThEOXQIR2L6mxJvEs+veIzpmqD7ynWxMXGpnk3smkHQOp6forLdHsKpAMW9iJpaBBIxz285t1n1C3w==",
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz",
|
||||
"integrity": "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "timesafari",
|
||||
"version": "0.5.1",
|
||||
"version": "0.5.8",
|
||||
"description": "Time Safari Application",
|
||||
"author": {
|
||||
"name": "Time Safari Team"
|
||||
|
||||
@@ -1,894 +0,0 @@
|
||||
/** * Backup Files List Component * * Displays a list of backup files saved by
|
||||
the app and provides options to: * - View backup files by type (contacts, seed,
|
||||
other) * - Open individual files in the device's file viewer * - Access the
|
||||
backup directory in the device's file explorer * * @component * @displayName
|
||||
BackupFilesList * @example * ```vue *
|
||||
<BackupFilesList />
|
||||
* ``` */
|
||||
|
||||
<template>
|
||||
<div class="backup-files-list">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold">Backup Files</h3>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-if="platformCapabilities.hasFileSystem"
|
||||
class="text-sm bg-blue-500 hover:bg-blue-600 text-white px-3 py-1 rounded"
|
||||
:disabled="isLoading"
|
||||
@click="refreshFiles()"
|
||||
>
|
||||
<font-awesome
|
||||
icon="refresh"
|
||||
class="fa-fw"
|
||||
:class="{ 'animate-spin': isLoading }"
|
||||
/>
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
v-if="platformCapabilities.hasFileSystem"
|
||||
class="text-sm bg-green-500 hover:bg-green-600 text-white px-3 py-1 rounded"
|
||||
:disabled="isLoading"
|
||||
@click="openBackupDirectory()"
|
||||
>
|
||||
<font-awesome icon="folder-open" class="fa-fw" />
|
||||
Open Directory
|
||||
</button>
|
||||
<button
|
||||
v-if="platformCapabilities.hasFileSystem && isDevelopment"
|
||||
class="text-sm bg-yellow-500 hover:bg-yellow-600 text-white px-3 py-1 rounded"
|
||||
:disabled="isLoading"
|
||||
title="Debug file discovery (development only)"
|
||||
@click="debugFileDiscovery()"
|
||||
>
|
||||
<font-awesome icon="bug" class="fa-fw" />
|
||||
Debug
|
||||
</button>
|
||||
<button
|
||||
:disabled="isLoading"
|
||||
class="px-3 py-1 bg-green-500 text-white rounded text-sm hover:bg-green-600 disabled:opacity-50"
|
||||
@click="createTestBackup"
|
||||
>
|
||||
Create Test Backup
|
||||
</button>
|
||||
<button
|
||||
:disabled="isLoading"
|
||||
class="px-3 py-1 bg-purple-500 text-white rounded text-sm hover:bg-purple-600 disabled:opacity-50"
|
||||
@click="testDirectoryContexts"
|
||||
>
|
||||
Test Contexts
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="text-center py-4">
|
||||
<font-awesome icon="spinner" class="animate-spin fa-2x" />
|
||||
<p class="mt-2">Loading backup files...</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="backupFiles.length === 0"
|
||||
class="text-center py-4 text-gray-500"
|
||||
>
|
||||
<font-awesome icon="folder-open" class="fa-2x mb-2" />
|
||||
<p>No backup files found</p>
|
||||
<p class="text-sm mt-1">
|
||||
Create backups using the export functions above
|
||||
</p>
|
||||
<div
|
||||
class="mt-3 p-3 bg-blue-50 border border-blue-200 rounded-lg text-left"
|
||||
>
|
||||
<p class="text-sm font-medium text-blue-800 mb-2">
|
||||
💡 How to create backup files:
|
||||
</p>
|
||||
<ul class="text-xs text-blue-700 space-y-1">
|
||||
<li>
|
||||
• Use the "Export Contacts" button above to create contact backups
|
||||
</li>
|
||||
<li>• Use the "Export Seed" button to backup your recovery phrase</li>
|
||||
<li>
|
||||
• Backup files are saved to persistent storage that survives app
|
||||
installations
|
||||
</li>
|
||||
<li
|
||||
v-if="platformCapabilities.isMobile && !platformCapabilities.isIOS"
|
||||
class="text-orange-700"
|
||||
>
|
||||
• On Android: Files are saved to Downloads/TimeSafari or app data
|
||||
directory
|
||||
</li>
|
||||
<li v-if="platformCapabilities.isIOS" class="text-orange-700">
|
||||
• On iOS: Files are saved to Documents folder (accessible via Files
|
||||
app)
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<!-- File Type Filter -->
|
||||
<div class="flex gap-2 mb-3">
|
||||
<button
|
||||
v-for="type in ['all', 'contacts', 'seed', 'other'] as const"
|
||||
:key="type"
|
||||
:class="[
|
||||
'text-sm px-3 py-1 rounded border',
|
||||
selectedType === type
|
||||
? 'bg-blue-500 text-white border-blue-500'
|
||||
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50',
|
||||
]"
|
||||
@click="selectedType = type"
|
||||
>
|
||||
{{
|
||||
type === "all"
|
||||
? "All"
|
||||
: type.charAt(0).toUpperCase() + type.slice(1)
|
||||
}}
|
||||
<span class="ml-1 text-xs"> ({{ getFileCountByType(type) }}) </span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Files List -->
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span v-for="(crumb, idx) in breadcrumbs" :key="idx">
|
||||
<span
|
||||
v-if="idx < breadcrumbs.length - 1"
|
||||
class="text-blue-600 cursor-pointer underline"
|
||||
@click="goToBreadcrumb(idx)"
|
||||
>
|
||||
{{ crumb }}
|
||||
</span>
|
||||
<span v-else class="font-bold">{{ crumb }}</span>
|
||||
<span v-if="idx < breadcrumbs.length - 1"> / </span>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="currentPath.length > 1" class="mb-2">
|
||||
<button class="text-xs text-blue-500 underline" @click="goUp">
|
||||
⬅ Up
|
||||
</button>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="inline-flex items-center">
|
||||
<input
|
||||
v-model="debugShowAll"
|
||||
type="checkbox"
|
||||
class="mr-2"
|
||||
@change="loadDirectory"
|
||||
/>
|
||||
<span class="text-xs">Debug: Show all entries as files</span>
|
||||
</label>
|
||||
<span v-if="debugShowAll" class="text-xs text-red-600 ml-2"
|
||||
>[Debug mode: forcibly treating all entries as files]</span
|
||||
>
|
||||
</div>
|
||||
<div class="space-y-2 max-h-64 overflow-y-auto">
|
||||
<div
|
||||
v-for="entry in folders"
|
||||
:key="'folder-' + entry.path"
|
||||
class="flex items-center justify-between p-3 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer"
|
||||
@click="openFolder(entry)"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<font-awesome icon="folder" class="fa-fw text-yellow-500" />
|
||||
<span class="font-medium">{{ entry.name }}</span>
|
||||
<span
|
||||
class="text-xs bg-gray-200 text-gray-700 px-2 py-0.5 rounded-full ml-2"
|
||||
>Folder</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="entry in files"
|
||||
:key="'file-' + entry.path"
|
||||
class="flex items-center justify-between p-3 bg-white border border-gray-200 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<font-awesome icon="file-alt" class="fa-fw text-gray-500" />
|
||||
<span class="font-medium truncate">{{ entry.name }}</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500 mt-1">
|
||||
<span v-if="entry.size">{{ formatFileSize(entry.size) }}</span>
|
||||
<span v-else>Size unknown</span>
|
||||
<span
|
||||
v-if="entry.path && !platformCapabilities.isIOS"
|
||||
class="ml-2 text-xs text-blue-600"
|
||||
>📁 {{ entry.path }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 ml-3">
|
||||
<button
|
||||
class="text-blue-500 hover:text-blue-700 p-1"
|
||||
title="Open file"
|
||||
@click="openFile(entry.uri, entry.name)"
|
||||
>
|
||||
<font-awesome icon="external-link-alt" class="fa-fw" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary -->
|
||||
<div class="text-sm text-gray-500 mt-3 pt-3 border-t">
|
||||
Showing {{ filteredFiles.length }} of {{ backupFiles.length }} backup
|
||||
files
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-gray-600 mb-2">
|
||||
<p>
|
||||
📁 Backup files are saved to persistent storage that survives app
|
||||
installations:
|
||||
</p>
|
||||
<ul class="list-disc list-inside ml-2 mt-1 text-xs">
|
||||
<li v-if="platformCapabilities.isIOS">
|
||||
iOS: Documents folder (accessible via Files app)
|
||||
</li>
|
||||
<li
|
||||
v-if="platformCapabilities.isMobile && !platformCapabilities.isIOS"
|
||||
>
|
||||
Android: Downloads/TimeSafari or external storage (accessible via
|
||||
file managers)
|
||||
</li>
|
||||
<li v-if="!platformCapabilities.isMobile">
|
||||
Desktop: User's download directory
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue, Watch } from "vue-facing-decorator";
|
||||
import { NotificationIface } from "../constants/app";
|
||||
import { logger } from "../utils/logger";
|
||||
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
|
||||
import {
|
||||
PlatformService,
|
||||
PlatformCapabilities,
|
||||
} from "../services/PlatformService";
|
||||
|
||||
/**
|
||||
* @vue-component
|
||||
* Backup Files List Component
|
||||
* Displays and manages backup files with platform-specific functionality
|
||||
*/
|
||||
@Component
|
||||
export default class BackupFilesList extends Vue {
|
||||
/**
|
||||
* Notification function injected by Vue
|
||||
* Used to show success/error messages to the user
|
||||
*/
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
|
||||
/**
|
||||
* Platform service instance for platform-specific operations
|
||||
*/
|
||||
private platformService: PlatformService =
|
||||
PlatformServiceFactory.getInstance();
|
||||
|
||||
/**
|
||||
* Platform capabilities for the current platform
|
||||
*/
|
||||
private get platformCapabilities(): PlatformCapabilities {
|
||||
return this.platformService.getCapabilities();
|
||||
}
|
||||
|
||||
/**
|
||||
* List of backup files found on the device
|
||||
*/
|
||||
backupFiles: Array<{
|
||||
name: string;
|
||||
uri: string;
|
||||
size?: number;
|
||||
type: "contacts" | "seed" | "other";
|
||||
path?: string;
|
||||
}> = [];
|
||||
|
||||
/**
|
||||
* Currently selected file type filter
|
||||
*/
|
||||
selectedType: "all" | "contacts" | "seed" | "other" = "all";
|
||||
|
||||
/**
|
||||
* Loading state for file operations
|
||||
*/
|
||||
isLoading = false;
|
||||
|
||||
/**
|
||||
* Interval for periodic refresh (5 minutes)
|
||||
*/
|
||||
private refreshInterval: number | null = null;
|
||||
|
||||
/**
|
||||
* Current path for folder navigation (array for breadcrumbs)
|
||||
*/
|
||||
currentPath: string[] = [];
|
||||
|
||||
/**
|
||||
* List of files/folders in the current directory
|
||||
*/
|
||||
directoryEntries: Array<{
|
||||
name: string;
|
||||
uri: string;
|
||||
size?: number;
|
||||
path: string;
|
||||
type: "file" | "folder";
|
||||
}> = [];
|
||||
|
||||
/**
|
||||
* Temporary debug mode to show all entries as files
|
||||
*/
|
||||
debugShowAll = false;
|
||||
|
||||
/**
|
||||
* Checks and requests storage permissions if needed.
|
||||
* Returns true if permission is granted, false otherwise.
|
||||
*/
|
||||
private async ensureStoragePermission(): Promise<boolean> {
|
||||
logger.log(
|
||||
"[BackupFilesList] ensureStoragePermission called. platformCapabilities:",
|
||||
this.platformCapabilities,
|
||||
);
|
||||
if (!this.platformCapabilities.hasFileSystem) return true;
|
||||
// Only relevant for native platforms (Android/iOS)
|
||||
const platformService = this.platformService as any;
|
||||
if (typeof platformService.checkStoragePermissions === "function") {
|
||||
try {
|
||||
await platformService.checkStoragePermissions();
|
||||
logger.log("[BackupFilesList] Storage permission granted.");
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error("[BackupFilesList] Storage permission denied:", error);
|
||||
|
||||
// Get specific guidance for the platform
|
||||
let guidance =
|
||||
"This app needs permission to access your files to list and restore backups.";
|
||||
if (
|
||||
typeof platformService.getStoragePermissionGuidance === "function"
|
||||
) {
|
||||
try {
|
||||
guidance = await platformService.getStoragePermissionGuidance();
|
||||
} catch (guidanceError) {
|
||||
logger.warn(
|
||||
"[BackupFilesList] Could not get permission guidance:",
|
||||
guidanceError,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "warning",
|
||||
title: "Storage Permission Required",
|
||||
text: guidance,
|
||||
},
|
||||
10000, // Show for 10 seconds to give user time to read
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle hook to load backup files when component is mounted
|
||||
*/
|
||||
async mounted() {
|
||||
logger.log(
|
||||
"[BackupFilesList] mounted hook called. platformCapabilities:",
|
||||
this.platformCapabilities,
|
||||
);
|
||||
if (this.platformCapabilities.hasFileSystem) {
|
||||
// Check/request permission before loading
|
||||
const hasPermission = await this.ensureStoragePermission();
|
||||
if (hasPermission) {
|
||||
// Set default root path
|
||||
if (this.platformCapabilities.isIOS) {
|
||||
this.currentPath = ["."];
|
||||
} else {
|
||||
this.currentPath = ["Download", "TimeSafari"];
|
||||
}
|
||||
await this.loadDirectory();
|
||||
this.refreshInterval = window.setInterval(
|
||||
() => {
|
||||
this.loadDirectory();
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle hook to clean up resources when component is unmounted
|
||||
*/
|
||||
beforeUnmount() {
|
||||
if (this.refreshInterval) {
|
||||
clearInterval(this.refreshInterval);
|
||||
this.refreshInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed property for filtered files based on selected type
|
||||
* Note: The 'All' tab count is sometimes too small. Logging for debugging.
|
||||
*/
|
||||
get filteredFiles() {
|
||||
if (this.selectedType === "all") {
|
||||
logger.log("[BackupFilesList] filteredFiles (All):", this.backupFiles);
|
||||
return this.backupFiles;
|
||||
}
|
||||
const filtered = this.backupFiles.filter(
|
||||
(file) => file.type === this.selectedType,
|
||||
);
|
||||
logger.log(
|
||||
`[BackupFilesList] filteredFiles (${this.selectedType}):`,
|
||||
filtered,
|
||||
);
|
||||
return filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed property to check if we're in development mode
|
||||
*/
|
||||
get isDevelopment(): boolean {
|
||||
return import.meta.env.DEV;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the current directory entries
|
||||
*/
|
||||
async loadDirectory() {
|
||||
if (!this.platformCapabilities.hasFileSystem) return;
|
||||
this.isLoading = true;
|
||||
try {
|
||||
const path =
|
||||
this.currentPath.join("/") ||
|
||||
(this.platformCapabilities.isIOS ? "." : "Download/TimeSafari");
|
||||
this.directoryEntries = await (
|
||||
this.platformService as PlatformService
|
||||
).listFilesInDirectory(path, this.debugShowAll);
|
||||
logger.log("[BackupFilesList] Loaded directory:", {
|
||||
path,
|
||||
entries: this.directoryEntries,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("[BackupFilesList] Failed to load directory:", error);
|
||||
this.directoryEntries = [];
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate into a folder
|
||||
*/
|
||||
async openFolder(entry: { name: string; path: string }) {
|
||||
this.currentPath.push(entry.name);
|
||||
await this.loadDirectory();
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a breadcrumb
|
||||
*/
|
||||
async goToBreadcrumb(index: number) {
|
||||
this.currentPath = this.currentPath.slice(0, index + 1);
|
||||
await this.loadDirectory();
|
||||
}
|
||||
|
||||
/**
|
||||
* Go up one directory
|
||||
*/
|
||||
async goUp() {
|
||||
if (this.currentPath.length > 1) {
|
||||
this.currentPath.pop();
|
||||
await this.loadDirectory();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed property for breadcrumbs
|
||||
*/
|
||||
get breadcrumbs() {
|
||||
return this.currentPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed property for showing files and folders
|
||||
*/
|
||||
get folders() {
|
||||
return this.directoryEntries.filter((e) => e.type === "folder");
|
||||
}
|
||||
get files() {
|
||||
return this.directoryEntries.filter((e) => e.type === "file");
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the list of backup files from the device
|
||||
*/
|
||||
async refreshFiles() {
|
||||
logger.log("[BackupFilesList] refreshFiles called.");
|
||||
if (!this.platformCapabilities.hasFileSystem) {
|
||||
return;
|
||||
}
|
||||
// Check/request permission before refreshing
|
||||
const hasPermission = await this.ensureStoragePermission();
|
||||
if (!hasPermission) {
|
||||
this.backupFiles = [];
|
||||
this.isLoading = false;
|
||||
return;
|
||||
}
|
||||
this.isLoading = true;
|
||||
try {
|
||||
this.backupFiles = await this.platformService.listBackupFiles();
|
||||
logger.log("[BackupFilesList] Refreshed backup files:", {
|
||||
count: this.backupFiles.length,
|
||||
files: this.backupFiles.map((f) => ({
|
||||
name: f.name,
|
||||
type: f.type,
|
||||
path: f.path,
|
||||
size: f.size,
|
||||
})),
|
||||
platform: this.platformCapabilities.isIOS ? "iOS" : "Android",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
// Debug: Log file type distribution
|
||||
const typeCounts = {
|
||||
contacts: this.backupFiles.filter((f) => f.type === "contacts").length,
|
||||
seed: this.backupFiles.filter((f) => f.type === "seed").length,
|
||||
other: this.backupFiles.filter((f) => f.type === "other").length,
|
||||
total: this.backupFiles.length,
|
||||
};
|
||||
logger.log("[BackupFilesList] File type distribution:", typeCounts);
|
||||
// Log the full backupFiles array for debugging the 'All' tab count
|
||||
logger.log(
|
||||
"[BackupFilesList] backupFiles array for All tab:",
|
||||
this.backupFiles,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("[BackupFilesList] Failed to refresh backup files:", error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Loading Files",
|
||||
text: "Failed to load backup files from your device.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a test backup file for debugging purposes
|
||||
*/
|
||||
async createTestBackup() {
|
||||
try {
|
||||
this.isLoading = true;
|
||||
logger.log("[BackupFilesList] Creating test backup file");
|
||||
|
||||
const result = await this.platformService.createTestBackupFile();
|
||||
|
||||
if (result.success) {
|
||||
logger.log("[BackupFilesList] Test backup file created successfully:", {
|
||||
fileName: result.fileName,
|
||||
uri: result.uri,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Test Backup Created",
|
||||
text: `Test backup file "${result.fileName}" created successfully. Refresh the list to see it.`,
|
||||
},
|
||||
5000,
|
||||
);
|
||||
|
||||
// Refresh the file list to show the new test file
|
||||
await this.refreshFiles();
|
||||
} else {
|
||||
throw new Error(result.error || "Failed to create test backup file");
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"[BackupFilesList] Failed to create test backup file:",
|
||||
error,
|
||||
);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Test Backup Failed",
|
||||
text: "Failed to create test backup file. Check the console for details.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests different directory contexts to debug file visibility issues
|
||||
*/
|
||||
async testDirectoryContexts() {
|
||||
try {
|
||||
this.isLoading = true;
|
||||
logger.log("[BackupFilesList] Testing directory contexts");
|
||||
|
||||
const debugOutput = await this.platformService.testDirectoryContexts();
|
||||
|
||||
logger.log(
|
||||
"[BackupFilesList] Directory context test results:",
|
||||
debugOutput,
|
||||
);
|
||||
|
||||
// Show the debug output in a notification or alert
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "info",
|
||||
title: "Directory Context Test",
|
||||
text: "Directory context test completed. Check the console for detailed results.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
|
||||
// Also log the full output to console for easy access
|
||||
logger.log("=== Directory Context Test Results ===");
|
||||
logger.log(debugOutput);
|
||||
logger.log("=== End Test Results ===");
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"[BackupFilesList] Failed to test directory contexts:",
|
||||
error,
|
||||
);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Context Test Failed",
|
||||
text: "Failed to test directory contexts. Check the console for details.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the file list after a backup is created
|
||||
* This method can be called from parent components
|
||||
*/
|
||||
async refreshAfterSave() {
|
||||
logger.log("[BackupFilesList] refreshAfterSave called");
|
||||
await this.refreshFiles();
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a specific file in the device's file viewer
|
||||
* @param fileUri - URI of the file to open
|
||||
* @param fileName - Name of the file for display
|
||||
*/
|
||||
async openFile(fileUri: string, fileName: string) {
|
||||
try {
|
||||
const result = await this.platformService.openFile(fileUri, fileName);
|
||||
|
||||
if (result.success) {
|
||||
logger.log("[BackupFilesList] File opened successfully:", {
|
||||
fileName,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} else {
|
||||
throw new Error(result.error || "Failed to open file");
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("[BackupFilesList] Failed to open file:", error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Opening File",
|
||||
text: `Failed to open ${fileName}. ${error instanceof Error ? error.message : String(error)}`,
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the backup directory in the device's file explorer
|
||||
*/
|
||||
async openBackupDirectory() {
|
||||
try {
|
||||
const result = await this.platformService.openBackupDirectory();
|
||||
|
||||
if (result.success) {
|
||||
logger.log("[BackupFilesList] Backup directory opened successfully:", {
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} else {
|
||||
throw new Error(result.error || "Failed to open backup directory");
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("[BackupFilesList] Failed to open backup directory:", error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Opening Directory",
|
||||
text: `Failed to open backup directory. ${error instanceof Error ? error.message : String(error)}`,
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the count of files for a specific type
|
||||
* Note: The 'All' tab count is sometimes too small. Logging for debugging.
|
||||
*/
|
||||
getFileCountByType(type: "all" | "contacts" | "seed" | "other"): number {
|
||||
let count;
|
||||
if (type === "all") {
|
||||
count = this.backupFiles.length;
|
||||
logger.log(
|
||||
"[BackupFilesList] getFileCountByType (All):",
|
||||
count,
|
||||
this.backupFiles,
|
||||
);
|
||||
return count;
|
||||
}
|
||||
count = this.backupFiles.filter((file) => file.type === type).length;
|
||||
logger.log(`[BackupFilesList] getFileCountByType (${type}):`, count);
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the appropriate icon for a file type
|
||||
* @param type - File type
|
||||
* @returns FontAwesome icon name
|
||||
*/
|
||||
getFileIcon(type: "contacts" | "seed" | "other"): string {
|
||||
switch (type) {
|
||||
case "contacts":
|
||||
return "address-book";
|
||||
case "seed":
|
||||
return "key";
|
||||
default:
|
||||
return "file-alt";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the appropriate icon color for a file type
|
||||
* @param type - File type
|
||||
* @returns CSS color class
|
||||
*/
|
||||
getFileIconColor(type: "contacts" | "seed" | "other"): string {
|
||||
switch (type) {
|
||||
case "contacts":
|
||||
return "text-blue-500";
|
||||
case "seed":
|
||||
return "text-orange-500";
|
||||
default:
|
||||
return "text-gray-500";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the appropriate badge color for a file type
|
||||
* @param type - File type
|
||||
* @returns CSS color class
|
||||
*/
|
||||
getTypeBadgeColor(type: "contacts" | "seed" | "other"): string {
|
||||
switch (type) {
|
||||
case "contacts":
|
||||
return "bg-blue-100 text-blue-800";
|
||||
case "seed":
|
||||
return "bg-orange-100 text-orange-800";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats file size in human-readable format
|
||||
* @param bytes - File size in bytes
|
||||
* @returns Formatted file size string
|
||||
*/
|
||||
formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug method to test file discovery
|
||||
* Can be called from browser console for troubleshooting
|
||||
*/
|
||||
public async debugFileDiscovery() {
|
||||
try {
|
||||
logger.log("[BackupFilesList] Starting debug file discovery...");
|
||||
|
||||
// Test the platform service's test methods
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
|
||||
// Test listing all user files
|
||||
const allFilesResult = await platformService.testListUserFiles();
|
||||
logger.log(
|
||||
"[BackupFilesList] All user files test result:",
|
||||
allFilesResult,
|
||||
);
|
||||
|
||||
// Test listing backup files specifically
|
||||
const backupFilesResult = await platformService.testBackupFiles();
|
||||
logger.log(
|
||||
"[BackupFilesList] Backup files test result:",
|
||||
backupFilesResult,
|
||||
);
|
||||
|
||||
// Note: testListAllBackupFiles method is not part of the PlatformService interface
|
||||
// It exists only in CapacitorPlatformService implementation
|
||||
// If needed, this could be added to the interface or called via type assertion
|
||||
|
||||
// Test debug listing all files without filtering (if available)
|
||||
if ("debugListAllFiles" in platformService) {
|
||||
const debugAllFiles = await (
|
||||
platformService as any
|
||||
).debugListAllFiles();
|
||||
logger.log("[BackupFilesList] Debug all files (no filtering):", {
|
||||
count: debugAllFiles.length,
|
||||
files: debugAllFiles.map((f: any) => ({
|
||||
name: f.name,
|
||||
path: f.path,
|
||||
size: f.size,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// Test comprehensive step-by-step debug (if available)
|
||||
if ("debugFileDiscoveryStepByStep" in platformService) {
|
||||
const stepByStepDebug = await (
|
||||
platformService as any
|
||||
).debugFileDiscoveryStepByStep();
|
||||
logger.log(
|
||||
"[BackupFilesList] Step-by-step debug output:",
|
||||
stepByStepDebug,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
allFiles: allFilesResult,
|
||||
backupFiles: backupFilesResult,
|
||||
currentBackupFiles: this.backupFiles,
|
||||
debugAllFiles:
|
||||
"debugListAllFiles" in platformService
|
||||
? await (platformService as any).debugListAllFiles()
|
||||
: null,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("[BackupFilesList] Debug file discovery failed:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@Watch("platformCapabilities.hasFileSystem", { immediate: true })
|
||||
async onFileSystemCapabilityChanged(newVal: boolean) {
|
||||
if (newVal) {
|
||||
await this.refreshFiles();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,8 +1,7 @@
|
||||
/** * Data Export Section Component * * Provides UI and functionality for
|
||||
exporting user data and backing up identifier seeds. * Includes buttons for seed
|
||||
backup and database export, with platform-specific download instructions. * Also
|
||||
displays a list of backup files with options to open them in the device's file
|
||||
explorer. * * @component * @displayName DataExportSection * @example * ```vue *
|
||||
backup and database export, with platform-specific download instructions. * *
|
||||
@component * @displayName DataExportSection * @example * ```vue *
|
||||
<DataExportSection :active-did="currentDid" />
|
||||
* ``` */
|
||||
|
||||
@@ -44,27 +43,18 @@ explorer. * * @component * @displayName DataExportSection * @example * ```vue *
|
||||
v-if="platformCapabilities.isIOS"
|
||||
class="list-disc list-outside ml-4"
|
||||
>
|
||||
On iOS: Files are saved to Documents folder (accessible via Files app)
|
||||
and persist between app installations.
|
||||
On iOS: You will be prompted to choose a location to save your backup
|
||||
file.
|
||||
</li>
|
||||
<li
|
||||
v-if="platformCapabilities.isMobile && !platformCapabilities.isIOS"
|
||||
class="list-disc list-outside ml-4"
|
||||
>
|
||||
On Android: Files are saved to Downloads/TimeSafari or external
|
||||
storage (accessible via file managers) and persist between app
|
||||
installations.
|
||||
On Android: You will be prompted to choose a location to save your
|
||||
backup file.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Backup Files List -->
|
||||
<div
|
||||
v-if="platformCapabilities.hasFileSystem"
|
||||
class="mt-6 pt-6 border-t border-gray-300"
|
||||
>
|
||||
<BackupFilesList ref="backupFilesList" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -75,21 +65,20 @@ import { AppString, NotificationIface } from "../constants/app";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
import * as databaseUtil from "../db/databaseUtil";
|
||||
|
||||
import { logger, getTimestampForFilename } from "../utils/logger";
|
||||
import { logger } from "../utils/logger";
|
||||
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
|
||||
import {
|
||||
PlatformService,
|
||||
PlatformCapabilities,
|
||||
} from "../services/PlatformService";
|
||||
import { contactsToExportJson } from "../libs/util";
|
||||
import BackupFilesList from "./BackupFilesList.vue";
|
||||
|
||||
/**
|
||||
* @vue-component
|
||||
* Data Export Section Component
|
||||
* Handles database export and seed backup functionality with platform-specific behavior
|
||||
*/
|
||||
@Component({ components: { BackupFilesList } })
|
||||
@Component
|
||||
export default class DataExportSection extends Vue {
|
||||
/**
|
||||
* Notification function injected by Vue
|
||||
@@ -162,9 +151,7 @@ export default class DataExportSection extends Vue {
|
||||
const jsonStr = JSON.stringify(exportData, null, 2);
|
||||
const blob = new Blob([jsonStr], { type: "application/json" });
|
||||
|
||||
// Create timestamped filename
|
||||
const timestamp = getTimestampForFilename();
|
||||
const fileName = `${AppString.APP_NAME_NO_SPACES}-backup-contacts-${timestamp}.json`;
|
||||
const fileName = `${AppString.APP_NAME_NO_SPACES}-backup-contacts.json`;
|
||||
|
||||
if (this.platformCapabilities.hasFileDownload) {
|
||||
// Web platform: Use download link
|
||||
@@ -175,21 +162,8 @@ export default class DataExportSection extends Vue {
|
||||
downloadAnchor.click();
|
||||
setTimeout(() => URL.revokeObjectURL(this.downloadUrl), 1000);
|
||||
} else if (this.platformCapabilities.hasFileSystem) {
|
||||
// Native platform: Write to user-accessible location and share
|
||||
const result = await this.platformService.writeAndShareFile(
|
||||
fileName,
|
||||
jsonStr,
|
||||
{
|
||||
allowLocationSelection: true,
|
||||
showLocationSelectionDialog: true,
|
||||
mimeType: "application/json",
|
||||
},
|
||||
);
|
||||
|
||||
// Handle the result
|
||||
if (!result.saved) {
|
||||
throw new Error(result.error || "Failed to save file");
|
||||
}
|
||||
// Native platform: Write to app directory
|
||||
await this.platformService.writeAndShareFile(fileName, jsonStr);
|
||||
} else {
|
||||
throw new Error("This platform does not support file downloads.");
|
||||
}
|
||||
@@ -201,19 +175,10 @@ export default class DataExportSection extends Vue {
|
||||
title: "Export Successful",
|
||||
text: this.platformCapabilities.hasFileDownload
|
||||
? "See your downloads directory for the backup."
|
||||
: "Backup saved to persistent storage that survives app installations. Use the share dialog to access your file and choose where to save it permanently.",
|
||||
: "The backup file has been saved.",
|
||||
},
|
||||
5000,
|
||||
3000,
|
||||
);
|
||||
|
||||
// Refresh the backup files list
|
||||
const backupFilesList = this.$refs.backupFilesList as any;
|
||||
if (
|
||||
backupFilesList &&
|
||||
typeof backupFilesList.refreshAfterSave === "function"
|
||||
) {
|
||||
await backupFilesList.refreshAfterSave();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Export Error:", error);
|
||||
this.$notify(
|
||||
@@ -247,18 +212,5 @@ export default class DataExportSection extends Vue {
|
||||
hidden: !this.downloadUrl || !this.platformCapabilities.hasFileDownload,
|
||||
};
|
||||
}
|
||||
|
||||
async mounted() {
|
||||
// Ensure permissions are requested and refresh backup files list on mount
|
||||
if (this.platformCapabilities.hasFileSystem) {
|
||||
const backupFilesList = this.$refs.backupFilesList as any;
|
||||
if (
|
||||
backupFilesList &&
|
||||
typeof backupFilesList.refreshFiles === "function"
|
||||
) {
|
||||
await backupFilesList.refreshFiles();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -321,7 +321,7 @@ export default class GiftedDialog extends Vue {
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
const errorMessage = this.getGiveCreationErrorMessage(result);
|
||||
const errorMessage = result.error;
|
||||
logger.error("Error with give creation result:", result);
|
||||
this.$notify(
|
||||
{
|
||||
@@ -367,19 +367,6 @@ export default class GiftedDialog extends Vue {
|
||||
|
||||
// Helper functions for readability
|
||||
|
||||
/**
|
||||
* @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(
|
||||
{
|
||||
|
||||
@@ -48,12 +48,15 @@
|
||||
<span>
|
||||
{{ didInfo(visDid) }}
|
||||
<span v-if="!serverUtil.isEmptyOrHiddenDid(visDid)">
|
||||
<a :href="`/did/${visDid}`" class="text-blue-500">
|
||||
<router-link
|
||||
:to="{ path: '/did/' + encodeURIComponent(visDid) }"
|
||||
class="text-blue-500"
|
||||
>
|
||||
<font-awesome
|
||||
icon="arrow-up-right-from-square"
|
||||
class="fa-fw"
|
||||
/>
|
||||
</a>
|
||||
</router-link>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -74,7 +77,7 @@
|
||||
If you'd like an introduction,
|
||||
<a
|
||||
class="text-blue-500"
|
||||
@click="copyToClipboard('A link to this page', windowLocation)"
|
||||
@click="copyToClipboard('A link to this page', deepLinkUrl)"
|
||||
>click here to copy this page, paste it into a message, and ask if
|
||||
they'll tell you more about the {{ roleName }}.</a
|
||||
>
|
||||
@@ -101,7 +104,7 @@ import * as R from "ramda";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
import * as serverUtil from "../libs/endorserServer";
|
||||
import { NotificationIface } from "../constants/app";
|
||||
import { APP_SERVER, NotificationIface } from "../constants/app";
|
||||
|
||||
@Component
|
||||
export default class HiddenDidDialog extends Vue {
|
||||
@@ -114,7 +117,8 @@ export default class HiddenDidDialog extends Vue {
|
||||
activeDid = "";
|
||||
allMyDids: Array<string> = [];
|
||||
canShare = false;
|
||||
windowLocation = window.location.href;
|
||||
deepLinkPathSuffix = "";
|
||||
deepLinkUrl = window.location.href; // this is changed to a deep link in the setup
|
||||
|
||||
R = R;
|
||||
serverUtil = serverUtil;
|
||||
@@ -126,17 +130,21 @@ export default class HiddenDidDialog extends Vue {
|
||||
}
|
||||
|
||||
open(
|
||||
deepLinkPathSuffix: string,
|
||||
roleName: string,
|
||||
visibleToDids: string[],
|
||||
allContacts: Array<Contact>,
|
||||
activeDid: string,
|
||||
allMyDids: Array<string>,
|
||||
) {
|
||||
this.deepLinkPathSuffix = deepLinkPathSuffix;
|
||||
this.roleName = roleName;
|
||||
this.visibleToDids = visibleToDids;
|
||||
this.allContacts = allContacts;
|
||||
this.activeDid = activeDid;
|
||||
this.allMyDids = allMyDids;
|
||||
|
||||
this.deepLinkUrl = APP_SERVER + "/deep-link/" + this.deepLinkPathSuffix;
|
||||
this.isOpen = true;
|
||||
}
|
||||
|
||||
@@ -170,11 +178,11 @@ export default class HiddenDidDialog extends Vue {
|
||||
}
|
||||
|
||||
onClickShareClaim() {
|
||||
this.copyToClipboard("A link to this page", this.windowLocation);
|
||||
this.copyToClipboard("A link to this page", this.deepLinkUrl);
|
||||
window.navigator.share({
|
||||
title: "Help Connect Me",
|
||||
text: "I'm trying to find the people who recorded this. Can you help me?",
|
||||
url: this.windowLocation,
|
||||
url: this.deepLinkUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,7 +268,7 @@ import {
|
||||
} from "../constants/app";
|
||||
import { retrieveSettingsForActiveAccount } from "../db/index";
|
||||
import { accessToken } from "../libs/crypto";
|
||||
import { logger, getTimestampForFilename } from "../utils/logger";
|
||||
import { logger } from "../utils/logger";
|
||||
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
|
||||
import * as databaseUtil from "../db/databaseUtil";
|
||||
|
||||
@@ -576,7 +576,7 @@ export default class ImageMethodDialog extends Vue {
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
this.blob = blob;
|
||||
this.fileName = `photo-${getTimestampForFilename()}.jpg`;
|
||||
this.fileName = `photo_${Date.now()}.jpg`;
|
||||
this.showRetry = true;
|
||||
this.stopCameraPreview();
|
||||
}
|
||||
|
||||
@@ -83,10 +83,7 @@
|
||||
import { Vue, Component, Prop } from "vue-facing-decorator";
|
||||
|
||||
import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
|
||||
import {
|
||||
createAndSubmitOffer,
|
||||
serverMessageForUser,
|
||||
} from "../libs/endorserServer";
|
||||
import { createAndSubmitOffer } from "../libs/endorserServer";
|
||||
import * as libsUtil from "../libs/util";
|
||||
import * as databaseUtil from "../db/databaseUtil";
|
||||
import { retrieveSettingsForActiveAccount } from "../db/index";
|
||||
@@ -250,7 +247,7 @@ export default class OfferDialog extends Vue {
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
const errorMessage = this.getOfferCreationErrorMessage(result);
|
||||
const errorMessage = result.error;
|
||||
logger.error("Error with offer creation result:", result);
|
||||
this.$notify(
|
||||
{
|
||||
@@ -290,21 +287,6 @@ export default class OfferDialog extends Vue {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions for readability
|
||||
|
||||
/**
|
||||
* @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
|
||||
getOfferCreationErrorMessage(result: any) {
|
||||
return (
|
||||
serverMessageForUser(result) ||
|
||||
result.error?.userMessage ||
|
||||
result.error?.error
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ import {
|
||||
import * as databaseUtil from "../db/databaseUtil";
|
||||
import { retrieveSettingsForActiveAccount } from "../db/index";
|
||||
import { accessToken } from "../libs/crypto";
|
||||
import { logger, getTimestampForFilename } from "../utils/logger";
|
||||
import { logger } from "../utils/logger";
|
||||
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
|
||||
|
||||
@Component({ components: { VuePictureCropper } })
|
||||
@@ -393,7 +393,7 @@ export default class PhotoDialog extends Vue {
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
this.blob = blob;
|
||||
this.fileName = `photo-${getTimestampForFilename()}.jpg`;
|
||||
this.fileName = `photo_${Date.now()}.jpg`;
|
||||
this.stopCameraPreview();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -38,14 +38,13 @@ export default class TopMessage extends Vue {
|
||||
settings.apiServer !== AppString.PROD_ENDORSER_API_SERVER
|
||||
) {
|
||||
const didPrefix = settings.activeDid?.slice(11, 15);
|
||||
this.message = "You're linked to a non-prod server, user " + didPrefix;
|
||||
this.message = "You're not using prod, user " + didPrefix;
|
||||
} else if (
|
||||
settings.warnIfProdServer &&
|
||||
settings.apiServer === AppString.PROD_ENDORSER_API_SERVER
|
||||
) {
|
||||
const didPrefix = settings.activeDid?.slice(11, 15);
|
||||
this.message =
|
||||
"You're linked to the production server, user " + didPrefix;
|
||||
this.message = "You are using prod, user " + didPrefix;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
this.$notify(
|
||||
|
||||
@@ -219,9 +219,9 @@ export async function logConsoleAndDb(
|
||||
isError = false,
|
||||
): Promise<void> {
|
||||
if (isError) {
|
||||
logger.error(`${new Date().toISOString()} ${message}`);
|
||||
logger.error(`${new Date().toISOString()}`, message);
|
||||
} else {
|
||||
logger.log(`${new Date().toISOString()} ${message}`);
|
||||
logger.log(`${new Date().toISOString()}`, message);
|
||||
}
|
||||
await logToDb(message);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { AxiosResponse } from "axios";
|
||||
import { GiverReceiverInputInfo } from "../libs/util";
|
||||
import { ErrorResult, ResultWithType } from "./common";
|
||||
|
||||
export interface GiverOutputInfo {
|
||||
action: string;
|
||||
@@ -47,12 +45,3 @@ export interface ProviderInfo {
|
||||
*/
|
||||
linkConfirmed: boolean;
|
||||
}
|
||||
|
||||
// Type for createAndSubmitClaim result
|
||||
export type CreateAndSubmitClaimResult = SuccessResult | ErrorResult;
|
||||
|
||||
// Update SuccessResult to use ClaimResult
|
||||
export interface SuccessResult extends ResultWithType {
|
||||
type: "success";
|
||||
response: AxiosResponse<ClaimResult>;
|
||||
}
|
||||
|
||||
@@ -15,10 +15,6 @@ export interface GenericCredWrapper<T extends GenericVerifiableCredential> {
|
||||
publicUrls?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ResultWithType {
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface ErrorResponse {
|
||||
error?: {
|
||||
message?: string;
|
||||
@@ -30,11 +26,6 @@ export interface InternalError {
|
||||
userMessage?: string;
|
||||
}
|
||||
|
||||
export interface ErrorResult extends ResultWithType {
|
||||
type: "error";
|
||||
error: InternalError;
|
||||
}
|
||||
|
||||
export interface KeyMeta {
|
||||
did: string;
|
||||
publicKeyHex: string;
|
||||
|
||||
@@ -29,18 +29,17 @@ import { z } from "zod";
|
||||
|
||||
// Add a union type of all valid route paths
|
||||
export const VALID_DEEP_LINK_ROUTES = [
|
||||
"user-profile",
|
||||
"project-details",
|
||||
"onboard-meeting-setup",
|
||||
"invite-one-accept",
|
||||
"contact-import",
|
||||
"confirm-gift",
|
||||
// note that similar lists are below in deepLinkSchemas and in src/services/deepLinks.ts
|
||||
"claim",
|
||||
"claim-cert",
|
||||
"claim-add-raw",
|
||||
"contact-edit",
|
||||
"contacts",
|
||||
"claim-cert",
|
||||
"confirm-gift",
|
||||
"contact-import",
|
||||
"did",
|
||||
"invite-one-accept",
|
||||
"onboard-meeting-setup",
|
||||
"project",
|
||||
"user-profile",
|
||||
] as const;
|
||||
|
||||
// Create a type from the array
|
||||
@@ -58,44 +57,39 @@ export const routeSchema = z.enum(VALID_DEEP_LINK_ROUTES);
|
||||
|
||||
// Parameter validation schemas for each route type
|
||||
export const deepLinkSchemas = {
|
||||
"user-profile": z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
"project-details": z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
"onboard-meeting-setup": z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
"invite-one-accept": z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
"contact-import": z.object({
|
||||
jwt: z.string(),
|
||||
}),
|
||||
"confirm-gift": z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
// note that similar lists are above in VALID_DEEP_LINK_ROUTES and in src/services/deepLinks.ts
|
||||
claim: z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
"claim-cert": z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
"claim-add-raw": z.object({
|
||||
id: z.string(),
|
||||
claim: z.string().optional(),
|
||||
claimJwtId: z.string().optional(),
|
||||
}),
|
||||
"contact-edit": z.object({
|
||||
did: z.string(),
|
||||
"claim-cert": z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
contacts: z.object({
|
||||
contacts: z.string(), // JSON string of contacts array
|
||||
"confirm-gift": z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
"contact-import": z.object({
|
||||
jwt: z.string(),
|
||||
}),
|
||||
did: z.object({
|
||||
did: z.string(),
|
||||
}),
|
||||
"invite-one-accept": z.object({
|
||||
jwt: z.string(),
|
||||
}),
|
||||
"onboard-meeting-setup": z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
project: z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
"user-profile": z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
};
|
||||
|
||||
export type DeepLinkParams = {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export type {
|
||||
// From common.ts
|
||||
CreateAndSubmitClaimResult,
|
||||
GenericCredWrapper,
|
||||
GenericVerifiableCredential,
|
||||
KeyMeta,
|
||||
@@ -18,11 +19,6 @@ export type {
|
||||
RegisterActionClaim,
|
||||
} from "./claims";
|
||||
|
||||
export type {
|
||||
// From claims-result.ts
|
||||
CreateAndSubmitClaimResult,
|
||||
} from "./claims-result";
|
||||
|
||||
export type {
|
||||
// From records.ts
|
||||
PlanSummaryRecord,
|
||||
|
||||
@@ -12,14 +12,6 @@ import type { PluginListenerHandle } from "@capacitor/core";
|
||||
* Supports 'backButton' and 'appUrlOpen' events from Capacitor
|
||||
*/
|
||||
interface AppInterface {
|
||||
/**
|
||||
* Force exit the app. This should only be used in conjunction with the `backButton` handler for Android to
|
||||
* exit the app when navigation is complete.
|
||||
*
|
||||
* @returns Promise that resolves when the app has been exited
|
||||
*/
|
||||
exitApp(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Add listener for back button events
|
||||
* @param eventName - Must be 'backButton'
|
||||
@@ -46,19 +38,8 @@ interface AppInterface {
|
||||
/**
|
||||
* App wrapper for Capacitor functionality
|
||||
* Provides type-safe event listeners for back button and URL open events
|
||||
* and app exit functionality
|
||||
*/
|
||||
export const App: AppInterface = {
|
||||
/**
|
||||
* Force exit the app. This should only be used in conjunction with the `backButton` handler for Android to
|
||||
* exit the app when navigation is complete.
|
||||
*
|
||||
* @returns Promise that resolves when the app has been exited
|
||||
*/
|
||||
exitApp(): Promise<void> {
|
||||
return CapacitorApp.exitApp();
|
||||
},
|
||||
|
||||
addListener(
|
||||
eventName: "backButton" | "appUrlOpen",
|
||||
listenerFunc: BackButtonListener | ((data: AppLaunchUrl) => void),
|
||||
|
||||
@@ -979,7 +979,7 @@ export const createAndSubmitConfirmation = async (
|
||||
handleId: string | undefined,
|
||||
apiServer: string,
|
||||
axios: Axios,
|
||||
) => {
|
||||
): Promise<CreateAndSubmitClaimResult> => {
|
||||
const goodClaim = removeSchemaContext(
|
||||
removeVisibleToDids(
|
||||
addLastClaimOrHandleAsIdIfMissing(claim, lastClaimId, handleId),
|
||||
@@ -1074,7 +1074,8 @@ export async function generateEndorserJwtUrlForAccount(
|
||||
|
||||
const vcJwt = await createEndorserJwtForDid(account.did, contactInfo);
|
||||
|
||||
const viewPrefix = APP_SERVER + CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI;
|
||||
const viewPrefix =
|
||||
APP_SERVER + "/deep-link" + CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI;
|
||||
return viewPrefix + vcJwt;
|
||||
}
|
||||
|
||||
|
||||
@@ -882,6 +882,71 @@ export const contactToCsvLine = (contact: Contact): string => {
|
||||
return fields.join(",");
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses a CSV line into a Contact object. See contactToCsvLine for the format.
|
||||
* @param lineRaw - The CSV line to parse
|
||||
* @returns A Contact object
|
||||
*/
|
||||
export const csvLineToContact = (lineRaw: string): Contact => {
|
||||
// Note that Endorser Mobile puts name first, then did, etc.
|
||||
let line = lineRaw.trim();
|
||||
let did, publicKeyInput, seesMe, registered;
|
||||
let name;
|
||||
let commaPos1 = -1;
|
||||
if (line.startsWith('"')) {
|
||||
let doubleDoubleQuotePos = line.lastIndexOf('""') + 2;
|
||||
if (doubleDoubleQuotePos === -1) {
|
||||
doubleDoubleQuotePos = 1;
|
||||
}
|
||||
const quote2Pos = line.indexOf('"', doubleDoubleQuotePos);
|
||||
if (quote2Pos > -1) {
|
||||
commaPos1 = line.indexOf(",", quote2Pos);
|
||||
name = line.substring(1, quote2Pos).trim();
|
||||
name = name.replace(/""/g, '"');
|
||||
} else {
|
||||
// something is weird with one " to start, so ignore it and start after "
|
||||
line = line.substring(1);
|
||||
commaPos1 = line.indexOf(",");
|
||||
name = line.substring(0, commaPos1).trim();
|
||||
}
|
||||
} else {
|
||||
commaPos1 = line.indexOf(",");
|
||||
name = line.substring(0, commaPos1).trim();
|
||||
}
|
||||
if (commaPos1 > -1) {
|
||||
did = line.substring(commaPos1 + 1).trim();
|
||||
const commaPos2 = line.indexOf(",", commaPos1 + 1);
|
||||
if (commaPos2 > -1) {
|
||||
did = line.substring(commaPos1 + 1, commaPos2).trim();
|
||||
publicKeyInput = line.substring(commaPos2 + 1).trim();
|
||||
const commaPos3 = line.indexOf(",", commaPos2 + 1);
|
||||
if (commaPos3 > -1) {
|
||||
publicKeyInput = line.substring(commaPos2 + 1, commaPos3).trim();
|
||||
seesMe = line.substring(commaPos3 + 1).trim() == "true";
|
||||
const commaPos4 = line.indexOf(",", commaPos3 + 1);
|
||||
if (commaPos4 > -1) {
|
||||
seesMe = line.substring(commaPos3 + 1, commaPos4).trim() == "true";
|
||||
registered = line.substring(commaPos4 + 1).trim() == "true";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// help with potential mistakes while this sharing requires copy-and-paste
|
||||
let publicKeyBase64 = publicKeyInput;
|
||||
if (publicKeyBase64 && /^[0-9A-Fa-f]{66}$/i.test(publicKeyBase64)) {
|
||||
// it must be all hex (compressed public key), so convert
|
||||
publicKeyBase64 = Buffer.from(publicKeyBase64, "hex").toString("base64");
|
||||
}
|
||||
const newContact: Contact = {
|
||||
did: did || "",
|
||||
name,
|
||||
publicKeyBase64,
|
||||
seesMe,
|
||||
registered,
|
||||
};
|
||||
return newContact;
|
||||
};
|
||||
|
||||
/**
|
||||
* Interface for the JSON export format of database tables
|
||||
*/
|
||||
|
||||
@@ -34,8 +34,7 @@ import router from "./router";
|
||||
import { handleApiError } from "./services/api";
|
||||
import { AxiosError } from "axios";
|
||||
import { DeepLinkHandler } from "./services/deepLinks";
|
||||
import { logConsoleAndDb } from "./db/databaseUtil";
|
||||
import { logger } from "./utils/logger";
|
||||
import { logger, safeStringify } from "./utils/logger";
|
||||
|
||||
logger.log("[Capacitor] Starting initialization");
|
||||
logger.log("[Capacitor] Platform:", process.env.VITE_PLATFORM);
|
||||
@@ -72,10 +71,10 @@ const handleDeepLink = async (data: { url: string }) => {
|
||||
await router.isReady();
|
||||
await deepLinkHandler.handleDeepLink(data.url);
|
||||
} catch (error) {
|
||||
logConsoleAndDb("[DeepLink] Error handling deep link: " + error, true);
|
||||
logger.error("[DeepLink] Error handling deep link: ", error);
|
||||
handleApiError(
|
||||
{
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
message: error instanceof Error ? error.message : safeStringify(error),
|
||||
} as AxiosError,
|
||||
"deep-link",
|
||||
);
|
||||
|
||||
@@ -83,6 +83,11 @@ const routes: Array<RouteRecordRaw> = [
|
||||
name: "discover",
|
||||
component: () => import("../views/DiscoverView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/deep-link/:path*",
|
||||
name: "deep-link",
|
||||
component: () => import("../views/DeepLinkRedirectView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/gifted-details",
|
||||
name: "gifted-details",
|
||||
|
||||
@@ -65,21 +65,9 @@ export interface PlatformService {
|
||||
* Writes content to a file at the specified path and shares it.
|
||||
* @param fileName - The filename of the file to write
|
||||
* @param content - The content to write to the file
|
||||
* @param options - Optional parameters for file saving behavior
|
||||
* @returns Promise that resolves to save/share result
|
||||
* @returns Promise that resolves when the write is complete
|
||||
*/
|
||||
writeAndShareFile(
|
||||
fileName: string,
|
||||
content: string,
|
||||
options?: {
|
||||
allowLocationSelection?: boolean;
|
||||
saveToDownloads?: boolean;
|
||||
saveToPrivateStorage?: boolean;
|
||||
mimeType?: string;
|
||||
showShareDialog?: boolean;
|
||||
showLocationSelectionDialog?: boolean;
|
||||
},
|
||||
): Promise<{ saved: boolean; uri?: string; shared: boolean; error?: string }>;
|
||||
writeAndShareFile(fileName: string, content: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Deletes a file at the specified path.
|
||||
@@ -95,48 +83,6 @@ export interface PlatformService {
|
||||
*/
|
||||
listFiles(directory: string): Promise<string[]>;
|
||||
|
||||
/**
|
||||
* Tests the file sharing functionality by creating and sharing a test file.
|
||||
* @returns Promise resolving to a test result message
|
||||
*/
|
||||
testFileSharing(): Promise<string>;
|
||||
|
||||
/**
|
||||
* Tests saving a file without showing the share dialog.
|
||||
* @returns Promise resolving to a test result message
|
||||
*/
|
||||
testFileSaveOnly(): Promise<string>;
|
||||
|
||||
/**
|
||||
* Tests the location selection functionality using the file picker.
|
||||
* @returns Promise resolving to a test result message
|
||||
*/
|
||||
testLocationSelection(): Promise<string>;
|
||||
|
||||
/**
|
||||
* Tests location selection without showing the dialog (restores original behavior).
|
||||
* @returns Promise resolving to a test result message
|
||||
*/
|
||||
testLocationSelectionSilent(): Promise<string>;
|
||||
|
||||
/**
|
||||
* Tests listing user-accessible files saved by the app.
|
||||
* @returns Promise resolving to a test result message
|
||||
*/
|
||||
testListUserFiles(): Promise<string>;
|
||||
|
||||
/**
|
||||
* Tests listing backup files specifically saved by the app.
|
||||
* @returns Promise resolving to a test result message
|
||||
*/
|
||||
testBackupFiles(): Promise<string>;
|
||||
|
||||
/**
|
||||
* Tests opening the backup directory in the device's file explorer.
|
||||
* @returns Promise resolving to a test result message
|
||||
*/
|
||||
testOpenBackupDirectory(): Promise<string>;
|
||||
|
||||
// Camera operations
|
||||
/**
|
||||
* Activates the device camera to take a picture.
|
||||
@@ -184,92 +130,4 @@ export interface PlatformService {
|
||||
sql: string,
|
||||
params?: unknown[],
|
||||
): Promise<{ changes: number; lastId?: number }>;
|
||||
|
||||
/**
|
||||
* Lists user-accessible files saved by the app.
|
||||
* Returns files from Downloads (Android) or Documents (iOS) directories.
|
||||
* @returns Promise resolving to array of file information
|
||||
*/
|
||||
listUserAccessibleFiles(): Promise<
|
||||
Array<{ name: string; uri: string; size?: number }>
|
||||
>;
|
||||
|
||||
/**
|
||||
* Lists backup files specifically saved by the app.
|
||||
* Filters for files that appear to be TimeSafari backups.
|
||||
* @returns Promise resolving to array of backup file information
|
||||
*/
|
||||
listBackupFiles(): Promise<
|
||||
Array<{
|
||||
name: string;
|
||||
uri: string;
|
||||
size?: number;
|
||||
type: "contacts" | "seed" | "other";
|
||||
path?: string;
|
||||
}>
|
||||
>;
|
||||
|
||||
/**
|
||||
* Opens a file in the device's default file viewer/app.
|
||||
* Uses the native share dialog to provide options for opening the file.
|
||||
* @param fileUri - URI of the file to open
|
||||
* @param fileName - Name of the file (for display purposes)
|
||||
* @returns Promise resolving to success status
|
||||
*/
|
||||
openFile(
|
||||
fileUri: string,
|
||||
fileName: string,
|
||||
): Promise<{ success: boolean; error?: string }>;
|
||||
|
||||
/**
|
||||
* Opens the directory containing backup files in the device's file explorer.
|
||||
* Uses the native share dialog to provide options for accessing the directory.
|
||||
* @returns Promise resolving to success status
|
||||
*/
|
||||
openBackupDirectory(): Promise<{ success: boolean; error?: string }>;
|
||||
|
||||
/**
|
||||
* Creates a test backup file to verify file writing and reading functionality.
|
||||
* This is useful for debugging file visibility issues.
|
||||
* @returns Promise resolving to success status and file information
|
||||
*/
|
||||
createTestBackupFile(): Promise<{
|
||||
success: boolean;
|
||||
fileName?: string;
|
||||
uri?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Tests different directory contexts to see what files are available.
|
||||
* This helps debug file visibility issues across different storage contexts.
|
||||
* @returns Promise resolving to debug information about file discovery across contexts
|
||||
*/
|
||||
testDirectoryContexts(): Promise<string>;
|
||||
|
||||
/**
|
||||
* Lists files and folders in a specific directory for directory browsing
|
||||
* @param path - The directory path to list
|
||||
* @param debugShowAll - Debug flag to treat all entries as files
|
||||
* @returns Promise resolving to array of directory entries
|
||||
*/
|
||||
listFilesInDirectory(
|
||||
path: string,
|
||||
debugShowAll?: boolean,
|
||||
): Promise<
|
||||
Array<{
|
||||
name: string;
|
||||
uri: string;
|
||||
size?: number;
|
||||
path: string;
|
||||
type: "file" | "folder";
|
||||
}>
|
||||
>;
|
||||
|
||||
/**
|
||||
* Debug method to check what's actually in the TimeSafari directory
|
||||
* This helps identify if the directory exists but is empty or has permission issues
|
||||
* @returns Promise resolving to debug information about the TimeSafari directory
|
||||
*/
|
||||
debugTimeSafariDirectory(): Promise<string>;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { AxiosError } from "axios";
|
||||
import { logger } from "../utils/logger";
|
||||
import { logger, safeStringify } from "../utils/logger";
|
||||
|
||||
/**
|
||||
* Handles API errors with platform-specific logging and error processing.
|
||||
@@ -37,7 +37,8 @@ import { logger } from "../utils/logger";
|
||||
*/
|
||||
export const handleApiError = (error: AxiosError, endpoint: string) => {
|
||||
if (process.env.VITE_PLATFORM === "capacitor") {
|
||||
logger.error(`[Capacitor API Error] ${endpoint}:`, {
|
||||
const endpointStr = safeStringify(endpoint); // we've seen this as an object in deep links
|
||||
logger.error(`[Capacitor API Error] ${endpointStr}:`, {
|
||||
message: error.message,
|
||||
status: error.response?.status,
|
||||
data: error.response?.data,
|
||||
|
||||
@@ -27,18 +27,16 @@
|
||||
* timesafari://<route>[/<param>][?queryParam1=value1&queryParam2=value2]
|
||||
*
|
||||
* Supported Routes:
|
||||
* - user-profile: View user profile
|
||||
* - project-details: View project details
|
||||
* - onboard-meeting-setup: Setup onboarding meeting
|
||||
* - invite-one-accept: Accept invitation
|
||||
* - contact-import: Import contacts
|
||||
* - confirm-gift: Confirm gift
|
||||
* - claim: View claim
|
||||
* - claim-cert: View claim certificate
|
||||
* - claim-add-raw: Add raw claim
|
||||
* - contact-edit: Edit contact
|
||||
* - contacts: View contacts
|
||||
* - claim-cert: View claim certificate
|
||||
* - confirm-gift
|
||||
* - contact-import: Import contacts
|
||||
* - did: View DID
|
||||
* - invite-one-accept: Accept invitation
|
||||
* - onboard-meeting-members
|
||||
* - project: View project details
|
||||
* - user-profile: View user profile
|
||||
*
|
||||
* @example
|
||||
* const handler = new DeepLinkHandler(router);
|
||||
@@ -81,18 +79,17 @@ export class DeepLinkHandler {
|
||||
string,
|
||||
{ name: string; paramKey?: string }
|
||||
> = {
|
||||
"user-profile": { name: "user-profile" },
|
||||
"project-details": { name: "project-details" },
|
||||
"onboard-meeting-setup": { name: "onboard-meeting-setup" },
|
||||
"invite-one-accept": { name: "invite-one-accept" },
|
||||
"contact-import": { name: "contact-import" },
|
||||
"confirm-gift": { name: "confirm-gift" },
|
||||
// note that similar lists are in src/interfaces/deepLinks.ts
|
||||
claim: { name: "claim" },
|
||||
"claim-cert": { name: "claim-cert" },
|
||||
"claim-add-raw": { name: "claim-add-raw" },
|
||||
"contact-edit": { name: "contact-edit", paramKey: "did" },
|
||||
contacts: { name: "contacts" },
|
||||
"claim-cert": { name: "claim-cert" },
|
||||
"confirm-gift": { name: "confirm-gift" },
|
||||
"contact-import": { name: "contact-import", paramKey: "jwt" },
|
||||
did: { name: "did", paramKey: "did" },
|
||||
"invite-one-accept": { name: "invite-one-accept", paramKey: "jwt" },
|
||||
"onboard-meeting-members": { name: "onboard-meeting-members" },
|
||||
project: { name: "project" },
|
||||
"user-profile": { name: "user-profile" },
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -101,7 +98,7 @@ export class DeepLinkHandler {
|
||||
*
|
||||
* @param url - The deep link URL to parse (format: scheme://path[?query])
|
||||
* @throws {DeepLinkError} If URL format is invalid
|
||||
* @returns Parsed URL components (path, params, query)
|
||||
* @returns Parsed URL components (path: string, params: {KEY: string}, query: {KEY: string})
|
||||
*/
|
||||
private parseDeepLink(url: string) {
|
||||
const parts = url.split("://");
|
||||
@@ -117,7 +114,16 @@ export class DeepLinkHandler {
|
||||
});
|
||||
|
||||
const [path, queryString] = parts[1].split("?");
|
||||
const [routePath, param] = path.split("/");
|
||||
const [routePath, ...pathParams] = path.split("/");
|
||||
// logger.info(
|
||||
// "[DeepLink] Debug:",
|
||||
// "Route Path:",
|
||||
// routePath,
|
||||
// "Path Params:",
|
||||
// pathParams,
|
||||
// "Query String:",
|
||||
// queryString,
|
||||
// );
|
||||
|
||||
// Validate route exists before proceeding
|
||||
if (!this.ROUTE_MAP[routePath]) {
|
||||
@@ -136,45 +142,14 @@ export class DeepLinkHandler {
|
||||
}
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
if (param) {
|
||||
if (pathParams) {
|
||||
// Now we know routePath exists in ROUTE_MAP
|
||||
const routeConfig = this.ROUTE_MAP[routePath];
|
||||
params[routeConfig.paramKey ?? "id"] = param;
|
||||
params[routeConfig.paramKey ?? "id"] = pathParams.join("/");
|
||||
}
|
||||
return { path: routePath, params, query };
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes incoming deep links and routes them appropriately.
|
||||
* Handles validation, error handling, and routing to the correct view.
|
||||
*
|
||||
* @param url - The deep link URL to process
|
||||
* @throws {DeepLinkError} If URL processing fails
|
||||
*/
|
||||
async handleDeepLink(url: string): Promise<void> {
|
||||
try {
|
||||
logConsoleAndDb("[DeepLink] Processing URL: " + url, false);
|
||||
const { path, params, query } = this.parseDeepLink(url);
|
||||
// Ensure params is always a Record<string,string> by converting undefined to empty string
|
||||
const sanitizedParams = Object.fromEntries(
|
||||
Object.entries(params).map(([key, value]) => [key, value ?? ""]),
|
||||
);
|
||||
await this.validateAndRoute(path, sanitizedParams, query);
|
||||
} catch (error) {
|
||||
const deepLinkError = error as DeepLinkError;
|
||||
logConsoleAndDb(
|
||||
`[DeepLink] Error (${deepLinkError.code}): ${deepLinkError.message}`,
|
||||
true,
|
||||
);
|
||||
|
||||
throw {
|
||||
code: deepLinkError.code || "UNKNOWN_ERROR",
|
||||
message: deepLinkError.message,
|
||||
details: deepLinkError.details,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes the deep link to appropriate view with validated parameters.
|
||||
* Validates route and parameters using Zod schemas before routing.
|
||||
@@ -245,6 +220,39 @@ export class DeepLinkHandler {
|
||||
code: "INVALID_PARAMETERS",
|
||||
message: (error as Error).message,
|
||||
details: error,
|
||||
params: params,
|
||||
query: query,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes incoming deep links and routes them appropriately.
|
||||
* Handles validation, error handling, and routing to the correct view.
|
||||
*
|
||||
* @param url - The deep link URL to process
|
||||
* @throws {DeepLinkError} If URL processing fails
|
||||
*/
|
||||
async handleDeepLink(url: string): Promise<void> {
|
||||
try {
|
||||
logConsoleAndDb("[DeepLink] Processing URL: " + url, false);
|
||||
const { path, params, query } = this.parseDeepLink(url);
|
||||
// Ensure params is always a Record<string,string> by converting undefined to empty string
|
||||
const sanitizedParams = Object.fromEntries(
|
||||
Object.entries(params).map(([key, value]) => [key, value ?? ""]),
|
||||
);
|
||||
await this.validateAndRoute(path, sanitizedParams, query);
|
||||
} catch (error) {
|
||||
const deepLinkError = error as DeepLinkError;
|
||||
logConsoleAndDb(
|
||||
`[DeepLink] Error (${deepLinkError.code}): ${deepLinkError.message}`,
|
||||
true,
|
||||
);
|
||||
|
||||
throw {
|
||||
code: deepLinkError.code || "UNKNOWN_ERROR",
|
||||
message: deepLinkError.message,
|
||||
details: deepLinkError.details,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -205,7 +205,6 @@ export class ElectronPlatformService implements PlatformService {
|
||||
isIOS: false,
|
||||
hasFileDownload: false, // Not implemented yet
|
||||
needsFileHandlingInstructions: false,
|
||||
isNativeApp: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -235,32 +234,11 @@ export class ElectronPlatformService implements PlatformService {
|
||||
* Writes content to a file and opens the system share dialog.
|
||||
* @param _fileName - Name of the file to create
|
||||
* @param _content - Content to write to the file
|
||||
* @param _options - Options for file saving behavior
|
||||
* @throws Error with "Not implemented" message
|
||||
* @todo Implement using Electron's dialog and file system APIs
|
||||
*/
|
||||
async writeAndShareFile(
|
||||
_fileName: string,
|
||||
_content: string,
|
||||
_options?: {
|
||||
allowLocationSelection?: boolean;
|
||||
saveToDownloads?: boolean;
|
||||
saveToPrivateStorage?: boolean;
|
||||
mimeType?: string;
|
||||
showShareDialog?: boolean;
|
||||
showLocationSelectionDialog?: boolean;
|
||||
},
|
||||
): Promise<{
|
||||
saved: boolean;
|
||||
uri?: string;
|
||||
shared: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
return {
|
||||
saved: false,
|
||||
shared: false,
|
||||
error: "Not implemented in Electron platform",
|
||||
};
|
||||
async writeAndShareFile(_fileName: string, _content: string): Promise<void> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -306,17 +284,6 @@ export class ElectronPlatformService implements PlatformService {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
/**
|
||||
* Should rotate the camera between front and back cameras.
|
||||
* @returns Promise that resolves when the camera is rotated
|
||||
* @throws Error with "Not implemented" message
|
||||
* @todo Implement camera rotation using Electron's media APIs
|
||||
*/
|
||||
async rotateCamera(): Promise<void> {
|
||||
logger.error("rotateCamera not implemented in Electron platform");
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
/**
|
||||
* Should handle deep link URLs for the desktop application.
|
||||
* @param _url - The deep link URL to handle
|
||||
@@ -378,173 +345,4 @@ export class ElectronPlatformService implements PlatformService {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the file sharing functionality.
|
||||
* @returns Promise resolving to a test result message
|
||||
*/
|
||||
async testFileSharing(): Promise<string> {
|
||||
return "File sharing not available in Electron platform - not implemented";
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests saving a file without showing the share dialog.
|
||||
* @returns Promise resolving to a test result message
|
||||
*/
|
||||
async testFileSaveOnly(): Promise<string> {
|
||||
return "File save only not available in Electron platform - not implemented";
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the location selection functionality using the file picker.
|
||||
* @returns Promise resolving to a test result message
|
||||
*/
|
||||
async testLocationSelection(): Promise<string> {
|
||||
return "Location selection not available in Electron platform - not implemented";
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests location selection without showing the dialog (restores original behavior).
|
||||
* @returns Promise resolving to a test result message
|
||||
*/
|
||||
async testLocationSelectionSilent(): Promise<string> {
|
||||
return "Location selection not available in Electron platform - not implemented";
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests listing user-accessible files saved by the app.
|
||||
* @returns Promise resolving to a test result message
|
||||
*/
|
||||
async testListUserFiles(): Promise<string> {
|
||||
return "File listing not available in Electron platform - not implemented";
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests listing backup files specifically saved by the app.
|
||||
* @returns Promise resolving to a test result message
|
||||
*/
|
||||
async testBackupFiles(): Promise<string> {
|
||||
return "Backup file listing not available in Electron platform - not implemented";
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests opening the backup directory in the device's file explorer.
|
||||
* @returns Promise resolving to a test result message
|
||||
*/
|
||||
async testOpenBackupDirectory(): Promise<string> {
|
||||
return "Directory access not available in Electron platform - not implemented";
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists user-accessible files saved by the app.
|
||||
* Not implemented in Electron platform.
|
||||
* @returns Promise resolving to empty array
|
||||
*/
|
||||
async listUserAccessibleFiles(): Promise<
|
||||
Array<{ name: string; uri: string; size?: number }>
|
||||
> {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists backup files specifically saved by the app.
|
||||
* Not implemented for Electron platform.
|
||||
* @returns Promise resolving to empty array
|
||||
*/
|
||||
async listBackupFiles(): Promise<
|
||||
Array<{
|
||||
name: string;
|
||||
uri: string;
|
||||
size?: number;
|
||||
type: "contacts" | "seed" | "other";
|
||||
path?: string;
|
||||
}>
|
||||
> {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a file in the device's default file viewer/app.
|
||||
* Not implemented in Electron platform.
|
||||
* @param _fileUri - URI of the file to open
|
||||
* @param _fileName - Name of the file (for display purposes)
|
||||
* @returns Promise resolving to error status
|
||||
*/
|
||||
async openFile(
|
||||
_fileUri: string,
|
||||
_fileName: string,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
return {
|
||||
success: false,
|
||||
error: "File opening not implemented in Electron platform",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the directory containing backup files in the device's file explorer.
|
||||
* Not implemented in Electron platform.
|
||||
* @returns Promise resolving to error status
|
||||
*/
|
||||
async openBackupDirectory(): Promise<{ success: boolean; error?: string }> {
|
||||
return {
|
||||
success: false,
|
||||
error: "Directory access not implemented in Electron platform",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists files and folders in a specific directory for directory browsing.
|
||||
* Not implemented for Electron platform.
|
||||
* @returns Promise resolving to empty array
|
||||
*/
|
||||
async listFilesInDirectory(
|
||||
_path: string,
|
||||
_debugShowAll?: boolean,
|
||||
): Promise<
|
||||
Array<{
|
||||
name: string;
|
||||
uri: string;
|
||||
size?: number;
|
||||
path: string;
|
||||
type: "file" | "folder";
|
||||
}>
|
||||
> {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug method to check what's actually in the TimeSafari directory.
|
||||
* Not implemented for Electron platform.
|
||||
* @returns Promise resolving to debug information
|
||||
*/
|
||||
async debugTimeSafariDirectory(): Promise<string> {
|
||||
return "Electron platform does not support file system access for debugging TimeSafari directory.";
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a test backup file to verify file writing and reading functionality.
|
||||
* Not implemented for Electron platform.
|
||||
* @returns Promise resolving to error status
|
||||
*/
|
||||
async createTestBackupFile(): Promise<{
|
||||
success: boolean;
|
||||
fileName?: string;
|
||||
uri?: string;
|
||||
error?: string;
|
||||
}> {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"Electron platform does not support file system access for creating test backup files.",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Test method to try different directory contexts and see what files are available.
|
||||
* Not implemented for Electron platform.
|
||||
* @returns Promise resolving to debug information
|
||||
*/
|
||||
async testDirectoryContexts(): Promise<string> {
|
||||
return "Electron platform does not support file system access for testing directory contexts.";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ export class PyWebViewPlatformService implements PlatformService {
|
||||
isIOS: false,
|
||||
hasFileDownload: false, // Not implemented yet
|
||||
needsFileHandlingInstructions: false,
|
||||
isNativeApp: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -123,211 +122,14 @@ export class PyWebViewPlatformService implements PlatformService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes content to a file at the specified path and shares it.
|
||||
* Not implemented in PyWebView platform.
|
||||
* @param _fileName - The filename of the file to write
|
||||
* @param _content - The content to write to the file
|
||||
* @param _options - Optional parameters for file saving behavior
|
||||
* @returns Promise that resolves to save/share result
|
||||
* Should write and share a file using the Python backend.
|
||||
* @param _fileName - Name of the file to write and share
|
||||
* @param _content - Content to write to the file
|
||||
* @throws Error with "Not implemented" message
|
||||
* @todo Implement file writing and sharing through pywebview's Python-JavaScript bridge
|
||||
*/
|
||||
async writeAndShareFile(
|
||||
_fileName: string,
|
||||
_content: string,
|
||||
_options?: {
|
||||
allowLocationSelection?: boolean;
|
||||
saveToDownloads?: boolean;
|
||||
saveToPrivateStorage?: boolean;
|
||||
mimeType?: string;
|
||||
showShareDialog?: boolean;
|
||||
showLocationSelectionDialog?: boolean;
|
||||
},
|
||||
): Promise<{
|
||||
saved: boolean;
|
||||
uri?: string;
|
||||
shared: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
return {
|
||||
saved: false,
|
||||
shared: false,
|
||||
error: "File sharing not implemented in PyWebView platform",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists user-accessible files saved by the app.
|
||||
* Not implemented in PyWebView platform.
|
||||
* @returns Promise resolving to empty array
|
||||
*/
|
||||
async listUserAccessibleFiles(): Promise<
|
||||
Array<{ name: string; uri: string; size?: number }>
|
||||
> {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists backup files specifically saved by the app.
|
||||
* Not implemented for PyWebView platform.
|
||||
* @returns Promise resolving to empty array
|
||||
*/
|
||||
async listBackupFiles(): Promise<
|
||||
Array<{
|
||||
name: string;
|
||||
uri: string;
|
||||
size?: number;
|
||||
type: "contacts" | "seed" | "other";
|
||||
path?: string;
|
||||
}>
|
||||
> {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a file in the device's default file viewer/app.
|
||||
* Not implemented in PyWebView platform.
|
||||
* @param _fileUri - URI of the file to open
|
||||
* @param _fileName - Name of the file (for display purposes)
|
||||
* @returns Promise resolving to error status
|
||||
*/
|
||||
async openFile(
|
||||
_fileUri: string,
|
||||
_fileName: string,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
return {
|
||||
success: false,
|
||||
error: "File opening not implemented in PyWebView platform",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the directory containing backup files in the device's file explorer.
|
||||
* Not implemented in PyWebView platform.
|
||||
* @returns Promise resolving to error status
|
||||
*/
|
||||
async openBackupDirectory(): Promise<{ success: boolean; error?: string }> {
|
||||
return {
|
||||
success: false,
|
||||
error: "Directory access not implemented in PyWebView platform",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests listing user-accessible files saved by the app.
|
||||
* @returns Promise resolving to a test result message
|
||||
*/
|
||||
async testListUserFiles(): Promise<string> {
|
||||
return "File listing not available in PyWebView platform - not implemented";
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests listing backup files specifically saved by the app.
|
||||
* @returns Promise resolving to a test result message
|
||||
*/
|
||||
async testBackupFiles(): Promise<string> {
|
||||
return "Backup file listing not available in PyWebView platform - not implemented";
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests opening the backup directory in the device's file explorer.
|
||||
* @returns Promise resolving to a test result message
|
||||
*/
|
||||
async testOpenBackupDirectory(): Promise<string> {
|
||||
return "Directory access not available in PyWebView platform - not implemented";
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the file sharing functionality.
|
||||
* @returns Promise resolving to a test result message
|
||||
*/
|
||||
async testFileSharing(): Promise<string> {
|
||||
return "File sharing not available in PyWebView platform - not implemented";
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests saving a file without showing the share dialog.
|
||||
* @returns Promise resolving to a test result message
|
||||
*/
|
||||
async testFileSaveOnly(): Promise<string> {
|
||||
return "File saving not available in PyWebView platform - not implemented";
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the location selection functionality.
|
||||
* @returns Promise resolving to a test result message
|
||||
*/
|
||||
async testLocationSelection(): Promise<string> {
|
||||
return "Location selection not available in PyWebView platform - not implemented";
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests location selection without showing the dialog.
|
||||
* @returns Promise resolving to a test result message
|
||||
*/
|
||||
async testLocationSelectionSilent(): Promise<string> {
|
||||
return "Silent location selection not available in PyWebView platform - not implemented";
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotates the camera between front and back.
|
||||
* Not implemented in PyWebView platform.
|
||||
*/
|
||||
async rotateCamera(): Promise<void> {
|
||||
// Not implemented
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists files and folders in a specific directory for directory browsing.
|
||||
* Not implemented for PyWebView platform.
|
||||
* @returns Promise resolving to empty array
|
||||
*/
|
||||
async listFilesInDirectory(
|
||||
_path: string,
|
||||
_debugShowAll?: boolean,
|
||||
): Promise<
|
||||
Array<{
|
||||
name: string;
|
||||
uri: string;
|
||||
size?: number;
|
||||
path: string;
|
||||
type: "file" | "folder";
|
||||
}>
|
||||
> {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug method to check what's actually in the TimeSafari directory.
|
||||
* Not implemented for PyWebView platform.
|
||||
* @returns Promise resolving to debug information
|
||||
*/
|
||||
async debugTimeSafariDirectory(): Promise<string> {
|
||||
return "PyWebView platform does not support file system access for debugging TimeSafari directory.";
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a test backup file to verify file writing and reading functionality.
|
||||
* Not implemented for PyWebView platform.
|
||||
* @returns Promise resolving to error status
|
||||
*/
|
||||
async createTestBackupFile(): Promise<{
|
||||
success: boolean;
|
||||
fileName?: string;
|
||||
uri?: string;
|
||||
error?: string;
|
||||
}> {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"PyWebView platform does not support file system access for creating test backup files.",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Test method to try different directory contexts and see what files are available.
|
||||
* Not implemented for PyWebView platform.
|
||||
* @returns Promise resolving to debug information
|
||||
*/
|
||||
async testDirectoryContexts(): Promise<string> {
|
||||
return "PyWebView platform does not support file system access for testing directory contexts.";
|
||||
async writeAndShareFile(_fileName: string, _content: string): Promise<void> {
|
||||
logger.error("writeAndShareFile not implemented in PyWebView platform");
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
PlatformService,
|
||||
PlatformCapabilities,
|
||||
} from "../PlatformService";
|
||||
import { logger, getTimestampForFilename } from "../../utils/logger";
|
||||
import { logger } from "../../utils/logger";
|
||||
import { QueryExecResult } from "@/interfaces/database";
|
||||
import databaseService from "../AbsurdSqlDatabaseService";
|
||||
|
||||
@@ -29,14 +29,10 @@ export class WebPlatformService implements PlatformService {
|
||||
return {
|
||||
hasFileSystem: false,
|
||||
hasCamera: true, // Through file input with capture
|
||||
isMobile:
|
||||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
||||
navigator.userAgent,
|
||||
),
|
||||
isMobile: /iPhone|iPad|iPod|Android/i.test(navigator.userAgent),
|
||||
isIOS: /iPad|iPhone|iPod/.test(navigator.userAgent),
|
||||
hasFileDownload: true,
|
||||
needsFileHandlingInstructions: false,
|
||||
isNativeApp: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -194,7 +190,7 @@ export class WebPlatformService implements PlatformService {
|
||||
if (blob) {
|
||||
resolve({
|
||||
blob,
|
||||
fileName: `photo-${getTimestampForFilename()}.jpg`,
|
||||
fileName: `photo_${Date.now()}.jpg`,
|
||||
});
|
||||
} else {
|
||||
reject(new Error("Failed to capture image from webcam"));
|
||||
@@ -360,31 +356,10 @@ export class WebPlatformService implements PlatformService {
|
||||
* Not supported in web platform.
|
||||
* @param _fileName - Unused fileName parameter
|
||||
* @param _content - Unused content parameter
|
||||
* @param _options - Unused options parameter
|
||||
* @returns Promise that resolves to a failure result
|
||||
* @throws Error indicating file system access is not available
|
||||
*/
|
||||
async writeAndShareFile(
|
||||
_fileName: string,
|
||||
_content: string,
|
||||
_options?: {
|
||||
allowLocationSelection?: boolean;
|
||||
saveToDownloads?: boolean;
|
||||
saveToPrivateStorage?: boolean;
|
||||
mimeType?: string;
|
||||
showShareDialog?: boolean;
|
||||
showLocationSelectionDialog?: boolean;
|
||||
},
|
||||
): Promise<{
|
||||
saved: boolean;
|
||||
uri?: string;
|
||||
shared: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
return {
|
||||
saved: false,
|
||||
shared: false,
|
||||
error: "File system access not available in web platform",
|
||||
};
|
||||
async writeAndShareFile(_fileName: string, _content: string): Promise<void> {
|
||||
throw new Error("File system access not available in web platform");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -415,183 +390,4 @@ export class WebPlatformService implements PlatformService {
|
||||
.query(sql, params)
|
||||
.then((result: QueryExecResult[]) => result[0]?.values[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the file sharing functionality.
|
||||
* @returns Promise resolving to a test result message
|
||||
*/
|
||||
async testFileSharing(): Promise<string> {
|
||||
return "File sharing not available in web platform - use download instead";
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests saving a file without showing the share dialog.
|
||||
* @returns Promise resolving to a test result message
|
||||
*/
|
||||
async testFileSaveOnly(): Promise<string> {
|
||||
return "File saving not available in web platform - use download instead";
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the location selection functionality using the file picker.
|
||||
* @returns Promise resolving to a test result message
|
||||
*/
|
||||
async testLocationSelection(): Promise<string> {
|
||||
return "Location selection not available in web platform - use download instead";
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests location selection without showing the dialog (restores original behavior).
|
||||
* @returns Promise resolving to a test result message
|
||||
*/
|
||||
async testLocationSelectionSilent(): Promise<string> {
|
||||
return "Location selection not available in web platform - use download instead";
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests listing user-accessible files saved by the app.
|
||||
* @returns Promise resolving to a test result message
|
||||
*/
|
||||
async testListUserFiles(): Promise<string> {
|
||||
return "File listing not available in web platform - files are downloaded directly";
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests listing backup files specifically saved by the app.
|
||||
* @returns Promise resolving to a test result message
|
||||
*/
|
||||
async testBackupFiles(): Promise<string> {
|
||||
return "Backup file listing not available in web platform - files are downloaded directly";
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests opening the backup directory in the device's file explorer.
|
||||
* @returns Promise resolving to a test result message
|
||||
*/
|
||||
async testOpenBackupDirectory(): Promise<string> {
|
||||
return "Directory access not available in web platform - files are downloaded directly";
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists user-accessible files saved by the app.
|
||||
* Not supported in web platform.
|
||||
* @returns Promise resolving to empty array
|
||||
*/
|
||||
async listUserAccessibleFiles(): Promise<
|
||||
Array<{ name: string; uri: string; size?: number }>
|
||||
> {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists backup files specifically saved by the app.
|
||||
* Not supported in web platform.
|
||||
* @returns Promise resolving to empty array
|
||||
*/
|
||||
async listBackupFiles(): Promise<
|
||||
Array<{
|
||||
name: string;
|
||||
uri: string;
|
||||
size?: number;
|
||||
type: "contacts" | "seed" | "other";
|
||||
path?: string;
|
||||
}>
|
||||
> {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a file in the device's default file viewer/app.
|
||||
* Not supported in web platform.
|
||||
* @param _fileUri - URI of the file to open
|
||||
* @param _fileName - Name of the file (for display purposes)
|
||||
* @returns Promise resolving to error status
|
||||
*/
|
||||
async openFile(
|
||||
_fileUri: string,
|
||||
_fileName: string,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
return {
|
||||
success: false,
|
||||
error: "File opening not available in web platform",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the directory containing backup files in the device's file explorer.
|
||||
* Not supported in web platform.
|
||||
* @returns Promise resolving to error status
|
||||
*/
|
||||
async openBackupDirectory(): Promise<{ success: boolean; error?: string }> {
|
||||
return {
|
||||
success: false,
|
||||
error: "Directory access not available in web platform",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotates the camera between front and back cameras.
|
||||
* Not supported in web platform.
|
||||
* @returns Promise that resolves immediately
|
||||
*/
|
||||
async rotateCamera(): Promise<void> {
|
||||
// Not supported in web platform
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists files and folders in a specific directory for directory browsing.
|
||||
* Not supported in web platform.
|
||||
* @returns Promise resolving to empty array
|
||||
*/
|
||||
async listFilesInDirectory(
|
||||
_path: string,
|
||||
_debugShowAll?: boolean,
|
||||
): Promise<
|
||||
Array<{
|
||||
name: string;
|
||||
uri: string;
|
||||
size?: number;
|
||||
path: string;
|
||||
type: "file" | "folder";
|
||||
}>
|
||||
> {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug method to check what's actually in the TimeSafari directory.
|
||||
* Not supported in web platform.
|
||||
* @returns Promise resolving to debug information
|
||||
*/
|
||||
async debugTimeSafariDirectory(): Promise<string> {
|
||||
return "Web platform does not support file system access for debugging TimeSafari directory.";
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a test backup file to verify file writing and reading functionality.
|
||||
* Not supported in web platform.
|
||||
* @returns Promise resolving to error status
|
||||
*/
|
||||
async createTestBackupFile(): Promise<{
|
||||
success: boolean;
|
||||
fileName?: string;
|
||||
uri?: string;
|
||||
error?: string;
|
||||
}> {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"Web platform does not support file system access for creating test backup files.",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Test method to try different directory contexts and see what files are available.
|
||||
* Not supported in web platform.
|
||||
* @returns Promise resolving to debug information
|
||||
*/
|
||||
async testDirectoryContexts(): Promise<string> {
|
||||
return "Web platform does not support file system access for testing directory contexts.";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { logToDb } from "../db/databaseUtil";
|
||||
|
||||
function safeStringify(obj: unknown) {
|
||||
export function safeStringify(obj: unknown) {
|
||||
const seen = new WeakSet();
|
||||
|
||||
return JSON.stringify(obj, (_key, value) => {
|
||||
@@ -67,8 +67,9 @@ export const logger = {
|
||||
// Errors will always be logged
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(message, ...args);
|
||||
const argsString = args.length > 0 ? " - " + safeStringify(args) : "";
|
||||
logToDb(message + argsString);
|
||||
const messageString = safeStringify(message);
|
||||
const argsString = args.length > 0 ? safeStringify(args) : "";
|
||||
logToDb(messageString + argsString);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -79,22 +80,3 @@ if (typeof module !== "undefined" && module.exports) {
|
||||
|
||||
// Add default export for ESM
|
||||
export default { logger };
|
||||
|
||||
/**
|
||||
* Formats current timestamp for use in filenames.
|
||||
* Returns ISO string with colons and periods replaced with hyphens, truncated to seconds.
|
||||
* Format: 2024-01-15T14-30-45
|
||||
* @returns Formatted timestamp string safe for filenames
|
||||
*/
|
||||
export function getTimestampForFilename(): string {
|
||||
return new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats current timestamp for use in filenames with date only.
|
||||
* Format: 2024-01-15
|
||||
* @returns Date-only timestamp string safe for filenames
|
||||
*/
|
||||
export function getDateForFilename(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
@@ -198,7 +198,7 @@ export default class ClaimAddRawView extends Vue {
|
||||
this.apiServer,
|
||||
this.axios,
|
||||
);
|
||||
if (result.type === "success") {
|
||||
if (result.success) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
|
||||
@@ -46,23 +46,35 @@
|
||||
</h2>
|
||||
<div class="flex justify-center w-full">
|
||||
<router-link
|
||||
v-if="veriClaim.id"
|
||||
:to="'/claim-cert/' + encodeURIComponent(veriClaim.id)"
|
||||
class="text-blue-500 mt-2"
|
||||
title="Printable Certificate"
|
||||
title="View Printable Certificate"
|
||||
>
|
||||
<font-awesome
|
||||
icon="square"
|
||||
class="text-white bg-yellow-500 p-1"
|
||||
/>
|
||||
</router-link>
|
||||
<button
|
||||
v-if="veriClaim.id"
|
||||
class="text-blue-500 ml-2 mt-2"
|
||||
title="Copy Printable Certificate Link"
|
||||
@click="
|
||||
copyToClipboard(
|
||||
'A link to the certificate page',
|
||||
`${APP_SERVER}/deep-link/claim-cert/${veriClaim.id}`,
|
||||
)
|
||||
"
|
||||
>
|
||||
<font-awesome icon="link" class="text-yellow-500 p-1" />
|
||||
</button>
|
||||
</div>
|
||||
<!-- show link icon to copy this URL to the clipboard -->
|
||||
<div class="flex justify-end w-full">
|
||||
<button
|
||||
title="Copy Link"
|
||||
@click="
|
||||
copyToClipboard('A link to this page', window.location.href)
|
||||
"
|
||||
@click="copyToClipboard('A link to this page', windowDeepLink)"
|
||||
>
|
||||
<font-awesome icon="link" class="text-slate-500" />
|
||||
</button>
|
||||
@@ -292,12 +304,17 @@
|
||||
<div class="text-sm">
|
||||
{{ didInfo(confirmerId) }}
|
||||
<span v-if="!serverUtil.isEmptyOrHiddenDid(confirmerId)">
|
||||
<a :href="`/did/${confirmerId}`" class="text-blue-500">
|
||||
<router-link
|
||||
:to="{
|
||||
path: '/did/' + encodeURIComponent(confirmerId),
|
||||
}"
|
||||
class="text-blue-500"
|
||||
>
|
||||
<font-awesome
|
||||
icon="arrow-up-right-from-square"
|
||||
class="fa-fw"
|
||||
/>
|
||||
</a>
|
||||
</router-link>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -329,12 +346,17 @@
|
||||
<div class="text-sm">
|
||||
{{ didInfo(confsVisibleTo) }}
|
||||
<span v-if="!serverUtil.isEmptyOrHiddenDid(confsVisibleTo)">
|
||||
<a :href="`/did/${confsVisibleTo}`" class="text-blue-500">
|
||||
<router-link
|
||||
:to="{
|
||||
path: '/did/' + encodeURIComponent(confsVisibleTo),
|
||||
}"
|
||||
class="text-blue-500"
|
||||
>
|
||||
<font-awesome
|
||||
icon="arrow-up-right-from-square"
|
||||
class="fa-fw"
|
||||
/>
|
||||
</a>
|
||||
</router-link>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -394,7 +416,7 @@
|
||||
contacts can see more details:
|
||||
<a
|
||||
class="text-blue-500"
|
||||
@click="copyToClipboard('A link to this page', windowLocation)"
|
||||
@click="copyToClipboard('A link to this page', windowDeepLink)"
|
||||
>click to copy this page info</a
|
||||
>
|
||||
and see if they can make an introduction. Someone is connected to
|
||||
@@ -417,7 +439,7 @@
|
||||
If you'd like an introduction,
|
||||
<a
|
||||
class="text-blue-500"
|
||||
@click="copyToClipboard('A link to this page', windowLocation)"
|
||||
@click="copyToClipboard('A link to this page', windowDeepLink)"
|
||||
>share this page with them and ask if they'll tell you more about
|
||||
about the participants.</a
|
||||
>
|
||||
@@ -443,12 +465,17 @@
|
||||
<span>
|
||||
{{ didInfo(visDid) }}
|
||||
<span v-if="!serverUtil.isEmptyOrHiddenDid(visDid)">
|
||||
<a :href="`/did/${visDid}`" class="text-blue-500">
|
||||
<router-link
|
||||
:to="{
|
||||
path: '/did/' + encodeURIComponent(visDid),
|
||||
}"
|
||||
class="text-blue-500"
|
||||
>
|
||||
<font-awesome
|
||||
icon="arrow-up-right-from-square"
|
||||
class="fa-fw"
|
||||
/>
|
||||
</a>
|
||||
</router-link>
|
||||
</span>
|
||||
<span v-if="veriClaim.publicUrls?.[visDid]"
|
||||
>, found at <a
|
||||
@@ -530,7 +557,7 @@ import { useClipboard } from "@vueuse/core";
|
||||
import { GenericVerifiableCredential } from "../interfaces";
|
||||
import GiftedDialog from "../components/GiftedDialog.vue";
|
||||
import QuickNav from "../components/QuickNav.vue";
|
||||
import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
|
||||
import { APP_SERVER, NotificationIface, USE_DEXIE_DB } from "../constants/app";
|
||||
import * as databaseUtil from "../db/databaseUtil";
|
||||
import { db } from "../db/index";
|
||||
import { logConsoleAndDb } from "../db/databaseUtil";
|
||||
@@ -577,8 +604,9 @@ export default class ClaimView extends Vue {
|
||||
veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
|
||||
veriClaimDump = "";
|
||||
veriClaimDidsVisible: { [key: string]: string[] } = {};
|
||||
windowLocation = window.location.href;
|
||||
windowDeepLink = window.location.href; // changed in the setup for deep linking
|
||||
|
||||
APP_SERVER = APP_SERVER;
|
||||
R = R;
|
||||
yaml = yaml;
|
||||
libsUtil = libsUtil;
|
||||
@@ -655,6 +683,7 @@ export default class ClaimView extends Vue {
|
||||
5000,
|
||||
);
|
||||
}
|
||||
this.windowDeepLink = `${APP_SERVER}/deep-link/claim/${claimId}`;
|
||||
|
||||
this.canShare = !!navigator.share;
|
||||
}
|
||||
@@ -925,7 +954,7 @@ export default class ClaimView extends Vue {
|
||||
this.apiServer,
|
||||
this.axios,
|
||||
);
|
||||
if (result.type === "success") {
|
||||
if (result.success) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
@@ -990,11 +1019,11 @@ export default class ClaimView extends Vue {
|
||||
}
|
||||
|
||||
onClickShareClaim() {
|
||||
this.copyToClipboard("A link to this page", this.windowLocation);
|
||||
this.copyToClipboard("A link to this page", this.windowDeepLink);
|
||||
window.navigator.share({
|
||||
title: "Help Connect Me",
|
||||
text: "I'm trying to find the people who recorded this. Can you help me?",
|
||||
url: this.windowLocation,
|
||||
url: this.windowDeepLink,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -407,14 +407,14 @@
|
||||
</a>
|
||||
</div>
|
||||
<div class="mt-2 ml-2">
|
||||
<a
|
||||
<router-link
|
||||
v-if="isRegistered"
|
||||
class="text-blue-500 cursor-pointer"
|
||||
:href="urlForNewGive"
|
||||
:to="urlForNewGive"
|
||||
>
|
||||
<font-awesome icon="file-lines" />
|
||||
Record a Give Similar to the Original
|
||||
</a>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -436,7 +436,7 @@ import { Component, Vue } from "vue-facing-decorator";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
|
||||
import QuickNav from "../components/QuickNav.vue";
|
||||
import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
|
||||
import { APP_SERVER, NotificationIface, USE_DEXIE_DB } from "../constants/app";
|
||||
import { db, retrieveSettingsForActiveAccount } from "../db/index";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
import * as databaseUtil from "../db/databaseUtil";
|
||||
@@ -494,7 +494,7 @@ export default class ConfirmGiftView extends Vue {
|
||||
veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
|
||||
veriClaimDump = "";
|
||||
veriClaimDidsVisible: { [key: string]: string[] } = {};
|
||||
windowLocation = window.location.href;
|
||||
windowLocation = window.location.href; // this is changed to a deep link in the setup
|
||||
|
||||
R = R;
|
||||
yaml = yaml;
|
||||
@@ -566,6 +566,9 @@ export default class ConfirmGiftView extends Vue {
|
||||
}
|
||||
|
||||
const claimId = decodeURIComponent(pathParam);
|
||||
|
||||
this.windowLocation = APP_SERVER + "/deep-link/confirm-gift/" + claimId;
|
||||
|
||||
await this.loadClaim(claimId, this.activeDid);
|
||||
}
|
||||
|
||||
@@ -676,12 +679,12 @@ export default class ConfirmGiftView extends Vue {
|
||||
/**
|
||||
* Add participant (giver/recipient) name & URL info
|
||||
*/
|
||||
this.giverName = this.didInfo(this.giveDetails?.agentDid);
|
||||
if (this.giveDetails?.agentDid) {
|
||||
this.giverName = this.didInfo(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.recipientName = this.didInfo(this.giveDetails.recipientDid);
|
||||
this.urlForNewGive += `&recipientDid=${encodeURIComponent(this.giveDetails.recipientDid)}&recipientName=${encodeURIComponent(this.recipientName)}`;
|
||||
}
|
||||
|
||||
@@ -831,7 +834,7 @@ export default class ConfirmGiftView extends Vue {
|
||||
this.apiServer,
|
||||
this.axios,
|
||||
);
|
||||
if (result.type === "success") {
|
||||
if (result.success) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
|
||||
@@ -104,6 +104,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Buffer } from "buffer/";
|
||||
import QRCodeVue3 from "qr-code-generator-vue3";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { Router } from "vue-router";
|
||||
@@ -117,14 +118,20 @@ import { db } from "../db/index";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
import { getContactJwtFromJwtUrl } from "../libs/crypto";
|
||||
import { decodeEndorserJwt, ETHR_DID_PREFIX } from "../libs/crypto/vc";
|
||||
import * as libsUtil from "../libs/util";
|
||||
import { retrieveSettingsForActiveAccount } from "../db/index";
|
||||
import * as databaseUtil from "../db/databaseUtil";
|
||||
import { setVisibilityUtil } from "../libs/endorserServer";
|
||||
import {
|
||||
CONTACT_CSV_HEADER,
|
||||
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
|
||||
generateEndorserJwtUrlForAccount,
|
||||
setVisibilityUtil,
|
||||
} from "../libs/endorserServer";
|
||||
import UserNameDialog from "../components/UserNameDialog.vue";
|
||||
import { generateEndorserJwtUrlForAccount } from "../libs/endorserServer";
|
||||
import { retrieveAccountMetadata } from "../libs/util";
|
||||
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
|
||||
import { parseJsonField } from "../db/databaseUtil";
|
||||
import { Account } from "@/db/tables/accounts";
|
||||
|
||||
interface QRScanResult {
|
||||
rawValue?: string;
|
||||
@@ -142,7 +149,7 @@ interface IUserNameDialog {
|
||||
UserNameDialog,
|
||||
},
|
||||
})
|
||||
export default class ContactQRScan extends Vue {
|
||||
export default class ContactQRScanFull extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
$router!: Router;
|
||||
|
||||
@@ -151,6 +158,8 @@ export default class ContactQRScan extends Vue {
|
||||
activeDid = "";
|
||||
apiServer = "";
|
||||
givenName = "";
|
||||
isRegistered = false;
|
||||
profileImageUrl = "";
|
||||
qrValue = "";
|
||||
ETHR_DID_PREFIX = ETHR_DID_PREFIX;
|
||||
|
||||
@@ -172,19 +181,22 @@ export default class ContactQRScan extends Vue {
|
||||
this.activeDid = settings.activeDid || "";
|
||||
this.apiServer = settings.apiServer || "";
|
||||
this.givenName = settings.firstName || "";
|
||||
this.isRegistered = !!settings.isRegistered;
|
||||
this.profileImageUrl = settings.profileImageUrl || "";
|
||||
|
||||
const account = await retrieveAccountMetadata(this.activeDid);
|
||||
if (account) {
|
||||
const name =
|
||||
(settings.firstName || "") +
|
||||
(settings.lastName ? ` ${settings.lastName}` : "");
|
||||
this.qrValue = await generateEndorserJwtUrlForAccount(
|
||||
account,
|
||||
!!settings.isRegistered,
|
||||
name,
|
||||
settings.profileImageUrl || "",
|
||||
false,
|
||||
);
|
||||
const publicKeyBase64 = Buffer.from(
|
||||
account.publicKeyHex,
|
||||
"hex",
|
||||
).toString("base64");
|
||||
this.qrValue =
|
||||
CONTACT_CSV_HEADER +
|
||||
"\n" +
|
||||
`"${name}",${account.did},${publicKeyBase64},false,${this.isRegistered}`;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error initializing component:", {
|
||||
@@ -336,57 +348,69 @@ export default class ContactQRScan extends Vue {
|
||||
|
||||
logger.info("Processing QR code scan result:", rawValue);
|
||||
|
||||
// Extract JWT
|
||||
const jwt = getContactJwtFromJwtUrl(rawValue);
|
||||
if (!jwt) {
|
||||
logger.warn("Invalid QR code format - no JWT found in URL");
|
||||
let contact: Contact;
|
||||
if (rawValue.includes(CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI)) {
|
||||
// Extract JWT
|
||||
const jwt = getContactJwtFromJwtUrl(rawValue);
|
||||
if (!jwt) {
|
||||
logger.warn("Invalid QR code format - no JWT found in URL");
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Invalid QR Code",
|
||||
text: "This QR code does not contain valid contact information. Scan a TimeSafari contact QR code.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Process JWT and contact info
|
||||
logger.info("Decoding JWT payload from QR code");
|
||||
const decodedJwt = await decodeEndorserJwt(jwt);
|
||||
if (!decodedJwt?.payload?.own) {
|
||||
logger.warn("Invalid JWT payload - missing 'own' field");
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Invalid Contact Info",
|
||||
text: "The contact information is incomplete or invalid.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const contactInfo = decodedJwt.payload.own;
|
||||
const did = contactInfo.did || decodedJwt.payload.iss;
|
||||
if (!did) {
|
||||
logger.warn("Invalid contact info - missing DID");
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Invalid Contact",
|
||||
text: "The contact DID is missing.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Create contact object
|
||||
contact = {
|
||||
did: did,
|
||||
name: contactInfo.name || "",
|
||||
publicKeyBase64: contactInfo.publicKeyBase64 || "",
|
||||
seesMe: contactInfo.seesMe || false,
|
||||
registered: contactInfo.registered || false,
|
||||
};
|
||||
} else if (rawValue.startsWith(CONTACT_CSV_HEADER)) {
|
||||
const lines = rawValue.split(/\n/);
|
||||
contact = libsUtil.csvLineToContact(lines[1]);
|
||||
} else {
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Invalid QR Code",
|
||||
text: "This QR code does not contain valid contact information. Please scan a TimeSafari contact QR code.",
|
||||
title: "Error",
|
||||
text: "Could not determine the type of contact info. Try again, or tap the QR code to copy it and send it to them.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Process JWT and contact info
|
||||
logger.info("Decoding JWT payload from QR code");
|
||||
const decodedJwt = await decodeEndorserJwt(jwt);
|
||||
if (!decodedJwt?.payload?.own) {
|
||||
logger.warn("Invalid JWT payload - missing 'own' field");
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Invalid Contact Info",
|
||||
text: "The contact information is incomplete or invalid.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const contactInfo = decodedJwt.payload.own;
|
||||
const did = contactInfo.did || decodedJwt.payload.iss;
|
||||
if (!did) {
|
||||
logger.warn("Invalid contact info - missing DID");
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Invalid Contact",
|
||||
text: "The contact DID is missing.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Create contact object
|
||||
const contact = {
|
||||
did: did,
|
||||
name: contactInfo.name || "",
|
||||
email: contactInfo.email || "",
|
||||
phone: contactInfo.phone || "",
|
||||
company: contactInfo.company || "",
|
||||
title: contactInfo.title || "",
|
||||
notes: contactInfo.notes || "",
|
||||
};
|
||||
|
||||
// Add contact but keep scanning
|
||||
logger.info("Adding new contact to database:", {
|
||||
did: contact.did,
|
||||
@@ -468,7 +492,7 @@ export default class ContactQRScan extends Vue {
|
||||
title: "Contact Exists",
|
||||
text: "This contact has already been added to your list.",
|
||||
},
|
||||
3000,
|
||||
5000,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -568,9 +592,19 @@ export default class ContactQRScan extends Vue {
|
||||
);
|
||||
}
|
||||
|
||||
onCopyUrlToClipboard() {
|
||||
async onCopyUrlToClipboard() {
|
||||
const account = (await libsUtil.retrieveFullyDecryptedAccount(
|
||||
this.activeDid,
|
||||
)) as Account;
|
||||
const jwtUrl = await generateEndorserJwtUrlForAccount(
|
||||
account,
|
||||
this.isRegistered,
|
||||
this.givenName,
|
||||
this.profileImageUrl,
|
||||
true,
|
||||
);
|
||||
useClipboard()
|
||||
.copy(this.qrValue)
|
||||
.copy(jwtUrl)
|
||||
.then(() => {
|
||||
this.$notify(
|
||||
{
|
||||
|
||||
@@ -159,6 +159,7 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { AxiosError } from "axios";
|
||||
import { Buffer } from "buffer/";
|
||||
import QRCodeVue3 from "qr-code-generator-vue3";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
@@ -174,17 +175,20 @@ import * as databaseUtil from "../db/databaseUtil";
|
||||
import { parseJsonField } from "../db/databaseUtil";
|
||||
import { getContactJwtFromJwtUrl } from "../libs/crypto";
|
||||
import {
|
||||
CONTACT_CSV_HEADER,
|
||||
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
|
||||
generateEndorserJwtUrlForAccount,
|
||||
register,
|
||||
setVisibilityUtil,
|
||||
} from "../libs/endorserServer";
|
||||
import { decodeEndorserJwt, ETHR_DID_PREFIX } from "../libs/crypto/vc";
|
||||
import { retrieveAccountMetadata } from "../libs/util";
|
||||
import * as libsUtil from "../libs/util";
|
||||
import { Router } from "vue-router";
|
||||
import { logger } from "../utils/logger";
|
||||
import { QRScannerFactory } from "@/services/QRScanner/QRScannerFactory";
|
||||
import { CameraState } from "@/services/QRScanner/types";
|
||||
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
|
||||
import { Account } from "@/db/tables/accounts";
|
||||
|
||||
interface QRScanResult {
|
||||
rawValue?: string;
|
||||
@@ -214,6 +218,7 @@ export default class ContactQRScanShow extends Vue {
|
||||
isRegistered = false;
|
||||
qrValue = "";
|
||||
isScanning = false;
|
||||
profileImageUrl = "";
|
||||
error: string | null = null;
|
||||
|
||||
// QR Scanner properties
|
||||
@@ -251,19 +256,21 @@ export default class ContactQRScanShow extends Vue {
|
||||
this.hideRegisterPromptOnNewContact =
|
||||
!!settings.hideRegisterPromptOnNewContact;
|
||||
this.isRegistered = !!settings.isRegistered;
|
||||
this.profileImageUrl = settings.profileImageUrl || "";
|
||||
|
||||
const account = await retrieveAccountMetadata(this.activeDid);
|
||||
const account = await libsUtil.retrieveAccountMetadata(this.activeDid);
|
||||
if (account) {
|
||||
const name =
|
||||
(settings.firstName || "") +
|
||||
(settings.lastName ? ` ${settings.lastName}` : "");
|
||||
this.qrValue = await generateEndorserJwtUrlForAccount(
|
||||
account,
|
||||
!!settings.isRegistered,
|
||||
name,
|
||||
settings.profileImageUrl || "",
|
||||
false,
|
||||
);
|
||||
const publicKeyBase64 = Buffer.from(
|
||||
account.publicKeyHex,
|
||||
"hex",
|
||||
).toString("base64");
|
||||
this.qrValue =
|
||||
CONTACT_CSV_HEADER +
|
||||
"\n" +
|
||||
`"${name}",${account.did},${publicKeyBase64},false,${this.isRegistered}`;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error initializing component:", {
|
||||
@@ -274,7 +281,7 @@ export default class ContactQRScanShow extends Vue {
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Initialization Error",
|
||||
text: "Failed to initialize QR scanner. Please try again.",
|
||||
text: "Failed to initialize QR renderer or scanner. Please try again.",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -461,53 +468,68 @@ export default class ContactQRScanShow extends Vue {
|
||||
|
||||
logger.info("Processing QR code scan result:", rawValue);
|
||||
|
||||
// Extract JWT
|
||||
const jwt = getContactJwtFromJwtUrl(rawValue);
|
||||
if (!jwt) {
|
||||
logger.warn("Invalid QR code format - no JWT found in URL");
|
||||
let contact: Contact;
|
||||
if (rawValue.includes(CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI)) {
|
||||
const jwt = getContactJwtFromJwtUrl(rawValue);
|
||||
if (!jwt) {
|
||||
logger.warn("Invalid QR code format - no JWT found in URL");
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Invalid QR Code",
|
||||
text: "This QR code does not contain valid contact information. Scan a TimeSafari contact QR code.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
logger.info("Decoding JWT payload from QR code");
|
||||
const decodedJwt = await decodeEndorserJwt(jwt);
|
||||
|
||||
// Process JWT and contact info
|
||||
if (!decodedJwt?.payload?.own) {
|
||||
logger.warn("Invalid JWT payload - missing 'own' field");
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Invalid Contact Info",
|
||||
text: "The contact information is incomplete or invalid.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const contactInfo = decodedJwt.payload.own;
|
||||
const did = contactInfo.did || decodedJwt.payload.iss;
|
||||
if (!did) {
|
||||
logger.warn("Invalid contact info - missing DID");
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Invalid Contact",
|
||||
text: "The contact DID is missing.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Create contact object
|
||||
contact = {
|
||||
did: did,
|
||||
name: contactInfo.name || "",
|
||||
publicKeyBase64: contactInfo.publicKeyBase64 || "",
|
||||
seesMe: contactInfo.seesMe || false,
|
||||
registered: contactInfo.registered || false,
|
||||
};
|
||||
} else if (rawValue.startsWith(CONTACT_CSV_HEADER)) {
|
||||
const lines = rawValue.split(/\n/);
|
||||
contact = libsUtil.csvLineToContact(lines[1]);
|
||||
} else {
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Invalid QR Code",
|
||||
text: "This QR code does not contain valid contact information. Please scan a TimeSafari contact QR code.",
|
||||
title: "Error",
|
||||
text: "Could not determine the type of contact info. Try again, or tap the QR code to copy it and send it to them.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Process JWT and contact info
|
||||
logger.info("Decoding JWT payload from QR code");
|
||||
const decodedJwt = await decodeEndorserJwt(jwt);
|
||||
if (!decodedJwt?.payload?.own) {
|
||||
logger.warn("Invalid JWT payload - missing 'own' field");
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Invalid Contact Info",
|
||||
text: "The contact information is incomplete or invalid.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const contactInfo = decodedJwt.payload.own;
|
||||
const did = contactInfo.did || decodedJwt.payload.iss;
|
||||
if (!did) {
|
||||
logger.warn("Invalid contact info - missing DID");
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Invalid Contact",
|
||||
text: "The contact DID is missing.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Create contact object
|
||||
const contact = {
|
||||
did: did,
|
||||
name: contactInfo.name || "",
|
||||
notes: contactInfo.notes || "",
|
||||
};
|
||||
|
||||
// Add contact but keep scanning
|
||||
logger.info("Adding new contact to database:", {
|
||||
did: contact.did,
|
||||
@@ -649,12 +671,20 @@ export default class ContactQRScanShow extends Vue {
|
||||
});
|
||||
}
|
||||
|
||||
onCopyUrlToClipboard() {
|
||||
//this.onScanDetect([{ rawValue: this.qrValue }]); // good for testing
|
||||
async onCopyUrlToClipboard() {
|
||||
const account = (await libsUtil.retrieveFullyDecryptedAccount(
|
||||
this.activeDid,
|
||||
)) as Account;
|
||||
const jwtUrl = await generateEndorserJwtUrlForAccount(
|
||||
account,
|
||||
this.isRegistered,
|
||||
this.givenName,
|
||||
this.profileImageUrl,
|
||||
true,
|
||||
);
|
||||
useClipboard()
|
||||
.copy(this.qrValue)
|
||||
.copy(jwtUrl)
|
||||
.then(() => {
|
||||
// console.log("Contact URL:", this.qrValue);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
@@ -772,7 +802,7 @@ export default class ContactQRScanShow extends Vue {
|
||||
title: "Contact Exists",
|
||||
text: "This contact has already been added to your list.",
|
||||
},
|
||||
3000,
|
||||
5000,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -126,7 +126,6 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
v-if="showGiveNumbers"
|
||||
href=""
|
||||
class="text-md bg-gradient-to-b shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
|
||||
:class="showGiveAmountsClassNames()"
|
||||
@click="toggleShowGiveTotals()"
|
||||
@@ -142,7 +141,6 @@
|
||||
</button>
|
||||
|
||||
<button
|
||||
href=""
|
||||
class="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-3 py-1.5 rounded-md"
|
||||
@click="toggleShowContactAmounts()"
|
||||
>
|
||||
@@ -493,7 +491,7 @@ export default class ContactsView extends Vue {
|
||||
private async processContactJwt() {
|
||||
// handle a contact sent via URL
|
||||
//
|
||||
// For external links, use /contact-import/:jwt with a JWT that has an array of contacts
|
||||
// For external links, use /deep-link/contact-import/:jwt with a JWT that has an array of contacts
|
||||
// because that will do better error checking for things like missing data on iOS platforms.
|
||||
const importedContactJwt = this.$route.query["contactJwt"] as string;
|
||||
if (importedContactJwt) {
|
||||
@@ -619,7 +617,7 @@ export default class ContactsView extends Vue {
|
||||
title: "Error with Invite",
|
||||
text: message,
|
||||
},
|
||||
5000,
|
||||
-1,
|
||||
);
|
||||
}
|
||||
// if we're here, they haven't redirected anywhere, so we'll redirect here without a query parameter
|
||||
@@ -935,45 +933,9 @@ export default class ContactsView extends Vue {
|
||||
}
|
||||
|
||||
private async addContactFromEndorserMobileLine(
|
||||
line: string,
|
||||
lineRaw: string,
|
||||
): Promise<IndexableType> {
|
||||
// Note that Endorser Mobile puts name first, then did, etc.
|
||||
let name = line;
|
||||
let did = "";
|
||||
let publicKeyInput, seesMe, registered;
|
||||
const commaPos1 = line.indexOf(",");
|
||||
if (commaPos1 > -1) {
|
||||
name = line.substring(0, commaPos1).trim();
|
||||
did = line.substring(commaPos1 + 1).trim();
|
||||
const commaPos2 = line.indexOf(",", commaPos1 + 1);
|
||||
if (commaPos2 > -1) {
|
||||
did = line.substring(commaPos1 + 1, commaPos2).trim();
|
||||
publicKeyInput = line.substring(commaPos2 + 1).trim();
|
||||
const commaPos3 = line.indexOf(",", commaPos2 + 1);
|
||||
if (commaPos3 > -1) {
|
||||
publicKeyInput = line.substring(commaPos2 + 1, commaPos3).trim();
|
||||
seesMe = line.substring(commaPos3 + 1).trim() == "true";
|
||||
const commaPos4 = line.indexOf(",", commaPos3 + 1);
|
||||
if (commaPos4 > -1) {
|
||||
seesMe = line.substring(commaPos3 + 1, commaPos4).trim() == "true";
|
||||
registered = line.substring(commaPos4 + 1).trim() == "true";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// help with potential mistakes while this sharing requires copy-and-paste
|
||||
let publicKeyBase64 = publicKeyInput;
|
||||
if (publicKeyBase64 && /^[0-9A-Fa-f]{66}$/i.test(publicKeyBase64)) {
|
||||
// it must be all hex (compressed public key), so convert
|
||||
publicKeyBase64 = Buffer.from(publicKeyBase64, "hex").toString("base64");
|
||||
}
|
||||
const newContact = {
|
||||
did,
|
||||
name,
|
||||
publicKeyBase64,
|
||||
seesMe,
|
||||
registered,
|
||||
};
|
||||
const newContact = libsUtil.csvLineToContact(lineRaw);
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
const { sql, params } = databaseUtil.generateInsertStatement(
|
||||
newContact as unknown as Record<string, unknown>,
|
||||
@@ -1160,7 +1122,7 @@ export default class ContactsView extends Vue {
|
||||
(regResult.error as string) ||
|
||||
"Something went wrong during registration.",
|
||||
},
|
||||
5000,
|
||||
-1,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -1194,7 +1156,7 @@ export default class ContactsView extends Vue {
|
||||
title: "Registration Error",
|
||||
text: userMessage,
|
||||
},
|
||||
5000,
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1215,7 +1177,6 @@ export default class ContactsView extends Vue {
|
||||
);
|
||||
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(
|
||||
{
|
||||
@@ -1431,14 +1392,11 @@ export default class ContactsView extends Vue {
|
||||
}
|
||||
return contact;
|
||||
});
|
||||
// console.log(
|
||||
// "Array of selected contacts:",
|
||||
// JSON.stringify(selectedContacts),
|
||||
// );
|
||||
const contactsJwt = await createEndorserJwtForDid(this.activeDid, {
|
||||
contacts: selectedContacts,
|
||||
});
|
||||
const contactsJwtUrl = APP_SERVER + "/contact-import/" + contactsJwt;
|
||||
const contactsJwtUrl =
|
||||
APP_SERVER + "/deep-link/contact-import/" + contactsJwt;
|
||||
useClipboard()
|
||||
.copy(contactsJwtUrl)
|
||||
.then(() => {
|
||||
|
||||
@@ -66,9 +66,14 @@ const formattedPath = computed(() => {
|
||||
const path = originalPath.value.replace(/^\/+/, "");
|
||||
|
||||
// Log for debugging
|
||||
logger.log("Original Path:", originalPath.value);
|
||||
logger.log("Route Params:", route.params);
|
||||
logger.log("Route Query:", route.query);
|
||||
logger.log(
|
||||
"[DeepLinkError] Original Path:",
|
||||
originalPath.value,
|
||||
"Route Params:",
|
||||
route.params,
|
||||
"Route Query:",
|
||||
route.query,
|
||||
);
|
||||
|
||||
return path;
|
||||
});
|
||||
|
||||
227
src/views/DeepLinkRedirectView.vue
Normal file
227
src/views/DeepLinkRedirectView.vue
Normal file
@@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="relative w-[100vw] h-[100vh]">
|
||||
<div
|
||||
class="p-6 bg-white w-full max-w-[calc((100vh-env(safe-area-inset-top)-env(safe-area-inset-bottom))*0.4)] mx-auto"
|
||||
>
|
||||
<div class="mb-4">
|
||||
<h1 class="text-xl text-center font-semibold relative mb-4">
|
||||
Redirecting to Time Safari
|
||||
</h1>
|
||||
|
||||
<div v-if="destinationUrl" class="space-y-4">
|
||||
<!-- Platform-specific messaging -->
|
||||
<div class="text-center text-gray-600 mb-4">
|
||||
<p v-if="isMobile">
|
||||
{{
|
||||
isIOS
|
||||
? "Opening Time Safari app on your iPhone..."
|
||||
: "Opening Time Safari app on your Android device..."
|
||||
}}
|
||||
</p>
|
||||
<p v-else>Opening Time Safari app...</p>
|
||||
<p class="text-sm mt-2">
|
||||
<span v-if="isMobile"
|
||||
>If the app doesn't open automatically, use one of these
|
||||
options:</span
|
||||
>
|
||||
<span v-else>Choose how you'd like to open this link:</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Deep Link Button -->
|
||||
<div class="text-center">
|
||||
<a
|
||||
:href="deepLinkUrl || '#'"
|
||||
class="inline-block bg-blue-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-blue-700 transition-colors"
|
||||
@click="handleDeepLinkClick"
|
||||
>
|
||||
<span v-if="isMobile">Open in Time Safari App</span>
|
||||
<span v-else>Try Opening in Time Safari App</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Web Fallback Link -->
|
||||
<div class="text-center">
|
||||
<a
|
||||
:href="webUrl || '#'"
|
||||
target="_blank"
|
||||
class="inline-block bg-gray-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-gray-700 transition-colors"
|
||||
@click="handleWebFallbackClick"
|
||||
>
|
||||
<span v-if="isMobile">Open in Web Browser Instead</span>
|
||||
<span v-else>Open in Web Browser</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Manual Instructions -->
|
||||
<div class="text-center text-sm text-gray-500 mt-4">
|
||||
<p v-if="isMobile">
|
||||
Or manually open:
|
||||
<code class="bg-gray-100 px-2 py-1 rounded">{{
|
||||
deepLinkUrl
|
||||
}}</code>
|
||||
</p>
|
||||
<p v-else>
|
||||
If you have the Time Safari app installed, you can also copy this
|
||||
link:
|
||||
<code class="bg-gray-100 px-2 py-1 rounded">{{
|
||||
deepLinkUrl
|
||||
}}</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Platform info for debugging -->
|
||||
<div
|
||||
v-if="isDevelopment"
|
||||
class="text-center text-xs text-gray-400 mt-4"
|
||||
>
|
||||
<p>
|
||||
Platform: {{ isMobile ? (isIOS ? "iOS" : "Android") : "Desktop" }}
|
||||
</p>
|
||||
<p>User Agent: {{ userAgent.substring(0, 50) }}...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="pageError" class="text-center text-red-500 mb-4">
|
||||
{{ pageError }}
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center text-gray-600">
|
||||
<p>Processing redirect...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
|
||||
|
||||
import { APP_SERVER } from "@/constants/app";
|
||||
import { logger } from "@/utils/logger";
|
||||
import { errorStringForLog } from "@/libs/endorserServer";
|
||||
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
|
||||
|
||||
@Component({})
|
||||
export default class DeepLinkRedirectView extends Vue {
|
||||
$router!: Router;
|
||||
$route!: RouteLocationNormalizedLoaded;
|
||||
pageError: string | null = null;
|
||||
destinationUrl: string | null = null; // full path after "/deep-link/"
|
||||
deepLinkUrl: string | null = null; // mobile link starting "timesafari://"
|
||||
webUrl: string | null = null; // web link, eg "https://timesafari.app/..."
|
||||
isDevelopment: boolean = false;
|
||||
userAgent: string = "";
|
||||
private platformService = PlatformServiceFactory.getInstance();
|
||||
|
||||
mounted() {
|
||||
// Get the path from the route parameter (catch-all parameter)
|
||||
const pathParam = this.$route.params.path;
|
||||
|
||||
// If pathParam is an array (catch-all parameter), join it
|
||||
const fullPath = Array.isArray(pathParam) ? pathParam.join("/") : pathParam;
|
||||
|
||||
// Get query parameters from the route
|
||||
const queryParams = this.$route.query;
|
||||
|
||||
// Build query string if there are query parameters
|
||||
let queryString = "";
|
||||
if (Object.keys(queryParams).length > 0) {
|
||||
const searchParams = new URLSearchParams();
|
||||
Object.entries(queryParams).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
const stringValue = Array.isArray(value) ? value[0] : value;
|
||||
if (stringValue !== null && stringValue !== undefined) {
|
||||
searchParams.append(key, stringValue);
|
||||
}
|
||||
}
|
||||
});
|
||||
queryString = "?" + searchParams.toString();
|
||||
}
|
||||
|
||||
// Combine path with query parameters
|
||||
const fullPathWithQuery = fullPath + queryString;
|
||||
|
||||
this.destinationUrl = fullPathWithQuery;
|
||||
this.deepLinkUrl = `timesafari://${fullPathWithQuery}`;
|
||||
this.webUrl = `${APP_SERVER}/${fullPathWithQuery}`;
|
||||
|
||||
this.isDevelopment = process.env.NODE_ENV !== "production";
|
||||
this.userAgent = navigator.userAgent;
|
||||
|
||||
this.openDeepLink();
|
||||
}
|
||||
|
||||
private openDeepLink() {
|
||||
if (!this.deepLinkUrl || !this.webUrl) {
|
||||
this.pageError =
|
||||
"No deep link was provided. Check the URL and try again.";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// For mobile, try the deep link URL; for desktop, use the web URL
|
||||
const redirectUrl = this.isMobile ? this.deepLinkUrl : this.webUrl;
|
||||
|
||||
// Method 1: Try window.location.href (works on most browsers)
|
||||
window.location.href = redirectUrl;
|
||||
|
||||
// Method 2: Fallback - create and click a link element
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const link = document.createElement("a");
|
||||
link.href = redirectUrl;
|
||||
link.style.display = "none";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"Fallback deep link failed: " + errorStringForLog(error),
|
||||
);
|
||||
this.pageError =
|
||||
"Redirecting to the Time Safari app failed. Please use a manual option below.";
|
||||
}
|
||||
}, 100);
|
||||
} catch (error) {
|
||||
logger.error("Deep link redirect failed: " + errorStringForLog(error));
|
||||
this.pageError =
|
||||
"Unable to open the Time Safari app. Please use a manual option below.";
|
||||
}
|
||||
}
|
||||
|
||||
private handleDeepLinkClick(event: Event) {
|
||||
if (!this.deepLinkUrl) return;
|
||||
|
||||
// Prevent default to handle the click manually
|
||||
event.preventDefault();
|
||||
|
||||
this.openDeepLink();
|
||||
}
|
||||
|
||||
private handleWebFallbackClick(event: Event) {
|
||||
if (!this.webUrl) return;
|
||||
|
||||
// Get platform capabilities
|
||||
const capabilities = this.platformService.getCapabilities();
|
||||
|
||||
// For mobile, try to open in a new tab/window
|
||||
if (capabilities.isMobile) {
|
||||
event.preventDefault();
|
||||
window.open(this.webUrl, "_blank");
|
||||
}
|
||||
// For desktop, let the default behavior happen (opens in same tab)
|
||||
}
|
||||
|
||||
// Computed properties for template
|
||||
get isMobile(): boolean {
|
||||
return this.platformService.getCapabilities().isMobile;
|
||||
}
|
||||
|
||||
get isIOS(): boolean {
|
||||
return this.platformService.getCapabilities().isIOS;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -523,9 +523,7 @@ export default class DiscoverView extends Vue {
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (e: any) {
|
||||
logger.error("Error with search all:", e);
|
||||
// this sometimes gives different information
|
||||
logger.error("Error with search all (error added): " + e);
|
||||
logger.error("Error with search all: " + errorStringForLog(e));
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
@@ -617,7 +615,7 @@ export default class DiscoverView extends Vue {
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (e: any) {
|
||||
logger.error("Error with search local:", e);
|
||||
logger.error("Error with search local: " + errorStringForLog(e));
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
@@ -788,7 +786,7 @@ export default class DiscoverView extends Vue {
|
||||
const route = {
|
||||
path: this.isProjectsActive
|
||||
? "/project/" + encodeURIComponent(id)
|
||||
: "/userProfile/" + encodeURIComponent(id),
|
||||
: "/user-profile/" + encodeURIComponent(id),
|
||||
};
|
||||
this.$router.push(route);
|
||||
}
|
||||
|
||||
@@ -826,7 +826,7 @@ export default class GiftedDetails extends Vue {
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
const errorMessage = this.getGiveCreationErrorMessage(result);
|
||||
const errorMessage = result.error;
|
||||
logger.error("Error with give creation result:", result);
|
||||
this.$notify(
|
||||
{
|
||||
@@ -899,19 +899,6 @@ export default class GiftedDetails extends Vue {
|
||||
|
||||
// Helper functions for readability
|
||||
|
||||
/**
|
||||
* @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(
|
||||
{
|
||||
|
||||
@@ -24,11 +24,11 @@
|
||||
<!-- eslint-disable prettier/prettier max-len -->
|
||||
<div>
|
||||
<p>
|
||||
This app focuses on gifts & gratitude, using them to build cool things together with your network.
|
||||
This app focuses on raw gratitude, using it to build cool things together with your network.
|
||||
</p>
|
||||
|
||||
<p class="ml-4">
|
||||
If you'd like to see the page-by-page help,
|
||||
If you'd like to see the page-by-page help again,
|
||||
<span
|
||||
class="text-blue-500 cursor-pointer"
|
||||
@click="unsetFinishedOnboarding()"
|
||||
@@ -37,14 +37,16 @@
|
||||
|
||||
<h2 class="text-xl font-semibold">What is the idea here?</h2>
|
||||
<p>
|
||||
We are building networks of people who want to grow good society from the ground up, using modern
|
||||
technology that connects people peer-to-peer.
|
||||
First of all, let's showcase gratitude: see what people have given, and recognize
|
||||
gifts you've seen. This is done in a way that leaves a permanent record -- one that
|
||||
came from you, and one that the recipient can prove it was for them. This can be
|
||||
personally gratifying, but it extends to broader work: volunteers get
|
||||
confirmation of activity, and they can selectively show off their contributions
|
||||
and network.
|
||||
We are building networks of people who want to grow good society from the ground up, using
|
||||
modern technology that connects people peer-to-peer.
|
||||
First of all, let's showcase gratitude: see what people have given, and recognize gifts
|
||||
you've seen. This is done in a way that leaves a permanent record -- one that provably
|
||||
came from you, and one that the recipient can prove they were mentioned.
|
||||
This can be personally gratifying, but it extends to broader work: volunteers get
|
||||
confirmation of activity, and they can selectively show off their contributions and
|
||||
network.
|
||||
This is a way to build trust and reputation. It's a way to build a network of people who
|
||||
are willing to help each other.
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
With this, you highlight giving and you also offer help --
|
||||
@@ -555,9 +557,6 @@
|
||||
initiative.
|
||||
</p>
|
||||
|
||||
<h2 class="text-xl font-semibold">What app version is this?</h2>
|
||||
<p>{{ package.version }} ({{ commitHash }})</p>
|
||||
|
||||
<h2 class="text-xl font-semibold">
|
||||
I have other questions or feedback, like getting a new profile or removing my data or requesting an improvement.
|
||||
</h2>
|
||||
@@ -567,6 +566,28 @@
|
||||
>info@TimeSafari.app</a
|
||||
>
|
||||
</p>
|
||||
|
||||
<h2 class="text-xl font-semibold">What app version is this?</h2>
|
||||
<p>{{ package.version }} ({{ commitHash }})</p>
|
||||
|
||||
<div v-if="Capacitor.isNativePlatform()">
|
||||
<h2 class="text-xl font-semibold">
|
||||
Do I have the latest version?
|
||||
</h2>
|
||||
<p v-if="Capacitor.getPlatform() === 'ios'">
|
||||
<a href="https://apps.apple.com/us/app/time-safari/id6742664907" target="_blank" class="text-blue-500">
|
||||
Check the App Store.
|
||||
</a>
|
||||
</p>
|
||||
<p v-else-if="Capacitor.getPlatform() === 'android'">
|
||||
<a href="https://timesafari.app/app.apk" target="_blank" class="text-blue-500">
|
||||
Download the latest APK to see.
|
||||
</a>
|
||||
</p>
|
||||
<p v-else>
|
||||
Sorry, your platform of '{{ Capacitor.getPlatform() }}' is not recognized.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- eslint enable -->
|
||||
</section>
|
||||
@@ -603,6 +624,7 @@ export default class HelpView extends Vue {
|
||||
showVerifiable = false;
|
||||
|
||||
APP_SERVER = APP_SERVER;
|
||||
Capacitor = Capacitor;
|
||||
|
||||
// Ideally, we put no functionality in here, especially in the setup,
|
||||
// because we never want this page to have a chance of throwing an error.
|
||||
|
||||
@@ -519,7 +519,6 @@ export default class HomeView extends Vue {
|
||||
// Retrieve DIDs with better error handling
|
||||
try {
|
||||
this.allMyDids = await retrieveAccountDids();
|
||||
logConsoleAndDb(`[HomeView] Retrieved ${this.allMyDids.length} DIDs`);
|
||||
} catch (error) {
|
||||
logConsoleAndDb(`[HomeView] Failed to retrieve DIDs: ${error}`, true);
|
||||
throw new Error(
|
||||
@@ -552,9 +551,6 @@ export default class HomeView extends Vue {
|
||||
if (USE_DEXIE_DB) {
|
||||
settings = await retrieveSettingsForActiveAccount();
|
||||
}
|
||||
logConsoleAndDb(
|
||||
`[HomeView] Retrieved settings for ${settings.activeDid || "no active DID"}`,
|
||||
);
|
||||
} catch (error) {
|
||||
logConsoleAndDb(
|
||||
`[HomeView] Failed to retrieve settings: ${error}`,
|
||||
@@ -581,9 +577,6 @@ export default class HomeView extends Vue {
|
||||
if (USE_DEXIE_DB) {
|
||||
this.allContacts = await db.contacts.toArray();
|
||||
}
|
||||
logConsoleAndDb(
|
||||
`[HomeView] Retrieved ${this.allContacts.length} contacts`,
|
||||
);
|
||||
} catch (error) {
|
||||
logConsoleAndDb(
|
||||
`[HomeView] Failed to retrieve contacts: ${error}`,
|
||||
@@ -641,9 +634,6 @@ export default class HomeView extends Vue {
|
||||
});
|
||||
}
|
||||
this.isRegistered = true;
|
||||
logConsoleAndDb(
|
||||
`[HomeView] User ${this.activeDid} is now registered`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logConsoleAndDb(
|
||||
@@ -685,11 +675,6 @@ export default class HomeView extends Vue {
|
||||
this.newOffersToUserHitLimit = offersToUser.hitLimit;
|
||||
this.numNewOffersToUserProjects = offersToProjects.data.length;
|
||||
this.newOffersToUserProjectsHitLimit = offersToProjects.hitLimit;
|
||||
|
||||
logConsoleAndDb(
|
||||
`[HomeView] Retrieved ${this.numNewOffersToUser} user offers and ` +
|
||||
`${this.numNewOffersToUserProjects} project offers`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logConsoleAndDb(
|
||||
@@ -1843,7 +1828,7 @@ export default class HomeView extends Vue {
|
||||
this.axios,
|
||||
);
|
||||
|
||||
if (result.type === "success") {
|
||||
if (result.success) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
<span
|
||||
v-else
|
||||
class="text-center text-slate-500 cursor-pointer"
|
||||
:title="inviteLink(invite.jwt)"
|
||||
:title="invite.inviteIdentifier"
|
||||
@click="
|
||||
showInvite(
|
||||
invite.inviteIdentifier,
|
||||
@@ -241,7 +241,7 @@ export default class InviteOneView extends Vue {
|
||||
}
|
||||
|
||||
inviteLink(jwt: string): string {
|
||||
return APP_SERVER + "/invite-one-accept/" + jwt;
|
||||
return APP_SERVER + "/deep-link/invite-one-accept/" + jwt;
|
||||
}
|
||||
|
||||
copyInviteAndNotify(inviteId: string, jwt: string) {
|
||||
@@ -324,7 +324,7 @@ export default class InviteOneView extends Vue {
|
||||
);
|
||||
await axios.post(
|
||||
this.apiServer + "/api/userUtil/invite",
|
||||
{ inviteIdentifier, inviteJwt, notes, expiresAt },
|
||||
{ inviteJwt, notes, expiresAt },
|
||||
{ headers },
|
||||
);
|
||||
const newInvite = {
|
||||
|
||||
@@ -720,7 +720,7 @@ export default class OnboardMeetingView extends Vue {
|
||||
|
||||
onboardMeetingMembersLink(): string {
|
||||
if (this.currentMeeting) {
|
||||
return `${APP_SERVER}/onboard-meeting-members/${this.currentMeeting?.groupId}?password=${encodeURIComponent(
|
||||
return `${APP_SERVER}/deep-link/onboard-meeting-members/${this.currentMeeting?.groupId}?password=${encodeURIComponent(
|
||||
this.currentMeeting?.password || "",
|
||||
)}`;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,12 @@
|
||||
>
|
||||
<font-awesome icon="pen" class="text-sm text-blue-500 ml-2 mb-1" />
|
||||
</button>
|
||||
<button title="Copy Link to Project" @click="onCopyLinkClick()">
|
||||
<font-awesome
|
||||
icon="link"
|
||||
class="text-sm text-slate-500 ml-2 mb-1"
|
||||
/>
|
||||
</button>
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
@@ -52,16 +58,28 @@
|
||||
icon="user"
|
||||
class="fa-fw text-slate-400"
|
||||
></font-awesome>
|
||||
{{ issuerInfoObject?.displayName }}
|
||||
<span v-if="!serverUtil.isEmptyOrHiddenDid(issuer)">
|
||||
<a :href="`/did/${issuer}`" class="text-blue-500">
|
||||
<span class="truncate inline-block max-w-[calc(100%-2rem)]">
|
||||
{{ issuerInfoObject?.displayName }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="!serverUtil.isHiddenDid(issuer)"
|
||||
class="inline-flex items-center"
|
||||
>
|
||||
<router-link
|
||||
:to="{
|
||||
path: '/did/' + encodeURIComponent(issuer),
|
||||
}"
|
||||
class="text-blue-500 ml-1"
|
||||
title="See more about this person"
|
||||
>
|
||||
<font-awesome
|
||||
icon="arrow-up-right-from-square"
|
||||
class="fa-fw"
|
||||
/>
|
||||
</a>
|
||||
</router-link>
|
||||
</span>
|
||||
<span v-else-if="serverUtil.isHiddenDid(issuer)">
|
||||
<span v-if="serverUtil.isHiddenDid(issuer)" class="ml-1">
|
||||
<font-awesome
|
||||
icon="info-circle"
|
||||
class="fa-fw text-blue-500 cursor-pointer"
|
||||
@@ -105,7 +123,7 @@
|
||||
class="fa-fw text-slate-400"
|
||||
></font-awesome>
|
||||
<a
|
||||
:href="addScheme(url)"
|
||||
:href="ensureScheme(url)"
|
||||
target="_blank"
|
||||
class="underline text-blue-500"
|
||||
>
|
||||
@@ -624,7 +642,7 @@ import TopMessage from "../components/TopMessage.vue";
|
||||
import QuickNav from "../components/QuickNav.vue";
|
||||
import EntityIcon from "../components/EntityIcon.vue";
|
||||
import ProjectIcon from "../components/ProjectIcon.vue";
|
||||
import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
|
||||
import { APP_SERVER, NotificationIface, USE_DEXIE_DB } from "../constants/app";
|
||||
import * as databaseUtil from "../db/databaseUtil";
|
||||
import {
|
||||
db,
|
||||
@@ -638,6 +656,7 @@ import { retrieveAccountDids } from "../libs/util";
|
||||
import HiddenDidDialog from "../components/HiddenDidDialog.vue";
|
||||
import { logger } from "../utils/logger";
|
||||
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
/**
|
||||
* Project View Component
|
||||
* @author Matthew Raymer
|
||||
@@ -834,6 +853,28 @@ export default class ProjectViewView extends Vue {
|
||||
});
|
||||
}
|
||||
|
||||
onCopyLinkClick() {
|
||||
const shortestProjectId = this.projectId.startsWith(
|
||||
serverUtil.ENDORSER_CH_HANDLE_PREFIX,
|
||||
)
|
||||
? this.projectId.substring(serverUtil.ENDORSER_CH_HANDLE_PREFIX.length)
|
||||
: this.projectId;
|
||||
const deepLink = `${APP_SERVER}/deep-link/project/${shortestProjectId}`;
|
||||
useClipboard()
|
||||
.copy(deepLink)
|
||||
.then(() => {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "toast",
|
||||
title: "Copied",
|
||||
text: "A link to this project was copied to the clipboard.",
|
||||
},
|
||||
2000,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Isn't there a better way to make this available to the template?
|
||||
expandText() {
|
||||
this.expanded = true;
|
||||
@@ -1296,7 +1337,7 @@ export default class ProjectViewView extends Vue {
|
||||
}
|
||||
|
||||
// return an HTTPS URL if it's not a global URL
|
||||
addScheme(url: string) {
|
||||
ensureScheme(url: string) {
|
||||
if (!libsUtil.isGlobalUri(url)) {
|
||||
return "https://" + url;
|
||||
}
|
||||
@@ -1425,7 +1466,7 @@ export default class ProjectViewView extends Vue {
|
||||
this.apiServer,
|
||||
this.axios,
|
||||
);
|
||||
if (result.type === "success") {
|
||||
if (result.success) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
@@ -1457,7 +1498,13 @@ export default class ProjectViewView extends Vue {
|
||||
}
|
||||
|
||||
openHiddenDidDialog() {
|
||||
const shortestProjectId = this.projectId.startsWith(
|
||||
serverUtil.ENDORSER_CH_HANDLE_PREFIX,
|
||||
)
|
||||
? this.projectId.substring(serverUtil.ENDORSER_CH_HANDLE_PREFIX.length)
|
||||
: this.projectId;
|
||||
(this.$refs.hiddenDidDialog as HiddenDidDialog).open(
|
||||
"project/" + shortestProjectId,
|
||||
"creator",
|
||||
this.issuerVisibleToDids,
|
||||
this.allContacts,
|
||||
|
||||
@@ -155,7 +155,7 @@ import { Contact } from "../db/tables/contacts";
|
||||
import {
|
||||
GenericCredWrapper,
|
||||
GenericVerifiableCredential,
|
||||
ErrorResult,
|
||||
CreateAndSubmitClaimResult,
|
||||
} from "../interfaces";
|
||||
import {
|
||||
BVC_MEETUPS_PROJECT_CLAIM_ID,
|
||||
@@ -298,28 +298,29 @@ export default class QuickActionBvcBeginView extends Vue {
|
||||
}
|
||||
|
||||
// in parallel, make a confirmation for each selected claim and send them all to the server
|
||||
const confirmResults = await Promise.allSettled(
|
||||
this.claimsToConfirmSelected.map(async (jwtId) => {
|
||||
const record = this.claimsToConfirm.find(
|
||||
(claim) => claim.id === jwtId,
|
||||
);
|
||||
if (!record) {
|
||||
return { type: "error", error: "Record not found." };
|
||||
}
|
||||
return createAndSubmitConfirmation(
|
||||
this.activeDid,
|
||||
record.claim as GenericVerifiableCredential,
|
||||
record.id,
|
||||
record.handleId,
|
||||
this.apiServer,
|
||||
axios,
|
||||
);
|
||||
}),
|
||||
);
|
||||
const confirmResults: PromiseSettledResult<CreateAndSubmitClaimResult>[] =
|
||||
await Promise.allSettled(
|
||||
this.claimsToConfirmSelected.map(async (jwtId) => {
|
||||
const record = this.claimsToConfirm.find(
|
||||
(claim) => claim.id === jwtId,
|
||||
);
|
||||
if (!record) {
|
||||
return { success: false, error: "Record not found." };
|
||||
}
|
||||
return createAndSubmitConfirmation(
|
||||
this.activeDid,
|
||||
record.claim as GenericVerifiableCredential,
|
||||
record.id,
|
||||
record.handleId,
|
||||
this.apiServer,
|
||||
axios,
|
||||
);
|
||||
}),
|
||||
);
|
||||
// check for any rejected confirmations
|
||||
const confirmsSucceeded = confirmResults.filter(
|
||||
(result) =>
|
||||
result.status === "fulfilled" && result.value.type === "success",
|
||||
// 'fulfilled' is the status in a successful PromiseFulfilledResult
|
||||
(result) => result.status === "fulfilled" && result.value.success,
|
||||
);
|
||||
if (confirmsSucceeded.length < this.claimsToConfirmSelected.length) {
|
||||
logger.error("Error sending confirmations:", confirmResults);
|
||||
@@ -353,7 +354,7 @@ export default class QuickActionBvcBeginView extends Vue {
|
||||
undefined,
|
||||
BVC_MEETUPS_PROJECT_CLAIM_ID,
|
||||
);
|
||||
giveSucceeded = giveResult.type === "success";
|
||||
giveSucceeded = giveResult.success;
|
||||
if (!giveSucceeded) {
|
||||
logger.error("Error sending give:", giveResult);
|
||||
this.$notify(
|
||||
@@ -362,7 +363,7 @@ export default class QuickActionBvcBeginView extends Vue {
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text:
|
||||
(giveResult as ErrorResult)?.error?.userMessage ||
|
||||
(giveResult as CreateAndSubmitClaimResult)?.error ||
|
||||
"There was an error sending that give.",
|
||||
},
|
||||
5000,
|
||||
|
||||
@@ -105,7 +105,7 @@ export default class ShareMyContactInfoView extends Vue {
|
||||
group: "alert",
|
||||
type: "info",
|
||||
title: "Copied",
|
||||
text: "Your contact info was copied to the clipboard. Have them paste it in the box on their 'Contacts' screen.",
|
||||
text: "Your contact info was copied to the clipboard. Have them click on it, or paste it in the box on their 'Contacts' screen.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
|
||||
@@ -215,65 +215,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<h2 class="text-xl font-bold mb-4">File Sharing Test</h2>
|
||||
Test the new file sharing functionality that saves to user-accessible
|
||||
locations.
|
||||
<div>
|
||||
<button
|
||||
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
|
||||
@click="testFileSharing()"
|
||||
>
|
||||
Test File Sharing
|
||||
</button>
|
||||
<button
|
||||
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
|
||||
@click="testFileSaveOnly()"
|
||||
>
|
||||
Test Save Only (No Share Dialog)
|
||||
</button>
|
||||
<button
|
||||
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
|
||||
@click="testLocationSelection()"
|
||||
>
|
||||
Test Location Selection
|
||||
</button>
|
||||
<button
|
||||
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
|
||||
@click="testLocationSelectionSilent()"
|
||||
>
|
||||
Test Silent Location Selection
|
||||
</button>
|
||||
<button
|
||||
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
|
||||
@click="testListUserFiles()"
|
||||
>
|
||||
List User Files
|
||||
</button>
|
||||
<button
|
||||
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
|
||||
@click="testBackupFiles()"
|
||||
>
|
||||
Test Backup Files
|
||||
</button>
|
||||
<button
|
||||
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
|
||||
@click="testOpenBackupDirectory()"
|
||||
>
|
||||
Test Open Directory
|
||||
</button>
|
||||
<button
|
||||
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
|
||||
@click="testFileDiscoveryDebug()"
|
||||
>
|
||||
Debug File Discovery
|
||||
</button>
|
||||
<div v-if="fileSharingResult" class="mt-2 p-2 bg-gray-100 rounded">
|
||||
<strong>Result:</strong> {{ fileSharingResult }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<h2 class="text-xl font-bold mb-4">Image Sharing</h2>
|
||||
Populates the "shared-photo" view as if they used "share_target".
|
||||
@@ -446,9 +387,6 @@ export default class Help extends Vue {
|
||||
sqlQuery = "";
|
||||
sqlResult: unknown = null;
|
||||
|
||||
// for file sharing test
|
||||
fileSharingResult = "";
|
||||
|
||||
cryptoLib = cryptoLib;
|
||||
|
||||
async mounted() {
|
||||
@@ -682,174 +620,5 @@ export default class Help extends Vue {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async testFileSharing() {
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
try {
|
||||
const result = await platformService.testFileSharing();
|
||||
this.fileSharingResult = result;
|
||||
logger.log("File Sharing Test Result:", this.fileSharingResult);
|
||||
} catch (error) {
|
||||
logger.error("File Sharing Test Error:", error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "File Sharing Error",
|
||||
text: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async testFileSaveOnly() {
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
try {
|
||||
const result = await platformService.testFileSaveOnly();
|
||||
this.fileSharingResult = result;
|
||||
logger.log("File Save Only Test Result:", this.fileSharingResult);
|
||||
} catch (error) {
|
||||
logger.error("File Save Only Test Error:", error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "File Save Only Error",
|
||||
text: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async testLocationSelection() {
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
try {
|
||||
const result = await platformService.testLocationSelection();
|
||||
this.fileSharingResult = result;
|
||||
logger.log("Location Selection Test Result:", this.fileSharingResult);
|
||||
} catch (error) {
|
||||
logger.error("Location Selection Test Error:", error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Location Selection Error",
|
||||
text: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async testLocationSelectionSilent() {
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
try {
|
||||
const result = await platformService.testLocationSelectionSilent();
|
||||
this.fileSharingResult = result;
|
||||
logger.log(
|
||||
"Silent Location Selection Test Result:",
|
||||
this.fileSharingResult,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Silent Location Selection Test Error:", error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Silent Location Selection Error",
|
||||
text: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async testListUserFiles() {
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
try {
|
||||
const result = await platformService.testListUserFiles();
|
||||
this.fileSharingResult = result;
|
||||
logger.log("List User Files Test Result:", this.fileSharingResult);
|
||||
} catch (error) {
|
||||
logger.error("List User Files Test Error:", error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "List User Files Error",
|
||||
text: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async testBackupFiles() {
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
try {
|
||||
const result = await platformService.testBackupFiles();
|
||||
this.fileSharingResult = result;
|
||||
logger.log("Backup Files Test Result:", this.fileSharingResult);
|
||||
} catch (error) {
|
||||
logger.error("Backup Files Test Error:", error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Backup Files Error",
|
||||
text: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async testOpenBackupDirectory() {
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
try {
|
||||
const result = await platformService.testOpenBackupDirectory();
|
||||
this.fileSharingResult = result;
|
||||
logger.log("Open Backup Directory Test Result:", this.fileSharingResult);
|
||||
} catch (error) {
|
||||
logger.error("Open Backup Directory Test Error:", error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Open Backup Directory Error",
|
||||
text: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async testFileDiscoveryDebug() {
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
try {
|
||||
if ("debugFileDiscoveryStepByStep" in platformService) {
|
||||
const result = await (
|
||||
platformService as any
|
||||
).debugFileDiscoveryStepByStep();
|
||||
this.fileSharingResult = result;
|
||||
logger.log("File Discovery Debug Test Result:", this.fileSharingResult);
|
||||
} else {
|
||||
this.fileSharingResult = "Debug method not available on this platform";
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("File Discovery Debug Test Error:", error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "File Discovery Debug Error",
|
||||
text: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
</button>
|
||||
Individual Profile
|
||||
</h1>
|
||||
<div class="text-sm text-center text-slate-500"></div>
|
||||
</div>
|
||||
|
||||
<!-- Loading Animation -->
|
||||
@@ -32,6 +33,12 @@
|
||||
<div class="text-sm">
|
||||
<font-awesome icon="user" class="fa-fw text-slate-400"></font-awesome>
|
||||
{{ didInfo(profile.issuerDid, activeDid, allMyDids, allContacts) }}
|
||||
<button title="Copy Link to Profile" @click="onCopyLinkClick()">
|
||||
<font-awesome
|
||||
icon="link"
|
||||
class="text-sm text-slate-500 ml-2 mb-1"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="profile.description" class="mt-4 text-slate-600">
|
||||
{{ profile.description }}
|
||||
@@ -100,6 +107,7 @@ import { Router, RouteLocationNormalizedLoaded } from "vue-router";
|
||||
import QuickNav from "../components/QuickNav.vue";
|
||||
import TopMessage from "../components/TopMessage.vue";
|
||||
import {
|
||||
APP_SERVER,
|
||||
DEFAULT_PARTNER_API_SERVER,
|
||||
NotificationIface,
|
||||
USE_DEXIE_DB,
|
||||
@@ -113,6 +121,7 @@ import { retrieveAccountDids } from "../libs/util";
|
||||
import { logger } from "../utils/logger";
|
||||
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
|
||||
import { Settings } from "@/db/tables/settings";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
@Component({
|
||||
components: {
|
||||
LMap,
|
||||
@@ -186,6 +195,10 @@ export default class UserProfileView extends Vue {
|
||||
if (response.status === 200) {
|
||||
const result = await response.json();
|
||||
this.profile = result.data;
|
||||
if (this.profile && this.profile.rowId !== profileId) {
|
||||
// currently the server returns "rowid" with lowercase "i"; remove when that's fixed
|
||||
this.profile.rowId = profileId;
|
||||
}
|
||||
} else {
|
||||
throw new Error("Failed to load profile");
|
||||
}
|
||||
@@ -204,5 +217,22 @@ export default class UserProfileView extends Vue {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onCopyLinkClick() {
|
||||
const deepLink = `${APP_SERVER}/deep-link/user-profile/${this.profile?.rowId}`;
|
||||
useClipboard()
|
||||
.copy(deepLink)
|
||||
.then(() => {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "toast",
|
||||
title: "Copied",
|
||||
text: "A link to this profile was copied to the clipboard.",
|
||||
},
|
||||
2000,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user