diff --git a/.forgejo/workflows/checkpr.yml b/.forgejo/workflows/checkpr.yml new file mode 100644 index 0000000..2f6dc9f --- /dev/null +++ b/.forgejo/workflows/checkpr.yml @@ -0,0 +1,34 @@ + +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + checkpr: + runs-on: fedora-41 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version-file: 'package.json' + + - name: Cache Dependencies + id: dependencies-cache + uses: actions/cache@v4 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }} + restore-keys: | + ${{ runner.os }}-bun- + + - name: Install Dependencies + run: bun install + + - name: Validate types + run: bun typecheck + + - name: Build Dist + run: VITE_CODE_VERSION=$GITHUB_SHA bun dist diff --git a/.forgejo/workflows/depoly.yml b/.forgejo/workflows/depoly.yml index edba093..0ad6d10 100644 --- a/.forgejo/workflows/depoly.yml +++ b/.forgejo/workflows/depoly.yml @@ -30,6 +30,9 @@ jobs: - name: Install Dependencies run: bun install + - name: Validate types + run: bun typecheck + - name: Build Dist (Staging) run: VITE_CODE_VERSION=$GITHUB_SHA bun dist -m staging if: env.GITHUB_REF_NAME == 'master' diff --git a/bun.lockb b/bun.lockb index 0645c92..bec25b1 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 219b2e0..6222ecd 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "dev": "vite --host 0.0.0.0", "preview": "vite preview", "dist": "vite build", - "count-source-lines": "exec scripts/src-lc.sh" + "count-source-lines": "exec scripts/src-lc.sh", + "typecheck": "tsc --noEmit --skipLibCheck" }, "keywords": [], "author": "Rubicon", @@ -41,6 +42,7 @@ "@solid-primitives/map": "^0.4.13", "@solid-primitives/page-visibility": "^2.0.17", "@solid-primitives/resize-observer": "^2.0.26", + "@solid-primitives/rootless": "^1.4.5", "@solidjs/router": "^0.15.1", "@suid/icons-material": "^0.8.1", "@suid/material": "^0.18.0", diff --git a/src/platform/MediaQuickview.css b/src/platform/MediaQuickview.css new file mode 100644 index 0000000..b1ceb07 --- /dev/null +++ b/src/platform/MediaQuickview.css @@ -0,0 +1,69 @@ +.MediaQuickview__root { + display: contents; +} + +.MediaQuickview { + border: none; + position: fixed; + width: 100vw; + width: 100dvw; + height: 100vh; + height: 100dvh; + max-width: 100vw; + max-height: 100vh; + contain: content; + padding: 0; + + &::backdrop { + background: none; + } + + >.Scaffold>.topbar { + position: fixed; + left: 0; + right: 0; + + >* { + background-color: var(--tutu-color-surface); + color: var(--tutu-color-on-surface); + } + } + + >.Scaffold>.pages { + display: grid; + grid-auto-flow: column; + grid-auto-columns: 100%; + height: 100%; + width: 100%; + overflow: auto hidden; + scroll-snap-type: x mandatory; + scroll-snap-align: center; + scroll-snap-stop: always; + + + >.page { + width: 100%; + height: 100%; + max-width: 100vw; + max-height: 100vh; + contain: layout style size paint; + + >* { + display: block; + width: 100%; + height: 100%; + + object-fit: contain; + object-position: center; + } + } + } + + &.lightout { + >.Scaffold { + >.topbar { + visibility: hidden; + } + } + } +} \ No newline at end of file diff --git a/src/platform/MediaQuickview.tsx b/src/platform/MediaQuickview.tsx new file mode 100644 index 0000000..2a9d7e1 --- /dev/null +++ b/src/platform/MediaQuickview.tsx @@ -0,0 +1,179 @@ +import { createCallback, createSubRoot } from "@solid-primitives/rootless"; +import { + createRenderEffect, + createSignal, + Index, + Match, + onCleanup, + onMount, + Switch, + type Component, +} from "solid-js"; +import { render } from "solid-js/web"; +import Scaffold from "~material/Scaffold"; +import { isPointNotInRect } from "./dom"; +import "./MediaQuickview.css"; +import AppTopBar from "~material/AppTopBar"; +import { IconButton } from "@suid/material"; +import { Close } from "@suid/icons-material"; + +function renderIsolateMediaQuickview( + each: QuickviewMedia[], + index: number, + transitionFrom?: Element, +) { + createSubRoot((disposeAll) => { + let container: HTMLDivElement; + + createRenderEffect(() => { + container = document.createElement("div"); + container.setAttribute("role", "presentation"); + container.classList.add("MediaQuickview__root"); + document.querySelector("body")!.appendChild(container); + + onCleanup(() => container.remove()); + + const dispose = render(() => { + return ( + + ); + }, container); + + onCleanup(dispose); + }); + }); +} + +export function createMediaQuickview() { + return createCallback(renderIsolateMediaQuickview); +} + +function ImagePage(props: { src: string; alt?: string }) { + const [scale, setScale] = createSignal(1); + const [offsetX, setOffsetX] = createSignal(0); + const [offsetY, setOffsetY] = createSignal(0); + + const onWheel = (event: WheelEvent & { currentTarget: HTMLElement }) => { + // This is a de-facto standard for scaling: + // Browsers will simulate ctrl + wheel for two point scaling gesture. + if (event.ctrlKey) { + event.preventDefault(); + event.stopPropagation(); + + const offset = event.deltaY; + + setScale((x) => x + offset / event.currentTarget.clientHeight); + } + }; + + return ( + { + const { top, left, width, height } = + currentTarget.getBoundingClientRect(); + }} + > + ); +} + +export type QuickviewMedia = { + cat: "image" | "video" | "gifv" | "audio" | "unknown"; + src: string; + alt?: string; +}; + +export type MediaQuickviewProps = { + each: QuickviewMedia[]; + defaultIndex: number; + transitonFrom?: Element; + + onClose?(): void; +}; + +const MediaQuickview: Component = (props) => { + let root: HTMLDialogElement; + const [lightOut, setLightOut] = createSignal(false); + + function onDialogClick( + event: MouseEvent & { currentTarget: HTMLDialogElement }, + ) { + event.stopPropagation(); + + if ( + isPointNotInRect( + event.currentTarget.getBoundingClientRect(), + event.clientX, + event.clientY, + ) + ) { + event.currentTarget.close(); + } else { + setLightOut((x) => !x); + } + } + + return ( + { + root = e; + onMount(() => { + e.showModal(); + }); + }} + class="MediaQuickview" + classList={{ lightout: lightOut() }} + onClose={props.onClose} + onCancel={props.onClose} + onClick={onDialogClick} + > + + root.close()}> + + + + } + > + { + onMount(() => { + e.children.item(props.defaultIndex)!.scrollIntoView({ + behavior: "instant", + inline: "center", + }); + }); + }} + class="pages" + > + + {(item, index) => { + return ( + + + + + + + + ); + }} + + + + + ); +}; + +export default MediaQuickview; diff --git a/src/platform/polyfills.ts b/src/platform/polyfills.ts index 032207a..1af3341 100644 --- a/src/platform/polyfills.ts +++ b/src/platform/polyfills.ts @@ -15,3 +15,24 @@ if (typeof window.crypto.randomUUID === "undefined") { ) as `${string}-${string}-${string}-${string}-${string}`; }; } + + +if (typeof Promise.withResolvers === "undefined") { + // Chrome/Edge 119, Firefox 121, Safari/iOS 17.4 + + // Promise.withResolvers is generic and works with subclasses - the typescript built-in decl + // could not handle the subclassing case. + Promise.withResolvers = function (this: AnyPromiseConstructor) { + let resolve!: PromiseWithResolvers["resolve"], reject!: PromiseWithResolvers["reject"]; + // These variables are expected to be set after `new this()` + + const promise = new this((resolve0, reject0) => { + resolve = resolve0; + reject = reject0; + }) + + return { + promise, resolve, reject + } + } +} diff --git a/src/serviceworker/main.ts b/src/serviceworker/main.ts index 71a4f37..99305a9 100644 --- a/src/serviceworker/main.ts +++ b/src/serviceworker/main.ts @@ -4,23 +4,27 @@ import { dispatchCall, isJSONRPCCall, type Call } from "./workerrpc"; function isServiceWorker( self: WorkerGlobalScope, + // @ts-ignore: workaround for workbox logger.d.ts decl ): self is ServiceWorkerGlobalScope { - return !!(self as unknown as ServiceWorkerGlobalScope).registration; + return ( + (self as unknown as Record)["serviceWorker"] instanceof + ServiceWorker + ); } -if (isServiceWorker(self)) { - cleanupOutdatedCaches(); - precacheAndRoute(self.__WB_MANIFEST, { - cleanURLs: false, - }); - - // auto update - self.skipWaiting(); - clientsClaim(); -} else { +if (!isServiceWorker(self)) { throw new TypeError("This entry point must be run in a service worker"); } +cleanupOutdatedCaches(); +precacheAndRoute(self.__WB_MANIFEST, { + cleanURLs: false, +}); + +// auto update +self.skipWaiting(); +clientsClaim(); + export const Service = { ping() {}, }; @@ -29,6 +33,9 @@ self.addEventListener("message", (event: MessageEvent) => { const payload = event.data; if (typeof payload !== "object") return; if (isJSONRPCCall(payload as Record)) { - dispatchCall(Service, event as MessageEvent>); + dispatchCall( + Service, + event as MessageEvent> & { source: MessageEventSource }, + ); } }); diff --git a/src/serviceworker/tsconfig.json b/src/serviceworker/tsconfig.json index bc88f57..090edc2 100644 --- a/src/serviceworker/tsconfig.json +++ b/src/serviceworker/tsconfig.json @@ -1,5 +1,7 @@ { "compilerOptions": { - "lib": ["ESNext", "WebWorker"], + "lib": ["WebWorker", "ESNext"], }, -} \ No newline at end of file + "extends": ["../../tsconfig.super.json"], + "include": ["./**/*.ts"], +} diff --git a/src/serviceworker/workerrpc.ts b/src/serviceworker/workerrpc.ts index 84bb39f..7680716 100644 --- a/src/serviceworker/workerrpc.ts +++ b/src/serviceworker/workerrpc.ts @@ -46,7 +46,7 @@ export type Result = JSONRPC & { id: string | number } & ( export function isJSONRPCResult( object: Record, ): object is Result { - return object["jsonrpc"] === "2.0" && object["id"] && !object["method"]; + return !!(object["jsonrpc"] === "2.0" && object["id"] && !object["method"]); } export function isJSONRPCCall( @@ -114,6 +114,9 @@ export class ResultDispatcher { { const callback = this.map.get(id); if (!callback) return; + // Now the callback is not undefined + + // Fast path if (typeof callback !== "boolean") { callback(message); this.map.delete(id); @@ -121,28 +124,30 @@ export class ResultDispatcher { } } - return new Promise((resolve) => { - let retried = 0; + const { promise, resolve } = Promise.withResolvers(); - const checkAndDispatch = () => { - const callback = this.map.get(id); - if (typeof callback !== "boolean") { - callback(message); - this.map.delete(id); - resolve(); - return; - } - setTimeout(checkAndDispatch, 0); - if (++retried > 3) { - console.warn( - `retried ${retried} time(s) but the callback is still disappeared, id is "${id}"`, - ); - } - }; + let retried = 0; - // start the loop - checkAndDispatch(); - }); + const checkAndDispatch = () => { + const callback = this.map.get(id); + if (typeof callback !== "boolean") { + callback!(message); // the nullability is already checked before + this.map.delete(id); + resolve(undefined); + return; + } + setTimeout(checkAndDispatch, 0); + if (++retried > 3) { + console.warn( + `retried ${retried} time(s) but the callback is still disappeared, id is "${id}"`, + ); + } + }; + + // start the loop + checkAndDispatch(); + + return promise; } createTypedCall< @@ -159,14 +164,16 @@ export class ResultDispatcher { } } -type AnyService = Record unknown) | undefined> +type AnyService = Record unknown>; -export async function dispatchCall< - S extends AnyService, ->(service: S, event: MessageEvent>) { +export async function dispatchCall>( + service: S, + event: MessageEvent> & { source: MessageEventSource }, +) { try { const fn = service[event.data.method]; if (!fn) { + console.warn("requested unknown method", event.data.method, event.data); if (event.data.id) return event.source.postMessage({ jsonrpc: "2.0", @@ -176,10 +183,12 @@ export async function dispatchCall< message: "Method not found", }, } as Result); + + return; } try { - const result = await fn(...event.data.params as unknown[]); + const result = await fn(...(event.data.params as unknown[])); if (!event.data.id) return; diff --git a/src/timelines/toots/MediaAttachmentGrid.tsx b/src/timelines/toots/MediaAttachmentGrid.tsx index c9d72fc..6291a86 100644 --- a/src/timelines/toots/MediaAttachmentGrid.tsx +++ b/src/timelines/toots/MediaAttachmentGrid.tsx @@ -5,13 +5,9 @@ import { Match, Switch, createMemo, - createRenderEffect, createSignal, - onCleanup, untrack, } from "solid-js"; -import MediaViewer from "../MediaViewer"; -import { render } from "solid-js/web"; import { createElementSize, useWindowSize, @@ -24,6 +20,7 @@ import cardStyle from "~material/cards.module.css"; import { Preview } from "@suid/icons-material"; import { IconButton } from "@suid/material"; import Masonry from "~platform/Masonry"; +import { createMediaQuickview } from "~platform/MediaQuickview"; type ElementSize = { width: number; height: number }; @@ -58,39 +55,23 @@ const MediaAttachmentGrid: Component<{ sensitive?: boolean; }> = (props) => { const [rootRef, setRootRef] = createSignal(); - const [viewerIndex, setViewerIndex] = createSignal(); - const viewerOpened = () => typeof viewerIndex() !== "undefined"; const settings = useStore($settings); const windowSize = useWindowSize(); const [reveal, setReveal] = createSignal([] as number[]); - createRenderEffect(() => { - const vidx = viewerIndex(); - if (typeof vidx === "undefined") return; - const container = document.createElement("div"); - container.setAttribute("role", "presentation"); - document.body.appendChild(container); - const dispose = render(() => { - onCleanup(() => { - document.body.removeChild(container); - }); - - return ( - setViewerIndex()} - /> - ); - }, container); - - onCleanup(dispose); - }); + const openMediaQuickview = createMediaQuickview(); const openViewerFor = (index: number) => { - setViewerIndex(index); + openMediaQuickview( + props.attachments.map((item) => { + return { + cat: item.type, + src: item.url as string, + alt: item.description || undefined, + }; + }), + index, + ); }; const columnCount = () => { diff --git a/tsconfig.json b/tsconfig.json index b92e528..feaab58 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,20 +1,11 @@ { "compilerOptions": { - "strict": true, - "target": "ESNext", - "module": "esnext", - "moduleResolution": "node", - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, "jsx": "preserve", "jsxImportSource": "solid-js", "types": ["vite/client", "vite-plugin-pwa/solid"], - "noEmit": true, - "isolatedModules": true, - "resolveJsonModule": true, - "paths": { - "~platform/*": ["./src/platform/*"], - "~material/*": ["./src/material/*"] - } - } + "lib": ["ESNext", "DOM", "DOM.Iterable"] + }, + "include": ["./src/**/*.ts", "./src/**/*.tsx", "./*.ts", "./types/**.ts"], + "exclude": ["./src/serviceworker/**"], + "extends": ["./tsconfig.super.json"] } diff --git a/tsconfig.super.json b/tsconfig.super.json new file mode 100644 index 0000000..9cf8e60 --- /dev/null +++ b/tsconfig.super.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "strict": true, + "target": "ESNext", + "module": "esnext", + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "noEmit": true, + "isolatedModules": true, + "resolveJsonModule": true, + "paths": { + "~platform/*": ["./src/platform/*"], + "~material/*": ["./src/material/*"] + } + } +} diff --git a/types/lib.esnext.promise.d.ts b/types/lib.esnext.promise.d.ts new file mode 100644 index 0000000..5df9b56 --- /dev/null +++ b/types/lib.esnext.promise.d.ts @@ -0,0 +1,26 @@ +interface AnyPromiseWithResolvers { + promise: Instance; + resolve: (value: T | PromiseLike) => void; + reject: (reason?: any) => void; +} + +type AnyPromiseConstructor = new ( + executor: ( + resolve: PromiseWithResolvers["resolve"], + reject: PromiseWithResolvers["reject"], + ) => void, +) => Promise; + +interface PromiseConstructor { + /** + * Creates a new Promise and returns it in an object, along with its resolve and reject functions. + * @returns An object with the properties `promise`, `resolve`, and `reject`. + * + * ```ts + * const { promise, resolve, reject } = Promise.withResolvers(); + * ``` + */ + withResolvers>( + this: K, + ): AnyPromiseWithResolvers>; +}