diff --git a/src/app/core-ui/work-context-menu/work-context-menu.component.ts b/src/app/core-ui/work-context-menu/work-context-menu.component.ts index b6a5543f6..e80ac274a 100644 --- a/src/app/core-ui/work-context-menu/work-context-menu.component.ts +++ b/src/app/core-ui/work-context-menu/work-context-menu.component.ts @@ -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 { - // 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 { diff --git a/src/app/core/share/share.service.ts b/src/app/core/share/share.service.ts index 4b7cc2407..7ac840f4d 100644 --- a/src/app/core/share/share.service.ts +++ b/src/app/core/share/share.service.ts @@ -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; + // 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 { + // 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',