diff --git a/bun.lockb b/bun.lockb index be11679..337f675 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/index.html b/index.html index 2712433..b97ae88 100644 --- a/index.html +++ b/index.html @@ -7,6 +7,7 @@ Tutu + diff --git a/package.json b/package.json index 54e32ca..32a9d10 100644 --- a/package.json +++ b/package.json @@ -16,22 +16,22 @@ "devDependencies": { "@suid/vite-plugin": "^0.2.0", "@types/hammerjs": "^2.0.45", - "postcss": "^8.4.39", - "prettier": "^3.3.2", - "typescript": "^5.5.2", - "vite": "^5.3.2", + "postcss": "^8.4.41", + "prettier": "^3.3.3", + "typescript": "^5.5.4", + "vite": "^5.4.0", "vite-plugin-package-version": "^1.1.0", - "vite-plugin-pwa": "^0.20.0", + "vite-plugin-pwa": "^0.20.1", "vite-plugin-solid": "^2.10.2", "vite-plugin-solid-styled": "^0.11.1", - "wrangler": "^3.64.0" + "wrangler": "^3.70.0" }, "dependencies": { "@nanostores/persistent": "^0.9.1", "@nanostores/solid": "^0.4.2", "@solid-primitives/event-listener": "^2.3.3", "@solid-primitives/intersection-observer": "^2.1.6", - "@solid-primitives/resize-observer": "^2.0.25", + "@solid-primitives/resize-observer": "^2.0.26", "@solidjs/router": "^0.11.5", "@suid/icons-material": "^0.7.0", "@suid/material": "^0.16.0", @@ -40,9 +40,10 @@ "hammerjs": "^2.0.8", "masto": "^6.8.0", "nanostores": "^0.9.5", - "solid-js": "^1.8.18", + "solid-js": "^1.8.20", "solid-styled": "^0.11.1", - "stacktrace-js": "^2.0.2" + "stacktrace-js": "^2.0.2", + "web-animations-js": "^2.3.2" }, "packageManager": "bun@1.1.21" } diff --git a/src/App.tsx b/src/App.tsx index 1522795..c3573a5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -22,6 +22,7 @@ const AccountMastodonOAuth2Callback = lazy( ); const TimelineHome = lazy(() => import("./timelines/Home.js")); const Settings = lazy(() => import("./settings/Settings.js")); +const TootBottomSheet = lazy(() => import("./timelines/TootBottomSheet.js")); const Routing: Component = () => { return ( @@ -29,6 +30,7 @@ const Routing: Component = () => { + @@ -53,7 +55,7 @@ const App: Component = () => { ); }); -const UnexpectedError = lazy(() => import("./UnexpectedError.js")) + const UnexpectedError = lazy(() => import("./UnexpectedError.js")); return ( ) { - return createResource( - client, - (client) => { - return client.v1.accounts.verifyCredentials(); - }, - { - name: "MastodonAccountProfile", - }, - ); -} - export function useSignedInProfiles() { const sessions = useSessions(); const [accessor, tools] = createResource(sessions, async (all) => { @@ -24,11 +12,11 @@ export function useSignedInProfiles() { }); return [ () => { - if (accessor.loading) { - accessor(); + const value = accessor(); + if (!value) { return sessions().map((x) => ({ ...x, inf: x.account.inf })); } - return accessor(); + return value; }, tools, ] as const; diff --git a/src/masto/timelines.ts b/src/masto/timelines.ts index 55d1276..917ecf7 100644 --- a/src/masto/timelines.ts +++ b/src/masto/timelines.ts @@ -14,55 +14,49 @@ type Timeline = { }; export function useTimeline(timeline: Accessor) { - let minId: string | undefined; - let maxId: string | undefined; let otl: Timeline | undefined; - const idSet = new Set(); + let npager: mastodon.Paginator | undefined; + let opager: mastodon.Paginator | undefined; const [snapshot, { refetch }] = createResource< - { records: mastodon.v1.Status[]; direction: "old" | "new" }, + { + records: mastodon.v1.Status[]; + direction: "new" | "old"; + tlChanged: boolean; + }, [Timeline], TimelineFetchTips | undefined >( () => [timeline()] as const, async ([tl], info) => { + let tlChanged = false; if (otl !== tl) { - minId = undefined; - maxId = undefined; - idSet.clear(); + console.debug("timeline reset"); + npager = opager = undefined; otl = tl; + tlChanged = true; } const direction = typeof info.refetching !== "boolean" - ? info.refetching?.direction + ? (info.refetching?.direction ?? "old") : "old"; - const pager = await tl.list( - direction === "old" - ? { - maxId: minId, - } - : { - minId: maxId, - }, - ); - const diff = pager.filter((x) => !idSet.has(x.id)); - for (const v of diff.map((x) => x.id)) { - idSet.add(v); - } if (direction === "old") { - minId = pager[pager.length - 1]?.id; - if (!maxId && pager.length > 0) { - maxId = pager[0].id; + if (!opager) { + opager = tl.list({}).setDirection("next"); } + const next = await opager.next(); return { - direction: "old" as const, - records: diff, + direction, + records: next.value ?? [], + end: next.done, + tlChanged, }; } else { - maxId = pager.length > 0 ? pager[0].id : undefined; - if (!minId && pager.length > 0) { - minId = pager[pager.length - 1]?.id; + if (!npager) { + npager = tl.list({}).setDirection("prev"); } - return { direction: "new" as const, records: diff }; + const next = await npager.next(); + const page = next.value ?? []; + return { direction, records: page, end: next.done, tlChanged }; } }, ); @@ -72,7 +66,10 @@ export function useTimeline(timeline: Accessor) { createEffect(() => { const shot = snapshot(); if (!shot) return; - const { direction, records } = shot; + const { direction, records, tlChanged } = shot; + if (tlChanged) { + setStore(() => []); + } if (direction == "new") { setStore((x) => [...records, ...x]); } else if (direction == "old") { diff --git a/src/masto/toot.ts b/src/masto/toot.ts index 85fd817..2651577 100644 --- a/src/masto/toot.ts +++ b/src/masto/toot.ts @@ -1,3 +1,4 @@ +import { cache } from "@solidjs/router"; import type { mastodon } from "masto"; import { createRenderEffect, createResource, type Accessor } from "solid-js"; diff --git a/src/material/BottomSheet.module.css b/src/material/BottomSheet.module.css index e48ce13..237d0bd 100644 --- a/src/material/BottomSheet.module.css +++ b/src/material/BottomSheet.module.css @@ -11,10 +11,14 @@ border-radius: 2px; overscroll-behavior: contain; + &::backdrop { + background-color: black; + opacity: 0.5; + } + box-shadow: var(--tutu-shadow-e16); :global(.MuiToolbar-root) > :global(.MuiButtonBase-root):first-child { - color: white; margin-left: -0.5em; margin-right: 24px; } @@ -30,4 +34,9 @@ max-height: 100%; } } + + &.animated { + position: absolute; + transform: none; + } } diff --git a/src/material/BottomSheet.tsx b/src/material/BottomSheet.tsx index eeefcc5..b5b93cc 100644 --- a/src/material/BottomSheet.tsx +++ b/src/material/BottomSheet.tsx @@ -1,25 +1,118 @@ -import { createEffect, type ParentComponent } from "solid-js"; +import { + createEffect, + createRenderEffect, + onCleanup, + onMount, + startTransition, + useTransition, + type ParentComponent, +} from "solid-js"; import styles from "./BottomSheet.module.css"; +import { useHeroSignal } from "../platform/anim"; export type BottomSheetProps = { open?: boolean; }; +export const HERO = Symbol("BottomSheet Hero Symbol"); + +function composeAnimationFrame( + { + top, + left, + height, + width, + }: Record<"top" | "left" | "height" | "width", number>, + x: Record, +) { + return { + top: `${top}px`, + left: `${left}px`, + height: `${height}px`, + width: `${width}px`, + ...x, + }; +} + +const MOVE_SPEED = 1400; // 1400px/s, bottom sheet is big and a bit heavier than small papers + const BottomSheet: ParentComponent = (props) => { let element: HTMLDialogElement; + let animation: Animation | undefined; + const hero = useHeroSignal(HERO); + + const [pending] = useTransition() createEffect(() => { if (props.open) { - if (!element.open) { - element.showModal(); + if (!element.open && !pending()) { + animatedOpen(); } } else { if (element.open) { - element.close(); + animatedClose(); } } }); + const animatedClose = () => { + const endRect = hero(); + if (endRect) { + const startRect = element.getBoundingClientRect(); + const animation = animateHero(startRect, endRect, element, true); + const onClose = () => { + element.close(); + }; + animation.addEventListener("finish", onClose); + animation.addEventListener("cancel", onClose); + } else { + element.close(); + } + }; + + const animatedOpen = () => { + element.showModal(); + const startRect = hero(); + if (!startRect) return; + const endRect = element.getBoundingClientRect(); + animateHero(startRect, endRect, element); + }; + + const animateHero = ( + startRect: DOMRect, + endRect: DOMRect, + element: HTMLElement, + reserve?: boolean, + ) => { + const easing = "cubic-bezier(0.4, 0, 0.2, 1)"; + element.classList.add(styles.animated); + const distance = Math.sqrt( + Math.pow(Math.abs(startRect.top - endRect.top), 2) + + Math.pow(Math.abs(startRect.left - startRect.top), 2), + ); + const duration = (distance / MOVE_SPEED) * 1000; + animation = element.animate( + [ + composeAnimationFrame(startRect, { opacity: reserve ? 1 : 0.5 }), + composeAnimationFrame(endRect, { opacity: reserve ? 0.5 : 1 }), + ], + { easing, duration }, + ); + const onAnimationEnd = () => { + element.classList.remove(styles.animated); + animation = undefined; + }; + animation.addEventListener("finish", onAnimationEnd); + animation.addEventListener("cancel", onAnimationEnd); + return animation; + }; + + onCleanup(() => { + if (animation) { + animation.cancel(); + } + }); + return ( {props.children} diff --git a/src/platform/anim.ts b/src/platform/anim.ts index 75446ef..0a937e2 100644 --- a/src/platform/anim.ts +++ b/src/platform/anim.ts @@ -1,13 +1,49 @@ -import { createContext, useContext, type Accessor } from "solid-js"; +import { + createContext, + createRenderEffect, + createSignal, + untrack, + useContext, + type Accessor, + type Signal, +} from "solid-js"; export type HeroSource = { - [key: string | symbol | number]: HTMLElement | undefined; + [key: string | symbol | number]: DOMRect | undefined; }; -const HeroSourceContext = createContext>(() => ({})); +const HeroSourceContext = createContext>(/* __@PURE__ */undefined); export const HeroSourceProvider = HeroSourceContext.Provider; -export function useHeroSource() { +function useHeroSource() { return useContext(HeroSourceContext); } + +/** + * Use hero value for the {@link key}. + */ +export function useHeroSignal( + key: string | symbol | number, +): Accessor { + const source = useHeroSource(); + if (source) { + const [get, set] = createSignal(); + + createRenderEffect(() => { + const value = source[0](); + if (value[key]) { + set(value[key]); + source[1]((x) => { + const cpy = Object.assign({}, x); + delete cpy[key]; + return cpy; + }); + } + }); + + return get; + } else { + return () => undefined; + } +} diff --git a/src/platform/host.ts b/src/platform/host.ts new file mode 100644 index 0000000..f1a2e27 --- /dev/null +++ b/src/platform/host.ts @@ -0,0 +1,12 @@ +export function isiOS() { + return [ + 'iPad Simulator', + 'iPhone Simulator', + 'iPod Simulator', + 'iPad', + 'iPhone', + 'iPod' + ].includes(navigator.platform) + // iPad on iOS 13 detection + || (navigator.userAgent.includes("Mac") && "ontouchend" in document) +} \ No newline at end of file diff --git a/src/platform/polyfills.ts b/src/platform/polyfills.ts new file mode 100644 index 0000000..6897af7 --- /dev/null +++ b/src/platform/polyfills.ts @@ -0,0 +1,8 @@ +//! This module has side effect. +//! It recommended to include the module by