diff --git a/src/App.tsx b/src/App.tsx index abdff31..8f7ba44 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -27,6 +27,7 @@ import { import { Service } from "./serviceworker/services.js"; import { makeEventListener } from "@solid-primitives/event-listener"; import { ServiceWorkerProvider } from "./platform/host.js"; +import StackedRouter from "./platform/StackedRouter.js"; const AccountSignIn = lazy(() => import("./accounts/SignIn.js")); const AccountMastodonOAuth2Callback = lazy( @@ -37,24 +38,21 @@ const Settings = lazy(() => import("./settings/Settings.js")); const TootBottomSheet = lazy(() => import("./timelines/TootBottomSheet.js")); const MotionSettings = lazy(() => import("./settings/Motions.js")); const LanguageSettings = lazy(() => import("./settings/Language.js")); -const RegionSettings = lazy(() => import("./settings/Region.jsx")); +const RegionSettings = lazy(() => import("./settings/Region.js")); const UnexpectedError = lazy(() => import("./UnexpectedError.js")); const Profile = lazy(() => import("./profiles/Profile.js")); const Routing: Component = () => { return ( - - - - - - - - - - - - + + + + + + + + + { component={AccountMastodonOAuth2Callback} /> - + ); }; @@ -70,7 +68,9 @@ const App: Component = () => { const theme = useRootTheme(); const accts = useStore($accounts); const lang = useLanguage(); - const [serviceWorker, setServiceWorker] = createSignal(); + const [serviceWorker, setServiceWorker] = createSignal< + ServiceWorker | undefined + >(undefined, { name: "serviceWorker" }); const dispatcher = new ResultDispatcher(); let checkAge = 0; diff --git a/src/UnexpectedError.tsx b/src/UnexpectedError.tsx index 906d99d..4177758 100644 --- a/src/UnexpectedError.tsx +++ b/src/UnexpectedError.tsx @@ -49,11 +49,13 @@ const UnexpectedError: Component<{ error?: any }> = (props) => { Oh, it is our fault. There is an unexpected error in our app, and it's not your fault. - You can reload to see if this guy is gone. If you meet this guy + You can restart the app to see if this guy is gone. If you meet this guy repeatly, please report to us. - window.location.reload()}>Reload + (window.location.replace("/"))}> + Restart App + diff --git a/src/accounts/MastodonOAuth2Callback.tsx b/src/accounts/MastodonOAuth2Callback.tsx index 249c58f..0714984 100644 --- a/src/accounts/MastodonOAuth2Callback.tsx +++ b/src/accounts/MastodonOAuth2Callback.tsx @@ -1,4 +1,4 @@ -import { useNavigate, useSearchParams } from "@solidjs/router"; +import { useSearchParams } from "@solidjs/router"; import { Component, Show, @@ -14,6 +14,7 @@ import { LinearProgress } from "@suid/material"; import Img from "../material/Img"; import { createRestAPIClient } from "masto"; import { Title } from "../material/typography"; +import { useNavigator } from "../platform/StackedRouter"; type OAuth2CallbackParams = { code?: string; @@ -25,7 +26,7 @@ const MastodonOAuth2Callback: Component = () => { const progressId = createUniqueId(); const titleId = createUniqueId(); const [params] = useSearchParams(); - const navigate = useNavigate(); + const { push: navigate } = useNavigator(); const setDocumentTitle = useDocumentTitle("Back from Mastodon..."); const [siteImg, setSiteImg] = createSignal<{ src: string; diff --git a/src/masto/clients.ts b/src/masto/clients.ts index 95c9b0f..d11c7c9 100644 --- a/src/masto/clients.ts +++ b/src/masto/clients.ts @@ -8,7 +8,8 @@ import { } from "solid-js"; import { Account } from "../accounts/stores"; import { createRestAPIClient, mastodon } from "masto"; -import { useLocation, useNavigate } from "@solidjs/router"; +import { useLocation } from "@solidjs/router"; +import { useNavigator } from "../platform/StackedRouter"; const restfulCache: Record = {}; @@ -56,12 +57,12 @@ export const Provider = Context.Provider; export function useSessions() { const sessions = useSessionsRaw(); - const navigate = useNavigate(); + const {push} = useNavigator(); const location = useLocation(); createRenderEffect(() => { if (sessions().length > 0) return; - navigate( + push( "/accounts/sign-in?back=" + encodeURIComponent(location.pathname), { replace: true }, ); diff --git a/src/material/BottomSheet.css b/src/material/BottomSheet.css index c0f2803..049ca32 100644 --- a/src/material/BottomSheet.css +++ b/src/material/BottomSheet.css @@ -25,22 +25,6 @@ box-shadow: var(--tutu-shadow-e16); - .MuiToolbar-root { - >.MuiButtonBase-root { - - &:first-child { - margin-left: -0.5em; - margin-right: 24px; - } - - &:last-child { - margin-right: -0.5em; - margin-left: 24px; - } - - } - } - @media (max-width: 560px) { & { left: 0; diff --git a/src/material/Scaffold.css b/src/material/Scaffold.css index fdcf2bd..56b2cd0 100644 --- a/src/material/Scaffold.css +++ b/src/material/Scaffold.css @@ -1,22 +1,42 @@ - -.Scaffold__topbar { +.Scaffold>.topbar { position: sticky; top: 0px; z-index: var(--tutu-zidx-nav, auto); + + .MuiToolbar-root { + >.MuiButtonBase-root { + &:first-child { + margin-left: -0.5em; + margin-right: 24px; + } + + &:last-child { + margin-right: -0.5em; + margin-left: 24px; + } + + } + } } -.Scaffold__fab-dock { +.Scaffold>.fab-dock { position: fixed; bottom: 40px; right: 40px; z-index: var(--tutu-zidx-nav, auto); } -.Scaffold__bottom-dock { +.Scaffold>.bottom-dock { position: sticky; bottom: 0; left: 0; right: 0; z-index: var(--tutu-zidx-nav, auto); padding-bottom: var(--safe-area-inset-bottom, 0); +} + +.Scaffold { + height: 100%; + width: 100%; + background-color: var(--tutu-color-surface); } \ No newline at end of file diff --git a/src/material/Scaffold.tsx b/src/material/Scaffold.tsx index 693793f..4e905c3 100644 --- a/src/material/Scaffold.tsx +++ b/src/material/Scaffold.tsx @@ -28,42 +28,48 @@ const Scaffold: Component = (props) => { "bottom", "children", "ref", + "class", ]); const [topbarElement, setTopbarElement] = createSignal(); const topbarSize = createElementSize(topbarElement); return ( - <> + { + createRenderEffect(() => { + e.style.setProperty( + "--scaffold-topbar-height", + (topbarSize.height?.toString() ?? 0) + "px", + ); + }); + + if (managed.ref) { + (managed.ref as (val: typeof e) => void)(e); + } + }} + {...rest} + > - + {props.topbar} - {props.fab} + + {props.fab} + - { - createRenderEffect(() => { - e.style.setProperty( - "--scaffold-topbar-height", - (topbarSize.height?.toString() ?? 0) + "px", - ); - }); - if (managed.ref) { - (managed.ref as (val: typeof e) => void)(e); - } - }} - {...rest} - > - {managed.children} - + {managed.children} + - {props.bottom} + + {props.bottom} + - > + ); }; diff --git a/src/platform/A.tsx b/src/platform/A.tsx new file mode 100644 index 0000000..3d0b33d --- /dev/null +++ b/src/platform/A.tsx @@ -0,0 +1,21 @@ +import { splitProps, type JSX } from "solid-js"; +import { useNavigator } from "./StackedRouter"; +import { useResolvedPath } from "@solidjs/router"; + +function handleClick( + push: (name: string, state: unknown) => void, + event: MouseEvent & { currentTarget: HTMLAnchorElement }, +) { + const target = event.currentTarget; + event.preventDefault(); + push(target.href, { state: target.getAttribute("state") || undefined }); +} + +const A = (oprops: Omit) => { + const [props, rest] = splitProps(oprops, ["href"]); + const resolvedPath = useResolvedPath(() => props.href || "#"); + const { push } = useNavigator(); + return ; +}; + +export default A; diff --git a/src/platform/BackButton.tsx b/src/platform/BackButton.tsx new file mode 100644 index 0000000..0c59876 --- /dev/null +++ b/src/platform/BackButton.tsx @@ -0,0 +1,24 @@ +import type { IconButtonProps } from "@suid/material/IconButton"; +import IconButton from "@suid/material/IconButton"; +import { Show, type Component } from "solid-js"; +import { useCurrentFrame, useNavigator } from "./StackedRouter"; +import { ArrowBack, Close } from "@suid/icons-material"; + +export type BackButtonProps = Omit; + +const BackButton: Component = (props) => { + const currentFrame = useCurrentFrame(); + const { pop } = useNavigator(); + + const hasPrevSubPage = () => currentFrame().index > 1; + + return ( + + }> + + + + ); +}; + +export default BackButton; diff --git a/src/platform/StackedRouter.css b/src/platform/StackedRouter.css new file mode 100644 index 0000000..eac26ce --- /dev/null +++ b/src/platform/StackedRouter.css @@ -0,0 +1,59 @@ +.StackedPage { + container: StackedPage / size; + display: contents; + max-width: 100vw; + max-width: 100dvw; + + contain: layout; +} + +dialog.StackedPage { + border: none; + position: fixed; + padding: 0; + overscroll-behavior: none; + width: 560px; + max-height: 100vh; + max-height: 100dvh; + background: none; + display: none; + + contain: strict; + contain-intrinsic-size: auto 560px auto 100vh; + contain-intrinsic-size: auto 560px auto 100dvh; + content-visibility: auto; + + background: var(--tutu-color-surface); + box-shadow: var(--tutu-shadow-e16); + + margin-left: auto; + margin-right: auto; + + @media (max-width: 560px) { + & { + width: 100vw; + width: 100dvw; + height: 100vh; + height: 100dvh; + contain-intrinsic-size: 100vw 100vh; + contain-intrinsic-size: 100dvw 100dvh; + } + + } + + &[open] { + display: contents; + } + + &::backdrop { + background: none; + } + + &.animating { + overflow: hidden; + + * { + overflow: hidden; + } + } +} \ No newline at end of file diff --git a/src/platform/StackedRouter.tsx b/src/platform/StackedRouter.tsx new file mode 100644 index 0000000..e1477f1 --- /dev/null +++ b/src/platform/StackedRouter.tsx @@ -0,0 +1,432 @@ +import { StaticRouter, type RouterProps } from "@solidjs/router"; +import { + Component, + createContext, + createRenderEffect, + createUniqueId, + Index, + onMount, + Show, + untrack, + useContext, + type Accessor, +} from "solid-js"; +import { createStore, unwrap } from "solid-js/store"; +import "./StackedRouter.css"; +import { animateSlideInFromRight, animateSlideOutToRight } from "./anim"; +import { ANIM_CURVE_DECELERATION, ANIM_CURVE_STD } from "../material/theme"; +import { + makeEventListener, +} from "@solid-primitives/event-listener"; + +export type StackedRouterProps = Omit; + +export type StackFrame = { + path: string; + rootId: string; + state: unknown; + + animateOpen?: (element: HTMLElement) => Animation; + animateClose?: (element: HTMLElement) => Animation; +}; + +export type NewFrameOptions = (T extends undefined + ? { + state?: T; + } + : { state: T }) & { + /** + * The new frame should replace the current frame. + */ + replace?: boolean; + /** + * The animatedOpen phase of the life cycle. + * + * You can use this hook to animate the opening + * of the frame. In this phase, the frame content is created + * and is mounted to the document. + * + * You must return an {@link Animation}. This function must be + * without side effects. This phase is ended after the {@link Animation} + * finished. + */ + animateOpen?: StackFrame["animateOpen"]; + /** + * The animatedClose phase of the life cycle. + * + * You can use this hook to animate the closing of the frame. + * In this phase, the frame content is still mounted in the + * document and will be unmounted after this phase. + * + * You must return an {@link Animation}. This function must be + * without side effects. This phase is ended after the + * {@link Animation} finished. + */ + animateClose?: StackFrame["animateClose"]; +}; + +export type FramePusher = T[K] extends + | undefined + | any + ? (path: K, state?: Readonly>) => Readonly + : (path: K, state: Readonly>) => Readonly; + +export type Navigator> = { + frames: readonly StackFrame[]; + push: FramePusher; + pop: (depth?: number) => void; +}; + +const NavigatorContext = /* @__PURE__ */ createContext(); + +export function useMaybeNavigator() { + return useContext(NavigatorContext); +} + +/** + * Get the navigator of the {@link StackedRouter}. + * + * This function returns a {@link Navigator} without available + * push guide. Push guide is a record type contains available + * path and its state. If you need push guide, you may want to + * define your own function (like `useAppNavigator`) and cast the + * navigator to the type you need. + */ +export function useNavigator() { + const navigator = useMaybeNavigator(); + + if (!navigator) { + throw new TypeError("not in available scope of StackedRouter"); + } + + return navigator; +} + +export type CurrentFrame = { + index: number; + frame: Readonly; +}; + +const CurrentFrameContext = + /* @__PURE__ */ createContext>>(); + +export function useMaybeCurrentFrame() { + return useContext(CurrentFrameContext); +} + +export function useCurrentFrame() { + const frame = useMaybeCurrentFrame(); + + if (!frame) { + throw new TypeError("not in available scope of StackedRouter"); + } + + return frame; +} + +/** + * Return an accessor of is current frame is suspended. + * + * A suspended frame is the one not on the top. "Suspended" + * is the description of a certain situtation, not in the life cycle + * of a frame. + */ +export function useMaybeIsFrameSuspended() { + const { frames } = useMaybeNavigator() || {}; + + if (typeof frames === "undefined") { + return () => false; + } + + const thisFrame = useCurrentFrame(); + + return () => { + const idx = thisFrame().index; + return frames.length - 1 > idx; + }; +} + +function onDialogClick( + onClose: () => void, + event: MouseEvent & { currentTarget: HTMLDialogElement }, +) { + if (event.target !== event.currentTarget) return; + const rect = event.currentTarget.getBoundingClientRect(); + const isNotInDialog = + event.clientY < rect.top || + event.clientY > rect.bottom || + event.clientX < rect.left || + event.clientX > rect.right; + if (isNotInDialog) { + onClose(); + } +} + +function animateClose(element: HTMLElement) { + if (window.innerWidth <= 560) { + return animateSlideOutToRight(element, { easing: ANIM_CURVE_DECELERATION }); + } else { + return element.animate( + { + opacity: [0.5, 0], + }, + { easing: ANIM_CURVE_STD, duration: 220 }, + ); + } +} + +function animateOpen(element: HTMLElement) { + if (window.innerWidth <= 560) { + return animateSlideInFromRight(element, { + easing: ANIM_CURVE_DECELERATION, + }); + } else { + return element.animate( + { + opacity: [0.5, 1], + }, + { easing: ANIM_CURVE_STD, duration: 220 }, + ); + } +} + +function serializableStack(stack: readonly StackFrame[]) { + const frames = unwrap(stack); + return frames.map((fr) => { + return fr.animateClose || fr.animateOpen + ? { + path: fr.path, + rootId: fr.rootId, + state: fr.state, + } + : fr; + }); +} + +/** + * The router that stacks the pages. + */ +const StackedRouter: Component = (oprops) => { + const [stack, mutStack] = createStore([] as StackFrame[], { name: "stack" }); + + const pushFrame = (path: string, opts?: Readonly>) => + untrack(() => { + const frame = { + path, + state: opts?.state, + rootId: createUniqueId(), + animateOpen: opts?.animateOpen, + animateClose: opts?.animateClose, + }; + + mutStack(opts?.replace ? stack.length - 1 : stack.length, frame); + if (opts?.replace) { + window.history.replaceState(serializableStack(stack), "", path); + } else { + window.history.pushState(serializableStack(stack), "", path); + } + return frame; + }); + + const onlyPopFrame = (depth: number) => { + mutStack((o) => o.toSpliced(o.length - depth, depth)); + window.history.go(-depth); + }; + + const popFrame = (depth: number = 1) => + untrack(() => { + if (import.meta.env.DEV) { + if (depth < 0) { + console.warn("the depth to pop should not < 0, now is", depth); + } + } + if (stack.length > 1) { + const lastFrame = stack[stack.length - 1]; + const element = document.getElementById( + lastFrame.rootId, + )! as HTMLDialogElement; + const createAnimation = lastFrame.animateClose ?? animateClose; + requestAnimationFrame(() => { + element.classList.add("animating"); + const animation = createAnimation(element); + animation.addEventListener("finish", () => { + element.classList.remove("animating"); + onlyPopFrame(depth); + }); + }); + } else { + onlyPopFrame(depth); + } + }); + + createRenderEffect(() => { + if (stack.length === 0) { + pushFrame(window.location.pathname); + } + }); + + createRenderEffect(() => { + makeEventListener(window, "popstate", (event) => { + if (!event.state) return; + + if (stack.length === 0) { + mutStack(event.state); + } else if (stack.length > event.state.length) { + popFrame(stack.length - event.state.length); + } + }); + }); + + const onBeforeDialogMount = (element: HTMLDialogElement) => { + onMount(() => { + const lastFr = untrack(() => stack[stack.length - 1]); + const createAnimation = lastFr.animateOpen ?? animateOpen; + requestAnimationFrame(() => { + element.showModal(); + element.classList.add("animating"); + const animation = createAnimation(element); + animation.addEventListener("finish", () => + element.classList.remove("animating"), + ); + }); + }); + }; + + let reenterableAnimation: Animation | undefined; + let origX = 0, + origWidth = 0; + + const resetAnimation = () => { + reenterableAnimation = undefined; + }; + + const onDialogTouchStart = ( + event: TouchEvent & { currentTarget: HTMLDialogElement }, + ) => { + if (event.touches.length !== 1) { + return; + } + const [fig0] = event.touches; + const { x, width } = event.currentTarget.getBoundingClientRect(); + if (fig0.clientX < x - 22 || fig0.clientX > x + 22) { + return; + } + origX = x; + origWidth = width; + + const lastFr = stack[stack.length - 1]; + const createAnimation = lastFr.animateClose ?? animateClose; + reenterableAnimation = createAnimation(event.currentTarget); + reenterableAnimation.pause(); + reenterableAnimation.addEventListener("finish", resetAnimation); + reenterableAnimation.addEventListener("cancel", resetAnimation); + }; + + const onDialogTouchMove = ( + event: TouchEvent & { currentTarget: HTMLDialogElement }, + ) => { + if (event.touches.length !== 1) { + if (reenterableAnimation) { + reenterableAnimation.reverse(); + reenterableAnimation.play(); + } + } + + if (!reenterableAnimation) return; + + event.preventDefault(); + event.stopPropagation(); + + const [fig0] = event.touches; + const ofsX = fig0.clientX - origX; + const pc = ofsX / origWidth / window.devicePixelRatio; + + const { activeDuration, delay } = + reenterableAnimation.effect!.getComputedTiming(); + + const totalTime = (delay || 0) + Number(activeDuration); + reenterableAnimation.currentTime = totalTime * pc; + }; + + const onDialogTouchEnd = (event: TouchEvent) => { + if (!reenterableAnimation) return; + + event.preventDefault(); + event.stopPropagation(); + + const { activeDuration, delay } = + reenterableAnimation.effect!.getComputedTiming(); + const totalTime = (delay || 0) + Number(activeDuration); + + if (Number(reenterableAnimation.currentTime) / totalTime > 0.1) { + reenterableAnimation.addEventListener("finish", () => { + onlyPopFrame(1); + }); + reenterableAnimation.play(); + } else { + reenterableAnimation.cancel(); + } + }; + + const onDialogTouchCancel = (event: TouchEvent) => { + if (!reenterableAnimation) return; + + event.preventDefault(); + event.stopPropagation(); + reenterableAnimation.cancel(); + }; + + return ( + + + {(frame, index) => { + const currentFrame = () => { + return { + index, + frame: frame(), + }; + }; + + return ( + + + + + } + > + + + + + + ); + }} + + + ); +}; + +export default StackedRouter; diff --git a/src/profiles/Profile.css b/src/profiles/Profile.css index cce8364..3f0bce8 100644 --- a/src/profiles/Profile.css +++ b/src/profiles/Profile.css @@ -1,4 +1,6 @@ .Profile { + height: 100%; + .intro { background-color: var(--tutu-color-surface-d); color: var(--tutu-color-on-surface); diff --git a/src/profiles/Profile.tsx b/src/profiles/Profile.tsx index caf3cd0..21ce929 100644 --- a/src/profiles/Profile.tsx +++ b/src/profiles/Profile.tsx @@ -45,7 +45,7 @@ import { Verified, } from "@suid/icons-material"; import { Body2, Title } from "../material/typography"; -import { useNavigate, useParams } from "@solidjs/router"; +import { useParams } from "@solidjs/router"; import { useSessionForAcctStr } from "../masto/clients"; import { resolveCustomEmoji } from "../masto/toot"; import { FastAverageColor } from "fast-average-color"; @@ -57,9 +57,10 @@ import TootFilterButton from "./TootFilterButton"; import Menu, { createManagedMenuState } from "../material/Menu"; import { share } from "../platform/share"; import "./Profile.css"; +import { useNavigator } from "../platform/StackedRouter"; const Profile: Component = () => { - const navigate = useNavigate(); + const { pop } = useNavigator(); const params = useParams<{ acct: string; id: string }>(); const acctText = () => decodeURIComponent(params.acct); const session = useSessionForAcctStr(acctText); @@ -209,11 +210,7 @@ const Profile: Component = () => { paddingTop: "var(--safe-area-inset-top)", }} > - + { - const navigate = useNavigate() + const { pop } = useNavigator(); const [t] = createTranslator( () => import("./i18n/lang-names.json"), (code) => @@ -37,9 +37,9 @@ const ChooseLang: Component = () => { }; }>, ); - const settings = useStore($settings) + const settings = useStore($settings); - const code = () => settings().language + const code = () => settings().language; const unsupportedLangCodes = createMemo(() => { return iso639_1.getAllCodes().filter((x) => !["zh", "en"].includes(x)); @@ -48,8 +48,8 @@ const ChooseLang: Component = () => { const matchedLangCode = createMemo(() => autoMatchLangTag()); const onCodeChange = (code?: string) => { - $settings.setKey("language", code) - } + $settings.setKey("language", code); + }; return ( { variant="dense" sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }} > - + {t("Choose Language")} @@ -96,7 +96,10 @@ const ChooseLang: Component = () => { {t(`lang.${c}`)} diff --git a/src/settings/Motions.tsx b/src/settings/Motions.tsx index a90bd44..5b8cabb 100644 --- a/src/settings/Motions.tsx +++ b/src/settings/Motions.tsx @@ -13,14 +13,14 @@ import { Toolbar, } from "@suid/material"; import { Title } from "../material/typography"; -import { useNavigate } from "@solidjs/router"; import { ArrowBack } from "@suid/icons-material"; import { createTranslator } from "../platform/i18n"; import { useStore } from "@nanostores/solid"; import { $settings } from "./stores"; +import { useNavigator } from "../platform/StackedRouter"; const Motions: Component = () => { - const navigate = useNavigate(); + const {pop} = useNavigator(); const [t] = createTranslator( (code) => import(`./i18n/${code}.json`) as Promise<{ @@ -36,7 +36,7 @@ const Motions: Component = () => { variant="dense" sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }} > - + {t("motions")} diff --git a/src/settings/Region.tsx b/src/settings/Region.tsx index 130ef1f..c4b7a50 100644 --- a/src/settings/Region.tsx +++ b/src/settings/Region.tsx @@ -20,12 +20,12 @@ import { } from "../platform/i18n"; import { Title } from "../material/typography"; import type { Template } from "@solid-primitives/i18n"; -import { useNavigate } from "@solidjs/router"; import { $settings } from "./stores"; import { useStore } from "@nanostores/solid"; +import { useNavigator } from "../platform/StackedRouter"; const ChooseRegion: Component = () => { - const navigate = useNavigate(); + const {pop} = useNavigator(); const [t] = createTranslator( () => import("./i18n/lang-names.json"), (code) => @@ -54,7 +54,7 @@ const ChooseRegion: Component = () => { variant="dense" sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }} > - + {t("Choose Region")} diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 34bb4cc..aca4c59 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -1,10 +1,7 @@ import { - children, - createSignal, For, Show, - type JSX, - type ParentComponent, + type Component, } from "solid-js"; import Scaffold from "../material/Scaffold.js"; import { @@ -30,7 +27,7 @@ import { Refresh as RefreshIcon, Translate as TranslateIcon, } from "@suid/icons-material"; -import { A, useNavigate } from "@solidjs/router"; +import A from "../platform/A.js"; import { Title } from "../material/typography.jsx"; import { css } from "solid-styled"; import { signOut, type Account } from "../accounts/stores.js"; @@ -44,9 +41,9 @@ import { useDateFnLocale, } from "../platform/i18n.jsx"; import { type Template } from "@solid-primitives/i18n"; -import BottomSheet from "../material/BottomSheet.jsx"; import { useServiceWorker } from "../platform/host.js"; import { useSessions } from "../masto/clients.js"; +import { useNavigator } from "../platform/StackedRouter.jsx"; type Inset = { top?: number; @@ -162,7 +159,7 @@ type Strings = { ["lang.auto"]: Template<{ detected: string }>; } & Record; -const Settings: ParentComponent = (props) => { +const Settings: Component = () => { const [t] = createTranslator( (code) => import(`./i18n/${code}.json`) as Promise<{ @@ -170,9 +167,9 @@ const Settings: ParentComponent = (props) => { }>, () => import(`./i18n/lang-names.json`), ); - const navigate = useNavigate(); + const {pop} = useNavigator(); const settings$ = useStore($settings); - const { needRefresh, offlineReady } = useServiceWorker(); + const { needRefresh } = useServiceWorker(); const dateFnLocale = useDateFnLocale(); const profiles = useSessions(); @@ -181,8 +178,6 @@ const Settings: ParentComponent = (props) => { signOut((a) => a.site === acct.site && a.accessToken === acct.accessToken); }; - const subpage = children(() => props.children); - css` ul { padding: 0; @@ -200,7 +195,7 @@ const Settings: ParentComponent = (props) => { variant="dense" sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }} > - + {t("Settings")} @@ -208,10 +203,6 @@ const Settings: ParentComponent = (props) => { } > - navigate(-1)}> - {subpage()} - - diff --git a/src/timelines/Home.tsx b/src/timelines/Home.tsx index 99045bf..13b59d7 100644 --- a/src/timelines/Home.tsx +++ b/src/timelines/Home.tsx @@ -3,11 +3,9 @@ import { Show, onMount, type ParentComponent, - children, - Suspense, + createRenderEffect, } from "solid-js"; import { useDocumentTitle } from "../utils"; -import { type mastodon } from "masto"; import Scaffold from "../material/Scaffold"; import { AppBar, @@ -23,14 +21,8 @@ import ProfileMenuButton from "./ProfileMenuButton"; import Tabs from "../material/Tabs"; import Tab from "../material/Tab"; import { makeEventListener } from "@solid-primitives/event-listener"; -import BottomSheet, { - HERO as BOTTOM_SHEET_HERO, -} from "../material/BottomSheet"; import { $settings } from "../settings/stores"; import { useStore } from "@nanostores/solid"; -import { HeroSourceProvider, type HeroSource } from "../platform/anim"; -import { useNavigate } from "@solidjs/router"; -import { setCache as setTootBottomSheetCache } from "./TootBottomSheet"; import TrendTimelinePanel from "./TrendTimelinePanel"; import TimelinePanel from "./TimelinePanel"; import { useSessions } from "../masto/clients"; @@ -43,29 +35,17 @@ const Home: ParentComponent = (props) => { const settings$ = useStore($settings); const profiles = useSessions(); - const profile = () => { - const all = profiles(); - if (all.length > 0) { - return all[0].account.inf; - } - }; const client = () => { const all = profiles(); return all?.[0]?.client; }; - const navigate = useNavigate(); - const [heroSrc, setHeroSrc] = createSignal({}); - const [panelOffset, setPanelOffset] = createSignal(0); const prefetching = () => !settings$().prefetchTootsDisabled; - const [currentFocusOn, setCurrentFocusOn] = createSignal([]); const [focusRange, setFocusRange] = createSignal([0, 0] as readonly [ number, number, ]); - const child = children(() => props.children); - let scrollEventLockReleased = true; const recalculateTabIndicator = () => { @@ -102,17 +82,17 @@ const Home: ParentComponent = (props) => { } }; + const requestRecalculateTabIndicator = () => { + if (scrollEventLockReleased) { + requestAnimationFrame(recalculateTabIndicator); + } + }; + + createRenderEffect(() => { + makeEventListener(window, "resize", requestRecalculateTabIndicator); + }); + onMount(() => { - makeEventListener(panelList, "scroll", () => { - if (scrollEventLockReleased) { - requestAnimationFrame(recalculateTabIndicator); - } - }); - makeEventListener(window, "resize", () => { - if (scrollEventLockReleased) { - requestAnimationFrame(recalculateTabIndicator); - } - }); requestAnimationFrame(recalculateTabIndicator); }); @@ -135,30 +115,6 @@ const Home: ParentComponent = (props) => { } }; - const openFullScreenToot = ( - toot: mastodon.v1.Status, - srcElement?: HTMLElement, - reply?: boolean, - ) => { - const p = profiles()[0]; - const inf = p.account.inf ?? profile(); - if (!inf) { - console.warn("no account info?"); - return; - } - setHeroSrc((x) => - Object.assign({}, x, { [BOTTOM_SHEET_HERO]: srcElement }), - ); - const acct = `${inf.username}@${p.account.site}`; - setTootBottomSheetCache(acct, toot); - navigate(`/${encodeURIComponent(acct)}/toot/${toot.id}`, { - state: reply - ? { - tootReply: true, - } - : undefined, - }); - }; css` .tab-panel { @@ -209,7 +165,7 @@ const Home: ParentComponent = (props) => { class="responsive" sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }} > - + Home @@ -239,48 +195,40 @@ const Home: ParentComponent = (props) => { } > - - - - - - - - + + + + + + - - - - - - - - - - - - - - - navigate(-1)}> - {child()} - - - + + + + + + + + + + + + + + > ); diff --git a/src/timelines/ProfileMenuButton.tsx b/src/timelines/ProfileMenuButton.tsx index 0aa088c..b74386c 100644 --- a/src/timelines/ProfileMenuButton.tsx +++ b/src/timelines/ProfileMenuButton.tsx @@ -21,7 +21,7 @@ import { Star as LikeIcon, FeaturedPlayList as ListIcon, } from "@suid/icons-material"; -import { A } from "@solidjs/router"; +import A from "../platform/A"; const ProfileMenuButton: ParentComponent<{ profile?: { @@ -51,7 +51,7 @@ const ProfileMenuButton: ParentComponent<{ props.onClick?.(); }; - const inf = () => props.profile?.account.inf + const inf = () => props.profile?.account.inf; const onClose = () => { props.onClick?.(); @@ -130,7 +130,7 @@ const ProfileMenuButton: ParentComponent<{ {props.children} - + diff --git a/src/timelines/PullDownToRefresh.tsx b/src/timelines/PullDownToRefresh.tsx index 10df2bf..7cbacdb 100644 --- a/src/timelines/PullDownToRefresh.tsx +++ b/src/timelines/PullDownToRefresh.tsx @@ -10,6 +10,7 @@ import { Refresh as RefreshIcon } from "@suid/icons-material"; import { CircularProgress } from "@suid/material"; import { makeEventListener } from "@solid-primitives/event-listener"; import { createVisibilityObserver } from "@solid-primitives/intersection-observer"; +import { useMaybeIsFrameSuspended } from "../platform/StackedRouter"; const PullDownToRefresh: Component<{ loading?: boolean; @@ -33,6 +34,7 @@ const PullDownToRefresh: Component<{ }); const rootVisible = obvx(() => rootElement); + const isFrameSuspended = useMaybeIsFrameSuspended() createEffect(() => { if (!rootVisible()) setPullDown(0); @@ -109,6 +111,9 @@ const PullDownToRefresh: Component<{ if (!rootVisible()) { return; } + if (isFrameSuspended()) { + return; + } const element = props.linkedElement; if (!element) return; makeEventListener(element, "wheel", handleLinkedWheel); @@ -159,6 +164,9 @@ const PullDownToRefresh: Component<{ if (!rootVisible()) { return; } + if (isFrameSuspended()) { + return; + } const element = props.linkedElement; if (!element) return; makeEventListener(element, "touchmove", handleTouch); diff --git a/src/timelines/TimelinePanel.tsx b/src/timelines/TimelinePanel.tsx index 150ca0e..7a4c4e1 100644 --- a/src/timelines/TimelinePanel.tsx +++ b/src/timelines/TimelinePanel.tsx @@ -20,12 +20,6 @@ const TimelinePanel: Component<{ client: mastodon.rest.Client; name: "home" | "public"; prefetch?: boolean; - - openFullScreenToot: ( - toot: mastodon.v1.Status, - srcElement?: HTMLElement, - reply?: boolean, - ) => void; }> = (props) => { const [scrollLinked, setScrollLinked] = createSignal(); diff --git a/src/timelines/TootBottomSheet.css b/src/timelines/TootBottomSheet.css index ceb7c17..e6babb0 100644 --- a/src/timelines/TootBottomSheet.css +++ b/src/timelines/TootBottomSheet.css @@ -1,12 +1,11 @@ .TootBottomSheet { overflow: hidden; - height: calc(100% - var(--scaffold-topbar-height, 0px)); .Scrollable { padding-bottom: var(--safe-area-inset-bottom, 0); overflow-y: auto; overscroll-behavior-y: contain; - height: 100%; + height: calc(100% - var(--scaffold-topbar-height, 0px)); } .progress-line { diff --git a/src/timelines/TootBottomSheet.tsx b/src/timelines/TootBottomSheet.tsx index 1c6b111..e82ce00 100644 --- a/src/timelines/TootBottomSheet.tsx +++ b/src/timelines/TootBottomSheet.tsx @@ -1,10 +1,9 @@ -import { useLocation, useNavigate, useParams } from "@solidjs/router"; +import { useLocation, useParams } from "@solidjs/router"; import { catchError, createEffect, createRenderEffect, createResource, - createSignal, Show, type Component, } from "solid-js"; @@ -25,6 +24,8 @@ import { useDocumentTitle } from "../utils"; import { createTimelineControlsForArray } from "../masto/timelines"; import TootList from "./TootList"; import "./TootBottomSheet.css"; +import { useNavigator } from "../platform/StackedRouter"; +import BackButton from "../platform/BackButton"; let cachedEntry: [string, mastodon.v1.Status] | undefined; @@ -43,7 +44,7 @@ const TootBottomSheet: Component = (props) => { const location = useLocation<{ tootReply?: boolean; }>(); - const navigate = useNavigate(); + const { pop, push } = useNavigator(); const time = createTimeSource(); const acctText = () => decodeURIComponent(params.acct); const session = useSessionForAcctStr(acctText); @@ -186,7 +187,7 @@ const TootBottomSheet: Component = (props) => { target.dataset.client || `@${new URL(target.href).origin}`, ); - navigate(`/${acct}/profile/${target.dataset.acctId}`); + push(`/${acct}/profile/${target.dataset.acctId}`); return; } else { @@ -228,9 +229,7 @@ const TootBottomSheet: Component = (props) => { variant="dense" sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }} > - - - + @@ -246,9 +245,7 @@ const TootBottomSheet: Component = (props) => { } class="TootBottomSheet" > - + { - + diff --git a/src/timelines/TootList.tsx b/src/timelines/TootList.tsx index 7ef86da..38670e2 100644 --- a/src/timelines/TootList.tsx +++ b/src/timelines/TootList.tsx @@ -13,13 +13,28 @@ import { useDefaultSession } from "../masto/clients"; import { useHeroSource } from "../platform/anim"; import { HERO as BOTTOM_SHEET_HERO } from "../material/BottomSheet"; import { setCache as setTootBottomSheetCache } from "./TootBottomSheet"; -import { useNavigate } from "@solidjs/router"; import RegularToot, { findElementActionable, findRootToot, } from "./RegularToot"; import cardStyle from "../material/cards.module.css"; import type { ThreadNode } from "../masto/timelines"; +import { useNavigator } from "../platform/StackedRouter"; +import { ANIM_CURVE_STD } from "../material/theme"; + +function durationOf(rect0: DOMRect, rect1: DOMRect) { + const distancelt = Math.sqrt( + Math.pow(Math.abs(rect0.top - rect1.top), 2) + + Math.pow(Math.abs(rect0.left - rect1.left), 2), + ); + const distancerb = Math.sqrt( + Math.pow(Math.abs(rect0.bottom - rect1.bottom), 2) + + Math.pow(Math.abs(rect0.right - rect1.right), 2), + ); + const distance = distancelt + distancerb; + const duration = distance / 1.6; + return duration; +} function positionTootInThread(index: number, threadLength: number) { if (index === 0) { @@ -40,7 +55,7 @@ const TootList: Component<{ const session = useDefaultSession(); const heroSrc = useHeroSource(); const [expandedThreadId, setExpandedThreadId] = createSignal(); - const navigate = useNavigate(); + const { push } = useNavigator(); const onBookmark = async (status: mastodon.v1.Status) => { const client = session()?.client; @@ -99,7 +114,7 @@ const TootList: Component<{ const openFullScreenToot = ( toot: mastodon.v1.Status, - srcElement?: HTMLElement, + srcElement: HTMLElement, reply?: boolean, ) => { const p = session()?.account; @@ -115,12 +130,55 @@ const TootList: Component<{ const acct = `${inf.username}@${p.site}`; setTootBottomSheetCache(acct, toot); - navigate(`/${encodeURIComponent(acct)}/toot/${toot.id}`, { - state: reply - ? { - tootReply: true, - } - : undefined, + + push(`/${encodeURIComponent(acct)}/toot/${toot.id}`, { + animateOpen(element) { + const rect0 = srcElement.getBoundingClientRect(); // the start rect + const rect1 = element.getBoundingClientRect(); // the end rect + + const duration = durationOf(rect0, rect1); + + const keyframes = { + top: [`${rect0.top}px`, `${rect1.top}px`], + bottom: [`${rect0.bottom}px`, `${rect1.bottom}px`], + left: [`${rect0.left}px`, `${rect1.left}px`], + right: [`${rect0.right}px`, `${rect1.right}px`], + height: [`${rect0.height}px`, `${rect1.height}px`], + margin: 0, + }; + + srcElement.style.visibility = "hidden"; + + const animation = element.animate(keyframes, { + duration, + easing: ANIM_CURVE_STD, + }); + return animation; + }, + + animateClose(element) { + const rect0 = element.getBoundingClientRect(); // the start rect + const rect1 = srcElement.getBoundingClientRect(); // the end rect + + const duration = durationOf(rect0, rect1); + + const keyframes = { + top: [`${rect0.top}px`, `${rect1.top}px`], + bottom: [`${rect0.bottom}px`, `${rect1.bottom}px`], + left: [`${rect0.left}px`, `${rect1.left}px`], + right: [`${rect0.right}px`, `${rect1.right}px`], + height: [`${rect0.height}px`, `${rect1.height}px`], + margin: 0, + }; + + srcElement.style.visibility = ""; + + const animation = element.animate(keyframes, { + duration, + easing: ANIM_CURVE_STD, + }); + return animation; + }, }); }; @@ -146,7 +204,7 @@ const TootList: Component<{ target.dataset.client || `@${new URL(target.href).origin}`, ); - navigate(`/${acct}/profile/${target.dataset.acctId}`); + push(`/${acct}/profile/${target.dataset.acctId}`); return; } else { diff --git a/src/timelines/TrendTimelinePanel.tsx b/src/timelines/TrendTimelinePanel.tsx index 1691ec8..9f0ba75 100644 --- a/src/timelines/TrendTimelinePanel.tsx +++ b/src/timelines/TrendTimelinePanel.tsx @@ -13,12 +13,6 @@ import TootList from "./TootList.jsx"; const TrendTimelinePanel: Component<{ client: mastodon.rest.Client; - - openFullScreenToot: ( - toot: mastodon.v1.Status, - srcElement?: HTMLElement, - reply?: boolean, - ) => void; }> = (props) => { const [scrollLinked, setScrollLinked] = createSignal(); const [tl, snapshot, { refetch: refetchTimeline }] = createTimelineSnapshot(
There is an unexpected error in our app, and it's not your fault.
- You can reload to see if this guy is gone. If you meet this guy + You can restart the app to see if this guy is gone. If you meet this guy repeatly, please report to us.