diff --git a/.cursor/plans/drive_suite_build_7921c055.plan.md b/.cursor/plans/drive_suite_build_7921c055.plan.md new file mode 100644 index 0000000..8fd2df1 --- /dev/null +++ b/.cursor/plans/drive_suite_build_7921c055.plan.md @@ -0,0 +1,409 @@ +--- +name: Drive Suite Build +overview: "Build UltiDrive as a Google Drive–like frontend in `drive-suite`, with all backend/infra in `ulti-backend` (ultid API, Nextcloud, OnlyOffice, Authentik, deploy). Mail stays frontend-only in `gmail-interface-clone`. Production routing: same domain at `/drive`." +todos: + - id: phase0-provisioning + content: Fix drive EnsurePrincipal + extend drive API (quota, shares CRUD, restore, favorite, create blank file) + status: completed + - id: phase0-onlyoffice + content: Deploy OnlyOffice Document Server, enable NC onlyoffice app, add /api/v1/office session+callback endpoints + status: completed + - id: phase1-scaffold + content: Scaffold drive-suite Next.js app (auth, API client, basePath /drive, app shell, URL routing) + status: completed + - id: phase2-mvp + content: Build Google Drive file browser MVP wired to real /api/v1/drive (upload, browse, share, trash, recent, starred) + status: completed + - id: phase3-editor + content: Integrate OnlyOffice editor embed for docx/xlsx/pptx via /drive/edit/{id} + status: completed + - id: phase4-chrome + content: "Google-like editor chrome: OnlyOffice theme first, then custom wrapper using drive-dump specs if needed" + status: completed + - id: phase5-suite + content: "Wire suite integration: nginx /drive route, launcher hrefs, mail attachment picker, Authentik callbacks" + status: completed +isProject: false +--- + +# UltiDrive (Drive Suite) — Build Plan + +## Repository roles + +| Repo | Responsibility | +|------|----------------| +| **[`ulti-backend`](file:///Users/red/workdev/ulti-backend)** | **All backend infrastructure and server-side code:** custom Go API (`ultid`), Docker Compose, edge nginx, Authentik, Postgres/KeyDB/RustFS, headless Nextcloud, mail stack (IMAP/SMTP/sync), OnlyOffice Document Server, Jitsi, observability, deploy scripts, env templates, Authentik blueprints. This is the single ops + API hub for the suite. | +| **[`gmail-interface-clone`](file:///Users/red/workdev/gmail-interface-clone)** | **Frontend only — Ultimail.** Next.js UI for mail (list, compose, settings, contacts). Talks to ulti-backend via `/api/v1` proxy; no server infrastructure lives here. | +| **[`drive-suite`](file:///Users/red/workdev/drive-suite)** | **Frontend only — UltiDrive.** Next.js UI for the Google Drive–like file browser and editor shell. Same pattern as mail: API calls go to ulti-backend; deploy wiring (nginx route, compose service) is defined in ulti-backend. | + +Suite frontends are thin clients; every persistent service and integration runs from **ulti-backend**. + +--- + +## Current state + +| Layer | Status | +|-------|--------| +| [`drive-suite/`](file:///Users/red/workdev/drive-suite) | Empty placeholder — greenfield | +| [`ulti-backend`](file:///Users/red/workdev/ulti-backend) | Headless NC 30 deployed; `/api/v1/drive` proxy (list, upload, download, move, copy, rename, trash, recent, starred, shares) | +| [`gmail-interface-clone`](file:///Users/red/workdev/gmail-interface-clone) | Auth stack, shadcn UI, app shell patterns; UltiDrive tile exists but has no `href` | +| [`ultimail/apps/web/src/components/drive/`](file:///Users/red/workdev/ultimail/apps/web/src/components/drive) | Mock UI components (file-browser, share-dialog, upload-zone) — reference only | +| [`drive-dump/`](file:///Users/red/workdev/drive-dump) | Google Docs chrome research (titlebar, toolbar CSS specs) — useful for editor skinning, not for file browser | +| OnlyOffice | Not deployed; mentioned only in [`project-plan/ultidrive.md`](file:///Users/red/workdev/ulti-backend/project-plan/ultidrive.md) | + +**Critical backend bug to fix first:** drive handlers pass `claims.Sub` as NC user ID, but NC OIDC maps `preferred_username` (email). Contacts already use `EnsurePrincipal(email, sub, name)` — drive must match or provisioning fails. + +--- + +## Target architecture + +```mermaid +flowchart TB + subgraph frontends [Frontend repos] + MailFE["gmail-interface-clone"] + DriveFE["drive-suite"] + end + + subgraph ultiBackend [ulti-backend deploy and API] + Nginx["edge nginx"] + Ultid["ultid Go API"] + Auth["Authentik"] + NC["Nextcloud headless"] + OO["OnlyOffice"] + MailSvc["mail IMAP SMTP sync"] + S3["RustFS S3"] + end + + MailFE -->|"/mail"| Nginx + DriveFE -->|"/drive"| Nginx + Nginx --> Ultid + Nginx --> Auth + Ultid --> DriveAPI["/api/v1/drive"] + Ultid --> OfficeAPI["/api/v1/office"] + Ultid --> MailAPI["/api/v1/mail"] + Ultid --> WS["/ws"] + DriveAPI --> NC + OfficeAPI --> NC + OfficeAPI --> OO + NC --> OO + NC --> S3 + MailAPI --> MailSvc +``` + +**Principles:** +- Browser never talks to Nextcloud or OnlyOffice directly — all via **ultid** in ulti-backend (auth, RBAC, stable API). +- Nextcloud UI stays hidden (`/cloud/` admin-only); users only see UltiDrive. +- Same Authentik OIDC session; production path `/drive` on the suite domain (shared cookies with `/mail`). + +--- + +## Phase 0 — Backend hardening ([`ulti-backend`](file:///Users/red/workdev/ulti-backend) only) + +All work in this phase stays in ulti-backend (Go API + `deploy/`). No infrastructure or API logic belongs in drive-suite or gmail-interface-clone. + +Fix and extend the existing drive layer before building UI. + +### 0.1 User provisioning fix +In [`internal/api/drive/handlers.go`](file:///Users/red/workdev/ulti-backend/internal/api/drive/handlers.go) and `service.go`, replace raw `claims.Sub` with: + +```go +userID, err := nc.EnsurePrincipal(ctx, claims.Email, claims.Sub, claims.Name) +``` + +Mirror the contacts pattern in [`internal/nextcloud/users.go`](file:///Users/red/workdev/ulti-backend/internal/nextcloud/users.go). + +### 0.2 API gaps (priority order) + +| Endpoint | Purpose | +|----------|---------| +| `GET /drive/quota` | Storage bar in UI | +| `GET /drive/shares?path=` | List existing shares | +| `PUT /drive/shares/{id}` | Update role / expiry / password | +| `DELETE /drive/shares/{id}` | Revoke share | +| `POST /drive/trash/restore` | Restore from trash | +| `POST /drive/favorite` | Star/unstar | +| `POST /drive/files/new` | Create empty doc/sheet/slide (NC template or OO blank) | +| `GET /drive/search` | Full-text (v1: name filter; v2: Meilisearch) | + +Extend [`internal/nextcloud/drive.go`](file:///Users/red/workdev/ulti-backend/internal/nextcloud/drive.go) with OCS share list/update/delete and favorite WebDAV properties. + +### 0.3 WebSocket events +Add `drive.file_changed`, `drive.share_updated` to ultid WS hub so drive UI can invalidate TanStack Query cache (same pattern as mail events in [`gmail-interface-clone/lib/api/ws.ts`](file:///Users/red/workdev/gmail-interface-clone/lib/api/ws.ts)). + +### 0.4 OnlyOffice + Nextcloud deployment + +New files under [`deploy/onlyoffice/`](file:///Users/red/workdev/ulti-backend/deploy): + +- `docker-compose.onlyoffice.yml` — Document Server container +- Update [`deploy/nextcloud/init.sh`](file:///Users/red/workdev/ulti-backend/deploy/nextcloud/init.sh) — `$OCC app:enable onlyoffice` +- Update [`deploy/compose-up.sh`](file:///Users/red/workdev/ulti-backend/deploy/compose-up.sh) — conditional include when `ONLYOFFICE_ENABLED=true` +- Update [`deploy/nginx/default.conf.template`](file:///Users/red/workdev/ulti-backend/deploy/nginx/default.conf.template): + - `/drive/` → drive-suite container + - `/office/` → OnlyOffice (internal, JWT-protected; not exposed to users directly) + +Env vars to add to `.env.example`: + +``` +ONLYOFFICE_ENABLED=true +ONLYOFFICE_URL=http://onlyoffice:80 +ONLYOFFICE_JWT_SECRET=... +ONLYOFFICE_PUBLIC_URL=https://{DOMAIN}/office +``` + +### 0.5 Office API (new package `internal/api/office/`) + +| Endpoint | Role | +|----------|------| +| `POST /office/session` | Given `{ path, mode: view\|edit }`, return OnlyOffice editor config + signed JWT | +| `GET /office/callback` | OnlyOffice save callback → NC WebDAV PUT | +| `POST /office/create` | Create blank docx/xlsx/pptx in user folder | + +Flow: drive UI opens `/drive/edit/{fileId}` → backend builds OnlyOffice config pointing at NC file via internal URL → OnlyOffice loads document → saves via callback. + +--- + +## Phase 1 — Scaffold [`drive-suite`](file:///Users/red/workdev/drive-suite) (Google Drive shell) + +Frontend-only repo. Copy **UI/auth/client patterns** from [`gmail-interface-clone`](file:///Users/red/workdev/gmail-interface-clone) (the mail frontend), not from ultimail (different router). Match stack: **Next.js 16, React 19, Tailwind 4, shadcn new-york, Zustand 5, TanStack Query 5, pnpm**. + +### 1.1 Repo bootstrap +``` +drive-suite/ +├── app/ +│ ├── layout.tsx # QueryProvider + AuthProvider +│ ├── page.tsx # redirect → /drive +│ ├── api/auth/ # copy OIDC routes from mail +│ ├── auth/complete/ +│ ├── login/ +│ └── drive/ +│ ├── layout.tsx # DriveAppShell +│ ├── [[...segments]]/page.tsx +│ ├── edit/[fileId]/page.tsx # OnlyOffice embed (Phase 3) +│ └── settings/[[...section]]/ +├── components/drive/ # file browser, sidebar, header, share, upload +├── lib/ +│ ├── auth/ # session, pkce, jwt-claims +│ ├── api/client.ts # Bearer + 401 refresh +│ ├── api/hooks/use-drive-queries.ts +│ ├── drive-url.ts # URL = source of truth +│ └── stores/ # drive-ui-store, drive-settings-store +├── next.config.mjs # basePath: '/drive' in prod; /api/v1 rewrite +└── Dockerfile # same multi-stage pattern as mail +``` + +**`basePath: '/drive'`** in production so assets and routes work behind nginx path routing. + +### 1.2 Auth +Copy auth stack from mail; change: +- Default `returnTo` → `/drive` +- Persist key → `ultidrive-auth` +- Keep `ulti_*` cookie names (same origin) + +### 1.3 App shell (Google Drive UX) + +Three-zone layout mirroring [`mail-app-shell.tsx`](file:///Users/red/workdev/gmail-interface-clone/app/mail/mail-app-shell.tsx): + +``` +┌──────────────────────────────────────────────────────────┐ +│ Header: search, view toggle, account, suite launcher │ +├──────────┬───────────────────────────────────────────────┤ +│ Sidebar │ Main: breadcrumb + file grid/list │ +│ My Drive │ │ +│ Recent │ (optional detail panel for file preview) │ +│ Starred │ │ +│ Trash │ │ +│ Storage │ │ +└──────────┴───────────────────────────────────────────────┘ +``` + +Port/adapt from ultimail mock components: +- [`file-browser.tsx`](file:///Users/red/workdev/ultimail/apps/web/src/components/drive/file-browser.tsx) — grid/list, sort, multi-select +- [`breadcrumb-nav.tsx`](file:///Users/red/workdev/ultimail/apps/web/src/components/drive/breadcrumb-nav.tsx) +- [`upload-zone.tsx`](file:///Users/red/workdev/ultimail/apps/web/src/components/drive/upload-zone.tsx) — drag-drop + chunked upload via `X-Upload-*` headers +- [`share-dialog.tsx`](file:///Users/red/workdev/ultimail/apps/web/src/components/drive/share-dialog.tsx) +- [`quota-bar.tsx`](file:///Users/red/workdev/ultimail/apps/web/src/components/drive/quota-bar.tsx) + +Add `--drive-*` CSS tokens in `globals.css` parallel to mail's `--mail-*`. + +### 1.4 URL routing + +[`lib/drive-url.ts`](file:///Users/red/workdev/drive-suite/lib/drive-url.ts) + `use-drive-route.ts`: + +| URL segment | View | +|-------------|------| +| `/drive` | My Drive root | +| `/drive/folders/documents/page/2` | Folder + pagination | +| `/drive/recent` | Recent files | +| `/drive/starred` | Favorites | +| `/drive/trash` | Trash | +| `/drive/search?q=report` | Search results | +| `/drive/file/{id}` | Preview panel | +| `/drive/edit/{id}` | Editor (Phase 3) | + +Wire real API from day one — no mock data. + +--- + +## Phase 2 — Core Drive features (MVP) + +Ship a usable Google Drive clone before editors. + +| Feature | Backend | Frontend | +|---------|---------|----------| +| Browse folders | `GET /drive/files/*` | Grid/list + breadcrumb | +| Upload (simple + chunked) | `POST /drive/files/*` | Progress, retry | +| Create folder | `POST /drive/folders/*` | New folder dialog | +| Rename / move / copy | existing endpoints | Context menu + drag-drop move | +| Delete / trash / restore | DELETE + trash endpoints | Trash view | +| Starred / recent | existing endpoints | Sidebar views | +| Share (link + users) | shares CRUD | Share dialog (owner/editor/viewer) | +| Download | `GET /drive/download/*` | Context menu | +| Preview | download + mime routing | Images, PDF inline; others download | +| Storage quota | `GET /drive/quota` | Sidebar bar | +| Multi-select bulk ops | batch endpoints or sequential | Toolbar actions | + +**New file affordance (Google-style):** +- Dropdown: Blank document / spreadsheet / presentation / folder / upload +- Creates file via `POST /office/create` or NC template, then opens editor + +--- + +## Phase 3 — OnlyOffice editor integration + +### 3.1 Embed pattern +[`app/drive/edit/[fileId]/page.tsx`](file:///Users/red/workdev/drive-suite/app/drive/edit/[fileId]/page.tsx): + +1. Fetch editor config from `POST /api/v1/office/session` +2. Load `@onlyoffice/document-editor-react` (or iframe with DocsAPI) +3. Full-viewport editor; minimal chrome initially + +### 3.2 File type routing + +| MIME / extension | Editor | +|------------------|--------| +| `.docx`, `.odt` | Document | +| `.xlsx`, `.ods` | Spreadsheet | +| `.pptx`, `.odp` | Presentation | +| Other | Preview or download only | + +### 3.3 Save / co-editing +OnlyOffice handles real-time co-editing natively when connected to NC via the OnlyOffice app. Backend callback ensures saves land in NC WebDAV. No custom CRDT needed for v1. + +--- + +## Phase 4 — Google-like editor chrome (theme → wrapper → fork) + +Decision gate after Phase 3 ships with stock OnlyOffice UI. + +### Step A — OnlyOffice theme (try first, ~1–2 weeks) +- Custom JSON skin: toolbar colors, fonts (Roboto/Google Sans), icon set +- CSS overrides via OnlyOffice `customization` config +- Hide OnlyOffice branding; match light/dark to suite theme + +### Step B — Custom chrome wrapper (if theme insufficient) +Leverage [`drive-dump`](file:///Users/red/workdev/drive-dump) research: +- [`docs/CHROME-SPEC.md`](file:///Users/red/workdev/drive-dump/docs/CHROME-SPEC.md) — titlebar, menubar, toolbar DOM/CSS specs +- [`web/components/chrome/`](file:///Users/red/workdev/drive-dump/web/components/chrome) — React chrome components +- Build suite-owned titlebar (doc title, share, star, account) **above** OnlyOffice iframe +- Pass `customization.layout` to hide OnlyOffice's own header/toolbar +- Separate chrome per app type: Docs, Sheets, Slides (toolbar sets differ) + +### Step C — Fork (last resort) +Only if theme + wrapper cannot reach Google parity on toolbars/menus. Fork OnlyOffice Document Server frontend (AGPL) — high maintenance cost. **Defer until A+B evaluated.** + +Per [`drive-dump/docs/EDITOR-CORE-DECISION.md`](file:///Users/red/workdev/drive-dump/docs/EDITOR-CORE-DECISION.md): do **not** rebuild the editing engine — OnlyOffice remains the core; only chrome is customized. + +--- + +## Phase 5 — Suite integration + +### 5.1 Cross-app navigation +- [`header-account-actions.tsx`](file:///Users/red/workdev/gmail-interface-clone/components/gmail/header-account-actions.tsx): add `href: "/drive"` to UltiDrive tile +- Reciprocal launcher in drive-suite header back to `/mail`, Agenda, etc. +- Shared suite favicon/branding via `pnpm run brand:authentik` pattern + +### 5.2 Mail ↔ Drive +- **Mail UI** (gmail-interface-clone): compose attachment picker "Insert from UltiDrive"; "Save attachment to Drive" on received mail +- **API** (ulti-backend): mail attachment upload → `POST /drive/files/...` in ultid + +### 5.3 Deploy wiring (ulti-backend) + +All routing and compose changes live in **ulti-backend** [`deploy/`](file:///Users/red/workdev/ulti-backend/deploy). The drive-suite repo supplies the Docker image / build context only. + +Update [`deploy/nginx/default.conf.template`](file:///Users/red/workdev/ulti-backend/deploy/nginx/default.conf.template): + +```nginx +location /drive/ { + proxy_pass http://drive-suite:3000/; + # WebSocket + standard headers +} +``` + +Add a `drive-suite` service to ulti-backend compose (port 3000 internal; dev: build from `../drive-suite` or host :3001). Mail frontend is deployed the same way today — gmail-interface-clone is not part of ulti-backend source tree, but its container is orchestrated from ulti-backend deploy. + +Update Authentik blueprints / OIDC redirect URIs in ulti-backend for `/drive/api/auth/callback`. + +--- + +## Phase 6 — Advanced (post-MVP) + +| Feature | Approach | +|---------|----------| +| Full-text search | Meilisearch indexer on NC files + `/drive/search` | +| Version history | NC versions API wrapper | +| Shared drives / team folders | NC `groupfolders` app + API | +| Public link sharing page | `/drive/shared/{token}` read-only view | +| Desktop mount | rclone sidecar in Tauri (per ultidrive.md) | +| Offline uploads | IDB queue pattern from mail offline-queue | +| Comments on files | OnlyOffice comments (built-in) or NC comments API | + +--- + +## Suggested delivery order + +```mermaid +gantt + title UltiDrive delivery phases + dateFormat YYYY-MM-DD + section ulti backend + API and provisioning :a1, 2026-06-02, 14d + OnlyOffice deploy :a2, after a1, 7d + section drive suite + Scaffold :a3, 2026-06-02, 7d + Drive MVP :a4, after a3, 21d + Editor embed :a5, after a2, 14d + Google chrome :a6, after a5, 21d + section integration + Suite wiring :a7, after a4, 7d +``` + +Parallel tracks: **a1→a2** (ulti-backend) runs alongside **a3→a4** (drive-suite); **a5** starts after OnlyOffice (**a2**); **a7** (suite wiring) after Drive MVP (**a4**). + +**First shippable milestone (4–5 weeks):** Phase 0 + 1 + 2 + 5 nginx/auth — full Drive file browser on real backend. + +**Second milestone (+3 weeks):** Phase 3 — open and edit docs/sheets/slides in OnlyOffice. + +**Third milestone (+3 weeks):** Phase 4 — Google-like editor chrome. + +--- + +## Key files to create/modify + +| Repo | Role | Files | +|------|------|-------| +| **ulti-backend** | Backend + all infra | `internal/api/drive/*`, `internal/api/office/*`, `deploy/onlyoffice/*`, `deploy/nginx/*`, compose, Authentik blueprints | +| **drive-suite** | Drive frontend only | Entire Next.js app; copy auth + API client patterns from gmail-interface-clone | +| **gmail-interface-clone** | Mail frontend only | `header-account-actions.tsx`, compose drive picker (UI links to `/drive`) | +| **Reference only** | — | `ultimail/.../drive/*`, `drive-dump/docs/CHROME-SPEC.md` | + +--- + +## Risks and mitigations + +| Risk | Mitigation | +|------|------------| +| NC user ID mismatch | Fix EnsurePrincipal in Phase 0 before any UI work | +| OnlyOffice JWT / NC connector misconfig | Test with single doc open before building chrome | +| Chunk upload edge cases | Reuse existing `X-Upload-*` protocol; add integration tests | +| Google UI parity scope creep | Theme first; wrapper second; fork only as last resort | +| Path-based routing (`/drive`) asset breaks | `basePath` in next.config from day one | diff --git a/.cursor/rules/local-env.mdc b/.cursor/rules/local-env.mdc new file mode 100644 index 0000000..291c9bc --- /dev/null +++ b/.cursor/rules/local-env.mdc @@ -0,0 +1,12 @@ +--- +description: Fichiers .env locaux — les agents peuvent les lire et les modifier +alwaysApply: true +--- + +# Environnement local (test) + +Les fichiers `.env` et `.env*.local` à la racine de ce dépôt contiennent **uniquement des valeurs de test locales**. + +- Les agents **peuvent et doivent** les lire et les modifier quand la tâche l'exige (URLs, ports, clés de dev, etc.). +- Ne pas les committer : ils restent dans `.gitignore`. +- Pour documenter les variables, mettre à jour `.env.example`. diff --git a/.cursorignore b/.cursorignore new file mode 100644 index 0000000..9249d5d --- /dev/null +++ b/.cursorignore @@ -0,0 +1,4 @@ +# Environnement local (test) — accessible aux agents Cursor. +# Contre la liste d'ignore par défaut de Cursor ; fichiers toujours dans .gitignore. +!.env +!.env*.local diff --git a/.env.example b/.env.example index 6da992a..ef6e193 100644 --- a/.env.example +++ b/.env.example @@ -8,7 +8,8 @@ NEXT_PUBLIC_WS_URL=ws://localhost/ws NEXT_PUBLIC_OIDC_ISSUER=http://localhost/auth/application/o/ulti/ NEXT_PUBLIC_OIDC_CLIENT_ID=ulti-backend # URL publique affichée dans les redirects OIDC (navigateur) — utiliser localhost, pas 0.0.0.0 -NEXT_PUBLIC_APP_URL=http://localhost:3000 +# URL publique navigateur (suite nginx) — pas :3000 si tu passes par http://localhost/mail +NEXT_PUBLIC_APP_URL=http://localhost # Secret serveur uniquement — doit matcher ULTID_OIDC_CLIENT_SECRET / blueprint OIDC_CLIENT_SECRET=changeme diff --git a/app/globals.css b/app/globals.css index c4f51f4..790732c 100644 --- a/app/globals.css +++ b/app/globals.css @@ -934,6 +934,51 @@ html.dark .ultimail-app iframe[title='Sujet du message'] { color-scheme: dark; } +/* Hovercard destinataire (portail Radix hors .ultimail-app) */ +html.dark [data-contact-hover-card] { + color-scheme: dark; +} + +html.dark [data-contact-hover-card] :where(.text-\[\#202124\], .text-\[\#3c4043\], .text-\[\#1f1f1f\]) { + color: var(--foreground) !important; +} + +html.dark [data-contact-hover-card] :where(.text-\[\#5f6368\], .text-\[\#444746\]) { + color: var(--muted-foreground) !important; +} + +html.dark [data-contact-hover-card] :where(.text-\[\#001d35\]) { + color: var(--foreground) !important; +} + +html.dark [data-contact-hover-card] :where(.bg-\[\#d3e3fd\]) { + background-color: var(--mail-nav-selected) !important; +} + +html.dark [data-contact-hover-card] :where(.hover\:bg-\[\#c4d9fc\]:hover) { + background-color: var(--mail-active) !important; +} + +html.dark [data-contact-hover-card] :where(.hover\:bg-\[\#f1f3f4\]:hover) { + background-color: var(--accent) !important; +} + +html.dark [data-contact-hover-card] :where(.bg-\[\#f1f3f4\]) { + background-color: var(--accent) !important; +} + +html.dark [data-contact-hover-card] :where(.hover\:bg-\[\#e8eaed\]:hover) { + background-color: var(--accent) !important; +} + +html.dark [data-contact-hover-card] :where(.text-\[\#1a73e8\]) { + color: #8ab4f8 !important; +} + +html.dark [data-contact-hover-card] :where(.border-\[\#eceff1\]) { + border-color: var(--border) !important; +} + /* ── Dark : panneau Contacts (formulaires) ── */ html.dark :where([data-contacts-panel] .bg-white) { background-color: var(--mail-surface) !important; diff --git a/components/gmail/compose/compose-bottom-toolbar.tsx b/components/gmail/compose/compose-bottom-toolbar.tsx index ad06ee8..3622a36 100644 --- a/components/gmail/compose/compose-bottom-toolbar.tsx +++ b/components/gmail/compose/compose-bottom-toolbar.tsx @@ -490,7 +490,7 @@ export function ComposeBottomToolbar(props: ComposeBottomToolbarProps) { diff --git a/components/gmail/contact-hover-card.tsx b/components/gmail/contact-hover-card.tsx index f76a943..7b79164 100644 --- a/components/gmail/contact-hover-card.tsx +++ b/components/gmail/contact-hover-card.tsx @@ -167,6 +167,7 @@ export function ContactHoverCard({ (null) const existingContact = mode === "edit" ? contacts.find((c) => c.id === contactId) : null @@ -221,45 +222,52 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) { }, [mode, createDraft, reset, clearCreateDraft]) useEffect(() => { - if (existingContact) { - const hasExtendedName = !!( - existingContact.namePrefix || - existingContact.middleName || - existingContact.nameSuffix || - existingContact.phoneticFirstName || - existingContact.phoneticLastName - ) - if (hasExtendedName) setNameExpanded(true) - if (existingContact.department) setCompanyExpanded(true) - - reset({ - namePrefix: existingContact.namePrefix ?? "", - firstName: existingContact.firstName, - middleName: existingContact.middleName ?? "", - lastName: existingContact.lastName, - nameSuffix: existingContact.nameSuffix ?? "", - phoneticFirstName: existingContact.phoneticFirstName ?? "", - phoneticLastName: existingContact.phoneticLastName ?? "", - company: existingContact.company ?? "", - department: existingContact.department ?? "", - jobTitle: existingContact.jobTitle ?? "", - emails: existingContact.emails.length - ? existingContact.emails - : [{ value: "", label: "Domicile" }], - phones: existingContact.phones.length - ? existingContact.phones - : [{ value: "", label: "Mobile" }], - addresses: existingContact.addresses ?? [], - birthday: existingContact.birthday ?? { - day: undefined, - month: undefined, - year: undefined, - }, - notes: existingContact.notes ?? "", - labels: existingContact.labels ?? [], - }) + if (mode !== "edit" || !contactId) { + hydratedEditIdRef.current = null + return } - }, [existingContact, reset]) + if (hydratedEditIdRef.current === contactId) return + const contact = contacts.find((c) => c.id === contactId) + if (!contact) return + hydratedEditIdRef.current = contactId + + const hasExtendedName = !!( + contact.namePrefix || + contact.middleName || + contact.nameSuffix || + contact.phoneticFirstName || + contact.phoneticLastName + ) + if (hasExtendedName) setNameExpanded(true) + if (contact.department) setCompanyExpanded(true) + + reset({ + namePrefix: contact.namePrefix ?? "", + firstName: contact.firstName, + middleName: contact.middleName ?? "", + lastName: contact.lastName, + nameSuffix: contact.nameSuffix ?? "", + phoneticFirstName: contact.phoneticFirstName ?? "", + phoneticLastName: contact.phoneticLastName ?? "", + company: contact.company ?? "", + department: contact.department ?? "", + jobTitle: contact.jobTitle ?? "", + emails: contact.emails.length + ? contact.emails + : [{ value: "", label: "Domicile" }], + phones: contact.phones.length + ? contact.phones + : [{ value: "", label: "Mobile" }], + addresses: contact.addresses ?? [], + birthday: contact.birthday ?? { + day: undefined, + month: undefined, + year: undefined, + }, + notes: contact.notes ?? "", + labels: contact.labels ?? [], + }) + }, [mode, contactId, contacts, reset]) const firstName = watch("firstName") const lastName = watch("lastName") @@ -775,8 +783,8 @@ const FloatingInput = forwardRef( const innerRef = useRef(null) useEffect(() => { - if (innerRef.current && innerRef.current.value) setFilled(true) - }) + if (innerRef.current?.value) setFilled(true) + }, [defaultValue]) const setRefs = useCallback( (node: HTMLInputElement | null) => { @@ -842,8 +850,8 @@ const FloatingTextarea = forwardRef( const innerRef = useRef(null) useEffect(() => { - if (innerRef.current && innerRef.current.value) setFilled(true) - }) + if (innerRef.current?.value) setFilled(true) + }, []) const setRefs = useCallback( (node: HTMLTextAreaElement | null) => { diff --git a/components/gmail/email-list/hooks/use-email-list-data.ts b/components/gmail/email-list/hooks/use-email-list-data.ts index 28615d4..e33d87e 100644 --- a/components/gmail/email-list/hooks/use-email-list-data.ts +++ b/components/gmail/email-list/hooks/use-email-list-data.ts @@ -65,7 +65,8 @@ import { } from "@/components/gmail/email-list/email-list-helpers" import { useMailListPullRefresh } from "@/hooks/use-mail-list-pull-refresh" import { ensureVcLogosCollection } from "@/lib/register-vc-logos" -import { attachmentsForEmailList } from "@/lib/attachment-display" +import { resolveListRowAttachments } from "@/lib/attachment-display" +import { useListMessageAttachments } from "@/lib/api/hooks/use-list-message-attachments" import { resolveParsedCalendarInvitation } from "@/lib/resolve-email-calendar-invitation" import { resolveEmailInboxCategoryTabs } from "@/lib/inbox-category-tabs" import { cleanSenderName } from "@/lib/sender-display" @@ -621,6 +622,16 @@ export function useEmailListData({ const listMailIndex = useMemo(() => buildListMailIndex(allEmails), [allEmails]) + const attachmentFetchIds = useMemo( + () => + listEmails + .filter((e) => e.hasAttachment && !(e.attachments?.length)) + .map((e) => e.id), + [listEmails] + ) + const { byId: fetchedAttachmentsById, stateById: attachmentFetchStateById } = + useListMessageAttachments(attachmentFetchIds) + const listRowExtras = useMemo(() => { const invitationById = new Map< string, @@ -638,7 +649,15 @@ export function useEmailListData({ for (const e of listEmails) { invitationById.set(e.id, resolveParsedCalendarInvitation(e)) - attachmentsById.set(e.id, attachmentsForEmailList(e)) + const fetchState = attachmentFetchStateById.get(e.id) ?? "idle" + attachmentsById.set( + e.id, + resolveListRowAttachments( + e, + fetchedAttachmentsById.get(e.id), + fetchState + ) + ) if (showCategoryTabIcons) { const tabs = resolveEmailInboxCategoryTabs( e, @@ -653,6 +672,8 @@ export function useEmailListData({ return { invitationById, attachmentsById, categoryTabsById } }, [ listEmails, + fetchedAttachmentsById, + attachmentFetchStateById, selectedFolder, inboxTab, folderFilterCtx, diff --git a/components/gmail/email-view.tsx b/components/gmail/email-view.tsx index 3ce1e22..bb34544 100644 --- a/components/gmail/email-view.tsx +++ b/components/gmail/email-view.tsx @@ -15,6 +15,8 @@ import { senderInitial, } from "@/lib/sender-display" import type { ApiMessageSummary, ApiMessageFull } from "@/lib/api/types" +import { useMessageAttachments } from "@/lib/api/hooks/use-message-attachments" +import { attachmentsForEmailList } from "@/lib/attachment-display" import type { Email, EmailAttachment } from "@/lib/email-data" import { mailFlagIsRead, @@ -65,6 +67,7 @@ import { SpamWhyBanner, ThreadPriorMessage, formatApiMessageBody, + plainTextBodyFallback, } from "@/components/gmail/email-view/email-view-messages" function apiToLegacyEmail( @@ -148,6 +151,10 @@ export function EmailView({ ), [fullMessage, fullMessagePending, email.snippet] ) + const plainTextFallback = useMemo( + () => plainTextBodyFallback(fullMessage), + [fullMessage] + ) const [showFullThread, setShowFullThread] = useState(false) const [mainDetailsOpen, setMainDetailsOpen] = useState(false) @@ -169,14 +176,11 @@ export function EmailView({ const showFullThreadList = !isSingleMessageView || showFullThread const messagesBefore = showFullThreadList ? threadBefore : [] const messagesAfter = showFullThreadList ? threadAfter : [] - /** Conversation preview: all thread messages expanded (each gets its own remote-content banner). */ - const expandAllThreadMessages = - showFullThreadList && (!isSingleMessageView || showFullThread) const [expandedIds, setExpandedIds] = useState>(new Set()) const isThreadMessageExpanded = useCallback( - (msgId: string) => expandAllThreadMessages || expandedIds.has(msgId), - [expandAllThreadMessages, expandedIds] + (msgId: string) => expandedIds.has(msgId), + [expandedIds] ) const toggleExpanded = (msgId: string) => { setExpandedIds((prev) => { @@ -216,11 +220,18 @@ export function EmailView({ [email, fullMessage, threadMessages] ) - const mainMessageAttachments = useMemo((): EmailAttachment[] => { - if (email.has_attachments) - return [{ name: "Pièce jointe", kind: "other" }] - return [] - }, [email.has_attachments]) + const { data: fetchedAttachments } = useMessageAttachments( + email.id, + email.has_attachments + ) + const mainMessageAttachments = useMemo( + (): EmailAttachment[] => + attachmentsForEmailList({ + hasAttachment: email.has_attachments, + attachments: fetchedAttachments, + }), + [email.has_attachments, fetchedAttachments] + ) const { composeWindows } = useComposeWindows() const { savedThreadReplyDrafts } = useComposeDrafts() @@ -393,6 +404,7 @@ export function EmailView({ onDetailsOpenChange={setMainDetailsOpen} collapseQuotedReplies={otherThreadCount > 0} messageId={email.id} + plainTextFallback={plainTextFallback} /> {messagesAfter.map((msg) => ( diff --git a/components/gmail/email-view/email-view-details-popover.tsx b/components/gmail/email-view/email-view-details-popover.tsx index fe594d8..87a01f2 100644 --- a/components/gmail/email-view/email-view-details-popover.tsx +++ b/components/gmail/email-view/email-view-details-popover.tsx @@ -71,16 +71,16 @@ export function EmailViewDetailsPopover({ diff --git a/components/gmail/email-view/email-view-messages.tsx b/components/gmail/email-view/email-view-messages.tsx index 6b99d59..4b66a06 100644 --- a/components/gmail/email-view/email-view-messages.tsx +++ b/components/gmail/email-view/email-view-messages.tsx @@ -32,6 +32,15 @@ import { MAIL_TOOLTIP_CONTENT_CLASS, } from "@/lib/mail-chrome-classes" import { repairMimeBodies } from "@/lib/mail-mime-body" +import { plainTextToDisplayHtml } from "@/lib/mail-plain-text-html" + +export function plainTextBodyFallback( + full: { body_text?: string; body_html?: string } | null | undefined +): string | undefined { + const { bodyText } = repairMimeBodies(full?.body_text, full?.body_html) + const t = bodyText?.trim() + return t || undefined +} export function formatApiMessageBody( full: { body_html?: string; body_text?: string } | null | undefined, @@ -50,11 +59,7 @@ export function formatApiMessageBody( if (html) return html const text = repaired.bodyText?.trim() if (text) { - const escaped = text - .replace(/&/g, "&") - .replace(//g, ">") - return `
${escaped}
` + return plainTextToDisplayHtml(text) } if (full) { const s = snippet?.trim() @@ -119,6 +124,10 @@ export function ThreadPriorMessage({ ), [fullMessage, message.snippet, isExpanded, isPending] ) + const plainTextFallback = useMemo( + () => plainTextBodyFallback(fullMessage), + [fullMessage] + ) const isSpam = messageIsSpam(merged.flags, merged.labels) @@ -151,6 +160,7 @@ export function ThreadPriorMessage({ onDetailsOpenChange={setDetailsOpen} collapseQuotedReplies={collapseQuotedReplies} messageId={message.id} + plainTextFallback={plainTextFallback} /> ) } @@ -232,6 +242,7 @@ export function ExpandedMessage({ onDetailsOpenChange, collapseQuotedReplies = false, messageId, + plainTextFallback, }: { sender: string senderEmail: string @@ -251,6 +262,7 @@ export function ExpandedMessage({ detailsOpen?: boolean onDetailsOpenChange?: (open: boolean) => void collapseQuotedReplies?: boolean + plainTextFallback?: string }) { return (
@@ -285,6 +297,7 @@ export function ExpandedMessage({ senderEmail={senderEmail} messageId={messageId} collapseQuotedReplies={collapseQuotedReplies} + plainTextFallback={plainTextFallback} />
diff --git a/components/gmail/email-view/email-view-toolbar.tsx b/components/gmail/email-view/email-view-toolbar.tsx index 967b376..b8b5f5e 100644 --- a/components/gmail/email-view/email-view-toolbar.tsx +++ b/components/gmail/email-view/email-view-toolbar.tsx @@ -134,7 +134,7 @@ export function EmailViewMessageToolbar({ -
+
{ + if (!/cid:/i.test(mainHtml)) return + if (Object.keys(cidMap ?? {}).length > 0) return + if (reindexPending || reindexDone || reindexFailed) return + reindexInlineAttachments() + }, [ + mainHtml, + cidMap, + reindexPending, + reindexDone, + reindexFailed, + reindexInlineAttachments, + ]) + const hasRemoteContent = useMemo( () => htmlHasRemoteContent(mainHtml) || @@ -89,13 +112,14 @@ export function MessageBodyContent({ restrictPopups: isSpam, senderEmail, cidUrlMap, + plainTextFallback, + messageId, } return (
{showRemoteBanner ? ( allowMessageRemoteContent(messageId)} onAlwaysShow={() => { trustSender(senderEmail) @@ -103,7 +127,7 @@ export function MessageBodyContent({ }} /> ) : null} - + {hasHiddenQuote ? (
) : null}
diff --git a/components/gmail/email-view/remote-content-banner.tsx b/components/gmail/email-view/remote-content-banner.tsx index f7640fd..90f7c0c 100644 --- a/components/gmail/email-view/remote-content-banner.tsx +++ b/components/gmail/email-view/remote-content-banner.tsx @@ -1,11 +1,9 @@ "use client" export function RemoteContentBanner({ - senderEmail, onShowOnce, onAlwaysShow, }: { - senderEmail: string onShowOnce: () => void onAlwaysShow: () => void }) { @@ -25,7 +23,7 @@ export function RemoteContentBanner({ onClick={onAlwaysShow} className="text-primary hover:underline" > - toujours afficher le contenu distant venant de {senderEmail} + toujours afficher le contenu distant de cet expéditeur

) diff --git a/components/gmail/email-view/sandboxed-content.tsx b/components/gmail/email-view/sandboxed-content.tsx index 0375ed7..0b11910 100644 --- a/components/gmail/email-view/sandboxed-content.tsx +++ b/components/gmail/email-view/sandboxed-content.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -12,14 +13,25 @@ import { useTheme } from "next-themes" import { emailPreviewBaseCss, emailPreviewDarkOverrideCss, + emailPreviewDarkRemoteBodyTailCss, + emailPreviewDarkTailOverrideCss, emailPreviewLightOverrideCss, emailPreviewWrapperCss, } from "@/lib/email-preview-dark-styles" import { + EMAIL_PREVIEW_MIN_IFRAME_HEIGHT, + measureEmailPreviewIframeHeight, +} from "@/lib/email-preview-iframe-height" +import { + buildEmailPreviewSrcdoc, prepareEmailHtmlForIframe, - injectEmailHtmlIntoDocument, } from "@/lib/mail-html-iframe" import { buildEmailPreviewCsp } from "@/lib/mail-remote-content" +import { + isEmailPreviewContrastDebugEnabled, + logEmailPreviewContrastIssues, + repairEmailPreviewContrast, +} from "@/lib/email-preview-contrast" const EMAIL_PREVIEW_IFRAME_STYLE: CSSProperties = { display: "block", @@ -30,34 +42,33 @@ function documentIsDark(): boolean { return document.documentElement.classList.contains("dark") } -function measureIframeContentHeight(doc: Document): number { - const body = doc.body - const root = doc.documentElement - if (!body) return 60 - const heights = [ - body.scrollHeight, - body.offsetHeight, - root?.scrollHeight ?? 0, - root?.clientHeight ?? 0, - ] - return Math.max(60, ...heights) + 2 -} - export function SandboxedContent({ html, blockRemoteContent, restrictPopups = false, senderEmail, cidUrlMap, + plainTextFallback, + messageId, + previewPart = "body", }: { html: string blockRemoteContent: boolean restrictPopups?: boolean senderEmail?: string cidUrlMap?: Record + /** Plain body when HTML is image-only or empty with remote content blocked. */ + plainTextFallback?: string + /** Pour les logs dev [email-preview:low-contrast]. */ + messageId?: string + previewPart?: "body" | "quoted" }) { const iframeRef = useRef(null) - const [height, setHeight] = useState(120) + const contrastLoggedKeyRef = useRef(null) + const contrastDelayTimerRef = useRef(null) + const resizeObserverRef = useRef(null) + const contentGenerationRef = useRef(0) + const [height, setHeight] = useState(EMAIL_PREVIEW_MIN_IFRAME_HEIGHT) const sandboxValue = restrictPopups ? "allow-same-origin" @@ -77,12 +88,16 @@ export function SandboxedContent({ isDark, senderEmail, cidUrlMap, + plainTextFallback, }), - [html, blockRemoteContent, isDark, senderEmail, cidUrlMap] + [html, blockRemoteContent, isDark, senderEmail, cidUrlMap, plainTextFallback] ) const themeCss = useMemo(() => { - if (!blockRemoteContent) return emailPreviewWrapperCss() + if (!blockRemoteContent) { + const wrapper = emailPreviewWrapperCss(isDark) + return isDark ? `${wrapper}${emailPreviewDarkOverrideCss()}` : wrapper + } return `${emailPreviewBaseCss(isDark)}${ isDark ? emailPreviewDarkOverrideCss() : emailPreviewLightOverrideCss() }` @@ -93,65 +108,152 @@ export function SandboxedContent({ [blockRemoteContent] ) - const injectContent = useCallback(() => { - const iframe = iframeRef.current - if (!iframe) return + const srcdoc = useMemo( + () => + buildEmailPreviewSrcdoc(parsedEmail, { + csp: cspContent, + wrapperCss: themeCss, + plainTextFallback, + bodyTailCss: isDark + ? blockRemoteContent + ? emailPreviewDarkTailOverrideCss() + : emailPreviewDarkRemoteBodyTailCss() + : undefined, + }), + [ + parsedEmail, + cspContent, + themeCss, + plainTextFallback, + isDark, + blockRemoteContent, + ] + ) - const doc = iframe.contentDocument - if (!doc) return + const iframeKey = `${messageId ?? "no-id"}:${previewPart}:${blockRemoteContent ? "remote-blocked" : "remote-allowed"}:${isDark ? "dark" : "light"}` - injectEmailHtmlIntoDocument(doc, { - csp: cspContent, - documentBaseHref: parsedEmail.documentBaseHref, - resolveBaseHref: parsedEmail.resolveBaseHref, - headMarkup: parsedEmail.headMarkup, - bodyHtml: parsedEmail.bodyHtml, - wrapperCss: themeCss, - }) + const syncHeight = useCallback((generation: number) => { + if (generation !== contentGenerationRef.current) return + const doc = iframeRef.current?.contentDocument + if (!doc?.body) return + const next = measureEmailPreviewIframeHeight(doc) + setHeight((prev) => (prev === next ? prev : next)) + }, []) - const syncHeight = () => { - const liveDoc = iframe.contentDocument - if (!liveDoc) return - const next = measureIframeContentHeight(liveDoc) - setHeight((prev) => (prev === next ? prev : next)) - } + const scheduleHeightSync = useCallback( + (generation: number) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + syncHeight(generation) + }) + }) + }, + [syncHeight] + ) - const resizeObserver = new ResizeObserver(syncHeight) + const runContrastPipeline = useCallback( + (doc: Document, pass: "initial" | "delayed", generation: number) => { + if (!isDark || generation !== contentGenerationRef.current) return - if (doc.body) { - resizeObserver.observe(doc.body) - for (const img of doc.images) { - if (!img.complete) { - img.addEventListener("load", syncHeight, { once: true }) - img.addEventListener("error", syncHeight, { once: true }) + const repair = repairEmailPreviewContrast(doc, { + isDark: true, + repairMode: blockRemoteContent ? "dark-only" : "all", + newsletterLightCanvas: false, + assumedCanvasRgb: [32, 33, 36], + }) + + if (repair && (repair.lightSurfaces > 0 || repair.darkSurfaces > 0)) { + scheduleHeightSync(generation) + if (isEmailPreviewContrastDebugEnabled()) { + console.info("[email-preview:contrast-repaired]", { + messageId, + part: previewPart, + pass, + blockRemoteContent, + ...repair, + }) } } - for (const link of doc.querySelectorAll('link[rel~="stylesheet"]')) { - link.addEventListener("load", syncHeight, { once: true }) - link.addEventListener("error", syncHeight, { once: true }) - } - syncHeight() - requestAnimationFrame(syncHeight) - setTimeout(syncHeight, 250) - setTimeout(syncHeight, 1000) + + if (!isEmailPreviewContrastDebugEnabled()) return + + const logKey = `${messageId ?? "no-id"}:${previewPart}:${blockRemoteContent}:${isDark}:${srcdoc.length}:${pass}` + if (contrastLoggedKeyRef.current === logKey) return + contrastLoggedKeyRef.current = logKey + logEmailPreviewContrastIssues( + { + messageId, + part: previewPart, + blockRemoteContent, + isDark, + senderEmail, + }, + doc, + { newsletterLightCanvas: false, assumedCanvasRgb: [32, 33, 36] } + ) + }, + [ + messageId, + previewPart, + blockRemoteContent, + isDark, + senderEmail, + srcdoc, + scheduleHeightSync, + ] + ) + + const handleIframeLoad = useCallback(() => { + const generation = contentGenerationRef.current + const doc = iframeRef.current?.contentDocument + + resizeObserverRef.current?.disconnect() + if (doc?.body) { + resizeObserverRef.current = new ResizeObserver(() => { + syncHeight(generation) + }) + resizeObserverRef.current.observe(doc.body) } - return () => resizeObserver.disconnect() - }, [parsedEmail, themeCss, cspContent]) + scheduleHeightSync(generation) + if (doc) runContrastPipeline(doc, "initial", generation) - useEffect(() => { - const cleanup = injectContent() - return () => cleanup?.() - }, [injectContent]) + if (contrastDelayTimerRef.current !== null) { + window.clearTimeout(contrastDelayTimerRef.current) + } + contrastDelayTimerRef.current = window.setTimeout(() => { + contrastDelayTimerRef.current = null + if (generation !== contentGenerationRef.current) return + const lateDoc = iframeRef.current?.contentDocument + if (lateDoc) { + scheduleHeightSync(generation) + runContrastPipeline(lateDoc, "delayed", generation) + } + }, 1000) + }, [scheduleHeightSync, runContrastPipeline, syncHeight]) + + useLayoutEffect(() => { + contentGenerationRef.current += 1 + setHeight(EMAIL_PREVIEW_MIN_IFRAME_HEIGHT) + contrastLoggedKeyRef.current = null + resizeObserverRef.current?.disconnect() + resizeObserverRef.current = null + if (contrastDelayTimerRef.current !== null) { + window.clearTimeout(contrastDelayTimerRef.current) + contrastDelayTimerRef.current = null + } + }, [srcdoc, messageId]) return (