feat(share): outline multi platform share functionality

This commit is contained in:
Johannes Millan 2025-10-22 13:19:14 +02:00
parent 242bc25ac4
commit 9574dd4f19
14 changed files with 1568 additions and 59 deletions

View file

@ -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;

View file

@ -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;

View file

@ -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 }),

View file

@ -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',

View 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.

View 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());
}
}

View 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>

View 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;
}

View 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();
}
}

View 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);
});
});
});

View 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;
}
}

View 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;
}

View 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');
});
});
});

View file

@ -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;
}
}
}