Files
webmail/lib/deep-link-handoff.ts
T
Linus Rath d9ffe8a76e fix: reach directly shared files and keep others' files on a failed copy
Fixes from the review of the Files work (1cf9944b..a9a25b94).

- A file shared on its own, or one below a folder its owner took back,
  could not be reached. Once Stalwart 1.0's placeholder folders were
  left out it became a root of "Shared with me", which listed folders
  only, and search opened it on the drive root. 0.16 lists such files
  as roots too. Each owner's files shared on their own are now listed
  on one page under "Shared with me" ("Files from <name>"), where they
  open, preview and download like any other file and nothing can be
  added; two of the same name get a number. Search opens a shared hit
  where it is listed, and links into a shared drive name the node by id
  (`/files?shared=<owner>:<id>`), so a shared folder or page also
  survives a reload. A Files page that is already open takes the link
  from search directly, since Next keeps it mounted when only the query
  changes.
- A copy that fails part-way destroys only the nodes it made, the
  deepest first and without `onDestroyRemoveChildren`, one call per
  level because 0.16 refuses a folder destroyed in the same call as
  its children. It used to destroy the new folder with everything
  below it, so whatever someone put there during a long copy was
  deleted for good. A folder holding such nodes now stays, and the
  error says part of the copy was left in place.
- The document editor opens read-only on 0.16 for a sharee without the
  rename right. 0.16.6 to 0.16.23 refuse any change a sharee makes
  without Modify, so every save of such a document failed with a 500.
  1.0, told apart by its `maxFileNodeDepth`, still asks for
  `mayModifyContent` alone.
- Ctrl+C with only symlinks selected empties the clipboard instead of
  keeping an earlier cut for the next paste.
- A paste reads each account's file tree once, not once per pasted
  item for the listing and 1.0's depth check.
- The Stalwart bundle's README says that on older 0.16 releases and
  the 1.0 pre-releases a failed update also drops the cached copy, and
  the next restart or reload of the Applications downloads it again,
  not only a later `UpdateApps`. The static README says a restart
  applies the CORS setting on the current 1.0 pre-releases, where
  `ReloadSettings` fails with the stock spam-filter rules.
2026-09-25 22:16:19 +02:00

107 lines
4.1 KiB
TypeScript

import type { AppSurface } from '@/lib/deep-links';
/**
* Handoff for a deep link that has to survive a route change (#733).
*
* The Pro shell owns a single route (`/pro`) and renders each surface inside a
* tab, so a permalink like `/mail/message/<id>` can't reach the surface as a
* route param: ProInterfaceRedirect intercepts it and replaces the URL. It
* parks the segments here, opens the right tab, and the surface picks them up
* on mount - exactly like `protocol-handlers/session` does for `mailto:`.
*
* Some links carry a query-string half too (`?account=` for calendar events,
* `?preview=` for files). Under `/pro` the address bar has no search string
* left by the time the surface parses the link, so it travels with the parked
* entry instead of being read back from `window.location`.
*
* Surfaces in the Pro shell stay mounted for the whole session, so a link that
* arrives *while the shell is already running* (an in-app link to
* `/settings/folders`, a `mailto:`-style handoff, ...) would be parked and
* never seen - the mount-time consume has long since run. For that case a
* mounted surface subscribes here: when a listener exists, `setPendingDeepLink`
* delivers the segments to it directly instead of parking them.
*
* Module state, deliberately: it must not survive a reload (the URL is gone by
* then, so re-applying it would reopen something the user already closed).
*/
export interface PendingDeepLink {
segments: string[];
/** Query-string half of the link (leading `?` included), when it has one. */
search?: string;
}
const pending = new Map<AppSurface, PendingDeepLink>();
type PendingDeepLinkListener = (segments: string[], search?: string) => void;
const listeners = new Map<AppSurface, Set<PendingDeepLinkListener>>();
export function setPendingDeepLink(surface: AppSurface, segments: string[], search?: string): void {
const live = listeners.get(surface);
if (live && live.size > 0) {
// A mounted surface is listening - deliver instead of parking, so links
// arriving while the Pro shell is already running still land.
// No trailing undefined: listeners predating the search half see the
// exact call shape they always did.
for (const listener of [...live]) {
if (search === undefined) listener(segments);
else listener(segments, search);
}
return;
}
pending.set(surface, { segments, search });
}
/**
* Hands a link to a surface that is mounted and listening, and says whether
* one was. Unlike setPendingDeepLink it never parks the link: without a
* listener the caller navigates instead, and the surface reads the link from
* the route when it mounts.
*/
export function deliverDeepLink(surface: AppSurface, segments: string[], search?: string): boolean {
const live = listeners.get(surface);
if (!live || live.size === 0) return false;
setPendingDeepLink(surface, segments, search);
return true;
}
/** Returns the parked link once, then forgets it. */
export function consumePendingDeepLinkEntry(surface: AppSurface): PendingDeepLink | null {
const entry = pending.get(surface);
if (!entry) return null;
pending.delete(surface);
return entry;
}
/** Segments-only variant for surfaces whose links never carry a search string. */
export function consumePendingDeepLink(surface: AppSurface): string[] | null {
return consumePendingDeepLinkEntry(surface)?.segments ?? null;
}
/**
* Live delivery for an already-mounted surface (the Pro shell keeps surfaces
* mounted for the whole session). Returns the unsubscribe function. While the
* Pro interface is on, only the embedded (Pro) instance of a surface should
* subscribe - during a cold-load redirect the standard instance briefly
* renders too and must not steal the link parked for the Pro instance that
* mounts right after.
*/
export function subscribePendingDeepLink(
surface: AppSurface,
listener: PendingDeepLinkListener,
): () => void {
let set = listeners.get(surface);
if (!set) {
set = new Set();
listeners.set(surface, set);
}
set.add(listener);
return () => {
set.delete(listener);
};
}
export function clearPendingDeepLinks(): void {
pending.clear();
}