Compare commits

...

19 Commits

Author SHA1 Message Date
Matthew Raymer
d8328ef89f Merge branch 'master' into android-file-save 2025-10-22 07:33:48 +00:00
1ce7c0486a Merge pull request 'feat: implement member visibility dialog with checkbox selection and refresh' (#208) from meeting-members-set-visibility into master
Reviewed-on: #208
2025-10-21 04:52:14 -04:00
Jose Olarte III
4f3a1b390d feat: auto-show visibility dialog for meeting members
- Show dialog on initial load if members need visibility settings
- Show dialog during auto-refresh only when new members are added (not removed)
- Show dialog on manual refresh if any members need visibility settings
- Remove manual "Set Visibility" buttons from UI as dialog now appears automatically
- Add logic to track previous visibility members and detect changes
- Improve UX by proactively prompting users to set visibility for new meeting members

The dialog now appears automatically in these scenarios:
- Component initialization with members needing visibility
- Auto-refresh when new members join the meeting
- Manual refresh when members need visibility settings
2025-10-17 19:17:47 +08:00
Jose Olarte III
4de4fbecaf refactor: rename SetVisibilityDialog to SetBulkVisibilityDialog and remove unused code
- Rename SetVisibilityDialog.vue to SetBulkVisibilityDialog.vue for better clarity
- Update all component references in MembersList.vue (import, registration, template usage)
- Update component class name from SetVisibilityDialog to SetBulkVisibilityDialog
- Rename calling function name to showSetBulkVisibilityDialog to match class name change
- Remove unused properties and created() method from App.vue

This cleanup removes dead code and improves component naming consistency.
2025-10-17 17:59:41 +08:00
Jose Olarte III
e3598992e7 feat: pause auto-refresh when SetVisibilityDialog is open
- Pause auto-refresh when SetVisibilityDialog becomes visible
- Resume auto-refresh when dialog is closed
- Prevents background refresh interference during visibility settings
- Fix type compatibility for visibilityDialogMembers data structure

This ensures users can interact with the visibility dialog without
the members list refreshing in the background, providing a better
user experience for setting member visibility preferences.
2025-10-16 17:49:59 +08:00
Jose Olarte III
ea19195850 refactor: extract SetVisibilityDialog into standalone component
- Extract "Set Visibility to Meeting Members" dialog from App.vue into dedicated SetVisibilityDialog.vue component
- Move dialog logic directly to MembersList.vue for better component coupling
- Remove unnecessary intermediate state management from App.vue
- Clean up redundant style definitions (rely on existing Tailwind CSS classes)
- Remove unused logger imports and debug functions
- Add explanatory comment for Vue template constant pattern

This improves maintainability by isolating dialog functionality and follows established component patterns in the codebase.
2025-10-16 17:18:56 +08:00
Jose Olarte III
ca545fd4b8 feat: add auto-refresh with countdown to MembersList
- Auto-refresh members list every 10 seconds
- Display countdown timer in refresh buttons
- Manual refresh resets countdown to 10 seconds
2025-10-15 20:25:01 +08:00
Jose Olarte III
07b538cadc feat: implement member visibility dialog with checkbox selection and refresh
- Add "Set Visibility" dialog for meeting members who need visibility settings
- Filter members to show only those not in contacts or without seesMe set
- Implement checkbox selection with "Select All" functionality
- Add reactive checkbox behavior with proper state management
- Default all checkboxes to checked when dialog opens
- Implement "Set Visibility" action to add contacts and set seesMe property
- Add success notifications with count of affected members
- Disable "Set Visibility" button when no members are selected
- Use notification callbacks for data refresh
- Hide "Set Visibility" buttons when no members need visibility settings
- Add proper dialog state management and cleanup
- Ensure dialog closes before triggering data refresh to prevent stale states

The implementation provides a smooth user experience for managing member visibility settings with proper state synchronization between components.
2025-10-14 21:21:35 +08:00
Jose Olarte III
b84546686a WIP: button and icon additions
- Mirrored "Refresh" and "Visibility" buttons on top and bottom of member list
- Added back info icons for list actions
- When clicked, Person icon shows informative notification
2025-10-13 21:38:12 +08:00
Jose Olarte III
461ee84d2a WIP: meeting members adjustments 2025-10-12 23:30:03 +08:00
Jose Olarte III
acf7d611e8 WIP: mockup for set visibility dialog 2025-10-09 21:56:50 +08:00
b0d13b3cd4 Merge pull request 'feat: disable zoom and fix iOS viewport issues' (#206) from ios-disable-zoom into master
Reviewed-on: #206
2025-10-08 06:04:32 -04:00
Jose Olarte III
5256681089 Merge branch 'master' into ios-disable-zoom 2025-10-08 18:03:27 +08:00
Jose Olarte III
225b34d480 feat: improve text overflow handling across UI components
- Add overflow-hidden, text-ellipsis and truncate classes to long text elements in list items and views to prevent text overflow
- Ensure proper text wrapping and ellipsis display for long content
2025-10-07 19:00:12 +08:00
d9f9460be7 Merge pull request 'refactor: standardize view headings across all components' (#207) from view-headings-refresh into master
Reviewed-on: #207
2025-10-07 05:17:05 -04:00
Jose Olarte III
f4144c7469 feat: disable zoom and fix iOS viewport issues
- Add user-scalable=no and interactive-widget=overlays-content to viewport meta tag
- Implement iOS viewport height fixes to prevent keyboard-related layout shifts
- Use dynamic viewport height (100dvh) for better mobile support
- Add fixed positioning and overflow controls to prevent viewport changes
- Enable scrolling only within #app container for better UX
2025-09-30 21:06:13 +08:00
Matthew Raymer
a9b3f6dfab feat(export): implement unique filename generation with device identification
- Add generateUniqueFileName() method to all platform services
- Implement device ID hashing for privacy-friendly identification
- Add JSON validation before file operations
- Generate filenames with format: base_deviceHash_timestamp.json
- Support multiple exports in same second with counter system
- Ensure filenames fit within platform length limits (200 chars max)
- Update saveToDevice() and saveAs() methods across all platforms

Platforms updated:
- CapacitorPlatformService: Mobile file operations with unique names
- WebPlatformService: Browser download with device fingerprinting
- ElectronPlatformService: Desktop IPC with machine identification

Resolves file naming conflicts and improves debugging capabilities
while maintaining user privacy through hashed device identifiers.
2025-08-19 10:31:12 +00:00
Matthew Raymer
6b1937e37b feat(git-hooks): enhance pre-commit hook with whitelist support for console statements
Add whitelist functionality to debug checker to allow intentional console statements in specific files:
- Add WHITELIST_FILES configuration for platform services and utilities
- Update pre-commit hook to skip console pattern checks for whitelisted files
- Support regex patterns in whitelist for flexible file matching
- Maintain security while allowing legitimate debug code in platform services

This resolves the issue where the hook was blocking commits due to intentional console statements in whitelisted files like WebPlatformService and CapacitorPlatformService.
2025-08-19 07:41:45 +00:00
Matthew Raymer
b735aac1fc feat(platform): implement dual-flow file sharing with Save to Device option
Add new platform service methods for direct file saving alongside existing share functionality:
- Add saveToDevice() and saveAs() methods to PlatformService interface
- Implement cross-platform support in WebPlatformService, ElectronPlatformService, and CapacitorPlatformService
- Update DataExportSection UI to provide Share Contacts and Save to Device buttons
- Add AndroidFileSaver plugin architecture with fallback implementation
- Include comprehensive documentation for native Android plugin implementation

This addresses the Android simulator file sharing limitation by providing users with clear choices between app-to-app sharing and direct device storage, while maintaining backward compatibility across all platforms.

- CapacitorPlatformService: Add MediaStore/SAF support with graceful fallbacks
- UI Components: Replace single download button with dual-action interface
- Documentation: Add AndroidFileSaver plugin implementation guide
- Type Safety: Maintain interface consistency across all platform services
2025-08-19 07:39:57 +00:00
21 changed files with 1827 additions and 156 deletions

View File

@@ -0,0 +1,251 @@
# Android File Saver Plugin Implementation Guide
## Overview
This document outlines the implementation of the `AndroidFileSaver` Capacitor plugin that provides Storage Access Framework (SAF) and MediaStore functionality for direct file saving on Android devices.
## Plugin Purpose
The `AndroidFileSaver` plugin enables two key file operations:
1. **`saveToDownloads`**: Direct save to Downloads folder using MediaStore (API 29+)
2. **`saveAs`**: User-chosen location using Storage Access Framework (SAF)
## Implementation Requirements
### 1. Plugin Structure
```typescript
// Plugin interface
interface AndroidFileSaverPlugin {
saveToDownloads(options: {
fileName: string;
content: string;
mimeType: string
}): Promise<{
success: boolean;
path?: string;
error?: string
}>;
saveAs(options: {
fileName: string;
content: string;
mimeType: string
}): Promise<{
success: boolean;
path?: string;
error?: string
}>;
}
```
### 2. Android Implementation
#### MediaStore for Downloads (API 29+)
```java
@PluginMethod
public void saveToDownloads(PluginCall call) {
String fileName = call.getString("fileName");
String content = call.getString("content");
String mimeType = call.getString("mimeType");
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Use MediaStore for Downloads
ContentValues values = new ContentValues();
values.put(MediaStore.Downloads.DISPLAY_NAME, fileName);
values.put(MediaStore.Downloads.MIME_TYPE, mimeType);
values.put(MediaStore.Downloads.IS_PENDING, 1);
ContentResolver resolver = getContext().getContentResolver();
Uri uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values);
if (uri != null) {
try (ParcelFileDescriptor pfd = resolver.openFileDescriptor(uri, "w")) {
if (pfd != null) {
try (FileOutputStream fos = new FileOutputStream(pfd.getFileDescriptor())) {
fos.write(content.getBytes());
fos.flush();
// Mark as no longer pending
values.clear();
values.put(MediaStore.Downloads.IS_PENDING, 0);
resolver.update(uri, values, null, null);
call.resolve(new JSObject()
.put("success", true)
.put("path", uri.toString()));
return;
}
}
} catch (IOException e) {
resolver.delete(uri, null, null);
}
}
call.resolve(new JSObject()
.put("success", false)
.put("error", "Failed to save file"));
} else {
// Fallback for older Android versions
call.resolve(new JSObject()
.put("success", false)
.put("error", "Requires Android API 29+"));
}
}
```
#### Storage Access Framework (SAF) for Save As
```java
@PluginMethod
public void saveAs(PluginCall call) {
String fileName = call.getString("fileName");
String content = call.getString("content");
String mimeType = call.getString("mimeType");
// Store call for later use
bridge.saveCall(call);
// Create intent for SAF
Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType(mimeType);
intent.putExtra(Intent.EXTRA_TITLE, fileName);
// Start activity for result
bridge.startActivityForResult(call, intent, "saveAsResult");
}
@ActivityCallback
private void saveAsResult(PluginCall call, ActivityResult result) {
if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) {
Uri uri = result.getData().getData();
String content = call.getString("content");
try (ParcelFileDescriptor pfd = getContext().getContentResolver()
.openFileDescriptor(uri, "w")) {
if (pfd != null) {
try (FileOutputStream fos = new FileOutputStream(pfd.getFileDescriptor())) {
fos.write(content.getBytes());
fos.flush();
call.resolve(new JSObject()
.put("success", true)
.put("path", uri.toString()));
return;
}
}
} catch (IOException e) {
// Handle error
}
call.resolve(new JSObject()
.put("success", false)
.put("error", "Failed to save file"));
} else {
call.resolve(new JSObject()
.put("success", false)
.put("error", "User cancelled or failed"));
}
}
```
### 3. Plugin Registration
```java
@CapacitorPlugin(name = "AndroidFileSaver")
public class AndroidFileSaverPlugin extends Plugin {
// Implementation methods here
}
```
## Integration Steps
### 1. Create Plugin Project
```bash
# Create new Capacitor plugin
npx @capacitor/cli plugin:generate android-file-saver
cd android-file-saver
```
### 2. Add to Android Project
```bash
# In your main project
npm install ./android-file-saver
npx cap sync android
```
### 3. Update Android Manifest
Ensure the following permissions are present:
```xml
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
```
## Fallback Behavior
When the plugin is not available, the system falls back to:
1. **Web**: Browser download mechanism
2. **Electron**: Native file save via IPC
3. **Capacitor**: Share dialog (existing behavior)
## Testing
### 1. Plugin Availability
```typescript
// Check if plugin is available
if (AndroidFileSaver) {
// Plugin available - use native methods
} else {
// Plugin not available - use fallback
}
```
### 2. Error Handling
```typescript
try {
const result = await AndroidFileSaver.saveToDownloads({
fileName: "test.json",
content: '{"test": "data"}',
mimeType: "application/json"
});
if (result.success) {
logger.info("File saved:", result.path);
} else {
logger.error("Save failed:", result.error);
}
} catch (error) {
logger.error("Plugin error:", error);
}
```
## Security Considerations
1. **Content Validation**: Validate file content before saving
2. **MIME Type Verification**: Ensure MIME type matches file content
3. **Permission Handling**: Request storage permissions appropriately
4. **Error Logging**: Log errors without exposing sensitive data
## Future Enhancements
1. **Progress Callbacks**: Add progress reporting for large files
2. **Batch Operations**: Support saving multiple files
3. **Custom Locations**: Allow saving to app-specific directories
4. **File Compression**: Add optional file compression
## References
- [Android Storage Access Framework](https://developer.android.com/guide/topics/providers/document-provider)
- [MediaStore Downloads](https://developer.android.com/reference/android/provider/MediaStore.Downloads)
- [Capacitor Plugin Development](https://capacitorjs.com/docs/plugins/android)
- [Android Scoped Storage](https://developer.android.com/training/data-storage)

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no, interactive-widget=overlays-content" />
<!-- CORS headers removed to allow images from any domain -->
@@ -13,4 +13,4 @@
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
</html>

View File

@@ -7,6 +7,24 @@
html {
font-family: 'Work Sans', ui-sans-serif, system-ui, sans-serif !important;
}
/* Fix iOS viewport height changes when keyboard appears/disappears */
html, body {
height: 100%;
height: 100vh;
height: 100dvh; /* Dynamic viewport height for better mobile support */
overflow: hidden; /* Disable all scrolling on html and body */
position: fixed; /* Force fixed positioning to prevent viewport changes */
width: 100%;
top: 0;
left: 0;
}
#app {
height: 100vh;
height: 100dvh;
overflow-y: auto;
}
}
@layer components {

View File

@@ -78,8 +78,11 @@
</div>
<!-- Description -->
<p class="font-medium">
<a class="cursor-pointer" @click="emitLoadClaim(record.jwtId)">
<p class="font-medium overflow-hidden">
<a
class="block cursor-pointer overflow-hidden text-ellipsis"
@click="emitLoadClaim(record.jwtId)"
>
<vue-markdown
:source="truncatedDescription"
class="markdown-content"

View File

@@ -46,7 +46,7 @@
<span class="text-xs truncate">{{ contact.did }}</span>
</div>
<div class="text-sm">
<div class="text-sm truncate">
{{ contact.notes }}
</div>
</div>

View File

@@ -25,33 +25,46 @@ messages * - Conditional UI based on platform capabilities * * @component *
Backup Identifier Seed
</router-link>
<button
:disabled="isExporting"
:class="exportButtonClasses"
@click="exportDatabase()"
>
{{ isExporting ? "Exporting..." : "Download Contacts" }}
</button>
<div class="flex flex-col gap-2 mt-2">
<button
:disabled="isExporting"
:class="shareButtonClasses"
@click="shareContacts()"
>
{{ isExporting ? "Exporting..." : "Share Contacts" }}
</button>
<button
:disabled="isExporting"
:class="saveButtonClasses"
@click="saveContactsToDevice()"
>
{{ isExporting ? "Exporting..." : "Save to Device" }}
</button>
</div>
<div
v-if="capabilities.needsFileHandlingInstructions"
:class="instructionsContainerClasses"
>
<p>
After the export, you can save the file in your preferred storage
location.
</p>
<p>Choose how you want to export your contacts:</p>
<ul>
<li :class="listItemClasses">
<strong>Share Contacts:</strong> Opens the system share dialog to send
to apps like Gmail, Drive, or messaging apps.
</li>
<li :class="listItemClasses">
<strong>Save to Device:</strong> Saves directly to your device's
Downloads folder (Android) or Documents folder (iOS).
</li>
<li v-if="capabilities.isIOS" :class="listItemClasses">
On iOS: You will be prompted to choose a location to save your backup
file.
On iOS: Files are saved to the Files app in your Documents folder.
</li>
<li
v-if="capabilities.isMobile && !capabilities.isIOS"
:class="listItemClasses"
>
On Android: You will be prompted to choose a location to save your
backup file.
On Android: Files are saved directly to your Downloads folder.
</li>
</ul>
</div>
@@ -151,6 +164,20 @@ export default class DataExportSection extends Vue {
return "block w-full text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed";
}
/**
* CSS classes for the share button
*/
get shareButtonClasses(): string {
return "block w-full text-center text-md bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed";
}
/**
* CSS classes for the save to device button
*/
get saveButtonClasses(): string {
return "block w-full text-center text-md bg-gradient-to-b from-purple-400 to-purple-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed";
}
/**
* CSS classes for the instructions container
*/
@@ -228,6 +255,100 @@ export default class DataExportSection extends Vue {
}
}
/**
* Shares contacts data using the platform's share functionality
* Opens the system share dialog for app-to-app handoff
*
* @throws {Error} If sharing fails
*/
public async shareContacts(): Promise<void> {
if (this.isExporting) {
return; // Prevent multiple simultaneous exports
}
try {
this.isExporting = true;
// Fetch contacts from database using mixin's cached method
const allContacts = await this.$contacts();
// Convert contacts to export format
const processedContacts: Contact[] = allContacts.map((contact) => {
const exContact: Contact = R.omit(["contactMethods"], contact);
exContact.contactMethods = contact.contactMethods
? typeof contact.contactMethods === "string" &&
contact.contactMethods.trim() !== ""
? JSON.parse(contact.contactMethods)
: []
: [];
return exContact;
});
const exportData = contactsToExportJson(processedContacts);
const jsonStr = JSON.stringify(exportData, null, 2);
// Use platform service to share the file
await this.platformService.writeAndShareFile(this.fileName, jsonStr);
this.notify.success(
"Contact sharing completed successfully. Use the share dialog to send to your preferred app.",
);
} catch (error) {
logger.error("Share Error:", error);
this.notify.error(
`There was an error sharing the data: ${error instanceof Error ? error.message : "Unknown error"}`,
);
} finally {
this.isExporting = false;
}
}
/**
* Saves contacts data directly to the device's storage
* Uses platform-specific save methods (Downloads folder, Documents, etc.)
*
* @throws {Error} If saving fails
*/
public async saveContactsToDevice(): Promise<void> {
if (this.isExporting) {
return; // Prevent multiple simultaneous exports
}
try {
this.isExporting = true;
// Fetch contacts from database using mixin's cached method
const allContacts = await this.$contacts();
// Convert contacts to export format
const processedContacts: Contact[] = allContacts.map((contact) => {
const exContact: Contact = R.omit(["contactMethods"], contact);
exContact.contactMethods = contact.contactMethods
? typeof contact.contactMethods === "string" &&
contact.contactMethods.trim() !== ""
? JSON.parse(contact.contactMethods)
: []
: [];
return exContact;
});
const exportData = contactsToExportJson(processedContacts);
const jsonStr = JSON.stringify(exportData, null, 2);
// Use platform service to save directly to device
await this.platformService.saveToDevice(this.fileName, jsonStr);
this.notify.success("Contact data saved successfully to your device.");
} catch (error) {
logger.error("Save Error:", error);
this.notify.error(
`There was an error saving the data: ${error instanceof Error ? error.message : "Unknown error"}`,
);
} finally {
this.isExporting = false;
}
}
created() {
this.notify = createNotifyHelpers(this.$notify);
this.loadSeedBackupStatus();

View File

@@ -11,7 +11,7 @@
<!-- Members List -->
<div v-else>
<div class="text-center text-red-600 py-4">
<div class="text-center text-red-600 my-4">
{{ decryptionErrorMessage() }}
</div>
@@ -23,97 +23,94 @@
to set it.
</div>
<div>
<span
<ul class="list-disc text-sm ps-4 space-y-2 mb-4">
<li
v-if="membersToShow().length > 0 && showOrganizerTools && isOrganizer"
class="inline-flex items-center flex-wrap"
>
<span class="inline-flex items-center">
&bull; Click
<span
class="mx-2 min-w-[24px] min-h-[24px] w-6 h-6 flex items-center justify-center rounded-full bg-blue-100 text-blue-600"
>
<font-awesome icon="plus" class="text-sm" />
</span>
/
<span
class="mx-2 min-w-[24px] min-h-[24px] w-6 h-6 flex items-center justify-center rounded-full bg-blue-100 text-blue-600"
>
<font-awesome icon="minus" class="text-sm" />
</span>
to add/remove them to/from the meeting.
</span>
</span>
</div>
<div>
<span
v-if="membersToShow().length > 0"
class="inline-flex items-center"
>
&bull; Click
Click
<span
class="mx-2 w-8 h-8 flex items-center justify-center rounded-full bg-green-100 text-green-600"
class="inline-block w-5 h-5 rounded-full bg-blue-100 text-blue-600 text-center"
>
<font-awesome icon="circle-user" class="text-xl" />
<font-awesome icon="plus" class="text-sm" />
</span>
/
<span
class="inline-block w-5 h-5 rounded-full bg-blue-100 text-blue-600 text-center"
>
<font-awesome icon="minus" class="text-sm" />
</span>
to add/remove them to/from the meeting.
</li>
<li v-if="membersToShow().length > 0">
Click
<span
class="inline-block w-5 h-5 rounded-full bg-green-100 text-green-600 text-center"
>
<font-awesome icon="circle-user" class="text-sm" />
</span>
to add them to your contacts.
</span>
</div>
</li>
</ul>
<div class="flex justify-center">
<div class="flex justify-between">
<!--
always have at least one refresh button even without members in case the organizer
changes the password
-->
<button
class="btn-action-refresh"
title="Refresh members list"
@click="fetchMembers"
class="text-sm bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
title="Refresh members list now"
@click="manualRefresh"
>
<font-awesome icon="rotate" :class="{ 'fa-spin': isLoading }" />
Refresh
<span class="text-xs">({{ countdownTimer }}s)</span>
</button>
</div>
<div
v-for="member in membersToShow()"
:key="member.member.memberId"
class="mt-2 p-4 bg-gray-50 rounded-lg"
<ul
v-if="membersToShow().length > 0"
class="border-t border-slate-300 my-2"
>
<div class="flex items-center justify-between">
<div class="flex items-center">
<h3 class="text-lg font-medium">
{{ member.name || unnamedMember }}
</h3>
<div
v-if="!getContactFor(member.did) && member.did !== activeDid"
class="flex justify-end"
>
<button
class="btn-add-contact"
title="Add as contact"
@click="addAsContact(member)"
<li
v-for="member in membersToShow()"
:key="member.member.memberId"
class="border-b border-slate-300 py-1.5"
>
<div class="flex items-center gap-2 justify-between">
<div class="flex items-center gap-1 overflow-hidden">
<h3 class="font-semibold truncate">
{{ member.name || unnamedMember }}
</h3>
<div
v-if="!getContactFor(member.did) && member.did !== activeDid"
class="flex items-center gap-1"
>
<font-awesome icon="circle-user" class="text-xl" />
</button>
<button
class="btn-add-contact"
title="Add as contact"
@click="addAsContact(member)"
>
<font-awesome icon="circle-user" />
</button>
<button
class="btn-info-contact"
title="Contact Info"
@click="
informAboutAddingContact(
getContactFor(member.did) !== undefined,
)
"
>
<font-awesome icon="circle-info" class="text-sm" />
</button>
</div>
</div>
<button
v-if="member.did !== activeDid"
class="btn-info-contact"
title="Contact info"
@click="
informAboutAddingContact(
getContactFor(member.did) !== undefined,
)
"
>
<font-awesome icon="circle-info" class="text-base" />
</button>
</div>
<div class="flex">
<span
v-if="
showOrganizerTools && isOrganizer && member.did !== activeDid
"
class="flex items-center"
class="flex items-center gap-1"
>
<button
class="btn-admission"
@@ -124,30 +121,37 @@
>
<font-awesome
:icon="member.member.admitted ? 'minus' : 'plus'"
class="text-sm"
/>
</button>
<button
class="btn-info-admission"
title="Admission info"
title="Admission Info"
@click="informAboutAdmission()"
>
<font-awesome icon="circle-info" class="text-base" />
<font-awesome icon="circle-info" class="text-sm" />
</button>
</span>
</div>
</div>
<p class="text-sm text-gray-600 truncate">
{{ member.did }}
</p>
</div>
<div v-if="membersToShow().length > 0" class="flex justify-center mt-4">
<p class="text-xs text-gray-600 truncate">
{{ member.did }}
</p>
</li>
</ul>
<div v-if="membersToShow().length > 0" class="flex justify-between">
<!--
always have at least one refresh button even without members in case the organizer
changes the password
-->
<button
class="btn-action-refresh"
title="Refresh members list"
@click="fetchMembers"
class="text-sm bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
title="Refresh members list now"
@click="manualRefresh"
>
<font-awesome icon="rotate" :class="{ 'fa-spin': isLoading }" />
Refresh
<span class="text-xs">({{ countdownTimer }}s)</span>
</button>
</div>
@@ -156,6 +160,15 @@
</p>
</div>
</div>
<!-- Set Visibility Dialog Component -->
<SetBulkVisibilityDialog
:visible="showSetVisibilityDialog"
:members-data="visibilityDialogMembers"
:active-did="activeDid"
:api-server="apiServer"
@close="closeSetVisibilityDialog"
/>
</template>
<script lang="ts">
@@ -178,6 +191,7 @@ import {
NOTIFY_CONTINUE_WITHOUT_ADDING,
} from "@/constants/notifications";
import { SOMEONE_UNNAMED } from "@/constants/entities";
import SetBulkVisibilityDialog from "./SetBulkVisibilityDialog.vue";
interface Member {
admitted: boolean;
@@ -193,6 +207,9 @@ interface DecryptedMember {
}
@Component({
components: {
SetBulkVisibilityDialog,
},
mixins: [PlatformServiceMixin],
})
export default class MembersList extends Vue {
@@ -219,8 +236,25 @@ export default class MembersList extends Vue {
missingMyself = false;
activeDid = "";
apiServer = "";
// Set Visibility Dialog state
showSetVisibilityDialog = false;
visibilityDialogMembers: Array<{
did: string;
name: string;
isContact: boolean;
member: { memberId: string };
}> = [];
contacts: Array<Contact> = [];
// Auto-refresh functionality
countdownTimer = 10;
autoRefreshInterval: NodeJS.Timeout | null = null;
lastRefreshTime = 0;
// Track previous visibility members to detect changes
previousVisibilityMembers: string[] = [];
/**
* Get the unnamed member constant
*/
@@ -242,6 +276,21 @@ export default class MembersList extends Vue {
this.firstName = settings.firstName || "";
await this.fetchMembers();
await this.loadContacts();
// Start auto-refresh
this.startAutoRefresh();
// Check if we should show the visibility dialog on initial load
this.checkAndShowVisibilityDialog();
}
async refreshData() {
// Force refresh both contacts and members
await this.loadContacts();
await this.fetchMembers();
// Check if we should show the visibility dialog after refresh
this.checkAndShowVisibilityDialog();
}
async fetchMembers() {
@@ -344,7 +393,7 @@ export default class MembersList extends Vue {
informAboutAdmission() {
this.notify.info(
"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.",
"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 (-) symbol means you can remove them, but they will stay registered.",
TIMEOUTS.VERY_LONG,
);
}
@@ -371,6 +420,80 @@ export default class MembersList extends Vue {
return this.contacts.find((contact) => contact.did === did);
}
getMembersForVisibility() {
return this.decryptedMembers
.filter((member) => {
// Exclude the current user
if (member.did === this.activeDid) {
return false;
}
const contact = this.getContactFor(member.did);
// Include members who:
// 1. Haven't been added as contacts yet, OR
// 2. Are contacts but don't have visibility set (seesMe property)
return !contact || !contact.seesMe;
})
.map((member) => ({
did: member.did,
name: member.name,
isContact: !!this.getContactFor(member.did),
member: {
memberId: member.member.memberId.toString(),
},
}));
}
/**
* Check if we should show the visibility dialog
* Returns true if there are members for visibility and either:
* - This is the first time (no previous members tracked), OR
* - New members have been added since last check (not removed)
*/
shouldShowVisibilityDialog(): boolean {
const currentMembers = this.getMembersForVisibility();
if (currentMembers.length === 0) {
return false;
}
// If no previous members tracked, show dialog
if (this.previousVisibilityMembers.length === 0) {
return true;
}
// Check if new members have been added (not just any change)
const currentMemberIds = currentMembers.map((m) => m.did);
const previousMemberIds = this.previousVisibilityMembers;
// Find new members (members in current but not in previous)
const newMembers = currentMemberIds.filter(
(id) => !previousMemberIds.includes(id),
);
// Only show dialog if there are new members added
return newMembers.length > 0;
}
/**
* Update the tracking of previous visibility members
*/
updatePreviousVisibilityMembers() {
const currentMembers = this.getMembersForVisibility();
this.previousVisibilityMembers = currentMembers.map((m) => m.did);
}
/**
* Show the visibility dialog if conditions are met
*/
checkAndShowVisibilityDialog() {
if (this.shouldShowVisibilityDialog()) {
this.showSetBulkVisibilityDialog();
}
this.updatePreviousVisibilityMembers();
}
checkWhetherContactBeforeAdmitting(decrMember: DecryptedMember) {
const contact = this.getContactFor(decrMember.did);
if (!decrMember.member.admitted && !contact) {
@@ -508,6 +631,79 @@ export default class MembersList extends Vue {
this.notify.error(message, TIMEOUTS.LONG);
}
}
showSetBulkVisibilityDialog() {
// Filter members to show only those who need visibility set
const membersForVisibility = this.getMembersForVisibility();
// Pause auto-refresh when dialog opens
this.stopAutoRefresh();
// Open the dialog directly
this.visibilityDialogMembers = membersForVisibility;
this.showSetVisibilityDialog = true;
}
startAutoRefresh() {
this.lastRefreshTime = Date.now();
this.countdownTimer = 10;
this.autoRefreshInterval = setInterval(() => {
const now = Date.now();
const timeSinceLastRefresh = (now - this.lastRefreshTime) / 1000;
if (timeSinceLastRefresh >= 10) {
// Time to refresh
this.refreshData();
this.lastRefreshTime = now;
this.countdownTimer = 10;
} else {
// Update countdown
this.countdownTimer = Math.max(
0,
Math.round(10 - timeSinceLastRefresh),
);
}
}, 1000); // Update every second
}
stopAutoRefresh() {
if (this.autoRefreshInterval) {
clearInterval(this.autoRefreshInterval);
this.autoRefreshInterval = null;
}
}
manualRefresh() {
// Clear existing auto-refresh interval
if (this.autoRefreshInterval) {
clearInterval(this.autoRefreshInterval);
this.autoRefreshInterval = null;
}
// Trigger immediate refresh and restart timer
this.refreshData();
this.startAutoRefresh();
// Always show dialog on manual refresh if there are members for visibility
if (this.getMembersForVisibility().length > 0) {
this.showSetBulkVisibilityDialog();
}
}
// Set Visibility Dialog methods
closeSetVisibilityDialog() {
this.showSetVisibilityDialog = false;
this.visibilityDialogMembers = [];
// Refresh data when dialog is closed
this.refreshData();
// Resume auto-refresh when dialog is closed
this.startAutoRefresh();
}
beforeDestroy() {
this.stopAutoRefresh();
}
}
</script>
@@ -522,29 +718,23 @@ export default class MembersList extends Vue {
.btn-add-contact {
/* stylelint-disable-next-line at-rule-no-unknown */
@apply ml-2 w-8 h-8 flex items-center justify-center rounded-full
@apply w-6 h-6 flex items-center justify-center rounded-full
bg-green-100 text-green-600 hover:bg-green-200 hover:text-green-800
transition-colors;
}
.btn-info-contact {
.btn-info-contact,
.btn-info-admission {
/* stylelint-disable-next-line at-rule-no-unknown */
@apply ml-2 mb-2 w-6 h-6 flex items-center justify-center rounded-full
bg-slate-100 text-slate-500 hover:bg-slate-200 hover:text-slate-800
@apply w-6 h-6 flex items-center justify-center rounded-full
bg-slate-100 text-slate-400 hover:text-slate-600
transition-colors;
}
.btn-admission {
/* stylelint-disable-next-line at-rule-no-unknown */
@apply mr-2 w-6 h-6 flex items-center justify-center rounded-full
@apply w-6 h-6 flex items-center justify-center rounded-full
bg-blue-100 text-blue-600 hover:bg-blue-200 hover:text-blue-800
transition-colors;
}
.btn-info-admission {
/* stylelint-disable-next-line at-rule-no-unknown */
@apply mr-2 mb-2 w-6 h-6 flex items-center justify-center rounded-full
bg-slate-100 text-slate-500 hover:bg-slate-200 hover:text-slate-800
transition-colors;
}
</style>

View File

@@ -0,0 +1,333 @@
<template>
<div v-if="visible" class="dialog-overlay">
<div class="dialog">
<div class="text-slate-900 text-center">
<h3 class="text-lg font-semibold leading-[1.25] mb-2">
Set Visibility to Meeting Members
</h3>
<p class="text-sm mb-4">
Would you like to <b>make your activities visible</b> to the following
members? (This will also add them as contacts if they aren't already.)
</p>
<!-- Custom table area - you can customize this -->
<div v-if="shouldInitializeSelection" class="mb-4">
<table
class="w-full border-collapse border border-slate-300 text-sm text-start"
>
<thead v-if="membersData && membersData.length > 0">
<tr class="bg-slate-100 font-medium">
<th class="border border-slate-300 px-3 py-2">
<label class="flex items-center gap-2">
<input
type="checkbox"
:checked="isAllSelected"
:indeterminate="isIndeterminate"
@change="toggleSelectAll"
/>
Select All
</label>
</th>
</tr>
</thead>
<tbody>
<!-- Dynamic data from MembersList -->
<tr v-if="!membersData || membersData.length === 0">
<td
class="border border-slate-300 px-3 py-2 text-center italic text-gray-500"
>
No members need visibility settings
</td>
</tr>
<tr
v-for="member in membersData || []"
:key="member.member.memberId"
>
<td class="border border-slate-300 px-3 py-2">
<div class="flex items-center justify-between gap-2">
<label class="flex items-center gap-2">
<input
type="checkbox"
:checked="isMemberSelected(member.did)"
@change="toggleMemberSelection(member.did)"
/>
{{ member.name || SOMEONE_UNNAMED }}
</label>
<!-- Friend indicator - only show if they are already a contact -->
<font-awesome
v-if="member.isContact"
icon="user-circle"
class="fa-fw ms-auto text-slate-400 cursor-pointer hover:text-slate-600"
@click="showContactInfo"
/>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="space-y-2">
<button
v-if="membersData && membersData.length > 0"
:disabled="!hasSelectedMembers"
:class="[
'block w-full text-center text-md font-bold uppercase px-2 py-2 rounded-md',
hasSelectedMembers
? 'bg-blue-600 text-white cursor-pointer'
: 'bg-slate-400 text-slate-200 cursor-not-allowed',
]"
@click="setVisibilityForSelectedMembers"
>
Set Visibility
</button>
<button
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md"
@click="cancel"
>
{{
membersData && membersData.length > 0 ? "Maybe Later" : "Cancel"
}}
</button>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Vue, Component, Prop } from "vue-facing-decorator";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { SOMEONE_UNNAMED } from "@/constants/entities";
import { setVisibilityUtil } from "@/libs/endorserServer";
import { createNotifyHelpers } from "@/utils/notify";
interface MemberData {
did: string;
name: string;
isContact: boolean;
member: {
memberId: string;
};
}
@Component({
mixins: [PlatformServiceMixin],
})
export default class SetBulkVisibilityDialog extends Vue {
@Prop({ default: false }) visible!: boolean;
@Prop({ default: () => [] }) membersData!: MemberData[];
@Prop({ default: "" }) activeDid!: string;
@Prop({ default: "" }) apiServer!: string;
// Vue notification system
$notify!: (
notification: { group: string; type: string; title: string; text: string },
timeout?: number,
) => void;
// Notification system
notify!: ReturnType<typeof createNotifyHelpers>;
// Component state
selectedMembers: string[] = [];
selectionInitialized = false;
// Constants
// In Vue templates, imported constants need to be explicitly made available to the template
readonly SOMEONE_UNNAMED = SOMEONE_UNNAMED;
get hasSelectedMembers() {
return this.selectedMembers.length > 0;
}
get isAllSelected() {
if (!this.membersData || this.membersData.length === 0) return false;
return this.membersData.every((member) =>
this.selectedMembers.includes(member.did),
);
}
get isIndeterminate() {
if (!this.membersData || this.membersData.length === 0) return false;
const selectedCount = this.membersData.filter((member) =>
this.selectedMembers.includes(member.did),
).length;
return selectedCount > 0 && selectedCount < this.membersData.length;
}
get shouldInitializeSelection() {
// This method will initialize selection when the dialog opens
if (!this.selectionInitialized) {
this.initializeSelection();
this.selectionInitialized = true;
}
return true;
}
created() {
this.notify = createNotifyHelpers(this.$notify);
}
initializeSelection() {
// Reset selection when dialog opens
this.selectedMembers = [];
// Select all by default
this.selectedMembers = this.membersData.map((member) => member.did);
}
resetSelection() {
this.selectedMembers = [];
this.selectionInitialized = false;
}
toggleSelectAll() {
if (!this.membersData || this.membersData.length === 0) return;
if (this.isAllSelected) {
// Deselect all
this.selectedMembers = [];
} else {
// Select all
this.selectedMembers = this.membersData.map((member) => member.did);
}
}
toggleMemberSelection(memberDid: string) {
const index = this.selectedMembers.indexOf(memberDid);
if (index > -1) {
this.selectedMembers.splice(index, 1);
} else {
this.selectedMembers.push(memberDid);
}
}
isMemberSelected(memberDid: string) {
return this.selectedMembers.includes(memberDid);
}
async setVisibilityForSelectedMembers() {
try {
const selectedMembers = this.membersData.filter((member) =>
this.selectedMembers.includes(member.did),
);
let successCount = 0;
for (const member of selectedMembers) {
try {
// If they're not a contact yet, add them as a contact first
if (!member.isContact) {
await this.addAsContact(member);
}
// Set their seesMe to true
await this.updateContactVisibility(member.did, true);
successCount++;
} catch (error) {
// eslint-disable-next-line no-console
console.error(`Error processing member ${member.did}:`, error);
// Continue with other members even if one fails
}
}
// Show success notification
this.$notify(
{
group: "alert",
type: "success",
title: "Visibility Set Successfully",
text: `Visibility set for ${successCount} member${successCount === 1 ? "" : "s"}.`,
},
5000,
);
// Emit success event
this.$emit("success", successCount);
this.close();
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error setting visibility:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to set visibility for some members. Please try again.",
},
5000,
);
}
}
async addAsContact(member: { did: string; name: string }) {
try {
const newContact = {
did: member.did,
name: member.name,
};
await this.$insertContact(newContact);
} catch (err) {
// eslint-disable-next-line no-console
console.error("Error adding contact:", err);
if (err instanceof Error && err.message?.indexOf("already exists") > -1) {
// Contact already exists, continue
} else {
throw err; // Re-throw if it's not a duplicate error
}
}
}
async updateContactVisibility(did: string, seesMe: boolean) {
try {
// Get the contact object
const contact = await this.$getContact(did);
if (!contact) {
throw new Error(`Contact not found for DID: ${did}`);
}
// Use the proper API to set visibility on the server
const result = await setVisibilityUtil(
this.activeDid,
this.apiServer,
this.axios,
contact,
seesMe,
);
if (!result.success) {
throw new Error(result.error || "Failed to set visibility");
}
} catch (err) {
// eslint-disable-next-line no-console
console.error("Error updating contact visibility:", err);
throw err;
}
}
showContactInfo() {
this.$notify(
{
group: "alert",
type: "info",
title: "Contact Info",
text: "This user is already your contact, but your activities are not visible to them yet.",
},
5000,
);
}
close() {
this.resetSelection();
this.$emit("close");
}
cancel() {
this.close();
}
}
</script>

View File

@@ -59,7 +59,7 @@ export const PASSKEYS_ENABLED =
export interface NotificationIface {
group: string; // "alert" | "modal"
type: string; // "toast" | "info" | "success" | "warning" | "danger"
title: string;
title?: string;
text?: string;
callback?: (success: boolean) => Promise<void>; // if this triggered an action
noText?: string;
@@ -68,4 +68,11 @@ export interface NotificationIface {
onYes?: () => Promise<void>;
promptToStopAsking?: boolean;
yesText?: string;
membersData?: Array<{
member: { admitted: boolean; content: string; memberId: number };
name: string;
did: string;
isContact: boolean;
contact?: { did: string; name?: string; seesMe?: boolean };
}>; // For passing member data to visibility dialog
}

View File

@@ -88,6 +88,24 @@ export interface PlatformService {
*/
writeAndShareFile(fileName: string, content: string): Promise<void>;
/**
* Saves content directly to the device's Downloads folder (Android) or Documents folder (iOS).
* Uses MediaStore on Android API 29+ and falls back to SAF on older versions.
* @param fileName - The filename of the file to save
* @param content - The content to write to the file
* @returns Promise that resolves when the file is saved
*/
saveToDevice(fileName: string, content: string): Promise<void>;
/**
* Opens the system file picker to let the user choose where to save a file.
* Uses Storage Access Framework (SAF) on Android and appropriate APIs on other platforms.
* @param fileName - The suggested filename for the file
* @param content - The content to write to the file
* @returns Promise that resolves when the file is saved
*/
saveAs(fileName: string, content: string): Promise<void>;
/**
* Deletes a file at the specified path.
* @param path - The path to the file to delete

View File

@@ -14,6 +14,65 @@ import {
DBSQLiteValues,
} from "@capacitor-community/sqlite";
// Android-specific imports for SAF and MediaStore
import { registerPlugin } from "@capacitor/core";
// Define interfaces for Android-specific functionality
interface AndroidFileSaverPlugin {
saveToDownloads(options: {
fileName: string;
content: string;
mimeType: string;
}): Promise<{ success: boolean; path?: string; error?: string }>;
saveAs(options: {
fileName: string;
content: string;
mimeType: string;
}): Promise<{ success: boolean; path?: string; error?: string }>;
}
// Register the plugin (will be undefined if not available)
const AndroidFileSaver =
registerPlugin<AndroidFileSaverPlugin>("AndroidFileSaver");
// Fallback implementation for when the plugin is not available
class AndroidFileSaverFallback implements AndroidFileSaverPlugin {
async saveToDownloads(options: {
fileName: string;
content: string;
mimeType: string;
}): Promise<{ success: boolean; path?: string; error?: string }> {
logger.warn(
"[CapacitorPlatformService] AndroidFileSaver plugin not available, using fallback",
{
fileName: options.fileName,
mimeType: options.mimeType,
contentLength: options.content.length,
},
);
return { success: false, error: "AndroidFileSaver plugin not available" };
}
async saveAs(options: {
fileName: string;
content: string;
mimeType: string;
}): Promise<{ success: boolean; path?: string; error?: string }> {
logger.warn(
"[CapacitorPlatformService] AndroidFileSaver plugin not available, using fallback",
{
fileName: options.fileName,
mimeType: options.mimeType,
contentLength: options.content.length,
},
);
return { success: false, error: "AndroidFileSaver plugin not available" };
}
}
// Use fallback if plugin is not available
const AndroidFileSaverImpl = AndroidFileSaver || new AndroidFileSaverFallback();
import { runMigrations } from "@/db-sql/migration";
import { QueryExecResult } from "@/interfaces/database";
import {
@@ -468,7 +527,7 @@ export class CapacitorPlatformService implements PlatformService {
* ## Logging:
*
* Detailed logging is provided throughout the process using emoji-tagged
* console messages that appear in the Electron DevTools console. This
* log messages that appear in the Electron DevTools. This
* includes:
* - SQL statement execution details
* - Parameter values for debugging
@@ -1119,6 +1178,280 @@ export class CapacitorPlatformService implements PlatformService {
}
}
/**
* Saves content directly to the device's Downloads folder (Android) or Documents folder (iOS).
* Uses MediaStore on Android API 29+ and falls back to SAF on older versions.
*
* @param fileName - The filename of the file to save
* @param content - The content to write to the file
* @returns Promise that resolves when the file is saved
*/
async saveToDevice(fileName: string, content: string): Promise<void> {
const timestamp = new Date().toISOString();
const logData = {
action: "saveToDevice",
fileName,
contentLength: content.length,
platform: this.getCapabilities().isIOS ? "iOS" : "Android",
timestamp,
};
logger.log("[CapacitorPlatformService]", JSON.stringify(logData, null, 2));
try {
// Validate JSON content
try {
JSON.parse(content);
} catch {
throw new Error('Content must be valid JSON');
}
// Generate unique filename
const uniqueFileName = this.generateUniqueFileName(fileName);
if (this.getCapabilities().isIOS) {
// iOS: Use Filesystem to save to Documents directory
const { uri } = await Filesystem.writeFile({
path: uniqueFileName,
data: content,
directory: Directory.Documents,
encoding: Encoding.UTF8,
recursive: true,
});
logger.log("[CapacitorPlatformService] File saved to iOS Documents:", {
uri,
fileName: uniqueFileName,
timestamp: new Date().toISOString(),
});
} else {
// Android: Try to use native MediaStore/SAF implementation
const result = await AndroidFileSaverImpl.saveToDownloads({
fileName: uniqueFileName,
content,
mimeType: this.getMimeType(uniqueFileName),
});
if (result.success) {
logger.log(
"[CapacitorPlatformService] File saved to Android Downloads:",
{
path: result.path,
fileName: uniqueFileName,
timestamp: new Date().toISOString(),
},
);
} else {
throw new Error(`Failed to save to Downloads: ${result.error}`);
}
}
} catch (error) {
const err = error as Error;
const errLog = {
message: err.message,
stack: err.stack,
timestamp: new Date().toISOString(),
};
logger.error(
"[CapacitorPlatformService] Error saving file to device:",
JSON.stringify(errLog, null, 2),
);
throw new Error(`Failed to save file to device: ${err.message}`);
}
}
/**
* Opens the system file picker to let the user choose where to save a file.
* Uses Storage Access Framework (SAF) on Android and appropriate APIs on other platforms.
*
* @param fileName - The suggested filename for the file
* @param content - The content to write to the file
* @returns Promise that resolves when the file is saved
*/
async saveAs(fileName: string, content: string): Promise<void> {
const timestamp = new Date().toISOString();
const logData = {
action: "saveAs",
fileName,
contentLength: content.length,
platform: this.getCapabilities().isIOS ? "iOS" : "Android",
timestamp,
};
logger.log("[CapacitorPlatformService]", JSON.stringify(logData, null, 2));
try {
// Validate JSON content
try {
JSON.parse(content);
} catch {
throw new Error('Content must be valid JSON');
}
// Generate unique filename
const uniqueFileName = this.generateUniqueFileName(fileName);
if (this.getCapabilities().isIOS) {
// iOS: Use Filesystem to save to Documents directory with user choice
const { uri } = await Filesystem.writeFile({
path: uniqueFileName,
data: content,
directory: Directory.Documents,
encoding: Encoding.UTF8,
recursive: true,
});
logger.log("[CapacitorPlatformService] File saved to iOS Documents:", {
uri,
fileName: uniqueFileName,
timestamp: new Date().toISOString(),
});
} else {
// Android: Use SAF for user-chosen location
const result = await AndroidFileSaverImpl.saveAs({
fileName: uniqueFileName,
content,
mimeType: this.getMimeType(uniqueFileName),
});
if (result.success) {
logger.log(
"[CapacitorPlatformService] File saved via Android SAF:",
{
path: result.path,
fileName: uniqueFileName,
timestamp: new Date().toISOString(),
},
);
} else {
throw new Error(`Failed to save via SAF: ${result.error}`);
}
}
} catch (error) {
const err = error as Error;
const errLog = {
message: err.message,
stack: err.stack,
timestamp: new Date().toISOString(),
};
logger.error(
"[CapacitorPlatformService] Error in saveAs:",
JSON.stringify(errLog, null, 2),
);
throw new Error(`Failed to save file: ${err.message}`);
}
}
/**
* Generates unique filename with timestamp, hashed device ID, and counter
*/
private generateUniqueFileName(baseName: string, counter = 0): string {
const now = new Date();
const timestamp = now.toISOString()
.replace(/[:.]/g, '-')
.replace('T', '_')
.replace('Z', '');
const deviceIdHash = this.getHashedDeviceIdentifier();
const counterSuffix = counter > 0 ? `_${counter}` : '';
const maxBaseLength = 45;
const truncatedBase = baseName.length > maxBaseLength
? baseName.substring(0, maxBaseLength)
: baseName;
const nameWithoutExt = truncatedBase.replace(/\.json$/i, '');
const extension = '.json';
const devicePart = `_${deviceIdHash}`;
const timestampPart = `_${timestamp}${counterSuffix}`;
const totalLength = nameWithoutExt.length + devicePart.length + timestampPart.length + extension.length;
if (totalLength > 200) {
const availableLength = 200 - devicePart.length - timestampPart.length - extension.length;
const finalBase = nameWithoutExt.substring(0, Math.max(10, availableLength));
return `${finalBase}${devicePart}${timestampPart}${extension}`;
}
return `${nameWithoutExt}${devicePart}${timestampPart}${extension}`;
}
/**
* Gets hashed device identifier
*/
private getHashedDeviceIdentifier(): string {
try {
const deviceInfo = this.getDeviceInfo();
return this.hashString(deviceInfo);
} catch (error) {
return 'mobile';
}
}
/**
* Gets device info string
*/
private getDeviceInfo(): string {
try {
// For mobile platforms, use device info
const capabilities = this.getCapabilities();
if (capabilities.isIOS) {
return 'ios_mobile';
} else if (capabilities.isAndroid) {
return 'android_mobile';
} else {
return 'mobile';
}
} catch (error) {
return 'mobile';
}
}
/**
* Simple hash function for device ID
*/
private hashString(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return Math.abs(hash).toString(16).padStart(4, '0').substring(0, 4);
}
/**
* Determines the MIME type for a given filename based on its extension.
*
* @param fileName - The filename to determine MIME type for
* @returns The MIME type string
*/
private getMimeType(fileName: string): string {
const extension = fileName.split(".").pop()?.toLowerCase();
switch (extension) {
case "json":
return "application/json";
case "txt":
return "text/plain";
case "csv":
return "text/csv";
case "pdf":
return "application/pdf";
case "xml":
return "application/xml";
case "html":
return "text/html";
case "jpg":
case "jpeg":
return "image/jpeg";
case "png":
return "image/png";
case "gif":
return "image/gif";
default:
return "application/octet-stream";
}
}
/**
* Deletes a file from the app's data directory.
* @param path - Relative path to the file to delete

View File

@@ -146,6 +146,236 @@ export class ElectronPlatformService extends CapacitorPlatformService {
return true;
}
/**
* Saves content directly to the device's Downloads folder (Electron platform).
* Uses Electron's IPC to save files directly to the Downloads directory.
*
* @param fileName - The filename of the file to save
* @param content - The content to write to the file
* @returns Promise that resolves when the file is saved
*/
async saveToDevice(fileName: string, content: string): Promise<void> {
try {
// Ensure content is valid JSON
try {
JSON.parse(content);
} catch {
throw new Error('Content must be valid JSON');
}
// Generate unique filename
const uniqueFileName = this.generateUniqueFileNameElectron(fileName);
logger.info(
`[ElectronPlatformService] Using native IPC for direct file save`,
uniqueFileName,
);
// Check if we're running in Electron with the API available
if (typeof window !== "undefined" && window.electronAPI) {
// Use the native Electron IPC API for file exports
const result = await window.electronAPI.exportData(uniqueFileName, content);
if (result.success) {
logger.info(
`[ElectronPlatformService] File saved successfully to`,
result.path,
);
logger.info(
`[ElectronPlatformService] File saved to Downloads folder`,
uniqueFileName,
);
} else {
logger.error(
`[ElectronPlatformService] Native save failed`,
result.error,
);
throw new Error(`Native file save failed: ${result.error}`);
}
} else {
// Fallback to web-style download if Electron API is not available
logger.warn(
"[ElectronPlatformService] Electron API not available, falling back to web download",
);
const blob = new Blob([content], { type: "application/json" });
const url = URL.createObjectURL(blob);
const downloadLink = document.createElement("a");
downloadLink.href = url;
downloadLink.download = uniqueFileName;
downloadLink.style.display = "none";
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
setTimeout(() => URL.revokeObjectURL(url), 1000);
logger.info(
`[ElectronPlatformService] Fallback download initiated`,
uniqueFileName,
);
}
} catch (error) {
logger.error("[ElectronPlatformService] File save failed", error);
throw new Error(`Failed to save file: ${error}`);
}
}
/**
* Opens the system file picker to let the user choose where to save a file (Electron platform).
* Uses Electron's IPC to show the native save dialog.
*
* @param fileName - The suggested filename for the file
* @param content - The content to write to the file
* @returns Promise that resolves when the file is saved
*/
async saveAs(fileName: string, content: string): Promise<void> {
try {
// Ensure content is valid JSON
try {
JSON.parse(content);
} catch {
throw new Error('Content must be valid JSON');
}
// Generate unique filename
const uniqueFileName = this.generateUniqueFileNameElectron(fileName);
logger.info(
`[ElectronPlatformService] Using native IPC for save as dialog`,
uniqueFileName,
);
// Check if we're running in Electron with the API available
if (typeof window !== "undefined" && window.electronAPI) {
// Use the native Electron IPC API for file exports (same as saveToDevice for now)
// TODO: Implement native save dialog when available
const result = await window.electronAPI.exportData(uniqueFileName, content);
if (result.success) {
logger.info(
`[ElectronPlatformService] File saved successfully to`,
result.path,
);
logger.info(
`[ElectronPlatformService] File saved via save as`,
uniqueFileName,
);
} else {
logger.error(
`[ElectronPlatformService] Native save as failed`,
result.error,
);
throw new Error(`Native file save as failed: ${result.error}`);
}
} else {
// Fallback to web-style download if Electron API is not available
logger.warn(
"[ElectronPlatformService] Electron API not available, falling back to web download",
);
const blob = new Blob([content], { type: "application/json" });
const url = URL.createObjectURL(blob);
const downloadLink = document.createElement("a");
downloadLink.href = url;
downloadLink.download = uniqueFileName;
downloadLink.style.display = "none";
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
setTimeout(() => URL.revokeObjectURL(url), 1000);
logger.info(
`[ElectronPlatformService] Fallback download initiated`,
uniqueFileName,
);
}
} catch (error) {
logger.error("[ElectronPlatformService] File save as failed", error);
throw new Error(`Failed to save file as: ${error}`);
}
}
/**
* Generates unique filename with timestamp, hashed device ID, and counter
*/
private generateUniqueFileNameElectron(baseName: string, counter = 0): string {
const now = new Date();
const timestamp = now.toISOString()
.replace(/[:.]/g, '-')
.replace('T', '_')
.replace('Z', '');
const deviceIdHash = this.getHashedDeviceIdentifierElectron();
const counterSuffix = counter > 0 ? `_${counter}` : '';
const maxBaseLength = 45;
const truncatedBase = baseName.length > maxBaseLength
? baseName.substring(0, maxBaseLength)
: baseName;
const nameWithoutExt = truncatedBase.replace(/\.json$/i, '');
const extension = '.json';
const devicePart = `_${deviceIdHash}`;
const timestampPart = `_${timestamp}${counterSuffix}`;
const totalLength = nameWithoutExt.length + devicePart.length + timestampPart.length + extension.length;
if (totalLength > 200) {
const availableLength = 200 - devicePart.length - timestampPart.length - extension.length;
const finalBase = nameWithoutExt.substring(0, Math.max(10, availableLength));
return `${finalBase}${devicePart}${timestampPart}${extension}`;
}
return `${nameWithoutExt}${devicePart}${timestampPart}${extension}`;
}
/**
* Gets hashed device identifier
*/
private getHashedDeviceIdentifierElectron(): string {
try {
const deviceInfo = this.getDeviceInfoElectron();
return this.hashStringElectron(deviceInfo);
} catch (error) {
return 'electron';
}
}
/**
* Gets device info string
*/
private getDeviceInfoElectron(): string {
try {
// Use machine-specific information
const os = require('os');
const hostname = os.hostname() || 'unknown';
const platform = os.platform() || 'unknown';
const arch = os.arch() || 'unknown';
// Create device info string
return `${platform}_${hostname}_${arch}`;
} catch (error) {
return 'electron';
}
}
/**
* Simple hash function for device ID
*/
private hashStringElectron(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return Math.abs(hash).toString(16).padStart(4, '0').substring(0, 4);
}
/**
* Checks if running on Capacitor platform.
*

View File

@@ -97,9 +97,7 @@ export class WebPlatformService implements PlatformService {
}
} else {
// We're in a worker context - skip initBackend call
// Use console for critical startup message to avoid circular dependency
// eslint-disable-next-line no-console
console.log(
logger.info(
"[WebPlatformService] Skipping initBackend call in worker context",
);
}
@@ -594,6 +592,157 @@ export class WebPlatformService implements PlatformService {
}
}
/**
* Saves content directly to the device's Downloads folder (Android) or Documents folder (iOS).
* Uses MediaStore on Android API 29+ and falls back to SAF on older versions.
*
* @param fileName - The filename of the file to save
* @param content - The content to write to the file
* @returns Promise that resolves when the file is saved
*/
async saveToDevice(fileName: string, content: string): Promise<void> {
try {
// Ensure content is valid JSON
try {
JSON.parse(content);
} catch {
throw new Error('Content must be valid JSON');
}
// Generate unique filename
const uniqueFileName = this.generateUniqueFileName(fileName);
// Web platform: Use the same download mechanism as writeAndShareFile
await this.writeAndShareFile(uniqueFileName, content);
logger.log("[WebPlatformService] File saved to device", uniqueFileName);
} catch (error) {
logger.error("[WebPlatformService] Error saving file to device", error);
throw new Error(
`Failed to save file to device: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
/**
* Opens the system file picker to let the user choose where to save a file (web platform).
* Uses the browser's download mechanism with a suggested filename.
*
* @param fileName - The suggested filename for the file
* @param content - The content to write to the file
* @returns Promise that resolves when the file is saved
*/
async saveAs(fileName: string, content: string): Promise<void> {
try {
// Ensure content is valid JSON
try {
JSON.parse(content);
} catch {
throw new Error('Content must be valid JSON');
}
// Generate unique filename
const uniqueFileName = this.generateUniqueFileName(fileName);
// Web platform: Use the same download mechanism as writeAndShareFile
await this.writeAndShareFile(uniqueFileName, content);
logger.log("[WebPlatformService] File saved as", uniqueFileName);
} catch (error) {
logger.error("[WebPlatformService] Error saving file as", error);
throw new Error(
`Failed to save file as: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
/**
* Generates unique filename with timestamp, hashed device ID, and counter
*/
private generateUniqueFileName(baseName: string, counter = 0): string {
const now = new Date();
const timestamp = now.toISOString()
.replace(/[:.]/g, '-')
.replace('T', '_')
.replace('Z', '');
const deviceIdHash = this.getHashedDeviceIdentifier();
const counterSuffix = counter > 0 ? `_${counter}` : '';
const maxBaseLength = 45;
const truncatedBase = baseName.length > maxBaseLength
? baseName.substring(0, maxBaseLength)
: baseName;
const nameWithoutExt = truncatedBase.replace(/\.json$/i, '');
const extension = '.json';
const devicePart = `_${deviceIdHash}`;
const timestampPart = `_${timestamp}${counterSuffix}`;
const totalLength = nameWithoutExt.length + devicePart.length + timestampPart.length + extension.length;
if (totalLength > 200) {
const availableLength = 200 - devicePart.length - timestampPart.length - extension.length;
const finalBase = nameWithoutExt.substring(0, Math.max(10, availableLength));
return `${finalBase}${devicePart}${timestampPart}${extension}`;
}
return `${nameWithoutExt}${devicePart}${timestampPart}${extension}`;
}
/**
* Gets hashed device identifier
*/
private getHashedDeviceIdentifier(): string {
try {
const deviceInfo = this.getDeviceInfo();
return this.hashString(deviceInfo);
} catch (error) {
return 'web';
}
}
/**
* Gets device info string
*/
private getDeviceInfo(): string {
try {
// Use browser fingerprint or fallback
const userAgent = navigator.userAgent;
const language = navigator.language || 'unknown';
const platform = navigator.platform || 'unknown';
let browser = 'unknown';
let os = 'unknown';
if (userAgent.includes('Chrome')) browser = 'chrome';
else if (userAgent.includes('Firefox')) browser = 'firefox';
else if (userAgent.includes('Safari')) browser = 'safari';
else if (userAgent.includes('Edge')) browser = 'edge';
if (userAgent.includes('Windows')) os = 'win';
else if (userAgent.includes('Mac')) os = 'mac';
else if (userAgent.includes('Linux')) os = 'linux';
else if (userAgent.includes('Android')) os = 'android';
else if (userAgent.includes('iOS')) os = 'ios';
return `${browser}_${os}_${platform}_${language}`;
} catch (error) {
return 'web';
}
}
/**
* Simple hash function for device ID
*/
private hashString(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return Math.abs(hash).toString(16).padStart(4, '0').substring(0, 4);
}
/**
* @see PlatformService.dbQuery
*/

View File

@@ -88,19 +88,22 @@
</button>
</div>
</div>
<div class="text-sm">
<div data-testId="description">
<div class="text-sm overflow-hidden">
<div
data-testId="description"
class="overflow-hidden text-ellipsis"
>
<font-awesome icon="message" class="fa-fw text-slate-400" />
<vue-markdown
:source="claimDescription"
class="markdown-content"
/>
</div>
<div>
<div class="overflow-hidden text-ellipsis">
<font-awesome icon="user" class="fa-fw text-slate-400" />
{{ didInfo(veriClaim.issuer) }}
</div>
<div>
<div class="overflow-hidden text-ellipsis">
<font-awesome icon="calendar" class="fa-fw text-slate-400" />
Recorded
{{ formattedIssueDate }}

View File

@@ -34,7 +34,7 @@
class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4"
>
<div>
<h2 class="text-xl font-semibold">
<h2 class="text-xl font-semibold overflow-hidden text-ellipsis">
{{ contactFromDid?.name || "(no name)" }}
<router-link
:to="{ name: 'contact-edit', params: { did: contactFromDid?.did } }"

View File

@@ -282,8 +282,8 @@
/>
</div>
<div class="grow">
<h2 class="text-base font-semibold">
<div class="grow overflow-hidden">
<h2 class="text-base font-semibold truncate">
{{ project.name || unnamedProject }}
</h2>
<div class="text-sm">

View File

@@ -60,7 +60,9 @@
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2
2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1
1 0 011 1v4a1 1 0 001 1m-6 0h6"
></path>
</svg>
Go Home

View File

@@ -77,6 +77,7 @@ import {
} from "../libs/endorserServer";
import { generateSaveAndActivateIdentity } from "../libs/util";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { NotificationIface } from "../constants/app";
@Component({
components: {
@@ -97,6 +98,7 @@ export default class OnboardMeetingMembersView extends Vue {
projectLink = "";
$route!: RouteLocationNormalizedLoaded;
$router!: Router;
$notify!: (notification: NotificationIface, timeout?: number) => void;
userNameDialog!: InstanceType<typeof UserNameDialog>;

View File

@@ -230,26 +230,28 @@
class="mt-8 p-4 border rounded-lg bg-white shadow"
>
<div class="flex items-center justify-between">
<h2 class="text-2xl">Meeting Members</h2>
</div>
<div class="mt-4">
&bull; Page for Members
<span
class="ml-4 cursor-pointer text-blue-600"
title="Click to copy link for members"
@click="copyMembersLinkToClipboard"
>
<font-awesome icon="link" />
</span>
<a
v-if="!!currentMeeting.password"
:href="onboardMeetingMembersLink()"
class="ml-4 text-blue-600"
target="_blank"
>
<font-awesome icon="external-link" />
</a>
<h2 class="font-bold">Meeting Members</h2>
</div>
<ul class="list-disc text-sm mt-4 mb-2 ps-4 space-y-2">
<li>
Page for Members:
<span
class="ml-4 cursor-pointer text-blue-600"
title="Click to copy link for members"
@click="copyMembersLinkToClipboard"
>
<font-awesome icon="link" />
</span>
<a
v-if="!!currentMeeting.password"
:href="onboardMeetingMembersLink()"
class="ml-4 text-blue-600"
target="_blank"
>
<font-awesome icon="external-link" />
</a>
</li>
</ul>
<MembersList
:password="currentMeeting.password || ''"

View File

@@ -28,7 +28,7 @@
<font-awesome icon="question" class="block text-center w-[1em]" />
</router-link>
</div>
<h2 class="text-center text-lg font-normal">
<h2 class="text-center text-lg font-normal overflow-hidden text-ellipsis">
{{ name }}
<button
v-if="activeDid === issuer || activeDid === agentDid"
@@ -157,7 +157,7 @@
</div>
<div class="text-sm text-slate-500">
<div v-if="!expanded">
<div v-if="!expanded" class="overflow-hidden text-ellipsis">
<vue-markdown
:source="truncatedDesc"
class="mb-4 markdown-content"
@@ -169,7 +169,7 @@
>... Read More</a
>
</div>
<div v-else>
<div v-else class="overflow-hidden text-ellipsis">
<vue-markdown :source="description" class="mb-4 markdown-content" />
<a
v-if="description.length >= truncateLength"
@@ -308,7 +308,10 @@
/>{{ offer.amount }}
</span>
</div>
<div v-if="offer.objectDescription" class="text-slate-500">
<div
v-if="offer.objectDescription"
class="text-slate-500 overflow-hidden text-ellipsis"
>
<font-awesome icon="comment" class="fa-fw text-slate-400" />
{{ offer.objectDescription }}
</div>
@@ -450,7 +453,10 @@
<font-awesome icon="calendar" class="fa-fw text-slate-400" />
{{ give.issuedAt?.substring(0, 10) }}
</div>
<div v-if="give.description" class="text-slate-500">
<div
v-if="give.description"
class="text-slate-500 overflow-hidden text-ellipsis"
>
<font-awesome icon="comment" class="fa-fw text-slate-400" />
{{ give.description }}
</div>
@@ -557,7 +563,10 @@
<font-awesome icon="calendar" class="fa-fw text-slate-400" />
{{ give.issuedAt?.substring(0, 10) }}
</div>
<div v-if="give.description" class="text-slate-500">
<div
v-if="give.description"
class="text-slate-500 overflow-hidden text-ellipsis"
>
<font-awesome icon="comment" class="fa-fw text-slate-400" />
{{ give.description }}
</div>

View File

@@ -118,8 +118,8 @@
/>
</div>
<div>
<div>
<div class="overflow-hidden">
<div class="text-sm truncate">
To
{{
offer.fulfillsPlanHandleId
@@ -132,7 +132,7 @@
)
}}
</div>
<div>
<div class="truncate">
{{ offer.objectDescription }}
</div>
@@ -254,7 +254,7 @@
</div>
<div class="grow overflow-hidden">
<h2 class="text-base font-semibold">
<h2 class="text-base font-semibold truncate">
{{ project.name || unnamedProject }}
</h2>
<div class="text-sm truncate">