diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 8e42a32..ea23d61 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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(path: string, init?: RequestInit): Promise { - const response = await fetch(path, { - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - ...init, - }) + 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 diff --git a/frontend/src/components/ChatPane.css b/frontend/src/components/ChatPane.css index 49546fe..26e1a67 100644 --- a/frontend/src/components/ChatPane.css +++ b/frontend/src/components/ChatPane.css @@ -70,3 +70,7 @@ padding: var(--sp-2) var(--sp-4) 0; margin: 0; } + +.chat-pane-note { + color: var(--ds-muted); +} diff --git a/frontend/src/components/ChatPane.tsx b/frontend/src/components/ChatPane.tsx index 6de84a3..a206ddf 100644 --- a/frontend/src/components/ChatPane.tsx +++ b/frontend/src/components/ChatPane.tsx @@ -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([]) const [live, setLive] = useState([]) const [wsError, setWsError] = useState(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 {wsError &&

{wsError}

} + {historyUnavailableOffline && ( +

+ Message history for this room isn't available offline yet. +

+ )} diff --git a/frontend/src/components/Composer.css b/frontend/src/components/Composer.css index 8bfee81..58435c2 100644 --- a/frontend/src/components/Composer.css +++ b/frontend/src/components/Composer.css @@ -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; +} diff --git a/frontend/src/components/Composer.tsx b/frontend/src/components/Composer.tsx index 6eb948d..248d97a 100644 --- a/frontend/src/components/Composer.tsx +++ b/frontend/src/components/Composer.tsx @@ -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(null) + const online = useOnlineStatus() function autoGrow() { const el = textareaRef.current @@ -35,29 +37,34 @@ export function Composer({ roomName, disabled, onSend }: ComposerProps) { return (
-