diff --git a/compose/index.html b/compose/index.html index 1c7515fb..2d136fc7 100644 --- a/compose/index.html +++ b/compose/index.html @@ -11,6 +11,6 @@
- + diff --git a/index.html b/index.html index 39c9843e..665498c4 100644 --- a/index.html +++ b/index.html @@ -91,6 +91,6 @@ } }, 5000); - + diff --git a/src/components/name-text.jsx b/src/components/name-text.tsx similarity index 81% rename from src/components/name-text.jsx rename to src/components/name-text.tsx index b7bfcfae..0742b99d 100644 --- a/src/components/name-text.jsx +++ b/src/components/name-text.tsx @@ -1,6 +1,7 @@ import './name-text.css'; import { useLingui } from '@lingui/react'; +import type { JSX } from 'preact'; import { api } from '../utils/api'; import mem from '../utils/mem'; @@ -10,8 +11,42 @@ import Avatar from './avatar'; import EmojiText from './emoji-text'; import RolesTags from './roles-tags'; -const nameCollator = mem((locale) => { - const options = { +interface NameTextEmoji { + shortcode: string; + url: string; + staticUrl?: string; +} + +interface NameTextRole { + name?: string; +} + +interface NameTextAccount { + acct: string; + avatar?: string; + avatarStatic?: string; + id: string; + url: string; + displayName?: string; + emojis?: readonly NameTextEmoji[]; + bot?: boolean; + username: string; + roles?: NameTextRole[] | null; + [key: string]: unknown; +} + +interface NameTextProps { + account: NameTextAccount | null | undefined; + instance?: string; + showAvatar?: boolean; + showAcct?: boolean; + short?: boolean; + external?: boolean; + onClick?: (e: JSX.TargetedMouseEvent) => unknown; +} + +const nameCollator = mem((locale: string | undefined) => { + const options: Intl.CollatorOptions = { sensitivity: 'base', }; try { @@ -34,7 +69,7 @@ function NameText({ short, external, onClick, -}) { +}: NameTextProps) { const { i18n } = useLingui(); if (!account) return null; const { @@ -78,7 +113,7 @@ function NameText({ void; +} + initActivateLang(); initPWAViewport(); -if (window.opener) { - console = window.opener.console; +const opener = (window as Window & { opener?: Window | null }).opener; +if (opener) { + // The compose popup proxies its console through the parent window so + // logs surface in the opener's devtools. + (globalThis as { console: Console }).console = ( + opener as Window & { console: Console } + ).console; } function App() { const { t } = useLingui(); const [uiState, setUIState] = useState('default'); - const [isLoggedIn, setIsLoggedIn] = useState(null); + const [isLoggedIn, setIsLoggedIn] = useState(null); const { editStatus, replyToStatus, replyMode, draftStatus, quoteStatus } = - window.__COMPOSE__ || {}; + (window as Window & { __COMPOSE__?: ComposePayload }).__COMPOSE__ || {}; useTitle( editStatus @@ -41,6 +62,7 @@ function App() { replyToStatus.account?.acct || replyToStatus.account?.username }` : t`Compose`, + '', ); useEffect(() => { @@ -55,7 +77,7 @@ function App() { if (uiState === 'closed') { try { // Focus parent window - window.opener.focus(); + (window as Window & { opener?: Window | null }).opener?.focus(); } catch (e) {} window.close(); } @@ -109,12 +131,16 @@ function App() { draftStatus={draftStatus} quoteStatus={quoteStatus} standalone - hasOpener={window.opener} - onClose={(results) => { + hasOpener={(window as Window & { opener?: Window | null }).opener} + onClose={(results: ComposeCloseResults | null | undefined) => { const { newStatus, fn = () => {} } = results || {}; try { if (newStatus) { - window.opener.__STATES__.reloadStatusPage++; + const openerWin = (window as Window & { opener?: Window | null }) + .opener as Window & { + __STATES__: { reloadStatusPage: number }; + }; + openerWin.__STATES__.reloadStatusPage++; } fn(); setUIState('closed'); @@ -137,5 +163,7 @@ render( , - document.getElementById('app-standalone'), + // Preserve original JS behavior of failing loudly via `render(...)` if the + // template's root element is ever missing rather than silently skipping. + document.getElementById('app-standalone') as HTMLElement, ); diff --git a/src/main.jsx b/src/main.tsx similarity index 67% rename from src/main.jsx rename to src/main.tsx index 06a68a8c..10758126 100644 --- a/src/main.jsx +++ b/src/main.tsx @@ -12,6 +12,7 @@ import * as Sentry from '@sentry/react'; // https://bugzilla.mozilla.org/show_bug.cgi?id=1423593 // import '@formatjs/intl-segmenter/polyfill'; import { render } from 'preact'; +import type { ComponentType, VNode } from 'preact'; import { HashRouter } from 'react-router-dom'; import { App } from './app'; @@ -24,6 +25,18 @@ import { import { initPWAViewport } from './utils/pwa-viewport'; import states from './utils/states'; +// Vite aliases `react` to `preact/compat` at bundle time, so Sentry's +// `ErrorBoundary` works at runtime with preact children. The shipped Sentry +// types extend `React.Component`, and preact's JSX type system does not +// accept React class components directly. Per the CLAUDE.md migration rules, +// an `as unknown as ` shim around an untyped/non-preact peer is +// acceptable; a later batch (or a real react→preact/compat ambient module) +// can replace this with a structurally typed boundary. +const SentryErrorBoundary = Sentry.ErrorBoundary as unknown as ComponentType<{ + fallback?: VNode; + children?: unknown; +}>; + if (!redirectLegacyOrigin()) { importLegacyOriginStorage().finally(() => { initActivateLang(); @@ -50,13 +63,16 @@ if (!redirectLegacyOrigin()) { - Something went wrong.

}> + Something went wrong.

}> -
+
, - document.getElementById('app'), + // The HTML template guarantees this element. Preserve the original JS + // behavior of failing loudly through `render(...)` if it is ever missing + // rather than silently skipping mount. + document.getElementById('app') as HTMLElement, ); // Storage cleanup @@ -75,7 +91,7 @@ if (!redirectLegacyOrigin()) { const FAST_INTERVAL = 10_000; // 10 seconds const SLOW_INTERVAL = 60 * 60 * 1000; // 1 hour async function clearCaches() { - if (window.__IDLE__) { + if ((window as Window & { __IDLE__?: boolean }).__IDLE__) { try { const keys = await caches.keys(); for (const key of keys) { @@ -100,10 +116,22 @@ if (!redirectLegacyOrigin()) { } if ('serviceWorker' in navigator) { - function processShareData(data) { + interface ShareData { + title?: string; + text?: string; + url?: string; + files?: readonly File[]; + } + interface SharedDataPayload { + initialText: string; + files: readonly File[]; + } + function processShareData( + data: ShareData | null | undefined, + ): SharedDataPayload | null { if (!data) return null; - const textParts = []; + const textParts: string[] = []; if (data.title) textParts.push(data.title); if (data.text) textParts.push(data.text); if (data.url) textParts.push(data.url); @@ -115,19 +143,22 @@ if (!redirectLegacyOrigin()) { } navigator.serviceWorker.addEventListener('message', (event) => { - const { data, action } = event.data || {}; + const { data, action } = + (event.data as { data?: ShareData; action?: string } | undefined) || + {}; if (action === 'compose-with-shared-data') { console.log('💪 Received shared data from SW', data); const sharedData = processShareData(data); if (sharedData) { - window.__SHARED_DATA__ = sharedData; + (window as Window & { __SHARED_DATA__?: SharedDataPayload }).__SHARED_DATA__ = + sharedData; states.showCompose = true; // It'll use __SHARED_DATA__ } } }); } - window.__CLOAK__ = () => { + (window as Window & { __CLOAK__?: () => void }).__CLOAK__ = () => { document.body.classList.toggle('cloak'); }; }); diff --git a/src/pages/mock-home.jsx b/src/pages/mock-home.tsx similarity index 66% rename from src/pages/mock-home.jsx rename to src/pages/mock-home.tsx index 2d927786..89accc05 100644 --- a/src/pages/mock-home.jsx +++ b/src/pages/mock-home.tsx @@ -1,3 +1,4 @@ +import type { ComponentType } from 'preact'; import { useEffect, useMemo } from 'preact/hooks'; import ComposeButton from '../components/compose-button'; @@ -5,24 +6,54 @@ import Icon from '../components/icon'; import Link from '../components/link'; import NavMenu from '../components/nav-menu'; import Shortcuts from '../components/shortcuts'; -import Status from '../components/status'; +import StatusComponent from '../components/status'; import mockPostsData from '../data/mock-posts.json'; import states from '../utils/states'; import useTitle from '../utils/useTitle'; +// Shape of the mocked status passed into `` from this page. Mirrors +// what `toCamelCase(mockPostsData[n])` produces plus the two locally +// assigned fields (`_instance`, `createdAt`). +interface MockStatus { + id: string; + _instance: string; + createdAt: string; + [key: string]: unknown; +} + +interface MockStatusProps { + status: MockStatus; + instance: string; + allowFilters: boolean; +} + +// `Status` (still .jsx) declares all destructured props as required from +// TS's perspective. Shim with the local prop shape this page actually +// passes; a later batch that converts `status.jsx` removes this cast. +const Status = StatusComponent as unknown as ComponentType; + +type JsonLike = + | string + | number + | boolean + | null + | undefined + | JsonLike[] + | { [key: string]: JsonLike }; + // Helper function to convert snake_case keys to camelCase recursively // This mimics the behavior of masto.js which uses change-case library // to transform API responses from snake_case to camelCase -function toCamelCase(obj) { +function toCamelCase(obj: JsonLike): JsonLike { if (!obj || typeof obj !== 'object') return obj; if (Array.isArray(obj)) return obj.map(toCamelCase); - return Object.keys(obj).reduce((acc, key) => { + return Object.keys(obj).reduce>((acc, key) => { // Convert snake_case to camelCase: user_name -> userName - const camelKey = key.replace(/_([a-z])/g, (_, letter) => + const camelKey = key.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase(), ); - acc[camelKey] = toCamelCase(obj[key]); + acc[camelKey] = toCamelCase((obj as Record)[key]); return acc; }, {}); } @@ -51,15 +82,19 @@ function MockHome() { }; }, []); - const statuses = useMemo(() => { + const statuses = useMemo(() => { const now = new Date(); return mockPostsData.map((status, index) => { const accountURL = new URL(status.account.url); const instance = accountURL.hostname; - // Convert all snake_case keys to camelCase - const transformedStatus = toCamelCase(status); + // Convert all snake_case keys to camelCase. The JSON fixture has + // `id` and the snake→camel mapping is identity for it, so we narrow + // the result back to MockStatus here without a second cast below. + const transformedStatus = toCamelCase( + status as JsonLike, + ) as MockStatus; transformedStatus._instance = instance; // Mock createdAt dates: now, then 15 minutes ago, 30 minutes ago, etc. @@ -73,7 +108,7 @@ function MockHome() { return ( <> -
+