dsh-mobile 0.8.7 → 0.8.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -3864,22 +3864,46 @@ window.__ModuleLoader__.load({
3864
3864
  return true;
3865
3865
  }
3866
3866
  /**
3867
+ * Whether the App's background service reports that it will actually post a
3868
+ * task-completion notification. All three facts must be explicit: the service
3869
+ * is enabled, its event stream is connected, and Android currently allows the
3870
+ * task channel to post (`canPost`: notifications enabled and channel importance
3871
+ * not NONE). The App records `notify:blocked` and does not touch
3872
+ * `connected`/`lastEventAt` when `canPost` is false, so treating enabled+
3873
+ * connected alone as a promise would let the page yield to a silent App and
3874
+ * drop the notification entirely.
3875
+ */
3876
+ function backgroundNotificationsWillPost(report) {
3877
+ if (report === null || typeof report !== "object" || Array.isArray(report)) return false;
3878
+ const record = report;
3879
+ return record.enabled === true && record.connected === true && record.canPost === true;
3880
+ }
3881
+ /**
3867
3882
  * Deliver one notification through the native bridge when available.
3868
3883
  * Returns false when there is no bridge (desktop browsers keep their own
3869
3884
  * UI) so callers can fall back; a denial flips the module sticky flag via
3870
3885
  * onDenied for the session-lifetime nag guard.
3886
+ *
3887
+ * For task-completion notifications the page first asks the App whether its
3888
+ * background service will post the same event. When it will, the page yields:
3889
+ * one completed turn then produces exactly one notification, authored by the
3890
+ * plugin's Host-side event text and rendered by the App. Old Apps (no
3891
+ * `notification.background.get` report, no `connected` state, or no `canPost`
3892
+ * state) keep the previous behavior and the page still notifies.
3871
3893
  */
3872
3894
  function fireTaskNotifyEvent(event, onDenied) {
3873
3895
  const bridge = readNativeBridge();
3874
3896
  if (bridge === void 0) return false;
3897
+ const deliver = () => bridge.invoke("notification.notify", {
3898
+ title: event.title,
3899
+ body: event.body,
3900
+ tag: event.tag,
3901
+ ...event.navigate === void 0 ? {} : { navigate: event.navigate }
3902
+ });
3875
3903
  Promise.resolve().then(() => bridge.capabilities()).then((capabilities) => {
3876
3904
  if (!capabilities.includes("notification.notify")) return;
3877
- return bridge.invoke("notification.notify", {
3878
- title: event.title,
3879
- body: event.body,
3880
- tag: event.tag,
3881
- ...event.navigate === void 0 ? {} : { navigate: event.navigate }
3882
- });
3905
+ if (event.kind !== "done" || !capabilities.includes("notification.background.get")) return deliver();
3906
+ return bridge.invoke("notification.background.get", {}).then((report) => backgroundNotificationsWillPost(report) ? void 0 : deliver(), () => deliver());
3883
3907
  }).then(() => void 0, (error) => {
3884
3908
  const code = typeof error === "object" && error !== null && "code" in error ? String(error.code) : "";
3885
3909
  if ((code === "permission_denied" || code === "denied") && onDenied !== void 0) onDenied();
@@ -4281,6 +4305,182 @@ window.__ModuleLoader__.load({
4281
4305
  }
4282
4306
  };
4283
4307
  }
4308
+ // src/mobile-path-copy.ts
4309
+ /** Tap-to-copy for the stock preview header path on touch devices.
4310
+ *
4311
+ * Taps land on `PathLabel`'s nested directory/name spans, so the titled owner is
4312
+ * resolved through `closest()`; the changes-review file switcher keeps its own tap.
4313
+ */
4314
+ /** The repository's shared touch predicate; kept identical to `TOUCH_PRIMARY_QUERY` (mobile-layout.ts)
4315
+ * and `BROWSER_TOUCH_PRIMARY_QUERY` (native-mobile.ts) by a source drift test. */
4316
+ const PATH_COPY_TOUCH_QUERY = "(hover: none), (pointer: coarse)";
4317
+ /** Stock-DSH element whose `title` carries the path shown to the user: the preview
4318
+ * header path bar. The presented-file card is deliberately NOT a target — its tap
4319
+ * stays a pure open action, so opening a file never replaces the clipboard. */
4320
+ const PATH_COPY_TARGET_SELECTOR = "[data-textpreview-path]";
4321
+ /** Replace `{path}` in a localized toast template with the copied value, literally.
4322
+ *
4323
+ * The replacement is a function because a string replacement would interpret `$&`,
4324
+ * `` $` `` and `$'` inside the path as replacement patterns, which would show a
4325
+ * mangled message for a legal file name that contains them. */
4326
+ function formatPathCopyMessage(template, path) {
4327
+ return template.replaceAll("{path}", () => path);
4328
+ }
4329
+ /** The `title` of a copy target, verbatim — trailing spaces are legal in file names —
4330
+ * or `undefined` when the attribute is absent or blank (blankness judged after trimming). */
4331
+ function pathCopyText(element) {
4332
+ const title = element.getAttribute("title");
4333
+ const blank = title?.trim();
4334
+ return title !== null && blank !== void 0 && blank !== "" ? title : void 0;
4335
+ }
4336
+ function isClosestCarrier(value) {
4337
+ return typeof value?.closest === "function";
4338
+ }
4339
+ /** Resolve the copy target owning the tapped node: the titled ancestor outside the review scope.
4340
+ * @param target - an event target; non-elements resolve to `null`.
4341
+ * @returns the element whose `title` to copy, or `null` when the tap is not a copy target. */
4342
+ function pathCopyOwner(target) {
4343
+ if (!isClosestCarrier(target)) return null;
4344
+ const owner = target.closest(PATH_COPY_TARGET_SELECTOR);
4345
+ if (owner === null) return null;
4346
+ if (target.closest("[data-changes-review]") !== null) return null;
4347
+ return pathCopyText(owner) === void 0 ? null : owner;
4348
+ }
4349
+ /** Copy through the API, falling back to `execCopy`; resolves, never rejects.
4350
+ * @param text - the value to place on the clipboard.
4351
+ * @param io - the clipboard operations to use.
4352
+ * @returns whether the value reached the clipboard by either route. */
4353
+ async function copyPathText(text, io) {
4354
+ try {
4355
+ await io.writeText(text);
4356
+ return true;
4357
+ } catch {
4358
+ try {
4359
+ return io.execCopy(text);
4360
+ } catch {
4361
+ return false;
4362
+ }
4363
+ }
4364
+ }
4365
+ /** Build the browser clipboard adapter for one document.
4366
+ *
4367
+ * `writeText` delegates to `navigator.clipboard` when present and rejects otherwise, so
4368
+ * {@link copyPathText} routes to the fallback. `execCopy` copies through an off-screen but
4369
+ * rendered readonly textarea — `display:none` is not a reliable copy source — and restores
4370
+ * focus, selection, and scroll on every path.
4371
+ * @param document - the document that will host the fallback textarea.
4372
+ * @returns the adapter bound to that document and this window's navigator. */
4373
+ function domCopyIo(document) {
4374
+ return {
4375
+ writeText: (text) => {
4376
+ const clipboard = globalThis.navigator?.clipboard;
4377
+ if (clipboard?.writeText === void 0) return Promise.reject(/* @__PURE__ */ new Error("clipboard api unavailable"));
4378
+ return clipboard.writeText(text);
4379
+ },
4380
+ execCopy: (value) => {
4381
+ const activeElement = document.activeElement;
4382
+ const selection = document.getSelection();
4383
+ const savedRanges = selection === null ? [] : Array.from({ length: selection.rangeCount }, (_, at) => selection.getRangeAt(at).cloneRange());
4384
+ const root = document.documentElement;
4385
+ const scrollOwners = [root];
4386
+ for (let node = activeElement; node !== null && node !== root; node = node.parentElement ?? null) scrollOwners.push(node);
4387
+ const savedScroll = scrollOwners.map((owner) => ({
4388
+ owner,
4389
+ top: owner.scrollTop,
4390
+ left: owner.scrollLeft
4391
+ }));
4392
+ const area = document.createElement("textarea");
4393
+ area.value = value;
4394
+ area.readOnly = true;
4395
+ area.style.position = "fixed";
4396
+ area.style.left = "-9999px";
4397
+ document.body.appendChild(area);
4398
+ try {
4399
+ area.select();
4400
+ return document.execCommand("copy");
4401
+ } catch {
4402
+ return false;
4403
+ } finally {
4404
+ document.body.removeChild(area);
4405
+ if (selection !== null) {
4406
+ selection.removeAllRanges();
4407
+ for (const range of savedRanges) selection.addRange(range);
4408
+ }
4409
+ if (activeElement !== null) activeElement.focus?.({ preventScroll: true });
4410
+ for (const { owner, top, left } of savedScroll) {
4411
+ owner.scrollTop = top;
4412
+ owner.scrollLeft = left;
4413
+ }
4414
+ }
4415
+ }
4416
+ };
4417
+ }
4418
+ /** Host the copy confirmation toast on one document.
4419
+ * @param document - the document whose body hosts the toast.
4420
+ * @returns the toast handle. */
4421
+ function createPathCopyToast(document) {
4422
+ const node = document.createElement("div");
4423
+ node.className = "dsh-mobile-path-toast";
4424
+ node.setAttribute("role", "status");
4425
+ node.setAttribute("aria-live", "polite");
4426
+ node.lang = document.documentElement.lang;
4427
+ document.body.appendChild(node);
4428
+ let timer;
4429
+ return {
4430
+ node,
4431
+ show(message) {
4432
+ node.textContent = message;
4433
+ node.setAttribute("data-visible", "true");
4434
+ if (timer !== void 0) clearTimeout(timer);
4435
+ timer = setTimeout(() => {
4436
+ node.removeAttribute("data-visible");
4437
+ timer = void 0;
4438
+ }, 2200);
4439
+ },
4440
+ dispose() {
4441
+ if (timer !== void 0) {
4442
+ clearTimeout(timer);
4443
+ timer = void 0;
4444
+ }
4445
+ node.removeAttribute("data-visible");
4446
+ document.body.removeChild(node);
4447
+ }
4448
+ };
4449
+ }
4450
+ /** Install the delegated tap-to-copy listener and its toast.
4451
+ *
4452
+ * The listener neither prevents nor stops propagation: the preview header keeps its
4453
+ * own focus behavior, and taps on other elements keep their own actions. Disposal
4454
+ * stops new copies, cancels the fallback of a still-pending write, and drops a
4455
+ * completion that lands after disposal.
4456
+ * @param deps - document, touch gate, localized templates, optional clipboard adapter.
4457
+ * @returns the disposer. */
4458
+ function installMobilePathCopy(deps) {
4459
+ const source = deps.io ?? domCopyIo(deps.document);
4460
+ const toast = createPathCopyToast(deps.document);
4461
+ let disposed = false;
4462
+ const io = {
4463
+ writeText: (text) => source.writeText(text),
4464
+ execCopy: (value) => disposed ? false : source.execCopy(value)
4465
+ };
4466
+ const onClick = (event) => {
4467
+ if (disposed || !deps.isTouchPrimary()) return;
4468
+ const owner = pathCopyOwner(event.target);
4469
+ if (owner === null) return;
4470
+ const path = pathCopyText(owner);
4471
+ if (path === void 0) return;
4472
+ copyPathText(path, io).then((ok) => {
4473
+ if (disposed) return;
4474
+ toast.show(formatPathCopyMessage(ok ? deps.labels.copied : deps.labels.failed, path));
4475
+ });
4476
+ };
4477
+ deps.document.addEventListener("click", onClick);
4478
+ return () => {
4479
+ disposed = true;
4480
+ deps.document.removeEventListener("click", onClick);
4481
+ toast.dispose();
4482
+ };
4483
+ }
4284
4484
  // src/native-mobile.ts
4285
4485
  /**
4286
4486
  * Viewport below which the injected chrome becomes an overlay drawer: the
@@ -4300,6 +4500,12 @@ window.__ModuleLoader__.load({
4300
4500
  the query restores the fixed scrim, and its more specific [hidden] rule
4301
4501
  keeps winning there. */
4302
4502
  .dsh-native-mobile-backdrop,.dsh-mobile-branch-toast,.dsh-mobile-media-toast { display:none; }
4503
+ /* The path-copy toast confirms a clipboard write whose gate is the touch
4504
+ predicate, not viewport width, so its box lives outside the overlay query:
4505
+ a landscape phone or tablet copy must stay visible. The bottom slot keeps
4506
+ it clear of the branch and media toasts' shared top-center position. */
4507
+ .dsh-mobile-path-toast { display:none; }
4508
+ .dsh-mobile-path-toast[data-visible="true"] { display:block; position:fixed; z-index:330; left:50%; transform:translateX(-50%); bottom:max(12px,env(safe-area-inset-bottom)); max-width:calc(100vw - 32px); box-sizing:border-box; padding:7px 14px; border:1px solid rgb(15 23 42 / 10%); border-radius:999px; background:rgb(15 23 42 / 92%); color:#fff; font-size:13px; line-height:20px; text-align:center; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; pointer-events:none; }
4303
4509
  /* The content-font preference may exceed Safari's focused-editable minimum.
4304
4510
  The html-level class exists on every non-desktop mobile page, so it also
4305
4511
  covers body-level portals (model picker search) the center column never
@@ -5496,6 +5702,14 @@ window.__ModuleLoader__.load({
5496
5702
  mediaToastTimer = 0;
5497
5703
  }, 2200);
5498
5704
  };
5705
+ const disposePathCopy = installMobilePathCopy({
5706
+ document,
5707
+ isTouchPrimary: () => window.matchMedia(PATH_COPY_TOUCH_QUERY).matches,
5708
+ labels: {
5709
+ copied: label("Copiato: {path}", "Copied: {path}", "已复制:{path}"),
5710
+ failed: label("Impossibile copiare il percorso.", "Could not copy the path.", "无法复制路径。")
5711
+ }
5712
+ });
5499
5713
  let mediaRequestGeneration = 0;
5500
5714
  let disposed = false;
5501
5715
  let boundComposer = null;
@@ -6123,6 +6337,7 @@ window.__ModuleLoader__.load({
6123
6337
  disposed = true;
6124
6338
  stopStockBack();
6125
6339
  sidebarScrollMemory.dispose();
6340
+ disposePathCopy();
6126
6341
  disposeTaskWatcher();
6127
6342
  mediaRequestGeneration += 1;
6128
6343
  mediaPickerAbortController.abort();
@@ -7115,6 +7330,124 @@ window.__ModuleLoader__.load({
7115
7330
  }
7116
7331
  }, copy.reset)));
7117
7332
  }
7333
+ // src/composer-collapse.ts
7334
+ /**
7335
+ * Storage key for the opt-in switch.
7336
+ *
7337
+ * The setting row lives in the client bundle while the tracker lives in the
7338
+ * layout bundle, and tsdown.config.ts emits those as two independent CJS modules
7339
+ * that each inline this file. A module-level singleton therefore cannot carry the
7340
+ * value across: the row would flip its own copy while the layout kept the
7341
+ * boot-time value, and the toggle would only apply after a reload. The persisted
7342
+ * flag plus the two events below are the shared state instead, mirroring the
7343
+ * session-prefetch opt-in.
7344
+ */
7345
+ const COMPOSER_COLLAPSE_STORAGE_KEY = "dsh-mobile.composer-collapse.enabled";
7346
+ const COMPOSER_COLLAPSE_EVENT = "dsh-mobile:composer-collapse-change";
7347
+ /**
7348
+ * Last value written in this page when storage refused the write.
7349
+ *
7350
+ * This is a per-bundle fallback for the blocked-storage case only: it is not the
7351
+ * cross-bundle transport (that is the persisted flag plus the events above). It
7352
+ * exists so a switch that cannot be persisted still reads back as flipped in the
7353
+ * same page, instead of the row snapping back while the layout obeys the event.
7354
+ */
7355
+ let unpersistedEnabled;
7356
+ /** Read the persisted opt-in. Defaults to off; unreadable storage keeps it off. */
7357
+ function readComposerCollapseEnabled(storage) {
7358
+ try {
7359
+ const stored = (storage === void 0 ? globalThis.localStorage : storage)?.getItem("dsh-mobile.composer-collapse.enabled") ?? null;
7360
+ if (stored !== null) return stored === "true";
7361
+ return storage === void 0 ? unpersistedEnabled ?? false : false;
7362
+ } catch {
7363
+ return storage === void 0 ? unpersistedEnabled ?? false : false;
7364
+ }
7365
+ }
7366
+ /** Persist the opt-in and notify the layout bundle in the same page. */
7367
+ function writeComposerCollapseEnabled(enabled, storage) {
7368
+ try {
7369
+ const resolved = storage === void 0 ? globalThis.localStorage : storage;
7370
+ if (resolved === null) throw new Error("storage unavailable");
7371
+ resolved.setItem(COMPOSER_COLLAPSE_STORAGE_KEY, String(enabled));
7372
+ if (storage === void 0) unpersistedEnabled = void 0;
7373
+ } catch {
7374
+ if (storage === void 0) unpersistedEnabled = enabled;
7375
+ }
7376
+ try {
7377
+ globalThis.dispatchEvent?.(new CustomEvent(COMPOSER_COLLAPSE_EVENT, { detail: { enabled } }));
7378
+ } catch {}
7379
+ }
7380
+ /**
7381
+ * Subscribe to opt-in changes coming from either bundle. The listener is not
7382
+ * called with the initial value; read it once with readComposerCollapseEnabled().
7383
+ */
7384
+ function subscribeComposerCollapseEnabled(listener) {
7385
+ const onStorage = (event) => {
7386
+ if (event.key !== null && event.key !== "dsh-mobile.composer-collapse.enabled") return;
7387
+ listener(readComposerCollapseEnabled());
7388
+ };
7389
+ const onCustom = (event) => {
7390
+ const detail = event.detail;
7391
+ listener(detail?.enabled ?? readComposerCollapseEnabled());
7392
+ };
7393
+ globalThis.addEventListener?.("storage", onStorage);
7394
+ globalThis.addEventListener?.(COMPOSER_COLLAPSE_EVENT, onCustom);
7395
+ return () => {
7396
+ globalThis.removeEventListener?.("storage", onStorage);
7397
+ globalThis.removeEventListener?.(COMPOSER_COLLAPSE_EVENT, onCustom);
7398
+ };
7399
+ }
7400
+ // src/composer-collapse-settings.ts
7401
+ const COMPOSER_COLLAPSE_COPY = {
7402
+ en: {
7403
+ title: "Reading mode: collapse composer",
7404
+ desc: "Collapse the composer editor while scrolling down a long conversation; tap it, focus it, or scroll back up to restore. It never collapses while a draft or a running reply is present.",
7405
+ on: "On",
7406
+ off: "Off"
7407
+ },
7408
+ zh: {
7409
+ title: "阅读模式:收起输入栏",
7410
+ desc: "向下滚动阅读长会话时收起输入框编辑区;点按、聚焦或向上回滚即恢复。有草稿或回复生成中时不会收起。",
7411
+ on: "已开启",
7412
+ off: "已关闭"
7413
+ },
7414
+ it: {
7415
+ title: "Modalità lettura: comprimi il campo",
7416
+ desc: "Comprime l’editor del campo mentre scorri una conversazione lunga; toccalo, mettilo a fuoco o scorri verso l’alto per ripristinarlo. Non si comprime con una bozza o una risposta in corso.",
7417
+ on: "Attivo",
7418
+ off: "Disattivo"
7419
+ }
7420
+ };
7421
+ /** Browser-only settings row; toggling it only persists the flag. */
7422
+ function ComposerCollapseSettings({ locale, read, write, subscribe }) {
7423
+ const readEnabled = read ?? (() => readComposerCollapseEnabled());
7424
+ const writeEnabled = write ?? ((enabled) => {
7425
+ writeComposerCollapseEnabled(enabled);
7426
+ });
7427
+ const enabled = (0, react.useSyncExternalStore)(subscribe ?? ((listener) => subscribeComposerCollapseEnabled(listener)), readEnabled, () => false);
7428
+ const copy = COMPOSER_COLLAPSE_COPY[locale];
7429
+ return (0, react.createElement)("label", {
7430
+ className: "dsh-mobile-settings_row",
7431
+ lang: locale === "zh" ? "zh-CN" : locale,
7432
+ "data-dsh-mobile-composer-collapse": enabled ? "on" : "off"
7433
+ }, (0, react.createElement)("div", { className: "dsh-mobile-settings_rowText" }, (0, react.createElement)("div", { className: "dsh-mobile-settings_title" }, copy.title), (0, react.createElement)("div", {
7434
+ className: "dsh-mobile-settings_desc",
7435
+ "aria-live": "polite"
7436
+ }, copy.desc)), (0, react.createElement)("input", {
7437
+ type: "checkbox",
7438
+ role: "switch",
7439
+ className: "dsh-mobile-settings_switchInput",
7440
+ checked: enabled,
7441
+ "aria-label": copy.title,
7442
+ "aria-checked": enabled ? "true" : "false",
7443
+ onChange: (event) => {
7444
+ writeEnabled(event.currentTarget.checked);
7445
+ }
7446
+ }), (0, react.createElement)("span", {
7447
+ className: "dsh-mobile-settings_selector",
7448
+ "aria-hidden": "true"
7449
+ }, enabled ? copy.on : copy.off));
7450
+ }
7118
7451
  // src/mobile-display-scale.ts
7119
7452
  /** Native App page scaling in DSH General settings, independent from local typography. */
7120
7453
  /** Validate the native reply before displaying or reusing its values in a request. */
@@ -18004,6 +18337,11 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
18004
18337
  preference: localFont.preference,
18005
18338
  locale: selectedMobileControlLocale()
18006
18339
  }));
18340
+ const removeComposerCollapse = ctx.slots.register({
18341
+ name: "settings.general.item",
18342
+ id: "dsh-mobile-composer-collapse",
18343
+ order: 15
18344
+ }, () => (0, react.createElement)(ComposerCollapseSettings, { locale: selectedMobileControlLocale() }));
18007
18345
  const removeDisplayScale = ctx.slots.register({
18008
18346
  name: "settings.general.item",
18009
18347
  id: "dsh-mobile-display-scale",
@@ -18029,6 +18367,7 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
18029
18367
  removeTaskNotifications();
18030
18368
  removeSwitchComputer();
18031
18369
  removeDisplayScale();
18370
+ removeComposerCollapse();
18032
18371
  removeFontSize();
18033
18372
  };
18034
18373
  });
@@ -18150,6 +18489,7 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
18150
18489
  exports.parseMobileExtensionManifest = parseMobileExtensionManifest;
18151
18490
  exports.parseMobileRemoteProvider = parseMobileRemoteProvider;
18152
18491
  exports.publishAuthoritativeExtensionIds = publishAuthoritativeExtensionIds;
18492
+ exports.readComposerCollapseEnabled = readComposerCollapseEnabled;
18153
18493
  exports.reconcileRemovedExtensions = reconcileRemovedExtensions;
18154
18494
  exports.registerUniqueDisposable = registerUniqueDisposable;
18155
18495
  exports.renderDiagnosticPayloadSafely = renderDiagnosticPayloadSafely;
@@ -18162,6 +18502,7 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
18162
18502
  exports.startLifecycleRefreshScheduler = startLifecycleRefreshScheduler;
18163
18503
  exports.trustAuthenticatedGatewayConnection = trustAuthenticatedGatewayConnection;
18164
18504
  exports.validateDiagnosticChecks = validateDiagnosticChecks;
18505
+ exports.writeComposerCollapseEnabled = writeComposerCollapseEnabled;
18165
18506
  return module.exports;
18166
18507
  }
18167
18508
  });