Commit Graph

815 Commits

Author SHA1 Message Date
admin c3f2f8d954 fix(sidebar): trigger logout from click 2026-06-17 12:59:48 +08:00
admin 1152491088 refactor(repositories): use barrel imports 2026-06-17 12:09:40 +08:00
admin d41b3c4473 refactor(schemas): extract nullable-default helpers to shared file
The three defensive Zod helpers (stringOrEmpty, numberOrZero,
booleanOrFalse) used by user.ts are general-purpose Zod utilities
that any schema may need — they're not user-specific. The original
inline declaration in user.ts had two downsides:

1. Any new schema (chat, auth, metrics, etc.) that needs the same
   'null | undefined → default scalar' pattern would have to
   duplicate the helper.

2. The long defensive-parse comment block (11 lines) lived inside
   user.ts and didn't explain the helpers' general purpose.

Move them to src/data/schemas/nullable-defaults.ts (top-level
sibling of auth/, chat/, metrics/, user/ subdirs) so any schema
can import them. Keep the original explanation verbatim at the
top of the new file.

Bundled in this commit:
- user.ts imports the helpers from the new file (drops 11 lines
  of comment + 3 const declarations)
- 3 auto-generated barrel index files regenerated by barrelsby
  to pick up the new exports (chat/, subscription/, stores/user/)
2026-06-17 12:05:09 +08:00
admin 312f41cdaf Merge branch 'dev' into test 2026-06-17 12:00:55 +08:00
admin 10e6c69af8 fix(pwa): prepare beforeinstallprompt listener early to avoid event loss
The previous install flow had a timing race: the
`beforeinstallprompt` event fires at unpredictable times after
page load (often ~30s+ into the session, when Chrome's heuristic
finally decides the user is engaged). If the listener was
attached only inside the dialog's onClick handler, by the time
the user clicked OK the event had already fired into the void
and the deferred prompt was null — so `install()` returned
"unavailable" every time.

The new flow attaches the listener as early as possible:

1. `pwaUtil.prepareInstallPrompt()` — new public method that
   calls the private `attachListener()`. Idempotent (guard via
   `listenersAttached`).

2. `pwaUtil.canPromptInstall()` — companion getter that returns
   whether a deferred event is currently cached. Useful for UI
   to decide whether to even show the install CTA.

3. `pwa-install-overlay.tsx` — calls `prepareInstallPrompt()`
   right after the isSupported / isInstalled guards. The overlay
   mounts when the user enters /chat, so this is the earliest
   practical point in the user flow.

4. `pwa-screen.tsx` — calls `prepareInstallPrompt()` BOTH at
   module load (synchronous, so the listener is attached before
   the splash screen's useEffects run) AND in a useEffect for
   robustness. The splash screen is the very first screen the
   user sees, so attaching here is the earliest possible.

5. `pwa-install-dialog.tsx` — `handleInstall` is now async,
   and `onInstall` accepts `() => void | Promise<void>`. The
   overlay calls `await pwaUtil.install()` so the deferred
   prompt flow can complete before the dialog unmounts.

Together: the listener is now attached from the moment the
splash screen module loads, the deferred prompt is captured
whenever Chrome decides to fire the event, and the dialog's
install button can reliably `prompt()` against the cached
deferred. The full install flow now works end-to-end.
2026-06-17 12:00:24 +08:00
admin 22d59f69c4 Merge branch 'dev' into test 2026-06-17 11:52:44 +08:00
admin c07a10ee80 refactor(splash): handle Skip redirect in splash screen, decouple SplashButton
Previously, the Skip button (guest login entry) relied on the auth
state machine's `pendingRedirect` flag to trigger the navigation
to /chat after guest login completed. That introduced a
non-obvious coupling: the button dispatched an event, the machine
ran an actor, the auth screen (or splash useEffect) saw the flag
and redirected.

This refactor moves the redirect into the splash screen itself:

- `SplashButton` now takes an `onSkip` prop and no longer knows
  about auth dispatch. Pure presentation component.
- `SplashScreen` provides `handleSkip` that dispatches the
  `AuthGuestLoginSubmitted` event AND immediately calls
  `router.replace(/chat)` for snappier perceived navigation.
- `auth-machine.ts`: `loadingGuestLogin.onDone` no longer sets
  `pendingRedirect: true` (splash already navigated). Sets
  it to `false` explicitly so other screens (auth, sidebar) that
  also react to `pendingRedirect` don't double-navigate if the
  user triggers guest login from a non-splash surface in the future.

No behavior change for the happy path: Skip → /chat works the same.
The refactor is purely about responsibility allocation and
component decoupling.
2026-06-17 11:51:35 +08:00
admin d5938ca1fb Merge branch 'dev' into test 2026-06-17 11:32:29 +08:00
admin 015ec111bd fix(pwa): rename serwist route from [[...slug]] to [path] to match Serwist's hardcoded param
The Serwist createSerwistRoute factory returns a GET handler with
`params: Promise<{ path: string }>` hardcoded. Next.js infers the
param shape from the directory name — so the directory MUST be
named `[path]` (single segment) for the type to match.

`[[...slug]]` (catch-all) was wrong on two counts:
1. The segment name is `slug`, not `path` (Serwist expects
   exactly `path`).
2. The shape is `string[]` (array), not `string` (Serwist
   expects a single string).

Per the Serwist source comment: 'Asset and chunk names must be at
the top, as our path is /serwist/[path], not /serwist/[...path]'
— all SW output files (sw.js, workbox-XXX.js) are flat, single
segment is sufficient.

The fix is a directory rename only — the route.ts content
(createSerwistRoute call) is unchanged.

Verification:
- pnpm build now succeeds (TypeScript check passes)
- The Serwist route appears in the build output as `●  /serwist/[path]`
  prerendered with `sw.js` and `sw.js.map` static pages
- `pnpm start` + Chrome: `/serwist/sw.js` will be served by the
  route handler and registered by <SerwistProvider>
2026-06-17 11:30:57 +08:00
admin 73fadaba95 feat: add git commit guidelines and workflow documentation 2026-06-17 11:27:22 +08:00
admin 6e50aa4c8c Merge branch 'dev' into test 2026-06-17 11:15:03 +08:00
admin 0d5c416cba fix(images): allowlist Facebook avatar hosts in next/image remotePatterns
Facebook OAuth login returns avatar URLs from
platform-lookaside.fbsbx.com (and occasionally graph.facebook.com).
next/image's optimizer rejects these with 400 Bad Request when they
are not in the allowlist, so chat/sidebar/subscription avatar
<Image> components all fail to render.

Add images.remotePatterns entries for both hosts. Three <Image>
consumers (message-avatar, user-header, subscription-user-row) need
no changes — they all pass the avatarUrl through verbatim and will
work once the optimizer accepts the host.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-17 11:14:24 +08:00
admin ee760abdf5 refactor(auth): clean up user logout handling in SidebarScreen 2026-06-17 11:11:25 +08:00
admin ae0f385bb0 Merge branch 'dev' into test 2026-06-17 11:10:24 +08:00
admin 67dcf97edc refactor(pwa): migrate from @ducanh2912/next-pwa to @serwist/turbopack
Next.js 16 ships Turbopack as the default bundler, but
@ducanh2912/next-pwa is webpack-only — the previous PWA setup
required `--webpack` on dev/build/"dev:proxy" scripts, accepting
a perf regression in exchange for working PWA install.

@serwist/turbopack (paired with the official 'serwist' package
and 'esbuild') is the Turbopack-native successor: it works with
the default bundler, no opt-out flag, and gives us a real TS
service worker source file we can read and edit.

What this commit does:

1. Swap deps in package.json:
   - Remove @ducanh2912/next-pwa
   - Add @serwist/turbopack, serwist, esbuild (all devDeps)
   - Drop --webpack flag from dev / dev:proxy / build scripts
     (Turbopack is restored as the bundler)

2. next.config.ts:
   - Replace withPWAInit wrapping with withSerwist named import
   - Update comment block to describe new architecture

3. New src/app/sw.ts: Serwist service worker source.
   skipWaiting + clientsClaim + navigationPreload + defaultCache.
   This file is compiled to a real SW at build time by Serwist.

4. New src/app/serwist/[[...slug]]/route.ts: catch-all Route
   Handler that serves the compiled SW at /serwist/sw.js plus
   Serwist's chunked runtime assets. Catch-all is required
   because Serwist serves multiple files under /serwist/.

5. src/app/layout.tsx: replace <SwRegister /> with
   <SerwistProvider swUrl="/serwist/sw.js"> from
   @serwist/turbopack/react. The provider handles registration
   + lifecycle internally.

6. Delete src/app/_components/core/sw-register.tsx: replaced
   by SerwistProvider.

7. .gitignore: add public/sw.js and public/workbox-*.js
   (defensive — these are build artifacts that change hash
   every build and shouldn't be committed).

8. Untrack public/workbox-3c9d0171.js: stale workbox runtime
   from the previous next-pwa build, hash is build-specific
   so it can never be regenerated correctly.

Not modified (intentionally):
- src/utils/pwa.ts: pure beforeinstallprompt browser-API wrapper,
  library-agnostic, stays as-is.
- pwa-install-overlay / pwa-install-dialog: install UI flow
  unchanged.
- public/manifest.json: still works; layout's metadata.manifest
  points at it.

Verification:
- pnpm install resolved cleanly (added 24 pkgs, removed 209)
- npx tsc --noEmit passes (EXIT=0)
- Serwist provides named exports, not default — withSerwist and
  createSerwistRoute are both named imports
2026-06-17 11:09:36 +08:00
admin d9013a9dc4 Merge branch 'dev' into test 2026-06-17 10:07:15 +08:00
admin bb2ae41232 fix(auth): include OAuth backend-sync phase in isLoading derivation
The AuthState.isLoading derivation in auth-context.tsx covered the
OAuth redirect phase (loadingOAuth) but NOT the backend-sync phase
that runs after the OAuth callback returns
(syncingGoogleBackend / syncingFacebookBackend). Any UI that
read isLoading would incorrectly report "not loading" during
the backend token-exchange round-trip.

Add the two missing state.matches() lines so isLoading is true
for the entire OAuth login window (button click → OAuth redirect
→ callback → backend sync).

Note: in the current OAuth flow the user is on /chat during the
sync phase (NextAuth redirected them there), so the splash/auth
button is not actually visible to reflect the new true value.
The fix is still correct because:

- Conceptually aligned: any in-flight login op is "loading"
- Defense in depth: future UI on /chat that consumes isLoading
  will get accurate feedback
- Non-regression: only makes isLoading more permissive (true more
  often), never false-fires

A visible /chat overlay during the sync phase would be a separate,
larger change — noted in the planning doc but not in this commit.
2026-06-17 10:06:07 +08:00
admin e85963e7bf refactor(auth): rename AuthStatusCheckSubmitted → AuthInit (init-only)
The previous event name implied "re-check status", but the actual
semantics is just "read storage on app start and sync loginStatus into
the machine". It was being dispatched from three sites:

1. AuthStatusChecker mount useEffect (the legit one-time init)
2. AuthStatusChecker loginStatus-watching useEffect (dead code — the
   machine's onDone already writes loginStatus, re-reading storage
   just returns the same value as a no-op)
3. Sidebar post-logout effect (also dead code — AuthReset directly
   sets loginStatus to initialState's "notLoggedIn", and
   userLogoutActor already cleared storage, so the values are
   already aligned)

This commit:

- Renames event AuthStatusCheckSubmitted → AuthInit in:
  * auth-events.ts (type union)
  * auth-machine.ts (handler + transition target: checkingAuthStatus →
    initializing, mirroring UserInit / initializing in user-machine)
  * auth-status-checker.tsx (single dispatch site)
  * root-providers.tsx (one comment)

- Simplifies auth-status-checker.tsx from 74 → ~40 lines:
  * Removes useEffect ② (loginStatus-watching re-check)
  * Removes prevLoginStatusRef + skipNextChangeRef + useAuthState
    (dead loop-guard machinery no longer needed)

- Removes the post-logout re-check dispatch from sidebar-screen.tsx:
  * AuthReset alone is sufficient — userLogoutActor cleared storage
    and AuthReset writes back initialState, so they match by
    construction. No re-verification needed.

Net: -44 lines, no behavior change for the happy paths (startup,
login, logout), one fewer source of false re-checks.
2026-06-17 09:52:45 +08:00
admin 88c26c2889 Merge branch 'dev' into test 2026-06-17 09:51:50 +08:00
admin 2acc005809 feat(pwa): wire up @ducanh2912/next-pwa + register SW in layout
PWA install flow was wired but unusable: pwaUtil.install() calls
deferred.prompt(), but Chrome only fires beforeinstallprompt when a
valid Service Worker is registered + a manifest is linked. The project
had both intent (manifest, usePwaInstall hook) but no live SW —
public/sw.js was a stale workbox build artifact from a previous
attempt with hardcoded chunk URLs that no longer matched any build.

What this commit does:

1. Add @ducanh2912/next-pwa@10.2.9 as devDependency.
2. Wrap nextConfig with withPWA({...}):
   - dest: "public" SW outputs to public/sw.js
   - disable: true skip SW generation in dev (HMR-friendly)
   - register: false don't auto-inject registration
   - workboxOptions.skipWaiting: true + clientsClaim: true — new SW
     takes over immediately
3. Add --webpack flag to dev/build/dev:proxy scripts.
   @ducanh2912/next-pwa is webpack-only; Turbopack (Next 16 default)
   doesn't load webpack plugins. Per user decision to accept the perf
   trade-off in exchange for a working PWA.
4. Delete stale public/sw.js (workbox will regenerate it at build
   time with the correct chunk URLs).
5. Add src/app/_components/core/sw-register.tsx: a no-UI client
   component that registers /sw.js in production. Mounted at the end
   of <body> in layout.tsx. disable: true means dev mode skips
   registration (no /sw.js to fetch anyway).

After this commit + pnpm build, public/sw.js is regenerated with
correct chunk URLs, registered on first prod load, and Chrome fires
beforeinstallprompt — usePwaInstall + pwaUtil.install() then trigger
the native install prompt end-to-end.
2026-06-16 19:36:03 +08:00
admin e632fa7139 fix(auth): correct Facebook Graph field expansion syntax for picture
Facebook Graph API field expansion uses parentheses, not equals signs.
The buggy `picture.type=large` produced HTTP 400 with
`OAuthException code 2500: Syntax error ... at character 32`.

Fix `FIELDS` constant and 2 JSDoc comment lines in facebook-graph.ts
to use `picture.type(large)` per Facebook's documented syntax.

Effect: after Facebook OAuth sync, `fetchFacebookUserData` now
succeeds (HTTP 200), `UserStorage.setAvatarUrl` and
`AuthStorage.setFacebookId` get populated, and the
`[auth-machine] syncFacebookBackendActor: fetchFacebookUserData
failed (continuing anyway)` warning stops firing on the happy path.
2026-06-16 19:22:14 +08:00
admin 851c1d4f11 Merge branch 'dev' into test 2026-06-16 19:06:19 +08:00
admin d6f2cb7c56 fix(pwa): enhance PWA install overlay logic for daily display limits and environment handling 2026-06-16 19:05:50 +08:00
admin 5be40949a1 feat(pwa): implement PwaUtil class for PWA support and installation checks 2026-06-16 18:59:17 +08:00
admin 8d786f1e04 fix(chat): align user bubble to match AI bubble for consistent layout 2026-06-16 18:58:42 +08:00
admin 88b94a9f1a Merge branch 'dev' into test 2026-06-16 18:35:26 +08:00
admin 0a9abc2502 fix(auth): stop parsing logout response body (avoids ZodError on null data)
Root cause: backend `/auth/logout` returns `{ success: true, data: null }`
(no business payload for a fire-and-forget endpoint). The previous
`AuthApi.logout` ran `unwrap(env)` (which passes null through, since
null is defined) and then `LogoutResponse.fromJson(null)`, which
Zod-parses as `z.object(...)` and throws "Invalid input: expected
object, received null".

The error was caught in `AuthRepository.logout` with
`console.warn(... clearing local anyway)` — logout actually still
worked (local clear ran), but a noisy red ZodError hit the console on
every logout click.

Fix: align `AuthApi.logout` with the existing fire-and-forget pattern
used by `register` and `sendCode` — call `httpClient` and ignore the
response body. Drop the now-unused `LogoutResponse` import.

`AuthRepository.logout` already discards the return value, so the
`Promise<LogoutResponse> → Promise<void>` signature change is safe.

Bundled in this commit (unrelated):
- chat-machine.actors.ts / user-machine.ts: removed stale design-doc
  comment blocks (IDE/linter cleanup)
- user-machine.actors.ts: removed redundant `userStorage.clearUserData()`
  from `userLogoutActor` — `authRepo.logout` already clears local
  user data, so this was a no-op duplicate.
- sidebar-screen.tsx: removed one stale comment line.
2026-06-16 18:34:55 +08:00
admin 63704a7309 Merge branch 'dev' into test 2026-06-16 18:24:49 +08:00
admin 8832552321 fix(githooks): write log timestamps in local time (was UTC + Z) 2026-06-16 18:24:27 +08:00
admin 39e7d61c8a fix(auth): re-check auth status after logout; clear NextAuth session after sync
1) Sidebar logout flow:
   - After AuthReset, also dispatch AuthStatusCheckSubmitted so the auth
     machine re-verifies storage (not just resets to initialState). This
     makes sure the redirect to /chat reflects the actual storage state,
     not just the forcibly-cleared context.

2) OAuthSessionSync:
   - After dispatching AuthGoogleSyncSubmitted / AuthFacebookSyncSubmitted,
     call signOut({ redirect: false }) to drop the NextAuth session.
     The OAuth idToken / accessToken has already been handed to the
     backend; clearing the browser-side session prevents lingering OAuth
     credential exposure. Fire-and-forget — once status flips to
     "unauthenticated", the useEffect early-returns so no re-entry.

Note: auth-machine.ts lost a 3-line stale comment block about XState v5
type inference for inline assign; bundled with this commit.
2026-06-16 18:24:23 +08:00
admin a44463b3ee refactor(user): split user-machine into helpers and actors
Mirror the existing chat-module structure (chat-machine.helpers.ts +
chat-machine.actors.ts) for consistency across state-machine modules.

Split:
- user-machine.helpers.ts: pure data layer (no XState dep)
  - userStorage singleton
  - InitData interface
  - toView DTO → UserView mapper
  - readInitData (parallel getUser + getAvatarUrl)
- user-machine.actors.ts: async actor implementations (fromPromise)
  - userRepo / authRepo injection
  - userInitActor / userFetchActor / userLogoutActor
- user-machine.ts: only setup().createMachine() — keeps inline assign
  actions referencing context/event where they belong, imports actors
  (not helpers, per the layered convention).

No behaviour change — only file boundaries moved. Public API (exports of
UserState / UserEvent / initialState / userMachine / UserMachine) stays
identical so external imports are untouched.
2026-06-16 18:12:11 +08:00
admin 634c70a56a chore: verify local-time hook timestamps 2026-06-16 18:05:39 +08:00
admin f6735c75c8 fix(githooks): write log timestamps in local time (was UTC + Z) 2026-06-16 18:05:03 +08:00
admin bde8b99c04 Merge branch 'dev' into test 2026-06-16 17:56:18 +08:00
admin 8b6e38d3cb feat(chat): prepend greeting message when chat history is empty
On first chat open (or when both local and network return empty),
the chat screen would render completely blank, which feels cold and
broken. Prepend a single AI persona greeting as the first message so
the first impression is warm.

Trigger:
- Final returned messages array is empty
- Either network succeeds with empty result, or network fails and local
  is also empty

Design notes:
- Greeting is NOT persisted to local/network — pure UI-layer fallback.
  Once the user sends a real message, network has content and the
  greeting no longer appears.
- If the user closes the app without ever sending a message and reopens,
  the greeting reappears (intentional: "warm first frame on each cold
  start").
- Rendered as an AI bubble (isFromAI: true) so the visual style matches
  the AI persona.
2026-06-16 17:56:03 +08:00
admin 2e238c3df8 Merge branch 'dev' into test 2026-06-16 17:43:39 +08:00
admin c5686d2749 feat(auth): implement Facebook user data fetching from Graph API and persist profile information 2026-06-16 17:43:19 +08:00
admin 82bf917ace feat(auth): enhance AuthStatusChecker with detailed status change handling and prevention of infinite loops 2026-06-16 17:42:37 +08:00
admin 4ee9d6cb81 feat(docs): add git commit guidelines and best practices 2026-06-16 17:31:20 +08:00
admin 200fb1642f fix(auth): surface validation errors in email login/register forms
Validation failure was previously silent — the form's submit() returned
without dispatching or showing anything on screen, so users could not tell
why the login/register button appeared to do nothing.

Changes:
- Add validationError local state in EmailLoginForm and EmailRegisterForm
- Display first validation error via AuthErrorMessage, prioritised over
  the server's globalError (validation errors feel more immediate)
- Clear validation error on field change (standard "error fades as you
  fix it" UX) — implemented as onChange handlers, not useEffect, to
  avoid React's cascading-render anti-pattern
- Add observability to the entire email flow (was previously silent end
  to end): form submit ENTRY, validator rejection, actor ENTRY, actor
  DONE — mirrors the logging style already used by guestLoginActor
- Add entry: assign({ errorMessage: null }) to loadingEmailLogin and
  loadingEmailRegister so stale errors clear on transition into the
  loading state (matches loadingGuestLogin)
2026-06-16 17:29:58 +08:00
admin 2792d0c9c5 feat(chat): implement WebSocket message sending and integrate with chat state machine 2026-06-16 16:50:57 +08:00
admin 6692c5a68a Merge branch 'dev' into test 2026-06-16 16:42:57 +08:00
admin 03a2580023 chore: trigger post-receive hook verification 2026-06-16 16:36:52 +08:00
admin 436a0addcc fix(githooks): pin absolute GIT_DIR and unset GIT_WORK_TREE in post-receive 2026-06-16 16:31:35 +08:00
admin 5764b3c433 feat(ui): replace emoji icons with lucide-react icon components 2026-06-16 16:23:36 +08:00
admin db350aae44 fix(githooks): use absolute-git-dir to find worktree in post-receive
`git rev-parse --show-toplevel` in hook context (cwd=GIT_DIR, GIT_DIR
set) returns the .git path itself without erroring, so the previous
`|| echo $PWD` fallback never triggered and the script silently
cd'd into .git/.

Use `git rev-parse --absolute-git-dir` to reliably get the absolute
git-dir, then its parent is the worktree root. This works in both
interactive shells and hook context.
2026-06-16 15:58:38 +08:00
admin b09e2d093d Merge branch 'dev' into test 2026-06-16 15:53:50 +08:00
admin 594682ba6b chore(images): update auth, chat, icons, and splash images 2026-06-16 15:48:10 +08:00
admin 697dce64b1 chore: remove bold markers from Chinese characters in post-receive hook comments 2026-06-16 15:39:00 +08:00
admin bab731bd21 chore(githooks): simplify post-receive hook repo root detection
Remove the workaround that derived REPO_TOPLEVEL from
`git rev-parse --absolute-git-dir` and fall back to
`git rev-parse --show-toplevel` directly. Also drop the
related comments and debug echo. The simpler approach is
sufficient for the hook's needs and makes the script easier
to maintain.
2026-06-16 15:33:22 +08:00