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> {
|
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const response = await fetch(path, {
|
let response: Response
|
||||||
credentials: 'include',
|
try {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
response = await fetch(path, {
|
||||||
...init,
|
credentials: 'include',
|
||||||
})
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
...init,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
throw new NetworkError()
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
let detail = response.statusText
|
let detail = response.statusText
|
||||||
|
|||||||
@@ -70,3 +70,7 @@
|
|||||||
padding: var(--sp-2) var(--sp-4) 0;
|
padding: var(--sp-2) var(--sp-4) 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-pane-note {
|
||||||
|
color: var(--ds-muted);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { NetworkError } from '../api/client'
|
||||||
import { getRoomMessages } from '../api/rooms'
|
import { getRoomMessages } from '../api/rooms'
|
||||||
import { useChatSocket } from '../ws/useChatSocket'
|
import { useChatSocket } from '../ws/useChatSocket'
|
||||||
import type { ChatMessageEnvelope, Message, MyRoomItem, RoomMember, ServerEnvelope } from '../types'
|
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 [history, setHistory] = useState<Message[]>([])
|
||||||
const [live, setLive] = useState<ChatMessageEnvelope[]>([])
|
const [live, setLive] = useState<ChatMessageEnvelope[]>([])
|
||||||
const [wsError, setWsError] = useState<string | null>(null)
|
const [wsError, setWsError] = useState<string | null>(null)
|
||||||
|
const [historyUnavailableOffline, setHistoryUnavailableOffline] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setHistory([])
|
setHistory([])
|
||||||
setLive([])
|
setLive([])
|
||||||
setWsError(null)
|
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])
|
}, [room.id])
|
||||||
|
|
||||||
const onMessage = useCallback((envelope: ServerEnvelope) => {
|
const onMessage = useCallback((envelope: ServerEnvelope) => {
|
||||||
@@ -71,6 +82,11 @@ export function ChatPane({ room, members, isMobile, onBack, onToggleInfo, infoOp
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{wsError && <p className="chat-pane-error">{wsError}</p>}
|
{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} />
|
<MessageList messages={[...history, ...live]} members={members} />
|
||||||
<Composer roomName={room.name} disabled={!connected} onSend={send} />
|
<Composer roomName={room.name} disabled={!connected} onSend={send} />
|
||||||
|
|||||||
@@ -2,12 +2,18 @@
|
|||||||
padding: var(--sp-3) var(--sp-4);
|
padding: var(--sp-3) var(--sp-4);
|
||||||
border-top: 1px solid var(--ds-border);
|
border-top: 1px solid var(--ds-border);
|
||||||
background: var(--ds-surface);
|
background: var(--ds-surface);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-box {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.composer textarea {
|
.composer-box textarea {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
resize: none;
|
resize: none;
|
||||||
background: var(--ds-surface-2);
|
background: var(--ds-surface-2);
|
||||||
@@ -20,7 +26,7 @@
|
|||||||
max-height: 120px;
|
max-height: 120px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.composer textarea:focus {
|
.composer-box textarea:focus {
|
||||||
border-color: var(--ds-accent);
|
border-color: var(--ds-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,3 +48,9 @@
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
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 { useRef, useState, type KeyboardEvent } from 'react'
|
||||||
|
import { useOnlineStatus } from '../hooks/useOnlineStatus'
|
||||||
import './Composer.css'
|
import './Composer.css'
|
||||||
|
|
||||||
interface ComposerProps {
|
interface ComposerProps {
|
||||||
@@ -10,6 +11,7 @@ interface ComposerProps {
|
|||||||
export function Composer({ roomName, disabled, onSend }: ComposerProps) {
|
export function Composer({ roomName, disabled, onSend }: ComposerProps) {
|
||||||
const [value, setValue] = useState('')
|
const [value, setValue] = useState('')
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
const online = useOnlineStatus()
|
||||||
|
|
||||||
function autoGrow() {
|
function autoGrow() {
|
||||||
const el = textareaRef.current
|
const el = textareaRef.current
|
||||||
@@ -35,29 +37,34 @@ export function Composer({ roomName, disabled, onSend }: ComposerProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="composer">
|
<div className="composer">
|
||||||
<textarea
|
<div className="composer-box">
|
||||||
ref={textareaRef}
|
<textarea
|
||||||
rows={1}
|
ref={textareaRef}
|
||||||
value={value}
|
rows={1}
|
||||||
disabled={disabled}
|
value={value}
|
||||||
onChange={(e) => {
|
disabled={disabled}
|
||||||
setValue(e.target.value)
|
onChange={(e) => {
|
||||||
autoGrow()
|
setValue(e.target.value)
|
||||||
}}
|
autoGrow()
|
||||||
onKeyDown={handleKeyDown}
|
}}
|
||||||
placeholder={`Message #${roomName}`}
|
onKeyDown={handleKeyDown}
|
||||||
/>
|
placeholder={disabled ? (online ? 'Connecting…' : "You're offline") : `Message #${roomName}`}
|
||||||
<button
|
/>
|
||||||
type="button"
|
<button
|
||||||
className="composer-send"
|
type="button"
|
||||||
onClick={handleSend}
|
className="composer-send"
|
||||||
disabled={disabled || !value.trim()}
|
onClick={handleSend}
|
||||||
aria-label="Send message"
|
disabled={disabled || !value.trim()}
|
||||||
>
|
aria-label="Send message"
|
||||||
<svg width="15" height="15" viewBox="0 0 20 20" aria-hidden="true">
|
>
|
||||||
<polygon points="2,2 18,10 2,18 6,10" fill="currentColor" />
|
<svg width="15" height="15" viewBox="0 0 20 20" aria-hidden="true">
|
||||||
</svg>
|
<polygon points="2,2 18,10 2,18 6,10" fill="currentColor" />
|
||||||
</button>
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{disabled && (
|
||||||
|
<div className="composer-status">{online ? 'Connecting…' : "You're offline — messages can't be sent right now"}</div>
|
||||||
|
)}
|
||||||
</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);
|
color: var(--ds-muted);
|
||||||
text-transform: uppercase;
|
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
|
onOpenBrowse: () => void
|
||||||
onOpenInvites: () => void
|
onOpenInvites: () => void
|
||||||
inviteCount: number
|
inviteCount: number
|
||||||
|
unavailableOffline?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({
|
export function Sidebar({
|
||||||
@@ -22,6 +23,7 @@ export function Sidebar({
|
|||||||
onOpenBrowse,
|
onOpenBrowse,
|
||||||
onOpenInvites,
|
onOpenInvites,
|
||||||
inviteCount,
|
inviteCount,
|
||||||
|
unavailableOffline,
|
||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
const query = searchQuery.trim().toLowerCase()
|
const query = searchQuery.trim().toLowerCase()
|
||||||
const filtered = query ? rooms.filter((r) => r.name.toLowerCase().includes(query)) : rooms
|
const filtered = query ? rooms.filter((r) => r.name.toLowerCase().includes(query)) : rooms
|
||||||
@@ -67,17 +69,25 @@ export function Sidebar({
|
|||||||
Browse rooms
|
Browse rooms
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{filtered.length > 0 && <div className="sidebar-section-label">Rooms</div>}
|
{unavailableOffline ? (
|
||||||
<nav>
|
<p className="sidebar-offline-note">
|
||||||
{filtered.map((room, i) => (
|
Your rooms aren't available offline yet. Reconnect to load them.
|
||||||
<RoomRow
|
</p>
|
||||||
key={room.id}
|
) : (
|
||||||
room={room}
|
<>
|
||||||
colorIndex={i}
|
{filtered.length > 0 && <div className="sidebar-section-label">Rooms</div>}
|
||||||
active={room.id === activeRoomId}
|
<nav>
|
||||||
/>
|
{filtered.map((room, i) => (
|
||||||
))}
|
<RoomRow
|
||||||
</nav>
|
key={room.id}
|
||||||
|
room={room}
|
||||||
|
colorIndex={i}
|
||||||
|
active={room.id === activeRoomId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||||
import * as authApi from '../api/auth'
|
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'
|
import type { User } from '../types'
|
||||||
|
|
||||||
interface AuthContextValue {
|
interface AuthContextValue {
|
||||||
user: User | null
|
user: User | null
|
||||||
loading: boolean
|
loading: boolean
|
||||||
|
offline: boolean
|
||||||
login: (usernameOrEmail: string, password: string) => Promise<void>
|
login: (usernameOrEmail: string, password: string) => Promise<void>
|
||||||
logout: () => Promise<void>
|
logout: () => Promise<void>
|
||||||
}
|
}
|
||||||
@@ -15,30 +17,58 @@ const AuthContext = createContext<AuthContextValue | undefined>(undefined)
|
|||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [user, setUser] = useState<User | null>(null)
|
const [user, setUser] = useState<User | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [offline, setOffline] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
authApi
|
authApi
|
||||||
.me()
|
.me()
|
||||||
.then(setUser)
|
.then((u) => {
|
||||||
|
setUser(u)
|
||||||
|
setOffline(false)
|
||||||
|
saveLastUser(u)
|
||||||
|
})
|
||||||
.catch((err) => {
|
.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)
|
console.error('Failed to load current user', err)
|
||||||
}
|
}
|
||||||
|
const cached = loadLastUser()
|
||||||
|
setUser(cached)
|
||||||
|
setOffline(cached !== null)
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
async function login(usernameOrEmail: string, password: string) {
|
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() {
|
async function logout() {
|
||||||
await authApi.logout()
|
await authApi.logout()
|
||||||
setUser(null)
|
setUser(null)
|
||||||
|
clearLastUser()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, loading, login, logout }}>
|
<AuthContext.Provider value={{ user, loading, offline, login, logout }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</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 { useCallback, useEffect, useState } from 'react'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import { NetworkError } from '../api/client'
|
||||||
import { listMyInvites } from '../api/invites'
|
import { listMyInvites } from '../api/invites'
|
||||||
import { listMyRooms, listRoomMembers } from '../api/rooms'
|
import { listMyRooms, listRoomMembers } from '../api/rooms'
|
||||||
import { BrowseRoomsModal } from '../components/BrowseRoomsModal'
|
import { BrowseRoomsModal } from '../components/BrowseRoomsModal'
|
||||||
import { ChatPane } from '../components/ChatPane'
|
import { ChatPane } from '../components/ChatPane'
|
||||||
import { InvitesModal } from '../components/InvitesModal'
|
import { InvitesModal } from '../components/InvitesModal'
|
||||||
import { NewRoomModal } from '../components/NewRoomModal'
|
import { NewRoomModal } from '../components/NewRoomModal'
|
||||||
|
import { OfflineBanner } from '../components/OfflineBanner'
|
||||||
import { RoomInfoPanel } from '../components/RoomInfoPanel'
|
import { RoomInfoPanel } from '../components/RoomInfoPanel'
|
||||||
import { Sidebar } from '../components/Sidebar'
|
import { Sidebar } from '../components/Sidebar'
|
||||||
import { TopBar } from '../components/TopBar'
|
import { TopBar } from '../components/TopBar'
|
||||||
@@ -27,13 +29,23 @@ export function ChatShellPage() {
|
|||||||
const [inviteCount, setInviteCount] = useState(0)
|
const [inviteCount, setInviteCount] = useState(0)
|
||||||
const [infoOpen, setInfoOpen] = useState(false)
|
const [infoOpen, setInfoOpen] = useState(false)
|
||||||
const [modal, setModal] = useState<ModalKind>(null)
|
const [modal, setModal] = useState<ModalKind>(null)
|
||||||
|
const [roomsUnavailableOffline, setRoomsUnavailableOffline] = useState(false)
|
||||||
|
|
||||||
const activeRoom = rooms.find((r) => r.id === roomId)
|
const activeRoom = rooms.find((r) => r.id === roomId)
|
||||||
|
|
||||||
const refreshRooms = useCallback(async () => {
|
const refreshRooms = useCallback(async () => {
|
||||||
const list = await listMyRooms()
|
try {
|
||||||
setRooms(list)
|
const list = await listMyRooms()
|
||||||
return list
|
setRooms(list)
|
||||||
|
setRoomsUnavailableOffline(false)
|
||||||
|
return list
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof NetworkError) {
|
||||||
|
setRoomsUnavailableOffline(true)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const refreshMembers = useCallback(() => {
|
const refreshMembers = useCallback(() => {
|
||||||
@@ -62,6 +74,7 @@ export function ChatShellPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="chat-shell">
|
<div className="chat-shell">
|
||||||
<TopBar />
|
<TopBar />
|
||||||
|
<OfflineBanner />
|
||||||
<div className="chat-shell-body">
|
<div className="chat-shell-body">
|
||||||
{(!isMobile || !roomId) && (
|
{(!isMobile || !roomId) && (
|
||||||
<Sidebar
|
<Sidebar
|
||||||
@@ -73,6 +86,7 @@ export function ChatShellPage() {
|
|||||||
onOpenBrowse={() => setModal('browse')}
|
onOpenBrowse={() => setModal('browse')}
|
||||||
onOpenInvites={() => setModal('invites')}
|
onOpenInvites={() => setModal('invites')}
|
||||||
inviteCount={inviteCount}
|
inviteCount={inviteCount}
|
||||||
|
unavailableOffline={roomsUnavailableOffline && rooms.length === 0}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
+85
-3
@@ -2,6 +2,11 @@ import { defineConfig } from 'vite'
|
|||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import { VitePWA } from 'vite-plugin-pwa'
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
|
|
||||||
|
const READ_CACHE_EXPIRATION = {
|
||||||
|
maxEntries: 50,
|
||||||
|
maxAgeSeconds: 7 * 24 * 60 * 60, // 7 days
|
||||||
|
}
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
@@ -13,8 +18,8 @@ export default defineConfig({
|
|||||||
short_name: 'Talking',
|
short_name: 'Talking',
|
||||||
start_url: '/',
|
start_url: '/',
|
||||||
display: 'standalone',
|
display: 'standalone',
|
||||||
background_color: '#ffffff',
|
background_color: '#07080f', // --ds-void
|
||||||
theme_color: '#111111',
|
theme_color: '#101030', // --ds-surface
|
||||||
icons: [
|
icons: [
|
||||||
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
|
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||||
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
|
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
|
||||||
@@ -28,9 +33,71 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
workbox: {
|
workbox: {
|
||||||
navigateFallbackDenylist: [/^\/api/, /^\/ws/],
|
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: [
|
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',
|
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