feat(notification): enrich notifications, resources are clicable
This commit is contained in:
@@ -45,6 +45,17 @@ export default ts.config(
|
||||
'no-undef': 'off'
|
||||
}
|
||||
},
|
||||
{
|
||||
// Auto-generated AsyncAPI DTOs (Modelina output). Empty
|
||||
// interfaces are legitimate for wire messages whose `data`
|
||||
// field is intentionally a no-fields object (pure-poke events
|
||||
// like `notification_received`). See
|
||||
// `docs/plan/templated-messages.md § Bus event is a pure poke`.
|
||||
files: ['src/lib/generated/message-bus/**/*.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-empty-object-type': 'off'
|
||||
}
|
||||
},
|
||||
{
|
||||
// `static/` holds vendored, verbatim assets (the delta-upload worker and
|
||||
// the wasm-bindgen hash glue) — lint them as the upstream ships them.
|
||||
|
||||
@@ -971,3 +971,33 @@ export const NOTIFICATION_KIND = {
|
||||
JOB_COMPLETED_FOR_YOU: 'job_completed_for_you',
|
||||
STORAGE_QUOTA_THRESHOLD: 'storage_quota_threshold'
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Payload shape for `share_granted` notifications — hand-mirror of
|
||||
* `crate::domain::entities::notification::SharegrantedPayload`.
|
||||
* `#[derive(ToSchema)]` on the Rust struct makes it authoritative;
|
||||
* this interface is a projection for FE type-narrowing until the
|
||||
* codebase adopts `openapi-typescript` for the REST surface.
|
||||
*
|
||||
* See `docs/plan/templated-messages.md § Making payloads a real
|
||||
* Rust struct` for the rationale — OpenAPI owns the payload; the
|
||||
* bus event is a pure poke.
|
||||
*/
|
||||
export interface SharegrantedPayload {
|
||||
granter_id: string;
|
||||
resource_type: string;
|
||||
resource_id: string;
|
||||
resource_name?: string;
|
||||
/** Storage path for `folder` / `file` kinds only. `undefined`
|
||||
* for drive / calendar / address_book / playlist. */
|
||||
resource_path?: string;
|
||||
/** FE-navigation hint for kinds whose `resource_id` isn't itself
|
||||
* a folder id. Populated for `drive` (the drive's root folder
|
||||
* id — the FE routes to `/files/{navigate_folder_id}` because
|
||||
* drives don't have a browsable URL of their own). Absent for
|
||||
* folder (uses `resource_id` directly), file (routes to
|
||||
* `/shared-with-me?file=`), and non-browsable kinds. */
|
||||
navigate_folder_id?: string;
|
||||
role: string;
|
||||
expires_at?: string;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
notifications as persistentNotifications,
|
||||
useNotifications
|
||||
} from '$lib/composables/useNotifications.svelte';
|
||||
import NotificationRow from '$lib/components/NotificationRow.svelte';
|
||||
import { errorToast } from '$lib/utils/errors';
|
||||
import { formatBytes } from '$lib/utils/format';
|
||||
|
||||
@@ -346,67 +347,6 @@
|
||||
const totalUnread = $derived(ui.unread + persistentNotifications.unread);
|
||||
const totalUnreadBadge = $derived(totalUnread > 99 ? '99+' : String(totalUnread));
|
||||
|
||||
/** Format the server-side `created_at` for a persistent row. */
|
||||
function formatPersistentTime(iso: string): string {
|
||||
try {
|
||||
return formatTime(new Date(iso).getTime());
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** Human summary for a persistent notification. Kind-specific
|
||||
* wording lives here so the DTO stays payload-agnostic. */
|
||||
function persistentSummary(row: { kind: string; payload: Record<string, unknown> }): string {
|
||||
switch (row.kind) {
|
||||
case 'share_granted': {
|
||||
const role = String(row.payload.role ?? 'a role');
|
||||
const resType = String(row.payload.resource_type ?? 'resource');
|
||||
return t(
|
||||
'notifications.persistent.share_granted',
|
||||
{ role, resType },
|
||||
`You were granted ${role} on a ${resType}.`
|
||||
);
|
||||
}
|
||||
case 'new_login_from_new_device':
|
||||
return t(
|
||||
'notifications.persistent.new_device_login',
|
||||
'A new device signed into your account.'
|
||||
);
|
||||
case 'job_completed_for_you': {
|
||||
const name = String(row.payload.name ?? row.payload.job_name ?? 'a job');
|
||||
return t('notifications.persistent.job_completed', { name }, `Job "${name}" finished.`);
|
||||
}
|
||||
case 'storage_quota_threshold':
|
||||
return t(
|
||||
'notifications.persistent.quota_threshold',
|
||||
'You are approaching your storage quota.'
|
||||
);
|
||||
default:
|
||||
return t(
|
||||
'notifications.persistent.generic',
|
||||
{ kind: row.kind },
|
||||
`Notification (${row.kind}).`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Icon for a persistent row's kind. Falls back to a generic bell. */
|
||||
function persistentIcon(kind: string): string {
|
||||
switch (kind) {
|
||||
case 'share_granted':
|
||||
return 'user-plus';
|
||||
case 'new_login_from_new_device':
|
||||
return 'shield-alt';
|
||||
case 'job_completed_for_you':
|
||||
return 'check-circle';
|
||||
case 'storage_quota_threshold':
|
||||
return 'database';
|
||||
default:
|
||||
return 'bell';
|
||||
}
|
||||
}
|
||||
|
||||
function openMobileSearch() {
|
||||
searchActive = true;
|
||||
requestAnimationFrame(() => searchInputEl?.focus());
|
||||
@@ -966,32 +906,7 @@
|
||||
></div>
|
||||
{/if}
|
||||
{#each persistentNotifications.items as row (row.id)}
|
||||
<div
|
||||
class="notif-item notif-item--{row.kind}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
data-testid="appshell-notif-persistent-item"
|
||||
aria-label={persistentSummary(row)}
|
||||
style:font-weight={row.read_at === null ? '500' : 'normal'}
|
||||
style:cursor="pointer"
|
||||
onclick={() => void persistentNotifications.markRead(row.id)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
void persistentNotifications.markRead(row.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span class="notif-item-icon">
|
||||
<Icon name={persistentIcon(row.kind)} />
|
||||
</span>
|
||||
<div class="notif-item-body">
|
||||
<div class="notif-item-text">{persistentSummary(row)}</div>
|
||||
<div class="notif-item-time">
|
||||
{formatPersistentTime(row.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NotificationRow {row} onactivate={() => (notifOpen = false)} />
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
<!--
|
||||
Single-file renderer for one persistent notification (bell).
|
||||
|
||||
Slice E's rendering path — reuses the AppShell bell's existing
|
||||
`.notif-item*` classes so it slots into the same dropdown as the
|
||||
transient toast rows. One switch on `row.kind`; a kind's block gets
|
||||
extracted to its own component only when it exceeds ~30 lines,
|
||||
needs local `$state`, or two kinds start sharing a sub-component.
|
||||
See `docs/plan/templated-messages.md § Rendering`.
|
||||
|
||||
Click semantics:
|
||||
- Row body click → navigate to the resource + mark-read
|
||||
- Anchor click (inner) → same navigation, `stopPropagation` so
|
||||
the outer click doesn't re-fire
|
||||
- Close button click → delete the row, `stopPropagation`
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import UserVignette from '$lib/components/UserVignette.svelte';
|
||||
import Icon from '$lib/icons/Icon.svelte';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
import type { Notification, SharegrantedPayload } from '$lib/api/types';
|
||||
import { NOTIFICATION_KIND } from '$lib/api/types';
|
||||
import { notifications } from '$lib/composables/useNotifications.svelte';
|
||||
|
||||
/** SvelteKit's `resolve()` is typed with compile-time route keys.
|
||||
* The bell's `href` is a runtime-computed path from the resource's
|
||||
* storage path, not a compile-time key — same pattern as
|
||||
* `AppShell::navHref`. Cast at one wrapper site rather than
|
||||
* scattering `@ts-expect-error` per callsite. */
|
||||
function runtimeResolve(href: string): string {
|
||||
// @ts-expect-error runtime-known path, not a literal typed route key
|
||||
return resolve(href);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
row: Notification;
|
||||
/** Called after the row-body click's mark-read/navigate — the
|
||||
* bell panel wraps this to close its dropdown on activation. */
|
||||
onactivate?: () => void;
|
||||
}
|
||||
let { row, onactivate }: Props = $props();
|
||||
|
||||
function formatPersistentTime(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleString();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function persistentIcon(kind: string): string {
|
||||
switch (kind) {
|
||||
case NOTIFICATION_KIND.SHARE_GRANTED:
|
||||
return 'user-plus';
|
||||
case NOTIFICATION_KIND.NEW_LOGIN_FROM_NEW_DEVICE:
|
||||
return 'shield-alt';
|
||||
case NOTIFICATION_KIND.JOB_COMPLETED_FOR_YOU:
|
||||
return 'check-circle';
|
||||
case NOTIFICATION_KIND.STORAGE_QUOTA_THRESHOLD:
|
||||
return 'database';
|
||||
default:
|
||||
return 'bell';
|
||||
}
|
||||
}
|
||||
|
||||
/** Best-effort resource link by kind:
|
||||
*
|
||||
* - `folder` → `/files/{id}` (SvelteKit's `/files/[...path]`
|
||||
* route resolves by UUID; the caller has Read on the shared
|
||||
* folder by construction of the grant).
|
||||
* - `file` → `/shared-with-me?file={id}` — the `/files/{id}`
|
||||
* route requires a FOLDER id AND access to the parent folder,
|
||||
* neither of which is guaranteed for a file-scoped grant.
|
||||
* `/shared-with-me` is the guaranteed-accessible home for
|
||||
* shares (every recipient of a `share_granted` sees their
|
||||
* row here), and its `?file=` deep link opens the inline
|
||||
* FileViewer.
|
||||
* - `drive` → `/files/{navigate_folder_id}` — the drive
|
||||
* itself has no browsable URL, but its root folder does.
|
||||
* Backend enrichment populates `navigate_folder_id` from
|
||||
* `Drive.root_folder_id`. If the lookup failed at ingest
|
||||
* (`navigate_folder_id` absent), the row falls back to
|
||||
* bold-text — better silent than a broken link.
|
||||
* - Other kinds (calendar, address_book, playlist) → `null`.
|
||||
* Not reachable via `/files/*`; the row renders the
|
||||
* resource name as bold text (not a link). Adding routing
|
||||
* for those = one branch here + backend enrichment for the
|
||||
* kind.
|
||||
*
|
||||
* `resource_path` on the payload is kept as a display-time
|
||||
* snapshot (used by hover / a11y labels) but not the anchor
|
||||
* target; the anchor uses `resource_id` (or
|
||||
* `navigate_folder_id`) so the link survives future renames +
|
||||
* moves. See `docs/plan/templated-messages.md § File
|
||||
* notification routing`. */
|
||||
function resourceHref(payload: unknown): string | null {
|
||||
if (typeof payload !== 'object' || payload === null) return null;
|
||||
const p = payload as Record<string, unknown>;
|
||||
const type = p.resource_type;
|
||||
const id = p.resource_id;
|
||||
if (typeof id !== 'string' || id.length === 0) return null;
|
||||
if (type === 'folder') return `/files/${id}`;
|
||||
if (type === 'file') return `/shared-with-me?file=${encodeURIComponent(id)}`;
|
||||
if (type === 'drive') {
|
||||
const navId = p.navigate_folder_id;
|
||||
if (typeof navId === 'string' && navId.length > 0) {
|
||||
return `/files/${navId}`;
|
||||
}
|
||||
// Enrichment failed at ingest — no browsable target.
|
||||
// Bold-text fallback in the template.
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function onBodyClick(): void {
|
||||
const href = resourceHref(row.payload);
|
||||
void notifications.markRead(row.id);
|
||||
onactivate?.();
|
||||
// `href` is a runtime path from the resource's storage
|
||||
// path — not a compile-time route key. `runtimeResolve`
|
||||
// wraps `resolve()` at a single site (lint rule looks for
|
||||
// the literal call, so we disable at the callsite).
|
||||
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
||||
if (href) void goto(runtimeResolve(href));
|
||||
}
|
||||
|
||||
function onBodyKeydown(e: KeyboardEvent): void {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onBodyClick();
|
||||
}
|
||||
}
|
||||
|
||||
function onAnchorClick(e: MouseEvent): void {
|
||||
// The <a> element handles navigation natively (letting the
|
||||
// browser go via <a href> preserves cmd-click / middle-click
|
||||
// semantics). Just mark-read + stop the outer click.
|
||||
e.stopPropagation();
|
||||
void notifications.markRead(row.id);
|
||||
onactivate?.();
|
||||
}
|
||||
|
||||
function onCloseClick(e: MouseEvent): void {
|
||||
e.stopPropagation();
|
||||
void notifications.delete(row.id);
|
||||
}
|
||||
|
||||
function onCloseKeydown(e: KeyboardEvent): void {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void notifications.delete(row.id);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="notif-item notif-item--{row.kind}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
data-testid="notification-row"
|
||||
style:font-weight={row.read_at === null ? '500' : 'normal'}
|
||||
style:cursor="pointer"
|
||||
onclick={onBodyClick}
|
||||
onkeydown={onBodyKeydown}
|
||||
>
|
||||
<span class="notif-item-icon">
|
||||
<Icon name={persistentIcon(row.kind)} />
|
||||
</span>
|
||||
<div class="notif-item-body">
|
||||
<div class="notif-item-text">
|
||||
{#if row.kind === NOTIFICATION_KIND.SHARE_GRANTED}
|
||||
{@const p = row.payload as unknown as SharegrantedPayload}
|
||||
{@const href = resourceHref(row.payload)}
|
||||
<UserVignette userId={p.granter_id} />
|
||||
<!-- eslint-disable-next-line svelte/no-useless-mustaches -->
|
||||
{' '}{t(
|
||||
'notifications.share_granted.verb',
|
||||
'shared'
|
||||
)}<!-- eslint-disable-next-line svelte/no-useless-mustaches -->
|
||||
{' '}
|
||||
{#if href}
|
||||
<!-- `runtimeResolve` wraps SvelteKit's `resolve()`
|
||||
with the same `@ts-expect-error` pattern
|
||||
AppShell uses — bell href is a runtime path,
|
||||
not a compile-time route key. -->
|
||||
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||
<a href={runtimeResolve(href)} onclick={onAnchorClick}>
|
||||
{p.resource_name ?? p.resource_type}
|
||||
</a>
|
||||
{:else}
|
||||
<strong>{p.resource_name ?? p.resource_type}</strong>
|
||||
{/if}
|
||||
<!-- eslint-disable-next-line svelte/no-useless-mustaches -->
|
||||
{' '}({p.role})
|
||||
{:else}
|
||||
<!-- Generic fallback for kinds without their own template
|
||||
yet (new_login_from_new_device, job_completed_for_you,
|
||||
storage_quota_threshold — each waits for its
|
||||
ingester to ship, see `docs/plan/templated-messages.md
|
||||
§ Deferred`). -->
|
||||
<span>
|
||||
{t('notifications.persistent.generic', { kind: row.kind }, `Notification (${row.kind}).`)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="notif-item-time">{formatPersistentTime(row.created_at)}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="notif-item-close"
|
||||
aria-label={t('common.delete', 'Delete')}
|
||||
onclick={onCloseClick}
|
||||
onkeydown={onCloseKeydown}
|
||||
>
|
||||
<Icon name="times" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/*
|
||||
* Row layout classes (`.notif-item`, `.notif-item-icon`,
|
||||
* `.notif-item-body`, `.notif-item-text`, `.notif-item-time`)
|
||||
* are inherited from AppShell's bell dropdown. Only the close
|
||||
* button needs its own styling — everything else is styled
|
||||
* by the parent panel.
|
||||
*
|
||||
* `:global()` needed because Svelte scopes styles per
|
||||
* component; the button's class is applied to a real element
|
||||
* in this component but interacts with the parent's `.notif-item`
|
||||
* hover state.
|
||||
*/
|
||||
.notif-item-close {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0.25rem;
|
||||
margin-left: auto;
|
||||
color: var(--color-text-muted);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
align-self: flex-start;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.notif-item-close:hover,
|
||||
.notif-item-close:focus-visible {
|
||||
background: var(--color-hover);
|
||||
color: var(--color-text);
|
||||
}
|
||||
</style>
|
||||
@@ -40,6 +40,8 @@
|
||||
import { messageBus } from '$lib/message-bus/client.svelte';
|
||||
import { session } from '$lib/stores/session.svelte';
|
||||
import { serverConfig } from '$lib/stores/serverConfig.svelte';
|
||||
import { ui } from '$lib/stores/ui.svelte';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
import {
|
||||
deleteNotification as apiDelete,
|
||||
getUnreadCount,
|
||||
@@ -47,11 +49,54 @@ import {
|
||||
markAllNotificationsRead,
|
||||
markNotificationRead
|
||||
} from '$lib/api/endpoints/notifications';
|
||||
import type { Notification } from '$lib/api/types';
|
||||
import type { Notification, SharegrantedPayload } from '$lib/api/types';
|
||||
import { NOTIFICATION_KIND } from '$lib/api/types';
|
||||
import log from 'loglevel';
|
||||
|
||||
const bellLog = log.getLogger('oxi:notifications');
|
||||
|
||||
/**
|
||||
* Plain-string summary of a persistent notification — used for the
|
||||
* transient toast preview (fade-to-bell UX affordance) and for
|
||||
* accessible labels where a component can't render rich content.
|
||||
*
|
||||
* Rich Svelte rendering lives in `NotificationRow.svelte`. This is
|
||||
* the string-only equivalent for toasts and aria-labels.
|
||||
*/
|
||||
export function summaryFor(row: Notification): string {
|
||||
switch (row.kind) {
|
||||
case NOTIFICATION_KIND.SHARE_GRANTED: {
|
||||
const p = row.payload as unknown as SharegrantedPayload;
|
||||
const resource = p.resource_name ?? p.resource_type ?? 'a resource';
|
||||
return t(
|
||||
'notifications.persistent.share_granted',
|
||||
{ role: p.role ?? 'access', resource },
|
||||
`Someone shared "${resource}" with you (${p.role ?? 'access'}).`
|
||||
);
|
||||
}
|
||||
case NOTIFICATION_KIND.NEW_LOGIN_FROM_NEW_DEVICE:
|
||||
return t(
|
||||
'notifications.persistent.new_device_login',
|
||||
'A new device signed into your account.'
|
||||
);
|
||||
case NOTIFICATION_KIND.JOB_COMPLETED_FOR_YOU: {
|
||||
const name = String((row.payload as { name?: unknown }).name ?? 'a job');
|
||||
return t('notifications.persistent.job_completed', { name }, `Job "${name}" finished.`);
|
||||
}
|
||||
case NOTIFICATION_KIND.STORAGE_QUOTA_THRESHOLD:
|
||||
return t(
|
||||
'notifications.persistent.quota_threshold',
|
||||
'You are approaching your storage quota.'
|
||||
);
|
||||
default:
|
||||
return t(
|
||||
'notifications.persistent.generic',
|
||||
{ kind: row.kind },
|
||||
`Notification (${row.kind}).`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge `incoming` rows into `existing`, deduplicating on `id`.
|
||||
* Where an id appears in both, the incoming (fresh-from-server)
|
||||
@@ -125,6 +170,13 @@ class NotificationsStore {
|
||||
* Merges via `mergeById` so a concurrent WS push and reconnect
|
||||
* catch-up can't double-count a row that landed twice.
|
||||
*
|
||||
* Fresh rows — rows the local set didn't have before the merge —
|
||||
* each fire a transient toast via `ui.notify(..., record: false)`
|
||||
* so the user gets a peripheral awareness cue that fades to the
|
||||
* bell (which keeps the row in its persistent history). The
|
||||
* bell's ring animation plays via `ui.ringBell()` so a single
|
||||
* bump signals "something new is in there".
|
||||
*
|
||||
* Silent no-op when the server returns 0 rows — we're already in
|
||||
* sync. Updates `#lastReceivedAt` to the newest of the merged set.
|
||||
*/
|
||||
@@ -144,10 +196,30 @@ class NotificationsStore {
|
||||
limit: 100
|
||||
});
|
||||
if (res.items.length > 0) {
|
||||
// Snapshot the pre-merge id set so we can identify
|
||||
// which rows are genuinely fresh vs already-known
|
||||
// (an already-known row can come back on a delta
|
||||
// fetch if its read_at flipped on another device).
|
||||
const before = new Set(this.#items.map((n) => n.id));
|
||||
this.#items = mergeById(this.#items, res.items);
|
||||
// Newest of merged set — take the first item's
|
||||
// created_at since the result is sorted DESC.
|
||||
this.#lastReceivedAt = res.items[0].created_at;
|
||||
|
||||
// Toast preview for every fresh row. Pass
|
||||
// `record: false` so it doesn't add a phantom
|
||||
// transient entry to `ui.notifications` that would
|
||||
// duplicate the persistent row already in
|
||||
// `notifications.items` (the bell dropdown shows
|
||||
// them side by side). `ui.ringBell()` bumps the
|
||||
// bell-ring animation once for the batch.
|
||||
const fresh = res.items.filter((r) => !before.has(r.id));
|
||||
if (fresh.length > 0) {
|
||||
for (const row of fresh) {
|
||||
ui.notify(summaryFor(row), 'info', 4000, false);
|
||||
}
|
||||
ui.ringBell();
|
||||
}
|
||||
}
|
||||
// unread_count is the authoritative live server count —
|
||||
// always update it even when the delta was empty (a row
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
// AUTO-GENERATED — do not edit by hand.
|
||||
// Regenerate with `just asyncapi-ts`.
|
||||
interface NotificationReceivedData {
|
||||
created_at: string;
|
||||
kind: string;
|
||||
notification_id: string;
|
||||
}
|
||||
interface NotificationReceivedData {}
|
||||
export type { NotificationReceivedData as default };
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
import { primeContextPage } from '$lib/utils/listContext';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import {
|
||||
addFavorite,
|
||||
dateBucket,
|
||||
@@ -131,6 +132,63 @@
|
||||
if (viewerOpen) void fileViewer.load();
|
||||
});
|
||||
|
||||
// ── File-preview deep link (?file=<id>) ──────────────────────────────────
|
||||
// Notification bell's `share_granted` for a FILE resource links here with
|
||||
// `?file=<id>` because `/files/{uuid}` requires a folder id and the
|
||||
// recipient may not have access to the file's parent folder. Every
|
||||
// recipient of a `share_granted` sees the shared item in this list, so
|
||||
// this route is the guaranteed-accessible home for the deep link.
|
||||
// Mirrors the URL↔viewer pattern in `routes/files/[...path]/+page.svelte`.
|
||||
// See `docs/plan/templated-messages.md § File notification routing`.
|
||||
//
|
||||
// URL → viewer. Runs on navigation, on Back/Forward, and once the initial
|
||||
// listing arrives (the item may not be in `items` yet on cold deep link).
|
||||
// `untrack` stops re-firing on viewer-state changes so a user-initiated
|
||||
// close can't be re-opened here.
|
||||
$effect(() => {
|
||||
const fileId = page.url.searchParams.get('file');
|
||||
const currentItems = items;
|
||||
untrack(() => {
|
||||
if (!fileId) {
|
||||
if (viewerOpen) viewerOpen = false;
|
||||
return;
|
||||
}
|
||||
if (viewerOpen && viewerFile?.id === fileId) return;
|
||||
const match = currentItems.find((it) => isFile(it) && it.id === fileId);
|
||||
if (match && isFile(match)) {
|
||||
viewerFile = match;
|
||||
viewerOpen = true;
|
||||
}
|
||||
// If the file isn't in the current page yet, do nothing —
|
||||
// the next `load()` completion re-fires this effect (items
|
||||
// is `$derived`) and picks up the deep link when the item
|
||||
// lands. A shared file the caller no longer has access to
|
||||
// (revoked between notification and click) never lands and
|
||||
// the viewer stays closed — same failure mode as any
|
||||
// deferred-navigation dangling reference.
|
||||
});
|
||||
});
|
||||
|
||||
// Viewer → URL: on close (X / Esc / backdrop), drop the `?file=` param.
|
||||
// `replaceState` so closing doesn't add a history entry. Only act on a
|
||||
// genuine open→closed transition — on cold deep link the viewer starts
|
||||
// closed *with* the param while the listing is still loading, and
|
||||
// stripping it there would race the URL→viewer effect above.
|
||||
let viewerWasOpen = false;
|
||||
$effect(() => {
|
||||
const open = viewerOpen;
|
||||
const hasParam = page.url.searchParams.get('file') !== null;
|
||||
untrack(() => {
|
||||
if (viewerWasOpen && !open && hasParam) {
|
||||
const url = new URL(page.url);
|
||||
url.searchParams.delete('file');
|
||||
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
||||
void goto(url, { keepFocus: true, noScroll: true, replaceState: true });
|
||||
}
|
||||
viewerWasOpen = open;
|
||||
});
|
||||
});
|
||||
|
||||
function open(item: FileItem | FolderItem) {
|
||||
if (!isFile(item)) {
|
||||
goto(resolve(`/files/${item.id}`));
|
||||
@@ -138,6 +196,18 @@
|
||||
}
|
||||
viewerFile = item;
|
||||
viewerOpen = true;
|
||||
// Reflect the open file in the URL — makes the view linkable
|
||||
// (paste-into-slack a `/shared-with-me?file=<id>` and it opens
|
||||
// straight into the viewer), matches the pattern
|
||||
// `/files/[...path]` uses, and lets the browser's Back button
|
||||
// close the viewer. `pushState` (not `replaceState`) so Back
|
||||
// pops the viewer off history instead of leaving the page.
|
||||
const url = new URL(page.url);
|
||||
if (url.searchParams.get('file') !== item.id) {
|
||||
url.searchParams.set('file', item.id);
|
||||
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
||||
void goto(url, { keepFocus: true, noScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user