Private
Public Access
Phase 3: PWA offline caching (frontend only)
Adds a real Workbox runtime-caching strategy on top of the Phase 1 app-shell
precache: StaleWhileRevalidate (cache-and-refresh) for the five read
endpoints (rooms/mine, open rooms, room messages, room members, invites/mine)
with a bounded/expiring cache per endpoint, while /api/auth/* and all
mutations stay network-only. An OfflineBanner (navigator.onLine-driven) and
a clearer Composer status line ("Connecting..." vs "You're offline") surface
what's actually happening; api/client.ts gains a NetworkError distinct from
ApiError so a genuine cache-miss-while-offline shows a quiet empty state
instead of a red error.
Manual offline testing (backend stopped, `vite preview` against the real
production service worker) surfaced a real gap the plan hadn't accounted
for: GET /api/auth/me is intentionally NetworkOnly, but that meant
ProtectedRoute could never confirm a session while offline and always
bounced to /login -- none of the newly-cached room/message data was ever
reachable. Fixed by caching a minimal, non-sensitive "last known user" in
localStorage (lib/lastUser.ts) and having AuthContext fall back to it for
any *unconfirmed* auth check (network failure, or a down backend answering
through a live reverse proxy with its own 502/503/504 -- both happen in
real deployments, not just literal airplane-mode). Only a server-confirmed
401 still clears it and signs the user out; every real action still
re-checks the actual session cookie server-side, so this can't grant
anything -- it only keeps cached UI reachable.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,12 +7,27 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// Thrown when fetch() itself fails (e.g. offline with no cache entry for this
|
||||
// request) -- distinct from ApiError, which means the server was reachable
|
||||
// and responded with an error status. Callers use this to show a quiet
|
||||
// "not available offline" state instead of a real-error message.
|
||||
export class NetworkError extends Error {
|
||||
constructor() {
|
||||
super('Network unreachable')
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(path, {
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...init,
|
||||
})
|
||||
} catch {
|
||||
throw new NetworkError()
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let detail = response.statusText
|
||||
|
||||
@@ -70,3 +70,7 @@
|
||||
padding: var(--sp-2) var(--sp-4) 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chat-pane-note {
|
||||
color: var(--ds-muted);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { NetworkError } from '../api/client'
|
||||
import { getRoomMessages } from '../api/rooms'
|
||||
import { useChatSocket } from '../ws/useChatSocket'
|
||||
import type { ChatMessageEnvelope, Message, MyRoomItem, RoomMember, ServerEnvelope } from '../types'
|
||||
@@ -21,12 +22,22 @@ export function ChatPane({ room, members, isMobile, onBack, onToggleInfo, infoOp
|
||||
const [history, setHistory] = useState<Message[]>([])
|
||||
const [live, setLive] = useState<ChatMessageEnvelope[]>([])
|
||||
const [wsError, setWsError] = useState<string | null>(null)
|
||||
const [historyUnavailableOffline, setHistoryUnavailableOffline] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setHistory([])
|
||||
setLive([])
|
||||
setWsError(null)
|
||||
getRoomMessages(room.id).then(setHistory).catch((err) => setWsError(String(err)))
|
||||
setHistoryUnavailableOffline(false)
|
||||
getRoomMessages(room.id)
|
||||
.then(setHistory)
|
||||
.catch((err) => {
|
||||
if (err instanceof NetworkError) {
|
||||
setHistoryUnavailableOffline(true)
|
||||
} else {
|
||||
setWsError(String(err))
|
||||
}
|
||||
})
|
||||
}, [room.id])
|
||||
|
||||
const onMessage = useCallback((envelope: ServerEnvelope) => {
|
||||
@@ -71,6 +82,11 @@ export function ChatPane({ room, members, isMobile, onBack, onToggleInfo, infoOp
|
||||
</header>
|
||||
|
||||
{wsError && <p className="chat-pane-error">{wsError}</p>}
|
||||
{historyUnavailableOffline && (
|
||||
<p className="chat-pane-error chat-pane-note">
|
||||
Message history for this room isn't available offline yet.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<MessageList messages={[...history, ...live]} members={members} />
|
||||
<Composer roomName={room.name} disabled={!connected} onSend={send} />
|
||||
|
||||
@@ -2,12 +2,18 @@
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-top: 1px solid var(--ds-border);
|
||||
background: var(--ds-surface);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.composer-box {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.composer textarea {
|
||||
.composer-box textarea {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
background: var(--ds-surface-2);
|
||||
@@ -20,7 +26,7 @@
|
||||
max-height: 120px;
|
||||
}
|
||||
|
||||
.composer textarea:focus {
|
||||
.composer-box textarea:focus {
|
||||
border-color: var(--ds-accent);
|
||||
}
|
||||
|
||||
@@ -42,3 +48,9 @@
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.composer-status {
|
||||
font-size: 0.76rem;
|
||||
color: var(--ds-muted);
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useRef, useState, type KeyboardEvent } from 'react'
|
||||
import { useOnlineStatus } from '../hooks/useOnlineStatus'
|
||||
import './Composer.css'
|
||||
|
||||
interface ComposerProps {
|
||||
@@ -10,6 +11,7 @@ interface ComposerProps {
|
||||
export function Composer({ roomName, disabled, onSend }: ComposerProps) {
|
||||
const [value, setValue] = useState('')
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const online = useOnlineStatus()
|
||||
|
||||
function autoGrow() {
|
||||
const el = textareaRef.current
|
||||
@@ -35,6 +37,7 @@ export function Composer({ roomName, disabled, onSend }: ComposerProps) {
|
||||
|
||||
return (
|
||||
<div className="composer">
|
||||
<div className="composer-box">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
@@ -45,7 +48,7 @@ export function Composer({ roomName, disabled, onSend }: ComposerProps) {
|
||||
autoGrow()
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={`Message #${roomName}`}
|
||||
placeholder={disabled ? (online ? 'Connecting…' : "You're offline") : `Message #${roomName}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -59,5 +62,9 @@ export function Composer({ roomName, disabled, onSend }: ComposerProps) {
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{disabled && (
|
||||
<div className="composer-status">{online ? 'Connecting…' : "You're offline — messages can't be sent right now"}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
.offline-banner {
|
||||
flex: none;
|
||||
background: color-mix(in srgb, var(--ds-highlight) 16%, var(--ds-surface));
|
||||
color: var(--ds-text);
|
||||
border-bottom: 1px solid var(--ds-border);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
padding: 6px var(--sp-4);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useOnlineStatus } from '../hooks/useOnlineStatus'
|
||||
import './OfflineBanner.css'
|
||||
|
||||
export function OfflineBanner() {
|
||||
const online = useOnlineStatus()
|
||||
|
||||
if (online) return null
|
||||
|
||||
return (
|
||||
<div className="offline-banner" role="status">
|
||||
You're offline — showing cached data.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -103,3 +103,11 @@
|
||||
color: var(--ds-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.sidebar-offline-note {
|
||||
color: var(--ds-muted);
|
||||
font-size: 0.82rem;
|
||||
padding: var(--sp-4);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ interface SidebarProps {
|
||||
onOpenBrowse: () => void
|
||||
onOpenInvites: () => void
|
||||
inviteCount: number
|
||||
unavailableOffline?: boolean
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
@@ -22,6 +23,7 @@ export function Sidebar({
|
||||
onOpenBrowse,
|
||||
onOpenInvites,
|
||||
inviteCount,
|
||||
unavailableOffline,
|
||||
}: SidebarProps) {
|
||||
const query = searchQuery.trim().toLowerCase()
|
||||
const filtered = query ? rooms.filter((r) => r.name.toLowerCase().includes(query)) : rooms
|
||||
@@ -67,6 +69,12 @@ export function Sidebar({
|
||||
Browse rooms
|
||||
</button>
|
||||
|
||||
{unavailableOffline ? (
|
||||
<p className="sidebar-offline-note">
|
||||
Your rooms aren't available offline yet. Reconnect to load them.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{filtered.length > 0 && <div className="sidebar-section-label">Rooms</div>}
|
||||
<nav>
|
||||
{filtered.map((room, i) => (
|
||||
@@ -78,6 +86,8 @@ export function Sidebar({
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||
import * as authApi from '../api/auth'
|
||||
import { ApiError } from '../api/client'
|
||||
import { ApiError, NetworkError } from '../api/client'
|
||||
import { clearLastUser, loadLastUser, saveLastUser } from '../lib/lastUser'
|
||||
import type { User } from '../types'
|
||||
|
||||
interface AuthContextValue {
|
||||
user: User | null
|
||||
loading: boolean
|
||||
offline: boolean
|
||||
login: (usernameOrEmail: string, password: string) => Promise<void>
|
||||
logout: () => Promise<void>
|
||||
}
|
||||
@@ -15,30 +17,58 @@ const AuthContext = createContext<AuthContextValue | undefined>(undefined)
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [offline, setOffline] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
authApi
|
||||
.me()
|
||||
.then(setUser)
|
||||
.then((u) => {
|
||||
setUser(u)
|
||||
setOffline(false)
|
||||
saveLastUser(u)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!(err instanceof ApiError && err.status === 401)) {
|
||||
// A confirmed 401 means the server is reachable and says "not
|
||||
// logged in" -- that's the only case that should actually clear the
|
||||
// cached identity. Everything else (a real network failure, or the
|
||||
// backend being down behind a reverse proxy that answers with its
|
||||
// own 502/503/504 -- both happen in production, not just literal
|
||||
// offline) means we simply couldn't get a confirmed answer.
|
||||
// /api/auth/me is NetworkOnly by design (never trust a stale
|
||||
// "who am I" as the *source of truth*), but treating "couldn't
|
||||
// check" the same as "confirmed logged out" would lock users out of
|
||||
// the cached rooms/messages entirely whenever the backend is
|
||||
// unreachable. Every real action still re-checks the actual session
|
||||
// cookie server-side, so falling back here can't grant anything.
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
clearLastUser()
|
||||
return
|
||||
}
|
||||
if (!(err instanceof NetworkError)) {
|
||||
console.error('Failed to load current user', err)
|
||||
}
|
||||
const cached = loadLastUser()
|
||||
setUser(cached)
|
||||
setOffline(cached !== null)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
async function login(usernameOrEmail: string, password: string) {
|
||||
setUser(await authApi.login(usernameOrEmail, password))
|
||||
const u = await authApi.login(usernameOrEmail, password)
|
||||
setUser(u)
|
||||
setOffline(false)
|
||||
saveLastUser(u)
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await authApi.logout()
|
||||
setUser(null)
|
||||
clearLastUser()
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, logout }}>
|
||||
<AuthContext.Provider value={{ user, loading, offline, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
// navigator.onLine reflects the OS network interface, not whether our
|
||||
// backend is actually reachable -- it can be true when the API is down. This
|
||||
// is still a useful fast/cheap signal for a banner; the WebSocket
|
||||
// `connected` state (see useChatSocket) is the more reliable one for
|
||||
// whether live messaging actually works right now.
|
||||
export function useOnlineStatus(): boolean {
|
||||
const [online, setOnline] = useState(() => navigator.onLine)
|
||||
|
||||
useEffect(() => {
|
||||
const goOnline = () => setOnline(true)
|
||||
const goOffline = () => setOnline(false)
|
||||
window.addEventListener('online', goOnline)
|
||||
window.addEventListener('offline', goOffline)
|
||||
return () => {
|
||||
window.removeEventListener('online', goOnline)
|
||||
window.removeEventListener('offline', goOffline)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return online
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { User } from '../types'
|
||||
|
||||
// A minimal, non-sensitive "who was I last logged in as" cache, so the app
|
||||
// shell can render offline (ProtectedRoute has something to show) even
|
||||
// though GET /api/auth/me is intentionally NetworkOnly and can't confirm the
|
||||
// session while offline. This never grants access to anything real -- every
|
||||
// server-side action still re-checks the actual session cookie, so a stale
|
||||
// or wrong cached user here can, at worst, show cached UI; it can't act.
|
||||
const KEY = 'kit_last_user'
|
||||
|
||||
export function saveLastUser(user: User): void {
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(user))
|
||||
} catch {
|
||||
// storage unavailable (private browsing, quota) -- offline fallback
|
||||
// just won't work this session, not fatal.
|
||||
}
|
||||
}
|
||||
|
||||
export function loadLastUser(): User | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY)
|
||||
return raw ? (JSON.parse(raw) as User) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function clearLastUser(): void {
|
||||
try {
|
||||
localStorage.removeItem(KEY)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { NetworkError } from '../api/client'
|
||||
import { listMyInvites } from '../api/invites'
|
||||
import { listMyRooms, listRoomMembers } from '../api/rooms'
|
||||
import { BrowseRoomsModal } from '../components/BrowseRoomsModal'
|
||||
import { ChatPane } from '../components/ChatPane'
|
||||
import { InvitesModal } from '../components/InvitesModal'
|
||||
import { NewRoomModal } from '../components/NewRoomModal'
|
||||
import { OfflineBanner } from '../components/OfflineBanner'
|
||||
import { RoomInfoPanel } from '../components/RoomInfoPanel'
|
||||
import { Sidebar } from '../components/Sidebar'
|
||||
import { TopBar } from '../components/TopBar'
|
||||
@@ -27,13 +29,23 @@ export function ChatShellPage() {
|
||||
const [inviteCount, setInviteCount] = useState(0)
|
||||
const [infoOpen, setInfoOpen] = useState(false)
|
||||
const [modal, setModal] = useState<ModalKind>(null)
|
||||
const [roomsUnavailableOffline, setRoomsUnavailableOffline] = useState(false)
|
||||
|
||||
const activeRoom = rooms.find((r) => r.id === roomId)
|
||||
|
||||
const refreshRooms = useCallback(async () => {
|
||||
try {
|
||||
const list = await listMyRooms()
|
||||
setRooms(list)
|
||||
setRoomsUnavailableOffline(false)
|
||||
return list
|
||||
} catch (err) {
|
||||
if (err instanceof NetworkError) {
|
||||
setRoomsUnavailableOffline(true)
|
||||
return []
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}, [])
|
||||
|
||||
const refreshMembers = useCallback(() => {
|
||||
@@ -62,6 +74,7 @@ export function ChatShellPage() {
|
||||
return (
|
||||
<div className="chat-shell">
|
||||
<TopBar />
|
||||
<OfflineBanner />
|
||||
<div className="chat-shell-body">
|
||||
{(!isMobile || !roomId) && (
|
||||
<Sidebar
|
||||
@@ -73,6 +86,7 @@ export function ChatShellPage() {
|
||||
onOpenBrowse={() => setModal('browse')}
|
||||
onOpenInvites={() => setModal('invites')}
|
||||
inviteCount={inviteCount}
|
||||
unavailableOffline={roomsUnavailableOffline && rooms.length === 0}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
+85
-3
@@ -2,6 +2,11 @@ import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
const READ_CACHE_EXPIRATION = {
|
||||
maxEntries: 50,
|
||||
maxAgeSeconds: 7 * 24 * 60 * 60, // 7 days
|
||||
}
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
@@ -13,8 +18,8 @@ export default defineConfig({
|
||||
short_name: 'Talking',
|
||||
start_url: '/',
|
||||
display: 'standalone',
|
||||
background_color: '#ffffff',
|
||||
theme_color: '#111111',
|
||||
background_color: '#07080f', // --ds-void
|
||||
theme_color: '#101030', // --ds-surface
|
||||
icons: [
|
||||
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
|
||||
@@ -28,9 +33,71 @@ export default defineConfig({
|
||||
},
|
||||
workbox: {
|
||||
navigateFallbackDenylist: [/^\/api/, /^\/ws/],
|
||||
// urlPattern uses function matchers against url.pathname rather than
|
||||
// RegExp (which Workbox tests against the *full href*, origin
|
||||
// included -- a `^/api/` anchor would silently never match).
|
||||
runtimeCaching: [
|
||||
// Never serve a stale cached "who am I" response.
|
||||
{
|
||||
urlPattern: /^\/api\//,
|
||||
urlPattern: ({ url }) => url.pathname.startsWith('/api/auth/'),
|
||||
handler: 'NetworkOnly',
|
||||
},
|
||||
// Cache-and-refresh: show the last known list/history immediately,
|
||||
// update from the network in the background. Routes default to
|
||||
// matching GET only, so mutations to these same paths are
|
||||
// untouched and still go straight to network.
|
||||
{
|
||||
urlPattern: ({ url }) => url.pathname === '/api/rooms/mine',
|
||||
handler: 'StaleWhileRevalidate',
|
||||
options: {
|
||||
cacheName: 'api-rooms-mine',
|
||||
expiration: READ_CACHE_EXPIRATION,
|
||||
cacheableResponse: { statuses: [0, 200] },
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: ({ url }) => url.pathname === '/api/rooms',
|
||||
handler: 'StaleWhileRevalidate',
|
||||
options: {
|
||||
cacheName: 'api-rooms-open',
|
||||
expiration: READ_CACHE_EXPIRATION,
|
||||
cacheableResponse: { statuses: [0, 200] },
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: ({ url }) =>
|
||||
/^\/api\/rooms\/[^/]+\/messages$/.test(url.pathname),
|
||||
handler: 'StaleWhileRevalidate',
|
||||
options: {
|
||||
cacheName: 'api-room-messages',
|
||||
expiration: READ_CACHE_EXPIRATION,
|
||||
cacheableResponse: { statuses: [0, 200] },
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: ({ url }) =>
|
||||
/^\/api\/rooms\/[^/]+\/members$/.test(url.pathname),
|
||||
handler: 'StaleWhileRevalidate',
|
||||
options: {
|
||||
cacheName: 'api-room-members',
|
||||
expiration: READ_CACHE_EXPIRATION,
|
||||
cacheableResponse: { statuses: [0, 200] },
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: ({ url }) => url.pathname === '/api/invites/mine',
|
||||
handler: 'StaleWhileRevalidate',
|
||||
options: {
|
||||
cacheName: 'api-invites-mine',
|
||||
expiration: READ_CACHE_EXPIRATION,
|
||||
cacheableResponse: { statuses: [0, 200] },
|
||||
},
|
||||
},
|
||||
// Defensive default: anything else under /api/ (including any
|
||||
// future GET endpoint) stays network-only until explicitly opted
|
||||
// in above.
|
||||
{
|
||||
urlPattern: ({ url }) => url.pathname.startsWith('/api/'),
|
||||
handler: 'NetworkOnly',
|
||||
},
|
||||
],
|
||||
@@ -49,4 +116,19 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
// `vite preview` doesn't inherit `server.proxy` -- needed to exercise the
|
||||
// real production service worker (only registered against a built
|
||||
// bundle, not `vite dev`) against the actual backend.
|
||||
preview: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://localhost:8000',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user