mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-23 02:36:05 +00:00
feat(share): outline multi platform share functionality
This commit is contained in:
parent
242bc25ac4
commit
9574dd4f19
14 changed files with 1568 additions and 59 deletions
7
electron/electronAPI.d.ts
vendored
7
electron/electronAPI.d.ts
vendored
|
|
@ -70,6 +70,13 @@ export interface ElectronAPI {
|
|||
data: string,
|
||||
): Promise<{ success: boolean; path?: string }>;
|
||||
|
||||
shareNative(payload: {
|
||||
text?: string;
|
||||
url?: string;
|
||||
title?: string;
|
||||
files?: string[];
|
||||
}): Promise<{ success: boolean; error?: string }>;
|
||||
|
||||
isLinux(): boolean;
|
||||
|
||||
isMacOS(): boolean;
|
||||
|
|
|
|||
|
|
@ -79,6 +79,17 @@ export const initIpcInterfaces = (): void => {
|
|||
return { success: false };
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC.SHARE_NATIVE, async (ev, payload) => {
|
||||
// TODO: Implement native share for macOS (NSSharingService) and Windows (WinRT Share UI)
|
||||
// For now, return false to fall back to web-based sharing
|
||||
// Native implementation would require:
|
||||
// - macOS: Swift/Objective-C bridge using NSSharingServicePicker
|
||||
// - Windows: C#/C++ bridge using Windows.ApplicationModel.DataTransfer.DataTransferManager
|
||||
// - Linux: No native share, always use fallback
|
||||
log('Native share requested but not implemented, falling back to web share');
|
||||
return { success: false, error: 'Native share not yet implemented' };
|
||||
});
|
||||
|
||||
ipcMain.on(IPC.LOCK_SCREEN, () => {
|
||||
if ((app as any).isLocked) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -80,6 +80,16 @@ const ea: ElectronAPI = {
|
|||
success: boolean;
|
||||
path?: string;
|
||||
}>,
|
||||
shareNative: (payload: {
|
||||
text?: string;
|
||||
url?: string;
|
||||
title?: string;
|
||||
files?: string[];
|
||||
}) =>
|
||||
_invoke('SHARE_NATIVE', payload) as Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>,
|
||||
scheduleRegisterBeforeClose: (id) => _send('REGISTER_BEFORE_CLOSE', { id }),
|
||||
unscheduleRegisterBeforeClose: (id) => _send('UNREGISTER_BEFORE_CLOSE', { id }),
|
||||
setDoneRegisterBeforeClose: (id) => _send('BEFORE_CLOSE_DONE', { id }),
|
||||
|
|
|
|||
|
|
@ -63,6 +63,8 @@ export enum IPC {
|
|||
|
||||
SAVE_FILE_DIALOG = 'SAVE_FILE_DIALOG',
|
||||
|
||||
SHARE_NATIVE = 'SHARE_NATIVE',
|
||||
|
||||
// Plugin Node Execution
|
||||
PLUGIN_EXEC_NODE_SCRIPT = 'PLUGIN_EXEC_NODE_SCRIPT',
|
||||
|
||||
|
|
|
|||
249
src/app/core/share/README.md
Normal file
249
src/app/core/share/README.md
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
# Share Component
|
||||
|
||||
Multi-platform share functionality for Super Productivity.
|
||||
|
||||
## Overview
|
||||
|
||||
This module provides a reusable share system that works across all platforms:
|
||||
|
||||
- **Desktop (Electron)**: Opens share URLs in browser via shell
|
||||
- **Mobile (Android)**: Uses Capacitor Share plugin when available
|
||||
- **Web (PWA)**: Uses Web Share API when available
|
||||
- **Fallback**: Material dialog with intent URLs for all social platforms
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```typescript
|
||||
import { ShareService } from './core/share/share.service';
|
||||
import { ShareFormatter } from './core/share/share-formatter';
|
||||
|
||||
// In your component
|
||||
constructor(private shareService: ShareService) {}
|
||||
|
||||
async shareWorkSummary() {
|
||||
const payload = ShareFormatter.formatWorkSummary({
|
||||
totalTimeSpent: 3600000, // 1 hour in ms
|
||||
tasksCompleted: 5,
|
||||
dateRange: {
|
||||
start: '2024-01-01',
|
||||
end: '2024-01-07',
|
||||
},
|
||||
}, {
|
||||
includeUTM: true,
|
||||
includeHashtags: true,
|
||||
});
|
||||
|
||||
await this.shareService.share(payload);
|
||||
}
|
||||
```
|
||||
|
||||
### Using the Share Button Component
|
||||
|
||||
```html
|
||||
<share-button
|
||||
[payload]="mySharePayload"
|
||||
tooltip="Share your achievements"
|
||||
/>
|
||||
```
|
||||
|
||||
```typescript
|
||||
// In component
|
||||
import { ShareButtonComponent } from './core/share/share-button/share-button.component';
|
||||
import { ShareFormatter } from './core/share/share-formatter';
|
||||
|
||||
@Component({
|
||||
imports: [ShareButtonComponent],
|
||||
// ...
|
||||
})
|
||||
export class MyComponent {
|
||||
readonly sharePayload = ShareFormatter.formatWorkSummary({
|
||||
totalTimeSpent: this.totalTime,
|
||||
tasksCompleted: this.completedTaskCount,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### ShareService
|
||||
|
||||
Main service for sharing content.
|
||||
|
||||
#### Methods
|
||||
|
||||
- `share(payload: SharePayload, target?: ShareTarget): Promise<ShareResult>`
|
||||
|
||||
- Main share method that automatically detects platform and uses best method
|
||||
- If target is specified, shares directly to that target
|
||||
- Otherwise, tries native share first, then shows dialog
|
||||
|
||||
- `getShareTargets(): ShareTargetConfig[]`
|
||||
- Returns list of available share targets with their configurations
|
||||
|
||||
### ShareFormatter
|
||||
|
||||
Utility class for formatting content into shareable payloads.
|
||||
|
||||
#### Methods
|
||||
|
||||
- `formatWorkSummary(data: WorkSummaryData, options?: ShareFormatterOptions): SharePayload`
|
||||
|
||||
- Formats work statistics as shareable text with time spent, tasks completed, etc.
|
||||
|
||||
- `formatPromotion(customText?: string, options?: ShareFormatterOptions): SharePayload`
|
||||
|
||||
- Creates a promotional share payload for the app
|
||||
|
||||
- `optimizeForTwitter(payload: SharePayload): SharePayload`
|
||||
- Truncates text to fit Twitter's character limit
|
||||
|
||||
### SharePayload Interface
|
||||
|
||||
```typescript
|
||||
interface SharePayload {
|
||||
text?: string; // Main text content
|
||||
url?: string; // URL to share
|
||||
title?: string; // Optional title (used by Reddit, Email)
|
||||
files?: string[]; // Optional file paths for native share (future use)
|
||||
}
|
||||
```
|
||||
|
||||
### ShareTarget Type
|
||||
|
||||
Supported share targets:
|
||||
|
||||
- `twitter` - Twitter/X
|
||||
- `linkedin` - LinkedIn
|
||||
- `reddit` - Reddit
|
||||
- `facebook` - Facebook
|
||||
- `whatsapp` - WhatsApp
|
||||
- `telegram` - Telegram
|
||||
- `email` - Email
|
||||
- `mastodon` - Mastodon (with custom instance support)
|
||||
- `clipboard-link` - Copy link to clipboard
|
||||
- `clipboard-text` - Copy formatted text to clipboard
|
||||
- `native` - Use native OS share sheet
|
||||
|
||||
## Platform Support
|
||||
|
||||
### Desktop (Electron)
|
||||
|
||||
- Uses `shell.openExternal()` to open share URLs
|
||||
- Native share handler stubbed out (ready for macOS/Windows native implementation)
|
||||
- IPC event: `SHARE_NATIVE`
|
||||
|
||||
### Mobile (Android via Capacitor)
|
||||
|
||||
- Checks for Capacitor Share plugin at runtime via `window.Capacitor?.Plugins?.Share`
|
||||
- No build-time dependency required
|
||||
- Falls back gracefully if plugin not installed
|
||||
|
||||
### Web (PWA/Browser)
|
||||
|
||||
- Uses Web Share API when available
|
||||
- Falls back to share dialog with intent URLs
|
||||
|
||||
## Architecture
|
||||
|
||||
### Files Structure
|
||||
|
||||
```
|
||||
src/app/core/share/
|
||||
├── share.model.ts # TypeScript interfaces
|
||||
├── share-formatter.ts # Work summary formatter
|
||||
├── share-formatter.spec.ts # Formatter tests
|
||||
├── share.service.ts # Main share service
|
||||
├── share.service.spec.ts # Service tests
|
||||
├── share-dialog/ # Material dialog component
|
||||
│ ├── share-dialog.component.ts
|
||||
│ ├── share-dialog.component.html
|
||||
│ └── share-dialog.component.scss
|
||||
└── share-button/ # Reusable button component
|
||||
└── share-button.component.ts
|
||||
```
|
||||
|
||||
### Electron Integration
|
||||
|
||||
```
|
||||
electron/
|
||||
├── shared-with-frontend/
|
||||
│ └── ipc-events.const.ts # Added SHARE_NATIVE event
|
||||
├── electronAPI.d.ts # Added shareNative method
|
||||
├── preload.ts # Exposed shareNative to renderer
|
||||
└── ipc-handler.ts # IPC handler (fallback stub)
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Native OS Share Implementation
|
||||
|
||||
The Electron IPC handler currently returns a fallback error. To implement true native share:
|
||||
|
||||
#### macOS
|
||||
|
||||
Create a Swift/Objective-C bridge using `NSSharingServicePicker`:
|
||||
|
||||
```swift
|
||||
import Cocoa
|
||||
|
||||
@objc class ShareHelper: NSObject {
|
||||
@objc static func share(text: String, url: String, files: [String]) {
|
||||
let items = [text, URL(string: url)!] + files.map { URL(fileURLWithPath: $0) }
|
||||
let picker = NSSharingServicePicker(items: items)
|
||||
// Show picker at mouse location
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Windows
|
||||
|
||||
Create a C#/C++ bridge using WinRT `DataTransferManager`:
|
||||
|
||||
```csharp
|
||||
using Windows.ApplicationModel.DataTransfer;
|
||||
|
||||
var dataTransferManager = DataTransferManager.GetForCurrentView();
|
||||
dataTransferManager.DataRequested += (sender, args) => {
|
||||
args.Request.Data.SetText(text);
|
||||
args.Request.Data.SetWebLink(new Uri(url));
|
||||
};
|
||||
DataTransferManager.ShowShareUI();
|
||||
```
|
||||
|
||||
### Capacitor Plugin Installation
|
||||
|
||||
To enable native Android sharing, install the Capacitor Share plugin:
|
||||
|
||||
```bash
|
||||
npm install @capacitor/share
|
||||
```
|
||||
|
||||
The service will automatically detect and use it when available.
|
||||
|
||||
## Testing
|
||||
|
||||
Unit tests are included for:
|
||||
|
||||
- `share-formatter.spec.ts` - Tests formatting logic
|
||||
- `share.service.spec.ts` - Tests service methods and URL building
|
||||
|
||||
Run tests:
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
When adding new share targets:
|
||||
|
||||
1. Add target to `ShareTarget` type in `share.model.ts`
|
||||
2. Add URL builder to `_buildShareUrl()` in `share.service.ts`
|
||||
3. Add button config to `shareTargets` array in `share-dialog.component.ts`
|
||||
4. Add tests in `share.service.spec.ts`
|
||||
|
||||
## License
|
||||
|
||||
Part of Super Productivity - see main project LICENSE.
|
||||
45
src/app/core/share/share-button/share-button.component.ts
Normal file
45
src/app/core/share/share-button/share-button.component.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { Component, ChangeDetectionStrategy, inject, input } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { ShareService } from '../share.service';
|
||||
import { SharePayload } from '../share.model';
|
||||
|
||||
/**
|
||||
* Reusable share button component
|
||||
* Can be placed anywhere in the app to trigger sharing
|
||||
*/
|
||||
@Component({
|
||||
selector: 'share-button',
|
||||
template: `
|
||||
<button
|
||||
mat-icon-button
|
||||
[matTooltip]="tooltip()"
|
||||
(click)="share()"
|
||||
[disabled]="disabled()"
|
||||
>
|
||||
<mat-icon>share</mat-icon>
|
||||
</button>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [MatButtonModule, MatIconModule, MatTooltipModule],
|
||||
})
|
||||
export class ShareButtonComponent {
|
||||
private _shareService = inject(ShareService);
|
||||
|
||||
/** The payload to share when button is clicked */
|
||||
readonly payload = input.required<SharePayload>();
|
||||
|
||||
/** Tooltip text (default: 'Share') */
|
||||
readonly tooltip = input<string>('Share');
|
||||
|
||||
/** Whether button is disabled */
|
||||
readonly disabled = input<boolean>(false);
|
||||
|
||||
/**
|
||||
* Trigger share action
|
||||
*/
|
||||
async share(): Promise<void> {
|
||||
await this._shareService.share(this.payload());
|
||||
}
|
||||
}
|
||||
73
src/app/core/share/share-dialog/share-dialog.component.html
Normal file
73
src/app/core/share/share-dialog/share-dialog.component.html
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<h2 mat-dialog-title>Share</h2>
|
||||
|
||||
<div mat-dialog-content>
|
||||
<div class="share-options">
|
||||
<!-- Native Share Button (if available) -->
|
||||
@if (data.showNative) {
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
class="share-target-button native-button"
|
||||
(click)="shareNative()"
|
||||
>
|
||||
<mat-icon>share</mat-icon>
|
||||
<span>System Share</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
<!-- Social Media Share Buttons -->
|
||||
@for (shareTarget of shareTargets; track shareTarget.target) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="share-target-button"
|
||||
(click)="shareToTarget(shareTarget.target)"
|
||||
>
|
||||
<mat-icon>{{ shareTarget.icon }}</mat-icon>
|
||||
<span>{{ shareTarget.label }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Mastodon Instance Input -->
|
||||
<div class="mastodon-instance">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Mastodon Instance</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[(ngModel)]="mastodonInstance"
|
||||
placeholder="mastodon.social"
|
||||
/>
|
||||
<mat-icon matPrefix>language</mat-icon>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<!-- Clipboard Actions -->
|
||||
<div class="clipboard-actions">
|
||||
<button
|
||||
mat-button
|
||||
(click)="copyLink()"
|
||||
>
|
||||
<mat-icon>link</mat-icon>
|
||||
<span>Copy Link</span>
|
||||
</button>
|
||||
<button
|
||||
mat-button
|
||||
(click)="copyText()"
|
||||
>
|
||||
<mat-icon>content_copy</mat-icon>
|
||||
<span>Copy Text</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
mat-dialog-actions
|
||||
align="end"
|
||||
>
|
||||
<button
|
||||
mat-button
|
||||
(click)="close()"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
67
src/app/core/share/share-dialog/share-dialog.component.scss
Normal file
67
src/app/core/share/share-dialog/share-dialog.component.scss
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.share-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.share-target-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
span {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.native-button {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.mastodon-instance {
|
||||
margin-bottom: 16px;
|
||||
|
||||
mat-form-field {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.clipboard-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.12);
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mat-dialog-content {
|
||||
min-width: 400px;
|
||||
}
|
||||
107
src/app/core/share/share-dialog/share-dialog.component.ts
Normal file
107
src/app/core/share/share-dialog/share-dialog.component.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ShareService } from '../share.service';
|
||||
import { ShareDialogOptions, ShareResult, ShareTarget } from '../share.model';
|
||||
import { ShareFormatter } from '../share-formatter';
|
||||
|
||||
interface ShareTargetButton {
|
||||
target: ShareTarget;
|
||||
label: string;
|
||||
icon: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'share-dialog',
|
||||
templateUrl: './share-dialog.component.html',
|
||||
styleUrls: ['./share-dialog.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatFormFieldModule,
|
||||
FormsModule,
|
||||
],
|
||||
})
|
||||
export class ShareDialogComponent {
|
||||
private _dialogRef = inject(MatDialogRef<ShareDialogComponent>);
|
||||
private _shareService = inject(ShareService);
|
||||
readonly data = inject<ShareDialogOptions>(MAT_DIALOG_DATA);
|
||||
|
||||
mastodonInstance = this.data.mastodonInstance || 'mastodon.social';
|
||||
|
||||
readonly shareTargets: ShareTargetButton[] = [
|
||||
{ target: 'twitter', label: 'Twitter / X', icon: 'link' },
|
||||
{ target: 'linkedin', label: 'LinkedIn', icon: 'link' },
|
||||
{ target: 'reddit', label: 'Reddit', icon: 'link' },
|
||||
{ target: 'facebook', label: 'Facebook', icon: 'link' },
|
||||
{ target: 'whatsapp', label: 'WhatsApp', icon: 'chat' },
|
||||
{ target: 'telegram', label: 'Telegram', icon: 'send' },
|
||||
{ target: 'email', label: 'Email', icon: 'email' },
|
||||
{ target: 'mastodon', label: 'Mastodon', icon: 'link' },
|
||||
];
|
||||
|
||||
async shareToTarget(target: ShareTarget): Promise<void> {
|
||||
let payload = this.data.payload;
|
||||
|
||||
// Optimize for Twitter
|
||||
if (target === 'twitter') {
|
||||
payload = ShareFormatter.optimizeForTwitter(payload);
|
||||
}
|
||||
|
||||
const result: ShareResult = await this._shareService['_shareToTarget'](
|
||||
payload,
|
||||
target,
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
this._dialogRef.close(result);
|
||||
}
|
||||
}
|
||||
|
||||
async shareNative(): Promise<void> {
|
||||
const result: ShareResult = await this._shareService['_tryNativeShare'](
|
||||
this.data.payload,
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
this._dialogRef.close(result);
|
||||
}
|
||||
}
|
||||
|
||||
async copyLink(): Promise<void> {
|
||||
const result: ShareResult = await this._shareService['_copyToClipboard'](
|
||||
this.data.payload.url || '',
|
||||
'Link',
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
this._dialogRef.close(result);
|
||||
}
|
||||
}
|
||||
|
||||
async copyText(): Promise<void> {
|
||||
const text = this._shareService['_formatTextForClipboard'](this.data.payload);
|
||||
const result: ShareResult = await this._shareService['_copyToClipboard'](
|
||||
text,
|
||||
'Text',
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
this._dialogRef.close(result);
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this._dialogRef.close();
|
||||
}
|
||||
}
|
||||
145
src/app/core/share/share-formatter.spec.ts
Normal file
145
src/app/core/share/share-formatter.spec.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { ShareFormatter, WorkSummaryData } from './share-formatter';
|
||||
|
||||
describe('ShareFormatter', () => {
|
||||
describe('formatWorkSummary', () => {
|
||||
it('should format basic work summary', () => {
|
||||
const data: WorkSummaryData = {
|
||||
totalTimeSpent: 3600000, // 1 hour
|
||||
tasksCompleted: 5,
|
||||
};
|
||||
|
||||
const payload = ShareFormatter.formatWorkSummary(data);
|
||||
|
||||
expect(payload.text).toContain('📊 My productivity summary:');
|
||||
expect(payload.text).toContain('1h');
|
||||
expect(payload.text).toContain('5 tasks completed');
|
||||
expect(payload.url).toBeDefined();
|
||||
});
|
||||
|
||||
it('should include date range when provided', () => {
|
||||
const data: WorkSummaryData = {
|
||||
totalTimeSpent: 3600000,
|
||||
tasksCompleted: 5,
|
||||
dateRange: {
|
||||
start: '2024-01-01',
|
||||
end: '2024-01-07',
|
||||
},
|
||||
};
|
||||
|
||||
const payload = ShareFormatter.formatWorkSummary(data);
|
||||
|
||||
expect(payload.text).toContain('2024-01-01');
|
||||
expect(payload.text).toContain('2024-01-07');
|
||||
});
|
||||
|
||||
it('should include top tasks when provided', () => {
|
||||
const data: WorkSummaryData = {
|
||||
totalTimeSpent: 3600000,
|
||||
tasksCompleted: 5,
|
||||
topTasks: [
|
||||
{ title: 'Task 1', timeSpent: 1800000 },
|
||||
{ title: 'Task 2', timeSpent: 1200000 },
|
||||
],
|
||||
};
|
||||
|
||||
const payload = ShareFormatter.formatWorkSummary(data);
|
||||
|
||||
expect(payload.text).toContain('Task 1');
|
||||
expect(payload.text).toContain('Task 2');
|
||||
});
|
||||
|
||||
it('should include UTM parameters when requested', () => {
|
||||
const data: WorkSummaryData = {
|
||||
totalTimeSpent: 3600000,
|
||||
tasksCompleted: 5,
|
||||
};
|
||||
|
||||
const payload = ShareFormatter.formatWorkSummary(data, {
|
||||
includeUTM: true,
|
||||
});
|
||||
|
||||
expect(payload.url).toContain('utm_source');
|
||||
expect(payload.url).toContain('utm_medium');
|
||||
expect(payload.url).toContain('utm_campaign');
|
||||
});
|
||||
|
||||
it('should include hashtags when requested', () => {
|
||||
const data: WorkSummaryData = {
|
||||
totalTimeSpent: 3600000,
|
||||
tasksCompleted: 5,
|
||||
};
|
||||
|
||||
const payload = ShareFormatter.formatWorkSummary(data, {
|
||||
includeHashtags: true,
|
||||
});
|
||||
|
||||
expect(payload.text).toContain('#productivity');
|
||||
expect(payload.text).toContain('#SuperProductivity');
|
||||
});
|
||||
|
||||
it('should set project name in title when provided', () => {
|
||||
const data: WorkSummaryData = {
|
||||
totalTimeSpent: 3600000,
|
||||
tasksCompleted: 5,
|
||||
projectName: 'My Project',
|
||||
};
|
||||
|
||||
const payload = ShareFormatter.formatWorkSummary(data);
|
||||
|
||||
expect(payload.title).toBe('Work Summary - My Project');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatPromotion', () => {
|
||||
it('should format default promotional text', () => {
|
||||
const payload = ShareFormatter.formatPromotion();
|
||||
|
||||
expect(payload.text).toContain('Super Productivity');
|
||||
expect(payload.title).toBe('Super Productivity');
|
||||
expect(payload.url).toBeDefined();
|
||||
});
|
||||
|
||||
it('should use custom text when provided', () => {
|
||||
const customText = 'Check out this awesome app!';
|
||||
const payload = ShareFormatter.formatPromotion(customText);
|
||||
|
||||
expect(payload.text).toBe(customText);
|
||||
});
|
||||
|
||||
it('should include UTM parameters when requested', () => {
|
||||
const payload = ShareFormatter.formatPromotion(undefined, {
|
||||
includeUTM: true,
|
||||
utmSource: 'custom',
|
||||
});
|
||||
|
||||
expect(payload.url).toContain('utm_source=custom');
|
||||
});
|
||||
});
|
||||
|
||||
describe('optimizeForTwitter', () => {
|
||||
it('should truncate long text for Twitter', () => {
|
||||
const longText = 'a'.repeat(300);
|
||||
const payload = {
|
||||
text: longText,
|
||||
url: 'https://example.com',
|
||||
};
|
||||
|
||||
const optimized = ShareFormatter.optimizeForTwitter(payload);
|
||||
|
||||
expect(optimized.text!.length).toBeLessThanOrEqual(280 - 23 - 1); // 280 - URL length - space
|
||||
expect(optimized.text).toEndWith('...');
|
||||
});
|
||||
|
||||
it('should not truncate short text', () => {
|
||||
const shortText = 'Short text';
|
||||
const payload = {
|
||||
text: shortText,
|
||||
url: 'https://example.com',
|
||||
};
|
||||
|
||||
const optimized = ShareFormatter.optimizeForTwitter(payload);
|
||||
|
||||
expect(optimized.text).toBe(shortText);
|
||||
});
|
||||
});
|
||||
});
|
||||
178
src/app/core/share/share-formatter.ts
Normal file
178
src/app/core/share/share-formatter.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import { msToClockString } from '../../ui/duration/ms-to-clock-string.pipe';
|
||||
import { msToString } from '../../ui/duration/ms-to-string.pipe';
|
||||
import { SharePayload } from './share.model';
|
||||
|
||||
/**
|
||||
* Data for creating a work summary share
|
||||
*/
|
||||
export interface WorkSummaryData {
|
||||
/** Total time spent in milliseconds */
|
||||
totalTimeSpent: number;
|
||||
/** Number of tasks completed */
|
||||
tasksCompleted: number;
|
||||
/** Optional: Date range for the summary */
|
||||
dateRange?: {
|
||||
start: string;
|
||||
end: string;
|
||||
};
|
||||
/** Optional: Top tasks by time spent */
|
||||
topTasks?: Array<{ title: string; timeSpent: number }>;
|
||||
/** Optional: Project name */
|
||||
projectName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for formatting share content
|
||||
*/
|
||||
export interface ShareFormatterOptions {
|
||||
/** Include UTM parameters in the URL */
|
||||
includeUTM?: boolean;
|
||||
/** UTM source override (default: 'share') */
|
||||
utmSource?: string;
|
||||
/** UTM medium override (default: 'social') */
|
||||
utmMedium?: string;
|
||||
/** Base URL for the app (default: https://super-productivity.com) */
|
||||
baseUrl?: string;
|
||||
/** Maximum length for text (for Twitter, etc.) */
|
||||
maxLength?: number;
|
||||
/** Include hashtags */
|
||||
includeHashtags?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://super-productivity.com';
|
||||
const DEFAULT_UTM_SOURCE = 'share';
|
||||
const DEFAULT_UTM_MEDIUM = 'social';
|
||||
const TWITTER_MAX_LENGTH = 280;
|
||||
|
||||
/**
|
||||
* Formats work summary data into a shareable payload
|
||||
*/
|
||||
export class ShareFormatter {
|
||||
/**
|
||||
* Create a share payload from work summary data
|
||||
*/
|
||||
static formatWorkSummary(
|
||||
data: WorkSummaryData,
|
||||
options: ShareFormatterOptions = {},
|
||||
): SharePayload {
|
||||
const url = this._buildUrl(options);
|
||||
const text = this._buildWorkSummaryText(data, options);
|
||||
|
||||
return {
|
||||
text,
|
||||
url,
|
||||
title: this._buildTitle(data),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a generic promotional share payload
|
||||
*/
|
||||
static formatPromotion(
|
||||
customText?: string,
|
||||
options: ShareFormatterOptions = {},
|
||||
): SharePayload {
|
||||
const url = this._buildUrl(options);
|
||||
const text =
|
||||
customText ||
|
||||
'Check out Super Productivity - an advanced todo list and time tracking app with focus on flexibility and privacy!';
|
||||
|
||||
return {
|
||||
text,
|
||||
url,
|
||||
title: 'Super Productivity',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimize payload for Twitter (character limit)
|
||||
*/
|
||||
static optimizeForTwitter(payload: SharePayload): SharePayload {
|
||||
const { text } = payload;
|
||||
// Twitter counts URLs as 23 characters
|
||||
const urlLength = 23;
|
||||
const maxTextLength = TWITTER_MAX_LENGTH - urlLength - 1; // -1 for space
|
||||
|
||||
let optimizedText = text || '';
|
||||
if (optimizedText.length > maxTextLength) {
|
||||
optimizedText = optimizedText.substring(0, maxTextLength - 3) + '...';
|
||||
}
|
||||
|
||||
return {
|
||||
...payload,
|
||||
text: optimizedText,
|
||||
};
|
||||
}
|
||||
|
||||
private static _buildUrl(options: ShareFormatterOptions): string {
|
||||
const baseUrl = options.baseUrl || DEFAULT_BASE_URL;
|
||||
|
||||
if (!options.includeUTM) {
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
const utmSource = options.utmSource || DEFAULT_UTM_SOURCE;
|
||||
const utmMedium = options.utmMedium || DEFAULT_UTM_MEDIUM;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
utm_source: utmSource,
|
||||
utm_medium: utmMedium,
|
||||
utm_campaign: 'app_share',
|
||||
});
|
||||
|
||||
return `${baseUrl}?${params.toString()}`;
|
||||
}
|
||||
|
||||
private static _buildTitle(data: WorkSummaryData): string {
|
||||
if (data.projectName) {
|
||||
return `Work Summary - ${data.projectName}`;
|
||||
}
|
||||
return 'My Work Summary';
|
||||
}
|
||||
|
||||
private static _buildWorkSummaryText(
|
||||
data: WorkSummaryData,
|
||||
options: ShareFormatterOptions,
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
// Header
|
||||
if (data.dateRange) {
|
||||
parts.push(
|
||||
`📊 My productivity from ${data.dateRange.start} to ${data.dateRange.end}:`,
|
||||
);
|
||||
} else {
|
||||
parts.push('📊 My productivity summary:');
|
||||
}
|
||||
|
||||
// Main stats
|
||||
const timeStr = msToString(data.totalTimeSpent);
|
||||
const clockStr = msToClockString(data.totalTimeSpent);
|
||||
parts.push(`⏱️ ${timeStr} (${clockStr}) of focused work`);
|
||||
parts.push(`✅ ${data.tasksCompleted} tasks completed`);
|
||||
|
||||
// Top tasks (if provided and not too long)
|
||||
if (data.topTasks && data.topTasks.length > 0 && !options.maxLength) {
|
||||
parts.push('\nTop tasks:');
|
||||
data.topTasks.slice(0, 3).forEach((task) => {
|
||||
const taskTime = msToString(task.timeSpent);
|
||||
parts.push(`• ${task.title} (${taskTime})`);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Hashtags
|
||||
if (options.includeHashtags) {
|
||||
parts.push('\n#productivity #timetracking #SuperProductivity');
|
||||
}
|
||||
|
||||
let text = parts.join('\n');
|
||||
|
||||
// Apply max length if specified
|
||||
if (options.maxLength && text.length > options.maxLength) {
|
||||
text = text.substring(0, options.maxLength - 3) + '...';
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
}
|
||||
69
src/app/core/share/share.model.ts
Normal file
69
src/app/core/share/share.model.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
/**
|
||||
* Payload for sharing content
|
||||
*/
|
||||
export interface SharePayload {
|
||||
/** The main text content to share */
|
||||
text?: string;
|
||||
/** URL to share (e.g., app landing page with UTM parameters) */
|
||||
url?: string;
|
||||
/** Optional title (used by Reddit, Email) */
|
||||
title?: string;
|
||||
/** Optional file paths for native share (images, files) */
|
||||
files?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Available share targets
|
||||
*/
|
||||
export type ShareTarget =
|
||||
| 'twitter'
|
||||
| 'linkedin'
|
||||
| 'reddit'
|
||||
| 'facebook'
|
||||
| 'whatsapp'
|
||||
| 'telegram'
|
||||
| 'email'
|
||||
| 'mastodon'
|
||||
| 'clipboard-link'
|
||||
| 'clipboard-text'
|
||||
| 'native';
|
||||
|
||||
/**
|
||||
* Result of a share operation
|
||||
*/
|
||||
export interface ShareResult {
|
||||
/** Whether the share was successful */
|
||||
success: boolean;
|
||||
/** Error message if failed */
|
||||
error?: string;
|
||||
/** The target that was used */
|
||||
target?: ShareTarget;
|
||||
/** Whether native share was attempted */
|
||||
usedNative?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for share targets
|
||||
*/
|
||||
export interface ShareTargetConfig {
|
||||
/** Display label for the target */
|
||||
label: string;
|
||||
/** Material icon name */
|
||||
icon: string;
|
||||
/** Whether this target is available on the current platform */
|
||||
available: boolean;
|
||||
/** Optional color for the target button */
|
||||
color?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for the share dialog
|
||||
*/
|
||||
export interface ShareDialogOptions {
|
||||
/** The payload to share */
|
||||
payload: SharePayload;
|
||||
/** Whether to show the native share option */
|
||||
showNative?: boolean;
|
||||
/** Pre-selected Mastodon instance */
|
||||
mastodonInstance?: string;
|
||||
}
|
||||
175
src/app/core/share/share.service.spec.ts
Normal file
175
src/app/core/share/share.service.spec.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import { TestBed } from '@angular/core/testing';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { ShareService } from './share.service';
|
||||
import { SnackService } from '../snack/snack.service';
|
||||
import { SharePayload } from './share.model';
|
||||
|
||||
describe('ShareService', () => {
|
||||
let service: ShareService;
|
||||
let mockSnackService: jasmine.SpyObj<SnackService>;
|
||||
let mockMatDialog: jasmine.SpyObj<MatDialog>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockSnackService = jasmine.createSpyObj('SnackService', ['open']);
|
||||
mockMatDialog = jasmine.createSpyObj('MatDialog', ['open']);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
ShareService,
|
||||
{ provide: SnackService, useValue: mockSnackService },
|
||||
{ provide: MatDialog, useValue: mockMatDialog },
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(ShareService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('getShareTargets', () => {
|
||||
it('should return all share target configurations', () => {
|
||||
const targets = service.getShareTargets();
|
||||
|
||||
expect(targets.length).toBeGreaterThan(0);
|
||||
expect(targets[0]).toHaveProperty('label');
|
||||
expect(targets[0]).toHaveProperty('icon');
|
||||
expect(targets[0]).toHaveProperty('available');
|
||||
});
|
||||
|
||||
it('should include Twitter target', () => {
|
||||
const targets = service.getShareTargets();
|
||||
const twitter = targets.find((t) => t.label === 'Twitter');
|
||||
|
||||
expect(twitter).toBeDefined();
|
||||
expect(twitter!.available).toBe(true);
|
||||
});
|
||||
|
||||
it('should include Email target', () => {
|
||||
const targets = service.getShareTargets();
|
||||
const email = targets.find((t) => t.label === 'Email');
|
||||
|
||||
expect(email).toBeDefined();
|
||||
expect(email!.icon).toBe('email');
|
||||
});
|
||||
});
|
||||
|
||||
describe('share', () => {
|
||||
it('should return error when no content provided', async () => {
|
||||
const payload: SharePayload = {};
|
||||
|
||||
const result = await service.share(payload);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('No content to share');
|
||||
});
|
||||
|
||||
it('should accept text-only payload', async () => {
|
||||
const payload: SharePayload = { text: 'Test content' };
|
||||
|
||||
// This will attempt native share or show dialog
|
||||
const result = await service.share(payload);
|
||||
|
||||
// Result depends on platform capabilities
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it('should accept URL-only payload', async () => {
|
||||
const payload: SharePayload = { url: 'https://example.com' };
|
||||
|
||||
const result = await service.share(payload);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('_buildShareUrl', () => {
|
||||
it('should build Twitter share URL', () => {
|
||||
const payload: SharePayload = {
|
||||
text: 'Test tweet',
|
||||
url: 'https://example.com',
|
||||
};
|
||||
|
||||
const url = service['_buildShareUrl'](payload, 'twitter');
|
||||
|
||||
expect(url).toContain('twitter.com/intent/tweet');
|
||||
expect(url).toContain(encodeURIComponent('Test tweet'));
|
||||
});
|
||||
|
||||
it('should build LinkedIn share URL', () => {
|
||||
const payload: SharePayload = { url: 'https://example.com' };
|
||||
|
||||
const url = service['_buildShareUrl'](payload, 'linkedin');
|
||||
|
||||
expect(url).toContain('linkedin.com');
|
||||
expect(url).toContain(encodeURIComponent('https://example.com'));
|
||||
});
|
||||
|
||||
it('should build Email share URL', () => {
|
||||
const payload: SharePayload = {
|
||||
title: 'Check this out',
|
||||
text: 'Great content',
|
||||
url: 'https://example.com',
|
||||
};
|
||||
|
||||
const url = service['_buildShareUrl'](payload, 'email');
|
||||
|
||||
expect(url).toContain('mailto:');
|
||||
expect(url).toContain('subject=');
|
||||
expect(url).toContain('body=');
|
||||
});
|
||||
|
||||
it('should build WhatsApp share URL', () => {
|
||||
const payload: SharePayload = {
|
||||
text: 'Check this out',
|
||||
url: 'https://example.com',
|
||||
};
|
||||
|
||||
const url = service['_buildShareUrl'](payload, 'whatsapp');
|
||||
|
||||
expect(url).toContain('wa.me');
|
||||
expect(url).toContain('text=');
|
||||
});
|
||||
|
||||
it('should throw error for unknown target', () => {
|
||||
const payload: SharePayload = { text: 'Test' };
|
||||
|
||||
expect(() => {
|
||||
service['_buildShareUrl'](payload, 'unknown' as any);
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('_formatTextForClipboard', () => {
|
||||
it('should format payload with title, text, and URL', () => {
|
||||
const payload: SharePayload = {
|
||||
title: 'My Title',
|
||||
text: 'My text content',
|
||||
url: 'https://example.com',
|
||||
};
|
||||
|
||||
const formatted = service['_formatTextForClipboard'](payload);
|
||||
|
||||
expect(formatted).toContain('My Title');
|
||||
expect(formatted).toContain('My text content');
|
||||
expect(formatted).toContain('https://example.com');
|
||||
});
|
||||
|
||||
it('should handle text-only payload', () => {
|
||||
const payload: SharePayload = { text: 'Just text' };
|
||||
|
||||
const formatted = service['_formatTextForClipboard'](payload);
|
||||
|
||||
expect(formatted).toBe('Just text');
|
||||
});
|
||||
|
||||
it('should handle URL-only payload', () => {
|
||||
const payload: SharePayload = { url: 'https://example.com' };
|
||||
|
||||
const formatted = service['_formatTextForClipboard'](payload);
|
||||
|
||||
expect(formatted).toBe('https://example.com');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,11 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { Share } from '@capacitor/share';
|
||||
import { Share as CapacitorShare } from '@capacitor/share';
|
||||
import { IS_ELECTRON } from '../../app.constants';
|
||||
import { IS_ANDROID_WEB_VIEW } from '../../util/is-android-web-view';
|
||||
import { SnackService } from '../snack/snack.service';
|
||||
import { SharePayload, ShareResult, ShareTarget, ShareTargetConfig } from './share.model';
|
||||
|
||||
export type ShareOutcome = 'shared' | 'cancelled' | 'unavailable' | 'failed';
|
||||
export type ShareSupport = 'native' | 'web' | 'none';
|
||||
|
|
@ -10,56 +15,43 @@ interface ShareParams {
|
|||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Share service for multi-platform content sharing.
|
||||
* Supports Electron (Desktop), Capacitor (Android), and Web (PWA/Browser).
|
||||
* Provides a legacy shareText API that only triggers native/web share without UI fallbacks.
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ShareService {
|
||||
private _shareSupportPromise?: Promise<ShareSupport>;
|
||||
|
||||
private _snackService = inject(SnackService);
|
||||
private _matDialog = inject(MatDialog);
|
||||
|
||||
async shareText({ title, text }: ShareParams): Promise<ShareOutcome> {
|
||||
if (!text) {
|
||||
if (!text || typeof window === 'undefined') {
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
const result = await this._tryNativeShare({
|
||||
title: title ?? undefined,
|
||||
text,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
return 'shared';
|
||||
}
|
||||
|
||||
if (result.error === 'Share cancelled') {
|
||||
return 'cancelled';
|
||||
}
|
||||
|
||||
if (result.error === 'Native share not available') {
|
||||
return 'unavailable';
|
||||
}
|
||||
|
||||
const support = await this.getShareSupport();
|
||||
|
||||
if (support === 'native') {
|
||||
try {
|
||||
await Share.share({
|
||||
title: title ?? undefined,
|
||||
text,
|
||||
});
|
||||
return 'shared';
|
||||
} catch (err) {
|
||||
if (this._isCancelled(err)) {
|
||||
return 'cancelled';
|
||||
}
|
||||
console.error('Native share failed:', err);
|
||||
return 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
if (support === 'web' && typeof navigator.share === 'function') {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: title ?? undefined,
|
||||
text,
|
||||
});
|
||||
return 'shared';
|
||||
} catch (err) {
|
||||
if (this._isCancelled(err)) {
|
||||
return 'cancelled';
|
||||
}
|
||||
console.error('Web share failed:', err);
|
||||
return 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
return 'unavailable';
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
async getShareSupport(): Promise<ShareSupport> {
|
||||
|
|
@ -74,32 +66,397 @@ export class ShareService {
|
|||
return this._shareSupportPromise;
|
||||
}
|
||||
|
||||
private _isCancelled(err: unknown): boolean {
|
||||
if (!err) {
|
||||
return false;
|
||||
/**
|
||||
* Main share method - automatically detects platform and uses best method.
|
||||
*/
|
||||
async share(payload: SharePayload, target?: ShareTarget): Promise<ShareResult> {
|
||||
if (!payload.text && !payload.url) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'No content to share',
|
||||
};
|
||||
}
|
||||
const message = (err as Error)?.message ?? '';
|
||||
const name = (err as Error)?.name ?? '';
|
||||
|
||||
return (
|
||||
name === 'AbortError' ||
|
||||
name === 'NotAllowedError' ||
|
||||
message.toLowerCase().includes('cancel') ||
|
||||
message.toLowerCase().includes('abort')
|
||||
);
|
||||
if (target) {
|
||||
return this._shareToTarget(payload, target);
|
||||
}
|
||||
|
||||
const nativeResult = await this._tryNativeShare(payload);
|
||||
if (nativeResult.success) {
|
||||
return nativeResult;
|
||||
}
|
||||
|
||||
return this._showShareDialog(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Share to a specific target.
|
||||
*/
|
||||
private async _shareToTarget(
|
||||
payload: SharePayload,
|
||||
target: ShareTarget,
|
||||
): Promise<ShareResult> {
|
||||
try {
|
||||
switch (target) {
|
||||
case 'native':
|
||||
return this._tryNativeShare(payload);
|
||||
case 'clipboard-link':
|
||||
return this._copyToClipboard(payload.url || '', 'Link');
|
||||
case 'clipboard-text':
|
||||
return this._copyToClipboard(this._formatTextForClipboard(payload), 'Text');
|
||||
default:
|
||||
return this._openShareUrl(payload, target);
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
target,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to use native share (Electron, Android, Web Share API).
|
||||
*/
|
||||
private async _tryNativeShare(payload: SharePayload): Promise<ShareResult> {
|
||||
if (IS_ELECTRON && typeof window.ea?.shareNative === 'function') {
|
||||
try {
|
||||
const result = await window.ea.shareNative(payload);
|
||||
if (result.success) {
|
||||
this._snackService.open('Shared successfully!');
|
||||
return {
|
||||
success: true,
|
||||
usedNative: true,
|
||||
target: 'native',
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Electron native share failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (await this._isCapacitorShareAvailable()) {
|
||||
try {
|
||||
await CapacitorShare.share({
|
||||
title: payload.title,
|
||||
text: payload.text,
|
||||
url: payload.url,
|
||||
files: payload.files,
|
||||
dialogTitle: 'Share via',
|
||||
});
|
||||
this._snackService.open('Shared successfully!');
|
||||
return {
|
||||
success: true,
|
||||
usedNative: true,
|
||||
target: 'native',
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Share cancelled',
|
||||
};
|
||||
}
|
||||
console.warn('Capacitor share failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (IS_ANDROID_WEB_VIEW) {
|
||||
try {
|
||||
const win = window as any;
|
||||
if (win.Capacitor?.Plugins?.Share) {
|
||||
await win.Capacitor.Plugins.Share.share({
|
||||
title: payload.title,
|
||||
text: payload.text,
|
||||
url: payload.url,
|
||||
dialogTitle: 'Share via',
|
||||
});
|
||||
this._snackService.open('Shared successfully!');
|
||||
return {
|
||||
success: true,
|
||||
usedNative: true,
|
||||
target: 'native',
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Capacitor share via window failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof navigator !== 'undefined' && 'share' in navigator) {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: payload.title,
|
||||
text: payload.text,
|
||||
url: payload.url,
|
||||
});
|
||||
this._snackService.open('Shared successfully!');
|
||||
return {
|
||||
success: true,
|
||||
usedNative: true,
|
||||
target: 'native',
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return { success: false, error: 'Share cancelled' };
|
||||
}
|
||||
console.warn('Web Share API failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Native share not available',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Open share dialog with all available targets.
|
||||
*/
|
||||
private async _showShareDialog(payload: SharePayload): Promise<ShareResult> {
|
||||
try {
|
||||
const { DialogShareComponent } = await import(
|
||||
'./dialog-share/dialog-share.component'
|
||||
);
|
||||
|
||||
const dialogRef = this._matDialog.open(DialogShareComponent, {
|
||||
width: '500px',
|
||||
data: {
|
||||
payload,
|
||||
showNative: await this._isSystemShareAvailable(),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await dialogRef.afterClosed().toPromise();
|
||||
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Share cancelled',
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to open share dialog',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a share URL in the default browser.
|
||||
*/
|
||||
private async _openShareUrl(
|
||||
payload: SharePayload,
|
||||
target: ShareTarget,
|
||||
): Promise<ShareResult> {
|
||||
const url = this._buildShareUrl(payload, target);
|
||||
|
||||
if (IS_ELECTRON && window.ea?.openExternalUrl) {
|
||||
window.ea.openExternalUrl(url);
|
||||
} else {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
|
||||
this._snackService.open('Opening share window...');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
target,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build share URL for a specific target.
|
||||
*/
|
||||
private _buildShareUrl(payload: SharePayload, target: ShareTarget): string {
|
||||
const enc = encodeURIComponent;
|
||||
const textAndUrl = [payload.text, payload.url].filter(Boolean).join(' ');
|
||||
|
||||
const urlBuilders: Record<string, () => string> = {
|
||||
twitter: () => `https://twitter.com/intent/tweet?text=${enc(textAndUrl)}`,
|
||||
linkedin: () =>
|
||||
`https://www.linkedin.com/sharing/share-offsite/?url=${enc(payload.url || '')}`,
|
||||
reddit: () =>
|
||||
`https://www.reddit.com/submit?url=${enc(payload.url || '')}&title=${enc(payload.title || payload.text || '')}`,
|
||||
facebook: () =>
|
||||
`https://www.facebook.com/sharer/sharer.php?u=${enc(payload.url || '')}`,
|
||||
whatsapp: () => `https://wa.me/?text=${enc(textAndUrl)}`,
|
||||
telegram: () =>
|
||||
`https://t.me/share/url?url=${enc(payload.url || '')}&text=${enc(payload.text || '')}`,
|
||||
email: () =>
|
||||
`mailto:?subject=${enc(payload.title || 'Check this out')}&body=${enc(textAndUrl)}`,
|
||||
mastodon: () => {
|
||||
const instance = 'mastodon.social';
|
||||
return `https://${instance}/share?text=${enc(textAndUrl)}`;
|
||||
},
|
||||
};
|
||||
|
||||
const builder = urlBuilders[target];
|
||||
if (!builder) {
|
||||
throw new Error(`Unknown share target: ${target}`);
|
||||
}
|
||||
|
||||
return builder();
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy text to clipboard.
|
||||
*/
|
||||
private async _copyToClipboard(text: string, label: string): Promise<ShareResult> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
this._snackService.open(`${label} copied to clipboard!`);
|
||||
return {
|
||||
success: true,
|
||||
target: label === 'Link' ? 'clipboard-link' : 'clipboard-text',
|
||||
};
|
||||
} catch (error) {
|
||||
try {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
|
||||
this._snackService.open(`${label} copied to clipboard!`);
|
||||
return {
|
||||
success: true,
|
||||
target: label === 'Link' ? 'clipboard-link' : 'clipboard-text',
|
||||
};
|
||||
} catch (fallbackError) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to copy to clipboard',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format payload as plain text for clipboard.
|
||||
*/
|
||||
private _formatTextForClipboard(payload: SharePayload): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (payload.title) {
|
||||
parts.push(payload.title);
|
||||
parts.push('');
|
||||
}
|
||||
|
||||
if (payload.text) {
|
||||
parts.push(payload.text);
|
||||
}
|
||||
|
||||
if (payload.url) {
|
||||
if (payload.text) {
|
||||
parts.push('');
|
||||
}
|
||||
parts.push(payload.url);
|
||||
}
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if native/system share is available on current platform.
|
||||
*/
|
||||
private async _isSystemShareAvailable(): Promise<boolean> {
|
||||
if (IS_ELECTRON && typeof window.ea?.shareNative === 'function') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (await this._isCapacitorShareAvailable()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (IS_ANDROID_WEB_VIEW) {
|
||||
const win = window as any;
|
||||
return !!win.Capacitor?.Plugins?.Share;
|
||||
}
|
||||
|
||||
if (typeof navigator !== 'undefined' && 'share' in navigator) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available share targets with their configurations.
|
||||
*/
|
||||
getShareTargets(): ShareTargetConfig[] {
|
||||
return [
|
||||
{
|
||||
label: 'Twitter',
|
||||
icon: 'link',
|
||||
available: true,
|
||||
color: '#1DA1F2',
|
||||
},
|
||||
{
|
||||
label: 'LinkedIn',
|
||||
icon: 'link',
|
||||
available: true,
|
||||
color: '#0A66C2',
|
||||
},
|
||||
{
|
||||
label: 'Reddit',
|
||||
icon: 'link',
|
||||
available: true,
|
||||
color: '#FF4500',
|
||||
},
|
||||
{
|
||||
label: 'Facebook',
|
||||
icon: 'link',
|
||||
available: true,
|
||||
color: '#1877F2',
|
||||
},
|
||||
{
|
||||
label: 'WhatsApp',
|
||||
icon: 'chat',
|
||||
available: true,
|
||||
color: '#25D366',
|
||||
},
|
||||
{
|
||||
label: 'Telegram',
|
||||
icon: 'send',
|
||||
available: true,
|
||||
color: '#0088CC',
|
||||
},
|
||||
{
|
||||
label: 'Email',
|
||||
icon: 'email',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
label: 'Mastodon',
|
||||
icon: 'link',
|
||||
available: true,
|
||||
color: '#6364FF',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private async _detectShareSupport(): Promise<ShareSupport> {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
try {
|
||||
const canShare = await Share.canShare();
|
||||
if (canShare.value) {
|
||||
return 'native';
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Share.canShare failed:', err);
|
||||
if (IS_ELECTRON && typeof window.ea?.shareNative === 'function') {
|
||||
return 'native';
|
||||
}
|
||||
|
||||
if (await this._isCapacitorShareAvailable()) {
|
||||
return 'native';
|
||||
}
|
||||
|
||||
if (IS_ANDROID_WEB_VIEW) {
|
||||
const win = window as any;
|
||||
if (win.Capacitor?.Plugins?.Share) {
|
||||
return 'native';
|
||||
}
|
||||
return 'none';
|
||||
}
|
||||
|
||||
if (typeof navigator !== 'undefined' && typeof navigator.share === 'function') {
|
||||
|
|
@ -108,4 +465,18 @@ export class ShareService {
|
|||
|
||||
return 'none';
|
||||
}
|
||||
|
||||
private async _isCapacitorShareAvailable(): Promise<boolean> {
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const canShare = await CapacitorShare.canShare();
|
||||
return !!canShare?.value;
|
||||
} catch (error) {
|
||||
console.warn('Capacitor Share.canShare failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue