Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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.",
Expand Down
72 changes: 62 additions & 10 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
hasAction,
isSelectedAccountChangedMessage,
Expand All @@ -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';
Expand All @@ -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;
}
Expand Down Expand Up @@ -88,7 +91,8 @@ export function App({ initialDisplaySettings }: AppProps = {}) {
() => initialDisplaySettings ?? getInitialDisplaySettings(),
);
const [hasEnteredDashboard, setHasEnteredDashboard] = useState(false);
const [activeView, setActiveView] = useState<AppView>('dashboard');
const [route, setRoute] = useState<ChibiHubRoute>(() => readChibiHubRoute(window.location.href));
const routeOriginRef = useRef<'navigation' | 'popstate' | 'startup'>('startup');
const [qortalIdentity, setQortalIdentity] = useState<QortalIdentity | null>(null);
const [isIdentityLoading, setIsIdentityLoading] = useState(false);
const [identityRefreshKey, setIdentityRefreshKey] = useState(0);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -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 (
<main
className={`chibi-app ${introComplete ? 'intro-complete' : 'intro-running'} ${
hasEnteredDashboard && account?.isUnlocked && activeView === 'chat' ? 'chat-active' : ''
hasEnteredDashboard && account?.isUnlocked && route.view === 'chat' ? 'chat-active' : ''
}`}
data-accent={displaySettings.accent}
data-text-size={displaySettings.textSize}
Expand Down Expand Up @@ -360,20 +410,22 @@ export function App({ initialDisplaySettings }: AppProps = {}) {
</header>

{hasEnteredDashboard && account?.isUnlocked ? (
activeView === 'chat' ? (
route.view === 'chat' ? (
<ChatPage
account={account}
bridgeState={bridgeState}
onBackToDashboard={() => setActiveView('dashboard')}
onBackToDashboard={() => navigate({ view: 'dashboard' })}
onRefreshIdentity={() => setIdentityRefreshKey((current) => current + 1)}
onSelectedGroupChange={handleSelectedGroupChange}
qortalIdentity={qortalIdentity}
requestedGroupId={route.groupId}
/>
) : (
<Dashboard
account={account}
accountBlockStatus={accountBlockStatus}
bridgeState={bridgeState}
onOpenChat={() => setActiveView('chat')}
onOpenChat={() => navigate({ groupId: null, view: 'chat' })}
qortalIdentity={qortalIdentity}
qortalNodeContext={qortalNodeContext}
qortalNodeError={qortalNodeError}
Expand Down
36 changes: 30 additions & 6 deletions src/ChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<ChatGroupSummary[]>([]);
const [selectedGroupId, setSelectedGroupId] = useState<number | null>(null);
const [hasLoadedGroups, setHasLoadedGroups] = useState(false);
const [messages, setMessages] = useState<ChatMessageView[]>([]);
const [isLoadingGroups, setIsLoadingGroups] = useState(true);
const [isLoadingMessages, setIsLoadingMessages] = useState(false);
Expand Down Expand Up @@ -523,6 +529,7 @@ export function ChatPage({

if (!chatAvailable) {
setIsLoadingGroups(false);
setHasLoadedGroups(false);
setGroups([]);
setSelectedGroupId(null);
setMessages([]);
Expand All @@ -533,6 +540,7 @@ export function ChatPage({
}

setIsLoadingGroups(true);
setHasLoadedGroups(false);
setError('');

void loadActiveGroupChats(account, bridgeState)
Expand All @@ -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) {
Expand All @@ -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([]);
Expand Down Expand Up @@ -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');
}}
>
<strong>{group.groupName}</strong>
<span>{group.senderLabel}: {group.lastMessagePreview}</span>
Expand Down
70 changes: 70 additions & 0 deletions src/appRoute.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
53 changes: 53 additions & 0 deletions src/appRoute.ts
Original file line number Diff line number Diff line change
@@ -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;
}