diff --git a/README.md b/README.md index 90a02bf..3068944 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ preview account from `~/qortium/git/qortium-core/preview/`; override paths with ## Versioning -ChibiHub follows the Qortium app versioning standard (QAVS) at version `1.4.1`: +ChibiHub follows the Qortium app versioning standard (QAVS) at version `1.4.2`: `1.4` is the minimum Qortium platform level and the final number is ChibiHub's release counter. The build reads the version from `package.json`, displays it in the app header, and emits `dist/qortium-app.json` for Qortium Home. diff --git a/package-lock.json b/package-lock.json index 783f6f7..2e990b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "chibihub", - "version": "1.4.1", + "version": "1.4.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "chibihub", - "version": "1.4.1", + "version": "1.4.2", "license": "0BSD", "dependencies": { "@fontsource/comic-neue": "^5.2.7", diff --git a/package.json b/package.json index 4812e28..7c140db 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "chibihub", - "version": "1.4.1", + "version": "1.4.2", "private": true, "license": "0BSD", "description": "A QDN app for Qortium: ChibiHub.", diff --git a/src/App.tsx b/src/App.tsx index 84d6f28..ab0f891 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { hasAction, isSelectedAccountChangedMessage, @@ -20,6 +20,11 @@ import { } from './accountBlockStatus'; import { ChatPage } from './ChatPage'; import { Dashboard } from './Dashboard'; +import { + getChibiHubRouteUrl, + readChibiHubRoute, + type ChibiHubRoute, +} from './appRoute'; import { QubinoMascot } from './QubinoMascot'; import { getEnterIntent, shouldEnterDashboardAfterUnlock } from './entryFlow'; import qubinoTintLogo from './assets/qubino-bw.png'; @@ -39,8 +44,6 @@ const INTRO_DURATION_MS = 3400; let hasPlayedIntroThisSession = false; -type AppView = 'chat' | 'dashboard'; - function prefersReducedMotion() { return typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches; } @@ -88,7 +91,8 @@ export function App({ initialDisplaySettings }: AppProps = {}) { () => initialDisplaySettings ?? getInitialDisplaySettings(), ); const [hasEnteredDashboard, setHasEnteredDashboard] = useState(false); - const [activeView, setActiveView] = useState('dashboard'); + const [route, setRoute] = useState(() => readChibiHubRoute(window.location.href)); + const routeOriginRef = useRef<'navigation' | 'popstate' | 'startup'>('startup'); const [qortalIdentity, setQortalIdentity] = useState(null); const [isIdentityLoading, setIsIdentityLoading] = useState(false); const [identityRefreshKey, setIdentityRefreshKey] = useState(0); @@ -122,12 +126,10 @@ export function App({ initialDisplaySettings }: AppProps = {}) { if (!selectedAccount?.isUnlocked) { setHasEnteredDashboard(false); - setActiveView('dashboard'); } } catch (refreshError) { setAccount(null); setHasEnteredDashboard(false); - setActiveView('dashboard'); setError(refreshError instanceof Error ? refreshError.message : String(refreshError)); } finally { setIsAccountLoading(false); @@ -222,6 +224,22 @@ export function App({ initialDisplaySettings }: AppProps = {}) { void refreshAccount(); }, [refreshAccount]); + useEffect(() => { + const canonicalUrl = getChibiHubRouteUrl( + window.location.href, + readChibiHubRoute(window.location.href), + ); + window.history.replaceState({}, '', canonicalUrl); + + const handlePopState = () => { + routeOriginRef.current = 'popstate'; + setRoute(readChibiHubRoute(window.location.href)); + }; + + window.addEventListener('popstate', handlePopState); + return () => window.removeEventListener('popstate', handlePopState); + }, []); + useEffect(() => { applyDisplaySettings(displaySettings); }, [displaySettings]); @@ -329,10 +347,42 @@ export function App({ initialDisplaySettings }: AppProps = {}) { } } + const navigate = useCallback( + (nextRoute: ChibiHubRoute, intent: 'push' | 'replace' = 'push') => { + const nextUrl = getChibiHubRouteUrl(window.location.href, nextRoute); + const currentUrl = new URL(window.location.href); + + if ( + `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}` !== + `${currentUrl.pathname}${currentUrl.search}${currentUrl.hash}` + ) { + window.history[intent === 'replace' ? 'replaceState' : 'pushState']({}, '', nextUrl); + } + + routeOriginRef.current = 'navigation'; + setRoute(nextRoute); + }, + [], + ); + + const handleSelectedGroupChange = useCallback( + (groupId: number | null, intent: 'push' | 'replace') => { + // A popstate must only rehydrate React state. App-created entries already + // contain valid group IDs, so a fallback after an external/stale entry must + // not mutate the entry the user just returned to. + if (intent === 'replace' && routeOriginRef.current === 'popstate') { + return; + } + + navigate({ groupId, view: 'chat' }, intent); + }, + [navigate], + ); + return (
{hasEnteredDashboard && account?.isUnlocked ? ( - activeView === 'chat' ? ( + route.view === 'chat' ? ( setActiveView('dashboard')} + onBackToDashboard={() => navigate({ view: 'dashboard' })} onRefreshIdentity={() => setIdentityRefreshKey((current) => current + 1)} + onSelectedGroupChange={handleSelectedGroupChange} qortalIdentity={qortalIdentity} + requestedGroupId={route.groupId} /> ) : ( setActiveView('chat')} + onOpenChat={() => navigate({ groupId: null, view: 'chat' })} qortalIdentity={qortalIdentity} qortalNodeContext={qortalNodeContext} qortalNodeError={qortalNodeError} diff --git a/src/ChatPage.tsx b/src/ChatPage.tsx index aa3a9c1..9c4fc65 100644 --- a/src/ChatPage.tsx +++ b/src/ChatPage.tsx @@ -19,6 +19,7 @@ import { import { loadChatImage } from './chatImages'; import { canOpenQdnLinks, openQdnLink, splitTextByQdnLinks } from './qdnLinks'; import { QubinoMascot, type QubinoAction } from './QubinoMascot'; +import { resolveChatGroupId } from './appRoute'; import { getQortalIdentityDisplayName, type QortalIdentity } from './qortalIdentity'; import { canSendQortalGroupChat, @@ -350,16 +351,21 @@ export function ChatPage({ bridgeState, onBackToDashboard, onRefreshIdentity, + onSelectedGroupChange, qortalIdentity, + requestedGroupId, }: { account: QdnSelectedAccount; bridgeState: BridgeState | null; onBackToDashboard: () => void; onRefreshIdentity: () => void; + onSelectedGroupChange: (groupId: number | null, intent: 'push' | 'replace') => void; qortalIdentity: QortalIdentity | null; + requestedGroupId: number | null; }) { const [groups, setGroups] = useState([]); const [selectedGroupId, setSelectedGroupId] = useState(null); + const [hasLoadedGroups, setHasLoadedGroups] = useState(false); const [messages, setMessages] = useState([]); const [isLoadingGroups, setIsLoadingGroups] = useState(true); const [isLoadingMessages, setIsLoadingMessages] = useState(false); @@ -523,6 +529,7 @@ export function ChatPage({ if (!chatAvailable) { setIsLoadingGroups(false); + setHasLoadedGroups(false); setGroups([]); setSelectedGroupId(null); setMessages([]); @@ -533,6 +540,7 @@ export function ChatPage({ } setIsLoadingGroups(true); + setHasLoadedGroups(false); setError(''); void loadActiveGroupChats(account, bridgeState) @@ -542,11 +550,7 @@ export function ChatPage({ } setGroups(nextGroups); - setSelectedGroupId((current) => - current != null && nextGroups.some((group) => group.groupId === current) - ? current - : (nextGroups[0]?.groupId ?? null), - ); + setHasLoadedGroups(true); }) .catch((loadError) => { if (isActive) { @@ -564,6 +568,23 @@ export function ChatPage({ }; }, [account, bridgeState, chatAvailable, groupRefreshKey]); + useEffect(() => { + if (isLoadingGroups || !hasLoadedGroups) { + return; + } + + const resolvedGroupId = resolveChatGroupId( + requestedGroupId, + groups.map((group) => group.groupId), + ); + + setSelectedGroupId((current) => (current === resolvedGroupId ? current : resolvedGroupId)); + + if (resolvedGroupId !== requestedGroupId) { + onSelectedGroupChange(resolvedGroupId, 'replace'); + } + }, [groups, hasLoadedGroups, isLoadingGroups, onSelectedGroupChange, requestedGroupId]); + useEffect(() => { if (!chatAvailable || selectedGroupId == null) { setMessages([]); @@ -720,7 +741,10 @@ export function ChatPage({ className={group.groupId === selectedGroupId ? 'chat-group-item active' : 'chat-group-item'} key={group.groupId} type="button" - onClick={() => setSelectedGroupId(group.groupId)} + onClick={() => { + setSelectedGroupId(group.groupId); + onSelectedGroupChange(group.groupId, 'push'); + }} > {group.groupName} {group.senderLabel}: {group.lastMessagePreview} diff --git a/src/appRoute.test.ts b/src/appRoute.test.ts new file mode 100644 index 0000000..6f0c53c --- /dev/null +++ b/src/appRoute.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { getChibiHubRouteUrl, readChibiHubRoute, resolveChatGroupId } from './appRoute'; + +describe('ChibiHub route codec', () => { + it('reads dashboard, chat, and group routes', () => { + expect(readChibiHubRoute('https://example.test/render/APP/ChibiHub/ChibiHub')).toEqual({ + view: 'dashboard', + }); + expect(readChibiHubRoute('https://example.test/?view=chat')).toEqual({ + groupId: null, + view: 'chat', + }); + expect(readChibiHubRoute('https://example.test/?view=chat&group=42')).toEqual({ + groupId: 42, + view: 'chat', + }); + expect(readChibiHubRoute('https://example.test/?group=7')).toEqual({ + groupId: 7, + view: 'chat', + }); + }); + + it('rejects malformed group IDs and canonicalizes unknown app routes to dashboard', () => { + expect(readChibiHubRoute('https://example.test/?view=other&group=-2')).toEqual({ + view: 'dashboard', + }); + expect(readChibiHubRoute('https://example.test/?view=chat&group=3.5')).toEqual({ + groupId: null, + view: 'chat', + }); + }); + + it('preserves host display, bridge, fragment, and unknown parameters', () => { + const url = getChibiHubRouteUrl( + 'https://example.test/render/APP/ChibiHub/ChibiHub?theme=dark&qdnHomeBridge=token&lang=fr&textSize=large&accent=violet&uiStyle=fun&future=value&view=old&group=bad#message', + { groupId: 81, view: 'chat' }, + ); + + expect(`${url.pathname}${url.search}${url.hash}`).toBe( + '/render/APP/ChibiHub/ChibiHub?theme=dark&qdnHomeBridge=token&lang=fr&textSize=large&accent=violet&uiStyle=fun&future=value&view=chat&group=81#message', + ); + }); + + it('removes only app-owned parameters for the dashboard', () => { + const url = getChibiHubRouteUrl( + 'https://example.test/?view=chat&group=9&qdnHomeBridge=token&custom=kept', + { view: 'dashboard' }, + ); + + expect(url.search).toBe('?qdnHomeBridge=token&custom=kept'); + }); + + it('emits no account, draft, reply, status, dialog, filter, or error state', () => { + const url = getChibiHubRouteUrl('https://example.test/', { groupId: 9, view: 'chat' }); + + expect(url.search).toBe('?view=chat&group=9'); + }); +}); + +describe('chat group restoration', () => { + it('holds a valid requested group once the available groups arrive', () => { + expect(resolveChatGroupId(12, [3, 12, 40])).toBe(12); + }); + + it('falls back deterministically only after group data is available', () => { + expect(resolveChatGroupId(12, [])).toBeNull(); + expect(resolveChatGroupId(12, [3, 40])).toBe(3); + expect(resolveChatGroupId(null, [3, 40])).toBe(3); + }); +}); diff --git a/src/appRoute.ts b/src/appRoute.ts new file mode 100644 index 0000000..f5801fa --- /dev/null +++ b/src/appRoute.ts @@ -0,0 +1,53 @@ +export type ChibiHubRoute = + | { view: 'dashboard' } + | { groupId: number | null; view: 'chat' }; + +const APP_ROUTE_KEYS = ['group', 'view'] as const; + +function parseGroupId(value: string | null): number | null { + if (!value || !/^\d+$/.test(value)) { + return null; + } + + const groupId = Number(value); + return Number.isSafeInteger(groupId) && groupId > 0 ? groupId : null; +} + +export function readChibiHubRoute(input: string | URL): ChibiHubRoute { + const url = input instanceof URL ? input : new URL(input, 'http://localhost'); + const groupId = parseGroupId(url.searchParams.get('group')); + + if (url.searchParams.get('view') === 'chat' || groupId !== null) { + return { groupId, view: 'chat' }; + } + + return { view: 'dashboard' }; +} + +export function getChibiHubRouteUrl(input: string | URL, route: ChibiHubRoute): URL { + const url = input instanceof URL ? new URL(input.href) : new URL(input, 'http://localhost'); + + for (const key of APP_ROUTE_KEYS) { + url.searchParams.delete(key); + } + + if (route.view === 'chat') { + url.searchParams.set('view', 'chat'); + if (route.groupId !== null) { + url.searchParams.set('group', String(route.groupId)); + } + } + + return url; +} + +export function resolveChatGroupId( + requestedGroupId: number | null, + availableGroupIds: readonly number[], +): number | null { + if (requestedGroupId !== null && availableGroupIds.includes(requestedGroupId)) { + return requestedGroupId; + } + + return availableGroupIds[0] ?? null; +}