diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml deleted file mode 100644 index 93e18a4fccbb5..0000000000000 --- a/.github/workflows/release-pr.yml +++ /dev/null @@ -1,170 +0,0 @@ -name: Manage release PR -on: - workflow_dispatch: - push: - branches: - - main - -concurrency: - group: ${{ github.workflow }} - cancel-in-progress: true - -permissions: {} - -jobs: - bump: - runs-on: ubuntu-latest - steps: - - name: Generate a token - id: generate-token - uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 - with: - app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} - private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - token: ${{ steps.generate-token.outputs.token }} - persist-credentials: true - ref: main - - - name: Install uv - uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0 - - - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - - - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 - with: - node-version-file: './server/.nvmrc' - cache: 'pnpm' - cache-dependency-path: '**/pnpm-lock.yaml' - - - name: Determine release type - id: bump-type - uses: ietf-tools/semver-action@c90370b2958652d71c06a3484129a4d423a6d8a8 # v1.11.0 - with: - token: ${{ steps.generate-token.outputs.token }} - - - name: Bump versions - env: - TYPE: ${{ steps.bump-type.outputs.bump }} - run: | - if [ "$TYPE" == "none" ]; then - exit 1 # TODO: Is there a cleaner way to abort the workflow? - fi - misc/release/pump-version.sh -s $TYPE -m true - - - name: Manage Outline release document - id: outline - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - env: - OUTLINE_API_KEY: ${{ secrets.OUTLINE_API_KEY }} - NEXT_VERSION: ${{ steps.bump-type.outputs.next }} - with: - github-token: ${{ steps.generate-token.outputs.token }} - script: | - const fs = require('fs'); - - const outlineKey = process.env.OUTLINE_API_KEY; - const parentDocumentId = 'da856355-0844-43df-bd71-f8edce5382d9' - const collectionId = 'e2910656-714c-4871-8721-447d9353bd73'; - const baseUrl = 'https://outline.immich.cloud'; - - const listResponse = await fetch(`${baseUrl}/api/documents.list`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${outlineKey}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ parentDocumentId }) - }); - - if (!listResponse.ok) { - throw new Error(`Outline list failed: ${listResponse.statusText}`); - } - - const listData = await listResponse.json(); - const allDocuments = listData.data || []; - - const document = allDocuments.find(doc => doc.title === 'next'); - - let documentId; - let documentUrl; - let documentText; - - if (!document) { - // Create new document - console.log('No existing document found. Creating new one...'); - const notesTmpl = fs.readFileSync('misc/release/notes.tmpl', 'utf8'); - const createResponse = await fetch(`${baseUrl}/api/documents.create`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${outlineKey}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - title: 'next', - text: notesTmpl, - collectionId: collectionId, - parentDocumentId: parentDocumentId, - publish: true - }) - }); - - if (!createResponse.ok) { - throw new Error(`Failed to create document: ${createResponse.statusText}`); - } - - const createData = await createResponse.json(); - documentId = createData.data.id; - const urlId = createData.data.urlId; - documentUrl = `${baseUrl}/doc/next-${urlId}`; - documentText = createData.data.text || ''; - console.log(`Created new document: ${documentUrl}`); - } else { - documentId = document.id; - const docPath = document.url; - documentUrl = `${baseUrl}${docPath}`; - documentText = document.text || ''; - console.log(`Found existing document: ${documentUrl}`); - } - - // Generate GitHub release notes - console.log('Generating GitHub release notes...'); - const releaseNotesResponse = await github.rest.repos.generateReleaseNotes({ - owner: context.repo.owner, - repo: context.repo.repo, - tag_name: `${process.env.NEXT_VERSION}`, - }); - - // Combine the content - const changelog = ` - # ${process.env.NEXT_VERSION} - - ${documentText} - - ${releaseNotesResponse.data.body} - - --- - - ` - - const existingChangelog = fs.existsSync('CHANGELOG.md') ? fs.readFileSync('CHANGELOG.md', 'utf8') : ''; - fs.writeFileSync('CHANGELOG.md', changelog + existingChangelog, 'utf8'); - - core.setOutput('document_url', documentUrl); - - - name: Create PR - id: create-pr - uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 - with: - token: ${{ steps.generate-token.outputs.token }} - commit-message: 'chore: release ${{ steps.bump-type.outputs.next }}' - title: 'chore: release ${{ steps.bump-type.outputs.next }}' - body: 'Release notes: ${{ steps.outline.outputs.document_url }}' - labels: 'changelog:skip' - branch: 'release/next' - draft: true diff --git a/cli/package.json b/cli/package.json index aed8be5bba0de..d6202e6a1a505 100644 --- a/cli/package.json +++ b/cli/package.json @@ -20,7 +20,7 @@ "@types/lodash-es": "^4.17.12", "@types/micromatch": "^4.0.9", "@types/mock-fs": "^4.13.1", - "@types/node": "^24.10.14", + "@types/node": "^24.11.0", "@vitest/coverage-v8": "^4.0.0", "byte-size": "^9.0.0", "cli-progress": "^3.12.0", diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index c132c224aa9ee..6e435b3c6b480 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -155,7 +155,7 @@ services: redis: container_name: immich_redis - image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d + image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6 healthcheck: test: redis-cli ping || exit 1 diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 3a5f781d5e802..4d07794fea544 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -56,7 +56,7 @@ services: redis: container_name: immich_redis - image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d + image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6 healthcheck: test: redis-cli ping || exit 1 restart: always diff --git a/docker/docker-compose.rootless.yml b/docker/docker-compose.rootless.yml index 7cbec36eb6e82..eb41bf9bcaf83 100644 --- a/docker/docker-compose.rootless.yml +++ b/docker/docker-compose.rootless.yml @@ -61,7 +61,7 @@ services: redis: container_name: immich_redis - image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d + image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6 user: '1000:1000' security_opt: - no-new-privileges:true diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 712114b777aad..a850ae0bfce47 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -49,7 +49,7 @@ services: redis: container_name: immich_redis - image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d + image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6 healthcheck: test: redis-cli ping || exit 1 restart: always diff --git a/docs/plans/2026-03-10-spaces-phase3-design.md b/docs/plans/2026-03-10-spaces-phase3-design.md new file mode 100644 index 0000000000000..2591ef4e5ce64 --- /dev/null +++ b/docs/plans/2026-03-10-spaces-phase3-design.md @@ -0,0 +1,230 @@ +# Shared Spaces Phase 3: Activity Feed & New-Since-Last-Visit — Design + +## Goal + +Add two features to shared spaces (web only): + +1. **Unified side panel** — replaces the existing `SpaceMembersPanel` with a tabbed panel containing an Activity feed (default) and Members tab +2. **"New since last visit" timeline marker** — divider + background tint showing assets added since the user's last visit + +## Methodology + +**Test-Driven Development throughout.** Every component, service method, and repository query is written test-first. The cycle is: write failing test → implement minimum code to pass → refactor. No implementation code without a preceding test. + +--- + +## Feature 1: Unified Side Panel + +### Server + +#### New table: `shared_space_activity` + +| Column | Type | Description | +| ----------- | ------------ | -------------------------------------------- | +| `id` | uuid v7 (PK) | Auto-generated | +| `spaceId` | uuid (FK) | Reference to `shared_space`, CASCADE delete | +| `userId` | uuid (FK) | Who performed the action, SET NULL on delete | +| `type` | varchar(30) | Event type enum | +| `data` | jsonb | Event-specific payload | +| `createdAt` | timestamptz | When it happened | + +Indexes: `shared_space_activity_spaceId_createdAt_idx` (compound, for feed queries sorted by time). + +#### Event types + +| Type | Logged in | `data` payload | +| -------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------- | +| `asset_add` | `addAssets()` | `{ count: 12, assetIds: ["id1","id2","id3","id4"] }` (first 4 IDs for thumbnail strip) | +| `asset_remove` | `removeAssets()` | `{ count: 3 }` | +| `member_join` | `addMember()` | `{ role: "editor", invitedById: "..." }` | +| `member_leave` | `removeMember()` (self) | `{}` | +| `member_remove` | `removeMember()` (by owner) | `{ removedUserId: "..." }` | +| `member_role_change` | `updateMember()` | `{ targetUserId: "...", oldRole: "viewer", newRole: "editor" }` | +| `cover_change` | `update()` when `thumbnailAssetId` changes | `{ assetId: "..." }` | +| `space_rename` | `update()` when `name` changes | `{ oldName: "...", newName: "..." }` | +| `space_color_change` | `update()` when `color` changes | `{ oldColor: "...", newColor: "..." }` | + +One row per API call. No time-window aggregation. + +#### Repository methods + +- `logActivity(spaceId, userId, type, data)` — insert row +- `getActivities(spaceId, { limit, offset })` — paginated feed, ordered by `createdAt DESC`, joined with `users` table for avatar/name + +#### Service changes + +Inline logging in existing methods. Each method calls `logActivity()` after the primary operation succeeds. No separate job queue — synchronous insert within the same request. + +#### API + +- `GET /shared-spaces/:id/activities?limit=50&offset=0` — paginated activity feed. Permission: `SharedSpaceRead`. + +### Web + +#### Unified panel component: `space-panel.svelte` + +Replaces `SpaceMembersPanel`. Same slide-out behavior (right edge, 380px desktop, full-width mobile) but with a tabbed interface. + +**Tab system:** Segmented control (pill-shaped toggle) in the header. Active segment uses the space's `UserAvatarColor` as background. Inactive segment is transparent with muted text. Matches existing `rounded-full` language from role badges and stat chips. + +``` +┌─────────────────────────────────────────┐ +│ ╭─────────────────────────────────╮ │ +│ │ ● Activity │ Members (5) │ ✕ │ +│ ╰─────────────────────────────────╯ │ +├─────────────────────────────────────────┤ +│ (tab content) │ +└─────────────────────────────────────────┘ +``` + +**Backdrop:** `bg-black/20 backdrop-blur-[2px]` instead of solid opacity — keeps timeline visible but softly defocused. + +**Content stagger:** On panel open, header appears immediately, then feed items stagger in with 30ms CSS `animation-delay` per item (max 8 staggered, rest instant). + +#### Activity tab: `space-activity-feed.svelte` + +Chronological feed grouped by day. + +**Day headers:** Sticky date separators using `text-xs font-semibold uppercase tracking-wider text-gray-400`. Pattern matches timeline date headers in the main grid. + +``` +── Today ────────────────────────── +── Yesterday ────────────────────── +── March 7 ───────────────────────── +``` + +**Three visual tiers by event type:** + +**High-impact** (`asset_add`, `asset_remove`) — full card with avatar + thumbnail strip: + +``` +┌─────────────────────────────────────┐ +│ [Avatar] Pierre │ +│ Added 12 photos 2h │ +│ ┌────┬────┬────┬────┐ │ +│ │ th │ th │ th │ th │ +8 more │ +│ └────┴────┴────┴────┘ │ +└─────────────────────────────────────┘ +``` + +Mini thumbnail strip: up to 4 thumbs (32x32, `rounded-md`). Asset IDs from `data.assetIds`. `+N more` badge if count > 4. + +**Medium** (`member_join`, `member_leave`, `member_remove`, `member_role_change`) — single row with avatar + left border accent: + +``` +│▎ [Avatar] Alex joined as Editor │ +│▎ Invited by Pierre 3d │ +``` + +2px left border in member's avatar color (`border-l-2`). + +**Low-impact** (`cover_change`, `space_rename`, `space_color_change`) — compact single line, no avatar: + +``` +│ ● Marie set a new cover photo 5d │ +``` + +Small dot (●) in space color. `text-sm text-gray-500`. + +**Empty state:** Space gradient at 10% opacity behind centered text: "This space just got started. Add photos and invite members to see activity here." + +**Pagination:** "Load more" button at the bottom, fetches next page via offset. + +#### Members tab: `space-members-tab.svelte` + +Existing `SpaceMembersPanel` content extracted into a tab component. Same contribution cards, role management, add member button. No behavioral changes. + +#### Trigger button + +The header button that opens the panel shows a pulsing unread dot (reuse `ActivityBadge` pattern) when new activity events exist since `lastViewedAt`. + +--- + +## Feature 2: "New Since Last Visit" Timeline Marker + +### Server + +No new endpoints needed. The existing `markSpaceViewed()` (fires on mount) and `lastViewedAt` / `newAssetCount` in the space response provide all necessary data. + +Add one field to `SharedSpaceResponseDto`: `lastViewedAt` (timestamp) — needed by the client to position the divider in the timeline. + +### Web + +#### Divider component: `space-new-assets-divider.svelte` + +A pill-shaped label centered on a horizontal rule, inserted into the timeline at the `lastViewedAt` boundary. + +``` + ╭──────────────────────────╮ +─────────│ 8 new · since Mar 8 │───────── + ╰──────────────────────────╯ +``` + +- Pill uses the space's color as background (`rounded-full px-3 py-1`), matching hero stat chips +- Icon: `mdiNewBox` left of text +- **Sticky behavior:** Pins to viewport top while scrolling through new assets, then scrolls normally once past the last new item (`position: sticky; top: 0`) + +#### Background tint on new assets + +All assets above the divider (added after `lastViewedAt`) get: + +- Background wash using the space's color at 5% opacity (`bg-{spaceColor}/5`) +- Left border rail: `border-l-2 border-{spaceColor}/30` on the timeline grid section +- Ties the highlight to the space's visual identity, not a generic blue + +**Entry animation:** Timeline renders normally → after 300ms delay, tint fades in (`transition-colors duration-500`) and divider pill scales up (`scale-95` → `scale-100`). + +**Persistence:** Tint stays for the entire session. Clears on next page load because `markSpaceViewed()` fires on mount, advancing `lastViewedAt`. + +#### Edge cases + +- **All assets are new** (first visit after bulk add): no divider, just tint on everything with a subtle top label: "All photos are new since your first visit" +- **No new assets**: nothing rendered (no divider, no tint) +- **0 assets total**: handled by onboarding banner + +--- + +## Testing Strategy + +### TDD Cycle + +Every piece of implementation follows red-green-refactor: + +1. **Write the failing test first** — define the expected behavior +2. **Write minimum code to pass** — no extras +3. **Refactor** — clean up while tests stay green + +### Server tests (Vitest, `newTestService()`) + +- **Activity logging:** test that each service method (`addAssets`, `removeAssets`, `addMember`, `removeMember`, `updateMember`, `update`) calls `logActivity` with correct type and data payload +- **Activity feed query:** test pagination, ordering, user join, permission check +- **Edge cases:** activity logged even when space has no other members, activity user join returns name/avatar, deleted user shows as null +- **`lastViewedAt` in response:** test that `get()` includes `lastViewedAt` for the requesting user + +### Web tests (Vitest, @testing-library/svelte) + +- **`space-panel.svelte`:** tab switching, active tab styling with space color, close on Escape, close button, responsive width, backdrop blur +- **`space-activity-feed.svelte`:** renders day headers, three event tiers (high/medium/low impact), thumbnail strip for asset_add, empty state, load more button, stagger animation classes +- **`space-new-assets-divider.svelte`:** renders pill with correct count and date, uses space color, sticky class, not rendered when `newAssetCount === 0` +- **Timeline tint integration:** tint wrapper applies correct opacity class, tint not present when no new assets, "all new" variant + +### E2E tests (Playwright) + +- Create space → add assets → open panel → verify activity feed shows "added N photos" +- Second user views space → first user adds photos → second user revisits → verify divider with correct count +- Panel tab switching between Activity and Members + +--- + +## Scope + +- **Web only** — mobile catches up in a future phase +- **No time-window aggregation** — one activity row per API call +- **No activity for space creation** — the creation event is implicit (the space exists) + +## Non-goals + +- Activity notifications (push/email) +- Activity feed on the spaces list page +- Mobile implementation +- Real-time updates via WebSocket (feed refreshes on panel open) diff --git a/docs/plans/2026-03-10-spaces-phase3-plan.md b/docs/plans/2026-03-10-spaces-phase3-plan.md new file mode 100644 index 0000000000000..9f3ddec6ac938 --- /dev/null +++ b/docs/plans/2026-03-10-spaces-phase3-plan.md @@ -0,0 +1,1496 @@ +# Spaces Phase 3: Activity Feed & New-Since-Last-Visit Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a unified side panel (activity feed + members tabs) and a "new since last visit" timeline marker to the shared spaces web UI. + +**Architecture:** New `shared_space_activity` table logs all space events. Existing service methods get inline activity logging. A new `GET /shared-spaces/:id/activities` endpoint serves the paginated feed. The existing `SpaceMembersPanel` is replaced by a tabbed `SpacePanel` with Activity (default) and Members tabs. The timeline gets a divider + background tint for new-since-last-visit assets. + +**Tech Stack:** NestJS 11 + Kysely (server), SvelteKit + Svelte 5 runes + Tailwind CSS 4 (web), Vitest + @testing-library/svelte (tests), OpenAPI codegen. + +**Methodology:** Strict TDD throughout — write failing test first, implement minimum code, verify green, refactor, commit. + +--- + +## Task 1: Schema — `shared_space_activity` table definition + +**Files:** + +- Create: `server/src/schema/tables/shared-space-activity.table.ts` +- Modify: `server/src/schema/index.ts` (register table) + +**Step 1: Create the table definition** + +```typescript +// server/src/schema/tables/shared-space-activity.table.ts +import { + Column, + CreateDateColumn, + ForeignKeyColumn, + Generated, + PrimaryGeneratedUuidV7Column, + Table, + Timestamp, +} from '@immich/sql-tools'; +import { SharedSpaceTable } from 'src/schema/tables/shared-space.table'; +import { UserTable } from 'src/schema/tables/user.table'; + +@Table('shared_space_activity') +export class SharedSpaceActivityTable { + @PrimaryGeneratedUuidV7Column() + id!: Generated; + + @ForeignKeyColumn(() => SharedSpaceTable, { onDelete: 'CASCADE', index: false }) + spaceId!: string; + + @ForeignKeyColumn(() => UserTable, { onDelete: 'SET NULL', nullable: true }) + userId!: string | null; + + @Column({ type: 'character varying', length: 30 }) + type!: string; + + @Column({ type: 'jsonb', default: "'{}'" }) + data!: Generated>; + + @CreateDateColumn() + createdAt!: Generated; +} +``` + +**Step 2: Register in schema index** + +In `server/src/schema/index.ts`, add the import and register the table alongside the other shared-space tables: + +```typescript +import { SharedSpaceActivityTable } from 'src/schema/tables/shared-space-activity.table'; +``` + +Add `SharedSpaceActivityTable` to the table array and add `shared_space_activity: SharedSpaceActivityTable` to the DB interface. + +**Step 3: Commit** + +```bash +git add server/src/schema/tables/shared-space-activity.table.ts server/src/schema/index.ts +git commit -m "feat: add shared_space_activity table definition" +``` + +--- + +## Task 2: Database migration + +**Files:** + +- Create: `server/src/schema/migrations/1772810000000-AddSharedSpaceActivityTable.ts` + +**Step 1: Write the migration** + +```typescript +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('shared_space_activity') + .addColumn('id', 'uuid', (col) => col.primaryKey().defaultTo(sql`gen_random_uuid()`)) + .addColumn('spaceId', 'uuid', (col) => col.notNull().references('shared_space.id').onDelete('cascade')) + .addColumn('userId', 'uuid', (col) => col.references('users.id').onDelete('set null')) + .addColumn('type', 'varchar(30)', (col) => col.notNull()) + .addColumn('data', 'jsonb', (col) => col.notNull().defaultTo(sql`'{}'::jsonb`)) + .addColumn('createdAt', 'timestamptz', (col) => col.notNull().defaultTo(sql`now()`)) + .execute(); + + await db.schema + .createIndex('shared_space_activity_spaceId_createdAt_idx') + .on('shared_space_activity') + .columns(['spaceId', 'createdAt']) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropIndex('shared_space_activity_spaceId_createdAt_idx').execute(); + await db.schema.dropTable('shared_space_activity').execute(); +} +``` + +**Step 2: Commit** + +```bash +git add server/src/schema/migrations/1772810000000-AddSharedSpaceActivityTable.ts +git commit -m "feat: add migration for shared_space_activity table" +``` + +--- + +## Task 3: Database types + +**Files:** + +- Modify: `server/src/database.ts` +- Modify: `server/src/enum.ts` + +**Step 1: Add the enum for activity types** + +In `server/src/enum.ts`, after `SharedSpaceRole`: + +```typescript +export enum SharedSpaceActivityType { + AssetAdd = 'asset_add', + AssetRemove = 'asset_remove', + MemberJoin = 'member_join', + MemberLeave = 'member_leave', + MemberRemove = 'member_remove', + MemberRoleChange = 'member_role_change', + CoverChange = 'cover_change', + SpaceRename = 'space_rename', + SpaceColorChange = 'space_color_change', +} +``` + +**Step 2: Add the type to `database.ts`** + +After the `SharedSpaceAsset` type: + +```typescript +export type SharedSpaceActivity = { + id: string; + spaceId: string; + userId: string | null; + type: string; + data: Record; + createdAt: Date; +}; +``` + +**Step 3: Commit** + +```bash +git add server/src/database.ts server/src/enum.ts +git commit -m "feat: add SharedSpaceActivityType enum and database type" +``` + +--- + +## Task 4: DTOs for activity + +**Files:** + +- Modify: `server/src/dtos/shared-space.dto.ts` + +**Step 1: Add activity response DTO and query DTO** + +```typescript +export class SharedSpaceActivityQueryDto { + @ApiPropertyOptional({ description: 'Number of items to return', default: 50 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number; + + @ApiPropertyOptional({ description: 'Number of items to skip', default: 0 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + offset?: number; +} + +export class SharedSpaceActivityResponseDto { + @ApiProperty({ description: 'Activity ID' }) + id!: string; + + @ApiProperty({ description: 'Activity type' }) + type!: string; + + @ApiProperty({ description: 'Event-specific data' }) + data!: Record; + + @ApiProperty({ description: 'When the event occurred' }) + createdAt!: string; + + @ApiPropertyOptional({ description: 'User ID who performed the action' }) + userId?: string | null; + + @ApiPropertyOptional({ description: 'User name' }) + userName?: string | null; + + @ApiPropertyOptional({ description: 'User email' }) + userEmail?: string | null; + + @ApiPropertyOptional({ description: 'User profile image path' }) + userProfileImagePath?: string | null; + + @ApiPropertyOptional({ description: 'User avatar color' }) + userAvatarColor?: string | null; +} +``` + +Add necessary imports: `Type` from `class-transformer`, `IsInt`, `Min`, `Max` from `class-validator`. + +**Step 2: Add `lastViewedAt` to `SharedSpaceResponseDto`** + +Add this field to the existing DTO: + +```typescript + @ApiPropertyOptional({ description: 'When the current user last viewed this space' }) + lastViewedAt?: string | null; +``` + +**Step 3: Commit** + +```bash +git add server/src/dtos/shared-space.dto.ts +git commit -m "feat: add activity DTOs and lastViewedAt to space response" +``` + +--- + +## Task 5: Repository — `logActivity` and `getActivities` + +**Files:** + +- Modify: `server/src/repositories/shared-space.repository.ts` + +**Step 1: Write failing test for `logActivity`** + +In `server/src/services/shared-space.service.spec.ts`, add a new describe block. But first we need the repository method. Since repository methods are auto-mocked in tests, we only need to verify the service calls them. The repository itself is tested via medium tests. Add the methods directly. + +**Step 2: Add `logActivity` method** + +```typescript +async logActivity(values: { spaceId: string; userId: string; type: string; data?: Record }) { + await this.db + .insertInto('shared_space_activity') + .values({ + spaceId: values.spaceId, + userId: values.userId, + type: values.type, + data: JSON.stringify(values.data ?? {}), + }) + .execute(); +} +``` + +**Step 3: Add `getActivities` method** + +```typescript +@GenerateSql({ params: [DummyValue.UUID, 50, 0] }) +getActivities(spaceId: string, limit: number = 50, offset: number = 0) { + return this.db + .selectFrom('shared_space_activity') + .leftJoin('users', 'users.id', 'shared_space_activity.userId') + .select([ + 'shared_space_activity.id', + 'shared_space_activity.type', + 'shared_space_activity.data', + 'shared_space_activity.createdAt', + 'shared_space_activity.userId', + 'users.name', + 'users.email', + 'users.profileImagePath', + 'users.avatarColor', + ]) + .where('shared_space_activity.spaceId', '=', spaceId) + .orderBy('shared_space_activity.createdAt', 'desc') + .limit(limit) + .offset(offset) + .execute(); +} +``` + +Add the import for `SharedSpaceActivityTable` at the top of the file. + +**Step 4: Commit** + +```bash +git add server/src/repositories/shared-space.repository.ts +git commit -m "feat: add logActivity and getActivities repository methods" +``` + +--- + +## Task 6: Test factory for activity + +**Files:** + +- Modify: `server/test/small.factory.ts` + +**Step 1: Add activity factory** + +After `sharedSpaceMemberFactory`: + +```typescript +const sharedSpaceActivityFactory = (data: Partial = {}): SharedSpaceActivity => ({ + id: newUuid(), + spaceId: newUuid(), + userId: newUuid(), + type: 'asset_add', + data: {}, + createdAt: newDate(), + ...data, +}); +``` + +Export it in the factory object as `sharedSpaceActivity`. + +Import `SharedSpaceActivity` from `src/database`. + +**Step 2: Commit** + +```bash +git add server/test/small.factory.ts +git commit -m "feat: add sharedSpaceActivity test factory" +``` + +--- + +## Task 7: Service tests — activity logging in `addAssets` + +**Files:** + +- Modify: `server/src/services/shared-space.service.spec.ts` + +**Step 1: Write failing tests** + +Add a new test in the `addAssets` describe block: + +```typescript +it('should log activity when adding assets', async () => { + mocks.sharedSpace.getMember.mockResolvedValue(makeMemberResult({ role: SharedSpaceRole.Editor })); + mocks.sharedSpace.addAssets.mockResolvedValue(void 0); + mocks.sharedSpace.update.mockResolvedValue(factory.sharedSpace()); + mocks.sharedSpace.logActivity.mockResolvedValue(void 0); + + await sut.addAssets(factory.auth(), 'space-1', { assetIds: ['a1', 'a2', 'a3'] }); + + expect(mocks.sharedSpace.logActivity).toHaveBeenCalledWith({ + spaceId: 'space-1', + userId: expect.any(String), + type: SharedSpaceActivityType.AssetAdd, + data: { count: 3, assetIds: ['a1', 'a2', 'a3'] }, + }); +}); +``` + +Import `SharedSpaceActivityType` from `src/enum`. + +**Step 2: Run test to verify it fails** + +```bash +cd server && pnpm test -- --run src/services/shared-space.service.spec.ts +``` + +Expected: FAIL — `logActivity` not called. + +**Step 3: Implement in service** + +In `addAssets()`, after `this.sharedSpaceRepository.update(...)`: + +```typescript +await this.sharedSpaceRepository.logActivity({ + spaceId, + userId: auth.user.id, + type: SharedSpaceActivityType.AssetAdd, + data: { count: dto.assetIds.length, assetIds: dto.assetIds.slice(0, 4) }, +}); +``` + +Import `SharedSpaceActivityType` from `src/enum`. + +**Step 4: Run test to verify it passes** + +```bash +cd server && pnpm test -- --run src/services/shared-space.service.spec.ts +``` + +**Step 5: Commit** + +```bash +git add server/src/services/shared-space.service.ts server/src/services/shared-space.service.spec.ts +git commit -m "feat: log activity on addAssets" +``` + +--- + +## Task 8: Service tests — activity logging in `removeAssets` + +**Files:** + +- Modify: `server/src/services/shared-space.service.spec.ts` +- Modify: `server/src/services/shared-space.service.ts` + +**Step 1: Write failing test** + +```typescript +it('should log activity when removing assets', async () => { + mocks.sharedSpace.getMember.mockResolvedValue(makeMemberResult({ role: SharedSpaceRole.Editor })); + mocks.sharedSpace.getById.mockResolvedValue(factory.sharedSpace()); + mocks.sharedSpace.removeAssets.mockResolvedValue(void 0); + mocks.sharedSpace.getLastAssetAddedAt.mockResolvedValue(void 0); + mocks.sharedSpace.update.mockResolvedValue(factory.sharedSpace()); + mocks.sharedSpace.logActivity.mockResolvedValue(void 0); + + await sut.removeAssets(factory.auth(), 'space-1', { assetIds: ['a1', 'a2'] }); + + expect(mocks.sharedSpace.logActivity).toHaveBeenCalledWith({ + spaceId: 'space-1', + userId: expect.any(String), + type: SharedSpaceActivityType.AssetRemove, + data: { count: 2 }, + }); +}); +``` + +**Step 2: Run test — verify FAIL** + +**Step 3: Implement in `removeAssets()`** + +After `this.sharedSpaceRepository.update(spaceId, updateData)`: + +```typescript +await this.sharedSpaceRepository.logActivity({ + spaceId, + userId: auth.user.id, + type: SharedSpaceActivityType.AssetRemove, + data: { count: dto.assetIds.length }, +}); +``` + +**Step 4: Run test — verify PASS** + +**Step 5: Commit** + +```bash +git add server/src/services/shared-space.service.ts server/src/services/shared-space.service.spec.ts +git commit -m "feat: log activity on removeAssets" +``` + +--- + +## Task 9: Service tests — activity logging in `addMember` + +**Files:** + +- Modify: `server/src/services/shared-space.service.spec.ts` +- Modify: `server/src/services/shared-space.service.ts` + +**Step 1: Write failing test** + +```typescript +it('should log activity when adding a member', async () => { + const auth = factory.auth(); + mocks.sharedSpace.getMember.mockResolvedValueOnce(makeMemberResult({ role: SharedSpaceRole.Owner })); + mocks.sharedSpace.getMember.mockResolvedValueOnce(null); // not existing + mocks.sharedSpace.addMember.mockResolvedValue(void 0); + mocks.sharedSpace.getMember.mockResolvedValueOnce( + makeMemberResult({ userId: 'new-user', role: SharedSpaceRole.Editor }), + ); + mocks.sharedSpace.logActivity.mockResolvedValue(void 0); + + await sut.addMember(auth, 'space-1', { userId: 'new-user', role: SharedSpaceRole.Editor }); + + expect(mocks.sharedSpace.logActivity).toHaveBeenCalledWith({ + spaceId: 'space-1', + userId: 'new-user', + type: SharedSpaceActivityType.MemberJoin, + data: { role: SharedSpaceRole.Editor, invitedById: auth.user.id }, + }); +}); +``` + +**Step 2: Run test — verify FAIL** + +**Step 3: Implement in `addMember()`** + +After `return this.mapMember(member)`, but before the return, add: + +```typescript +await this.sharedSpaceRepository.logActivity({ + spaceId, + userId: dto.userId, + type: SharedSpaceActivityType.MemberJoin, + data: { role, invitedById: auth.user.id }, +}); +``` + +**Step 4: Run test — verify PASS** + +**Step 5: Commit** + +```bash +git add server/src/services/shared-space.service.ts server/src/services/shared-space.service.spec.ts +git commit -m "feat: log activity on addMember" +``` + +--- + +## Task 10: Service tests — activity logging in `removeMember` (self-leave and owner-remove) + +**Files:** + +- Modify: `server/src/services/shared-space.service.spec.ts` +- Modify: `server/src/services/shared-space.service.ts` + +**Step 1: Write failing tests** + +```typescript +it('should log member_leave when member leaves', async () => { + const auth = factory.auth({ id: 'user-1' }); + mocks.sharedSpace.getMember.mockResolvedValue(makeMemberResult({ userId: 'user-1', role: SharedSpaceRole.Editor })); + mocks.sharedSpace.removeMember.mockResolvedValue(void 0); + mocks.sharedSpace.logActivity.mockResolvedValue(void 0); + + await sut.removeMember(auth, 'space-1', 'user-1'); + + expect(mocks.sharedSpace.logActivity).toHaveBeenCalledWith({ + spaceId: 'space-1', + userId: 'user-1', + type: SharedSpaceActivityType.MemberLeave, + data: {}, + }); +}); + +it('should log member_remove when owner removes a member', async () => { + const auth = factory.auth({ id: 'owner-1' }); + mocks.sharedSpace.getMember.mockResolvedValue(makeMemberResult({ userId: 'owner-1', role: SharedSpaceRole.Owner })); + mocks.sharedSpace.removeMember.mockResolvedValue(void 0); + mocks.sharedSpace.logActivity.mockResolvedValue(void 0); + + await sut.removeMember(auth, 'space-1', 'other-user'); + + expect(mocks.sharedSpace.logActivity).toHaveBeenCalledWith({ + spaceId: 'space-1', + userId: auth.user.id, + type: SharedSpaceActivityType.MemberRemove, + data: { removedUserId: 'other-user' }, + }); +}); +``` + +**Step 2: Run test — verify FAIL** + +**Step 3: Implement in `removeMember()`** + +For the self-leave case, after `this.sharedSpaceRepository.removeMember(spaceId, userId)`: + +```typescript +await this.sharedSpaceRepository.logActivity({ + spaceId, + userId, + type: SharedSpaceActivityType.MemberLeave, + data: {}, +}); +``` + +For the owner-remove case, after the second `this.sharedSpaceRepository.removeMember(spaceId, userId)`: + +```typescript +await this.sharedSpaceRepository.logActivity({ + spaceId, + userId: auth.user.id, + type: SharedSpaceActivityType.MemberRemove, + data: { removedUserId: userId }, +}); +``` + +**Step 4: Run test — verify PASS** + +**Step 5: Commit** + +```bash +git add server/src/services/shared-space.service.ts server/src/services/shared-space.service.spec.ts +git commit -m "feat: log activity on removeMember (leave and remove)" +``` + +--- + +## Task 11: Service tests — activity logging in `updateMember` (role change) + +**Files:** + +- Modify: `server/src/services/shared-space.service.spec.ts` +- Modify: `server/src/services/shared-space.service.ts` + +**Step 1: Write failing test** + +```typescript +it('should log activity when changing a member role', async () => { + const auth = factory.auth({ id: 'owner-1' }); + const targetMember = makeMemberResult({ userId: 'target-user', role: SharedSpaceRole.Viewer }); + mocks.sharedSpace.getMember.mockResolvedValueOnce( + makeMemberResult({ userId: 'owner-1', role: SharedSpaceRole.Owner }), + ); + mocks.sharedSpace.updateMember.mockResolvedValue(void 0); + mocks.sharedSpace.getMember.mockResolvedValueOnce( + makeMemberResult({ userId: 'target-user', role: SharedSpaceRole.Editor }), + ); + mocks.sharedSpace.logActivity.mockResolvedValue(void 0); + + // Need to get old role before update — read current member first + // The implementation should fetch the old role before updating + await sut.updateMember(auth, 'space-1', 'target-user', { role: SharedSpaceRole.Editor }); + + expect(mocks.sharedSpace.logActivity).toHaveBeenCalledWith({ + spaceId: 'space-1', + userId: auth.user.id, + type: SharedSpaceActivityType.MemberRoleChange, + data: { targetUserId: 'target-user', newRole: SharedSpaceRole.Editor }, + }); +}); +``` + +**Step 2: Run test — verify FAIL** + +**Step 3: Implement in `updateMember()`** + +After the existing `return this.mapMember(member)`, but before the return: + +```typescript +await this.sharedSpaceRepository.logActivity({ + spaceId, + userId: auth.user.id, + type: SharedSpaceActivityType.MemberRoleChange, + data: { targetUserId: userId, newRole: dto.role }, +}); +``` + +**Step 4: Run test — verify PASS** + +**Step 5: Commit** + +```bash +git add server/src/services/shared-space.service.ts server/src/services/shared-space.service.spec.ts +git commit -m "feat: log activity on updateMember role change" +``` + +--- + +## Task 12: Service tests — activity logging in `update()` (rename, color, cover) + +**Files:** + +- Modify: `server/src/services/shared-space.service.spec.ts` +- Modify: `server/src/services/shared-space.service.ts` + +**Step 1: Write failing tests** + +```typescript +it('should log space_rename when name changes', async () => { + const space = factory.sharedSpace({ name: 'Old Name' }); + mocks.sharedSpace.getMember.mockResolvedValue(makeMemberResult({ role: SharedSpaceRole.Owner })); + mocks.sharedSpace.getById.mockResolvedValue(space); + mocks.sharedSpace.update.mockResolvedValue({ ...space, name: 'New Name' }); + mocks.sharedSpace.logActivity.mockResolvedValue(void 0); + + await sut.update(factory.auth(), space.id, { name: 'New Name' }); + + expect(mocks.sharedSpace.logActivity).toHaveBeenCalledWith({ + spaceId: space.id, + userId: expect.any(String), + type: SharedSpaceActivityType.SpaceRename, + data: { oldName: 'Old Name', newName: 'New Name' }, + }); +}); + +it('should log space_color_change when color changes', async () => { + const space = factory.sharedSpace({ color: 'primary' }); + mocks.sharedSpace.getMember.mockResolvedValue(makeMemberResult({ role: SharedSpaceRole.Owner })); + mocks.sharedSpace.getById.mockResolvedValue(space); + mocks.sharedSpace.update.mockResolvedValue({ ...space, color: 'blue' }); + mocks.sharedSpace.logActivity.mockResolvedValue(void 0); + + await sut.update(factory.auth(), space.id, { color: UserAvatarColor.Blue }); + + expect(mocks.sharedSpace.logActivity).toHaveBeenCalledWith({ + spaceId: space.id, + userId: expect.any(String), + type: SharedSpaceActivityType.SpaceColorChange, + data: { oldColor: 'primary', newColor: UserAvatarColor.Blue }, + }); +}); + +it('should log cover_change when thumbnailAssetId changes', async () => { + const space = factory.sharedSpace({ thumbnailAssetId: null }); + mocks.sharedSpace.getMember.mockResolvedValue(makeMemberResult({ role: SharedSpaceRole.Editor })); + mocks.sharedSpace.getById.mockResolvedValue(space); + mocks.sharedSpace.update.mockResolvedValue({ ...space, thumbnailAssetId: 'asset-1' }); + mocks.sharedSpace.logActivity.mockResolvedValue(void 0); + + await sut.update(factory.auth(), space.id, { thumbnailAssetId: 'asset-1' }); + + expect(mocks.sharedSpace.logActivity).toHaveBeenCalledWith({ + spaceId: space.id, + userId: expect.any(String), + type: SharedSpaceActivityType.CoverChange, + data: { assetId: 'asset-1' }, + }); +}); + +it('should not log activity when update has no meaningful changes', async () => { + const space = factory.sharedSpace({ description: null }); + mocks.sharedSpace.getMember.mockResolvedValue(makeMemberResult({ role: SharedSpaceRole.Owner })); + mocks.sharedSpace.getById.mockResolvedValue(space); + mocks.sharedSpace.update.mockResolvedValue(space); + mocks.sharedSpace.logActivity.mockResolvedValue(void 0); + + await sut.update(factory.auth(), space.id, { description: 'New desc' }); + + expect(mocks.sharedSpace.logActivity).not.toHaveBeenCalled(); +}); +``` + +**Step 2: Run test — verify FAIL** + +**Step 3: Implement in `update()`** + +The `update()` method needs to fetch the current space before updating to detect changes. Modify it: + +```typescript +async update(auth: AuthDto, id: string, dto: SharedSpaceUpdateDto): Promise { + const isMetadataUpdate = dto.name !== undefined || dto.description !== undefined || dto.color !== undefined; + const minimumRole = isMetadataUpdate ? SharedSpaceRole.Owner : SharedSpaceRole.Editor; + await this.requireRole(auth, id, minimumRole); + + const existing = await this.sharedSpaceRepository.getById(id); + const space = await this.sharedSpaceRepository.update(id, { + name: dto.name, + description: dto.description, + thumbnailAssetId: dto.thumbnailAssetId, + color: dto.color, + }); + + // Log activity for meaningful changes + if (existing) { + if (dto.name !== undefined && dto.name !== existing.name) { + await this.sharedSpaceRepository.logActivity({ + spaceId: id, + userId: auth.user.id, + type: SharedSpaceActivityType.SpaceRename, + data: { oldName: existing.name, newName: dto.name }, + }); + } + if (dto.color !== undefined && dto.color !== existing.color) { + await this.sharedSpaceRepository.logActivity({ + spaceId: id, + userId: auth.user.id, + type: SharedSpaceActivityType.SpaceColorChange, + data: { oldColor: existing.color, newColor: dto.color }, + }); + } + if (dto.thumbnailAssetId !== undefined && dto.thumbnailAssetId !== existing.thumbnailAssetId) { + await this.sharedSpaceRepository.logActivity({ + spaceId: id, + userId: auth.user.id, + type: SharedSpaceActivityType.CoverChange, + data: { assetId: dto.thumbnailAssetId }, + }); + } + } + + return this.mapSpace(space); +} +``` + +**Step 4: Run test — verify PASS** + +**Step 5: Commit** + +```bash +git add server/src/services/shared-space.service.ts server/src/services/shared-space.service.spec.ts +git commit -m "feat: log activity on space update (rename, color, cover)" +``` + +--- + +## Task 13: Service — `getActivities` method with tests + +**Files:** + +- Modify: `server/src/services/shared-space.service.ts` +- Modify: `server/src/services/shared-space.service.spec.ts` + +**Step 1: Write failing tests** + +```typescript +describe('getActivities', () => { + it('should require membership', async () => { + mocks.sharedSpace.getMember.mockResolvedValue(null); + await expect(sut.getActivities(factory.auth(), 'space-1', {})).rejects.toThrow('Not a member'); + }); + + it('should return mapped activities', async () => { + mocks.sharedSpace.getMember.mockResolvedValue(makeMemberResult()); + mocks.sharedSpace.getActivities.mockResolvedValue([ + { + id: 'act-1', + type: 'asset_add', + data: { count: 5 }, + createdAt: new Date('2026-03-10T12:00:00Z'), + userId: 'user-1', + name: 'Pierre', + email: 'pierre@test.com', + profileImagePath: '/path/to/img', + avatarColor: 'primary', + }, + ]); + + const result = await sut.getActivities(factory.auth(), 'space-1', {}); + + expect(result).toEqual([ + { + id: 'act-1', + type: 'asset_add', + data: { count: 5 }, + createdAt: '2026-03-10T12:00:00.000Z', + userId: 'user-1', + userName: 'Pierre', + userEmail: 'pierre@test.com', + userProfileImagePath: '/path/to/img', + userAvatarColor: 'primary', + }, + ]); + }); + + it('should pass limit and offset to repository', async () => { + mocks.sharedSpace.getMember.mockResolvedValue(makeMemberResult()); + mocks.sharedSpace.getActivities.mockResolvedValue([]); + + await sut.getActivities(factory.auth(), 'space-1', { limit: 10, offset: 20 }); + + expect(mocks.sharedSpace.getActivities).toHaveBeenCalledWith('space-1', 10, 20); + }); +}); +``` + +**Step 2: Run test — verify FAIL** + +**Step 3: Implement `getActivities` in service** + +```typescript +async getActivities( + auth: AuthDto, + spaceId: string, + query: { limit?: number; offset?: number }, +): Promise { + await this.requireMembership(auth, spaceId); + + const activities = await this.sharedSpaceRepository.getActivities( + spaceId, + query.limit ?? 50, + query.offset ?? 0, + ); + + return activities.map((a) => ({ + id: a.id, + type: a.type, + data: a.data as Record, + createdAt: (a.createdAt as unknown as Date).toISOString(), + userId: a.userId, + userName: a.name, + userEmail: a.email, + userProfileImagePath: a.profileImagePath, + userAvatarColor: a.avatarColor, + })); +} +``` + +Import `SharedSpaceActivityResponseDto` in the service imports. + +**Step 4: Run test — verify PASS** + +**Step 5: Commit** + +```bash +git add server/src/services/shared-space.service.ts server/src/services/shared-space.service.spec.ts +git commit -m "feat: add getActivities service method with tests" +``` + +--- + +## Task 14: Service — add `lastViewedAt` to space response + +**Files:** + +- Modify: `server/src/services/shared-space.service.ts` +- Modify: `server/src/services/shared-space.service.spec.ts` + +**Step 1: Write failing test** + +```typescript +it('should include lastViewedAt in get response', async () => { + const space = factory.sharedSpace(); + const viewedAt = new Date('2026-03-09T10:00:00Z'); + mocks.sharedSpace.getMember.mockResolvedValue(makeMemberResult({ lastViewedAt: viewedAt })); + mocks.sharedSpace.getById.mockResolvedValue(space); + mocks.sharedSpace.getMembers.mockResolvedValue([]); + mocks.sharedSpace.getAssetCount.mockResolvedValue(0); + mocks.sharedSpace.getRecentAssets.mockResolvedValue([]); + + const result = await sut.get(factory.auth(), space.id); + + expect(result.lastViewedAt).toBe('2026-03-09T10:00:00.000Z'); +}); +``` + +**Step 2: Run test — verify FAIL** + +**Step 3: Implement in `get()`** + +In the `get()` method, the `requireMembership` call already returns the member. Use it: + +```typescript +async get(auth: AuthDto, id: string): Promise { + const membership = await this.requireMembership(auth, id); + // ... existing code ... + return { + ...this.mapSpace(space), + thumbnailAssetId, + memberCount: members.length, + assetCount, + // ... existing fields ... + lastViewedAt: membership.lastViewedAt ? membership.lastViewedAt.toISOString() : null, + }; +} +``` + +**Step 4: Run test — verify PASS** + +**Step 5: Commit** + +```bash +git add server/src/services/shared-space.service.ts server/src/services/shared-space.service.spec.ts +git commit -m "feat: include lastViewedAt in space response" +``` + +--- + +## Task 15: Controller — `GET /shared-spaces/:id/activities` + +**Files:** + +- Modify: `server/src/controllers/shared-space.controller.ts` + +**Step 1: Add the endpoint** + +```typescript +@Get(':id/activities') +@Authenticated({ permission: Permission.SharedSpaceRead }) +@Endpoint({ operationId: 'getSpaceActivities', summary: 'Get space activity feed' }) +getSpaceActivities( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Query() query: SharedSpaceActivityQueryDto, +): Promise { + return this.service.getActivities(auth, id, query); +} +``` + +Add `Query` to the NestJS imports. Add `SharedSpaceActivityQueryDto` and `SharedSpaceActivityResponseDto` to the DTO imports. + +**Step 2: Commit** + +```bash +git add server/src/controllers/shared-space.controller.ts +git commit -m "feat: add GET /shared-spaces/:id/activities endpoint" +``` + +--- + +## Task 16: OpenAPI regeneration + +**Step 1: Build server and regenerate** + +```bash +cd server && pnpm build +cd server && pnpm sync:open-api +make open-api +``` + +**Step 2: Verify generated TypeScript SDK includes new types** + +Check that `open-api/typescript-sdk/` contains `getSpaceActivities`, `SharedSpaceActivityResponseDto`, and `SharedSpaceActivityQueryDto`. + +**Step 3: Delete any `.rej` files** + +```bash +find open-api/ -name "*.rej" -delete +``` + +**Step 4: Commit** + +```bash +git add open-api/ server/src/queries/ +git commit -m "chore: regenerate OpenAPI clients and SQL docs" +``` + +--- + +## Task 17: Web — `space-activity-feed.svelte` component (tests first) + +**Files:** + +- Create: `web/src/lib/components/spaces/space-activity-feed.spec.ts` +- Create: `web/src/lib/components/spaces/space-activity-feed.svelte` + +**Step 1: Write failing tests** + +```typescript +// web/src/lib/components/spaces/space-activity-feed.spec.ts +import TestWrapper from '$lib/components/TestWrapper.svelte'; +import SpaceActivityFeed from '$lib/components/spaces/space-activity-feed.svelte'; +import { render, screen } from '@testing-library/svelte'; +import type { Component } from 'svelte'; + +function renderFeed(props: Record) { + return render(TestWrapper as Component<{ component: typeof SpaceActivityFeed; componentProps: typeof props }>, { + component: SpaceActivityFeed, + componentProps: props, + }); +} + +const makeActivity = (overrides: Record = {}) => ({ + id: 'act-1', + type: 'asset_add', + data: { count: 5, assetIds: ['a1', 'a2'] }, + createdAt: new Date().toISOString(), + userId: 'u1', + userName: 'Pierre', + userEmail: 'pierre@test.com', + userProfileImagePath: null, + userAvatarColor: 'primary', + ...overrides, +}); + +describe('SpaceActivityFeed', () => { + it('should show empty state when no activities', () => { + renderFeed({ activities: [], spaceColor: 'primary', onLoadMore: vi.fn(), hasMore: false }); + expect(screen.getByTestId('activity-empty-state')).toBeInTheDocument(); + }); + + it('should render asset_add event with thumbnail strip', () => { + const activities = [makeActivity({ type: 'asset_add', data: { count: 5, assetIds: ['a1', 'a2'] } })]; + renderFeed({ activities, spaceColor: 'primary', onLoadMore: vi.fn(), hasMore: false }); + expect(screen.getByTestId('activity-item-act-1')).toBeInTheDocument(); + expect(screen.getByTestId('activity-item-act-1')).toHaveTextContent('Pierre'); + expect(screen.getByTestId('activity-item-act-1')).toHaveTextContent('5'); + }); + + it('should render member_join event with medium styling', () => { + const activities = [makeActivity({ id: 'act-2', type: 'member_join', data: { role: 'editor' } })]; + renderFeed({ activities, spaceColor: 'primary', onLoadMore: vi.fn(), hasMore: false }); + expect(screen.getByTestId('activity-item-act-2')).toBeInTheDocument(); + }); + + it('should render space_rename event with compact styling', () => { + const activities = [makeActivity({ id: 'act-3', type: 'space_rename', data: { oldName: 'Old', newName: 'New' } })]; + renderFeed({ activities, spaceColor: 'primary', onLoadMore: vi.fn(), hasMore: false }); + expect(screen.getByTestId('activity-item-act-3')).toBeInTheDocument(); + }); + + it('should show day headers', () => { + const today = new Date().toISOString(); + const activities = [makeActivity({ createdAt: today })]; + renderFeed({ activities, spaceColor: 'primary', onLoadMore: vi.fn(), hasMore: false }); + expect(screen.getByTestId('day-header-0')).toBeInTheDocument(); + }); + + it('should show load more button when hasMore is true', () => { + renderFeed({ activities: [makeActivity()], spaceColor: 'primary', onLoadMore: vi.fn(), hasMore: true }); + expect(screen.getByTestId('load-more-button')).toBeInTheDocument(); + }); + + it('should NOT show load more button when hasMore is false', () => { + renderFeed({ activities: [makeActivity()], spaceColor: 'primary', onLoadMore: vi.fn(), hasMore: false }); + expect(screen.queryByTestId('load-more-button')).not.toBeInTheDocument(); + }); +}); +``` + +**Step 2: Run test — verify FAIL** (component doesn't exist) + +```bash +cd web && pnpm test -- --run src/lib/components/spaces/space-activity-feed.spec.ts +``` + +**Step 3: Implement the component** + +Create `web/src/lib/components/spaces/space-activity-feed.svelte`. The component receives `activities`, `spaceColor`, `onLoadMore`, `hasMore` props. It groups activities by day, renders three visual tiers (high/medium/low impact), shows thumbnail strips for asset_add events, and includes a load more button. + +Key implementation details: + +- Group by day using `toLocaleDateString()`, display as "Today", "Yesterday", or date string +- High-impact events (`asset_add`, `asset_remove`): full card with avatar + optional thumbnail strip (32x32 rounded-md images) +- Medium events (`member_join`, `member_leave`, `member_remove`, `member_role_change`): row with avatar and 2px left border accent +- Low-impact events (`cover_change`, `space_rename`, `space_color_change`): compact single line with dot +- Stagger animation: `animation-delay` of `30ms * index` on items (max 8) +- `data-testid` attributes for all testable elements + +**Step 4: Run test — verify PASS** + +**Step 5: Commit** + +```bash +git add web/src/lib/components/spaces/space-activity-feed.svelte web/src/lib/components/spaces/space-activity-feed.spec.ts +git commit -m "feat: add SpaceActivityFeed component with tests" +``` + +--- + +## Task 18: Web — unified `space-panel.svelte` component (tests first) + +**Files:** + +- Create: `web/src/lib/components/spaces/space-panel.spec.ts` +- Create: `web/src/lib/components/spaces/space-panel.svelte` + +**Step 1: Write failing tests** + +```typescript +// web/src/lib/components/spaces/space-panel.spec.ts +import TestWrapper from '$lib/components/TestWrapper.svelte'; +import SpacePanel from '$lib/components/spaces/space-panel.svelte'; +import { fireEvent, render, screen } from '@testing-library/svelte'; +import type { Component } from 'svelte'; + +function renderPanel(props: Record) { + return render(TestWrapper as Component<{ component: typeof SpacePanel; componentProps: typeof props }>, { + component: SpacePanel, + componentProps: props, + }); +} + +// Reuse makeSpace and makeMember from space-members-panel.spec.ts pattern + +describe('SpacePanel', () => { + const defaultProps = { + space: makeSpace(), + members: [makeMember({ userId: 'u1', name: 'Alice', role: 'owner' })], + activities: [], + currentUserId: 'u1', + isOwner: true, + open: true, + onClose: vi.fn(), + onMembersChanged: vi.fn(), + onLoadMoreActivities: vi.fn(), + hasMoreActivities: false, + }; + + it('should show Activity tab as active by default', () => { + renderPanel(defaultProps); + const activityTab = screen.getByTestId('tab-activity'); + expect(activityTab.className).toContain('bg-'); + }); + + it('should switch to Members tab on click', async () => { + renderPanel(defaultProps); + const membersTab = screen.getByTestId('tab-members'); + await fireEvent.click(membersTab); + expect(screen.getByTestId('member-list')).toBeInTheDocument(); + }); + + it('should show segmented control with space color', () => { + renderPanel(defaultProps); + expect(screen.getByTestId('tab-switcher')).toBeInTheDocument(); + }); + + it('should have translate-x-full when closed', () => { + renderPanel({ ...defaultProps, open: false }); + const panel = screen.getByTestId('space-panel'); + expect(panel.className).toContain('translate-x-full'); + }); + + it('should call onClose on Escape', async () => { + const onClose = vi.fn(); + renderPanel({ ...defaultProps, onClose }); + await fireEvent.keyDown(window, { key: 'Escape' }); + expect(onClose).toHaveBeenCalled(); + }); + + it('should show backdrop with blur on mobile when open', () => { + renderPanel(defaultProps); + const backdrop = screen.getByTestId('panel-backdrop'); + expect(backdrop.className).toContain('backdrop-blur'); + }); +}); +``` + +**Step 2: Run test — verify FAIL** + +**Step 3: Implement the component** + +Create `web/src/lib/components/spaces/space-panel.svelte`. This replaces `space-members-panel.svelte`. Key structure: + +- Segmented control header: two buttons (Activity / Members), active one gets space color background +- `$state` for `activeTab: 'activity' | 'members'` (default: `'activity'`) +- Activity tab renders `SpaceActivityFeed` +- Members tab renders the existing member list (extracted from `space-members-panel.svelte`) +- Same slide-out behavior: `translate-x-full` / `translate-x-0`, Escape to close, backdrop on mobile +- Backdrop uses `bg-black/20 backdrop-blur-[2px]` + +**Step 4: Run test — verify PASS** + +**Step 5: Commit** + +```bash +git add web/src/lib/components/spaces/space-panel.svelte web/src/lib/components/spaces/space-panel.spec.ts +git commit -m "feat: add unified SpacePanel component with tabs" +``` + +--- + +## Task 19: Web — `space-new-assets-divider.svelte` (tests first) + +**Files:** + +- Create: `web/src/lib/components/spaces/space-new-assets-divider.spec.ts` +- Create: `web/src/lib/components/spaces/space-new-assets-divider.svelte` + +**Step 1: Write failing tests** + +```typescript +// web/src/lib/components/spaces/space-new-assets-divider.spec.ts +import TestWrapper from '$lib/components/TestWrapper.svelte'; +import SpaceNewAssetsDivider from '$lib/components/spaces/space-new-assets-divider.svelte'; +import { render, screen } from '@testing-library/svelte'; +import type { Component } from 'svelte'; + +function renderDivider(props: Record) { + return render(TestWrapper as Component<{ component: typeof SpaceNewAssetsDivider; componentProps: typeof props }>, { + component: SpaceNewAssetsDivider, + componentProps: props, + }); +} + +describe('SpaceNewAssetsDivider', () => { + it('should render pill with correct count', () => { + renderDivider({ newAssetCount: 8, lastViewedAt: '2026-03-08T10:00:00Z', spaceColor: 'primary' }); + expect(screen.getByTestId('new-assets-divider')).toHaveTextContent('8 new'); + }); + + it('should render formatted date', () => { + renderDivider({ newAssetCount: 3, lastViewedAt: '2026-03-08T10:00:00Z', spaceColor: 'primary' }); + expect(screen.getByTestId('new-assets-divider')).toHaveTextContent('Mar 8'); + }); + + it('should not render when newAssetCount is 0', () => { + renderDivider({ newAssetCount: 0, lastViewedAt: '2026-03-08T10:00:00Z', spaceColor: 'primary' }); + expect(screen.queryByTestId('new-assets-divider')).not.toBeInTheDocument(); + }); + + it('should have sticky positioning', () => { + renderDivider({ newAssetCount: 5, lastViewedAt: '2026-03-08T10:00:00Z', spaceColor: 'primary' }); + const divider = screen.getByTestId('new-assets-divider'); + expect(divider.className).toContain('sticky'); + }); + + it('should use space color for pill background', () => { + renderDivider({ newAssetCount: 5, lastViewedAt: '2026-03-08T10:00:00Z', spaceColor: 'blue' }); + expect(screen.getByTestId('new-assets-pill')).toBeInTheDocument(); + }); +}); +``` + +**Step 2: Run test — verify FAIL** + +**Step 3: Implement the component** + +Create `web/src/lib/components/spaces/space-new-assets-divider.svelte`: + +- Props: `newAssetCount`, `lastViewedAt`, `spaceColor` +- Renders nothing if `newAssetCount === 0` +- Horizontal rule with centered pill: `{count} new · since {formatted date}` +- Pill uses space color background from the `gradientClasses` map (same pattern as `space-card.svelte`) +- `position: sticky; top: 0; z-index: 10` +- Entry animation: `scale-95 → scale-100` with `transition duration-300` after 300ms delay + +**Step 4: Run test — verify PASS** + +**Step 5: Commit** + +```bash +git add web/src/lib/components/spaces/space-new-assets-divider.svelte web/src/lib/components/spaces/space-new-assets-divider.spec.ts +git commit -m "feat: add SpaceNewAssetsDivider component with tests" +``` + +--- + +## Task 20: Web — integrate into space detail page + +**Files:** + +- Modify: `web/src/routes/(user)/spaces/[spaceId]/[[photos=photos]]/[[assetId=id]]/+page.svelte` + +**Step 1: Replace SpaceMembersPanel with SpacePanel** + +1. Replace `import SpaceMembersPanel` with `import SpacePanel` +2. Add activity state: `let activities = $state([])`, `let hasMoreActivities = $state(false)` +3. Add `loadActivities()` function that calls `getSpaceActivities({ id: spaceId })` from `@immich/sdk` +4. Add `loadMoreActivities()` that increments offset and appends +5. Load activities on mount (inside the existing `$effect` or `onMount`) +6. Replace `` with `` passing activities, members, and callbacks +7. Rename `membersPanelOpen` to `panelOpen` +8. Add `SpaceNewAssetsDivider` above the timeline when `space.newAssetCount > 0` +9. Add new-asset tint wrapper: a `div` with `bg-{spaceColor}/5 border-l-2 border-{spaceColor}/30` around the timeline section when new assets exist, with a fade-in transition after 300ms + +**Step 2: Update i18n keys** + +Add to `i18n/en.json`: + +- `spaces_activity`: `"Activity"` +- `spaces_load_more`: `"Load more"` +- `spaces_new_photos_since`: `"{count} new · since {date}"` +- `spaces_all_new`: `"All photos are new since your first visit"` +- `spaces_activity_empty`: `"This space just got started"` +- `spaces_activity_empty_description`: `"Add photos and invite members to see activity here."` +- `spaces_added_photos`: `"Added {count} photos"` +- `spaces_removed_photos`: `"Removed {count} photos"` +- `spaces_joined_as`: `"Joined as {role}"` +- `spaces_left_space`: `"Left the space"` +- `spaces_was_removed`: `"Was removed from the space"` +- `spaces_changed_role`: `"Changed {name}'s role to {role}"` +- `spaces_set_cover`: `"Set a new cover photo"` +- `spaces_renamed`: `"Renamed from \"{oldName}\" to \"{newName}\""` +- `spaces_changed_color`: `"Changed space color"` + +**Step 3: Run all web tests** + +```bash +cd web && pnpm test -- --run +``` + +**Step 4: Commit** + +```bash +git add web/src/routes/ web/src/lib/components/spaces/ i18n/en.json +git commit -m "feat: integrate SpacePanel and new-assets divider into space detail page" +``` + +--- + +## Task 21: Remove old `SpaceMembersPanel` + +**Files:** + +- Delete: `web/src/lib/components/spaces/space-members-panel.svelte` +- Delete: `web/src/lib/components/spaces/space-members-panel.spec.ts` + +Only delete after verifying all references have been updated in Task 20. + +**Step 1: Verify no remaining imports** + +```bash +cd web && grep -r "space-members-panel\|SpaceMembersPanel" src/ +``` + +Should return nothing. + +**Step 2: Delete files and commit** + +```bash +git rm web/src/lib/components/spaces/space-members-panel.svelte web/src/lib/components/spaces/space-members-panel.spec.ts +git commit -m "refactor: remove old SpaceMembersPanel (replaced by SpacePanel)" +``` + +--- + +## Task 22: Regenerate SQL docs + +**Step 1: Run SQL generation** + +```bash +cd server && pnpm sync:sql +``` + +Or if that's not available, update `server/src/queries/shared.space.repository.sql` manually with the new `logActivity` and `getActivities` queries. + +**Step 2: Commit** + +```bash +git add server/src/queries/ +git commit -m "chore: update generated SQL docs" +``` + +--- + +## Task 23: Lint, format, typecheck + +**Step 1: Run all checks** + +```bash +make lint-server && make lint-web +make format-server && make format-web +make check-server && make check-web +pnpm --filter=immich-i18n format:fix +``` + +**Step 2: Fix any issues found** + +**Step 3: Run all tests** + +```bash +cd server && pnpm test -- --run +cd web && pnpm test -- --run +``` + +**Step 4: Commit any fixes** + +```bash +git add -A && git commit -m "fix: lint, format, and typecheck fixes" +``` + +--- + +## Task 24: E2E tests + +**Files:** + +- Create: `e2e/src/specs/web/spaces-p3.e2e-spec.ts` + +**Step 1: Write E2E tests** + +Tests covering: + +1. **Activity feed**: Create space → add assets → open panel → verify "Added N photos" event visible +2. **Tab switching**: Open panel → verify Activity tab active → click Members → verify member list visible +3. **New-since-last-visit**: User A adds assets → User B visits space → User A adds more → User B revisits → verify divider with correct count + +Use existing E2E helpers: `createSpace`, `addSpaceAssets`, `addSpaceMember` from previous E2E utils. + +**Step 2: Run E2E tests locally if possible, or commit for CI** + +```bash +cd e2e && pnpm test:web -- --grep "spaces-p3" +``` + +**Step 3: Commit** + +```bash +git add e2e/ +git commit -m "test: add E2E tests for activity feed and new-since-last-visit" +``` + +--- + +## Summary + +| Task | Description | Type | +| ---- | ------------------------------------------------ | ------- | +| 1 | Schema: `shared_space_activity` table definition | Server | +| 2 | Database migration | Server | +| 3 | Database types + enum | Server | +| 4 | DTOs for activity | Server | +| 5 | Repository: `logActivity` + `getActivities` | Server | +| 6 | Test factory for activity | Server | +| 7-12 | Service: activity logging in all methods (TDD) | Server | +| 13 | Service: `getActivities` method (TDD) | Server | +| 14 | Service: `lastViewedAt` in response (TDD) | Server | +| 15 | Controller: GET endpoint | Server | +| 16 | OpenAPI regeneration | Codegen | +| 17 | Web: `SpaceActivityFeed` component (TDD) | Web | +| 18 | Web: `SpacePanel` unified component (TDD) | Web | +| 19 | Web: `SpaceNewAssetsDivider` component (TDD) | Web | +| 20 | Web: integration into detail page + i18n | Web | +| 21 | Web: remove old `SpaceMembersPanel` | Web | +| 22 | SQL docs regeneration | Codegen | +| 23 | Lint, format, typecheck | QA | +| 24 | E2E tests | Test | diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 7f117ee37c2c9..957de4698ebf0 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -44,7 +44,7 @@ services: redis: container_name: immich-e2e-redis - image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d + image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6 healthcheck: test: redis-cli ping || exit 1 diff --git a/e2e/package.json b/e2e/package.json index ff2fd7c88fbd9..914f0084ffb4e 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -34,7 +34,7 @@ "@playwright/test": "^1.44.1", "@socket.io/component-emitter": "^3.1.2", "@types/luxon": "^3.4.2", - "@types/node": "^24.10.14", + "@types/node": "^24.11.0", "@types/pg": "^8.15.1", "@types/pngjs": "^6.0.4", "@types/supertest": "^6.0.2", diff --git a/mobile/lib/constants/colors.dart b/mobile/lib/constants/colors.dart index 069ed519cf58a..e39480de32d2b 100644 --- a/mobile/lib/constants/colors.dart +++ b/mobile/lib/constants/colors.dart @@ -7,6 +7,6 @@ const String defaultColorPresetName = "indigo"; const Color immichBrandColorLight = Color(0xFF4150AF); const Color immichBrandColorDark = Color(0xFFACCBFA); -const Color whiteOpacity75 = Color.fromARGB((0.75 * 255) ~/ 1, 255, 255, 255); +const Color whiteOpacity75 = Color.fromRGBO(255, 255, 255, 0.75); const Color red400 = Color(0xFFEF5350); const Color grey200 = Color(0xFFEEEEEE); diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart index abb7b779fe3e2..0934536471355 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart @@ -19,7 +19,6 @@ import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; @@ -248,11 +247,6 @@ class _AssetPageState extends ConsumerState { if (scaleState != PhotoViewScaleState.initial) { if (_dragStart == null) _viewer.setControls(false); - - final heroTag = ref.read(assetViewerProvider).currentAsset?.heroTag; - if (heroTag != null) { - ref.read(videoPlayerProvider(heroTag).notifier).pause(); - } return; } diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart index 113c55932fb87..cc171f4490a9b 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -61,15 +61,27 @@ class ViewerBottomBar extends ConsumerWidget { ), ), child: Container( - color: Colors.black.withAlpha(125), - padding: EdgeInsets.only(bottom: context.padding.bottom, top: 16), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag), - if (!isReadonlyModeEnabled) - Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions), - ], + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [Colors.black45, Colors.black12, Colors.transparent], + stops: [0.0, 0.7, 1.0], + ), + ), + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.only(top: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag), + if (!isReadonlyModeEnabled) + Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions), + ], + ), + ), ), ), ), diff --git a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart index ecfe0b3ddc286..9285c01c41919 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart @@ -10,7 +10,6 @@ import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; @@ -186,11 +185,7 @@ class _NativeVideoViewerState extends ConsumerState with Widg final source = await _videoSource; if (source == null || !mounted) return; - unawaited( - nc.loadVideoSource(source).catchError((error) { - _log.severe('Error loading video source: $error'); - }), - ); + await _notifier.load(source); final loopVideo = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.loopVideo); await _notifier.setLoop(!widget.asset.isMotionPhoto && loopVideo); await _notifier.setVolume(1); @@ -213,21 +208,28 @@ class _NativeVideoViewerState extends ConsumerState with Widg @override Widget build(BuildContext context) { - // Prevent the provider from being disposed whilst the widget is alive. - ref.listen(videoPlayerProvider(widget.asset.heroTag), (_, __) {}); - final isCasting = ref.watch(castProvider.select((c) => c.isCasting)); - - return Stack( - children: [ - Center(child: widget.image), - if (!isCasting) - Visibility.maintain( - visible: _isVideoReady, - child: NativeVideoPlayerView(onViewReady: _initController), - ), - if (widget.showControls) Center(child: VideoViewerControls(asset: widget.asset)), - ], + final status = ref.watch(videoPlayerProvider(widget.asset.heroTag).select((v) => v.status)); + + return IgnorePointer( + child: Stack( + children: [ + Center(child: widget.image), + if (!isCasting) ...[ + Visibility.maintain( + visible: _isVideoReady, + child: NativeVideoPlayerView(onViewReady: _initController), + ), + Center( + child: AnimatedOpacity( + opacity: status == VideoPlaybackStatus.buffering ? 1.0 : 0.0, + duration: const Duration(milliseconds: 400), + child: const CircularProgressIndicator(), + ), + ), + ], + ], + ), ); } } diff --git a/mobile/lib/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart deleted file mode 100644 index e079f666ecfe2..0000000000000 --- a/mobile/lib/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart +++ /dev/null @@ -1,114 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/models/cast/cast_manager_state.dart'; -import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; -import 'package:immich_mobile/providers/cast.provider.dart'; -import 'package:immich_mobile/utils/hooks/timer_hook.dart'; -import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart'; -import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart'; - -class VideoViewerControls extends HookConsumerWidget { - final BaseAsset asset; - final Duration hideTimerDuration; - - const VideoViewerControls({super.key, required this.asset, this.hideTimerDuration = const Duration(seconds: 5)}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final videoPlayerName = asset.heroTag; - final assetIsVideo = asset.isVideo; - final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls && !s.showingDetails)); - final status = ref.watch(videoPlayerProvider(videoPlayerName).select((value) => value.status)); - - final cast = ref.watch(castProvider); - - // A timer to hide the controls - final hideTimer = useTimer(hideTimerDuration, () { - if (!context.mounted) { - return; - } - final status = ref.read(videoPlayerProvider(videoPlayerName)).status; - - // Do not hide on paused - if (status != VideoPlaybackStatus.paused && status != VideoPlaybackStatus.completed && assetIsVideo) { - ref.read(assetViewerProvider.notifier).setControls(false); - } - }); - final showBuffering = status == VideoPlaybackStatus.buffering && !cast.isCasting; - - /// Shows the controls and starts the timer to hide them - void showControlsAndStartHideTimer() { - hideTimer.reset(); - ref.read(assetViewerProvider.notifier).setControls(true); - } - - // When playback starts, reset the hide timer - ref.listen(videoPlayerProvider(videoPlayerName).select((v) => v.status), (previous, next) { - if (next == VideoPlaybackStatus.playing) { - hideTimer.reset(); - } - }); - - /// Toggles between playing and pausing depending on the state of the video - void togglePlay() { - showControlsAndStartHideTimer(); - - if (cast.isCasting) { - switch (cast.castState) { - case CastState.playing: - ref.read(castProvider.notifier).pause(); - case CastState.paused: - ref.read(castProvider.notifier).play(); - default: - } - return; - } - - final notifier = ref.read(videoPlayerProvider(videoPlayerName).notifier); - switch (status) { - case VideoPlaybackStatus.playing: - notifier.pause(); - case VideoPlaybackStatus.completed: - notifier.restart(); - default: - notifier.play(); - } - } - - void toggleControlsVisibility() { - if (showBuffering) return; - - if (showControls) { - ref.read(assetViewerProvider.notifier).setControls(false); - } else { - showControlsAndStartHideTimer(); - } - } - - return GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: toggleControlsVisibility, - child: IgnorePointer( - ignoring: !showControls, - child: Stack( - children: [ - if (showBuffering) - const Center(child: DelayedLoadingIndicator(fadeInDuration: Duration(milliseconds: 400))) - else - CenterPlayButton( - backgroundColor: Colors.black54, - iconColor: Colors.white, - isFinished: status == VideoPlaybackStatus.completed, - isPlaying: - status == VideoPlaybackStatus.playing || (cast.isCasting && cast.castState == CastState.playing), - show: assetIsVideo && showControls, - onPressed: togglePlay, - ), - ], - ), - ), - ); - } -} diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart index 4ba4152a8dcd6..397cd98acef16 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart @@ -75,17 +75,29 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { child: AnimatedOpacity( opacity: opacity, duration: Durations.short2, - child: AppBar( - backgroundColor: showingDetails ? Colors.transparent : Colors.black.withValues(alpha: 0.5), - leading: const _AppBarBackButton(), - iconTheme: const IconThemeData(size: 22, color: Colors.white), - actionsIconTheme: const IconThemeData(size: 22, color: Colors.white), - shape: const Border(), - actions: showingDetails || isReadonlyModeEnabled - ? null - : isInLockedView - ? lockedViewActions - : actions, + child: DecoratedBox( + decoration: BoxDecoration( + gradient: showingDetails + ? null + : const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.black45, Colors.black12, Colors.transparent], + stops: [0.0, 0.7, 1.0], + ), + ), + child: AppBar( + backgroundColor: Colors.transparent, + leading: const _AppBarBackButton(), + iconTheme: const IconThemeData(size: 22, color: Colors.white), + actionsIconTheme: const IconThemeData(size: 22, color: Colors.white), + shape: const Border(), + actions: showingDetails || isReadonlyModeEnabled + ? null + : isInLockedView + ? lockedViewActions + : actions, + ), ), ), ); diff --git a/mobile/lib/providers/asset_viewer/asset_viewer.provider.dart b/mobile/lib/providers/asset_viewer/asset_viewer.provider.dart index 785dfd1e4c380..19c92e7c965d1 100644 --- a/mobile/lib/providers/asset_viewer/asset_viewer.provider.dart +++ b/mobile/lib/providers/asset_viewer/asset_viewer.provider.dart @@ -100,11 +100,11 @@ class AssetViewerStateNotifier extends Notifier { return; } state = state.copyWith(showingDetails: showing, showingControls: showing ? true : state.showingControls); - if (showing) { - final heroTag = state.currentAsset?.heroTag; - if (heroTag != null) { - ref.read(videoPlayerProvider(heroTag).notifier).pause(); - } + + final heroTag = state.currentAsset?.heroTag; + if (heroTag != null) { + final notifier = ref.read(videoPlayerProvider(heroTag).notifier); + showing ? notifier.hold() : notifier.release(); } } diff --git a/mobile/lib/providers/asset_viewer/video_player_provider.dart b/mobile/lib/providers/asset_viewer/video_player_provider.dart index 0ca3bf4f74235..a4a8bd1762417 100644 --- a/mobile/lib/providers/asset_viewer/video_player_provider.dart +++ b/mobile/lib/providers/asset_viewer/video_player_provider.dart @@ -44,10 +44,7 @@ class VideoPlayerNotifier extends StateNotifier { NativeVideoPlayerController? _controller; Timer? _bufferingTimer; Timer? _seekTimer; - - void attachController(NativeVideoPlayerController controller) { - _controller = controller; - } + VideoPlaybackStatus? _holdStatus; @override void dispose() { @@ -59,6 +56,19 @@ class VideoPlayerNotifier extends StateNotifier { super.dispose(); } + void attachController(NativeVideoPlayerController controller) { + _controller = controller; + } + + Future load(VideoSource source) async { + _startBufferingTimer(); + try { + await _controller?.loadVideoSource(source); + } catch (e) { + _log.severe('Error loading video source: $e'); + } + } + Future pause() async { if (_controller == null) return; @@ -94,16 +104,50 @@ class VideoPlayerNotifier extends StateNotifier { } void seekTo(Duration position) { - if (_controller == null) return; + if (_controller == null || state.position == position) return; state = state.copyWith(position: position); - _seekTimer?.cancel(); - _seekTimer = Timer(const Duration(milliseconds: 100), () { - _controller?.seekTo(position.inMilliseconds); + if (_seekTimer?.isActive ?? false) return; + + _seekTimer = Timer(const Duration(milliseconds: 150), () { + _controller?.seekTo(state.position.inMilliseconds); }); } + void toggle() { + _holdStatus = null; + + switch (state.status) { + case VideoPlaybackStatus.paused: + play(); + case VideoPlaybackStatus.playing || VideoPlaybackStatus.buffering: + pause(); + case VideoPlaybackStatus.completed: + restart(); + } + } + + /// Pauses playback and preserves the current status for later restoration. + void hold() { + if (_holdStatus != null) return; + + _holdStatus = state.status; + pause(); + } + + /// Restores playback to the status before [hold] was called. + void release() { + final status = _holdStatus; + _holdStatus = null; + + switch (status) { + case VideoPlaybackStatus.playing || VideoPlaybackStatus.buffering: + play(); + default: + } + } + Future restart() async { seekTo(Duration.zero); await play(); @@ -149,13 +193,12 @@ class VideoPlayerNotifier extends StateNotifier { final position = Duration(milliseconds: playbackInfo.position); if (state.position == position) return; - if (state.status == VideoPlaybackStatus.buffering) { - state = state.copyWith(position: position, status: VideoPlaybackStatus.playing); - } else { - state = state.copyWith(position: position); - } + if (state.status == VideoPlaybackStatus.playing) _startBufferingTimer(); - _startBufferingTimer(); + state = state.copyWith( + position: position, + status: state.status == VideoPlaybackStatus.buffering ? VideoPlaybackStatus.playing : null, + ); } void onNativeStatusChanged() { @@ -173,9 +216,7 @@ class VideoPlayerNotifier extends StateNotifier { onNativePlaybackEnded(); } - if (state.status != newStatus) { - state = state.copyWith(status: newStatus); - } + if (state.status != newStatus) state = state.copyWith(status: newStatus); } void onNativePlaybackEnded() { @@ -186,7 +227,7 @@ class VideoPlayerNotifier extends StateNotifier { void _startBufferingTimer() { _bufferingTimer?.cancel(); _bufferingTimer = Timer(const Duration(seconds: 3), () { - if (mounted && state.status == VideoPlaybackStatus.playing) { + if (mounted && state.status != VideoPlaybackStatus.completed) { state = state.copyWith(status: VideoPlaybackStatus.buffering); } }); diff --git a/mobile/lib/providers/cast.provider.dart b/mobile/lib/providers/cast.provider.dart index 1cd5ded48726a..fea95f42aaec5 100644 --- a/mobile/lib/providers/cast.provider.dart +++ b/mobile/lib/providers/cast.provider.dart @@ -91,6 +91,16 @@ class CastNotifier extends StateNotifier { return discovered; } + void toggle() { + switch (state.castState) { + case CastState.playing: + pause(); + case CastState.paused: + play(); + default: + } + } + void play() { _gCastService.play(); } diff --git a/mobile/lib/widgets/asset_viewer/formatted_duration.dart b/mobile/lib/widgets/asset_viewer/formatted_duration.dart deleted file mode 100644 index fbcc8e6482c67..0000000000000 --- a/mobile/lib/widgets/asset_viewer/formatted_duration.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:immich_mobile/extensions/duration_extensions.dart'; - -class FormattedDuration extends StatelessWidget { - final Duration data; - const FormattedDuration(this.data, {super.key}); - - @override - Widget build(BuildContext context) { - return SizedBox( - width: data.inHours > 0 ? 70 : 60, // use a fixed width to prevent jitter - child: Text( - data.format(), - style: const TextStyle(fontSize: 14.0, color: Colors.white, fontWeight: FontWeight.w500), - textAlign: TextAlign.center, - ), - ); - } -} diff --git a/mobile/lib/widgets/asset_viewer/video_controls.dart b/mobile/lib/widgets/asset_viewer/video_controls.dart index 381388d8d2a32..29e877b3dc69d 100644 --- a/mobile/lib/widgets/asset_viewer/video_controls.dart +++ b/mobile/lib/widgets/asset_viewer/video_controls.dart @@ -1,22 +1,110 @@ +import 'dart:math'; + import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/widgets/asset_viewer/video_position.dart'; +import 'package:immich_mobile/constants/colors.dart'; +import 'package:immich_mobile/models/cast/cast_manager_state.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; +import 'package:immich_mobile/providers/cast.provider.dart'; +import 'package:immich_mobile/utils/hooks/timer_hook.dart'; +import 'package:immich_mobile/extensions/duration_extensions.dart'; +import 'package:immich_mobile/widgets/asset_viewer/animated_play_pause.dart'; -/// The video controls for the [videoPlayerProvider] -class VideoControls extends ConsumerWidget { +class VideoControls extends HookConsumerWidget { final String videoPlayerName; const VideoControls({super.key, required this.videoPlayerName}); + void _toggle(WidgetRef ref, bool isCasting) { + if (isCasting) { + ref.read(castProvider.notifier).toggle(); + } else { + ref.read(videoPlayerProvider(videoPlayerName).notifier).toggle(); + } + } + + void _onSeek(WidgetRef ref, bool isCasting, double value) { + final seekTo = Duration(microseconds: value.toInt()); + + if (isCasting) { + ref.read(castProvider.notifier).seekTo(seekTo); + return; + } + + ref.read(videoPlayerProvider(videoPlayerName).notifier).seekTo(seekTo); + } + @override Widget build(BuildContext context, WidgetRef ref) { - final isPortrait = context.orientation == Orientation.portrait; - return isPortrait - ? VideoPosition(videoPlayerName: videoPlayerName) - : Padding( - padding: const EdgeInsets.symmetric(horizontal: 60.0), - child: VideoPosition(videoPlayerName: videoPlayerName), - ); + final provider = videoPlayerProvider(videoPlayerName); + final cast = ref.watch(castProvider); + final isCasting = cast.isCasting; + + final (position, duration) = isCasting + ? ref.watch(castProvider.select((c) => (c.currentTime, c.duration))) + : ref.watch(provider.select((v) => (v.position, v.duration))); + + final videoStatus = ref.watch(provider.select((v) => v.status)); + final isPlaying = isCasting + ? cast.castState == CastState.playing + : videoStatus == VideoPlaybackStatus.playing || videoStatus == VideoPlaybackStatus.buffering; + final isFinished = !isCasting && videoStatus == VideoPlaybackStatus.completed; + + final hideTimer = useTimer(const Duration(seconds: 5), () { + if (!context.mounted) return; + if (ref.read(provider).status == VideoPlaybackStatus.playing) { + ref.read(assetViewerProvider.notifier).setControls(false); + } + }); + + ref.listen(provider.select((v) => v.status), (_, __) => hideTimer.reset()); + + final notifier = ref.read(provider.notifier); + final isLoaded = duration != Duration.zero; + + return Padding( + padding: const EdgeInsets.all(24), + child: Column( + spacing: 16, + children: [ + Row( + children: [ + IconButton( + iconSize: 32, + padding: const EdgeInsets.all(12), + constraints: const BoxConstraints(), + icon: isFinished + ? const Icon(Icons.replay, color: Colors.white, size: 32) + : AnimatedPlayPause(color: Colors.white, size: 32, playing: isPlaying), + onPressed: () => _toggle(ref, isCasting), + ), + const Spacer(), + Text( + "${position.format()} / ${duration.format()}", + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w500, + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + const SizedBox(width: 16), + ], + ), + Slider( + value: min(position.inMicroseconds.toDouble(), duration.inMicroseconds.toDouble()), + min: 0, + max: max(duration.inMicroseconds.toDouble(), 1), + thumbColor: Colors.white, + activeColor: Colors.white, + inactiveColor: whiteOpacity75, + padding: EdgeInsets.zero, + onChangeStart: (_) => notifier.hold(), + onChangeEnd: (_) => notifier.release(), + onChanged: isLoaded ? (value) => _onSeek(ref, isCasting, value) : null, + ), + ], + ), + ); } } diff --git a/mobile/lib/widgets/asset_viewer/video_position.dart b/mobile/lib/widgets/asset_viewer/video_position.dart deleted file mode 100644 index cbcbdb88e7fa5..0000000000000 --- a/mobile/lib/widgets/asset_viewer/video_position.dart +++ /dev/null @@ -1,110 +0,0 @@ -import 'dart:math'; - -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/constants/colors.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; -import 'package:immich_mobile/providers/cast.provider.dart'; -import 'package:immich_mobile/widgets/asset_viewer/formatted_duration.dart'; - -class VideoPosition extends HookConsumerWidget { - final String videoPlayerName; - - const VideoPosition({super.key, required this.videoPlayerName}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final isCasting = ref.watch(castProvider).isCasting; - - final (position, duration) = isCasting - ? ref.watch(castProvider.select((c) => (c.currentTime, c.duration))) - : ref.watch(videoPlayerProvider(videoPlayerName).select((v) => (v.position, v.duration))); - - final wasPlaying = useRef(true); - return duration == Duration.zero - ? const _VideoPositionPlaceholder() - : Column( - children: [ - Padding( - // align with slider's inherent padding - padding: const EdgeInsets.symmetric(horizontal: 12.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [FormattedDuration(position), FormattedDuration(duration)], - ), - ), - Row( - children: [ - Expanded( - child: Slider( - value: min(position.inMicroseconds / duration.inMicroseconds * 100, 100), - min: 0, - max: 100, - thumbColor: Colors.white, - activeColor: Colors.white, - inactiveColor: whiteOpacity75, - onChangeStart: (value) { - final status = ref.read(videoPlayerProvider(videoPlayerName)).status; - wasPlaying.value = status != VideoPlaybackStatus.paused; - ref.read(videoPlayerProvider(videoPlayerName).notifier).pause(); - }, - onChangeEnd: (value) { - if (wasPlaying.value) { - ref.read(videoPlayerProvider(videoPlayerName).notifier).play(); - } - }, - onChanged: (value) { - final seekToDuration = (duration * (value / 100.0)); - - if (isCasting) { - ref.read(castProvider.notifier).seekTo(seekToDuration); - return; - } - - ref.read(videoPlayerProvider(videoPlayerName).notifier).seekTo(seekToDuration); - }, - ), - ), - ], - ), - ], - ); - } -} - -class _VideoPositionPlaceholder extends StatelessWidget { - const _VideoPositionPlaceholder(); - - static void _onChangedDummy(_) {} - - @override - Widget build(BuildContext context) { - return const Column( - children: [ - Padding( - padding: EdgeInsets.symmetric(horizontal: 12.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [FormattedDuration(Duration.zero), FormattedDuration(Duration.zero)], - ), - ), - Row( - children: [ - Expanded( - child: Slider( - value: 0.0, - min: 0, - max: 100, - thumbColor: Colors.white, - activeColor: Colors.white, - inactiveColor: whiteOpacity75, - onChanged: _onChangedDummy, - ), - ), - ], - ), - ], - ); - } -} diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index cdf2ef19ddcce..89b48d1d13bab 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -19,7 +19,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^24.10.14", + "@types/node": "^24.11.0", "typescript": "^5.3.3" }, "repository": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf325b8cc4425..0be87956c1cf3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,7 +63,7 @@ importers: specifier: ^4.13.1 version: 4.13.4 '@types/node': - specifier: ^24.10.14 + specifier: ^24.11.0 version: 24.11.0 '@vitest/coverage-v8': specifier: ^4.0.0 @@ -220,7 +220,7 @@ importers: specifier: ^3.4.2 version: 3.7.1 '@types/node': - specifier: ^24.10.14 + specifier: ^24.11.0 version: 24.11.0 '@types/pg': specifier: ^8.15.1 @@ -323,7 +323,7 @@ importers: version: 1.2.0 devDependencies: '@types/node': - specifier: ^24.10.14 + specifier: ^24.11.0 version: 24.11.0 typescript: specifier: ^5.3.3 @@ -654,7 +654,7 @@ importers: specifier: ^2.0.0 version: 2.0.0 '@types/node': - specifier: ^24.10.14 + specifier: ^24.11.0 version: 24.11.0 '@types/nodemailer': specifier: ^7.0.0 diff --git a/server/package.json b/server/package.json index 76405eb9264f8..37f4e2c2a349c 100644 --- a/server/package.json +++ b/server/package.json @@ -139,7 +139,7 @@ "@types/luxon": "^3.6.2", "@types/mock-fs": "^4.13.1", "@types/multer": "^2.0.0", - "@types/node": "^24.10.14", + "@types/node": "^24.11.0", "@types/nodemailer": "^7.0.0", "@types/picomatch": "^4.0.0", "@types/pngjs": "^6.0.5", diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 2710546564ddf..f0601fbad9faf 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -672,7 +672,7 @@ describe(AssetService.name, () => { }); it('should immediately queue assets for deletion if trash is disabled', async () => { - const asset = factory.asset({ isOffline: false }); + const asset = AssetFactory.create(); mocks.assetJob.streamForDeletedJob.mockReturnValue(makeStream([asset])); mocks.systemMetadata.get.mockResolvedValue({ trash: { enabled: false } }); @@ -686,7 +686,7 @@ describe(AssetService.name, () => { }); it('should queue assets for deletion after trash duration', async () => { - const asset = factory.asset({ isOffline: false }); + const asset = AssetFactory.create(); mocks.assetJob.streamForDeletedJob.mockReturnValue(makeStream([asset])); mocks.systemMetadata.get.mockResolvedValue({ trash: { enabled: true, days: 7 } }); @@ -700,7 +700,7 @@ describe(AssetService.name, () => { }); it('should set deleteOnDisk to false for offline assets', async () => { - const asset = factory.asset({ isOffline: true }); + const asset = AssetFactory.create({ isOffline: true }); mocks.assetJob.streamForDeletedJob.mockReturnValue(makeStream([asset])); mocks.systemMetadata.get.mockResolvedValue({ trash: { enabled: false } }); @@ -1074,7 +1074,7 @@ describe(AssetService.name, () => { describe('upsertMetadata', () => { it('should throw a bad request exception if duplicate keys are sent', async () => { - const asset = factory.asset(); + const asset = AssetFactory.create(); const items = [ { key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } }, { key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } }, @@ -1090,7 +1090,7 @@ describe(AssetService.name, () => { }); it('should upsert metadata with unique keys', async () => { - const asset = factory.asset(); + const asset = AssetFactory.create(); const items = [{ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } }]; mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); @@ -1104,7 +1104,7 @@ describe(AssetService.name, () => { describe('upsertBulkMetadata', () => { it('should throw a bad request exception if duplicate keys are sent', async () => { - const asset = factory.asset(); + const asset = AssetFactory.create(); const items = [ { assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } }, { assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } }, @@ -1120,8 +1120,8 @@ describe(AssetService.name, () => { }); it('should upsert bulk metadata with unique keys', async () => { - const asset1 = factory.asset(); - const asset2 = factory.asset(); + const asset1 = AssetFactory.create(); + const asset2 = AssetFactory.create(); const items = [ { assetId: asset1.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } }, { assetId: asset2.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id2' } }, diff --git a/server/src/services/memory.service.spec.ts b/server/src/services/memory.service.spec.ts index 8041ded8fc182..4089c2bd8460a 100644 --- a/server/src/services/memory.service.spec.ts +++ b/server/src/services/memory.service.spec.ts @@ -2,6 +2,8 @@ import { BadRequestException } from '@nestjs/common'; import { MemoryType } from 'src/enum'; import { MemoryService } from 'src/services/memory.service'; import { OnThisDayData } from 'src/types'; +import { AssetFactory } from 'test/factories/asset.factory'; +import { MemoryFactory } from 'test/factories/memory.factory'; import { factory, newUuid, newUuids } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; @@ -54,11 +56,11 @@ describe(MemoryService.name, () => { it('should create on-this-day memories when assets exist', async () => { const user = factory.userAdmin(); - const asset = factory.asset({ ownerId: user.id }); + const asset = AssetFactory.create({ ownerId: user.id }); mocks.user.getList.mockResolvedValue([user]); mocks.systemMetadata.get.mockResolvedValue(null); mocks.asset.getByDayOfYear.mockResolvedValue([{ year: 2023, assets: [asset] }]); - mocks.memory.create.mockResolvedValue(factory.memory()); + mocks.memory.create.mockResolvedValue(MemoryFactory.create()); await sut.onMemoriesCreate(); @@ -82,9 +84,9 @@ describe(MemoryService.name, () => { describe('search', () => { it('should search memories', async () => { const [userId] = newUuids(); - const asset = factory.asset(); - const memory1 = factory.memory({ ownerId: userId, assets: [asset] }); - const memory2 = factory.memory({ ownerId: userId }); + const asset = AssetFactory.create(); + const memory1 = MemoryFactory.from({ ownerId: userId }).asset(asset).build(); + const memory2 = MemoryFactory.create({ ownerId: userId }); mocks.memory.search.mockResolvedValue([memory1, memory2]); @@ -143,7 +145,7 @@ describe(MemoryService.name, () => { it('should get a memory by id', async () => { const userId = newUuid(); - const memory = factory.memory({ ownerId: userId }); + const memory = MemoryFactory.create({ ownerId: userId }); mocks.memory.get.mockResolvedValue(memory); mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id])); @@ -160,7 +162,7 @@ describe(MemoryService.name, () => { describe('create', () => { it('should skip assets the user does not have access to', async () => { const [assetId, userId] = newUuids(); - const memory = factory.memory({ ownerId: userId }); + const memory = MemoryFactory.create({ ownerId: userId }); mocks.memory.create.mockResolvedValue(memory); @@ -188,8 +190,8 @@ describe(MemoryService.name, () => { it('should create a memory', async () => { const [assetId, userId] = newUuids(); - const asset = factory.asset({ id: assetId, ownerId: userId }); - const memory = factory.memory({ assets: [asset] }); + const asset = AssetFactory.create({ id: assetId, ownerId: userId }); + const memory = MemoryFactory.from().asset(asset).build(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); mocks.memory.create.mockResolvedValue(memory); @@ -210,7 +212,7 @@ describe(MemoryService.name, () => { }); it('should create a memory without assets', async () => { - const memory = factory.memory(); + const memory = MemoryFactory.create(); mocks.memory.create.mockResolvedValue(memory); @@ -225,7 +227,7 @@ describe(MemoryService.name, () => { it('should pass all optional fields when creating a memory', async () => { const userId = newUuid(); - const memory = factory.memory({ ownerId: userId }); + const memory = MemoryFactory.create({ ownerId: userId }); const showAt = new Date(); const hideAt = new Date(); const seenAt = new Date(); @@ -265,7 +267,7 @@ describe(MemoryService.name, () => { }); it('should update a memory', async () => { - const memory = factory.memory(); + const memory = MemoryFactory.create(); mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id])); mocks.memory.update.mockResolvedValue(memory); @@ -276,7 +278,7 @@ describe(MemoryService.name, () => { }); it('should update a memory with seenAt', async () => { - const memory = factory.memory(); + const memory = MemoryFactory.create(); const seenAt = new Date(); mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id])); @@ -288,7 +290,7 @@ describe(MemoryService.name, () => { }); it('should update a memory with memoryAt', async () => { - const memory = factory.memory(); + const memory = MemoryFactory.create(); const memoryAt = new Date(); mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id])); @@ -332,7 +334,7 @@ describe(MemoryService.name, () => { it('should require asset access', async () => { const assetId = newUuid(); - const memory = factory.memory(); + const memory = MemoryFactory.create(); mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id])); mocks.memory.get.mockResolvedValue(memory); @@ -346,8 +348,8 @@ describe(MemoryService.name, () => { }); it('should skip assets already in the memory', async () => { - const asset = factory.asset(); - const memory = factory.memory({ assets: [asset] }); + const asset = AssetFactory.create(); + const memory = MemoryFactory.from().asset(asset).build(); mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id])); mocks.memory.get.mockResolvedValue(memory); @@ -362,7 +364,7 @@ describe(MemoryService.name, () => { it('should add assets', async () => { const assetId = newUuid(); - const memory = factory.memory(); + const memory = MemoryFactory.create(); mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId])); @@ -380,7 +382,7 @@ describe(MemoryService.name, () => { it('should update memory updatedAt when assets are successfully added', async () => { const assetId = newUuid(); - const memory = factory.memory(); + const memory = MemoryFactory.create(); mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId])); @@ -395,8 +397,8 @@ describe(MemoryService.name, () => { }); it('should not update memory updatedAt when no assets are successfully added', async () => { - const asset = factory.asset(); - const memory = factory.memory({ assets: [asset] }); + const asset = AssetFactory.create(); + const memory = MemoryFactory.from().asset(asset).build(); mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id])); mocks.memory.get.mockResolvedValue(memory); @@ -429,8 +431,8 @@ describe(MemoryService.name, () => { }); it('should remove assets', async () => { - const memory = factory.memory(); - const asset = factory.asset(); + const memory = MemoryFactory.create(); + const asset = AssetFactory.create(); mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); @@ -446,8 +448,8 @@ describe(MemoryService.name, () => { }); it('should update memory updatedAt when assets are successfully removed', async () => { - const memory = factory.memory(); - const asset = factory.asset(); + const memory = MemoryFactory.create(); + const asset = AssetFactory.create(); mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); @@ -464,7 +466,7 @@ describe(MemoryService.name, () => { }); it('should not update memory updatedAt when no assets are successfully removed', async () => { - const memory = factory.memory(); + const memory = MemoryFactory.create(); mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id])); mocks.memory.getAssetIds.mockResolvedValue(new Set()); diff --git a/server/src/services/plugin.service.spec.ts b/server/src/services/plugin.service.spec.ts index 60fcb22115b74..cce4905bb06ad 100644 --- a/server/src/services/plugin.service.spec.ts +++ b/server/src/services/plugin.service.spec.ts @@ -2,7 +2,8 @@ import { BadRequestException } from '@nestjs/common'; import { JobName, JobStatus, PluginContext, PluginTriggerType } from 'src/enum'; import { pluginTriggers } from 'src/plugins'; import { PluginService } from 'src/services/plugin.service'; -import { factory, newUuid, newUuids } from 'test/small.factory'; +import { AssetFactory } from 'test/factories/asset.factory'; +import { newUuid, newUuids } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; const mockPluginCall = vi.fn(); @@ -85,7 +86,7 @@ const setupForWorkflowRun = async ( mockPluginCall: ReturnType, ) => { const [workflowId, ownerId, pluginId, filterId, actionId] = newUuids(); - const asset = factory.asset({ ownerId }); + const asset = AssetFactory.create({ ownerId }); // Bootstrap the service to set the pluginJwtSecret and loadedPlugins const coreManifest = JSON.stringify({ @@ -464,7 +465,7 @@ describe(PluginService.name, () => { describe('handleAssetCreate', () => { it('should queue workflow jobs when workflows exist for the owner', async () => { const [ownerId, workflowId] = newUuids(); - const asset = factory.asset({ ownerId }); + const asset = AssetFactory.create({ ownerId }); const workflow = newWorkflowEntity({ id: workflowId, ownerId }); mocks.workflow.getWorkflowByOwnerAndTrigger.mockResolvedValue([workflow as any]); @@ -486,7 +487,7 @@ describe(PluginService.name, () => { it('should not queue jobs when no workflows exist for the owner', async () => { const ownerId = newUuid(); - const asset = factory.asset({ ownerId }); + const asset = AssetFactory.create({ ownerId }); mocks.workflow.getWorkflowByOwnerAndTrigger.mockResolvedValue([]); @@ -497,7 +498,7 @@ describe(PluginService.name, () => { it('should queue multiple workflow jobs when multiple workflows match', async () => { const ownerId = newUuid(); - const asset = factory.asset({ ownerId }); + const asset = AssetFactory.create({ ownerId }); const [workflowId1, workflowId2] = newUuids(); const workflow1 = newWorkflowEntity({ id: workflowId1, ownerId }); const workflow2 = newWorkflowEntity({ id: workflowId2, ownerId }); @@ -522,7 +523,7 @@ describe(PluginService.name, () => { const result = await sut.handleWorkflowRun({ id: newUuid(), type: PluginTriggerType.AssetCreate, - event: { userId: newUuid(), asset: factory.asset() as any }, + event: { userId: newUuid(), asset: AssetFactory.create() as any }, }); expect(result).toBe(JobStatus.Failed); @@ -872,7 +873,7 @@ describe(PluginService.name, () => { const result = await sut.handleWorkflowRun({ id: newUuid(), type: PluginTriggerType.AssetCreate, - event: { userId: newUuid(), asset: factory.asset() as any }, + event: { userId: newUuid(), asset: AssetFactory.create() as any }, }); expect(result).toBe(JobStatus.Failed); diff --git a/server/test/factories/memory.factory.ts b/server/test/factories/memory.factory.ts new file mode 100644 index 0000000000000..bda1d15c25004 --- /dev/null +++ b/server/test/factories/memory.factory.ts @@ -0,0 +1,45 @@ +import { Selectable } from 'kysely'; +import { MemoryType } from 'src/enum'; +import { MemoryTable } from 'src/schema/tables/memory.table'; +import { AssetFactory } from 'test/factories/asset.factory'; +import { build } from 'test/factories/builder.factory'; +import { AssetLike, FactoryBuilder, MemoryLike } from 'test/factories/types'; +import { newDate, newUuid, newUuidV7 } from 'test/small.factory'; + +export class MemoryFactory { + #assets: AssetFactory[] = []; + + private constructor(private readonly value: Selectable) {} + + static create(dto: MemoryLike = {}) { + return MemoryFactory.from(dto).build(); + } + + static from(dto: MemoryLike = {}) { + return new MemoryFactory({ + id: newUuid(), + createdAt: newDate(), + updatedAt: newDate(), + updateId: newUuidV7(), + deletedAt: null, + ownerId: newUuid(), + type: MemoryType.OnThisDay, + data: { year: 2024 }, + isSaved: false, + memoryAt: newDate(), + seenAt: null, + showAt: newDate(), + hideAt: newDate(), + ...dto, + }); + } + + asset(asset: AssetLike, builder?: FactoryBuilder) { + this.#assets.push(build(AssetFactory.from(asset), builder)); + return this; + } + + build() { + return { ...this.value, assets: this.#assets.map((asset) => asset.build()) }; + } +} diff --git a/server/test/factories/types.ts b/server/test/factories/types.ts index c5a327a6247f1..0e070c1bcc7e4 100644 --- a/server/test/factories/types.ts +++ b/server/test/factories/types.ts @@ -6,6 +6,7 @@ import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; import { AssetFileTable } from 'src/schema/tables/asset-file.table'; import { AssetTable } from 'src/schema/tables/asset.table'; +import { MemoryTable } from 'src/schema/tables/memory.table'; import { PersonTable } from 'src/schema/tables/person.table'; import { SharedLinkTable } from 'src/schema/tables/shared-link.table'; import { StackTable } from 'src/schema/tables/stack.table'; @@ -24,3 +25,4 @@ export type UserLike = Partial>; export type AssetFaceLike = Partial>; export type PersonLike = Partial>; export type StackLike = Partial>; +export type MemoryLike = Partial>; diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index 0baaac3126a11..4452de4e02fc6 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -2,42 +2,26 @@ import { Activity, Album, ApiKey, - AssetFace, - AssetFile, AuthApiKey, AuthSharedLink, AuthUser, Exif, Library, - Memory, Partner, Person, Session, SharedSpace, SharedSpaceMember, - Stack, Tag, User, UserAdmin, } from 'src/database'; -import { MapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetEditAction, AssetEditActionItem, MirrorAxis } from 'src/dtos/editing.dto'; import { QueueStatisticsDto } from 'src/dtos/queue.dto'; -import { - AssetFileType, - AssetOrder, - AssetStatus, - AssetType, - AssetVisibility, - MemoryType, - Permission, - SharedSpaceRole, - SourceType, - UserMetadataKey, - UserStatus, -} from 'src/enum'; -import { DeepPartial, OnThisDayData, UserMetadataItem } from 'src/types'; +import { AssetFileType, AssetOrder, Permission, SharedSpaceRole, UserMetadataKey, UserStatus } from 'src/enum'; +import { UserMetadataItem } from 'src/types'; +import { UserFactory } from 'test/factories/user.factory'; import { v4, v7 } from 'uuid'; export const newUuid = () => v4(); @@ -126,9 +110,13 @@ const authUserFactory = (authUser: Partial = {}) => { return { id, isAdmin, name, email, quotaUsageInBytes, quotaSizeInBytes }; }; -const partnerFactory = (partner: Partial = {}) => { - const sharedBy = userFactory(partner.sharedBy || {}); - const sharedWith = userFactory(partner.sharedWith || {}); +const partnerFactory = ({ + sharedBy: sharedByProvided, + sharedWith: sharedWithProvided, + ...partner +}: Partial = {}) => { + const sharedBy = UserFactory.create(sharedByProvided ?? {}); + const sharedWith = UserFactory.create(sharedWithProvided ?? {}); return { sharedById: sharedBy.id, @@ -171,19 +159,6 @@ const queueStatisticsFactory = (dto?: Partial) => ({ ...dto, }); -const stackFactory = ({ owner, assets, ...stack }: DeepPartial = {}): Stack => { - const ownerId = newUuid(); - - return { - id: newUuid(), - primaryAssetId: assets?.[0].id ?? newUuid(), - ownerId, - owner: userFactory(owner ?? { id: ownerId }), - assets: assets?.map((asset) => assetFactory(asset)) ?? [], - ...stack, - }; -}; - const userFactory = (user: Partial = {}) => ({ id: newUuid(), name: 'Test User', @@ -241,44 +216,6 @@ const userAdminFactory = (user: Partial = {}) => { }; }; -const assetFactory = ( - asset: Omit, 'exifInfo' | 'owner' | 'stack' | 'tags' | 'faces' | 'files' | 'edits'> = {}, -) => { - return { - id: newUuid(), - createdAt: newDate(), - updatedAt: newDate(), - deletedAt: null, - updateId: newUuidV7(), - status: AssetStatus.Active, - checksum: newSha1(), - deviceAssetId: '', - deviceId: '', - duplicateId: null, - duration: null, - encodedVideoPath: null, - fileCreatedAt: newDate(), - fileModifiedAt: newDate(), - isExternal: false, - isFavorite: false, - isOffline: false, - libraryId: null, - livePhotoVideoId: null, - localDateTime: newDate(), - originalFileName: 'IMG_123.jpg', - originalPath: `/data/12/34/IMG_123.jpg`, - ownerId: newUuid(), - stackId: null, - thumbhash: null, - type: AssetType.Image, - visibility: AssetVisibility.Timeline, - width: null, - height: null, - isEdited: false, - ...asset, - }; -}; - const activityFactory = (activity: Partial = {}) => { const userId = activity.userId || newUuid(); return { @@ -286,7 +223,7 @@ const activityFactory = (activity: Partial = {}) => { comment: null, isLiked: false, userId, - user: userFactory({ id: userId }), + user: UserFactory.create({ id: userId }), assetId: newUuid(), albumId: newUuid(), createdAt: newDate(), @@ -322,24 +259,6 @@ const libraryFactory = (library: Partial = {}) => ({ ...library, }); -const memoryFactory = (memory: Partial = {}) => ({ - id: newUuid(), - createdAt: newDate(), - updatedAt: newDate(), - updateId: newUuidV7(), - deletedAt: null, - ownerId: newUuid(), - type: MemoryType.OnThisDay, - data: { year: 2024 } as OnThisDayData, - isSaved: false, - memoryAt: newDate(), - seenAt: null, - showAt: newDate(), - hideAt: newDate(), - assets: [], - ...memory, -}); - const versionHistoryFactory = () => ({ id: newUuid(), createdAt: newDate(), @@ -406,49 +325,6 @@ const assetOcrFactory = ( ...ocr, }); -const assetFileFactory = (file: Partial = {}) => ({ - id: newUuid(), - type: AssetFileType.Preview, - path: '/uploads/user-id/thumbs/path.jpg', - isEdited: false, - isProgressive: false, - ...file, -}); - -const exifFactory = (exif: Partial = {}) => ({ - assetId: newUuid(), - autoStackId: null, - bitsPerSample: null, - city: 'Austin', - colorspace: null, - country: 'United States of America', - dateTimeOriginal: newDate(), - description: '', - exifImageHeight: 420, - exifImageWidth: 42, - exposureTime: null, - fileSizeInByte: 69, - fNumber: 1.7, - focalLength: 4.38, - fps: null, - iso: 947, - latitude: 30.267_334_570_570_195, - longitude: -97.789_833_534_282_07, - lensModel: null, - livePhotoCID: null, - make: 'Google', - model: 'Pixel 7', - modifyDate: newDate(), - orientation: '1', - profileDescription: null, - projectionType: null, - rating: 4, - state: 'Texas', - tags: ['parent/child'], - timeZone: 'UTC-6', - ...exif, -}); - const tagFactory = (tag: Partial): Tag => ({ id: newUuid(), color: null, @@ -459,25 +335,6 @@ const tagFactory = (tag: Partial): Tag => ({ ...tag, }); -const faceFactory = ({ person, ...face }: DeepPartial = {}): AssetFace => ({ - assetId: newUuid(), - boundingBoxX1: 1, - boundingBoxX2: 2, - boundingBoxY1: 1, - boundingBoxY2: 2, - deletedAt: null, - id: newUuid(), - imageHeight: 420, - imageWidth: 42, - isVisible: true, - personId: null, - sourceType: SourceType.MachineLearning, - updatedAt: newDate(), - updateId: newUuidV7(), - person: person === null ? null : personFactory(person), - ...face, -}); - const assetEditFactory = (edit?: Partial): AssetEditActionItem => { switch (edit?.action) { case AssetEditAction.Crop: { @@ -559,26 +416,20 @@ const albumFactory = (album?: Partial>) => ({ export const factory = { activity: activityFactory, apiKey: apiKeyFactory, - asset: assetFactory, - assetFile: assetFileFactory, assetOcr: assetOcrFactory, auth: authFactory, authApiKey: authApiKeyFactory, authUser: authUserFactory, library: libraryFactory, - memory: memoryFactory, partner: partnerFactory, queueStatistics: queueStatisticsFactory, session: sessionFactory, - stack: stackFactory, user: userFactory, userAdmin: userAdminFactory, versionHistory: versionHistoryFactory, jobAssets: { sidecarWrite: assetSidecarWriteFactory, }, - exif: exifFactory, - face: faceFactory, person: personFactory, assetEdit: assetEditFactory, sharedSpace: sharedSpaceFactory, diff --git a/web/src/lib/actions/zoom-image.ts b/web/src/lib/actions/zoom-image.ts index 6288daa380b69..602ed9bd63f47 100644 --- a/web/src/lib/actions/zoom-image.ts +++ b/web/src/lib/actions/zoom-image.ts @@ -9,14 +9,15 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea zoomInstance.subscribe(({ state }) => assetViewerManager.onZoomChange(state)), ]; - const stopIfDisabled = (event: Event) => { + const onInteractionStart = (event: Event) => { if (options?.disabled) { event.stopImmediatePropagation(); } + assetViewerManager.cancelZoomAnimation(); }; - node.addEventListener('wheel', stopIfDisabled, { capture: true }); - node.addEventListener('pointerdown', stopIfDisabled, { capture: true }); + node.addEventListener('wheel', onInteractionStart, { capture: true }); + node.addEventListener('pointerdown', onInteractionStart, { capture: true }); node.style.overflow = 'visible'; return { @@ -27,8 +28,8 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea for (const unsubscribe of unsubscribes) { unsubscribe(); } - node.removeEventListener('wheel', stopIfDisabled, { capture: true }); - node.removeEventListener('pointerdown', stopIfDisabled, { capture: true }); + node.removeEventListener('wheel', onInteractionStart, { capture: true }); + node.removeEventListener('pointerdown', onInteractionStart, { capture: true }); zoomInstance.cleanup(); }, }; diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 5d3d671fc5135..e592024af87ab 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -107,7 +107,8 @@ }; const onZoom = () => { - assetViewerManager.zoom = assetViewerManager.zoom > 1 ? 1 : 2; + const targetZoom = assetViewerManager.zoom > 1 ? 1 : 2; + assetViewerManager.animatedZoom(targetZoom); }; const onPlaySlideshow = () => ($slideshowState = SlideshowState.PlaySlideshow); diff --git a/web/src/lib/components/shared-components/search-bar/search-tags-section.svelte b/web/src/lib/components/shared-components/search-bar/search-tags-section.svelte index 208de3f8a48df..b98ffeab90a45 100644 --- a/web/src/lib/components/shared-components/search-bar/search-tags-section.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-tags-section.svelte @@ -2,11 +2,11 @@ import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte'; import { preferences } from '$lib/stores/user.store'; import { getAllTags, type TagResponseDto } from '@immich/sdk'; - import { Checkbox, Icon, Label, Text } from '@immich/ui'; - import { mdiClose } from '@mdi/js'; + import { Checkbox, Label, Text } from '@immich/ui'; import { onMount } from 'svelte'; import { t } from 'svelte-i18n'; import { SvelteSet } from 'svelte/reactivity'; + import TagPill from '../tag-pill.svelte'; interface Props { selectedTags: SvelteSet | null; @@ -73,24 +73,7 @@ {#each selectedTags ?? [] as tagId (tagId)} {@const tag = tagMap[tagId]} {#if tag} -
- -

- {tag.value} -

-
- - -
+ handleRemove(tagId)} /> {/if} {/each} diff --git a/web/src/lib/components/shared-components/tag-pill.svelte b/web/src/lib/components/shared-components/tag-pill.svelte new file mode 100644 index 0000000000000..43148c595429c --- /dev/null +++ b/web/src/lib/components/shared-components/tag-pill.svelte @@ -0,0 +1,31 @@ + + +
+ +

+ {label} +

+
+ + +
diff --git a/web/src/lib/managers/asset-viewer-manager.svelte.ts b/web/src/lib/managers/asset-viewer-manager.svelte.ts index 36047d4690182..0facbcdf47f61 100644 --- a/web/src/lib/managers/asset-viewer-manager.svelte.ts +++ b/web/src/lib/managers/asset-viewer-manager.svelte.ts @@ -2,6 +2,7 @@ import { canCopyImageToClipboard } from '$lib/utils/asset-utils'; import { BaseEventManager } from '$lib/utils/base-event-manager.svelte'; import { PersistedLocalStorage } from '$lib/utils/persisted'; import type { ZoomImageWheelState } from '@zoom-image/core'; +import { cubicOut } from 'svelte/easing'; const isShowDetailPanel = new PersistedLocalStorage('asset-viewer-state', false); @@ -21,6 +22,7 @@ export type Events = { export class AssetViewerManager extends BaseEventManager { #zoomState = $state(createDefaultZoomState()); + #animationFrameId: number | null = null; imgRef = $state(); isShowActivityPanel = $state(false); @@ -45,6 +47,7 @@ export class AssetViewerManager extends BaseEventManager { } set zoom(zoom: number) { + this.cancelZoomAnimation(); this.zoomState = { ...this.zoomState, currentZoom: zoom }; } @@ -69,7 +72,35 @@ export class AssetViewerManager extends BaseEventManager { this.#zoomState = state; } + cancelZoomAnimation() { + if (this.#animationFrameId !== null) { + cancelAnimationFrame(this.#animationFrameId); + this.#animationFrameId = null; + } + } + + animatedZoom(targetZoom: number, duration = 300) { + this.cancelZoomAnimation(); + + const startZoom = this.#zoomState.currentZoom; + const startTime = performance.now(); + + const frame = (currentTime: number) => { + const elapsed = currentTime - startTime; + const linearProgress = Math.min(elapsed / duration, 1); + const easedProgress = cubicOut(linearProgress); + const interpolatedZoom = startZoom + (targetZoom - startZoom) * easedProgress; + + this.zoomState = { ...this.#zoomState, currentZoom: interpolatedZoom }; + + this.#animationFrameId = linearProgress < 1 ? requestAnimationFrame(frame) : null; + }; + + this.#animationFrameId = requestAnimationFrame(frame); + } + resetZoomState() { + this.cancelZoomAnimation(); this.zoomState = createDefaultZoomState(); } diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts index 8e31f281382b5..8addc173c4f5a 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts @@ -286,6 +286,17 @@ describe('TimelineManager', () => { expect(timelineManager.assetCount).toEqual(1); }); + it('ignores new assets that do not match the tag filter', async () => { + await timelineManager.updateOptions({ tagId: 'tag-1' }); + + const matching = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ tags: ['tag-1'] })); + const unrelated = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ tags: ['tag-2'] })); + + timelineManager.upsertAssets([matching, unrelated]); + + expect(await getAssets(timelineManager)).toEqual([matching]); + }); + // disabled due to the wasm Justified Layout import it('ignores trashed assets when isTrashed is true', async () => { const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ isTrashed: false })); diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts index 019290a5c9fc7..38c593bd00cc9 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts @@ -596,6 +596,7 @@ export class TimelineManager extends VirtualScrollManager { isMismatched(this.#options.visibility, asset.visibility) || isMismatched(this.#options.isFavorite, asset.isFavorite) || isMismatched(this.#options.isTrashed, asset.isTrashed) || + (this.#options.tagId && asset.tags && !asset.tags.includes(this.#options.tagId)) || (this.#options.assetFilter !== undefined && !this.#options.assetFilter.has(asset.id)) ); } diff --git a/web/src/lib/managers/timeline-manager/types.ts b/web/src/lib/managers/timeline-manager/types.ts index 3949c42b1c0b2..045a4a1d8ff40 100644 --- a/web/src/lib/managers/timeline-manager/types.ts +++ b/web/src/lib/managers/timeline-manager/types.ts @@ -19,6 +19,7 @@ export type Direction = 'earlier' | 'later'; export type TimelineAsset = { id: string; ownerId: string; + tags?: string[]; ratio: number; thumbhash: string | null; localDateTime: TimelineDateTime; diff --git a/web/src/lib/modals/AssetTagModal.svelte b/web/src/lib/modals/AssetTagModal.svelte index dbd5bdb118eae..5097be51aa84e 100644 --- a/web/src/lib/modals/AssetTagModal.svelte +++ b/web/src/lib/modals/AssetTagModal.svelte @@ -2,12 +2,13 @@ import { eventManager } from '$lib/managers/event-manager.svelte'; import { tagAssets } from '$lib/utils/asset-utils'; import { getAllTags, upsertTags, type TagResponseDto } from '@immich/sdk'; - import { FormModal, Icon } from '@immich/ui'; - import { mdiClose, mdiTag } from '@mdi/js'; + import { FormModal } from '@immich/ui'; + import { mdiTag } from '@mdi/js'; import { onMount } from 'svelte'; import { t } from 'svelte-i18n'; import { SvelteSet } from 'svelte/reactivity'; import Combobox, { type ComboBoxOption } from '../components/shared-components/combobox.svelte'; + import TagPill from '../components/shared-components/tag-pill.svelte'; interface Props { onClose: (updated?: boolean) => void; @@ -81,24 +82,7 @@ {#each selectedIds as tagId (tagId)} {@const tag = tagMap[tagId]} {#if tag} -
- -

- {tag.value} -

-
- - -
+ handleRemove(tagId)} /> {/if} {/each} diff --git a/web/src/lib/utils/timeline-util.ts b/web/src/lib/utils/timeline-util.ts index deccdd7d6e989..d7dc5d6aa4a70 100644 --- a/web/src/lib/utils/timeline-util.ts +++ b/web/src/lib/utils/timeline-util.ts @@ -170,6 +170,7 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): return { id: assetResponse.id, ownerId: assetResponse.ownerId, + tags: assetResponse.tags?.map((tag) => tag.id), ratio, thumbhash: assetResponse.thumbhash, localDateTime, diff --git a/web/src/test-data/factories/asset-factory.ts b/web/src/test-data/factories/asset-factory.ts index 00dd588243ea9..f3d2e80747ad2 100644 --- a/web/src/test-data/factories/asset-factory.ts +++ b/web/src/test-data/factories/asset-factory.ts @@ -37,6 +37,7 @@ export const timelineAssetFactory = Sync.makeFactory({ id: Sync.each(() => faker.string.uuid()), ratio: Sync.each((i) => 0.2 + ((i * 0.618_034) % 3.8)), // deterministic random float between 0.2 and 4.0 ownerId: Sync.each(() => faker.string.uuid()), + tags: [], thumbhash: Sync.each(() => faker.string.alphanumeric(28)), localDateTime: Sync.each(() => fromISODateTimeUTCToObject(faker.date.past().toISOString())), fileCreatedAt: Sync.each(() => fromISODateTimeUTCToObject(faker.date.past().toISOString())),