mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-22 18:30:09 +00:00
fix(share): prevent iOS share sheet from reopening on dismiss
On iOS, dismissing the native share sheet by tapping the background
would cause it to reopen immediately. Two issues were fixed:
1. The Capacitor Share plugin on iOS throws {errorMessage: "Share canceled"}
but the code only checked for error.name === 'AbortError'. This caused
the cancellation to not be detected, falling through to try the Web
Share API as a fallback, opening a second share dialog.
2. Moved the iOS share guard from component level to ShareService. The
component-level guard didn't work because WorkContextMenuComponent is
inside ng-template matMenuContent, so it gets destroyed when the menu
closes, losing the guard state.
This commit is contained in:
parent
09d86d8afb
commit
806dbc2dc3
2 changed files with 67 additions and 65 deletions
|
|
@ -8,7 +8,6 @@ import {
|
|||
} from '@angular/core';
|
||||
import { WorkContextType } from '../../features/work-context/work-context.model';
|
||||
import { T } from 'src/app/t.const';
|
||||
import { IS_IOS } from '../../util/is-ios';
|
||||
import { TODAY_TAG } from '../../features/tag/tag.const';
|
||||
import { DialogConfirmComponent } from '../../ui/dialog-confirm/dialog-confirm.component';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
|
|
@ -58,7 +57,6 @@ export class WorkContextMenuComponent implements OnInit {
|
|||
isForProject: boolean = true;
|
||||
base: string = 'project';
|
||||
shareSupport: ShareSupport = 'none';
|
||||
private _isShareInProgress = false;
|
||||
|
||||
// TODO: Skipped for migration because:
|
||||
// Accessor inputs cannot be migrated as they are too complex.
|
||||
|
|
@ -135,73 +133,53 @@ export class WorkContextMenuComponent implements OnInit {
|
|||
protected readonly INBOX_PROJECT = INBOX_PROJECT;
|
||||
|
||||
async shareTasksAsMarkdown(): Promise<void> {
|
||||
// Guard against concurrent share operations
|
||||
if (this._isShareInProgress) {
|
||||
const { status, markdown, contextTitle } =
|
||||
await this._markdownService.getMarkdownForContext(
|
||||
this.contextId,
|
||||
this.isForProject,
|
||||
);
|
||||
|
||||
if (status === 'empty' || !markdown) {
|
||||
this._snackService.open(T.GLOBAL_SNACK.NO_TASKS_TO_COPY);
|
||||
return;
|
||||
}
|
||||
|
||||
this._isShareInProgress = true;
|
||||
const shareResult = await this._shareService.shareText({
|
||||
title: contextTitle ?? 'Super Productivity',
|
||||
text: markdown,
|
||||
});
|
||||
|
||||
try {
|
||||
const { status, markdown, contextTitle } =
|
||||
await this._markdownService.getMarkdownForContext(
|
||||
this.contextId,
|
||||
this.isForProject,
|
||||
);
|
||||
|
||||
if (status === 'empty' || !markdown) {
|
||||
this._snackService.open(T.GLOBAL_SNACK.NO_TASKS_TO_COPY);
|
||||
return;
|
||||
}
|
||||
|
||||
const shareResult = await this._shareService.shareText({
|
||||
title: contextTitle ?? 'Super Productivity',
|
||||
text: markdown,
|
||||
});
|
||||
|
||||
if (shareResult === 'shared') {
|
||||
if (this.shareSupport === 'none') {
|
||||
const support = await this._shareService.getShareSupport();
|
||||
this._setShareSupport(support);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (shareResult === 'cancelled') {
|
||||
return;
|
||||
}
|
||||
|
||||
const didCopy = await this._markdownService.copyMarkdownText(markdown);
|
||||
if (didCopy) {
|
||||
if (shareResult === 'unavailable') {
|
||||
this._snackService.open(T.GLOBAL_SNACK.SHARE_UNAVAILABLE_FALLBACK);
|
||||
this._setShareSupport('none');
|
||||
} else if (shareResult === 'failed') {
|
||||
this._snackService.open(T.GLOBAL_SNACK.SHARE_FAILED_FALLBACK);
|
||||
this._setShareSupport('none');
|
||||
} else {
|
||||
this._snackService.open(T.GLOBAL_SNACK.COPY_TO_CLIPPBOARD);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this._snackService.open({
|
||||
msg: T.GLOBAL_SNACK.SHARE_FAILED,
|
||||
type: 'ERROR',
|
||||
});
|
||||
this._setShareSupport('none');
|
||||
} finally {
|
||||
// iOS-specific: Delay clearing flag to prevent re-trigger from focus events
|
||||
// On iOS, dismissing the native share sheet fires window focus events
|
||||
// that can cause the method to be called again
|
||||
if (IS_IOS) {
|
||||
setTimeout(() => {
|
||||
this._isShareInProgress = false;
|
||||
}, 500);
|
||||
} else {
|
||||
this._isShareInProgress = false;
|
||||
if (shareResult === 'shared') {
|
||||
if (this.shareSupport === 'none') {
|
||||
const support = await this._shareService.getShareSupport();
|
||||
this._setShareSupport(support);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (shareResult === 'cancelled') {
|
||||
return;
|
||||
}
|
||||
|
||||
const didCopy = await this._markdownService.copyMarkdownText(markdown);
|
||||
if (didCopy) {
|
||||
if (shareResult === 'unavailable') {
|
||||
this._snackService.open(T.GLOBAL_SNACK.SHARE_UNAVAILABLE_FALLBACK);
|
||||
this._setShareSupport('none');
|
||||
} else if (shareResult === 'failed') {
|
||||
this._snackService.open(T.GLOBAL_SNACK.SHARE_FAILED_FALLBACK);
|
||||
this._setShareSupport('none');
|
||||
} else {
|
||||
this._snackService.open(T.GLOBAL_SNACK.COPY_TO_CLIPPBOARD);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this._snackService.open({
|
||||
msg: T.GLOBAL_SNACK.SHARE_FAILED,
|
||||
type: 'ERROR',
|
||||
});
|
||||
this._setShareSupport('none');
|
||||
}
|
||||
|
||||
async unplanAllTodayTasks(): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Injectable, inject } from '@angular/core';
|
|||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { Directory, Filesystem } from '@capacitor/filesystem';
|
||||
import { IS_NATIVE_PLATFORM } from '../../util/is-native-platform';
|
||||
import { IS_IOS } from '../../util/is-ios';
|
||||
import { SnackService } from '../snack/snack.service';
|
||||
import {
|
||||
ShareCanvasImageParams,
|
||||
|
|
@ -35,6 +36,13 @@ interface ShareParams {
|
|||
export class ShareService {
|
||||
private _shareSupportPromise?: Promise<SharePlatformUtil.ShareSupport>;
|
||||
|
||||
// iOS: Timestamp-based debounce to prevent re-trigger when share sheet
|
||||
// dismissal fires synthetic click events that reopen the menu.
|
||||
// The component-level guard doesn't work because the menu component
|
||||
// is destroyed when closed (inside ng-template matMenuContent).
|
||||
private _lastNativeShareAttemptMs = 0;
|
||||
private readonly _IOS_SHARE_DEBOUNCE_MS = 1000;
|
||||
|
||||
private _snackService = inject(SnackService);
|
||||
private _matDialog = inject(MatDialog);
|
||||
|
||||
|
|
@ -202,6 +210,16 @@ export class ShareService {
|
|||
* Public API for dialog component.
|
||||
*/
|
||||
async tryNativeShare(payload: SharePayload): Promise<ShareResult> {
|
||||
// iOS: Debounce to prevent re-trigger when share sheet dismissal
|
||||
// fires synthetic click events that reopen the menu
|
||||
if (IS_IOS) {
|
||||
const now = Date.now();
|
||||
if (now - this._lastNativeShareAttemptMs < this._IOS_SHARE_DEBOUNCE_MS) {
|
||||
return { success: false, error: 'Share debounced' };
|
||||
}
|
||||
this._lastNativeShareAttemptMs = now;
|
||||
}
|
||||
|
||||
const normalized = ShareTextUtil.ensureShareText(payload);
|
||||
|
||||
const capacitorShare = SharePlatformUtil.getCapacitorSharePlugin();
|
||||
|
|
@ -220,8 +238,14 @@ export class ShareService {
|
|||
usedNative: true,
|
||||
target: 'native',
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
} catch (error: unknown) {
|
||||
// Check for AbortError (standard) or Capacitor iOS error format
|
||||
const err = error as { name?: string; errorMessage?: string; message?: string };
|
||||
const isCancelled =
|
||||
err?.name === 'AbortError' ||
|
||||
/cancel/i.test(err?.errorMessage ?? '') ||
|
||||
/cancel/i.test(err?.message ?? '');
|
||||
if (isCancelled) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Share cancelled',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue