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:
johannesjo 2026-01-21 17:09:57 +01:00
parent 09d86d8afb
commit 806dbc2dc3
2 changed files with 67 additions and 65 deletions

View file

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

View file

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