Compare commits

..

2 Commits

Author SHA1 Message Date
Matthew Raymer
5ee4a7e411 refactor: centralize platform-specific behavior in platform services
- Add platform-specific capability methods to PlatformService interface:
  - getExportInstructions()
  - getExportSuccessMessage()
  - needsSecondaryDownloadLink()
  - needsDownloadCleanup()

- Update platform service implementations:
  - WebPlatformService: Implement web-specific export behavior
  - CapacitorPlatformService: Implement mobile-specific export behavior
  - ElectronPlatformService: Add placeholder for export functionality
  - PyWebViewPlatformService: Add placeholder for export functionality

- Refactor DataExportSection component:
  - Remove direct platform checks (isWeb, isCapacitor, etc.)
  - Use platform service capabilities for UI behavior
  - Improve error handling and logging
  - Add proper cleanup for web platform downloads

- Update PlatformServiceFactory:
  - Make getInstance() async to support dynamic imports
  - Improve platform service initialization

- Fix code style and documentation:
  - Update JSDoc comments
  - Fix string quotes consistency
  - Add proper error handling
  - Improve logging messages

- Update Vite config:
  - Add all Capacitor dependencies to external list
  - Ensure consistent handling across platforms
2025-04-08 08:06:00 +00:00
Matthew Raymer
1c8528fb20 feat: Improve database export with native sharing on mobile
- Enhance CapacitorPlatformService to use native share sheet via Web Share API
- Add fallback to Capacitor Share API with temporary file storage
- Implement WebPlatformService export with direct download for desktop
- Clean up temporary files and URLs after sharing/download

The changes provide a more platform-appropriate experience:
- Mobile: Uses system share sheet for flexible saving/sharing options
- Desktop: Direct download to user's download folder
2025-04-08 04:23:52 +00:00
204 changed files with 12367 additions and 14765 deletions

View File

@@ -1,8 +0,0 @@
---
description: General project rules that applies to all file types. Should be most general
globs: **/*
---
- Naming Conventions: Follow clear and consistent naming conventions.
- Performance Optimization: Optimize code for performance.
- Key Conventions: Adhere to project-specific key conventions.
- Error Handling and Validation: implement comprehensive error handling and validation.

View File

@@ -1,7 +0,0 @@
---
description: Applies general TypeScript best practices and style guidelines to all TypeScript files in the project.
globs: **/*.ts
---
- You are an expert in TypeScript.
- TypeScript Usage: Follow TypeScript best practices for type safety and code maintainability.
- Syntax and Formatting: Adhere to consistent coding style and formatting guidelines for TypeScript.

View File

@@ -1,9 +0,0 @@
---
description:
globs:
alwaysApply: false
---
- make reports chronologically in paragraph form without using pronouns or references to people
- use this git command to make a report: git log --since="12 hours ago" --pretty=format:"%H - %s (%an)" -p --color --all > output.txt
- the output.txt is the basis of work in the last 12 hours
- reports should always include pending issues and next steps along with urls to commits that day

View File

@@ -1,6 +0,0 @@
---
description: Apply Tailwind CSS styling conventions in all relevant files.
globs: **/*.tsx
---
- You are an expert in Tailwind.
- UI and Styling: Use Tailwind CSS for consistent UI styling.

View File

@@ -1,270 +0,0 @@
---
description:
globs:
alwaysApply: true
---
# Time Safari Context
## Project Overview
Time Safari is an application designed to foster community building through gifts, gratitude, and collaborative projects. The app should make it extremely easy and intuitive for users of any age and capability to recognize contributions, build trust networks, and organize collective action. It is built on services that preserve privacy and data sovereignty.
The ultimate goals of Time Safari are two-fold:
1. **Connect** Make it easy, rewarding, and non-threatening for people to connect with others who have similar interests, and to initiate activities together. This helps people accomplish and learn from other individuals in less-structured environments; moreover, it helps them discover who they want to continue to support and with whom they want to maintain relationships.
2. **Reveal** Widely advertise the great support and rewards that are being given and accepted freely, especially non-monetary ones. Using visuals and text, display the kind of impact that gifts are making in the lives of others. Also show useful and engaging reports of project statistics and personal accomplishments.
## Core Approaches
Time Safari should help everyday users build meaningful connections and organize collective efforts by:
1. **Recognizing Contributions**: Creating permanent, verifiable records of gifts and contributions people give to each other and their communities.
2. **Facilitating Collaboration**: Making it ridiculously easy for people to ask for or propose help on projects and interests that matter to them.
3. **Building Trust Networks**: Enabling users to maintain their network and activity visibility. Developing reputation through verified contributions and references, which can be selectively shown to others outside the network.
4. **Preserving Privacy**: Ensuring personal identifiers are only shared with explicitly authorized contacts, allowing private individuals including children to participate safely.
5. **Engaging Content**: Displaying people's records in compelling stories, and highlighting those projects that are lifting people's lives long-term, both in physical support and in emotional-spiritual-creative thriving.
## Technical Foundation
This application is built on a privacy-preserving claims architecture (via endorser.ch) with these key characteristics:
- **Decentralized Identifiers (DIDs)**: User identities are based on public/private key pairs stored on their devices
- **Cryptographic Verification**: All claims and confirmations are cryptographically signed
- **User-Controlled Visibility**: Users explicitly control who can see their identifiers and data
- **Merkle-Chained Claims**: Claims are cryptographically chained for verification and integrity
- **Native and Web App**: Works on iOS, Android, and web browsers
## User Journey
The typical progression of usage follows these stages:
1. **Gratitude & Recognition**: Users begin by expressing and recording gratitude for gifts received, building a foundation of acknowledgment.
2. **Project Proposals**: Users propose projects and ideas, reaching out to connect with others who share similar interests.
3. **Action Triggers**: Offers of help serve as triggers and motivations to execute proposed projects, moving from ideas to action.
## Context for LLM Development
When developing new functionality for Time Safari, consider these design principles:
1. **Accessibility First**: Features should be usable by non-technical users with minimal learning curve.
2. **Privacy by Design**: All features must respect user privacy and data sovereignty.
3. **Progressive Enhancement**: Core functionality should work across all devices, with richer experiences where supported.
4. **Voluntary Collaboration**: The system should enable but never coerce participation.
5. **Trust Building**: Features should help build verifiable trust between users.
6. **Network Effects**: Consider how features scale as more users join the platform.
7. **Low Resource Requirements**: The system should be lightweight enough to run on inexpensive devices users already own.
## Use Cases to Support
LLM development should focus on enhancing these key use cases:
1. **Community Building**: Tools that help people find others with shared interests and values.
2. **Project Coordination**: Features that make it easy to propose collaborative projects and to submit suggestions and offers to existing ones.
3. **Reputation Building**: Methods for users to showcase their contributions and reliability, in contexts where they explicitly reveal that information.
4. **Governance Experimentation**: Features that facilitate decision-making and collective governance.
## Constraints
When developing new features, be mindful of these constraints:
1. **Privacy Preservation**: User identifiers must remain private except when explicitly shared.
2. **Platform Limitations**: Features must work within the constraints of the target app platforms, while aiming to leverage the best platform technology available.
3. **Endorser API Limitations**: Backend features are constrained by the endorser.ch API capabilities.
4. **Performance on Low-End Devices**: The application should remain performant on older/simpler devices.
5. **Offline-First When Possible**: Key functionality should work offline when feasible.
## Project Technologies
- Typescript using ES6 classes
- TailwindCSS
- Vite Build Tool
- Playwright E2E testing
- IndexDB
- Camera, Image uploads, QR Code reader, ...
## Mobile Features
- Deep Linking
- Local Notifications via a custom Capacitor plugin
## Project Architecture
- The application must work on web browser, PWA (Progressive Web Application), desktop via Electron, and mobile via Capacitor
- Building for each platform is managed via Vite
## Core Development Principles
### DRY development
- **Code Reuse**
- Extract common functionality into utility functions
- Create reusable components for UI patterns
- Implement service classes for shared business logic
- Use mixins for cross-cutting concerns
- Leverage TypeScript interfaces for shared type definitions
- **Component Patterns**
- Create base components for common UI elements
- Implement higher-order components for shared behavior
- Use slot patterns for flexible component composition
- Create composable services for business logic
- Implement factory patterns for component creation
- **State Management**
- Centralize state in Pinia stores
- Use computed properties for derived state
- Implement shared state selectors
- Create reusable state mutations
- Use action creators for common operations
- **Error Handling**
- Implement centralized error handling
- Create reusable error components
- Use error boundary components
- Implement consistent error logging
- Create error type definitions
- **Type Definitions**
- Create shared interfaces for common data structures
- Use type aliases for complex types
- Implement generic types for reusable components
- Create utility types for common patterns
- Use discriminated unions for state management
- **API Integration**
- Create reusable API client classes
- Implement request/response interceptors
- Use consistent error handling patterns
- Create type-safe API endpoints
- Implement caching strategies
- **Platform Services**
- Abstract platform-specific code behind interfaces
- Create platform-agnostic service layers
- Implement feature detection
- Use dependency injection for services
- Create service factories
- **Testing**
- Create reusable test utilities
- Implement test factories
- Use shared test configurations
- Create reusable test helpers
- Implement consistent test patterns
### SOLID Principles
- **Single Responsibility**: Each class/component should have only one reason to change
- Components should focus on one specific feature (e.g., QR scanning, DID management)
- Services should handle one type of functionality (e.g., platform services, crypto services)
- Utilities should provide focused helper functions
- **Open/Closed**: Software entities should be open for extension but closed for modification
- Use interfaces for service definitions
- Implement plugin architecture for platform-specific features
- Allow component behavior extension through props and events
- **Liskov Substitution**: Objects should be replaceable with their subtypes
- Platform services should work consistently across web/mobile
- Authentication providers should be interchangeable
- Storage implementations should be swappable
- **Interface Segregation**: Clients shouldn't depend on interfaces they don't use
- Break down large service interfaces into smaller, focused ones
- Component props should be minimal and purposeful
- Event emissions should be specific and targeted
- **Dependency Inversion**: High-level modules shouldn't depend on low-level modules
- Use dependency injection for services
- Abstract platform-specific code behind interfaces
- Implement factory patterns for component creation
### Law of Demeter
- Components should only communicate with immediate dependencies
- Avoid chaining method calls (e.g., `this.service.getUser().getProfile().getName()`)
- Use mediator patterns for complex component interactions
- Implement facade patterns for subsystem access
- Keep component communication through defined events and props
### Composition over Inheritance
- Prefer building components through composition
- Use mixins for shared functionality
- Implement feature toggles through props
- Create higher-order components for common patterns
- Use service composition for complex features
### Interface Segregation
- Define clear interfaces for services
- Keep component APIs minimal and focused
- Split large interfaces into smaller, specific ones
- Use TypeScript interfaces for type definitions
- Implement role-based interfaces for different use cases
### Fail Fast
- Validate inputs early in the process
- Use TypeScript strict mode
- Implement comprehensive error handling
- Add runtime checks for critical operations
- Use assertions for development-time validation
### Principle of Least Astonishment
- Follow Vue.js conventions consistently
- Use familiar naming patterns
- Implement predictable component behaviors
- Maintain consistent error handling
- Keep UI interactions intuitive
### Information Hiding
- Encapsulate implementation details
- Use private class members
- Implement proper access modifiers
- Hide complex logic behind simple interfaces
- Use TypeScript's access modifiers effectively
### Single Source of Truth
- Use Pinia for state management
- Maintain one source for user data
- Centralize configuration management
- Use computed properties for derived state
- Implement proper state synchronization
### Principle of Least Privilege
- Implement proper access control
- Use minimal required permissions
- Follow privacy-by-design principles
- Restrict component access to necessary data
- Implement proper authentication/authorization
### Continuous Integration/Continuous Deployment (CI/CD)
- Automated testing on every commit
- Consistent build process across platforms
- Automated deployment pipelines
- Quality gates for code merging
- Environment-specific configurations
This expanded documentation provides:
1. Clear principles for development
2. Practical implementation guidelines
3. Real-world examples
4. TypeScript integration
5. Best practices for Time Safari

View File

@@ -1,16 +0,0 @@
---
description:
globs:
alwaysApply: true
---
- all cross platform builds need to conform to [PlatformService.ts](mdc:src/services/PlatformService.ts), and [PlatformServiceFactory.ts](mdc:src/services/PlatformServiceFactory.ts)
- [CapacitorPlatformService.ts](mdc:src/services/platforms/CapacitorPlatformService.ts) is used for mobile both iOS and Android
- [ElectronPlatformService.ts](mdc:src/services/platforms/ElectronPlatformService.ts) is used for cross-platform (Windows, MacOS, and Linux) desktop builds using Electron.
- [WebPlatformService.ts](mdc:src/services/platforms/WebPlatformService.ts) is used for traditional web browsers and PWA (Progressive Web Applications)
- [PyWebViewPlatformService.ts](mdc:src/services/platforms/PyWebViewPlatformService.ts) is used for handling a electron-like desktop application which can run Python
- Vite is used to differentiate builds for platforms
- @vite.config.mts is used for general configuration which uses environment variables to determine next actions
- @vite.config.common.mts handles common features in vite builds.
- [vite.config.capacitor.mts](mdc:vite.config.capacitor.mts) handles features of Vite builds for capacitor
- [vite.config.electron.mts](mdc:vite.config.electron.mts) handles features of Vite builds for electron
- [vite.config.web.mts](mdc:vite.config.web.mts) handles features of Vite builds for traditional web browsers and PWAs

32
.eslintrc.js Normal file
View File

@@ -0,0 +1,32 @@
module.exports = {
root: true,
env: {
node: true,
es2022: true,
},
extends: [
"plugin:vue/vue3-recommended",
"eslint:recommended",
"@vue/typescript/recommended",
"plugin:prettier/recommended"
],
// parserOptions: {
// ecmaVersion: 2020,
// },
rules: {
"max-len": ["warn", {
code: 100,
ignoreComments: true,
ignorePattern: '^\\s*class="[^"]*"$',
ignoreStrings: true,
ignoreTemplateLiterals: true,
ignoreUrls: true,
}],
"no-console": process.env.NODE_ENV === "production" ? "error" : "warn",
"no-debugger": process.env.NODE_ENV === "production" ? "error" : "warn",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-unnecessary-type-constraint": "off",
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }]
},
};

View File

@@ -1,57 +0,0 @@
{
"root": true,
"env": {
"node": true,
"browser": true,
"es2022": true
},
"extends": [
"plugin:vue/vue3-recommended",
"eslint:recommended",
"@vue/typescript/recommended",
"plugin:prettier/recommended"
],
"parser": "vue-eslint-parser",
"parserOptions": {
"parser": "@typescript-eslint/parser",
"ecmaVersion": 2022,
"sourceType": "module",
"extraFileExtensions": [".vue"],
"ecmaFeatures": {
"jsx": true
}
},
"plugins": [
"@typescript-eslint",
"vue",
"prettier"
],
"rules": {
"no-console": "warn",
"no-debugger": "warn",
"@typescript-eslint/no-explicit-any": "off",
"vue/multi-word-component-names": "off",
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
"@typescript-eslint/no-unnecessary-type-constraint": "off",
"vue/no-parsing-error": ["error", {
"x-invalid-end-tag": false,
"invalid-first-character-of-tag-name": false
}],
"vue/no-v-html": "warn",
"prettier/prettier": ["error", {
"singleQuote": true,
"semi": false,
"trailingComma": "none"
}]
},
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.mts"],
"parser": "@typescript-eslint/parser"
},
{
"files": ["*.js", "*.jsx", "*.mjs"],
"parser": "@typescript-eslint/parser"
}
]
}

View File

@@ -1,55 +0,0 @@
export default {
root: true,
env: {
node: true,
browser: true,
es2022: true
},
extends: [
'plugin:vue/vue3-recommended',
'eslint:recommended',
'@vue/typescript/recommended'
],
parser: 'vue-eslint-parser',
parserOptions: {
parser: {
'ts': '@typescript-eslint/parser',
'js': '@typescript-eslint/parser',
'<template>': 'espree'
},
ecmaVersion: 2022,
sourceType: 'module',
extraFileExtensions: ['.vue'],
ecmaFeatures: {
jsx: true
}
},
plugins: [
'@typescript-eslint',
'vue'
],
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'@typescript-eslint/no-explicit-any': 'off',
'vue/multi-word-component-names': 'off',
'@typescript-eslint/no-unused-vars': ['error', { 'argsIgnorePattern': '^_' }]
},
overrides: [
{
files: ['*.ts', '*.tsx', '*.mts'],
parser: '@typescript-eslint/parser'
},
{
files: ['*.js', '*.jsx', '*.mjs'],
parser: '@typescript-eslint/parser'
}
],
settings: {
'import/resolver': {
node: {
extensions: ['.js', '.jsx', '.ts', '.tsx', '.vue', '.mjs', '.mts']
}
}
}
};

View File

@@ -215,9 +215,6 @@ Prerequisites: Android Studio with SDK installed
rm -rf dist
npm run build:web
npm run build:capacitor
cd android
./gradlew clean
./gradlew assembleDebug
```
2. Update Android project with latest build:

View File

@@ -1,322 +0,0 @@
# Time Safari Context
## Project Overview
Time Safari is an application designed to foster community building through gifts, gratitude, and collaborative projects. The app should make it extremely easy and intuitive for users of any age and capability to recognize contributions, build trust networks, and organize collective action. It is built on services that preserve privacy and data sovereignty.
The ultimate goals of Time Safari are two-fold:
1. **Connect** Make it easy, rewarding, and non-threatening for people to connect with others who have similar interests, and to initiate activities together. This helps people accomplish and learn from other individuals in less-structured environments; moreover, it helps them discover who they want to continue to support and with whom they want to maintain relationships.
2. **Reveal** Widely advertise the great support and rewards that are being given and accepted freely, especially non-monetary ones. Using visuals and text, display the kind of impact that gifts are making in the lives of others. Also show useful and engaging reports of project statistics and personal accomplishments.
## Core Approaches
Time Safari should help everyday users build meaningful connections and organize collective efforts by:
1. **Recognizing Contributions**: Creating permanent, verifiable records of gifts and contributions people give to each other and their communities.
2. **Facilitating Collaboration**: Making it ridiculously easy for people to ask for or propose help on projects and interests that matter to them.
3. **Building Trust Networks**: Enabling users to maintain their network and activity visibility. Developing reputation through verified contributions and references, which can be selectively shown to others outside the network.
4. **Preserving Privacy**: Ensuring personal identifiers are only shared with explicitly authorized contacts, allowing private individuals including children to participate safely.
5. **Engaging Content**: Displaying people's records in compelling stories, and highlighting those projects that are lifting people's lives long-term, both in physical support and in emotional-spiritual-creative thriving.
## Technical Foundation
This application is built on a privacy-preserving claims architecture (via endorser.ch) with these key characteristics:
- **Decentralized Identifiers (DIDs)**: User identities are based on public/private key pairs stored on their devices
- **Cryptographic Verification**: All claims and confirmations are cryptographically signed
- **User-Controlled Visibility**: Users explicitly control who can see their identifiers and data
- **Merkle-Chained Claims**: Claims are cryptographically chained for verification and integrity
- **Native and Web App**: Works on iOS, Android, and web browsers
## User Journey
The typical progression of usage follows these stages:
1. **Gratitude & Recognition**: Users begin by expressing and recording gratitude for gifts received, building a foundation of acknowledgment.
2. **Project Proposals**: Users propose projects and ideas, reaching out to connect with others who share similar interests.
3. **Action Triggers**: Offers of help serve as triggers and motivations to execute proposed projects, moving from ideas to action.
## Context for LLM Development
When developing new functionality for Time Safari, consider these design principles:
1. **Accessibility First**: Features should be usable by non-technical users with minimal learning curve.
2. **Privacy by Design**: All features must respect user privacy and data sovereignty.
3. **Progressive Enhancement**: Core functionality should work across all devices, with richer experiences where supported.
4. **Voluntary Collaboration**: The system should enable but never coerce participation.
5. **Trust Building**: Features should help build verifiable trust between users.
6. **Network Effects**: Consider how features scale as more users join the platform.
7. **Low Resource Requirements**: The system should be lightweight enough to run on inexpensive devices users already own.
## Use Cases to Support
LLM development should focus on enhancing these key use cases:
1. **Community Building**: Tools that help people find others with shared interests and values.
2. **Project Coordination**: Features that make it easy to propose collaborative projects and to submit suggestions and offers to existing ones.
3. **Reputation Building**: Methods for users to showcase their contributions and reliability, in contexts where they explicitly reveal that information.
4. **Governance Experimentation**: Features that facilitate decision-making and collective governance.
## Constraints
When developing new features, be mindful of these constraints:
1. **Privacy Preservation**: User identifiers must remain private except when explicitly shared.
2. **Platform Limitations**: Features must work within the constraints of the target app platforms, while aiming to leverage the best platform technology available.
3. **Endorser API Limitations**: Backend features are constrained by the endorser.ch API capabilities.
4. **Performance on Low-End Devices**: The application should remain performant on older/simpler devices.
5. **Offline-First When Possible**: Key functionality should work offline when feasible.
## Project Technologies
- Typescript using ES6 classes
- TailwindCSS
- Vite Build Tool
- Playwright E2E testing
- IndexDB
- Camera, Image uploads, QR Code reader, ...
## Mobile Features
- Deep Linking
- Local Notifications via a custom Capacitor plugin
## Project Architecture
- The application must work on web browser, PWA (Progressive Web Application), desktop via Electron, and mobile via Capacitor
- Building for each platform is managed via Vite
## Core Development Principles
### DRY development
- **Code Reuse**
- Extract common functionality into utility functions
- Create reusable components for UI patterns
- Implement service classes for shared business logic
- Use mixins for cross-cutting concerns
- Leverage TypeScript interfaces for shared type definitions
- **Component Patterns**
- Create base components for common UI elements
- Implement higher-order components for shared behavior
- Use slot patterns for flexible component composition
- Create composable services for business logic
- Implement factory patterns for component creation
- **State Management**
- Centralize state in Pinia stores
- Use computed properties for derived state
- Implement shared state selectors
- Create reusable state mutations
- Use action creators for common operations
- **Error Handling**
- Implement centralized error handling
- Create reusable error components
- Use error boundary components
- Implement consistent error logging
- Create error type definitions
- **Type Definitions**
- Create shared interfaces for common data structures
- Use type aliases for complex types
- Implement generic types for reusable components
- Create utility types for common patterns
- Use discriminated unions for state management
- **API Integration**
- Create reusable API client classes
- Implement request/response interceptors
- Use consistent error handling patterns
- Create type-safe API endpoints
- Implement caching strategies
- **Platform Services**
- Abstract platform-specific code behind interfaces
- Create platform-agnostic service layers
- Implement feature detection
- Use dependency injection for services
- Create service factories
- **Testing**
- Create reusable test utilities
- Implement test factories
- Use shared test configurations
- Create reusable test helpers
- Implement consistent test patterns
### SOLID Principles
- **Single Responsibility**: Each class/component should have only one reason to change
- Components should focus on one specific feature (e.g., QR scanning, DID management)
- Services should handle one type of functionality (e.g., platform services, crypto services)
- Utilities should provide focused helper functions
- **Open/Closed**: Software entities should be open for extension but closed for modification
- Use interfaces for service definitions
- Implement plugin architecture for platform-specific features
- Allow component behavior extension through props and events
- **Liskov Substitution**: Objects should be replaceable with their subtypes
- Platform services should work consistently across web/mobile
- Authentication providers should be interchangeable
- Storage implementations should be swappable
- **Interface Segregation**: Clients shouldn't depend on interfaces they don't use
- Break down large service interfaces into smaller, focused ones
- Component props should be minimal and purposeful
- Event emissions should be specific and targeted
- **Dependency Inversion**: High-level modules shouldn't depend on low-level modules
- Use dependency injection for services
- Abstract platform-specific code behind interfaces
- Implement factory patterns for component creation
### Law of Demeter
- Components should only communicate with immediate dependencies
- Avoid chaining method calls (e.g., `this.service.getUser().getProfile().getName()`)
- Use mediator patterns for complex component interactions
- Implement facade patterns for subsystem access
- Keep component communication through defined events and props
### Composition over Inheritance
- Prefer building components through composition
- Use mixins for shared functionality
- Implement feature toggles through props
- Create higher-order components for common patterns
- Use service composition for complex features
### Interface Segregation
- Define clear interfaces for services
- Keep component APIs minimal and focused
- Split large interfaces into smaller, specific ones
- Use TypeScript interfaces for type definitions
- Implement role-based interfaces for different use cases
### Fail Fast
- Validate inputs early in the process
- Use TypeScript strict mode
- Implement comprehensive error handling
- Add runtime checks for critical operations
- Use assertions for development-time validation
### Principle of Least Astonishment
- Follow Vue.js conventions consistently
- Use familiar naming patterns
- Implement predictable component behaviors
- Maintain consistent error handling
- Keep UI interactions intuitive
### Information Hiding
- Encapsulate implementation details
- Use private class members
- Implement proper access modifiers
- Hide complex logic behind simple interfaces
- Use TypeScript's access modifiers effectively
### Single Source of Truth
- Use Pinia for state management
- Maintain one source for user data
- Centralize configuration management
- Use computed properties for derived state
- Implement proper state synchronization
### Principle of Least Privilege
- Implement proper access control
- Use minimal required permissions
- Follow privacy-by-design principles
- Restrict component access to necessary data
- Implement proper authentication/authorization
### Continuous Integration/Continuous Deployment (CI/CD)
- Automated testing on every commit
- Consistent build process across platforms
- Automated deployment pipelines
- Quality gates for code merging
- Environment-specific configurations
This expanded documentation provides:
1. Clear principles for development
2. Practical implementation guidelines
3. Real-world examples
4. TypeScript integration
5. Best practices for Time Safari
## Vue Component Structure
- Use `@Options`, `@Ref`, `@Prop`, `@Emit`, and `@Watch` Typescript decorators for clear component structure
- Extend `Vue` class with proper type annotations for props, refs, and methods
- Use Tailwind utility classes for accessible and responsive design
- Avoid `setup()` or Composition API; use class syntax consistently
- Keep methods pure when possible; extract logic into utilities
- Ensure lifecycle methods are clearly defined inside class
- Use semantic HTML + Tailwind classes for styling
- Pinia for state management
## Vue Facing Decorators
- Ensure all Vue 3 components are written using TypeScript with strict type checking enabled.
- Always include explicit types for props, emits, and reactive properties.
- When using @Options, ensure it includes metadata like name, template, or styles.
- Use @Prop for defining props with validation and default values.
- Use @Emit for emitting events with proper payload typing.
- Use @Watch for reactive property changes, and @Ref for DOM references."
- Organize Vue 3 components with a clear structure: imports at the top, followed by @Options metadata, then class properties (props, refs, reactive state), lifecycle hooks, methods, and finally @Watch or @Emit handlers.
- Ensure all props have explicit types and optional validation.
- Use TypeScript interfaces or types for complex prop structures.
- Validate default values for props where applicable.
- Use lifecycle hooks (e.g., onMounted, onUnmounted) sparingly and document their purpose.
- Avoid side effects in lifecycle hooks unless absolutely necessary.
- Use @Emit for emitting events with strongly typed payloads.
- Ensure event names are descriptive and match the action being performed.
- Use ref or reactive for managing internal state.
- Avoid overusing reactive state for simple values. Prefer computed properties for derived state.
- Write unit tests for components using Vue Test Utils and Jest/Vitest.
- Ensure tests cover props, events, and lifecycle behavior.
- Avoid unnecessary re-renders by using v-once for static content and memoizing expensive computations with computed properties.
- Ensure components are accessible by using semantic HTML and ARIA attributes.
- Use scoped styles or CSS modules to encapsulate styles.
## es6 classes
- Use ES6 class syntax with decorators (@Options, @Prop, @Emit).
- Use modular imports and default exports.
- Use arrow functions for methods and callbacks.
- Use destructuring for props and state.
- Provide default parameters for optional props or arguments.
- Use template literals for dynamic strings.
- Use spread/rest operators for object manipulation and arguments.
- Use const/let appropriately for variable declarations.
- Use enhanced object literals for cleaner syntax.
- Use async/await for asynchronous operations.
- Add scoped styles for encapsulation.
- Ensure accessibility with semantic HTML and ARIA attributes.
## Documentation
- Include JSDoc comments for all public methods and props.
- Files must have comments explaing contents and workflow of file
- Methods and props should explain role and workflow of each

View File

@@ -1,2 +1,2 @@
#Wed Apr 09 09:01:13 UTC 2025
gradle.version=8.11.1
#Fri Mar 21 07:27:50 UTC 2025
gradle.version=8.2.1

Binary file not shown.

View File

@@ -14,7 +14,7 @@ project.ext.MY_KEY_PASSWORD = System.getenv('ANDROID_KEY_PASSWORD') ?: ""
// If no environment variables, try to load from secrets file
if (!project.ext.MY_KEYSTORE_FILE) {
def secretsPropertiesFile = rootProject.file("app/gradle.properties.secrets")
def secretsPropertiesFile = rootProject.file("gradle.properties.secrets")
if (secretsPropertiesFile.exists()) {
Properties secretsProperties = new Properties()
secretsProperties.load(new FileInputStream(secretsPropertiesFile))
@@ -31,7 +31,7 @@ android {
applicationId "app.timesafari.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 10
versionCode 9
versionName "0.4.4"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {

View File

@@ -11,11 +11,7 @@ apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-app')
implementation project(':capacitor-camera')
implementation project(':capacitor-clipboard')
implementation project(':capacitor-filesystem')
implementation project(':capacitor-share')
implementation project(':capawesome-capacitor-file-picker')
implementation project(':capacitor-mlkit-barcode-scanning')
}

View File

@@ -21,6 +21,6 @@ public class ExampleInstrumentedTest {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("app.timesafari.app", appContext.getPackageName());
assertEquals("app.timesafari", appContext.getPackageName());
}
}

View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8" ?>
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
@@ -7,6 +8,7 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
@@ -14,6 +16,7 @@
android:label="@string/title_activity_main"
android:launchMode="singleTask"
android:theme="@style/AppTheme.NoActionBarLaunch">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
@@ -25,6 +28,7 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="timesafari" />
</intent-filter>
</activity>
<provider
@@ -32,14 +36,13 @@
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
</provider>
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.CAMERA" />
</manifest>

View File

@@ -16,14 +16,6 @@
}
]
}
},
"MLKitBarcodeScanner": {
"formats": [
"QR_CODE"
],
"detectorSize": 1,
"lensFacing": "back",
"googleBarcodeScannerModuleInstallState": true
}
}
}

View File

@@ -7,24 +7,8 @@
"pkg": "@capacitor/camera",
"classpath": "com.capacitorjs.plugins.camera.CameraPlugin"
},
{
"pkg": "@capacitor/clipboard",
"classpath": "com.capacitorjs.plugins.clipboard.ClipboardPlugin"
},
{
"pkg": "@capacitor/filesystem",
"classpath": "com.capacitorjs.plugins.filesystem.FilesystemPlugin"
},
{
"pkg": "@capacitor/share",
"classpath": "com.capacitorjs.plugins.share.SharePlugin"
},
{
"pkg": "@capawesome/capacitor-file-picker",
"classpath": "io.capawesome.capacitorjs.plugins.filepicker.FilePickerPlugin"
},
{
"pkg": "@capacitor-mlkit/barcode-scanning",
"classpath": "io.capawesome.capacitorjs.plugins.mlkit.barcodescanning.BarcodeScannerPlugin"
}
]

View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="/favicon.ico">
<title>TimeSafari</title>
<script type="module" crossorigin src="/assets/index-CZMUlUNO.js"></script>
</head>
<body>
<noscript>
<strong>We're sorry but TimeSafari doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -2,5 +2,4 @@
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
<files-path name="my_files" path="." />
</paths>

View File

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

View File

@@ -8,17 +8,5 @@ project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/
include ':capacitor-camera'
project(':capacitor-camera').projectDir = new File('../node_modules/@capacitor/camera/android')
include ':capacitor-clipboard'
project(':capacitor-clipboard').projectDir = new File('../node_modules/@capacitor/clipboard/android')
include ':capacitor-filesystem'
project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android')
include ':capacitor-share'
project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android')
include ':capawesome-capacitor-file-picker'
project(':capawesome-capacitor-file-picker').projectDir = new File('../node_modules/@capawesome/capacitor-file-picker/android')
include ':capacitor-mlkit-barcode-scanning'
project(':capacitor-mlkit-barcode-scanning').projectDir = new File('../node_modules/@capacitor-mlkit/barcode-scanning/android')

View File

@@ -9,7 +9,7 @@
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -Djdk.io.File.enableADS=true -Dsun.net.inetaddr.ttl=0 -Dsun.net.inetaddr.negative.ttl=0 -Dsun.net.client.defaultConnectTimeout=10000 -Dsun.net.client.defaultReadTimeout=10000
org.gradle.jvmargs=-Xmx1536m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
@@ -20,8 +20,4 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -Djdk.io.File.enableADS=true
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Automatically convert third-party libraries to use AndroidX
android.enableJetifier=true
# Use DNS servers that can resolve Google's Maven repository
systemProp.sun.net.spi.nameservice.nameservers=8.8.8.8,8.8.4.4
systemProp.sun.net.spi.nameservice.provider.1=dns,sun
android.suppressUnsupportedCompileSdk=34

View File

@@ -18,12 +18,6 @@ const config: CapacitorConfig = {
}
]
}
},
MLKitBarcodeScanner: {
formats: ['QR_CODE'], // Only enable QR code scanning to improve performance
detectorSize: 1.0, // Use full camera view for detection
lensFacing: 'back', // Default to back camera
googleBarcodeScannerModuleInstallState: true // Enable Google Play Services barcode module installation if needed
}
}
};

View File

@@ -45,15 +45,5 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>UIFileSharingEnabled</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>UISupportsDocumentBrowser</key>
<true/>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>This app needs access to save exported files to your photo library.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>This app needs access to save exported files to your photo library.</string>
</dict>
</plist>

View File

@@ -12,12 +12,6 @@ def capacitor_pods
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app'
pod 'CapacitorCamera', :path => '../../node_modules/@capacitor/camera'
pod 'CapacitorClipboard', :path => '../../node_modules/@capacitor/clipboard'
pod 'CapacitorFilesystem', :path => '../../node_modules/@capacitor/filesystem'
pod 'CapacitorShare', :path => '../../node_modules/@capacitor/share'
pod 'CapawesomeCapacitorFilePicker', :path => '../../node_modules/@capawesome/capacitor-file-picker'
pod 'CapacitorMlkitBarcodeScanning', :path => '../../node_modules/@capacitor-mlkit/barcode-scanning'
end
target 'App' do

View File

@@ -122,8 +122,8 @@
504EC2FC1FED79650016851F /* Project object */ = {
isa = PBXProject;
attributes = {
LastSwiftUpdateCheck = 920;
LastUpgradeCheck = 920;
LastSwiftUpdateCheck = 0920;
LastUpgradeCheck = 0920;
TargetAttributes = {
504EC3031FED79650016851F = {
CreatedOnToolsVersion = 9.2;

1898
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,20 +2,15 @@
"name": "timesafari",
"version": "0.4.4",
"description": "Time Safari Application",
"type": "module",
"author": {
"name": "Time Safari Team"
},
"scripts": {
"dev": "vite",
"dev:web": "vite --config vite.config.web.mts",
"dev": "vite --config vite.config.dev.mts",
"serve": "vite preview",
"build": "vite build",
"build:mobile": "VITE_PLATFORM=capacitor vite build",
"preview": "vite preview",
"lint": "NODE_OPTIONS='--experimental-vm-modules --experimental-specifier-resolution=node' eslint --ext .vue,.js,.jsx,.mjs,.ts,.tsx,.mts --fix --ignore-path .gitignore",
"format": "prettier --write src/",
"lint-fix": "NODE_OPTIONS='--experimental-vm-modules --experimental-specifier-resolution=node' eslint \"src/**/*.{vue,js,jsx,ts,tsx,mjs,mts}\" --fix",
"build": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --config vite.config.mts",
"lint": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src",
"lint-fix": "eslint --ext .js,.ts,.vue --ignore-path .gitignore --fix src",
"prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node sw_combine.js",
"test:all": "npm run test:prerequisites && npm run build && npm run test:web && npm run test:mobile",
"test:prerequisites": "node scripts/check-prerequisites.js",
@@ -32,8 +27,6 @@
"build:web": "vite build --config vite.config.web.mts",
"electron:dev": "npm run build && electron dist-electron",
"electron:start": "electron dist-electron",
"clean:android": "adb uninstall app.timesafari.app || true",
"build:android": "npm run clean:android && rm -rf dist && npm run build:web && npm run build:capacitor && cd android && ./gradlew clean && ./gradlew assembleDebug && cd .. && npx cap sync android && npx capacitor-assets generate --android && npx cap open android",
"electron:build-linux": "npm run build:electron && electron-builder --linux AppImage",
"electron:build-linux-deb": "npm run build:electron && electron-builder --linux deb",
"electron:build-linux-prod": "NODE_ENV=production npm run build:electron && electron-builder --linux AppImage",
@@ -53,12 +46,9 @@
"@capacitor/app": "^6.0.0",
"@capacitor/camera": "^6.0.0",
"@capacitor/cli": "^6.2.0",
"@capacitor/clipboard": "^6.0.2",
"@capacitor/core": "^6.2.1",
"@capacitor/core": "^6.2.0",
"@capacitor/filesystem": "^6.0.0",
"@capacitor/ios": "^6.2.0",
"@capacitor/share": "^6.0.3",
"@capawesome/capacitor-file-picker": "^6.2.0",
"@dicebear/collection": "^5.4.1",
"@dicebear/core": "^5.4.1",
"@ethersproject/hdnode": "^5.7.0",
@@ -86,10 +76,8 @@
"@zxing/text-encoding": "^0.9.0",
"asn1-ber": "^1.2.2",
"axios": "^1.6.8",
"buffer": "^6.0.3",
"cbor-x": "^1.5.9",
"class-transformer": "^0.5.1",
"crypto-browserify": "^3.12.1",
"dexie": "^3.2.7",
"dexie-export-import": "^4.1.4",
"did-jwt": "^7.4.7",
@@ -100,13 +88,12 @@
"jdenticon": "^3.2.0",
"js-generate-password": "^0.1.9",
"js-yaml": "^4.1.0",
"jsqr": "^1.4.0",
"leaflet": "^1.9.4",
"localstorage-slim": "^2.7.0",
"lru-cache": "^10.2.0",
"luxon": "^3.4.4",
"merkletreejs": "^0.3.11",
"nostr-tools": "^2.12.0",
"nostr-tools": "^2.10.4",
"notiwind": "^2.0.2",
"papaparse": "^5.4.1",
"pina": "^0.20.2204228",
@@ -132,7 +119,6 @@
"zod": "^3.24.2"
},
"devDependencies": {
"@capacitor-mlkit/barcode-scanning": "^6.2.0",
"@capacitor/assets": "^3.0.5",
"@playwright/test": "^1.45.2",
"@types/dom-webcodecs": "^0.1.7",
@@ -148,23 +134,21 @@
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"@vitejs/plugin-vue": "^5.2.1",
"@vue/eslint-config-typescript": "^12.0.0",
"@vue/eslint-config-typescript": "^11.0.3",
"autoprefixer": "^10.4.19",
"concurrently": "^8.2.2",
"electron": "^33.2.1",
"electron-builder": "^25.1.8",
"eslint": "^8.54.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-import-resolver-node": "^0.3.9",
"eslint-plugin-prettier": "^5.2.6",
"eslint-plugin-vue": "^9.33.0",
"espree": "^10.3.0",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-vue": "^9.32.0",
"fs-extra": "^11.3.0",
"markdownlint": "^0.37.4",
"markdownlint-cli": "^0.44.0",
"npm-check-updates": "^17.1.13",
"postcss": "^8.4.38",
"prettier": "^3.5.3",
"prettier": "^3.2.5",
"rimraf": "^6.0.1",
"tailwindcss": "^3.4.1",
"typescript": "~5.2.2",

View File

@@ -1,22 +0,0 @@
#!/bin/bash
# Clean the public directory
rm -rf android/app/src/main/assets/public/*
# Copy web assets
cp -r dist/* android/app/src/main/assets/public/
# Ensure the directory structure exists
mkdir -p android/app/src/main/assets/public/assets
# Copy the main index file
cp dist/index.html android/app/src/main/assets/public/
# Copy all assets
cp -r dist/assets/* android/app/src/main/assets/public/assets/
# Copy other necessary files
cp dist/favicon.ico android/app/src/main/assets/public/
cp dist/robots.txt android/app/src/main/assets/public/
echo "Web assets copied successfully!"

View File

@@ -1,22 +0,0 @@
#!/bin/bash
# Create directories if they don't exist
mkdir -p android/app/src/main/res/mipmap-mdpi
mkdir -p android/app/src/main/res/mipmap-hdpi
mkdir -p android/app/src/main/res/mipmap-xhdpi
mkdir -p android/app/src/main/res/mipmap-xxhdpi
mkdir -p android/app/src/main/res/mipmap-xxxhdpi
# Generate placeholder icons using ImageMagick
convert -size 48x48 xc:blue -gravity center -pointsize 20 -fill white -annotate 0 "TS" android/app/src/main/res/mipmap-mdpi/ic_launcher.png
convert -size 72x72 xc:blue -gravity center -pointsize 30 -fill white -annotate 0 "TS" android/app/src/main/res/mipmap-hdpi/ic_launcher.png
convert -size 96x96 xc:blue -gravity center -pointsize 40 -fill white -annotate 0 "TS" android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
convert -size 144x144 xc:blue -gravity center -pointsize 60 -fill white -annotate 0 "TS" android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
convert -size 192x192 xc:blue -gravity center -pointsize 80 -fill white -annotate 0 "TS" android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
# Copy to round versions
cp android/app/src/main/res/mipmap-mdpi/ic_launcher.png android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
cp android/app/src/main/res/mipmap-hdpi/ic_launcher.png android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
cp android/app/src/main/res/mipmap-xhdpi/ic_launcher.png android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
cp android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
cp android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png

View File

@@ -29,9 +29,7 @@
>
<div class="w-full px-4 py-3">
<span class="font-semibold">{{ notification.title }}</span>
<p class="text-sm">
{{ notification.text }}
</p>
<p class="text-sm">{{ notification.text }}</p>
</div>
</div>
@@ -42,20 +40,21 @@
<div
class="flex items-center justify-center w-12 bg-slate-600 text-slate-100"
>
<font-awesome icon="circle-info" class="fa-fw fa-xl" />
<font-awesome
icon="circle-info"
class="fa-fw fa-xl"
></font-awesome>
</div>
<div class="relative w-full pl-4 pr-8 py-2 text-slate-900">
<span class="font-semibold">{{ notification.title }}</span>
<p class="text-sm">
{{ truncateLongWords(notification.text) }}
</p>
<p class="text-sm">{{ truncateLongWords(notification.text) }}</p>
<button
class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-slate-200 text-slate-600"
@click="close(notification.id)"
>
<font-awesome icon="xmark" class="fa-fw" />
<font-awesome icon="xmark" class="fa-fw"></font-awesome>
</button>
</div>
</div>
@@ -67,20 +66,21 @@
<div
class="flex items-center justify-center w-12 bg-emerald-600 text-emerald-100"
>
<font-awesome icon="circle-info" class="fa-fw fa-xl" />
<font-awesome
icon="circle-info"
class="fa-fw fa-xl"
></font-awesome>
</div>
<div class="relative w-full pl-4 pr-8 py-2 text-emerald-900">
<span class="font-semibold">{{ notification.title }}</span>
<p class="text-sm">
{{ truncateLongWords(notification.text) }}
</p>
<p class="text-sm">{{ truncateLongWords(notification.text) }}</p>
<button
class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-emerald-200 text-emerald-600"
@click="close(notification.id)"
>
<font-awesome icon="xmark" class="fa-fw" />
<font-awesome icon="xmark" class="fa-fw"></font-awesome>
</button>
</div>
</div>
@@ -92,20 +92,21 @@
<div
class="flex items-center justify-center w-12 bg-amber-600 text-amber-100"
>
<font-awesome icon="triangle-exclamation" class="fa-fw fa-xl" />
<font-awesome
icon="triangle-exclamation"
class="fa-fw fa-xl"
></font-awesome>
</div>
<div class="relative w-full pl-4 pr-8 py-2 text-amber-900">
<span class="font-semibold">{{ notification.title }}</span>
<p class="text-sm">
{{ truncateLongWords(notification.text) }}
</p>
<p class="text-sm">{{ truncateLongWords(notification.text) }}</p>
<button
class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-amber-200 text-amber-600"
@click="close(notification.id)"
>
<font-awesome icon="xmark" class="fa-fw" />
<font-awesome icon="xmark" class="fa-fw"></font-awesome>
</button>
</div>
</div>
@@ -117,20 +118,21 @@
<div
class="flex items-center justify-center w-12 bg-rose-600 text-rose-100"
>
<font-awesome icon="triangle-exclamation" class="fa-fw fa-xl" />
<font-awesome
icon="triangle-exclamation"
class="fa-fw fa-xl"
></font-awesome>
</div>
<div class="relative w-full pl-4 pr-8 py-2 text-rose-900">
<span class="font-semibold">{{ notification.title }}</span>
<p class="text-sm">
{{ truncateLongWords(notification.text) }}
</p>
<p class="text-sm">{{ truncateLongWords(notification.text) }}</p>
<button
class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-rose-200 text-rose-600"
@click="close(notification.id)"
>
<font-awesome icon="xmark" class="fa-fw" />
<font-awesome icon="xmark" class="fa-fw"></font-awesome>
</button>
</div>
</div>
@@ -181,33 +183,31 @@
<span class="font-semibold text-lg">
{{ notification.title }}
</span>
<p class="text-sm mb-2">
{{ notification.text }}
</p>
<p class="text-sm mb-2">{{ notification.text }}</p>
<button
v-if="notification.onYes"
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
@click="{
@click="
notification.onYes();
close(notification.id);
}"
"
>
Yes{{
notification.yesText ? ', ' + notification.yesText : ''
notification.yesText ? ", " + notification.yesText : ""
}}
</button>
<button
v-if="notification.onNo"
class="block w-full text-center text-md font-bold uppercase bg-yellow-600 text-white px-2 py-2 rounded-md mb-2"
@click="{
@click="
notification.onNo(stopAsking);
close(notification.id);
stopAsking = false; // reset value
}"
"
>
No{{ notification.noText ? ', ' + notification.noText : '' }}
No{{ notification.noText ? ", " + notification.noText : "" }}
</button>
<label
@@ -228,25 +228,25 @@
class="sr-only"
/>
<!-- line -->
<div class="block bg-slate-500 w-14 h-8 rounded-full" />
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
<!-- dot -->
<div
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
/>
></div>
</div>
</label>
<button
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md"
@click="{
@click="
notification.onCancel
? notification.onCancel(stopAsking)
: null;
close(notification.id);
stopAsking = false; // reset value for next time they open this modal
}"
"
>
{{ notification.onYes ? 'Cancel' : 'Close' }}
{{ notification.onYes ? "Cancel" : "Close" }}
</button>
</div>
</div>
@@ -306,10 +306,10 @@
<button
class="block w-full text-center text-md font-bold uppercase bg-rose-600 text-white px-2 py-2 rounded-md mb-2"
@click="{
@click="
close(notification.id);
turnOffNotifications(notification);
}"
"
>
Turn Off Notification
</button>
@@ -329,21 +329,21 @@
</template>
<script lang="ts">
import { Vue, Component } from 'vue-facing-decorator'
import { logConsoleAndDb, retrieveSettingsForActiveAccount } from './db/index'
import { NotificationIface } from './constants/app'
import { logger } from './utils/logger'
import { Vue, Component } from "vue-facing-decorator";
import { logConsoleAndDb, retrieveSettingsForActiveAccount } from "./db/index";
import { NotificationIface } from "./constants/app";
import { logger } from "./utils/logger";
interface Settings {
notifyingNewActivityTime?: string
notifyingReminderTime?: string
notifyingNewActivityTime?: string;
notifyingReminderTime?: string;
}
@Component
export default class App extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void
$notify!: (notification: NotificationIface, timeout?: number) => void;
stopAsking = false
stopAsking = false;
// created() {
// logger.log(
@@ -382,158 +382,158 @@ export default class App extends Vue {
truncateLongWords(sentence: string) {
return sentence
.split(' ')
.map((word) => (word.length > 30 ? word.slice(0, 30) + '...' : word))
.join(' ')
.split(" ")
.map((word) => (word.length > 30 ? word.slice(0, 30) + "..." : word))
.join(" ");
}
async turnOffNotifications(
notification: NotificationIface
notification: NotificationIface,
): Promise<boolean> {
logger.log('Starting turnOffNotifications...')
let subscription: PushSubscriptionJSON | null = null
let allGoingOff = false
logger.log("Starting turnOffNotifications...");
let subscription: PushSubscriptionJSON | null = null;
let allGoingOff = false;
try {
logger.log('Retrieving settings for the active account...')
const settings: Settings = await retrieveSettingsForActiveAccount()
logger.log('Retrieved settings:', settings)
logger.log("Retrieving settings for the active account...");
const settings: Settings = await retrieveSettingsForActiveAccount();
logger.log("Retrieved settings:", settings);
const notifyingNewActivity = !!settings?.notifyingNewActivityTime
const notifyingReminder = !!settings?.notifyingReminderTime
const notifyingNewActivity = !!settings?.notifyingNewActivityTime;
const notifyingReminder = !!settings?.notifyingReminderTime;
if (!notifyingNewActivity || !notifyingReminder) {
allGoingOff = true
logger.log('Both notifications are being turned off.')
allGoingOff = true;
logger.log("Both notifications are being turned off.");
}
logger.log('Checking service worker readiness...')
logger.log("Checking service worker readiness...");
await navigator.serviceWorker?.ready
.then((registration) => {
logger.log('Service worker is ready. Fetching subscription...')
return registration.pushManager.getSubscription()
logger.log("Service worker is ready. Fetching subscription...");
return registration.pushManager.getSubscription();
})
.then(async (subscript: PushSubscription | null) => {
if (subscript) {
subscription = subscript.toJSON()
logger.log('PushSubscription retrieved:', subscription)
subscription = subscript.toJSON();
logger.log("PushSubscription retrieved:", subscription);
if (allGoingOff) {
logger.log('Unsubscribing from push notifications...')
await subscript.unsubscribe()
logger.log('Successfully unsubscribed.')
logger.log("Unsubscribing from push notifications...");
await subscript.unsubscribe();
logger.log("Successfully unsubscribed.");
}
} else {
logConsoleAndDb('Subscription object is not available.')
logger.log('No subscription found.')
logConsoleAndDb("Subscription object is not available.");
logger.log("No subscription found.");
}
})
.catch((error) => {
logConsoleAndDb(
'Push provider server communication failed: ' +
"Push provider server communication failed: " +
JSON.stringify(error),
true
)
logger.error('Error during subscription fetch:', error)
})
true,
);
logger.error("Error during subscription fetch:", error);
});
if (!subscription) {
logger.log('No subscription available. Notifying user...')
logger.log("No subscription available. Notifying user...");
this.$notify(
{
group: 'alert',
type: 'info',
title: 'Finished',
text: 'Notifications are off.'
group: "alert",
type: "info",
title: "Finished",
text: "Notifications are off.",
},
5000
)
logger.log('Exiting as there is no subscription to process.')
return true
5000,
);
logger.log("Exiting as there is no subscription to process.");
return true;
}
const serverSubscription = {
...subscription
}
...subscription,
};
if (!allGoingOff) {
serverSubscription['notifyType'] = notification.title
serverSubscription["notifyType"] = notification.title;
logger.log(
`Server subscription updated with notifyType: ${notification.title}`
)
`Server subscription updated with notifyType: ${notification.title}`,
);
}
logger.log('Sending unsubscribe request to the server...')
const pushServerSuccess = await fetch('/web-push/unsubscribe', {
method: 'POST',
logger.log("Sending unsubscribe request to the server...");
const pushServerSuccess = await fetch("/web-push/unsubscribe", {
method: "POST",
headers: {
'Content-Type': 'application/json'
"Content-Type": "application/json",
},
body: JSON.stringify(serverSubscription)
body: JSON.stringify(serverSubscription),
})
.then(async (response) => {
if (!response.ok) {
const errorBody = await response.text()
const errorBody = await response.text();
logConsoleAndDb(
`Push server failed: ${response.status} ${errorBody}`,
true
)
logger.error('Push server error response:', errorBody)
true,
);
logger.error("Push server error response:", errorBody);
}
logger.log(`Server response status: ${response.status}`)
return response.ok
logger.log(`Server response status: ${response.status}`);
return response.ok;
})
.catch((error) => {
logConsoleAndDb(
'Push server communication failed: ' + JSON.stringify(error),
true
)
logger.error('Error during server communication:', error)
return false
})
"Push server communication failed: " + JSON.stringify(error),
true,
);
logger.error("Error during server communication:", error);
return false;
});
const message = pushServerSuccess
? 'Notification is off.'
: 'Notification is still on. Try to turn it off again.'
logger.log('Server response processed. Message:', message)
? "Notification is off."
: "Notification is still on. Try to turn it off again.";
logger.log("Server response processed. Message:", message);
this.$notify(
{
group: 'alert',
type: 'info',
title: 'Finished',
text: message
group: "alert",
type: "info",
title: "Finished",
text: message,
},
5000
)
5000,
);
if (notification.callback) {
logger.log('Executing notification callback...')
notification.callback(pushServerSuccess)
logger.log("Executing notification callback...");
notification.callback(pushServerSuccess);
}
logger.log(
'Completed turnOffNotifications with success:',
pushServerSuccess
)
return pushServerSuccess
"Completed turnOffNotifications with success:",
pushServerSuccess,
);
return pushServerSuccess;
} catch (error) {
logConsoleAndDb(
'Error turning off notifications: ' + JSON.stringify(error),
true
)
logger.error('Critical error in turnOffNotifications:', error)
"Error turning off notifications: " + JSON.stringify(error),
true,
);
logger.error("Critical error in turnOffNotifications:", error);
this.$notify(
{
group: 'alert',
type: 'error',
title: 'Error',
text: 'Failed to turn off notifications. Please try again.'
group: "alert",
type: "error",
title: "Error",
text: "Failed to turn off notifications. Please try again.",
},
5000
)
5000,
);
return false
return false;
}
}
}

View File

@@ -29,7 +29,7 @@
<div>
<h3 class="font-semibold">
{{ record.issuer.known ? record.issuer.displayName : '' }}
{{ record.issuer.known ? record.issuer.displayName : "" }}
</h3>
<p class="ms-auto text-xs text-slate-500 italic">
{{ friendlyDate }}
@@ -123,7 +123,7 @@
<div
class="shrink-0 w-0 h-0 border border-slate-300 border-t-[20px] sm:border-t-[25px] border-t-transparent border-b-[20px] sm:border-b-[25px] border-b-transparent border-s-[27px] sm:border-s-[34px] border-e-0"
/>
></div>
</div>
</div>
@@ -182,59 +182,59 @@
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-facing-decorator'
import { GiveRecordWithContactInfo } from '../types'
import EntityIcon from './EntityIcon.vue'
import { isGiveClaimType, notifyWhyCannotConfirm } from '../libs/util'
import { containsHiddenDid } from '../libs/endorserServer'
import ProjectIcon from './ProjectIcon.vue'
import { Component, Prop, Vue } from "vue-facing-decorator";
import { GiveRecordWithContactInfo } from "../types";
import EntityIcon from "./EntityIcon.vue";
import { isGiveClaimType, notifyWhyCannotConfirm } from "../libs/util";
import { containsHiddenDid } from "../libs/endorserServer";
import ProjectIcon from "./ProjectIcon.vue";
@Component({
components: {
EntityIcon,
ProjectIcon
}
ProjectIcon,
},
})
export default class ActivityListItem extends Vue {
@Prop() record!: GiveRecordWithContactInfo
@Prop() lastViewedClaimId?: string
@Prop() isRegistered!: boolean
@Prop() activeDid!: string
@Prop() confirmerIdList?: string[]
@Prop() record!: GiveRecordWithContactInfo;
@Prop() lastViewedClaimId?: string;
@Prop() isRegistered!: boolean;
@Prop() activeDid!: string;
@Prop() confirmerIdList?: string[];
get fetchAmount(): string {
const claim =
(this.record.fullClaim as unknown).claim || this.record.fullClaim
(this.record.fullClaim as unknown).claim || this.record.fullClaim;
const amount = claim.object?.amountOfThisGood
? this.displayAmount(claim.object.unitCode, claim.object.amountOfThisGood)
: ''
: "";
return amount
return amount;
}
get description(): string {
const claim =
(this.record.fullClaim as unknown).claim || this.record.fullClaim
(this.record.fullClaim as unknown).claim || this.record.fullClaim;
return `${claim.description}`
return `${claim.description}`;
}
private displayAmount(code: string, amt: number) {
return `${amt} ${this.currencyShortWordForCode(code, amt === 1)}`
return `${amt} ${this.currencyShortWordForCode(code, amt === 1)}`;
}
private currencyShortWordForCode(unitCode: string, single: boolean) {
return unitCode === 'HUR' ? (single ? 'hour' : 'hours') : unitCode
return unitCode === "HUR" ? (single ? "hour" : "hours") : unitCode;
}
get canConfirm(): boolean {
if (!this.isRegistered) return false
if (!isGiveClaimType(this.record.fullClaim?.['@type'])) return false
if (this.confirmerIdList?.includes(this.activeDid)) return false
if (this.record.issuerDid === this.activeDid) return false
if (containsHiddenDid(this.record.fullClaim)) return false
return true
if (!this.isRegistered) return false;
if (!isGiveClaimType(this.record.fullClaim?.["@type"])) return false;
if (this.confirmerIdList?.includes(this.activeDid)) return false;
if (this.record.issuerDid === this.activeDid) return false;
if (containsHiddenDid(this.record.fullClaim)) return false;
return true;
}
handleConfirmClick() {
@@ -242,24 +242,24 @@ export default class ActivityListItem extends Vue {
notifyWhyCannotConfirm(
this.$notify,
this.isRegistered,
this.record.fullClaim?.['@type'],
this.record.fullClaim?.["@type"],
this.record,
this.activeDid,
this.confirmerIdList
)
return
this.confirmerIdList,
);
return;
}
this.$emit('confirmClaim', this.record)
this.$emit("confirmClaim", this.record);
}
get friendlyDate(): string {
const date = new Date(this.record.issuedAt)
const date = new Date(this.record.issuedAt);
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
})
year: "numeric",
month: "short",
day: "numeric",
});
}
}
</script>

View File

@@ -26,9 +26,7 @@
>
<div class="w-full px-6 py-6 text-slate-900 text-center">
<span class="font-semibold text-lg">{{ title }}</span>
<p class="text-sm mb-2">
{{ text }}
</p>
<p class="text-sm mb-2">{{ text }}</p>
<button
class="block w-full text-center text-md font-bold capitalize bg-blue-800 text-white px-2 py-2 rounded-md mb-2"
@@ -67,48 +65,48 @@
</template>
<script lang="ts">
import { Component, Vue } from 'vue-facing-decorator'
import { NotificationIface } from '../constants/app'
import { Component, Vue } from "vue-facing-decorator";
import { NotificationIface } from "../constants/app";
@Component
export default class PromptDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void
$notify!: (notification: NotificationIface, timeout?: number) => void;
title = ''
text = ''
option1Text = ''
option2Text = ''
option3Text = ''
onOption1?: () => void
onOption2?: () => void
onOption3?: () => void
onCancel?: () => Promise<void>
title = "";
text = "";
option1Text = "";
option2Text = "";
option3Text = "";
onOption1?: () => void;
onOption2?: () => void;
onOption3?: () => void;
onCancel?: () => Promise<void>;
open(options: {
title: string
text: string
option1Text?: string
option2Text?: string
option3Text?: string
onOption1?: () => void
onOption2?: () => void
onOption3?: () => void
onCancel?: () => Promise<void>
title: string;
text: string;
option1Text?: string;
option2Text?: string;
option3Text?: string;
onOption1?: () => void;
onOption2?: () => void;
onOption3?: () => void;
onCancel?: () => Promise<void>;
}) {
this.title = options.title
this.text = options.text
this.option1Text = options.option1Text || ''
this.option2Text = options.option2Text || ''
this.option3Text = options.option3Text || ''
this.onOption1 = options.onOption1
this.onOption2 = options.onOption2
this.onOption3 = options.onOption3
this.onCancel = options.onCancel
this.title = options.title;
this.text = options.text;
this.option1Text = options.option1Text || "";
this.option2Text = options.option2Text || "";
this.option3Text = options.option3Text || "";
this.onOption1 = options.onOption1;
this.onOption2 = options.onOption2;
this.onOption3 = options.onOption3;
this.onCancel = options.onCancel;
this.$notify(
{
group: 'customModal',
type: 'confirm',
group: "customModal",
type: "confirm",
title: this.title,
text: this.text,
option1Text: this.option1Text,
@@ -117,38 +115,38 @@ export default class PromptDialog extends Vue {
onOption1: this.onOption1,
onOption2: this.onOption2,
onOption3: this.onOption3,
onCancel: this.onCancel
onCancel: this.onCancel,
} as NotificationIface,
-1
)
-1,
);
}
handleOption1(close: (id: string) => void) {
if (this.onOption1) {
this.onOption1()
this.onOption1();
}
close('string that does not matter')
close("string that does not matter");
}
handleOption2(close: (id: string) => void) {
if (this.onOption2) {
this.onOption2()
this.onOption2();
}
close('string that does not matter')
close("string that does not matter");
}
handleOption3(close: (id: string) => void) {
if (this.onOption3) {
this.onOption3()
this.onOption3();
}
close('string that does not matter')
close("string that does not matter");
}
handleCancel(close: (id: string) => void) {
if (this.onCancel) {
this.onCancel()
this.onCancel();
}
close('string that does not matter')
close("string that does not matter");
}
}
</script>

View File

@@ -2,9 +2,7 @@
<template>
<div v-if="visible" class="dialog-overlay">
<div class="dialog">
<h1 class="text-xl font-bold text-center mb-4">
{{ title }}
</h1>
<h1 class="text-xl font-bold text-center mb-4">{{ title }}</h1>
{{ message }}
Note that their name is only stored on this device.
<input
@@ -37,43 +35,43 @@
</template>
<script lang="ts">
import { Vue, Component } from 'vue-facing-decorator'
import { Vue, Component } from "vue-facing-decorator";
@Component
export default class ContactNameDialog extends Vue {
cancelCallback: () => void = () => {}
saveCallback: (name?: string) => void = () => {}
message = ''
newText = ''
title = 'Contact Name'
visible = false
cancelCallback: () => void = () => {};
saveCallback: (name?: string) => void = () => {};
message = "";
newText = "";
title = "Contact Name";
visible = false;
async open(
title?: string,
message?: string,
saveCallback?: (name?: string) => void,
cancelCallback?: () => void,
defaultName?: string
defaultName?: string,
) {
this.cancelCallback = cancelCallback || this.cancelCallback
this.saveCallback = saveCallback || this.saveCallback
this.message = message ?? this.message
this.newText = defaultName ?? ''
this.title = title ?? this.title
this.visible = true
this.cancelCallback = cancelCallback || this.cancelCallback;
this.saveCallback = saveCallback || this.saveCallback;
this.message = message ?? this.message;
this.newText = defaultName ?? "";
this.title = title ?? this.title;
this.visible = true;
}
async onClickSaveChanges() {
this.visible = false
this.visible = false;
if (this.saveCallback) {
this.saveCallback(this.newText)
this.saveCallback(this.newText);
}
}
onClickCancel() {
this.visible = false
this.visible = false;
if (this.cancelCallback) {
this.cancelCallback()
this.cancelCallback();
}
}
}

View File

@@ -29,47 +29,35 @@ backup and database export, with platform-specific download instructions. * *
(excluding Identifier Data)
</button>
<a
v-if="platformService?.needsSecondaryDownloadLink()"
ref="downloadLink"
:class="computedDownloadLinkClassNames()"
class="block w-full text-center text-md bg-gradient-to-b from-green-500 to-green-800 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-6"
>
If no download happened yet, click again here to download now.
</a>
<div v-if="platformCapabilities.needsFileHandlingInstructions" class="mt-4">
<p>
After the download, you can save the file in your preferred storage
location.
<div
v-if="platformService?.getExportInstructions().length > 0"
class="mt-4"
>
<p
v-for="instruction in platformService?.getExportInstructions()"
:key="instruction"
class="list-disc list-outside ml-4"
>
{{ instruction }}
</p>
<ul>
<li
v-if="platformCapabilities.isIOS"
class="list-disc list-outside ml-4"
>
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: You will be prompted to choose a location to save your
backup file.
</li>
</ul>
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-facing-decorator'
import { NotificationIface } from '../constants/app'
import { db } from '../db/index'
import { logger } from '../utils/logger'
import { PlatformServiceFactory } from '../services/PlatformServiceFactory'
import {
PlatformService,
PlatformCapabilities
} from '../services/PlatformService'
import { Component, Prop, Vue } from "vue-facing-decorator";
import { NotificationIface } from "../constants/app";
import { db } from "../db/index";
import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
import { PlatformService } from "../services/PlatformService";
/**
* @vue-component
@@ -82,45 +70,45 @@ export default class DataExportSection extends Vue {
* Notification function injected by Vue
* Used to show success/error messages to the user
*/
$notify!: (notification: NotificationIface, timeout?: number) => void
$notify!: (notification: NotificationIface, timeout?: number) => void;
/**
* Active DID (Decentralized Identifier) of the user
* Controls visibility of seed backup option
* @required
*/
@Prop({ required: true }) readonly activeDid!: string
@Prop({ required: true }) readonly activeDid!: string;
/**
* URL for the database export download
* Created and revoked dynamically during export process
* Only used in web platform
*/
downloadUrl = ''
downloadUrl = "";
/**
* 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()
}
private platformService?: PlatformService;
/**
* Lifecycle hook to clean up resources
* Revokes object URL when component is unmounted (web platform only)
*/
beforeUnmount() {
if (this.downloadUrl && this.platformCapabilities.hasFileDownload) {
URL.revokeObjectURL(this.downloadUrl)
if (this.downloadUrl && this.platformService?.needsDownloadCleanup()) {
URL.revokeObjectURL(this.downloadUrl);
}
}
async mounted() {
this.platformService = await PlatformServiceFactory.getInstance();
logger.log(
"DataExportSection mounted on platform:",
process.env.VITE_PLATFORM,
);
}
/**
* Exports the database to a JSON file
* Uses platform-specific methods for saving the exported data
@@ -131,45 +119,51 @@ export default class DataExportSection extends Vue {
*/
public async exportDatabase() {
try {
const blob = await db.export({ prettyJson: true })
const fileName = `${db.name}-backup.json`
if (this.platformCapabilities.hasFileDownload) {
// Web platform: Use download link
this.downloadUrl = URL.createObjectURL(blob)
const downloadAnchor = this.$refs.downloadLink as HTMLAnchorElement
downloadAnchor.href = this.downloadUrl
downloadAnchor.download = fileName
downloadAnchor.click()
setTimeout(() => URL.revokeObjectURL(this.downloadUrl), 1000)
} else if (this.platformCapabilities.hasFileSystem) {
// Native platform: Write to app directory
const content = await blob.text()
await this.platformService.writeAndShareFile(fileName, content)
if (!this.platformService) {
this.platformService = await PlatformServiceFactory.getInstance();
}
logger.log(
"Starting database export on platform:",
process.env.VITE_PLATFORM,
);
const blob = await db.export({ prettyJson: true });
const fileName = `${db.name}-backup.json`;
logger.log("Database export details:", {
fileName,
blobSize: `${blob.size} bytes`,
platform: process.env.VITE_PLATFORM,
});
await this.platformService.exportDatabase(blob, fileName);
logger.log("Database export completed successfully:", {
fileName,
platform: process.env.VITE_PLATFORM,
});
this.$notify(
{
group: 'alert',
type: 'success',
title: 'Export Successful',
text: this.platformCapabilities.hasFileDownload
? 'See your downloads directory for the backup. It is in the Dexie format.'
: 'Please choose a location to save your backup file.'
group: "alert",
type: "success",
title: "Export Successful",
text: this.platformService.getExportSuccessMessage(),
},
-1
)
-1,
);
} catch (error) {
logger.error('Export Error:', error)
logger.error("Database export failed:", {
error,
platform: process.env.VITE_PLATFORM,
});
this.$notify(
{
group: 'alert',
type: 'danger',
title: 'Export Error',
text: 'There was an error exporting the data.'
group: "alert",
type: "danger",
title: "Export Error",
text: "There was an error exporting the data.",
},
3000
)
3000,
);
}
}
@@ -179,8 +173,9 @@ export default class DataExportSection extends Vue {
*/
public computedStartDownloadLinkClassNames() {
return {
hidden: this.downloadUrl && this.platformCapabilities.hasFileDownload
}
hidden:
this.downloadUrl && this.platformService?.needsSecondaryDownloadLink(),
};
}
/**
@@ -189,8 +184,10 @@ export default class DataExportSection extends Vue {
*/
public computedDownloadLinkClassNames() {
return {
hidden: !this.downloadUrl || !this.platformCapabilities.hasFileDownload
}
hidden:
!this.downloadUrl ||
!this.platformService?.needsSecondaryDownloadLink(),
};
}
}
</script>

View File

@@ -1,40 +1,40 @@
<template>
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="w-fit" v-html="generateIcon()" />
<div class="w-fit" v-html="generateIcon()"></div>
</template>
<script lang="ts">
import { createAvatar, StyleOptions } from '@dicebear/core'
import { avataaars } from '@dicebear/collection'
import { Vue, Component, Prop } from 'vue-facing-decorator'
import { Contact } from '../db/tables/contacts'
import { createAvatar, StyleOptions } from "@dicebear/core";
import { avataaars } from "@dicebear/collection";
import { Vue, Component, Prop } from "vue-facing-decorator";
import { Contact } from "../db/tables/contacts";
@Component
export default class EntityIcon extends Vue {
@Prop contact: Contact
@Prop entityId = '' // overridden by contact.did or profileImageUrl
@Prop iconSize = 0
@Prop profileImageUrl = '' // overridden by contact.profileImageUrl
@Prop contact: Contact;
@Prop entityId = ""; // overridden by contact.did or profileImageUrl
@Prop iconSize = 0;
@Prop profileImageUrl = ""; // overridden by contact.profileImageUrl
generateIcon() {
const imageUrl = this.contact?.profileImageUrl || this.profileImageUrl
const imageUrl = this.contact?.profileImageUrl || this.profileImageUrl;
if (imageUrl) {
return `<img src="${imageUrl}" class="rounded" width="${this.iconSize}" height="${this.iconSize}" />`
return `<img src="${imageUrl}" class="rounded" width="${this.iconSize}" height="${this.iconSize}" />`;
} else {
const identifier = this.contact?.did || this.entityId
const identifier = this.contact?.did || this.entityId;
if (!identifier) {
return `<img src="../src/assets/blank-square.svg" class="rounded" width="${this.iconSize}" height="${this.iconSize}" />`
return `<img src="../src/assets/blank-square.svg" class="rounded" width="${this.iconSize}" height="${this.iconSize}" />`;
}
// https://api.dicebear.com/8.x/avataaars/svg?seed=
// ... does not render things with the same seed as this library.
// "did:ethr:0x222BB77E6Ff3774d34c751f3c1260866357B677b" yields a girl with flowers in her hair and a lightning earring
// ... which looks similar to '' at the dicebear site but which is different.
const options: StyleOptions<object> = {
seed: (identifier as string) || '',
size: this.iconSize
}
const avatar = createAvatar(avataaars, options)
const svgString = avatar.toString()
return svgString
seed: (identifier as string) || "",
size: this.iconSize,
};
const avatar = createAvatar(avataaars, options);
const svgString = avatar.toString();
return svgString;
}
}
}

View File

@@ -22,11 +22,11 @@
class="sr-only"
/>
<!-- line -->
<div class="block bg-slate-500 w-14 h-8 rounded-full" />
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
<!-- dot -->
<div
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
/>
></div>
</div>
</div>
@@ -52,11 +52,11 @@
class="sr-only"
/>
<!-- line -->
<div class="block bg-slate-500 w-14 h-8 rounded-full" />
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
<!-- dot -->
<div
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
/>
></div>
</div>
<div v-else class="relative ml-2">
<button class="ml-2 px-4 py-2 rounded-md bg-blue-200 text-blue-500">
@@ -91,96 +91,101 @@
</template>
<script lang="ts">
import { Vue, Component } from 'vue-facing-decorator'
import { LMap, LMarker, LRectangle, LTileLayer } from '@vue-leaflet/vue-leaflet'
import { Router } from 'vue-router'
import { MASTER_SETTINGS_KEY } from '../db/tables/settings'
import { db, retrieveSettingsForActiveAccount } from '../db/index'
import { Vue, Component } from "vue-facing-decorator";
import {
LMap,
LMarker,
LRectangle,
LTileLayer,
} from "@vue-leaflet/vue-leaflet";
import { Router } from "vue-router";
import { MASTER_SETTINGS_KEY } from "../db/tables/settings";
import { db, retrieveSettingsForActiveAccount } from "../db/index";
@Component({
components: {
LRectangle,
LMap,
LMarker,
LTileLayer
}
LTileLayer,
},
})
export default class FeedFilters extends Vue {
$router!: Router
onCloseIfChanged = () => {}
hasSearchBox = false
hasVisibleDid = false
isNearby = false
settingChanged = false
visible = false
$router!: Router;
onCloseIfChanged = () => {};
hasSearchBox = false;
hasVisibleDid = false;
isNearby = false;
settingChanged = false;
visible = false;
async open(onCloseIfChanged: () => void) {
this.onCloseIfChanged = onCloseIfChanged
this.onCloseIfChanged = onCloseIfChanged;
const settings = await retrieveSettingsForActiveAccount()
this.hasVisibleDid = !!settings.filterFeedByVisible
this.isNearby = !!settings.filterFeedByNearby
const settings = await retrieveSettingsForActiveAccount();
this.hasVisibleDid = !!settings.filterFeedByVisible;
this.isNearby = !!settings.filterFeedByNearby;
if (settings.searchBoxes && settings.searchBoxes.length > 0) {
this.hasSearchBox = true
this.hasSearchBox = true;
}
this.settingChanged = false
this.visible = true
this.settingChanged = false;
this.visible = true;
}
async toggleHasVisibleDid() {
this.settingChanged = true
this.hasVisibleDid = !this.hasVisibleDid
this.settingChanged = true;
this.hasVisibleDid = !this.hasVisibleDid;
await db.settings.update(MASTER_SETTINGS_KEY, {
filterFeedByVisible: this.hasVisibleDid
})
filterFeedByVisible: this.hasVisibleDid,
});
}
async toggleNearby() {
this.settingChanged = true
this.isNearby = !this.isNearby
this.settingChanged = true;
this.isNearby = !this.isNearby;
await db.settings.update(MASTER_SETTINGS_KEY, {
filterFeedByNearby: this.isNearby
})
filterFeedByNearby: this.isNearby,
});
}
async clearAll() {
if (this.hasVisibleDid || this.isNearby) {
this.settingChanged = true
this.settingChanged = true;
}
await db.settings.update(MASTER_SETTINGS_KEY, {
filterFeedByNearby: false,
filterFeedByVisible: false
})
filterFeedByVisible: false,
});
this.hasVisibleDid = false
this.isNearby = false
this.hasVisibleDid = false;
this.isNearby = false;
}
async setAll() {
if (!this.hasVisibleDid || !this.isNearby) {
this.settingChanged = true
this.settingChanged = true;
}
await db.settings.update(MASTER_SETTINGS_KEY, {
filterFeedByNearby: true,
filterFeedByVisible: true
})
filterFeedByVisible: true,
});
this.hasVisibleDid = true
this.isNearby = true
this.hasVisibleDid = true;
this.isNearby = true;
}
close() {
if (this.settingChanged) {
this.onCloseIfChanged()
this.onCloseIfChanged();
}
this.visible = false
this.visible = false;
}
done() {
this.close()
this.close();
}
}
</script>

View File

@@ -51,8 +51,8 @@
providerProjectId: fromProjectId,
recipientDid: receiver?.did,
recipientName: receiver?.name,
unitCode
}
unitCode,
},
}"
class="text-blue-500"
>
@@ -87,44 +87,44 @@
</template>
<script lang="ts">
import { Vue, Component, Prop } from 'vue-facing-decorator'
import { Vue, Component, Prop } from "vue-facing-decorator";
import { NotificationIface } from '../constants/app'
import { NotificationIface } from "../constants/app";
import {
createAndSubmitGive,
didInfo,
serverMessageForUser
} from '../libs/endorserServer'
import * as libsUtil from '../libs/util'
import { db, retrieveSettingsForActiveAccount } from '../db/index'
import { Contact } from '../db/tables/contacts'
import { retrieveAccountDids } from '../libs/util'
serverMessageForUser,
} from "../libs/endorserServer";
import * as libsUtil from "../libs/util";
import { db, retrieveSettingsForActiveAccount } from "../db/index";
import { Contact } from "../db/tables/contacts";
import { retrieveAccountDids } from "../libs/util";
@Component
export default class GiftedDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void
$notify!: (notification: NotificationIface, timeout?: number) => void;
@Prop() fromProjectId = ''
@Prop() toProjectId = ''
@Prop() fromProjectId = "";
@Prop() toProjectId = "";
activeDid = ''
allContacts: Array<Contact> = []
allMyDids: Array<string> = []
apiServer = ''
activeDid = "";
allContacts: Array<Contact> = [];
allMyDids: Array<string> = [];
apiServer = "";
amountInput = '0'
callbackOnSuccess?: (amount: number) => void = () => {}
customTitle?: string
description = ''
giver?: libsUtil.GiverReceiverInputInfo // undefined means no identified giver agent
isTrade = false
offerId = ''
prompt = ''
receiver?: libsUtil.GiverReceiverInputInfo
unitCode = 'HUR'
visible = false
amountInput = "0";
callbackOnSuccess?: (amount: number) => void = () => {};
customTitle?: string;
description = "";
giver?: libsUtil.GiverReceiverInputInfo; // undefined means no identified giver agent
isTrade = false;
offerId = "";
prompt = "";
receiver?: libsUtil.GiverReceiverInputInfo;
unitCode = "HUR";
visible = false;
libsUtil = libsUtil
libsUtil = libsUtil;
async open(
giver?: libsUtil.GiverReceiverInputInfo,
@@ -132,143 +132,146 @@ export default class GiftedDialog extends Vue {
offerId?: string,
customTitle?: string,
prompt?: string,
callbackOnSuccess: (amount: number) => void = () => {}
callbackOnSuccess: (amount: number) => void = () => {},
) {
this.customTitle = customTitle
this.giver = giver
this.prompt = prompt || ''
this.receiver = receiver
this.customTitle = customTitle;
this.giver = giver;
this.prompt = prompt || "";
this.receiver = receiver;
// if we show "given to user" selection, default checkbox to true
this.amountInput = '0'
this.callbackOnSuccess = callbackOnSuccess
this.offerId = offerId || ''
this.amountInput = "0";
this.callbackOnSuccess = callbackOnSuccess;
this.offerId = offerId || "";
try {
const settings = await retrieveSettingsForActiveAccount()
this.apiServer = settings.apiServer || ''
this.activeDid = settings.activeDid || ''
const settings = await retrieveSettingsForActiveAccount();
this.apiServer = settings.apiServer || "";
this.activeDid = settings.activeDid || "";
this.allContacts = await db.contacts.toArray()
this.allContacts = await db.contacts.toArray();
this.allMyDids = await retrieveAccountDids()
this.allMyDids = await retrieveAccountDids();
if (this.giver && !this.giver.name) {
this.giver.name = didInfo(
this.giver.did,
this.activeDid,
this.allMyDids,
this.allContacts
)
this.allContacts,
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
logger.error('Error retrieving settings from database:', err)
logger.error("Error retrieving settings from database:", err);
this.$notify(
{
group: 'alert',
type: 'danger',
title: 'Error',
text: err.message || 'There was an error retrieving your settings.'
group: "alert",
type: "danger",
title: "Error",
text: err.message || "There was an error retrieving your settings.",
},
-1
)
-1,
);
}
this.visible = true
this.visible = true;
}
close() {
// close the dialog but don't change values (since it might be submitting info)
this.visible = false
this.visible = false;
}
changeUnitCode() {
const units = Object.keys(this.libsUtil.UNIT_SHORT)
const index = units.indexOf(this.unitCode)
this.unitCode = units[(index + 1) % units.length]
const units = Object.keys(this.libsUtil.UNIT_SHORT);
const index = units.indexOf(this.unitCode);
this.unitCode = units[(index + 1) % units.length];
}
increment() {
this.amountInput = `${(parseFloat(this.amountInput) || 0) + 1}`
this.amountInput = `${(parseFloat(this.amountInput) || 0) + 1}`;
}
decrement() {
this.amountInput = `${Math.max(0, (parseFloat(this.amountInput) || 1) - 1)}`
this.amountInput = `${Math.max(
0,
(parseFloat(this.amountInput) || 1) - 1,
)}`;
}
cancel() {
this.close()
this.eraseValues()
this.close();
this.eraseValues();
}
eraseValues() {
this.description = ''
this.giver = undefined
this.amountInput = '0'
this.prompt = ''
this.unitCode = 'HUR'
this.description = "";
this.giver = undefined;
this.amountInput = "0";
this.prompt = "";
this.unitCode = "HUR";
}
async confirm() {
if (!this.activeDid) {
this.$notify(
{
group: 'alert',
type: 'danger',
title: 'Error',
text: 'You must select an identifier before you can record a give.'
group: "alert",
type: "danger",
title: "Error",
text: "You must select an identifier before you can record a give.",
},
3000
)
return
3000,
);
return;
}
if (parseFloat(this.amountInput) < 0) {
this.$notify(
{
group: 'alert',
type: 'danger',
text: 'You may not send a negative number.',
title: ''
group: "alert",
type: "danger",
text: "You may not send a negative number.",
title: "",
},
2000
)
return
2000,
);
return;
}
if (!this.description && !parseFloat(this.amountInput)) {
this.$notify(
{
group: 'alert',
type: 'danger',
title: 'Error',
group: "alert",
type: "danger",
title: "Error",
text: `You must enter a description or some number of ${
this.libsUtil.UNIT_LONG[this.unitCode]
}.`
}.`,
},
2000
)
return
2000,
);
return;
}
this.close()
this.close();
this.$notify(
{
group: 'alert',
type: 'toast',
text: 'Recording the give...',
title: ''
group: "alert",
type: "toast",
text: "Recording the give...",
title: "",
},
1000
)
1000,
);
// this is asynchronous, but we don't need to wait for it to complete
await this.recordGive(
(this.giver?.did as string) || null,
(this.receiver?.did as string) || null,
this.description,
parseFloat(this.amountInput),
this.unitCode
this.unitCode,
).then(() => {
this.eraseValues()
})
this.eraseValues();
});
}
/**
@@ -284,7 +287,7 @@ export default class GiftedDialog extends Vue {
recipientDid: string | null,
description: string,
amount: number,
unitCode: string = 'HUR'
unitCode: string = "HUR",
) {
try {
const result = await createAndSubmitGive(
@@ -300,54 +303,54 @@ export default class GiftedDialog extends Vue {
this.offerId,
this.isTrade,
undefined,
this.fromProjectId
)
this.fromProjectId,
);
if (
result.type === 'error' ||
result.type === "error" ||
this.isGiveCreationError(result.response)
) {
const errorMessage = this.getGiveCreationErrorMessage(result)
logger.error('Error with give creation result:', result)
const errorMessage = this.getGiveCreationErrorMessage(result);
logger.error("Error with give creation result:", result);
this.$notify(
{
group: 'alert',
type: 'danger',
title: 'Error',
text: errorMessage || 'There was an error creating the give.'
group: "alert",
type: "danger",
title: "Error",
text: errorMessage || "There was an error creating the give.",
},
-1
)
-1,
);
} else {
this.$notify(
{
group: 'alert',
type: 'success',
title: 'Success',
text: `That ${this.isTrade ? 'trade' : 'gift'} was recorded.`
group: "alert",
type: "success",
title: "Success",
text: `That ${this.isTrade ? "trade" : "gift"} was recorded.`,
},
7000
)
7000,
);
if (this.callbackOnSuccess) {
this.callbackOnSuccess(amount)
this.callbackOnSuccess(amount);
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
logger.error('Error with give recordation caught:', error)
logger.error("Error with give recordation caught:", error);
const errorMessage =
error.userMessage ||
serverMessageForUser(error) ||
'There was an error recording the give.'
"There was an error recording the give.";
this.$notify(
{
group: 'alert',
type: 'danger',
title: 'Error',
text: errorMessage
group: "alert",
type: "danger",
title: "Error",
text: errorMessage,
},
-1
)
-1,
);
}
}
@@ -359,7 +362,7 @@ export default class GiftedDialog extends Vue {
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
isGiveCreationError(result: any) {
return result.status !== 201 || result.data?.error
return result.status !== 201 || result.data?.error;
}
/**
@@ -372,19 +375,19 @@ export default class GiftedDialog extends Vue {
result.error?.userMessage ||
result.error?.error ||
result.response?.data?.error?.message
)
);
}
explainData() {
this.$notify(
{
group: 'alert',
type: 'success',
title: 'Data Sharing',
text: libsUtil.PRIVACY_MESSAGE
group: "alert",
type: "success",
title: "Data Sharing",
text: libsUtil.PRIVACY_MESSAGE,
},
-1
)
-1,
);
}
}
</script>

View File

@@ -7,7 +7,7 @@
class="text-lg text-center p-2 leading-none absolute right-0 -top-1"
@click="cancel"
>
<font-awesome icon="xmark" class="w-[1em]" />
<font-awesome icon="xmark" class="w-[1em]"></font-awesome>
</div>
</h1>
<span class="mt-2 flex justify-between">
@@ -71,92 +71,92 @@
</template>
<script lang="ts">
import { Vue, Component } from 'vue-facing-decorator'
import { Router } from 'vue-router'
import { Vue, Component } from "vue-facing-decorator";
import { Router } from "vue-router";
import { AppString, NotificationIface } from '../constants/app'
import { db } from '../db/index'
import { Contact } from '../db/tables/contacts'
import { GiverReceiverInputInfo } from '../libs/util'
import { AppString, NotificationIface } from "../constants/app";
import { db } from "../db/index";
import { Contact } from "../db/tables/contacts";
import { GiverReceiverInputInfo } from "../libs/util";
@Component
export default class GivenPrompts extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void
$router!: Router
$notify!: (notification: NotificationIface, timeout?: number) => void;
$router!: Router;
CATEGORY_CONTACTS = 1
CATEGORY_IDEAS = 0
CATEGORY_CONTACTS = 1;
CATEGORY_IDEAS = 0;
IDEAS = [
'What food did someone make? (How did it free up your time for something? Was something doable because it eased your stress?)',
'What did a family member do? (How did you take better action because it made you feel loved?)',
'What compliment did someone give you? (What task could you tackle because it boosted your confidence?)',
'Who is someone you can always rely on, and how did they demonstrate that? (What project tasks were enabled because you could depend on them?)',
'What did you see someone give to someone else? (What is the effect of the positivity you gained from seeing that?)',
'What is a way that someone helped you even though you have never met? (What different action did you take due to that newfound perspective or inspiration?)',
'How did a musician or author or artist inspire you? (What were you motivated to do more creatively because of that?)',
'What inspiration did you get from someone who handled tragedy well? (What could you accomplish with better grace or resilience after seeing their example?)',
'What is something worth respect that an organization gave you? (How did their contribution improve the situation or enable new activities?)',
'Who last gave you a good laugh? (What kind of bond or revitalization did that bring to a situation?)',
'What do you recall someone giving you while you were young? (How did it bring excitement or teach a skill or ignite a passion that resulted in improvements in your life?)',
'Who forgave you or overlooked a mistake? (How did that free you or build trust that enabled better relationships?)',
'What is a way an ancestor contributed to your life? (What in your life is now possible because of their efforts? What challenges are you undertaking knowing of their lives?)',
'What kind of help did someone at work give you? (How did that help with team progress? How did that lift your professional growth?)',
'How did a teacher or mentor or great example help you? (How did their guidance enhance your attitude or actions?)',
'What is a surprise gift you received? (What extra possibilities did it give you?)'
]
"What food did someone make? (How did it free up your time for something? Was something doable because it eased your stress?)",
"What did a family member do? (How did you take better action because it made you feel loved?)",
"What compliment did someone give you? (What task could you tackle because it boosted your confidence?)",
"Who is someone you can always rely on, and how did they demonstrate that? (What project tasks were enabled because you could depend on them?)",
"What did you see someone give to someone else? (What is the effect of the positivity you gained from seeing that?)",
"What is a way that someone helped you even though you have never met? (What different action did you take due to that newfound perspective or inspiration?)",
"How did a musician or author or artist inspire you? (What were you motivated to do more creatively because of that?)",
"What inspiration did you get from someone who handled tragedy well? (What could you accomplish with better grace or resilience after seeing their example?)",
"What is something worth respect that an organization gave you? (How did their contribution improve the situation or enable new activities?)",
"Who last gave you a good laugh? (What kind of bond or revitalization did that bring to a situation?)",
"What do you recall someone giving you while you were young? (How did it bring excitement or teach a skill or ignite a passion that resulted in improvements in your life?)",
"Who forgave you or overlooked a mistake? (How did that free you or build trust that enabled better relationships?)",
"What is a way an ancestor contributed to your life? (What in your life is now possible because of their efforts? What challenges are you undertaking knowing of their lives?)",
"What kind of help did someone at work give you? (How did that help with team progress? How did that lift your professional growth?)",
"How did a teacher or mentor or great example help you? (How did their guidance enhance your attitude or actions?)",
"What is a surprise gift you received? (What extra possibilities did it give you?)",
];
callbackOnFullGiftInfo?: (
contactInfo?: GiverReceiverInputInfo,
description?: string
) => void
currentCategory = this.CATEGORY_IDEAS // 0 = IDEAS, 1 = CONTACTS
currentContact: Contact | undefined = undefined
currentIdeaIndex = 0
numContacts = 0
shownContactDbIndices: Array<boolean> = []
visible = false
description?: string,
) => void;
currentCategory = this.CATEGORY_IDEAS; // 0 = IDEAS, 1 = CONTACTS
currentContact: Contact | undefined = undefined;
currentIdeaIndex = 0;
numContacts = 0;
shownContactDbIndices: Array<boolean> = [];
visible = false;
AppString = AppString
AppString = AppString;
async open(
callbackOnFullGiftInfo?: (
contactInfo?: GiverReceiverInputInfo,
description?: string
) => void
description?: string,
) => void,
) {
this.visible = true
this.callbackOnFullGiftInfo = callbackOnFullGiftInfo
this.visible = true;
this.callbackOnFullGiftInfo = callbackOnFullGiftInfo;
await db.open()
this.numContacts = await db.contacts.count()
this.shownContactDbIndices = new Array<boolean>(this.numContacts) // all undefined to start
await db.open();
this.numContacts = await db.contacts.count();
this.shownContactDbIndices = new Array<boolean>(this.numContacts); // all undefined to start
}
cancel() {
this.currentCategory = this.CATEGORY_IDEAS
this.currentContact = undefined
this.currentIdeaIndex = 0
this.numContacts = 0
this.shownContactDbIndices = []
this.currentCategory = this.CATEGORY_IDEAS;
this.currentContact = undefined;
this.currentIdeaIndex = 0;
this.numContacts = 0;
this.shownContactDbIndices = [];
this.visible = false
this.visible = false;
}
proceed() {
// proceed with logic but don't change values (just in case some actions are added later)
this.visible = false
this.visible = false;
if (this.currentCategory === this.CATEGORY_IDEAS) {
this.$router.push({
name: 'contact-gift',
name: "contact-gift",
query: {
prompt: this.IDEAS[this.currentIdeaIndex]
}
})
prompt: this.IDEAS[this.currentIdeaIndex],
},
});
} else {
// must be this.CATEGORY_CONTACTS
this.callbackOnFullGiftInfo?.(
this.currentContact as GiverReceiverInputInfo
)
this.currentContact as GiverReceiverInputInfo,
);
}
}
@@ -167,14 +167,14 @@ export default class GivenPrompts extends Vue {
async nextIdea() {
// check if the next one is an idea or a contact
if (this.currentCategory === this.CATEGORY_IDEAS) {
this.currentIdeaIndex++
this.currentIdeaIndex++;
if (this.currentIdeaIndex === this.IDEAS.length) {
// must have just finished ideas so move to contacts
this.findNextUnshownContact()
this.findNextUnshownContact();
}
} else {
// must be this.CATEGORY_CONTACTS
this.findNextUnshownContact()
this.findNextUnshownContact();
// when that's finished, it'll reset to ideas
}
}
@@ -186,52 +186,54 @@ export default class GivenPrompts extends Vue {
async prevIdea() {
// check if the next one is an idea or a contact
if (this.currentCategory === this.CATEGORY_IDEAS) {
this.currentIdeaIndex--
this.currentIdeaIndex--;
if (this.currentIdeaIndex < 0) {
// must have just finished ideas so move to contacts
this.findNextUnshownContact()
this.findNextUnshownContact();
}
} else {
// must be this.CATEGORY_CONTACTS
this.findNextUnshownContact()
this.findNextUnshownContact();
// when that's finished, it'll reset to ideas
}
}
nextIdeaPastContacts() {
this.currentContact = undefined
this.shownContactDbIndices = new Array<boolean>(this.numContacts)
this.currentContact = undefined;
this.shownContactDbIndices = new Array<boolean>(this.numContacts);
this.currentCategory = this.CATEGORY_IDEAS
this.currentCategory = this.CATEGORY_IDEAS;
// look at the previous idea and switch to the other side of the list
this.currentIdeaIndex =
this.currentIdeaIndex >= this.IDEAS.length ? 0 : this.IDEAS.length - 1
this.currentIdeaIndex >= this.IDEAS.length ? 0 : this.IDEAS.length - 1;
}
async findNextUnshownContact() {
if (this.currentCategory === this.CATEGORY_IDEAS) {
// we're not in the contact prompts, so reset index array
this.shownContactDbIndices = new Array<boolean>(this.numContacts)
this.shownContactDbIndices = new Array<boolean>(this.numContacts);
}
this.currentCategory = this.CATEGORY_CONTACTS
this.currentCategory = this.CATEGORY_CONTACTS;
let someContactDbIndex = Math.floor(Math.random() * this.numContacts)
let count = 0
let someContactDbIndex = Math.floor(Math.random() * this.numContacts);
let count = 0;
// as long as the index has an entry, loop
while (
this.shownContactDbIndices[someContactDbIndex] != null &&
count++ < this.numContacts
) {
someContactDbIndex = (someContactDbIndex + 1) % this.numContacts
someContactDbIndex = (someContactDbIndex + 1) % this.numContacts;
}
if (count >= this.numContacts) {
// all contacts have been shown
this.nextIdeaPastContacts()
this.nextIdeaPastContacts();
} else {
// get the contact at that offset
await db.open()
this.currentContact = await db.contacts.offset(someContactDbIndex).first()
this.shownContactDbIndices[someContactDbIndex] = true
await db.open();
this.currentContact = await db.contacts
.offset(someContactDbIndex)
.first();
this.shownContactDbIndices[someContactDbIndex] = true;
}
}
}

View File

@@ -100,33 +100,33 @@
</template>
<script lang="ts">
import { Component, Vue } from 'vue-facing-decorator'
import * as R from 'ramda'
import { useClipboard } from '@vueuse/core'
import { Contact } from '../db/tables/contacts'
import * as serverUtil from '../libs/endorserServer'
import { NotificationIface } from '../constants/app'
import { Component, Vue } from "vue-facing-decorator";
import * as R from "ramda";
import { useClipboard } from "@vueuse/core";
import { Contact } from "../db/tables/contacts";
import * as serverUtil from "../libs/endorserServer";
import { NotificationIface } from "../constants/app";
@Component
export default class HiddenDidDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void
$notify!: (notification: NotificationIface, timeout?: number) => void;
isOpen = false
roleName = ''
visibleToDids: string[] = []
allContacts: Array<Contact> = []
activeDid = ''
allMyDids: Array<string> = []
canShare = false
windowLocation = window.location.href
isOpen = false;
roleName = "";
visibleToDids: string[] = [];
allContacts: Array<Contact> = [];
activeDid = "";
allMyDids: Array<string> = [];
canShare = false;
windowLocation = window.location.href;
R = R
serverUtil = serverUtil
R = R;
serverUtil = serverUtil;
created() {
// When Chrome compatibility is fixed https://developer.mozilla.org/en-US/docs/Web/API/Web_Share_API#api.navigator.canshare
// then use this truer check: navigator.canShare && navigator.canShare()
this.canShare = !!navigator.share
this.canShare = !!navigator.share;
}
open(
@@ -134,18 +134,18 @@ export default class HiddenDidDialog extends Vue {
visibleToDids: string[],
allContacts: Array<Contact>,
activeDid: string,
allMyDids: Array<string>
allMyDids: Array<string>,
) {
this.roleName = roleName
this.visibleToDids = visibleToDids
this.allContacts = allContacts
this.activeDid = activeDid
this.allMyDids = allMyDids
this.isOpen = true
this.roleName = roleName;
this.visibleToDids = visibleToDids;
this.allContacts = allContacts;
this.activeDid = activeDid;
this.allMyDids = allMyDids;
this.isOpen = true;
}
close() {
this.isOpen = false
this.isOpen = false;
}
didInfo(did: string) {
@@ -153,8 +153,8 @@ export default class HiddenDidDialog extends Vue {
did,
this.activeDid,
this.allMyDids,
this.allContacts
)
this.allContacts,
);
}
copyToClipboard(name: string, text: string) {
@@ -163,23 +163,23 @@ export default class HiddenDidDialog extends Vue {
.then(() => {
this.$notify(
{
group: 'alert',
type: 'toast',
title: 'Copied',
text: (name || 'That') + ' was copied to the clipboard.'
group: "alert",
type: "toast",
title: "Copied",
text: (name || "That") + " was copied to the clipboard.",
},
2000
)
})
2000,
);
});
}
onClickShareClaim() {
this.copyToClipboard('A link to this page', this.windowLocation)
this.copyToClipboard("A link to this page", this.windowLocation);
window.navigator.share({
title: 'Help Connect Me',
title: "Help Connect Me",
text: "I'm trying to find the people who recorded this. Can you help me?",
url: this.windowLocation
})
url: this.windowLocation,
});
}
}
</script>

View File

@@ -12,7 +12,7 @@
class="text-lg text-center px-2 py-0.5 leading-none absolute right-0 top-0 text-white"
@click="close()"
>
<font-awesome icon="xmark" class="w-[1em]" />
<font-awesome icon="xmark" class="w-[1em]"></font-awesome>
</div>
</div>
@@ -56,102 +56,103 @@
</template>
<script lang="ts">
import axios from 'axios'
import { ref } from 'vue'
import { Component, Vue } from 'vue-facing-decorator'
import axios from "axios";
import { ref } from "vue";
import { Component, Vue } from "vue-facing-decorator";
import PhotoDialog from '../components/PhotoDialog.vue'
import { NotificationIface } from '../constants/app'
import PhotoDialog from "../components/PhotoDialog.vue";
import { NotificationIface } from "../constants/app";
const inputImageFileNameRef = ref<Blob>()
const inputImageFileNameRef = ref<Blob>();
@Component({
components: { PhotoDialog }
components: { PhotoDialog },
})
export default class ImageMethodDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void
$notify!: (notification: NotificationIface, timeout?: number) => void;
claimType: string
crop: boolean = false
imageCallback: (imageUrl?: string) => void = () => {}
imageUrl?: string
visible = false
claimType: string;
crop: boolean = false;
imageCallback: (imageUrl?: string) => void = () => {};
imageUrl?: string;
visible = false;
open(setImageFn: (arg: string) => void, claimType: string, crop?: boolean) {
this.claimType = claimType
this.crop = !!crop
this.imageCallback = setImageFn
this.claimType = claimType;
this.crop = !!crop;
this.imageCallback = setImageFn;
this.visible = true
this.visible = true;
}
openPhotoDialog(blob?: Blob, fileName?: string) {
this.visible = false
;(this.$refs.photoDialog as PhotoDialog).open(
this.visible = false;
(this.$refs.photoDialog as PhotoDialog).open(
this.imageCallback,
this.claimType,
this.crop,
blob,
fileName
)
fileName,
);
}
async uploadImageFile(event: Event) {
this.visible = false
this.visible = false;
inputImageFileNameRef.value = event.target.files[0]
inputImageFileNameRef.value = event.target.files[0];
// https://developer.mozilla.org/en-US/docs/Web/API/File
// ... plus it has a `type` property from my testing
const file = inputImageFileNameRef.value
const file = inputImageFileNameRef.value;
if (file != null) {
const reader = new FileReader()
const reader = new FileReader();
reader.onload = async (e) => {
const data = e.target?.result as ArrayBuffer
const data = e.target?.result as ArrayBuffer;
if (data) {
const blob = new Blob([new Uint8Array(data)], {
type: file.type
})
this.openPhotoDialog(blob, file.name as string)
type: file.type,
});
this.openPhotoDialog(blob, file.name as string);
}
}
reader.readAsArrayBuffer(file as Blob)
};
reader.readAsArrayBuffer(file as Blob);
}
}
async acceptUrl() {
this.visible = false
this.visible = false;
if (this.crop) {
try {
const urlBlobResponse: Blob = await axios.get(this.imageUrl as string, {
responseType: 'blob' // This ensures the data is returned as a Blob
})
const fullUrl = new URL(this.imageUrl as string)
const fileName = fullUrl.pathname.split('/').pop() as string
;(this.$refs.photoDialog as PhotoDialog).open(
responseType: "blob", // This ensures the data is returned as a Blob
});
const fullUrl = new URL(this.imageUrl as string);
const fileName = fullUrl.pathname.split("/").pop() as string;
(this.$refs.photoDialog as PhotoDialog).open(
this.imageCallback,
this.claimType,
this.crop,
urlBlobResponse.data as Blob,
fileName
)
fileName,
);
} catch (error) {
this.$notify(
{
group: 'alert',
type: 'danger',
title: 'Error',
text: 'There was an error retrieving that image.'
group: "alert",
type: "danger",
title: "Error",
text: "There was an error retrieving that image.",
},
5000
)
5000,
);
}
} else {
this.imageCallback(this.imageUrl)
this.imageCallback(this.imageUrl);
}
}
close() {
this.visible = false
this.visible = false;
}
}
</script>

View File

@@ -38,44 +38,44 @@
</template>
<script lang="ts">
import { Component, Vue, Prop } from 'vue-facing-decorator'
import { UAParser } from 'ua-parser-js'
import { Component, Vue, Prop } from "vue-facing-decorator";
import { UAParser } from "ua-parser-js";
@Component({ emits: ['update:isOpen'] })
@Component({ emits: ["update:isOpen"] })
export default class ImageViewer extends Vue {
@Prop() imageUrl!: string
@Prop() imageData!: Blob | null
@Prop() isOpen!: boolean
@Prop() imageUrl!: string;
@Prop() imageData!: Blob | null;
@Prop() isOpen!: boolean;
userAgent = new UAParser()
userAgent = new UAParser();
get isMobile() {
const os = this.userAgent.getOS().name
return os === 'iOS' || os === 'Android'
const os = this.userAgent.getOS().name;
return os === "iOS" || os === "Android";
}
close() {
this.$emit('update:isOpen', false)
this.$emit("update:isOpen", false);
}
async handleShare() {
const os = this.userAgent.getOS().name
const os = this.userAgent.getOS().name;
try {
if (os === 'iOS' || os === 'Android') {
if (os === "iOS" || os === "Android") {
if (navigator.share) {
// Always share the URL since it's more reliable across platforms
await navigator.share({
url: this.imageUrl
})
url: this.imageUrl,
});
} else {
// Fallback for browsers without share API
window.open(this.imageUrl, '_blank')
window.open(this.imageUrl, "_blank");
}
}
} catch (error) {
logger.warn('Share failed, opening in new tab:', error)
window.open(this.imageUrl, '_blank')
logger.warn("Share failed, opening in new tab:", error);
window.open(this.imageUrl, "_blank");
}
}
}

View File

@@ -7,12 +7,12 @@ loading state management. * * @author Matthew Raymer * @version 1.0.0 */
<template>
<div ref="scrollContainer">
<slot />
<div ref="sentinel" style="height: 1px" />
<div ref="sentinel" style="height: 1px"></div>
</div>
</template>
<script lang="ts">
import { Component, Emit, Prop, Vue } from 'vue-facing-decorator'
import { Component, Emit, Prop, Vue } from "vue-facing-decorator";
/**
* InfiniteScroll Component
@@ -38,19 +38,19 @@ import { Component, Emit, Prop, Vue } from 'vue-facing-decorator'
export default class InfiniteScroll extends Vue {
/** Distance in pixels from the bottom at which to trigger the reached-bottom event */
@Prop({ default: 200 })
readonly distance!: number
readonly distance!: number;
/** Intersection Observer instance for detecting scroll position */
private observer!: IntersectionObserver
private observer!: IntersectionObserver;
/** Flag to track initial render state */
private isInitialRender = true
private isInitialRender = true;
/** Flag to prevent multiple simultaneous loading states */
private isLoading = false
private isLoading = false;
/** Timeout ID for debouncing scroll events */
private debounceTimeout: number | null = null
private debounceTimeout: number | null = null;
/**
* Vue lifecycle hook that runs after component updates.
@@ -64,10 +64,13 @@ export default class InfiniteScroll extends Vue {
const options = {
root: null,
rootMargin: `0px 0px ${this.distance}px 0px`,
threshold: 1.0
}
this.observer = new IntersectionObserver(this.handleIntersection, options)
this.observer.observe(this.$refs.sentinel as HTMLElement)
threshold: 1.0,
};
this.observer = new IntersectionObserver(
this.handleIntersection,
options,
);
this.observer.observe(this.$refs.sentinel as HTMLElement);
}
}
@@ -80,10 +83,10 @@ export default class InfiniteScroll extends Vue {
*/
beforeUnmount() {
if (this.observer) {
this.observer.disconnect()
this.observer.disconnect();
}
if (this.debounceTimeout) {
window.clearTimeout(this.debounceTimeout)
window.clearTimeout(this.debounceTimeout);
}
}
@@ -98,25 +101,25 @@ export default class InfiniteScroll extends Vue {
* Used internally by the Intersection Observer
* @emits reached-bottom - Emitted when the user scrolls near the bottom
*/
@Emit('reached-bottom')
@Emit("reached-bottom")
handleIntersection(entries: IntersectionObserverEntry[]) {
const entry = entries[0]
const entry = entries[0];
if (entry.isIntersecting && !this.isLoading) {
// Debounce the intersection event
if (this.debounceTimeout) {
window.clearTimeout(this.debounceTimeout)
window.clearTimeout(this.debounceTimeout);
}
this.debounceTimeout = window.setTimeout(() => {
this.isLoading = true
this.$emit('reached-bottom', true)
this.isLoading = true;
this.$emit("reached-bottom", true);
// Reset loading state after a short delay
setTimeout(() => {
this.isLoading = false
}, 1000)
}, 300)
this.isLoading = false;
}, 1000);
}, 300);
}
return false
return false;
}
}
</script>

View File

@@ -46,50 +46,50 @@
</template>
<script lang="ts">
import { Vue, Component } from 'vue-facing-decorator'
import { Vue, Component } from "vue-facing-decorator";
import { NotificationIface } from '../constants/app'
import { NotificationIface } from "../constants/app";
@Component
export default class InviteDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void
$notify!: (notification: NotificationIface, timeout?: number) => void;
callback: (text: string, expiresAt: string) => void = () => {}
inviteIdentifier = ''
text = ''
visible = false
callback: (text: string, expiresAt: string) => void = () => {};
inviteIdentifier = "";
text = "";
visible = false;
expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7)
.toISOString()
.substring(0, 10)
.substring(0, 10);
async open(
inviteIdentifier: string,
aCallback: (text: string, expiresAt: string) => void
aCallback: (text: string, expiresAt: string) => void,
) {
this.callback = aCallback
this.inviteIdentifier = inviteIdentifier
this.visible = true
this.callback = aCallback;
this.inviteIdentifier = inviteIdentifier;
this.visible = true;
}
async onClickSaveChanges() {
if (!this.expiresAt) {
this.$notify(
{
group: 'alert',
type: 'warning',
title: 'Needs Expiration',
text: 'You must select an expiration date.'
group: "alert",
type: "warning",
title: "Needs Expiration",
text: "You must select an expiration date.",
},
5000
)
5000,
);
} else {
this.callback(this.text, this.expiresAt)
this.visible = false
this.callback(this.text, this.expiresAt);
this.visible = false;
}
}
onClickCancel() {
this.visible = false
this.visible = false;
}
}
</script>

View File

@@ -80,9 +80,7 @@
>
<div class="flex items-center justify-between">
<div class="flex items-center">
<h3 class="text-lg font-medium">
{{ member.name }}
</h3>
<h3 class="text-lg font-medium">{{ member.name }}</h3>
<div
v-if="!getContactFor(member.did) && member.did !== activeDid"
class="flex justify-end"
@@ -101,7 +99,7 @@
title="Contact info"
@click="
informAboutAddingContact(
getContactFor(member.did) !== undefined
getContactFor(member.did) !== undefined,
)
"
>
@@ -159,138 +157,138 @@
</template>
<script lang="ts">
import { Component, Vue, Prop } from 'vue-facing-decorator'
import { Component, Vue, Prop } from "vue-facing-decorator";
import {
logConsoleAndDb,
retrieveSettingsForActiveAccount,
db
} from '../db/index'
db,
} from "../db/index";
import {
errorStringForLog,
getHeaders,
register,
serverMessageForUser
} from '../libs/endorserServer'
import { decryptMessage } from '../libs/crypto'
import { Contact } from '../db/tables/contacts'
import * as libsUtil from '../libs/util'
import { NotificationIface } from '../constants/app'
serverMessageForUser,
} from "../libs/endorserServer";
import { decryptMessage } from "../libs/crypto";
import { Contact } from "../db/tables/contacts";
import * as libsUtil from "../libs/util";
import { NotificationIface } from "../constants/app";
interface Member {
admitted: boolean
content: string
memberId: number
admitted: boolean;
content: string;
memberId: number;
}
interface DecryptedMember {
member: Member
name: string
did: string
isRegistered: boolean
member: Member;
name: string;
did: string;
isRegistered: boolean;
}
@Component
export default class MembersList extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void
$notify!: (notification: NotificationIface, timeout?: number) => void;
libsUtil = libsUtil
libsUtil = libsUtil;
@Prop({ required: true }) password!: string
@Prop({ default: false }) showOrganizerTools!: boolean
@Prop({ required: true }) password!: string;
@Prop({ default: false }) showOrganizerTools!: boolean;
decryptedMembers: DecryptedMember[] = []
firstName = ''
isLoading = true
isOrganizer = false
members: Member[] = []
missingPassword = false
missingMyself = false
activeDid = ''
apiServer = ''
contacts: Array<Contact> = []
decryptedMembers: DecryptedMember[] = [];
firstName = "";
isLoading = true;
isOrganizer = false;
members: Member[] = [];
missingPassword = false;
missingMyself = false;
activeDid = "";
apiServer = "";
contacts: Array<Contact> = [];
async created() {
const settings = await retrieveSettingsForActiveAccount()
this.activeDid = settings.activeDid || ''
this.apiServer = settings.apiServer || ''
this.firstName = settings.firstName || ''
await this.fetchMembers()
await this.loadContacts()
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
this.firstName = settings.firstName || "";
await this.fetchMembers();
await this.loadContacts();
}
async fetchMembers() {
try {
this.isLoading = true
const headers = await getHeaders(this.activeDid)
this.isLoading = true;
const headers = await getHeaders(this.activeDid);
const response = await this.axios.get(
`${this.apiServer}/api/partner/groupOnboardMembers`,
{ headers }
)
{ headers },
);
if (response.data && response.data.data) {
this.members = response.data.data
await this.decryptMemberContents()
this.members = response.data.data;
await this.decryptMemberContents();
}
} catch (error) {
logConsoleAndDb(
'Error fetching members: ' + errorStringForLog(error),
true
)
"Error fetching members: " + errorStringForLog(error),
true,
);
this.$emit(
'error',
serverMessageForUser(error) || 'Failed to fetch members.'
)
"error",
serverMessageForUser(error) || "Failed to fetch members.",
);
} finally {
this.isLoading = false
this.isLoading = false;
}
}
async decryptMemberContents() {
this.decryptedMembers = []
this.decryptedMembers = [];
if (!this.password) {
this.missingPassword = true
return
this.missingPassword = true;
return;
}
let isFirstEntry = true,
foundMyself = false
foundMyself = false;
for (const member of this.members) {
try {
const decryptedContent = await decryptMessage(
member.content,
this.password
)
const content = JSON.parse(decryptedContent)
this.password,
);
const content = JSON.parse(decryptedContent);
this.decryptedMembers.push({
member: member,
name: content.name,
did: content.did,
isRegistered: !!content.isRegistered
})
isRegistered: !!content.isRegistered,
});
if (isFirstEntry && content.did === this.activeDid) {
this.isOrganizer = true
this.isOrganizer = true;
}
if (content.did === this.activeDid) {
foundMyself = true
foundMyself = true;
}
} catch (error) {
// do nothing, relying on the count of members to determine if there was an error
}
isFirstEntry = false
isFirstEntry = false;
}
this.missingMyself = !foundMyself
this.missingMyself = !foundMyself;
}
decryptionErrorMessage(): string {
if (this.isOrganizer) {
if (this.decryptedMembers.length < this.members.length) {
return 'Some members have data that cannot be decrypted with that password.'
return "Some members have data that cannot be decrypted with that password.";
} else {
// the lists must be equal
return ''
return "";
}
} else {
// non-organizers should only see problems if the first (organizer) member is not decrypted
@@ -298,10 +296,10 @@ export default class MembersList extends Vue {
this.decryptedMembers.length === 0 ||
this.decryptedMembers[0].member.memberId !== this.members[0].memberId
) {
return 'Your password is not the same as the organizer. Reload or have them check their password.'
return "Your password is not the same as the organizer. Reload or have them check their password.";
} else {
// the first (organizer) member was decrypted OK
return ''
return "";
}
}
}
@@ -309,118 +307,118 @@ export default class MembersList extends Vue {
membersToShow(): DecryptedMember[] {
if (this.isOrganizer) {
if (this.showOrganizerTools) {
return this.decryptedMembers
return this.decryptedMembers;
} else {
return this.decryptedMembers.filter(
(member: DecryptedMember) => member.member.admitted
)
(member: DecryptedMember) => member.member.admitted,
);
}
}
// non-organizers only get visible members from server
return this.decryptedMembers
return this.decryptedMembers;
}
informAboutAdmission() {
this.$notify(
{
group: 'alert',
type: 'info',
title: 'Admission info',
text: "This is to register people in Time Safari and to admit them to the meeting. A '+' symbol means they are not yet admitted and you can register and admit them. A '-' means you can remove them, but they will stay registered."
group: "alert",
type: "info",
title: "Admission info",
text: "This is to register people in Time Safari and to admit them to the meeting. A '+' symbol means they are not yet admitted and you can register and admit them. A '-' means you can remove them, but they will stay registered.",
},
10000
)
10000,
);
}
informAboutAddingContact(contactImportedAlready: boolean) {
if (contactImportedAlready) {
this.$notify(
{
group: 'alert',
type: 'info',
title: 'Contact Exists',
text: 'They are in your contacts. If you want to remove them, you must do that from the contacts screen.'
group: "alert",
type: "info",
title: "Contact Exists",
text: "They are in your contacts. If you want to remove them, you must do that from the contacts screen.",
},
10000
)
10000,
);
} else {
this.$notify(
{
group: 'alert',
type: 'info',
title: 'Contact Available',
text: 'This is to add them to your contacts. If you want to remove them later, you must do that from the contacts screen.'
group: "alert",
type: "info",
title: "Contact Available",
text: "This is to add them to your contacts. If you want to remove them later, you must do that from the contacts screen.",
},
10000
)
10000,
);
}
}
async loadContacts() {
this.contacts = await db.contacts.toArray()
this.contacts = await db.contacts.toArray();
}
getContactFor(did: string): Contact | undefined {
return this.contacts.find((contact) => contact.did === did)
return this.contacts.find((contact) => contact.did === did);
}
checkWhetherContactBeforeAdmitting(decrMember: DecryptedMember) {
const contact = this.getContactFor(decrMember.did)
const contact = this.getContactFor(decrMember.did);
if (!decrMember.member.admitted && !contact) {
// If not a contact, show confirmation dialog
this.$notify(
{
group: 'modal',
type: 'confirm',
title: 'Add as Contact First?',
text: 'This person is not in your contacts. Would you like to add them as a contact first?',
yesText: 'Add as Contact',
noText: 'Skip Adding Contact',
group: "modal",
type: "confirm",
title: "Add as Contact First?",
text: "This person is not in your contacts. Would you like to add them as a contact first?",
yesText: "Add as Contact",
noText: "Skip Adding Contact",
onYes: async () => {
await this.addAsContact(decrMember)
await this.addAsContact(decrMember);
// After adding as contact, proceed with admission
await this.toggleAdmission(decrMember)
await this.toggleAdmission(decrMember);
},
onNo: async () => {
// If they choose not to add as contact, show second confirmation
this.$notify(
{
group: 'modal',
type: 'confirm',
title: 'Continue Without Adding?',
text: 'Are you sure you want to proceed with admission? If they are not a contact, you will not know their name after this meeting.',
yesText: 'Continue',
group: "modal",
type: "confirm",
title: "Continue Without Adding?",
text: "Are you sure you want to proceed with admission? If they are not a contact, you will not know their name after this meeting.",
yesText: "Continue",
onYes: async () => {
await this.toggleAdmission(decrMember)
await this.toggleAdmission(decrMember);
},
onCancel: async () => {
// Do nothing, effectively canceling the operation
}
},
},
-1
)
}
-1,
);
},
},
-1
)
-1,
);
} else {
// If already a contact, proceed directly with admission
this.toggleAdmission(decrMember)
this.toggleAdmission(decrMember);
}
}
async toggleAdmission(decrMember: DecryptedMember) {
try {
const headers = await getHeaders(this.activeDid)
const headers = await getHeaders(this.activeDid);
await this.axios.put(
`${this.apiServer}/api/partner/groupOnboardMember/${decrMember.member.memberId}`,
{ admitted: !decrMember.member.admitted },
{ headers }
)
{ headers },
);
// Update local state
decrMember.member.admitted = !decrMember.member.admitted
decrMember.member.admitted = !decrMember.member.admitted;
const oldContact = this.getContactFor(decrMember.did)
const oldContact = this.getContactFor(decrMember.did);
// if admitted, now register that user if they are not registered
if (
decrMember.member.admitted &&
@@ -429,61 +427,61 @@ export default class MembersList extends Vue {
) {
const contactOldOrNew: Contact = oldContact || {
did: decrMember.did,
name: decrMember.name
}
name: decrMember.name,
};
try {
const result = await register(
this.activeDid,
this.apiServer,
this.axios,
contactOldOrNew
)
contactOldOrNew,
);
if (result.success) {
decrMember.isRegistered = true
decrMember.isRegistered = true;
if (oldContact) {
await db.contacts.update(decrMember.did, { registered: true })
oldContact.registered = true
await db.contacts.update(decrMember.did, { registered: true });
oldContact.registered = true;
}
this.$notify(
{
group: 'alert',
type: 'success',
title: 'Registered',
text: 'Besides being admitted, they were also registered.'
group: "alert",
type: "success",
title: "Registered",
text: "Besides being admitted, they were also registered.",
},
3000
)
3000,
);
} else {
throw result
throw result;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
// registration failure is likely explained by a message from the server
const additionalInfo =
serverMessageForUser(error) || error?.error || ''
serverMessageForUser(error) || error?.error || "";
this.$notify(
{
group: 'alert',
type: 'warning',
title: 'Registration failed',
group: "alert",
type: "warning",
title: "Registration failed",
text:
'They were admitted to the meeting. However, registration failed. You can register them from the contacts screen. ' +
additionalInfo
"They were admitted to the meeting. However, registration failed. You can register them from the contacts screen. " +
additionalInfo,
},
12000
)
12000,
);
}
}
} catch (error) {
logConsoleAndDb(
'Error toggling admission: ' + errorStringForLog(error),
true
)
"Error toggling admission: " + errorStringForLog(error),
true,
);
this.$emit(
'error',
"error",
serverMessageForUser(error) ||
'Failed to update member admission status.'
)
"Failed to update member admission status.",
);
}
}
@@ -491,36 +489,36 @@ export default class MembersList extends Vue {
try {
const newContact = {
did: member.did,
name: member.name
}
name: member.name,
};
await db.contacts.add(newContact)
this.contacts.push(newContact)
await db.contacts.add(newContact);
this.contacts.push(newContact);
this.$notify(
{
group: 'alert',
type: 'success',
title: 'Contact Added',
text: 'They were added to your contacts.'
group: "alert",
type: "success",
title: "Contact Added",
text: "They were added to your contacts.",
},
3000
)
3000,
);
} catch (err) {
logConsoleAndDb('Error adding contact: ' + errorStringForLog(err), true)
let message = 'An error prevented adding this contact.'
if (err instanceof Error && err.message?.indexOf('already exists') > -1) {
message = 'This person is already in your contact list.'
logConsoleAndDb("Error adding contact: " + errorStringForLog(err), true);
let message = "An error prevented adding this contact.";
if (err instanceof Error && err.message?.indexOf("already exists") > -1) {
message = "This person is already in your contact list.";
}
this.$notify(
{
group: 'alert',
type: 'danger',
title: 'Contact Not Added',
text: message
group: "alert",
type: "danger",
title: "Contact Not Added",
text: message,
},
5000
)
5000,
);
}
}
}

View File

@@ -49,8 +49,8 @@
projectName,
recipientDid,
recipientName,
unitCode: amountUnitCode
}
unitCode: amountUnitCode,
},
}"
class="text-blue-500"
>
@@ -80,114 +80,117 @@
</template>
<script lang="ts">
import { Vue, Component, Prop } from 'vue-facing-decorator'
import { Vue, Component, Prop } from "vue-facing-decorator";
import { NotificationIface } from '../constants/app'
import { NotificationIface } from "../constants/app";
import {
createAndSubmitOffer,
serverMessageForUser
} from '../libs/endorserServer'
import * as libsUtil from '../libs/util'
import { retrieveSettingsForActiveAccount } from '../db/index'
import { logger } from '../utils/logger'
serverMessageForUser,
} from "../libs/endorserServer";
import * as libsUtil from "../libs/util";
import { retrieveSettingsForActiveAccount } from "../db/index";
import { logger } from "../utils/logger";
@Component
export default class OfferDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void
$notify!: (notification: NotificationIface, timeout?: number) => void;
@Prop projectId?: string
@Prop projectName?: string
@Prop projectId?: string;
@Prop projectName?: string;
activeDid = ''
apiServer = ''
activeDid = "";
apiServer = "";
amountInput = '0'
amountUnitCode = 'HUR'
description = ''
expirationDateInput = ''
recipientDid? = ''
recipientName? = ''
visible = false
amountInput = "0";
amountUnitCode = "HUR";
description = "";
expirationDateInput = "";
recipientDid? = "";
recipientName? = "";
visible = false;
libsUtil = libsUtil
libsUtil = libsUtil;
async open(recipientDid?: string, recipientName?: string) {
try {
this.recipientDid = recipientDid
this.recipientName = recipientName
this.recipientDid = recipientDid;
this.recipientName = recipientName;
const settings = await retrieveSettingsForActiveAccount()
this.apiServer = settings.apiServer || ''
this.activeDid = settings.activeDid || ''
const settings = await retrieveSettingsForActiveAccount();
this.apiServer = settings.apiServer || "";
this.activeDid = settings.activeDid || "";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
logger.error('Error retrieving settings from database:', err)
logger.error("Error retrieving settings from database:", err);
this.$notify(
{
group: 'alert',
type: 'danger',
title: 'Error',
text: err.message || 'There was an error retrieving your settings.'
group: "alert",
type: "danger",
title: "Error",
text: err.message || "There was an error retrieving your settings.",
},
-1
)
-1,
);
}
this.visible = true
this.visible = true;
}
close() {
// close the dialog but don't change values (since it might be submitting info)
this.visible = false
this.visible = false;
}
changeUnitCode() {
const units = Object.keys(this.libsUtil.UNIT_SHORT)
const index = units.indexOf(this.amountUnitCode)
this.amountUnitCode = units[(index + 1) % units.length]
const units = Object.keys(this.libsUtil.UNIT_SHORT);
const index = units.indexOf(this.amountUnitCode);
this.amountUnitCode = units[(index + 1) % units.length];
}
increment() {
this.amountInput = `${(parseFloat(this.amountInput) || 0) + 1}`
this.amountInput = `${(parseFloat(this.amountInput) || 0) + 1}`;
}
decrement() {
this.amountInput = `${Math.max(0, (parseFloat(this.amountInput) || 1) - 1)}`
this.amountInput = `${Math.max(
0,
(parseFloat(this.amountInput) || 1) - 1,
)}`;
}
cancel() {
this.close()
this.eraseValues()
this.close();
this.eraseValues();
}
eraseValues() {
this.description = ''
this.amountInput = '0'
this.amountUnitCode = 'HUR'
this.description = "";
this.amountInput = "0";
this.amountUnitCode = "HUR";
}
async confirm() {
this.close()
this.close();
this.$notify(
{
group: 'alert',
type: 'toast',
text: 'Recording the offer...',
title: ''
group: "alert",
type: "toast",
text: "Recording the offer...",
title: "",
},
1000
)
1000,
);
// this is asynchronous, but we don't need to wait for it to complete
this.recordOffer(
this.description,
parseFloat(this.amountInput),
this.amountUnitCode,
this.expirationDateInput
this.expirationDateInput,
).then(() => {
this.description = ''
this.amountInput = '0'
})
this.description = "";
this.amountInput = "0";
});
}
/**
@@ -199,33 +202,33 @@ export default class OfferDialog extends Vue {
public async recordOffer(
description: string,
amount: number,
unitCode: string = 'HUR',
expirationDateInput?: string
unitCode: string = "HUR",
expirationDateInput?: string,
) {
if (!this.activeDid) {
this.$notify(
{
group: 'alert',
type: 'danger',
title: 'Error',
text: 'You must select an identity before you can record an offer.'
group: "alert",
type: "danger",
title: "Error",
text: "You must select an identity before you can record an offer.",
},
7000
)
return
7000,
);
return;
}
if (!description && !amount) {
this.$notify(
{
group: 'alert',
type: 'danger',
title: 'Error',
text: `You must enter a description or some number of ${this.libsUtil.UNIT_LONG[unitCode]}.`
group: "alert",
type: "danger",
title: "Error",
text: `You must enter a description or some number of ${this.libsUtil.UNIT_LONG[unitCode]}.`,
},
-1
)
return
-1,
);
return;
}
try {
@@ -236,54 +239,54 @@ export default class OfferDialog extends Vue {
description,
amount,
unitCode,
'',
"",
expirationDateInput,
this.recipientDid,
this.projectId
)
this.projectId,
);
if (
result.type === 'error' ||
result.type === "error" ||
this.isOfferCreationError(result.response)
) {
const errorMessage = this.getOfferCreationErrorMessage(result)
logger.error('Error with offer creation result:', result)
const errorMessage = this.getOfferCreationErrorMessage(result);
logger.error("Error with offer creation result:", result);
this.$notify(
{
group: 'alert',
type: 'danger',
title: 'Error',
text: errorMessage || 'There was an error creating the offer.'
group: "alert",
type: "danger",
title: "Error",
text: errorMessage || "There was an error creating the offer.",
},
-1
)
-1,
);
} else {
this.$notify(
{
group: 'alert',
type: 'success',
title: 'Success',
text: 'That offer was recorded.'
group: "alert",
type: "success",
title: "Success",
text: "That offer was recorded.",
},
5000
)
5000,
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
logger.error('Error with offer recordation caught:', error)
logger.error("Error with offer recordation caught:", error);
const message =
error.userMessage ||
error.response?.data?.error?.message ||
'There was an error recording the offer.'
"There was an error recording the offer.";
this.$notify(
{
group: 'alert',
type: 'danger',
title: 'Error',
text: message
group: "alert",
type: "danger",
title: "Error",
text: message,
},
-1
)
-1,
);
}
}
@@ -295,7 +298,7 @@ export default class OfferDialog extends Vue {
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
isOfferCreationError(result: any) {
return result.status !== 201 || result.data?.error
return result.status !== 201 || result.data?.error;
}
/**
@@ -308,7 +311,7 @@ export default class OfferDialog extends Vue {
serverMessageForUser(result) ||
result.error?.userMessage ||
result.error?.error
)
);
}
}
</script>

View File

@@ -198,64 +198,64 @@
</template>
<script lang="ts">
import { Component, Vue } from 'vue-facing-decorator'
import { Router } from 'vue-router'
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { NotificationIface } from '../constants/app'
import { NotificationIface } from "../constants/app";
import {
db,
retrieveSettingsForActiveAccount,
updateAccountSettings
} from '../db/index'
import { OnboardPage } from '../libs/util'
updateAccountSettings,
} from "../db/index";
import { OnboardPage } from "../libs/util";
@Component({
computed: {
OnboardPage() {
return OnboardPage
}
return OnboardPage;
},
},
components: { OnboardPage }
components: { OnboardPage },
})
export default class OnboardingDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void
$router!: Router
$notify!: (notification: NotificationIface, timeout?: number) => void;
$router!: Router;
activeDid = ''
firstContactName = null
givenName = ''
isRegistered = false
numContacts = 0
page = OnboardPage.Home
visible = false
activeDid = "";
firstContactName = null;
givenName = "";
isRegistered = false;
numContacts = 0;
page = OnboardPage.Home;
visible = false;
async open(page: OnboardPage) {
this.page = page
const settings = await retrieveSettingsForActiveAccount()
this.activeDid = settings.activeDid || ''
this.isRegistered = !!settings.isRegistered
const contacts = await db.contacts.toArray()
this.numContacts = contacts.length
this.page = page;
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
this.isRegistered = !!settings.isRegistered;
const contacts = await db.contacts.toArray();
this.numContacts = contacts.length;
if (this.numContacts > 0) {
this.firstContactName = contacts[0].name
this.firstContactName = contacts[0].name;
}
this.visible = true
this.visible = true;
if (this.page === OnboardPage.Create) {
// we'll assume that they've been through all the other pages
await updateAccountSettings(this.activeDid, {
finishedOnboarding: true
})
finishedOnboarding: true,
});
}
}
async onClickClose(done?: boolean, goHome?: boolean) {
this.visible = false
this.visible = false;
if (done) {
await updateAccountSettings(this.activeDid, {
finishedOnboarding: true
})
finishedOnboarding: true,
});
if (goHome) {
this.$router.push({ name: 'home' })
this.$router.push({ name: "home" });
}
}
}

View File

@@ -15,7 +15,7 @@
class="text-lg text-center px-2 py-0.5 leading-none absolute right-0 top-0 text-white"
@click="close()"
>
<font-awesome icon="xmark" class="w-[1em]" />
<font-awesome icon="xmark" class="w-[1em]"></font-awesome>
</div>
</div>
@@ -30,13 +30,13 @@
<VuePictureCropper
:box-style="{
backgroundColor: '#f8f8f8',
margin: 'auto'
margin: 'auto',
}"
:img="createBlobURL(blob)"
:options="{
viewMode: 1,
dragMode: 'crop',
aspectRatio: 9 / 9
aspectRatio: 9 / 9,
}"
class="max-h-[90vh] max-w-[90vw] object-contain"
/>
@@ -100,47 +100,52 @@
* @file PhotoDialog.vue
*/
import axios from 'axios'
import { Component, Vue } from 'vue-facing-decorator'
import VuePictureCropper, { cropper } from 'vue-picture-cropper'
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from '../constants/app'
import { retrieveSettingsForActiveAccount } from '../db/index'
import { accessToken } from '../libs/crypto'
import { logger } from '../utils/logger'
import { PlatformServiceFactory } from '../services/PlatformServiceFactory'
import axios from "axios";
import { Component, Vue } from "vue-facing-decorator";
import VuePictureCropper, { cropper } from "vue-picture-cropper";
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "../constants/app";
import { retrieveSettingsForActiveAccount } from "../db/index";
import { accessToken } from "../libs/crypto";
import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
import { PlatformService } from "../services/PlatformService";
@Component({ components: { VuePictureCropper } })
export default class PhotoDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid = ''
blob?: Blob
claimType = ''
crop = false
fileName?: string
setImageCallback: (arg: string) => void = () => {}
showRetry = true
uploading = false
visible = false
activeDid = "";
blob?: Blob;
claimType = "";
crop = false;
fileName?: string;
setImageCallback: (arg: string) => void = () => {};
showRetry = true;
uploading = false;
visible = false;
private platformService = PlatformServiceFactory.getInstance()
URL = window.URL || window.webkitURL
private platformService?: PlatformService;
URL = window.URL || window.webkitURL;
async mounted() {
try {
const settings = await retrieveSettingsForActiveAccount()
this.activeDid = settings.activeDid || ''
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
this.platformService = await PlatformServiceFactory.getInstance();
} catch (err: unknown) {
logger.error('Error retrieving settings from database:', err)
logger.error("Error retrieving settings from database:", err);
this.$notify(
{
group: 'alert',
type: 'danger',
title: 'Error',
text: err.message || 'There was an error retrieving your settings.'
group: "alert",
type: "danger",
title: "Error",
text:
err instanceof Error
? err.message
: "There was an error retrieving your settings.",
},
-1
)
-1,
);
}
}
@@ -149,140 +154,146 @@ export default class PhotoDialog extends Vue {
claimType: string,
crop?: boolean,
blob?: Blob,
inputFileName?: string
inputFileName?: string,
) {
this.visible = true
this.claimType = claimType
this.crop = !!crop
const bottomNav = document.querySelector('#QuickNav') as HTMLElement
this.visible = true;
this.claimType = claimType;
this.crop = !!crop;
const bottomNav = document.querySelector("#QuickNav") as HTMLElement;
if (bottomNav) {
bottomNav.style.display = 'none'
bottomNav.style.display = "none";
}
this.setImageCallback = setImageFn
this.setImageCallback = setImageFn;
if (blob) {
this.blob = blob
this.fileName = inputFileName
this.showRetry = false
this.blob = blob;
this.fileName = inputFileName;
this.showRetry = false;
} else {
this.blob = undefined
this.fileName = undefined
this.showRetry = true
this.blob = undefined;
this.fileName = undefined;
this.showRetry = true;
}
}
close() {
this.visible = false
const bottomNav = document.querySelector('#QuickNav') as HTMLElement
this.visible = false;
const bottomNav = document.querySelector("#QuickNav") as HTMLElement;
if (bottomNav) {
bottomNav.style.display = ''
bottomNav.style.display = "";
}
this.blob = undefined
this.blob = undefined;
}
async takePhoto() {
try {
const result = await this.platformService.takePicture()
this.blob = result.blob
this.fileName = result.fileName
if (!this.platformService) {
this.platformService = await PlatformServiceFactory.getInstance();
}
const result = await this.platformService.takePicture();
this.blob = result.blob;
this.fileName = result.fileName;
} catch (error: unknown) {
logger.error('Error taking picture:', error)
logger.error("Error taking picture:", error);
this.$notify(
{
group: 'alert',
type: 'danger',
title: 'Error',
text: 'Failed to take picture. Please try again.'
group: "alert",
type: "danger",
title: "Error",
text: "Failed to take picture. Please try again.",
},
5000
)
5000,
);
}
}
async pickPhoto() {
try {
const result = await this.platformService.pickImage()
this.blob = result.blob
this.fileName = result.fileName
if (!this.platformService) {
this.platformService = await PlatformServiceFactory.getInstance();
}
const result = await this.platformService.pickImage();
this.blob = result.blob;
this.fileName = result.fileName;
} catch (error: unknown) {
logger.error('Error picking image:', error)
logger.error("Error picking image:", error);
this.$notify(
{
group: 'alert',
type: 'danger',
title: 'Error',
text: 'Failed to pick image. Please try again.'
group: "alert",
type: "danger",
title: "Error",
text: "Failed to pick image. Please try again.",
},
5000
)
5000,
);
}
}
private createBlobURL(blob: Blob): string {
return URL.createObjectURL(blob)
return URL.createObjectURL(blob);
}
async retryImage() {
this.blob = undefined
this.blob = undefined;
}
async uploadImage() {
this.uploading = true
this.uploading = true;
if (this.crop) {
this.blob = (await cropper?.getBlob()) || undefined
this.blob = (await cropper?.getBlob()) || undefined;
}
const token = await accessToken(this.activeDid)
const token = await accessToken(this.activeDid);
const headers = {
Authorization: 'Bearer ' + token
}
const formData = new FormData()
Authorization: "Bearer " + token,
};
const formData = new FormData();
if (!this.blob) {
this.$notify(
{
group: 'alert',
type: 'danger',
title: 'Error',
text: 'There was an error finding the picture. Please try again.'
group: "alert",
type: "danger",
title: "Error",
text: "There was an error finding the picture. Please try again.",
},
5000
)
this.uploading = false
return
5000,
);
this.uploading = false;
return;
}
formData.append('image', this.blob, this.fileName || 'photo.jpg')
formData.append('claimType', this.claimType)
formData.append("image", this.blob, this.fileName || "photo.jpg");
formData.append("claimType", this.claimType);
try {
if (
window.location.hostname === 'localhost' &&
!DEFAULT_IMAGE_API_SERVER.includes('localhost')
window.location.hostname === "localhost" &&
!DEFAULT_IMAGE_API_SERVER.includes("localhost")
) {
logger.log(
'Using shared image API server, so only users on that server can play with images.'
)
"Using shared image API server, so only users on that server can play with images.",
);
}
const response = await axios.post(
DEFAULT_IMAGE_API_SERVER + '/image',
DEFAULT_IMAGE_API_SERVER + "/image",
formData,
{ headers }
)
this.uploading = false
{ headers },
);
this.uploading = false;
this.close()
this.setImageCallback(response.data.url as string)
this.close();
this.setImageCallback(response.data.url as string);
} catch (error: unknown) {
// Log the raw error first
logger.error('Raw error object:', JSON.stringify(error, null, 2))
logger.error("Raw error object:", JSON.stringify(error, null, 2));
let errorMessage = 'There was an error saving the picture.'
let errorMessage = "There was an error saving the picture.";
if (axios.isAxiosError(error)) {
const status = error.response?.status
const statusText = error.response?.statusText
const data = error.response?.data
const status = error.response?.status;
const statusText = error.response?.statusText;
const data = error.response?.data;
// Log detailed error information
logger.error('Upload error details:', {
logger.error("Upload error details:", {
status,
statusText,
data: JSON.stringify(data, null, 2),
@@ -290,49 +301,49 @@ export default class PhotoDialog extends Vue {
config: {
url: error.config?.url,
method: error.config?.method,
headers: error.config?.headers
}
})
headers: error.config?.headers,
},
});
if (status === 401) {
errorMessage = 'Authentication failed. Please try logging in again.'
errorMessage = "Authentication failed. Please try logging in again.";
} else if (status === 413) {
errorMessage = 'Image file is too large. Please try a smaller image.'
errorMessage = "Image file is too large. Please try a smaller image.";
} else if (status === 415) {
errorMessage =
'Unsupported image format. Please try a different image.'
"Unsupported image format. Please try a different image.";
} else if (status && status >= 500) {
errorMessage = 'Server error. Please try again later.'
errorMessage = "Server error. Please try again later.";
} else if (data?.message) {
errorMessage = data.message
errorMessage = data.message;
}
} else if (error instanceof Error) {
// Log non-Axios error with full details
logger.error('Non-Axios error details:', {
logger.error("Non-Axios error details:", {
name: error.name,
message: error.message,
stack: error.stack,
error: JSON.stringify(error, Object.getOwnPropertyNames(error), 2)
})
error: JSON.stringify(error, Object.getOwnPropertyNames(error), 2),
});
} else {
// Log any other type of error
logger.error('Unknown error type:', {
logger.error("Unknown error type:", {
error: JSON.stringify(error, null, 2),
type: typeof error
})
type: typeof error,
});
}
this.$notify(
{
group: 'alert',
type: 'danger',
title: 'Error',
text: errorMessage
group: "alert",
type: "danger",
title: "Error",
text: errorMessage,
},
5000
)
this.uploading = false
this.blob = undefined
5000,
);
this.uploading = false;
this.blob = undefined;
}
}
}

View File

@@ -15,35 +15,35 @@
/>
</template>
<script lang="ts">
import { toSvg } from 'jdenticon'
import { Vue, Component, Prop } from 'vue-facing-decorator'
import { toSvg } from "jdenticon";
import { Vue, Component, Prop } from "vue-facing-decorator";
const BLANK_CONFIG = {
lightness: {
color: [1.0, 1.0],
grayscale: [1.0, 1.0]
grayscale: [1.0, 1.0],
},
saturation: {
color: 0.0,
grayscale: 0.0
grayscale: 0.0,
},
backColor: '#0000'
}
backColor: "#0000",
};
@Component
export default class ProjectIcon extends Vue {
@Prop entityId = ''
@Prop iconSize = 0
@Prop imageUrl = ''
@Prop linkToFull = false
@Prop entityId = "";
@Prop iconSize = 0;
@Prop imageUrl = "";
@Prop linkToFull = false;
generateIdenticon() {
if (this.imageUrl) {
return `<img src="${this.imageUrl}" class="w-full h-full object-contain" />`
return `<img src="${this.imageUrl}" class="w-full h-full object-contain" />`;
} else {
const config = this.entityId ? undefined : BLANK_CONFIG
const svgString = toSvg(this.entityId, this.iconSize, config)
return svgString
const config = this.entityId ? undefined : BLANK_CONFIG;
const svgString = toSvg(this.entityId, this.iconSize, config);
return svgString;
}
}
}

View File

@@ -45,7 +45,7 @@
>
<!-- eslint-enable -->
<span class="w-full flex justify-between text-xs text-slate-500">
<span />
<span></span>
<span>(100 characters max)</span>
</span>
</div>
@@ -79,8 +79,8 @@
<button
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white mt-2 px-2 py-2 rounded-md"
@click="
close()
turnOnNotifications()
close();
turnOnNotifications();
"
>
Turn on Daily Message
@@ -100,46 +100,46 @@
</template>
<script lang="ts">
import { Component, Vue } from 'vue-facing-decorator'
import { Component, Vue } from "vue-facing-decorator";
import { DEFAULT_PUSH_SERVER, NotificationIface } from '../constants/app'
import { DEFAULT_PUSH_SERVER, NotificationIface } from "../constants/app";
import {
logConsoleAndDb,
retrieveSettingsForActiveAccount,
secretDB
} from '../db/index'
import { MASTER_SECRET_KEY } from '../db/tables/secret'
import { urlBase64ToUint8Array } from '../libs/crypto/vc/util'
import * as libsUtil from '../libs/util'
import { logger } from '../utils/logger'
secretDB,
} from "../db/index";
import { MASTER_SECRET_KEY } from "../db/tables/secret";
import { urlBase64ToUint8Array } from "../libs/crypto/vc/util";
import * as libsUtil from "../libs/util";
import { logger } from "../utils/logger";
// Example interface for error
interface ErrorResponse {
message: string
message: string;
}
// PushSubscriptionJSON is defined in the Push API https://www.w3.org/TR/push-api/#dom-pushsubscriptionjson
interface PushSubscriptionWithTime extends PushSubscriptionJSON {
message?: string
notifyTime: { utcHour: number; minute: number }
notifyType: string
message?: string;
notifyTime: { utcHour: number; minute: number };
notifyType: string;
}
interface ServiceWorkerMessage {
type: string
data: string
type: string;
data: string;
}
interface ServiceWorkerResponse {
// Define the properties and their types
success: boolean
message?: string
success: boolean;
message?: string;
}
interface VapidResponse {
data: {
vapidKey: string
}
vapidKey: string;
};
}
@Component
@@ -147,135 +147,135 @@ export default class PushNotificationPermission extends Vue {
// eslint-disable-next-line
$notify!: (notification: NotificationIface, timeout?: number) => Promise<() => void>;
DAILY_CHECK_TITLE = libsUtil.DAILY_CHECK_TITLE
DIRECT_PUSH_TITLE = libsUtil.DIRECT_PUSH_TITLE
DAILY_CHECK_TITLE = libsUtil.DAILY_CHECK_TITLE;
DIRECT_PUSH_TITLE = libsUtil.DIRECT_PUSH_TITLE;
callback: (success: boolean, time: string, message?: string) => void =
() => {}
hourAm = true
hourInput = '8'
isVisible = false
messageInput = ''
minuteInput = '00'
pushType = ''
serviceWorkerReady = false
vapidKey = ''
() => {};
hourAm = true;
hourInput = "8";
isVisible = false;
messageInput = "";
minuteInput = "00";
pushType = "";
serviceWorkerReady = false;
vapidKey = "";
async open(
pushType: string,
callback?: (success: boolean, time: string, message?: string) => void
callback?: (success: boolean, time: string, message?: string) => void,
) {
this.callback = callback || this.callback
this.isVisible = true
this.pushType = pushType
this.callback = callback || this.callback;
this.isVisible = true;
this.pushType = pushType;
try {
const settings = await retrieveSettingsForActiveAccount()
let pushUrl = DEFAULT_PUSH_SERVER
const settings = await retrieveSettingsForActiveAccount();
let pushUrl = DEFAULT_PUSH_SERVER;
if (settings?.webPushServer) {
pushUrl = settings.webPushServer
pushUrl = settings.webPushServer;
}
if (pushUrl.startsWith('http://localhost')) {
logConsoleAndDb('Not checking for VAPID in this local environment.')
if (pushUrl.startsWith("http://localhost")) {
logConsoleAndDb("Not checking for VAPID in this local environment.");
} else {
let responseData = ''
let responseData = "";
await this.axios
.get(pushUrl + '/web-push/vapid')
.get(pushUrl + "/web-push/vapid")
.then((response: VapidResponse) => {
this.vapidKey = response.data?.vapidKey || ''
logConsoleAndDb('Got vapid key: ' + this.vapidKey)
responseData = JSON.stringify(response.data)
this.vapidKey = response.data?.vapidKey || "";
logConsoleAndDb("Got vapid key: " + this.vapidKey);
responseData = JSON.stringify(response.data);
navigator.serviceWorker?.addEventListener(
'controllerchange',
"controllerchange",
() => {
logConsoleAndDb(
'New service worker is now controlling the page'
)
}
)
})
"New service worker is now controlling the page",
);
},
);
});
if (!this.vapidKey) {
this.$notify(
{
group: 'alert',
type: 'danger',
title: 'Error Setting Notifications',
text: 'Could not set notifications.'
group: "alert",
type: "danger",
title: "Error Setting Notifications",
text: "Could not set notifications.",
},
5000
)
5000,
);
logConsoleAndDb(
"Error Setting Notifications: web push server response didn't have vapidKey: " +
responseData,
true
)
true,
);
}
}
} catch (error) {
if (window.location.host.startsWith('localhost')) {
if (window.location.host.startsWith("localhost")) {
logConsoleAndDb(
'Ignoring the error getting VAPID for local development.'
)
"Ignoring the error getting VAPID for local development.",
);
} else {
logConsoleAndDb(
'Got an error initializing notifications: ' + JSON.stringify(error),
true
)
"Got an error initializing notifications: " + JSON.stringify(error),
true,
);
this.$notify(
{
group: 'alert',
type: 'danger',
title: 'Error Setting Notifications',
text: 'Got an error setting notifications.'
group: "alert",
type: "danger",
title: "Error Setting Notifications",
text: "Got an error setting notifications.",
},
5000
)
5000,
);
}
}
// there may be a long pause here on first initialization
navigator.serviceWorker?.ready.then(() => {
this.serviceWorkerReady = true
})
this.serviceWorkerReady = true;
});
if (this.pushType === this.DIRECT_PUSH_TITLE) {
this.messageInput =
"Click to share some gratitude with the world -- even if they're unnamed."
"Click to share some gratitude with the world -- even if they're unnamed.";
// focus on the message input
setTimeout(function () {
document.getElementById('push-message')?.focus()
}, 100)
document.getElementById("push-message")?.focus();
}, 100);
} else {
// not critical but doesn't make sense in a daily check
this.messageInput = ''
this.messageInput = "";
}
}
private close() {
this.isVisible = false
this.isVisible = false;
}
private sendMessageToServiceWorker(
message: ServiceWorkerMessage
message: ServiceWorkerMessage,
): Promise<unknown> {
return new Promise((resolve, reject) => {
if (navigator.serviceWorker?.controller) {
const messageChannel = new MessageChannel()
const messageChannel = new MessageChannel();
messageChannel.port1.onmessage = (event: MessageEvent) => {
if (event.data.error) {
reject(event.data.error as ErrorResponse)
reject(event.data.error as ErrorResponse);
} else {
resolve(event.data as ServiceWorkerResponse)
resolve(event.data as ServiceWorkerResponse);
}
}
};
navigator.serviceWorker?.controller.postMessage(message, [
messageChannel.port2
])
messageChannel.port2,
]);
} else {
reject('Service worker controller not available')
reject("Service worker controller not available");
}
})
});
}
private async askPermission(): Promise<NotificationPermission> {
@@ -283,136 +283,136 @@ export default class PushNotificationPermission extends Vue {
// "Requesting permission for notifications: " + JSON.stringify(navigator),
// );
if (
!('serviceWorker' in navigator && navigator.serviceWorker?.controller)
!("serviceWorker" in navigator && navigator.serviceWorker?.controller)
) {
return Promise.reject('Service worker not available.')
return Promise.reject("Service worker not available.");
}
await secretDB.open()
const secret = (await secretDB.secret.get(MASTER_SECRET_KEY))?.secret
await secretDB.open();
const secret = (await secretDB.secret.get(MASTER_SECRET_KEY))?.secret;
if (!secret) {
return Promise.reject('No secret found.')
return Promise.reject("No secret found.");
}
return this.sendSecretToServiceWorker(secret)
.then(() => this.checkNotificationSupport())
.then(() => this.requestNotificationPermission())
.catch((error) => Promise.reject(error))
.catch((error) => Promise.reject(error));
}
private sendSecretToServiceWorker(secret: string): Promise<void> {
const message: ServiceWorkerMessage = {
type: 'SEND_LOCAL_DATA',
data: secret
}
type: "SEND_LOCAL_DATA",
data: secret,
};
return this.sendMessageToServiceWorker(message).then((response) => {
logConsoleAndDb(
'Response from service worker: ' + JSON.stringify(response)
)
})
"Response from service worker: " + JSON.stringify(response),
);
});
}
private checkNotificationSupport(): Promise<void> {
if (!('Notification' in window)) {
if (!("Notification" in window)) {
this.$notify(
{
group: 'alert',
type: 'danger',
title: 'Browser Notifications Are Not Supported',
text: 'This browser does not support notifications.'
group: "alert",
type: "danger",
title: "Browser Notifications Are Not Supported",
text: "This browser does not support notifications.",
},
3000
)
return Promise.reject('This browser does not support notifications.')
3000,
);
return Promise.reject("This browser does not support notifications.");
}
if (window.Notification.permission === 'granted') {
return Promise.resolve()
if (window.Notification.permission === "granted") {
return Promise.resolve();
}
return Promise.resolve()
return Promise.resolve();
}
private requestNotificationPermission(): Promise<NotificationPermission> {
return window.Notification.requestPermission().then(
(permission: string) => {
if (permission !== 'granted') {
if (permission !== "granted") {
this.$notify(
{
group: 'alert',
type: 'danger',
title: 'Error Requesting Notification Permission',
group: "alert",
type: "danger",
title: "Error Requesting Notification Permission",
text:
'Allow this app permission to make notifications for personal reminders.' +
' You can adjust them at any time in your settings.'
"Allow this app permission to make notifications for personal reminders." +
" You can adjust them at any time in your settings.",
},
-1
)
throw new Error('Permission was not granted to this app.')
-1,
);
throw new Error("Permission was not granted to this app.");
}
return permission
}
)
return permission;
},
);
}
private checkHourInput() {
const hourNum = parseInt(this.hourInput)
const hourNum = parseInt(this.hourInput);
if (isNaN(hourNum)) {
this.hourInput = '12'
this.hourInput = "12";
} else if (hourNum < 1) {
this.hourInput = '12'
this.hourAm = !this.hourAm
this.hourInput = "12";
this.hourAm = !this.hourAm;
} else if (hourNum > 12) {
this.hourInput = '1'
this.hourAm = !this.hourAm
this.hourInput = "1";
this.hourAm = !this.hourAm;
} else {
this.hourInput = hourNum.toString()
this.hourInput = hourNum.toString();
}
}
private checkMinuteInput() {
const minuteNum = parseInt(this.minuteInput)
const minuteNum = parseInt(this.minuteInput);
if (isNaN(minuteNum)) {
this.minuteInput = '00'
this.minuteInput = "00";
} else if (minuteNum < 0) {
this.minuteInput = '59'
this.minuteInput = "59";
} else if (minuteNum < 10) {
this.minuteInput = '0' + minuteNum
this.minuteInput = "0" + minuteNum;
} else if (minuteNum > 59) {
this.minuteInput = '00'
this.minuteInput = "00";
} else {
this.minuteInput = minuteNum.toString()
this.minuteInput = minuteNum.toString();
}
}
private async turnOnNotifications() {
let notifyCloser = () => {}
let notifyCloser = () => {};
return this.askPermission()
.then((permission) => {
logConsoleAndDb('Permission granted: ' + JSON.stringify(permission))
logConsoleAndDb("Permission granted: " + JSON.stringify(permission));
// Call the function and handle promises
return this.subscribeToPush()
return this.subscribeToPush();
})
.then(() => {
logConsoleAndDb('Subscribed successfully.')
return navigator.serviceWorker?.ready
logConsoleAndDb("Subscribed successfully.");
return navigator.serviceWorker?.ready;
})
.then((registration) => {
return registration.pushManager.getSubscription()
return registration.pushManager.getSubscription();
})
.then(async (subscription) => {
if (subscription) {
notifyCloser = await this.$notify(
{
group: 'alert',
type: 'info',
title: 'Notification Setup Underway',
text: "Setting up notifications for interesting activity, which takes about 10 seconds. If you don't see a final confirmation, check the 'Troubleshoot' page."
group: "alert",
type: "info",
title: "Notification Setup Underway",
text: "Setting up notifications for interesting activity, which takes about 10 seconds. If you don't see a final confirmation, check the 'Troubleshoot' page.",
},
-1
)
-1,
);
// we already checked that this is a valid hour number
const rawHourNum = libsUtil.numberOrZero(this.hourInput)
const rawHourNum = libsUtil.numberOrZero(this.hourInput);
const adjHourNum = this.hourAm
? // If it's AM, then we'll change it to 0 for 12 AM but otherwise use rawHourNum
rawHourNum === 12
@@ -421,153 +421,153 @@ export default class PushNotificationPermission extends Vue {
: // Otherwise it's PM, so keep a 12 but otherwise add 12
rawHourNum === 12
? 12
: rawHourNum + 12
const hourNum = adjHourNum % 24 // probably unnecessary now
: rawHourNum + 12;
const hourNum = adjHourNum % 24; // probably unnecessary now
const utcHour =
hourNum + Math.round(new Date().getTimezoneOffset() / 60)
const finalUtcHour = (utcHour + (utcHour < 0 ? 24 : 0)) % 24
const minuteNum = libsUtil.numberOrZero(this.minuteInput)
hourNum + Math.round(new Date().getTimezoneOffset() / 60);
const finalUtcHour = (utcHour + (utcHour < 0 ? 24 : 0)) % 24;
const minuteNum = libsUtil.numberOrZero(this.minuteInput);
const utcMinute =
minuteNum + Math.round(new Date().getTimezoneOffset() % 60)
const finalUtcMinute = (utcMinute + (utcMinute < 0 ? 60 : 0)) % 60
minuteNum + Math.round(new Date().getTimezoneOffset() % 60);
const finalUtcMinute = (utcMinute + (utcMinute < 0 ? 60 : 0)) % 60;
const subscriptionWithTime: PushSubscriptionWithTime = {
notifyTime: { utcHour: finalUtcHour, minute: finalUtcMinute },
notifyType: this.pushType,
message: this.messageInput,
...subscription.toJSON()
}
await this.sendSubscriptionToServer(subscriptionWithTime)
...subscription.toJSON(),
};
await this.sendSubscriptionToServer(subscriptionWithTime);
// To help investigate potential issues with this: https://firebase.google.com/docs/cloud-messaging/migrate-v1
logConsoleAndDb(
'Subscription data sent to server with endpoint: ' +
subscription.endpoint
)
return subscriptionWithTime
"Subscription data sent to server with endpoint: " +
subscription.endpoint,
);
return subscriptionWithTime;
} else {
throw new Error('Subscription object is not available.')
throw new Error("Subscription object is not available.");
}
})
.then(async (subscription: PushSubscriptionWithTime) => {
logConsoleAndDb(
'Subscription data sent to server and all finished successfully.'
)
await libsUtil.sendTestThroughPushServer(subscription, true)
notifyCloser()
"Subscription data sent to server and all finished successfully.",
);
await libsUtil.sendTestThroughPushServer(subscription, true);
notifyCloser();
setTimeout(() => {
this.$notify(
{
group: 'alert',
type: 'success',
title: 'Notification Is On',
text: "You should see at least one on your device; if not, check the 'Troubleshoot' link."
group: "alert",
type: "success",
title: "Notification Is On",
text: "You should see at least one on your device; if not, check the 'Troubleshoot' link.",
},
7000
)
}, 500)
7000,
);
}, 500);
const timeText =
// eslint-disable-next-line
this.hourInput + ":" + this.minuteInput + " " + (this.hourAm ? "AM" : "PM");
this.callback(true, timeText, this.messageInput)
this.callback(true, timeText, this.messageInput);
})
.catch((error) => {
logConsoleAndDb(
'Got an error setting notification permissions: ' +
' string ' +
"Got an error setting notification permissions: " +
" string " +
error.toString() +
' JSON ' +
" JSON " +
JSON.stringify(error),
true
)
true,
);
this.$notify(
{
group: 'alert',
type: 'danger',
title: 'Error Setting Notification Permissions',
text: 'Could not set notification permissions.'
group: "alert",
type: "danger",
title: "Error Setting Notification Permissions",
text: "Could not set notification permissions.",
},
3000
)
3000,
);
// if we want to also unsubscribe, be sure to do that only if no other notification is active
})
});
}
private subscribeToPush(): Promise<void> {
return new Promise<void>((resolve, reject) => {
if (!('serviceWorker' in navigator && 'PushManager' in window)) {
const errorMsg = 'Push messaging is not supported'
logger.warn(errorMsg)
return reject(new Error(errorMsg))
if (!("serviceWorker" in navigator && "PushManager" in window)) {
const errorMsg = "Push messaging is not supported";
logger.warn(errorMsg);
return reject(new Error(errorMsg));
}
if (window.Notification.permission !== 'granted') {
const errorMsg = 'Notification permission not granted'
logger.warn(errorMsg)
return reject(new Error(errorMsg))
if (window.Notification.permission !== "granted") {
const errorMsg = "Notification permission not granted";
logger.warn(errorMsg);
return reject(new Error(errorMsg));
}
const applicationServerKey = urlBase64ToUint8Array(this.vapidKey)
const applicationServerKey = urlBase64ToUint8Array(this.vapidKey);
const options: PushSubscriptionOptions = {
userVisibleOnly: true,
applicationServerKey: applicationServerKey
}
applicationServerKey: applicationServerKey,
};
navigator.serviceWorker?.ready
.then((registration) => {
return registration.pushManager.subscribe(options)
return registration.pushManager.subscribe(options);
})
.then((subscription) => {
logConsoleAndDb(
'Push subscription successful: ' + JSON.stringify(subscription)
)
resolve()
"Push subscription successful: " + JSON.stringify(subscription),
);
resolve();
})
.catch((error) => {
logConsoleAndDb(
'Push subscription failed: ' +
"Push subscription failed: " +
JSON.stringify(error) +
' - ' +
" - " +
JSON.stringify(options),
true
)
true,
);
// Inform the user about the issue
this.$notify(
{
group: 'alert',
type: 'danger',
title: 'Error Setting Push Notifications',
group: "alert",
type: "danger",
title: "Error Setting Push Notifications",
text:
'We encountered an issue setting up push notifications. ' +
'If you wish to revoke notification permissions, please do so in your browser settings.'
"We encountered an issue setting up push notifications. " +
"If you wish to revoke notification permissions, please do so in your browser settings.",
},
-1
)
-1,
);
reject(error)
})
})
reject(error);
});
});
}
private sendSubscriptionToServer(
subscription: PushSubscriptionWithTime
subscription: PushSubscriptionWithTime,
): Promise<void> {
logConsoleAndDb(
'About to send subscription... ' + JSON.stringify(subscription)
)
return fetch('/web-push/subscribe', {
method: 'POST',
"About to send subscription... " + JSON.stringify(subscription),
);
return fetch("/web-push/subscribe", {
method: "POST",
headers: {
'Content-Type': 'application/json'
"Content-Type": "application/json",
},
body: JSON.stringify(subscription)
body: JSON.stringify(subscription),
}).then((response) => {
if (!response.ok) {
logger.error('Bad response subscribing to web push: ', response)
throw new Error('Failed to send push subscription to server')
logger.error("Bad response subscribing to web push: ", response);
throw new Error("Failed to send push subscription to server");
}
logConsoleAndDb('Push subscription sent to server successfully.')
})
logConsoleAndDb("Push subscription sent to server successfully.");
});
}
}
</script>

View File

@@ -1,124 +0,0 @@
import {
BarcodeScanner,
BarcodeFormat,
LensFacing,
ScanResult
} from '@capacitor-mlkit/barcode-scanning'
import type { QRScannerService, ScanListener } from './types'
import { logger } from '../../utils/logger'
export class CapacitorQRScanner implements QRScannerService {
private scanListener: ScanListener | null = null
private isScanning = false
private listenerHandles: Array<() => Promise<void>> = []
async checkPermissions() {
try {
const { camera } = await BarcodeScanner.checkPermissions()
return camera === 'granted'
} catch (error) {
logger.error('Error checking camera permissions:', error)
return false
}
}
async requestPermissions() {
try {
const { camera } = await BarcodeScanner.requestPermissions()
return camera === 'granted'
} catch (error) {
logger.error('Error requesting camera permissions:', error)
return false
}
}
async isSupported() {
try {
const { supported } = await BarcodeScanner.isSupported()
return supported
} catch (error) {
logger.error('Error checking barcode scanner support:', error)
return false
}
}
async startScan() {
if (this.isScanning) {
logger.warn('Scanner is already active')
return
}
try {
// First register listeners before starting scan
await this.registerListeners()
this.isScanning = true
await BarcodeScanner.startScan({
formats: [BarcodeFormat.QrCode],
lensFacing: LensFacing.Back
})
} catch (error) {
// Ensure cleanup on error
this.isScanning = false
await this.removeListeners()
logger.error('Error starting barcode scan:', error)
throw error
}
}
private async registerListeners() {
try {
const handle = await BarcodeScanner.addListener(
'barcodesScanned',
async (result: ScanResult) => {
if (result.barcodes.length > 0 && this.scanListener) {
const barcode = result.barcodes[0]
this.scanListener.onScan(barcode.rawValue)
await this.stopScan()
}
}
)
this.listenerHandles.push(() => handle.remove())
} catch (error) {
logger.error('Error registering barcode listener:', error)
throw error
}
}
private async removeListeners() {
for (const remove of this.listenerHandles) {
try {
await remove()
} catch (error) {
logger.error('Error removing listener:', error)
}
}
this.listenerHandles = []
}
async stopScan() {
if (!this.isScanning) {
return
}
try {
// First stop the scan
await BarcodeScanner.stopScan()
} catch (error) {
logger.error('Error stopping barcode scan:', error)
} finally {
// Always cleanup state even if stop fails
this.isScanning = false
await this.removeListeners()
}
}
addListener(listener: ScanListener): void {
this.scanListener = listener
}
async cleanup(): Promise<void> {
await this.stopScan()
this.scanListener = null
}
}

View File

@@ -1,319 +0,0 @@
<template>
<div v-if="visible" class="dialog-overlay z-[60]">
<div class="dialog relative">
<div class="text-lg text-center font-light relative z-50">
<div
id="ViewHeading"
class="text-center font-bold absolute top-0 left-0 right-0 px-4 py-0.5 bg-black/50 text-white leading-none"
>
<span v-if="state.isProcessing">{{ state.processingStatus }}</span>
<span v-else>Scan QR Code</span>
</div>
<div
class="text-lg text-center px-2 py-0.5 leading-none absolute right-0 top-0 text-white cursor-pointer"
@click="close()"
>
<font-awesome icon="xmark" class="w-[1em]" />
</div>
</div>
<div class="mt-8">
<!-- Web QR Code Scanner -->
<qrcode-stream
v-if="useQRReader"
class="w-full max-w-lg mx-auto"
@detect="onScanDetect"
@error="onScanError"
/>
<!-- Mobile Camera Button -->
<div v-else class="text-center mt-4">
<button
v-if="!state.isProcessing"
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
@click="openMobileCamera"
>
Open Camera
</button>
<div v-else class="text-center">
<font-awesome icon="spinner" class="fa-spin fa-3x" />
<p class="mt-2">
{{ state.processingDetails }}
</p>
</div>
</div>
<p v-if="state.error" class="mt-4 text-red-500 text-center">
{{ state.error }}
</p>
<p class="mt-4 text-sm text-gray-600 text-center">
Position the QR code within the camera view to scan
</p>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-facing-decorator'
import { QrcodeStream } from 'vue-qrcode-reader'
import { reactive } from 'vue'
import {
BarcodeScanner,
type ScanResult
} from '@capacitor-mlkit/barcode-scanning'
import type { PluginListenerHandle } from '@capacitor/core'
import { logger } from '../../utils/logger'
import { NotificationIface } from '../../constants/app'
// Declare global constants
declare const __USE_QR_READER__: boolean
declare const __IS_MOBILE__: boolean
interface AppState {
isProcessing: boolean
processingStatus: string
processingDetails: string
error: string
}
@Component({
components: {
QrcodeStream
}
})
export default class QRScannerDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void
visible = false
private scanListener: PluginListenerHandle | null = null
private onScanCallback: ((result: string) => void) | null = null
state = reactive<AppState>({
isProcessing: false,
processingStatus: '',
processingDetails: '',
error: ''
})
async open(onScan: (result: string) => void) {
this.onScanCallback = onScan
this.visible = true
this.state.error = ''
if (!this.useQRReader) {
// Check if barcode scanning is supported on mobile
try {
const { supported } = await BarcodeScanner.isSupported()
if (!supported) {
this.showError('Barcode scanning is not supported on this device')
return
}
} catch (error) {
this.showError('Failed to check barcode scanner support')
return
}
}
}
close() {
this.visible = false
this.stopScanning().catch((error) => {
logger.error('Error stopping scanner during close:', error)
})
this.onScanCallback = null
}
async openMobileCamera() {
try {
this.state.isProcessing = true
this.state.processingStatus = 'Starting camera...'
logger.log('Opening mobile camera - starting initialization')
// Check current permission status
const status = await BarcodeScanner.checkPermissions()
logger.log('Camera permission status:', JSON.stringify(status, null, 2))
if (status.camera !== 'granted') {
// Request permission if not granted
logger.log('Requesting camera permissions...')
const permissionStatus = await BarcodeScanner.requestPermissions()
if (permissionStatus.camera !== 'granted') {
throw new Error('Camera permission not granted')
}
logger.log(
'Camera permission granted:',
JSON.stringify(permissionStatus, null, 2)
)
}
// Remove any existing listener first
await this.cleanupScanListener()
// Set up the listener before starting the scan
logger.log('Setting up new barcode listener')
this.scanListener = await BarcodeScanner.addListener(
'barcodesScanned',
async (result: ScanResult) => {
logger.log(
'Barcode scan result received:',
JSON.stringify(result, null, 2)
)
if (result.barcodes && result.barcodes.length > 0) {
this.state.processingDetails = `Processing QR code: ${result.barcodes[0].rawValue}`
await this.handleScanResult(result.barcodes[0].rawValue)
}
}
)
logger.log('Barcode listener setup complete')
// Start the scanner
logger.log('Starting barcode scanner')
await BarcodeScanner.startScan()
logger.log('Barcode scanner started successfully')
this.state.isProcessing = false
this.state.processingStatus = ''
} catch (error) {
logger.error('Failed to open camera:', error)
this.state.isProcessing = false
this.state.processingStatus = ''
this.showError(
error instanceof Error ? error.message : 'Failed to open camera'
)
// Cleanup on error
await this.cleanupScanListener()
}
}
private async handleScanResult(rawValue: string) {
try {
this.state.isProcessing = true
this.state.processingStatus = 'Processing QR code...'
this.state.processingDetails = `Scanned value: ${rawValue}`
// Stop scanning before processing
await this.stopScanning()
if (this.onScanCallback) {
await this.onScanCallback(rawValue)
// Only close after the callback is complete
this.close()
}
} catch (error) {
logger.error('Error handling scan result:', error)
this.showError('Failed to process scan result')
} finally {
this.state.isProcessing = false
this.state.processingStatus = ''
this.state.processingDetails = ''
}
}
private async cleanupScanListener() {
try {
if (this.scanListener) {
await this.scanListener.remove()
this.scanListener = null
}
} catch (error) {
logger.error('Error removing scan listener:', error)
}
}
async stopScanning() {
try {
await this.cleanupScanListener()
if (!this.useQRReader) {
// Stop the native scanner
await BarcodeScanner.stopScan()
}
} catch (error) {
logger.error('Error stopping scanner:', error)
throw error
}
}
// Web QR reader handlers
async onScanDetect(result: { rawValue: string }) {
await this.handleScanResult(result.rawValue)
}
onScanError(error: Error) {
logger.error('Scan error:', error)
this.showError('Failed to scan QR code')
}
private showError(message: string) {
this.state.error = message
this.$notify(
{
group: 'alert',
type: 'danger',
title: 'Error',
text: message
},
5000
)
}
get useQRReader(): boolean {
return __USE_QR_READER__
}
get isMobile(): boolean {
return __IS_MOBILE__
}
}
</script>
<style>
.dialog-overlay {
z-index: 60;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem;
}
.dialog {
background-color: white;
padding: 1rem;
border-radius: 0.5rem;
width: 100%;
max-width: 700px;
position: relative;
z-index: 61;
}
/* Add styles for the camera preview */
.qrcode-stream {
position: relative;
z-index: 62;
}
.qrcode-stream video {
width: 100%;
height: auto;
max-height: 70vh;
object-fit: contain;
}
/* Ensure mobile camera elements are also properly layered */
.barcode-scanner-container {
position: relative;
z-index: 62;
width: 100%;
height: 100%;
}
</style>

View File

@@ -1,74 +0,0 @@
import type { QRScannerService, ScanListener } from './types'
import QRScannerDialog from './QRScannerDialog.vue'
import { createApp, type App } from 'vue'
import { logger } from '../../utils/logger'
// Import platform-specific flags from Vite config
declare const __USE_QR_READER__: boolean
export class WebDialogQRScanner implements QRScannerService {
private dialogApp: App | null = null
private dialogElement: HTMLDivElement | null = null
private scanListener: ScanListener | null = null
async checkPermissions(): Promise<boolean> {
return navigator?.mediaDevices !== undefined
}
async requestPermissions(): Promise<boolean> {
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true })
stream.getTracks().forEach((track) => track.stop())
return true
} catch (error) {
logger.error('Failed to get camera permissions:', error)
return false
}
}
async isSupported(): Promise<boolean> {
return Promise.resolve(
__USE_QR_READER__ && navigator?.mediaDevices !== undefined
)
}
async startScan(): Promise<void> {
if (!(await this.isSupported())) {
throw new Error('QR scanning is not supported in this environment')
}
this.dialogElement = document.createElement('div')
document.body.appendChild(this.dialogElement)
this.dialogApp = createApp(QRScannerDialog, {
onScan: (result: string) => {
if (this.scanListener) {
this.scanListener.onScan(result)
}
},
onClose: () => {
this.stopScan()
}
})
this.dialogApp.mount(this.dialogElement)
}
async stopScan(): Promise<void> {
if (this.dialogApp && this.dialogElement) {
this.dialogApp.unmount()
this.dialogElement.remove()
this.dialogApp = null
this.dialogElement = null
}
}
addListener(listener: ScanListener): void {
this.scanListener = listener
}
async cleanup(): Promise<void> {
await this.stopScan()
this.scanListener = null
}
}

View File

@@ -1,38 +0,0 @@
import { Capacitor } from '@capacitor/core'
import type { QRScannerService } from './types'
import { logger } from '../../utils/logger'
import { WebDialogQRScanner } from './WebDialogScanner'
import { CapacitorQRScanner } from './CapacitorScanner'
// Import platform-specific flags from Vite config
declare const __USE_QR_READER__: boolean
declare const __IS_MOBILE__: boolean
export class QRScannerFactory {
private static instance: QRScannerService | null = null
static getInstance(): QRScannerService {
if (!this.instance) {
// Use platform-specific flags for more accurate detection
if (__IS_MOBILE__ || Capacitor.isNativePlatform()) {
logger.log('Creating native QR scanner instance')
this.instance = new CapacitorQRScanner()
} else if (__USE_QR_READER__) {
logger.log('Creating web QR scanner instance')
this.instance = new WebDialogQRScanner()
} else {
throw new Error(
'No QR scanner implementation available for this platform'
)
}
}
return this.instance
}
static async cleanup() {
if (this.instance) {
await this.instance.cleanup()
this.instance = null
}
}
}

View File

@@ -1,14 +0,0 @@
export interface ScanListener {
onScan: (result: string) => void
onError?: (error: Error) => void
}
export interface QRScannerService {
checkPermissions(): Promise<boolean>
requestPermissions(): Promise<boolean>
isSupported(): Promise<boolean>
startScan(): Promise<void>
stopScan(): Promise<void>
addListener(listener: ScanListener): void
cleanup(): Promise<void>
}

View File

@@ -8,7 +8,7 @@
'basis-1/5': true,
'rounded-md': true,
'bg-slate-400 text-white': selected === 'Home',
'text-slate-500': selected !== 'Home'
'text-slate-500': selected !== 'Home',
}"
>
<router-link :to="{ name: 'home' }" class="block text-center py-2 px-1">
@@ -24,7 +24,7 @@
'basis-1/5': true,
'rounded-md': true,
'bg-slate-400 text-white': selected === 'Discover',
'text-slate-500': selected !== 'Discover'
'text-slate-500': selected !== 'Discover',
}"
>
<router-link
@@ -43,7 +43,7 @@
'basis-1/5': true,
'rounded-md': true,
'bg-slate-400 text-white': selected === 'Projects',
'text-slate-500': selected !== 'Projects'
'text-slate-500': selected !== 'Projects',
}"
>
<router-link
@@ -62,7 +62,7 @@
'basis-1/5': true,
'rounded-md': true,
'bg-slate-400 text-white': selected === 'Contacts',
'text-slate-500': selected !== 'Contacts'
'text-slate-500': selected !== 'Contacts',
}"
>
<router-link
@@ -81,7 +81,7 @@
'basis-1/5': true,
'rounded-md': true,
'bg-slate-400 text-white': selected === 'Profile',
'text-slate-500': selected !== 'Profile'
'text-slate-500': selected !== 'Profile',
}"
>
<router-link
@@ -106,10 +106,10 @@
</template>
<script lang="ts">
import { Component, Vue, Prop } from 'vue-facing-decorator'
import { Component, Vue, Prop } from "vue-facing-decorator";
@Component
export default class QuickNav extends Vue {
@Prop selected = ''
@Prop selected = "";
}
</script>

View File

@@ -13,46 +13,46 @@
</template>
<script lang="ts">
import { Component, Vue, Prop } from 'vue-facing-decorator'
import { Component, Vue, Prop } from "vue-facing-decorator";
import { AppString, NotificationIface } from '../constants/app'
import { retrieveSettingsForActiveAccount } from '../db/index'
import { AppString, NotificationIface } from "../constants/app";
import { retrieveSettingsForActiveAccount } from "../db/index";
@Component
export default class TopMessage extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void
$notify!: (notification: NotificationIface, timeout?: number) => void;
@Prop selected = ''
@Prop selected = "";
message = ''
message = "";
async mounted() {
try {
const settings = await retrieveSettingsForActiveAccount()
const settings = await retrieveSettingsForActiveAccount();
if (
settings.warnIfTestServer &&
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
const didPrefix = settings.activeDid?.slice(11, 15);
this.message = "You're linked to a non-prod server, user " + didPrefix;
} else if (
settings.warnIfProdServer &&
settings.apiServer === AppString.PROD_ENDORSER_API_SERVER
) {
const didPrefix = settings.activeDid?.slice(11, 15)
const didPrefix = settings.activeDid?.slice(11, 15);
this.message =
"You're linked to the production server, user " + didPrefix
"You're linked to the production server, user " + didPrefix;
}
} catch (err: unknown) {
this.$notify(
{
group: 'alert',
type: 'danger',
title: 'Error Detecting Server',
text: JSON.stringify(err)
group: "alert",
type: "danger",
title: "Error Detecting Server",
text: JSON.stringify(err),
},
-1
)
-1,
);
}
}
}

View File

@@ -35,49 +35,49 @@
</template>
<script lang="ts">
import { Vue, Component, Prop } from 'vue-facing-decorator'
import { Vue, Component, Prop } from "vue-facing-decorator";
import { NotificationIface } from '../constants/app'
import { db, retrieveSettingsForActiveAccount } from '../db/index'
import { MASTER_SETTINGS_KEY } from '../db/tables/settings'
import { NotificationIface } from "../constants/app";
import { db, retrieveSettingsForActiveAccount } from "../db/index";
import { MASTER_SETTINGS_KEY } from "../db/tables/settings";
@Component
export default class UserNameDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void
$notify!: (notification: NotificationIface, timeout?: number) => void;
@Prop({
default:
'This is not sent to servers. It is only shared with people when you send it to them.'
"This is not sent to servers. It is only shared with people when you send it to them.",
})
sharingExplanation!: string
@Prop({ default: false }) callbackOnCancel!: boolean
sharingExplanation!: string;
@Prop({ default: false }) callbackOnCancel!: boolean;
callback: (name?: string) => void = () => {}
givenName = ''
visible = false
callback: (name?: string) => void = () => {};
givenName = "";
visible = false;
/**
* @param aCallback - callback function for name, which may be ""
*/
async open(aCallback?: (name?: string) => void) {
this.callback = aCallback || this.callback
const settings = await retrieveSettingsForActiveAccount()
this.givenName = settings.firstName || ''
this.visible = true
this.callback = aCallback || this.callback;
const settings = await retrieveSettingsForActiveAccount();
this.givenName = settings.firstName || "";
this.visible = true;
}
async onClickSaveChanges() {
await db.settings.update(MASTER_SETTINGS_KEY, {
firstName: this.givenName
})
this.visible = false
this.callback(this.givenName)
firstName: this.givenName,
});
this.visible = false;
this.callback(this.givenName);
}
onClickCancel() {
this.visible = false
this.visible = false;
if (this.callbackOnCancel) {
this.callback()
this.callback();
}
}
}

View File

@@ -1,53 +1,53 @@
// from https://medium.com/nicasource/building-an-interactive-web-portfolio-with-vue-three-js-part-three-implementing-three-js-452cb375ef80
import * as TWEEN from '@tweenjs/tween.js'
import * as THREE from 'three'
import * as TWEEN from "@tweenjs/tween.js";
import * as THREE from "three";
import { createCamera } from './components/camera.js'
import { createLights } from './components/lights.js'
import { createScene } from './components/scene.js'
import { loadLandmarks } from './components/objects/landmarks.js'
import { createTerrain } from './components/objects/terrain.js'
import { Loop } from './systems/Loop.js'
import { Resizer } from './systems/Resizer.js'
import { createControls } from './systems/controls.js'
import { createRenderer } from './systems/renderer.js'
import { createCamera } from "./components/camera.js";
import { createLights } from "./components/lights.js";
import { createScene } from "./components/scene.js";
import { loadLandmarks } from "./components/objects/landmarks.js";
import { createTerrain } from "./components/objects/terrain.js";
import { Loop } from "./systems/Loop.js";
import { Resizer } from "./systems/Resizer.js";
import { createControls } from "./systems/controls.js";
import { createRenderer } from "./systems/renderer.js";
const COLOR1 = '#dddddd'
const COLOR2 = '#0055aa'
const COLOR1 = "#dddddd";
const COLOR2 = "#0055aa";
class World {
constructor(container, vue) {
this.PLATFORM_BORDER = 5
this.PLATFORM_EDGE_FOR_UNKNOWNS = 10
this.PLATFORM_SIZE = 100 // note that the loadLandmarks calculations may still assume 100
this.PLATFORM_BORDER = 5;
this.PLATFORM_EDGE_FOR_UNKNOWNS = 10;
this.PLATFORM_SIZE = 100; // note that the loadLandmarks calculations may still assume 100
this.update = this.update.bind(this)
this.update = this.update.bind(this);
this.vue = vue
this.vue = vue;
// Instances of camera, scene, and renderer
this.camera = createCamera()
this.scene = createScene(COLOR2)
this.renderer = createRenderer()
this.camera = createCamera();
this.scene = createScene(COLOR2);
this.renderer = createRenderer();
// necessary for models, says https://threejs.org/docs/index.html#examples/en/loaders/GLTFLoader
this.renderer.outputColorSpace = THREE.SRGBColorSpace
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
this.light = null
this.lights = []
this.bushes = []
this.light = null;
this.lights = [];
this.bushes = [];
// Initialize Loop
this.loop = new Loop(this.camera, this.scene, this.renderer)
this.loop = new Loop(this.camera, this.scene, this.renderer);
container.append(this.renderer.domElement)
container.append(this.renderer.domElement);
// Orbit Controls
const controls = createControls(this.camera, this.renderer.domElement)
const controls = createControls(this.camera, this.renderer.domElement);
// Light Instance, with optional light helper
const { light } = createLights(COLOR1)
const { light } = createLights(COLOR1);
// Terrain Instance
const terrain = createTerrain({
@@ -56,55 +56,55 @@ class World {
width:
this.PLATFORM_SIZE +
this.PLATFORM_BORDER * 2 +
this.PLATFORM_EDGE_FOR_UNKNOWNS * 2
})
this.PLATFORM_EDGE_FOR_UNKNOWNS * 2,
});
this.loop.updatables.push(controls)
this.loop.updatables.push(light)
this.loop.updatables.push(terrain)
this.loop.updatables.push(controls);
this.loop.updatables.push(light);
this.loop.updatables.push(terrain);
this.scene.add(light, terrain)
this.scene.add(light, terrain);
loadLandmarks(vue, this, this.scene, this.loop)
loadLandmarks(vue, this, this.scene, this.loop);
requestAnimationFrame(this.update)
requestAnimationFrame(this.update);
// Responsive handler
const resizer = new Resizer(container, this.camera, this.renderer)
const resizer = new Resizer(container, this.camera, this.renderer);
resizer.onResize = () => {
this.render()
}
this.render();
};
}
update(time) {
TWEEN.update(time)
TWEEN.update(time);
this.lights.forEach((light) => {
light.updateMatrixWorld()
light.target.updateMatrixWorld()
})
light.updateMatrixWorld();
light.target.updateMatrixWorld();
});
this.lights.forEach((bush) => {
bush.updateMatrixWorld()
})
requestAnimationFrame(this.update)
bush.updateMatrixWorld();
});
requestAnimationFrame(this.update);
}
render() {
// draw a single frame
this.renderer.render(this.scene, this.camera)
this.renderer.render(this.scene, this.camera);
}
// Animation handlers
start() {
this.loop.start()
this.loop.start();
}
stop() {
this.loop.stop()
this.loop.stop();
}
setExposedWorldProperties(key, value) {
this.vue.setWorldProperty(key, value)
this.vue.setWorldProperty(key, value);
}
}
export { World }
export { World };

View File

@@ -1,19 +1,19 @@
import { PerspectiveCamera } from 'three'
import { PerspectiveCamera } from "three";
function createCamera() {
const camera = new PerspectiveCamera(
35, // fov = Field Of View
1, // aspect ratio (dummy value)
0.1, // near clipping plane
350 // far clipping plane
)
350, // far clipping plane
);
// move the camera back so we can view the scene
camera.position.set(0, 100, 200)
camera.position.set(0, 100, 200);
// eslint-disable-next-line @typescript-eslint/no-empty-function
camera.tick = () => {}
camera.tick = () => {};
return camera
return camera;
}
export { createCamera }
export { createCamera };

View File

@@ -1,14 +1,14 @@
import { DirectionalLight, DirectionalLightHelper } from 'three'
import { DirectionalLight, DirectionalLightHelper } from "three";
function createLights(color) {
const light = new DirectionalLight(color, 4)
const lightHelper = new DirectionalLightHelper(light, 0)
light.position.set(60, 100, 30)
const light = new DirectionalLight(color, 4);
const lightHelper = new DirectionalLightHelper(light, 0);
light.position.set(60, 100, 30);
// eslint-disable-next-line @typescript-eslint/no-empty-function
light.tick = () => {}
light.tick = () => {};
return { light, lightHelper }
return { light, lightHelper };
}
export { createLights }
export { createLights };

View File

@@ -1,46 +1,46 @@
import axios from 'axios'
import * as THREE from 'three'
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader'
import * as SkeletonUtils from 'three/addons/utils/SkeletonUtils'
import * as TWEEN from '@tweenjs/tween.js'
import { retrieveSettingsForActiveAccount } from '../../../../db'
import { getHeaders } from '../../../../libs/endorserServer'
import { logger } from '../../../../utils/logger'
import axios from "axios";
import * as THREE from "three";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader";
import * as SkeletonUtils from "three/addons/utils/SkeletonUtils";
import * as TWEEN from "@tweenjs/tween.js";
import { retrieveSettingsForActiveAccount } from "../../../../db";
import { getHeaders } from "../../../../libs/endorserServer";
import { logger } from "../../../../utils/logger";
const ANIMATION_DURATION_SECS = 10
const ENDORSER_ENTITY_PREFIX = 'https://endorser.ch/entity/'
const ANIMATION_DURATION_SECS = 10;
const ENDORSER_ENTITY_PREFIX = "https://endorser.ch/entity/";
export async function loadLandmarks(vue, world, scene, loop) {
vue.setWorldProperty('animationDurationSeconds', ANIMATION_DURATION_SECS)
vue.setWorldProperty("animationDurationSeconds", ANIMATION_DURATION_SECS);
try {
const settings = await retrieveSettingsForActiveAccount()
const activeDid = settings.activeDid || ''
const apiServer = settings.apiServer
const headers = await getHeaders(activeDid)
const settings = await retrieveSettingsForActiveAccount();
const activeDid = settings.activeDid || "";
const apiServer = settings.apiServer;
const headers = await getHeaders(activeDid);
const url = apiServer + '/api/v2/report/claims?claimType=GiveAction'
const resp = await axios.get(url, { headers: headers })
const url = apiServer + "/api/v2/report/claims?claimType=GiveAction";
const resp = await axios.get(url, { headers: headers });
if (resp.status === 200) {
const landmarks = resp.data.data
const landmarks = resp.data.data;
const minDate = landmarks[landmarks.length - 1].issuedAt
const maxDate = landmarks[0].issuedAt
const minDate = landmarks[landmarks.length - 1].issuedAt;
const maxDate = landmarks[0].issuedAt;
world.setExposedWorldProperties('startTime', minDate.replace('T', ' '))
world.setExposedWorldProperties('endTime', maxDate.replace('T', ' '))
world.setExposedWorldProperties("startTime", minDate.replace("T", " "));
world.setExposedWorldProperties("endTime", maxDate.replace("T", " "));
const minTimeMillis = new Date(minDate).getTime()
const minTimeMillis = new Date(minDate).getTime();
const fullTimeMillis =
maxDate > minDate ? new Date(maxDate).getTime() - minTimeMillis : 1 // avoid divide by zero
maxDate > minDate ? new Date(maxDate).getTime() - minTimeMillis : 1; // avoid divide by zero
// ratio of animation time to real time
const fakeRealRatio = (ANIMATION_DURATION_SECS * 1000) / fullTimeMillis
const fakeRealRatio = (ANIMATION_DURATION_SECS * 1000) / fullTimeMillis;
// load plant model first because it takes a second
const loader = new GLTFLoader()
const loader = new GLTFLoader();
// choose the right plant
const modelLoc = '/models/lupine_plant/scene.gltf', // push with pokies
modScale = 0.1
const modelLoc = "/models/lupine_plant/scene.gltf", // push with pokies
modScale = 0.1;
//const modelLoc = "/models/round_bush/scene.gltf", // green & pink
// modScale = 1;
//const modelLoc = "/models/coreopsis-flower.glb", // 3 flowers
@@ -50,55 +50,59 @@ export async function loadLandmarks(vue, world, scene, loop) {
// calculate positions for each claim, especially because some are random
const locations = landmarks.map((claim) =>
locForGive(claim, world.PLATFORM_SIZE, world.PLATFORM_EDGE_FOR_UNKNOWNS)
)
locForGive(
claim,
world.PLATFORM_SIZE,
world.PLATFORM_EDGE_FOR_UNKNOWNS,
),
);
// eslint-disable-next-line @typescript-eslint/no-this-alias
loader.load(
modelLoc,
function (gltf) {
gltf.scene.scale.set(0, 0, 0)
gltf.scene.scale.set(0, 0, 0);
for (let i = 0; i < landmarks.length; i++) {
// claim is a GiveServerRecord (see endorserServer.ts)
const claim = landmarks[i]
const newPlant = SkeletonUtils.clone(gltf.scene)
const claim = landmarks[i];
const newPlant = SkeletonUtils.clone(gltf.scene);
const loc = locations[i]
newPlant.position.set(loc.x, 0, loc.z)
const loc = locations[i];
newPlant.position.set(loc.x, 0, loc.z);
world.scene.add(newPlant)
world.scene.add(newPlant);
const timeDelayMillis =
fakeRealRatio *
(new Date(claim.issuedAt).getTime() - minTimeMillis)
(new Date(claim.issuedAt).getTime() - minTimeMillis);
new TWEEN.Tween(newPlant.scale)
.delay(timeDelayMillis)
.to({ x: modScale, y: modScale, z: modScale }, 5000)
.start()
world.bushes = [...world.bushes, newPlant]
.start();
world.bushes = [...world.bushes, newPlant];
}
},
undefined,
function (error) {
logger.error(error)
}
)
logger.error(error);
},
);
// calculate when lights shine on appearing claim area
for (let i = 0; i < landmarks.length; i++) {
// claim is a GiveServerRecord (see endorserServer.ts)
const claim = landmarks[i]
const claim = landmarks[i];
const loc = locations[i]
const light = createLight()
light.position.set(loc.x, 20, loc.z)
light.target.position.set(loc.x, 0, loc.z)
loop.updatables.push(light)
scene.add(light)
scene.add(light.target)
const loc = locations[i];
const light = createLight();
light.position.set(loc.x, 20, loc.z);
light.target.position.set(loc.x, 0, loc.z);
loop.updatables.push(light);
scene.add(light);
scene.add(light.target);
// now figure out the timing and shine a light
const timeDelayMillis =
fakeRealRatio * (new Date(claim.issuedAt).getTime() - minTimeMillis)
fakeRealRatio * (new Date(claim.issuedAt).getTime() - minTimeMillis);
new TWEEN.Tween(light)
.delay(timeDelayMillis)
.to({ intensity: 100 }, 10)
@@ -106,30 +110,30 @@ export async function loadLandmarks(vue, world, scene, loop) {
new TWEEN.Tween(light.position)
.to({ y: 5 }, 5000)
.onComplete(() => {
scene.remove(light)
light.dispose()
})
scene.remove(light);
light.dispose();
}),
)
.start()
world.lights = [...world.lights, light]
.start();
world.lights = [...world.lights, light];
}
} else {
logger.error(
'Got bad server response status & data of',
"Got bad server response status & data of",
resp.status,
resp.data
)
resp.data,
);
vue.setAlert(
'Error With Server',
'There was an error retrieving your claims from the server.'
)
"Error With Server",
"There was an error retrieving your claims from the server.",
);
}
} catch (error) {
logger.error('Got exception contacting server:', error)
logger.error("Got exception contacting server:", error);
vue.setAlert(
'Error With Server',
'There was a problem retrieving your claims from the server.'
)
"Error With Server",
"There was a problem retrieving your claims from the server.",
);
}
}
@@ -139,30 +143,30 @@ export async function loadLandmarks(vue, world, scene, loop) {
* @returns {x:float, z:float} where -50 <= x & z < 50
*/
function locForGive(giveClaim, platformWidth, borderWidth) {
let loc
let loc;
if (giveClaim?.claim?.recipient?.identifier) {
// this is directly to a person
loc = locForEthrDid(giveClaim.claim.recipient.identifier)
loc = { x: loc.x - platformWidth / 2, z: loc.z - platformWidth / 2 }
loc = locForEthrDid(giveClaim.claim.recipient.identifier);
loc = { x: loc.x - platformWidth / 2, z: loc.z - platformWidth / 2 };
} else if (giveClaim?.object?.isPartOf?.identifier) {
// this is probably to a project
const objId = giveClaim.object.isPartOf.identifier
const objId = giveClaim.object.isPartOf.identifier;
if (objId.startsWith(ENDORSER_ENTITY_PREFIX)) {
loc = locForUlid(objId.substring(ENDORSER_ENTITY_PREFIX.length))
loc = { x: loc.x - platformWidth / 2, z: loc.z - platformWidth / 2 }
loc = locForUlid(objId.substring(ENDORSER_ENTITY_PREFIX.length));
loc = { x: loc.x - platformWidth / 2, z: loc.z - platformWidth / 2 };
}
}
if (!loc) {
// it must be outside our known addresses so let's put it somewhere random on the side
const leftSide = Math.random() < 0.5
const leftSide = Math.random() < 0.5;
loc = {
x: leftSide
? -platformWidth / 2 - borderWidth / 2
: platformWidth / 2 + borderWidth / 2,
z: Math.random() * platformWidth - platformWidth / 2
}
z: Math.random() * platformWidth - platformWidth / 2,
};
}
return loc
return loc;
}
/**
@@ -191,20 +195,20 @@ function locForGive(giveClaim, platformWidth, borderWidth) {
* @returns {x: float, z: float} where 0 <= x & z < 100
*/
function locForUlid(ulid) {
const xChars = ulid.substring(0, 13).split('').reverse().join('')
const zChars = ulid.substring(13, 26).split('').reverse().join('')
const xChars = ulid.substring(0, 13).split("").reverse().join("");
const zChars = ulid.substring(13, 26).split("").reverse().join("");
// from https://github.com/ulid/javascript/blob/5e9727b527aec5b841737c395a20085c4361e971/lib/index.ts#L21
const BASE32 = '0123456789ABCDEFGHJKMNPQRSTVWXYZ' // Crockford's Base32
const BASE32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; // Crockford's Base32
// We're currently only using 1024 possible x and z values
// because the display is pretty low-fidelity at this point.
const rawX = BASE32.indexOf(xChars[1]) * 32 + BASE32.indexOf(xChars[0])
const rawZ = BASE32.indexOf(zChars[1]) * 32 + BASE32.indexOf(zChars[0])
const rawX = BASE32.indexOf(xChars[1]) * 32 + BASE32.indexOf(xChars[0]);
const rawZ = BASE32.indexOf(zChars[1]) * 32 + BASE32.indexOf(zChars[0]);
const x = (100 * rawX) / 1024
const z = (100 * rawZ) / 1024
return { x, z }
const x = (100 * rawX) / 1024;
const z = (100 * rawZ) / 1024;
return { x, z };
}
/**
@@ -215,24 +219,24 @@ function locForUlid(ulid) {
function locForEthrDid(did) {
// "did:ethr:0x..."
if (did.length < 51) {
return { x: 0, z: 0 }
return { x: 0, z: 0 };
} else {
const randomness = did.substring('did:ethr:0x'.length)
const randomness = did.substring("did:ethr:0x".length);
// We'll use all the randomness for fully unique x & z values.
// But we'll only calculate this view with the first byte since our rendering resolution is low.
const xOff = parseInt(Number('0x' + randomness.substring(0, 2)), 10)
const x = (xOff * 100) / 256
const xOff = parseInt(Number("0x" + randomness.substring(0, 2)), 10);
const x = (xOff * 100) / 256;
// ... and since we're reserving 20 bytes total for x, start z with character 20,
// again with one byte.
const zOff = parseInt(Number('0x' + randomness.substring(20, 22)), 10)
const z = (zOff * 100) / 256
return { x, z }
const zOff = parseInt(Number("0x" + randomness.substring(20, 22)), 10);
const z = (zOff * 100) / 256;
return { x, z };
}
}
function createLight() {
const light = new THREE.SpotLight(0xffffff, 0, 0, Math.PI / 8, 0.5, 0)
const light = new THREE.SpotLight(0xffffff, 0, 0, Math.PI / 8, 0.5, 0);
// eslint-disable-next-line @typescript-eslint/no-empty-function
light.tick = () => {}
return light
light.tick = () => {};
return light;
}

View File

@@ -1,29 +1,29 @@
import { PlaneGeometry, MeshLambertMaterial, Mesh, TextureLoader } from 'three'
import { PlaneGeometry, MeshLambertMaterial, Mesh, TextureLoader } from "three";
export function createTerrain(props) {
const loader = new TextureLoader()
const height = loader.load('img/textures/leafy-autumn-forest-floor.jpg')
const loader = new TextureLoader();
const height = loader.load("img/textures/leafy-autumn-forest-floor.jpg");
// w h
const geometry = new PlaneGeometry(props.width, props.height, 64, 64)
const geometry = new PlaneGeometry(props.width, props.height, 64, 64);
const material = new MeshLambertMaterial({
color: props.color,
flatShading: true,
map: height
map: height,
//displacementMap: height,
//displacementScale: 5,
})
});
const plane = new Mesh(geometry, material)
plane.position.set(0, 0, 0)
plane.rotation.x -= Math.PI * 0.5
const plane = new Mesh(geometry, material);
plane.position.set(0, 0, 0);
plane.rotation.x -= Math.PI * 0.5;
//Storing our original vertices position on a new attribute
plane.geometry.attributes.position.originalPosition =
plane.geometry.attributes.position.array
plane.geometry.attributes.position.array;
// eslint-disable-next-line @typescript-eslint/no-empty-function
plane.tick = () => {}
plane.tick = () => {};
return plane
return plane;
}

View File

@@ -1,11 +1,11 @@
import { Color, Scene } from 'three'
import { Color, Scene } from "three";
function createScene(color) {
const scene = new Scene()
const scene = new Scene();
scene.background = new Color(color)
scene.background = new Color(color);
//scene.fog = new Fog(color, 60, 90);
return scene
return scene;
}
export { createScene }
export { createScene };

View File

@@ -1,33 +1,33 @@
import { Clock } from 'three'
import { Clock } from "three";
const clock = new Clock()
const clock = new Clock();
class Loop {
constructor(camera, scene, renderer) {
this.camera = camera
this.scene = scene
this.renderer = renderer
this.updatables = []
this.camera = camera;
this.scene = scene;
this.renderer = renderer;
this.updatables = [];
}
start() {
this.renderer.setAnimationLoop(() => {
this.tick()
this.tick();
// render a frame
this.renderer.render(this.scene, this.camera)
})
this.renderer.render(this.scene, this.camera);
});
}
stop() {
this.renderer.setAnimationLoop(null)
this.renderer.setAnimationLoop(null);
}
tick() {
const delta = clock.getDelta()
const delta = clock.getDelta();
for (const object of this.updatables) {
object.tick(delta)
object.tick(delta);
}
}
}
export { Loop }
export { Loop };

View File

@@ -1,33 +1,33 @@
const setSize = (container, camera, renderer) => {
// These are great for full-screen, which adjusts to a window.
const height = window.innerHeight
const width = window.innerWidth - 50
const height = window.innerHeight;
const width = window.innerWidth - 50;
// These are better for fitting in a container, which stays that size.
//const height = container.scrollHeight;
//const width = container.scrollWidth;
camera.aspect = width / height
camera.updateProjectionMatrix()
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height)
renderer.setPixelRatio(window.devicePixelRatio)
}
renderer.setSize(width, height);
renderer.setPixelRatio(window.devicePixelRatio);
};
class Resizer {
constructor(container, camera, renderer) {
// set initial size on load
setSize(container, camera, renderer)
setSize(container, camera, renderer);
window.addEventListener('resize', () => {
window.addEventListener("resize", () => {
// set the size again if a resize occurs
setSize(container, camera, renderer)
setSize(container, camera, renderer);
// perform any custom actions
this.onResize()
})
this.onResize();
});
}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onResize() {}
}
export { Resizer }
export { Resizer };

View File

@@ -1,12 +1,12 @@
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import { MathUtils } from 'three'
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { MathUtils } from "three";
function createControls(camera, canvas) {
const controls = new OrbitControls(camera, canvas)
const controls = new OrbitControls(camera, canvas);
//enable controls?
controls.enabled = true
controls.autoRotate = false
controls.enabled = true;
controls.autoRotate = false;
//controls.autoRotateSpeed = 0.2;
// control limits
@@ -14,8 +14,8 @@ function createControls(camera, canvas) {
// to prevent the user from clipping with the objects.
// y axis
controls.minPolarAngle = MathUtils.degToRad(40) // default
controls.maxPolarAngle = MathUtils.degToRad(75)
controls.minPolarAngle = MathUtils.degToRad(40); // default
controls.maxPolarAngle = MathUtils.degToRad(75);
// x axis
// controls.minAzimuthAngle = ...
@@ -23,16 +23,16 @@ function createControls(camera, canvas) {
//smooth camera:
// remember to add to loop updatables to work
controls.enableDamping = true
controls.enableDamping = true;
//controls.enableZoom = false;
controls.maxDistance = 250
controls.maxDistance = 250;
//controls.enablePan = false;
controls.tick = () => controls.update()
controls.tick = () => controls.update();
return controls
return controls;
}
export { createControls }
export { createControls };

View File

@@ -1,13 +1,13 @@
import { WebGLRenderer } from 'three'
import { WebGLRenderer } from "three";
function createRenderer() {
const renderer = new WebGLRenderer({ antialias: true })
const renderer = new WebGLRenderer({ antialias: true });
// turn on the physically correct lighting model
// (The browser complains: "THREE.WebGLRenderer: the property .physicallyCorrectLights has been removed. Set renderer.useLegacyLights instead." However, that changes the lighting in a way that doesn't look better.)
renderer.physicallyCorrectLights = true
renderer.physicallyCorrectLights = true;
return renderer
return renderer;
}
export { createRenderer }
export { createRenderer };

View File

@@ -6,63 +6,64 @@
export enum AppString {
// This is used in titles and verbiage inside the app.
// There is also an app name without spaces, for packaging in the package.json file used in the manifest.
APP_NAME = 'Time Safari',
APP_NAME = "Time Safari",
PROD_ENDORSER_API_SERVER = 'https://api.endorser.ch',
TEST_ENDORSER_API_SERVER = 'https://test-api.endorser.ch',
LOCAL_ENDORSER_API_SERVER = 'http://localhost:3000',
PROD_ENDORSER_API_SERVER = "https://api.endorser.ch",
TEST_ENDORSER_API_SERVER = "https://test-api.endorser.ch",
LOCAL_ENDORSER_API_SERVER = "http://localhost:3000",
PROD_IMAGE_API_SERVER = 'https://image-api.timesafari.app',
TEST_IMAGE_API_SERVER = 'https://test-image-api.timesafari.app',
LOCAL_IMAGE_API_SERVER = 'http://localhost:3001',
PROD_IMAGE_API_SERVER = "https://image-api.timesafari.app",
TEST_IMAGE_API_SERVER = "https://test-image-api.timesafari.app",
LOCAL_IMAGE_API_SERVER = "http://localhost:3001",
PROD_PARTNER_API_SERVER = 'https://partner-api.endorser.ch',
TEST_PARTNER_API_SERVER = 'https://test-partner-api.endorser.ch',
PROD_PARTNER_API_SERVER = "https://partner-api.endorser.ch",
TEST_PARTNER_API_SERVER = "https://test-partner-api.endorser.ch",
LOCAL_PARTNER_API_SERVER = LOCAL_ENDORSER_API_SERVER,
PROD_PUSH_SERVER = 'https://timesafari.app',
TEST1_PUSH_SERVER = 'https://test.timesafari.app',
TEST2_PUSH_SERVER = 'https://timesafari-pwa.anomalistlabs.com',
PROD_PUSH_SERVER = "https://timesafari.app",
TEST1_PUSH_SERVER = "https://test.timesafari.app",
TEST2_PUSH_SERVER = "https://timesafari-pwa.anomalistlabs.com",
NO_CONTACT_NAME = '(no name)'
NO_CONTACT_NAME = "(no name)",
}
export const APP_SERVER =
import.meta.env.VITE_APP_SERVER || 'https://timesafari.app'
import.meta.env.VITE_APP_SERVER || "https://timesafari.app";
export const DEFAULT_ENDORSER_API_SERVER =
import.meta.env.VITE_DEFAULT_ENDORSER_API_SERVER ||
AppString.TEST_ENDORSER_API_SERVER
AppString.TEST_ENDORSER_API_SERVER;
export const DEFAULT_IMAGE_API_SERVER =
import.meta.env.VITE_DEFAULT_IMAGE_API_SERVER ||
AppString.TEST_IMAGE_API_SERVER
AppString.TEST_IMAGE_API_SERVER;
export const DEFAULT_PARTNER_API_SERVER =
import.meta.env.VITE_DEFAULT_PARTNER_API_SERVER ||
AppString.TEST_PARTNER_API_SERVER
AppString.TEST_PARTNER_API_SERVER;
export const DEFAULT_PUSH_SERVER =
window.location.protocol + '//' + window.location.host
window.location.protocol + "//" + window.location.host;
export const IMAGE_TYPE_PROFILE = 'profile'
export const IMAGE_TYPE_PROFILE = "profile";
export const PASSKEYS_ENABLED = !!import.meta.env.VITE_PASSKEYS_ENABLED || false
export const PASSKEYS_ENABLED =
!!import.meta.env.VITE_PASSKEYS_ENABLED || false;
/**
* The possible values for "group" and "type" are in App.vue.
* Some of this comes from the notiwind package, some is custom.
*/
export interface NotificationIface {
group: string // "alert" | "modal"
type: string // "toast" | "info" | "success" | "warning" | "danger"
title: string
text?: string
callback?: (success: boolean) => Promise<void> // if this triggered an action
noText?: string
onCancel?: (stopAsking?: boolean) => Promise<void>
onNo?: (stopAsking?: boolean) => Promise<void>
onYes?: () => Promise<void>
promptToStopAsking?: boolean
yesText?: string
group: string; // "alert" | "modal"
type: string; // "toast" | "info" | "success" | "warning" | "danger"
title: string;
text?: string;
callback?: (success: boolean) => Promise<void>; // if this triggered an action
noText?: string;
onCancel?: (stopAsking?: boolean) => Promise<void>;
onNo?: (stopAsking?: boolean) => Promise<void>;
onYes?: () => Promise<void>;
promptToStopAsking?: boolean;
yesText?: string;
}

View File

@@ -1,44 +1,44 @@
import BaseDexie, { Table } from 'dexie'
import { encrypted, Encryption } from '@pvermeer/dexie-encrypted-addon'
import * as R from 'ramda'
import BaseDexie, { Table } from "dexie";
import { encrypted, Encryption } from "@pvermeer/dexie-encrypted-addon";
import * as R from "ramda";
import { Account, AccountsSchema } from './tables/accounts'
import { Contact, ContactSchema } from './tables/contacts'
import { Log, LogSchema } from './tables/logs'
import { MASTER_SECRET_KEY, Secret, SecretSchema } from './tables/secret'
import { Account, AccountsSchema } from "./tables/accounts";
import { Contact, ContactSchema } from "./tables/contacts";
import { Log, LogSchema } from "./tables/logs";
import { MASTER_SECRET_KEY, Secret, SecretSchema } from "./tables/secret";
import {
MASTER_SETTINGS_KEY,
Settings,
SettingsSchema
} from './tables/settings'
import { Temp, TempSchema } from './tables/temp'
import { DEFAULT_ENDORSER_API_SERVER } from '../constants/app'
import { logger } from '../utils/logger'
SettingsSchema,
} from "./tables/settings";
import { Temp, TempSchema } from "./tables/temp";
import { DEFAULT_ENDORSER_API_SERVER } from "../constants/app";
import { logger } from "../utils/logger";
// Define types for tables that hold sensitive and non-sensitive data
type SecretTable = { secret: Table<Secret> }
type SensitiveTables = { accounts: Table<Account> }
type SecretTable = { secret: Table<Secret> };
type SensitiveTables = { accounts: Table<Account> };
type NonsensitiveTables = {
contacts: Table<Contact>
logs: Table<Log>
settings: Table<Settings>
temp: Table<Temp>
}
contacts: Table<Contact>;
logs: Table<Log>;
settings: Table<Settings>;
temp: Table<Temp>;
};
// Using 'unknown' instead of 'any' for stricter typing and to avoid TypeScript warnings
export type SecretDexie<T extends unknown = SecretTable> = BaseDexie & T
export type SensitiveDexie<T extends unknown = SensitiveTables> = BaseDexie & T
export type SecretDexie<T extends unknown = SecretTable> = BaseDexie & T;
export type SensitiveDexie<T extends unknown = SensitiveTables> = BaseDexie & T;
export type NonsensitiveDexie<T extends unknown = NonsensitiveTables> =
BaseDexie & T
BaseDexie & T;
//// Initialize the DBs, starting with the sensitive ones.
// Initialize Dexie database for secret, which is then used to encrypt accountsDB
export const secretDB = new BaseDexie('TimeSafariSecret') as SecretDexie
secretDB.version(1).stores(SecretSchema)
export const secretDB = new BaseDexie("TimeSafariSecret") as SecretDexie;
secretDB.version(1).stores(SecretSchema);
// Initialize Dexie database for accounts
const accountsDexie = new BaseDexie('TimeSafariAccounts') as SensitiveDexie
const accountsDexie = new BaseDexie("TimeSafariAccounts") as SensitiveDexie;
// Instead of accountsDBPromise, use libs/util retrieveAccountMetadata or retrieveFullyDecryptedAccount
// so that it's clear whether the usage needs the private key inside.
@@ -49,13 +49,13 @@ const accountsDexie = new BaseDexie('TimeSafariAccounts') as SensitiveDexie
// to a user action required to unlock the data.
export const accountsDBPromise = useSecretAndInitializeAccountsDB(
secretDB,
accountsDexie
)
accountsDexie,
);
//// Now initialize the other DB.
// Initialize Dexie databases for non-sensitive data
export const db = new BaseDexie('TimeSafari') as NonsensitiveDexie
export const db = new BaseDexie("TimeSafari") as NonsensitiveDexie;
// Only the tables with index modifications need listing. https://dexie.org/docs/Tutorial/Design#database-versioning
@@ -64,31 +64,31 @@ export const db = new BaseDexie('TimeSafari') as NonsensitiveDexie
db.version(2).stores({
...ContactSchema,
...LogSchema,
...{ settings: 'id' } // old Settings schema
})
...{ settings: "id" }, // old Settings schema
});
// v3 added Temp
db.version(3).stores(TempSchema)
db.version(3).stores(TempSchema);
db.version(4)
.stores(SettingsSchema)
.upgrade((tx) => {
return tx
.table('settings')
.table("settings")
.toCollection()
.modify((settings) => {
settings.accountDid = '' // make it non-null for the default master settings, but still indexable
})
})
settings.accountDid = ""; // make it non-null for the default master settings, but still indexable
});
});
const DEFAULT_SETTINGS: Settings = {
id: MASTER_SETTINGS_KEY,
activeDid: undefined,
apiServer: DEFAULT_ENDORSER_API_SERVER
}
apiServer: DEFAULT_ENDORSER_API_SERVER,
};
// Event handler to initialize the non-sensitive database with default settings
db.on('populate', async () => {
await db.settings.add(DEFAULT_SETTINGS)
})
db.on("populate", async () => {
await db.settings.add(DEFAULT_SETTINGS);
});
// Manage the encryption key.
@@ -114,130 +114,130 @@ db.on('populate', async () => {
async function useSecretAndInitializeAccountsDB(
secretDB: SecretDexie,
accountsDB: SensitiveDexie
accountsDB: SensitiveDexie,
): Promise<SensitiveDexie> {
return secretDB
.open()
.then(() => {
return secretDB.secret.get(MASTER_SECRET_KEY)
return secretDB.secret.get(MASTER_SECRET_KEY);
})
.then((secretRow?: Secret) => {
let secret = secretRow?.secret
let secret = secretRow?.secret;
if (secret != null) {
// they already have it in IndexedDB, so just pass it along
return secret
return secret;
} else {
// check localStorage (for users before v 0.3.37)
const localSecret = localStorage.getItem('secret')
const localSecret = localStorage.getItem("secret");
if (localSecret != null) {
// they had one, so we want to move it to IndexedDB
secret = localSecret
secret = localSecret;
} else {
// they didn't have one, so let's generate one
secret = Encryption.createRandomEncryptionKey()
secret = Encryption.createRandomEncryptionKey();
}
// it is not in IndexedDB, so add it now
return secretDB.secret
.add({ id: MASTER_SECRET_KEY, secret })
.then(() => {
return secret
})
return secret;
});
}
})
.then((secret?: string) => {
if (secret == null) {
throw new Error('No secret found or created.')
throw new Error("No secret found or created.");
} else {
// apply encryption to the sensitive database using the secret key
encrypted(accountsDB, { secretKey: secret })
accountsDB.version(1).stores(AccountsSchema)
accountsDB.open()
return accountsDB
encrypted(accountsDB, { secretKey: secret });
accountsDB.version(1).stores(AccountsSchema);
accountsDB.open();
return accountsDB;
}
})
.catch((error) => {
logConsoleAndDb('Error processing secret & encrypted accountsDB.', error)
logConsoleAndDb("Error processing secret & encrypted accountsDB.", error);
// alert("There was an error processing encrypted data. See the Help page.");
throw error
})
throw error;
});
}
// retrieves default settings
// calls db.open()
export async function retrieveSettingsForDefaultAccount(): Promise<Settings> {
await db.open()
return (await db.settings.get(MASTER_SETTINGS_KEY)) || DEFAULT_SETTINGS
await db.open();
return (await db.settings.get(MASTER_SETTINGS_KEY)) || DEFAULT_SETTINGS;
}
export async function retrieveSettingsForActiveAccount(): Promise<Settings> {
const defaultSettings = await retrieveSettingsForDefaultAccount()
const defaultSettings = await retrieveSettingsForDefaultAccount();
if (!defaultSettings.activeDid) {
return defaultSettings
return defaultSettings;
} else {
const overrideSettings =
(await db.settings
.where('accountDid')
.where("accountDid")
.equals(defaultSettings.activeDid)
.first()) || {}
return R.mergeDeepRight(defaultSettings, overrideSettings)
.first()) || {};
return R.mergeDeepRight(defaultSettings, overrideSettings);
}
}
export async function updateDefaultSettings(
settingsChanges: Settings
settingsChanges: Settings,
): Promise<void> {
delete settingsChanges.accountDid // just in case
delete settingsChanges.accountDid; // just in case
// ensure there is no "id" that would override the key
delete settingsChanges.id
await db.settings.update(MASTER_SETTINGS_KEY, settingsChanges)
delete settingsChanges.id;
await db.settings.update(MASTER_SETTINGS_KEY, settingsChanges);
}
export async function updateAccountSettings(
accountDid: string,
settingsChanges: Settings
settingsChanges: Settings,
): Promise<void> {
settingsChanges.accountDid = accountDid
delete settingsChanges.id // key off account, not ID
settingsChanges.accountDid = accountDid;
delete settingsChanges.id; // key off account, not ID
const result = await db.settings
.where('accountDid')
.where("accountDid")
.equals(settingsChanges.accountDid)
.modify(settingsChanges)
.modify(settingsChanges);
if (result === 0) {
if (!settingsChanges.id) {
// It is unfortunate that we have to set this explicitly.
// We didn't make id a "++id" at the beginning and Dexie won't let us change it,
// plus we made our first settings objects MASTER_SETTINGS_KEY = 1 instead of 0
settingsChanges.id = (await db.settings.count()) + 1
settingsChanges.id = (await db.settings.count()) + 1;
}
await db.settings.add(settingsChanges)
await db.settings.add(settingsChanges);
}
}
export async function logToDb(message: string): Promise<void> {
await db.open()
const todayKey = new Date().toDateString()
await db.open();
const todayKey = new Date().toDateString();
// only keep one day's worth of logs
const previous = await db.logs.get(todayKey)
const previous = await db.logs.get(todayKey);
if (!previous) {
// when this is today's first log, clear out everything previous
// to avoid the log table getting too large
// (let's limit a different way someday)
await db.logs.clear()
await db.logs.clear();
}
const prevMessages = (previous && previous.message) || ''
const fullMessage = `${prevMessages}\n${new Date().toISOString()} ${message}`
await db.logs.update(todayKey, { message: fullMessage })
const prevMessages = (previous && previous.message) || "";
const fullMessage = `${prevMessages}\n${new Date().toISOString()} ${message}`;
await db.logs.update(todayKey, { message: fullMessage });
}
// similar method is in the sw_scripts/additional-scripts.js file
export async function logConsoleAndDb(
message: string,
isError = false
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)
await logToDb(message);
}

View File

@@ -5,45 +5,45 @@ export type Account = {
/**
* Auto-generated ID by Dexie
*/
id?: number // this is only blank on input, when the database assigns it
id?: number; // this is only blank on input, when the database assigns it
/**
* The date the account was created
*/
dateCreated: string
dateCreated: string;
/**
* The derivation path for the account, if this is from a mnemonic
*/
derivationPath?: string
derivationPath?: string;
/**
* Decentralized Identifier (DID) for the account
*/
did: string
did: string;
/**
* Stringified JSON containing underlying key material, if generated from a mnemonic
* Based on the IIdentifier type from Veramo
* @see {@link https://github.com/uport-project/veramo/blob/next/packages/core-types/src/types/IIdentifier.ts}
*/
identity?: string
identity?: string;
/**
* The mnemonic phrase for the account, if this is from a mnemonic
*/
mnemonic?: string
mnemonic?: string;
/**
* The Webauthn credential ID in hex, if this is from a passkey
*/
passkeyCredIdHex?: string
passkeyCredIdHex?: string;
/**
* The public key in hexadecimal format
*/
publicKeyHex: string
}
publicKeyHex: string;
};
/**
* Schema for the accounts table in the database.
@@ -52,5 +52,5 @@ export type Account = {
*/
export const AccountsSchema = {
accounts:
'++id, dateCreated, derivationPath, did, $identity, $mnemonic, publicKeyHex'
}
"++id, dateCreated, derivationPath, did, $identity, $mnemonic, publicKeyHex",
};

View File

@@ -1,24 +1,24 @@
export interface ContactMethod {
label: string
type: string // eg. "EMAIL", "SMS", "WHATSAPP", maybe someday "GOOGLE-CONTACT-API"
value: string
label: string;
type: string; // eg. "EMAIL", "SMS", "WHATSAPP", maybe someday "GOOGLE-CONTACT-API"
value: string;
}
export interface Contact {
//
// When adding a property, consider whether it should be added when exporting & sharing contacts.
did: string
contactMethods?: Array<ContactMethod>
name?: string
nextPubKeyHashB64?: string // base64-encoded SHA256 hash of next public key
notes?: string
profileImageUrl?: string
publicKeyBase64?: string
seesMe?: boolean // cached value of the server setting
registered?: boolean // cached value of the server setting
did: string;
contactMethods?: Array<ContactMethod>;
name?: string;
nextPubKeyHashB64?: string; // base64-encoded SHA256 hash of next public key
notes?: string;
profileImageUrl?: string;
publicKeyBase64?: string;
seesMe?: boolean; // cached value of the server setting
registered?: boolean; // cached value of the server setting
}
export const ContactSchema = {
contacts: '&did, name' // no need to key by other things
}
contacts: "&did, name", // no need to key by other things
};

View File

@@ -1,11 +1,11 @@
export interface Log {
date: string
message: string
date: string;
message: string;
}
export const LogSchema = {
// Currently keyed by "date" because A) today's log data is what we need so we append, and
// B) we don't want it to grow so we remove everything if this is the first entry today.
// See safari-notifications.js logMessage for the associated logic.
logs: 'date' // definitely don't key by the potentially large message field
}
logs: "date", // definitely don't key by the potentially large message field
};

View File

@@ -5,14 +5,14 @@ export type Secret = {
/**
* Auto-generated ID by Dexie
*/
id: number
id: number;
/**
* The secret key used to decrypt the identity if they're not using their own password
*/
secret: string
}
secret: string;
};
export const SecretSchema = { secret: '++id, secret' }
export const SecretSchema = { secret: "++id, secret" };
export const MASTER_SECRET_KEY = 0
export const MASTER_SECRET_KEY = 0;

View File

@@ -2,82 +2,82 @@
* BoundingBox type describes the geographical bounding box coordinates.
*/
export type BoundingBox = {
eastLong: number // Eastern longitude
maxLat: number // Maximum (Northernmost) latitude
minLat: number // Minimum (Southernmost) latitude
westLong: number // Western longitude
}
eastLong: number; // Eastern longitude
maxLat: number; // Maximum (Northernmost) latitude
minLat: number; // Minimum (Southernmost) latitude
westLong: number; // Western longitude
};
/**
* Settings type encompasses user-specific configuration details.
*/
export type Settings = {
// default entry is keyed with MASTER_SETTINGS_KEY; other entries are linked to an account with account ID
id?: number // this is erased for all those entries that are keyed with accountDid
id?: number; // this is erased for all those entries that are keyed with accountDid
// if supplied, this settings record overrides the master record when the user switches to this account
accountDid?: string // not used in the MASTER_SETTINGS_KEY entry
accountDid?: string; // not used in the MASTER_SETTINGS_KEY entry
// active Decentralized ID
activeDid?: string // only used in the MASTER_SETTINGS_KEY entry
activeDid?: string; // only used in the MASTER_SETTINGS_KEY entry
apiServer?: string // API server URL
apiServer?: string; // API server URL
filterFeedByNearby?: boolean // filter by nearby
filterFeedByVisible?: boolean // filter by visible users ie. anyone not hidden
finishedOnboarding?: boolean // the user has completed the onboarding process
filterFeedByNearby?: boolean; // filter by nearby
filterFeedByVisible?: boolean; // filter by visible users ie. anyone not hidden
finishedOnboarding?: boolean; // the user has completed the onboarding process
firstName?: string // user's full name, may be null if unwanted for a particular account
hideRegisterPromptOnNewContact?: boolean
isRegistered?: boolean
firstName?: string; // user's full name, may be null if unwanted for a particular account
hideRegisterPromptOnNewContact?: boolean;
isRegistered?: boolean;
// imageServer?: string; // if we want to allow modification then we should make image functionality optional -- or at least customizable
lastName?: string // deprecated - put all names in firstName
lastName?: string; // deprecated - put all names in firstName
lastAckedOfferToUserJwtId?: string // the last JWT ID for offer-to-user that they've acknowledged seeing
lastAckedOfferToUserProjectsJwtId?: string // the last JWT ID for offers-to-user's-projects that they've acknowledged seeing
lastAckedOfferToUserJwtId?: string; // the last JWT ID for offer-to-user that they've acknowledged seeing
lastAckedOfferToUserProjectsJwtId?: string; // the last JWT ID for offers-to-user's-projects that they've acknowledged seeing
// The claim list has a most recent one used in notifications that's separate from the last viewed
lastNotifiedClaimId?: string
lastViewedClaimId?: string
lastNotifiedClaimId?: string;
lastViewedClaimId?: string;
notifyingNewActivityTime?: string // set to their chosen time if they have turned on daily check for new activity via the push server
notifyingReminderMessage?: string // set to their chosen message for a daily reminder
notifyingReminderTime?: string // set to their chosen time for a daily reminder
notifyingNewActivityTime?: string; // set to their chosen time if they have turned on daily check for new activity via the push server
notifyingReminderMessage?: string; // set to their chosen message for a daily reminder
notifyingReminderTime?: string; // set to their chosen time for a daily reminder
partnerApiServer?: string // partner server API URL
partnerApiServer?: string; // partner server API URL
passkeyExpirationMinutes?: number // passkey access token time-to-live in minutes
passkeyExpirationMinutes?: number; // passkey access token time-to-live in minutes
profileImageUrl?: string // may be null if unwanted for a particular account
profileImageUrl?: string; // may be null if unwanted for a particular account
// Array of named search boxes defined by bounding boxes
searchBoxes?: Array<{
name: string
bbox: BoundingBox
}>
name: string;
bbox: BoundingBox;
}>;
showContactGivesInline?: boolean // Display contact inline or not
showGeneralAdvanced?: boolean // Show advanced features which don't have their own flag
showShortcutBvc?: boolean // Show shortcut for Bountiful Voluntaryist Community actions
vapid?: string // VAPID (Voluntary Application Server Identification) field for web push
warnIfProdServer?: boolean // Warn if using a production server
warnIfTestServer?: boolean // Warn if using a testing server
webPushServer?: string // Web Push server URL
}
showContactGivesInline?: boolean; // Display contact inline or not
showGeneralAdvanced?: boolean; // Show advanced features which don't have their own flag
showShortcutBvc?: boolean; // Show shortcut for Bountiful Voluntaryist Community actions
vapid?: string; // VAPID (Voluntary Application Server Identification) field for web push
warnIfProdServer?: boolean; // Warn if using a production server
warnIfTestServer?: boolean; // Warn if using a testing server
webPushServer?: string; // Web Push server URL
};
export function checkIsAnyFeedFilterOn(settings: Settings): boolean {
return !!(settings?.filterFeedByNearby || settings?.filterFeedByVisible)
return !!(settings?.filterFeedByNearby || settings?.filterFeedByVisible);
}
/**
* Schema for the Settings table in the database.
*/
export const SettingsSchema = {
settings: 'id, &accountDid'
}
settings: "id, &accountDid",
};
/**
* Constants.
*/
export const MASTER_SETTINGS_KEY = 1
export const MASTER_SETTINGS_KEY = 1;
export const DEFAULT_PASSKEY_EXPIRATION_MINUTES = 15
export const DEFAULT_PASSKEY_EXPIRATION_MINUTES = 15;

View File

@@ -1,12 +1,12 @@
// for ephemeral uses, eg. passing a blob from the service worker to the main thread
export type Temp = {
id: string
blob?: Blob // deprecated because webkit (Safari) does not support Blob
blobB64?: string // base64-encoded blob
}
id: string;
blob?: Blob; // deprecated because webkit (Safari) does not support Blob
blobB64?: string; // base64-encoded blob
};
/**
* Schema for the Temp table in the database.
*/
export const TempSchema = { temp: 'id' }
export const TempSchema = { temp: "id" };

View File

@@ -1,16 +1,16 @@
const { app, BrowserWindow } = require('electron')
const path = require('path')
const fs = require('fs')
const logger = require('../utils/logger')
const { app, BrowserWindow } = require("electron");
const path = require("path");
const fs = require("fs");
const logger = require("../utils/logger");
// Check if running in dev mode
const isDev = process.argv.includes('--inspect')
const isDev = process.argv.includes("--inspect");
function createWindow() {
// Add before createWindow function
const preloadPath = path.join(__dirname, 'preload.js')
logger.log('Checking preload path:', preloadPath)
logger.log('Preload exists:', fs.existsSync(preloadPath))
const preloadPath = path.join(__dirname, "preload.js");
logger.log("Checking preload path:", preloadPath);
logger.log("Preload exists:", fs.existsSync(preloadPath));
// Create the browser window.
const mainWindow = new BrowserWindow({
@@ -21,71 +21,71 @@ function createWindow() {
contextIsolation: true,
webSecurity: true,
allowRunningInsecureContent: false,
preload: path.join(__dirname, 'preload.js')
}
})
preload: path.join(__dirname, "preload.js"),
},
});
// Always open DevTools for now
mainWindow.webContents.openDevTools()
mainWindow.webContents.openDevTools();
// Intercept requests to fix asset paths
mainWindow.webContents.session.webRequest.onBeforeRequest(
{
urls: [
'file://*/*/assets/*',
'file://*/assets/*',
'file:///assets/*', // Catch absolute paths
'<all_urls>' // Catch all URLs as a fallback
]
"file://*/*/assets/*",
"file://*/assets/*",
"file:///assets/*", // Catch absolute paths
"<all_urls>", // Catch all URLs as a fallback
],
},
(details, callback) => {
let url = details.url
let url = details.url;
// Handle paths that don't start with file://
if (!url.startsWith('file://') && url.includes('/assets/')) {
url = `file://${path.join(__dirname, 'www', url)}`
if (!url.startsWith("file://") && url.includes("/assets/")) {
url = `file://${path.join(__dirname, "www", url)}`;
}
// Handle absolute paths starting with /assets/
if (url.includes('/assets/') && !url.includes('/www/assets/')) {
const baseDir = url.includes('dist-electron')
if (url.includes("/assets/") && !url.includes("/www/assets/")) {
const baseDir = url.includes("dist-electron")
? url.substring(
0,
url.indexOf('/dist-electron') + '/dist-electron'.length
url.indexOf("/dist-electron") + "/dist-electron".length,
)
: `file://${__dirname}`
const assetPath = url.split('/assets/')[1]
const newUrl = `${baseDir}/www/assets/${assetPath}`
callback({ redirectURL: newUrl })
return
: `file://${__dirname}`;
const assetPath = url.split("/assets/")[1];
const newUrl = `${baseDir}/www/assets/${assetPath}`;
callback({ redirectURL: newUrl });
return;
}
callback({}) // No redirect for other URLs
}
)
callback({}); // No redirect for other URLs
},
);
if (isDev) {
// Debug info
logger.log('Debug Info:')
logger.log('Running in dev mode:', isDev)
logger.log('App is packaged:', app.isPackaged)
logger.log('Process resource path:', process.resourcesPath)
logger.log('App path:', app.getAppPath())
logger.log('__dirname:', __dirname)
logger.log('process.cwd():', process.cwd())
logger.log("Debug Info:");
logger.log("Running in dev mode:", isDev);
logger.log("App is packaged:", app.isPackaged);
logger.log("Process resource path:", process.resourcesPath);
logger.log("App path:", app.getAppPath());
logger.log("__dirname:", __dirname);
logger.log("process.cwd():", process.cwd());
}
const indexPath = path.join(__dirname, 'www', 'index.html')
const indexPath = path.join(__dirname, "www", "index.html");
if (isDev) {
logger.log('Loading index from:', indexPath)
logger.log('www path:', path.join(__dirname, 'www'))
logger.log('www assets path:', path.join(__dirname, 'www', 'assets'))
logger.log("Loading index from:", indexPath);
logger.log("www path:", path.join(__dirname, "www"));
logger.log("www assets path:", path.join(__dirname, "www", "assets"));
}
if (!fs.existsSync(indexPath)) {
logger.error(`Index file not found at: ${indexPath}`)
throw new Error('Index file not found')
logger.error(`Index file not found at: ${indexPath}`);
throw new Error("Index file not found");
}
// Add CSP headers to allow API connections
@@ -94,81 +94,81 @@ function createWindow() {
callback({
responseHeaders: {
...details.responseHeaders,
'Content-Security-Policy': [
"Content-Security-Policy": [
"default-src 'self';" +
"connect-src 'self' https://api.endorser.ch https://*.timesafari.app;" +
"img-src 'self' data: https: blob:;" +
"script-src 'self' 'unsafe-inline' 'unsafe-eval';" +
"style-src 'self' 'unsafe-inline';" +
"font-src 'self' data:;"
]
}
})
}
)
"font-src 'self' data:;",
],
},
});
},
);
// Load the index.html
mainWindow
.loadFile(indexPath)
.then(() => {
logger.log('Successfully loaded index.html')
logger.log("Successfully loaded index.html");
if (isDev) {
mainWindow.webContents.openDevTools()
logger.log('DevTools opened - running in dev mode')
mainWindow.webContents.openDevTools();
logger.log("DevTools opened - running in dev mode");
}
})
.catch((err) => {
logger.error('Failed to load index.html:', err)
logger.error('Attempted path:', indexPath)
})
logger.error("Failed to load index.html:", err);
logger.error("Attempted path:", indexPath);
});
// Listen for console messages from the renderer
mainWindow.webContents.on('console-message', (_event, level, message) => {
logger.log('Renderer Console:', message)
})
mainWindow.webContents.on("console-message", (_event, level, message) => {
logger.log("Renderer Console:", message);
});
// Add right after creating the BrowserWindow
mainWindow.webContents.on(
'did-fail-load',
"did-fail-load",
(event, errorCode, errorDescription) => {
logger.error('Page failed to load:', errorCode, errorDescription)
}
)
logger.error("Page failed to load:", errorCode, errorDescription);
},
);
mainWindow.webContents.on('preload-error', (event, preloadPath, error) => {
logger.error('Preload script error:', preloadPath, error)
})
mainWindow.webContents.on("preload-error", (event, preloadPath, error) => {
logger.error("Preload script error:", preloadPath, error);
});
mainWindow.webContents.on(
'console-message',
"console-message",
(event, level, message, line, sourceId) => {
logger.log('Renderer Console:', line, sourceId, message)
}
)
logger.log("Renderer Console:", line, sourceId, message);
},
);
// Enable remote debugging when in dev mode
if (isDev) {
mainWindow.webContents.openDevTools()
mainWindow.webContents.openDevTools();
}
}
// Handle app ready
app.whenReady().then(createWindow)
app.whenReady().then(createWindow);
// Handle all windows closed
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
})
});
app.on('activate', () => {
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
createWindow();
}
})
});
// Handle any errors
process.on('uncaughtException', (error) => {
logger.error('Uncaught Exception:', error)
})
process.on("uncaughtException", (error) => {
logger.error("Uncaught Exception:", error);
});

View File

@@ -1,78 +1,78 @@
const { contextBridge, ipcRenderer } = require('electron')
const { contextBridge, ipcRenderer } = require("electron");
const logger = {
log: (message, ...args) => {
if (process.env.NODE_ENV !== 'production') {
if (process.env.NODE_ENV !== "production") {
/* eslint-disable no-console */
console.log(message, ...args)
console.log(message, ...args);
/* eslint-enable no-console */
}
},
warn: (message, ...args) => {
if (process.env.NODE_ENV !== 'production') {
if (process.env.NODE_ENV !== "production") {
/* eslint-disable no-console */
console.warn(message, ...args)
console.warn(message, ...args);
/* eslint-enable no-console */
}
},
error: (message, ...args) => {
/* eslint-disable no-console */
console.error(message, ...args) // Errors should always be logged
console.error(message, ...args); // Errors should always be logged
/* eslint-enable no-console */
}
}
},
};
// Use a more direct path resolution approach
const getPath = (pathType) => {
switch (pathType) {
case 'userData':
case "userData":
return (
process.env.APPDATA ||
(process.platform === 'darwin'
(process.platform === "darwin"
? `${process.env.HOME}/Library/Application Support`
: `${process.env.HOME}/.local/share`)
)
case 'home':
return process.env.HOME
case 'appPath':
return process.resourcesPath
);
case "home":
return process.env.HOME;
case "appPath":
return process.resourcesPath;
default:
return ''
return "";
}
}
};
logger.log('Preload script starting...')
logger.log("Preload script starting...");
try {
contextBridge.exposeInMainWorld('electronAPI', {
contextBridge.exposeInMainWorld("electronAPI", {
// Path utilities
getPath,
// IPC functions
send: (channel, data) => {
const validChannels = ['toMain']
const validChannels = ["toMain"];
if (validChannels.includes(channel)) {
ipcRenderer.send(channel, data)
ipcRenderer.send(channel, data);
}
},
receive: (channel, func) => {
const validChannels = ['fromMain']
const validChannels = ["fromMain"];
if (validChannels.includes(channel)) {
ipcRenderer.on(channel, (event, ...args) => func(...args))
ipcRenderer.on(channel, (event, ...args) => func(...args));
}
},
// Environment info
env: {
isElectron: true,
isDev: process.env.NODE_ENV === 'development'
isDev: process.env.NODE_ENV === "development",
},
// Path utilities
getBasePath: () => {
return process.env.NODE_ENV === 'development' ? '/' : './'
}
})
return process.env.NODE_ENV === "development" ? "/" : "./";
},
});
logger.log('Preload script completed successfully')
logger.log("Preload script completed successfully");
} catch (error) {
logger.error('Error in preload script:', error)
logger.error("Error in preload script:", error);
}

View File

@@ -1,58 +1,58 @@
import { AxiosResponse } from 'axios'
import { GiverReceiverInputInfo } from '../libs/util'
import { ErrorResult, ResultWithType } from './common'
import { AxiosResponse } from "axios";
import { GiverReceiverInputInfo } from "../libs/util";
import { ErrorResult, ResultWithType } from "./common";
export interface GiverOutputInfo {
action: string
giver?: GiverReceiverInputInfo
description?: string
amount?: number
unitCode?: string
action: string;
giver?: GiverReceiverInputInfo;
description?: string;
amount?: number;
unitCode?: string;
}
export interface ClaimResult {
success: { claimId: string; handleId: string }
error: { code: string; message: string }
success: { claimId: string; handleId: string };
error: { code: string; message: string };
}
export interface VerifiableCredential {
exp?: number
iat: number
iss: string
exp?: number;
iat: number;
iss: string;
vc: {
'@context': string[]
type: string[]
credentialSubject: VerifiableCredentialSubject
}
"@context": string[];
type: string[];
credentialSubject: VerifiableCredentialSubject;
};
}
export interface VerifiableCredentialSubject {
'@context': string
'@type': string
[key: string]: unknown
"@context": string;
"@type": string;
[key: string]: unknown;
}
export interface WorldProperties {
startTime?: string
endTime?: string
startTime?: string;
endTime?: string;
}
export interface ProviderInfo {
/**
* Could be a DID or a handleId that identifies the provider
*/
identifier: string
identifier: string;
/**
* Indicates if the provider link has been confirmed
*/
linkConfirmed: boolean
linkConfirmed: boolean;
}
// Type for createAndSubmitClaim result
export type CreateAndSubmitClaimResult = SuccessResult | ErrorResult
export type CreateAndSubmitClaimResult = SuccessResult | ErrorResult;
// Update SuccessResult to use ClaimResult
export interface SuccessResult extends ResultWithType {
type: 'success'
response: AxiosResponse<ClaimResult>
type: "success";
response: AxiosResponse<ClaimResult>;
}

View File

@@ -1,68 +1,68 @@
import { GenericVerifiableCredential } from './common'
import { GenericVerifiableCredential } from "./common";
export interface AgreeVerifiableCredential {
'@context': string
'@type': string
object: Record<string, unknown>
"@context": string;
"@type": string;
object: Record<string, unknown>;
}
// Note that previous VCs may have additional fields.
// https://endorser.ch/doc/html/transactions.html#id4
export interface GiveVerifiableCredential extends GenericVerifiableCredential {
'@context'?: string
'@type': 'GiveAction'
agent?: { identifier: string }
description?: string
fulfills?: { '@type': string; identifier?: string; lastClaimId?: string }[]
identifier?: string
image?: string
object?: { amountOfThisGood: number; unitCode: string }
provider?: GenericVerifiableCredential
recipient?: { identifier: string }
"@context"?: string;
"@type": "GiveAction";
agent?: { identifier: string };
description?: string;
fulfills?: { "@type": string; identifier?: string; lastClaimId?: string }[];
identifier?: string;
image?: string;
object?: { amountOfThisGood: number; unitCode: string };
provider?: GenericVerifiableCredential;
recipient?: { identifier: string };
}
// Note that previous VCs may have additional fields.
// https://endorser.ch/doc/html/transactions.html#id8
export interface OfferVerifiableCredential extends GenericVerifiableCredential {
'@context'?: string
'@type': 'Offer'
description?: string
includesObject?: { amountOfThisGood: number; unitCode: string }
"@context"?: string;
"@type": "Offer";
description?: string;
includesObject?: { amountOfThisGood: number; unitCode: string };
itemOffered?: {
description?: string
description?: string;
isPartOf?: {
identifier?: string
lastClaimId?: string
'@type'?: string
name?: string
}
}
offeredBy?: { identifier: string }
recipient?: { identifier: string }
validThrough?: string
identifier?: string;
lastClaimId?: string;
"@type"?: string;
name?: string;
};
};
offeredBy?: { identifier: string };
recipient?: { identifier: string };
validThrough?: string;
}
// Note that previous VCs may have additional fields.
// https://endorser.ch/doc/html/transactions.html#id7
export interface PlanVerifiableCredential extends GenericVerifiableCredential {
'@context': 'https://schema.org'
'@type': 'PlanAction'
name: string
agent?: { identifier: string }
description?: string
identifier?: string
lastClaimId?: string
"@context": "https://schema.org";
"@type": "PlanAction";
name: string;
agent?: { identifier: string };
description?: string;
identifier?: string;
lastClaimId?: string;
location?: {
geo: { '@type': 'GeoCoordinates'; latitude: number; longitude: number }
}
geo: { "@type": "GeoCoordinates"; latitude: number; longitude: number };
};
}
// AKA Registration & RegisterAction
export interface RegisterVerifiableCredential {
'@context': string
'@type': 'RegisterAction'
agent: { identifier: string }
identifier?: string
object: string
participant?: { identifier: string }
"@context": string;
"@type": "RegisterAction";
agent: { identifier: string };
identifier?: string;
object: string;
participant?: { identifier: string };
}

View File

@@ -1,36 +1,36 @@
// similar to VerifiableCredentialSubject... maybe rename this
export interface GenericVerifiableCredential {
'@context'?: string
'@type': string
[key: string]: unknown
"@context"?: string;
"@type": string;
[key: string]: unknown;
}
export interface GenericCredWrapper<T extends GenericVerifiableCredential> {
claim: T
claimType?: string
handleId: string
id: string
issuedAt: string
issuer: string
publicUrls?: Record<string, string>
claim: T;
claimType?: string;
handleId: string;
id: string;
issuedAt: string;
issuer: string;
publicUrls?: Record<string, string>;
}
export interface ResultWithType {
type: string
type: string;
}
export interface ErrorResponse {
error?: {
message?: string
}
message?: string;
};
}
export interface InternalError {
error: string
userMessage?: string
error: string;
userMessage?: string;
}
export interface ErrorResult extends ResultWithType {
type: 'error'
error: InternalError
type: "error";
error: InternalError;
}

View File

@@ -8,6 +8,6 @@
*/
export interface DeepLinkError extends Error {
code: string
details?: unknown
code: string;
details?: unknown;
}

View File

@@ -1,7 +1,7 @@
export * from './claims'
export * from './claims-result'
export * from './common'
export * from './limits'
export * from './records'
export * from './user'
export * from './deepLinks'
export * from "./claims";
export * from "./claims-result";
export * from "./common";
export * from "./limits";
export * from "./records";
export * from "./user";
export * from "./deepLinks";

View File

@@ -1,14 +1,14 @@
export interface EndorserRateLimits {
doneClaimsThisWeek: string
doneRegistrationsThisMonth: string
maxClaimsPerWeek: string
maxRegistrationsPerMonth: string
nextMonthBeginDateTime: string
nextWeekBeginDateTime: string
doneClaimsThisWeek: string;
doneRegistrationsThisMonth: string;
maxClaimsPerWeek: string;
maxRegistrationsPerMonth: string;
nextMonthBeginDateTime: string;
nextWeekBeginDateTime: string;
}
export interface ImageRateLimits {
doneImagesThisWeek: string
maxImagesPerWeek: string
nextWeekBeginDateTime: string
doneImagesThisWeek: string;
maxImagesPerWeek: string;
nextWeekBeginDateTime: string;
}

View File

@@ -1,64 +1,64 @@
import { GiveVerifiableCredential, OfferVerifiableCredential } from './claims'
import { GiveVerifiableCredential, OfferVerifiableCredential } from "./claims";
// a summary record; the VC is found the fullClaim field
export interface GiveSummaryRecord {
[x: string]: PropertyKey | undefined | GiveVerifiableCredential
type?: string
agentDid: string
amount: number
amountConfirmed: number
description: string
fullClaim: GiveVerifiableCredential
fulfillsHandleId: string
fulfillsPlanHandleId?: string
fulfillsType?: string
handleId: string
issuedAt: string
issuerDid: string
jwtId: string
providerPlanHandleId?: string
recipientDid: string
unit: string
[x: string]: PropertyKey | undefined | GiveVerifiableCredential;
type?: string;
agentDid: string;
amount: number;
amountConfirmed: number;
description: string;
fullClaim: GiveVerifiableCredential;
fulfillsHandleId: string;
fulfillsPlanHandleId?: string;
fulfillsType?: string;
handleId: string;
issuedAt: string;
issuerDid: string;
jwtId: string;
providerPlanHandleId?: string;
recipientDid: string;
unit: string;
}
// a summary record; the VC is found the fullClaim field
export interface OfferSummaryRecord {
amount: number
amountGiven: number
amountGivenConfirmed: number
fullClaim: OfferVerifiableCredential
fulfillsPlanHandleId: string
handleId: string
issuerDid: string
jwtId: string
nonAmountGivenConfirmed: number
objectDescription: string
offeredByDid: string
recipientDid: string
requirementsMet: boolean
unit: string
validThrough: string
amount: number;
amountGiven: number;
amountGivenConfirmed: number;
fullClaim: OfferVerifiableCredential;
fulfillsPlanHandleId: string;
handleId: string;
issuerDid: string;
jwtId: string;
nonAmountGivenConfirmed: number;
objectDescription: string;
offeredByDid: string;
recipientDid: string;
requirementsMet: boolean;
unit: string;
validThrough: string;
}
export interface OfferToPlanSummaryRecord extends OfferSummaryRecord {
planName: string
planName: string;
}
// a summary record; the VC is not currently part of this record
export interface PlanSummaryRecord {
agentDid?: string
description: string
endTime?: string
fulfillsPlanHandleId: string
handleId: string
image?: string
issuerDid: string
locLat?: number
locLon?: number
name?: string
startTime?: string
url?: string
jwtId?: string
agentDid?: string;
description: string;
endTime?: string;
fulfillsPlanHandleId: string;
handleId: string;
image?: string;
issuerDid: string;
locLat?: number;
locLon?: number;
name?: string;
startTime?: string;
url?: string;
jwtId?: string;
}
/**
@@ -71,23 +71,23 @@ export interface PlanData {
/**
* Description of the project
**/
description: string
description: string;
/**
* URL referencing information about the project
**/
handleId: string
image?: string
handleId: string;
image?: string;
/**
* The DID of the issuer
*/
issuerDid: string
issuerDid: string;
/**
* Name of the project
**/
name: string
name: string;
/**
* The identifier of the project record -- different from jwtId
* (Maybe we should use the jwtId to iterate through the records instead.)
**/
rowId?: string
rowId?: string;
}

View File

@@ -1,8 +1,8 @@
export interface UserInfo {
did: string
name: string
publicEncKey: string
registered: boolean
profileImageUrl?: string
nextPublicEncKeyHash?: string
did: string;
name: string;
publicEncKey: string;
registered: boolean;
profileImageUrl?: string;
nextPublicEncKeyHash?: string;
}

Some files were not shown because too many files have changed in this diff Show More