StackedRouter: new router simulates app behaviour #45

Merged
Rubicon merged 18 commits from stacky into master 2024-11-18 10:35:30 +00:00
25 changed files with 775 additions and 236 deletions

View file

@ -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 (
<Router>
<Route path="/" component={TimelineHome}>
<Route path=""></Route>
<Route path="/settings" component={Settings}>
<Route path=""></Route>
<Route path="/language" component={LanguageSettings}></Route>
<Route path="/region" component={RegionSettings}></Route>
<Route path="/motions" component={MotionSettings}></Route>
</Route>
<Route path="/:acct/toot/:id" component={TootBottomSheet}></Route>
<Route path="/:acct/profile/:id" component={Profile}></Route>
</Route>
<StackedRouter>
<Route path="/" component={TimelineHome} />
<Route path="/settings/language" component={LanguageSettings} />
<Route path="/settings/region" component={RegionSettings} />
<Route path="/settings/motions" component={MotionSettings} />
<Route path="/settings" component={Settings} />
<Route path="/:acct/toot/:id" component={TootBottomSheet} />
<Route path="/:acct/profile/:id" component={Profile} />
<Route path={"/accounts"}>
<Route path={"/sign-in"} component={AccountSignIn} />
<Route
@ -62,7 +60,7 @@ const Routing: Component = () => {
component={AccountMastodonOAuth2Callback}
/>
</Route>
</Router>
</StackedRouter>
);
};
@ -70,7 +68,9 @@ const App: Component = () => {
const theme = useRootTheme();
const accts = useStore($accounts);
const lang = useLanguage();
const [serviceWorker, setServiceWorker] = createSignal<ServiceWorker>();
const [serviceWorker, setServiceWorker] = createSignal<
ServiceWorker | undefined
>(undefined, { name: "serviceWorker" });
const dispatcher = new ResultDispatcher();
let checkAge = 0;

View file

@ -49,11 +49,13 @@ const UnexpectedError: Component<{ error?: any }> = (props) => {
<h1>Oh, it is our fault.</h1>
<p>There is an unexpected error in our app, and it's not your fault.</p>
<p>
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.
</p>
<div>
<Button onClick={() => window.location.reload()}>Reload</Button>
<Button onClick={() => (window.location.replace("/"))}>
Restart App
</Button>
</div>
<details>
<summary>

View file

@ -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<OAuth2CallbackParams>();
const navigate = useNavigate();
const { push: navigate } = useNavigator();
const setDocumentTitle = useDocumentTitle("Back from Mastodon...");
const [siteImg, setSiteImg] = createSignal<{
src: string;

View file

@ -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<string, mastodon.rest.Client> = {};
@ -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 },
);

View file

@ -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;

View file

@ -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);
}

View file

@ -28,42 +28,48 @@ const Scaffold: Component<ScaffoldProps> = (props) => {
"bottom",
"children",
"ref",
"class",
]);
const [topbarElement, setTopbarElement] = createSignal<HTMLElement>();
const topbarSize = createElementSize(topbarElement);
return (
<>
<div
class={`Scaffold ${managed.class || ""}`}
ref={(e) => {
createRenderEffect(() => {
e.style.setProperty(
"--scaffold-topbar-height",
(topbarSize.height?.toString() ?? 0) + "px",
);
});
if (managed.ref) {
(managed.ref as (val: typeof e) => void)(e);
}
}}
{...rest}
>
<Show when={props.topbar}>
<div class="Scaffold__topbar" ref={setTopbarElement} role="presentation">
<div class="topbar" ref={setTopbarElement} role="presentation">
{props.topbar}
</div>
</Show>
<Show when={props.fab}>
<div class="Scaffold__fab-dock" role="presentation">{props.fab}</div>
<div class="fab-dock" role="presentation">
{props.fab}
</div>
</Show>
<div
ref={(e) => {
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}
</div>
{managed.children}
<Show when={props.bottom}>
<div class="Scaffold__bottom-dock" role="presentation">{props.bottom}</div>
<div class="bottom-dock" role="presentation">
{props.bottom}
</div>
</Show>
</>
</div>
);
};

21
src/platform/A.tsx Normal file
View file

@ -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<JSX.HTMLElementTags["a"], "onClick" | "onclick">) => {
const [props, rest] = splitProps(oprops, ["href"]);
const resolvedPath = useResolvedPath(() => props.href || "#");
const { push } = useNavigator();
return <a onClick={[handleClick, push]} href={resolvedPath()} {...rest}></a>;
};
export default A;

View file

@ -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<IconButtonProps, "onClick" | "children">;
const BackButton: Component<BackButtonProps> = (props) => {
const currentFrame = useCurrentFrame();
const { pop } = useNavigator();
const hasPrevSubPage = () => currentFrame().index > 1;
return (
<IconButton onClick={[pop, 1]} {...props}>
<Show when={hasPrevSubPage()} fallback={<Close />}>
<ArrowBack />
</Show>
</IconButton>
);
};
export default BackButton;

View file

@ -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;
}
}
}

View file

@ -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<RouterProps, "url">;
export type StackFrame = {
path: string;
rootId: string;
state: unknown;
animateOpen?: (element: HTMLElement) => Animation;
animateClose?: (element: HTMLElement) => Animation;
};
export type NewFrameOptions<T> = (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 keyof T = keyof T> = T[K] extends
| undefined
| any
? (path: K, state?: Readonly<NewFrameOptions<T[K]>>) => Readonly<StackFrame>
: (path: K, state: Readonly<NewFrameOptions<T[K]>>) => Readonly<StackFrame>;
export type Navigator<PushGuide = Record<string, any>> = {
frames: readonly StackFrame[];
push: FramePusher<PushGuide>;
pop: (depth?: number) => void;
};
const NavigatorContext = /* @__PURE__ */ createContext<Navigator>();
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<StackFrame>;
};
const CurrentFrameContext =
/* @__PURE__ */ createContext<Accessor<Readonly<CurrentFrame>>>();
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<StackedRouterProps> = (oprops) => {
const [stack, mutStack] = createStore([] as StackFrame[], { name: "stack" });
const pushFrame = (path: string, opts?: Readonly<NewFrameOptions<any>>) =>
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 (
<NavigatorContext.Provider
value={{
push: pushFrame,
pop: popFrame,
frames: stack,
}}
>
<Index each={stack}>
{(frame, index) => {
const currentFrame = () => {
return {
index,
frame: frame(),
};
};
return (
<CurrentFrameContext.Provider value={currentFrame}>
<Show
when={index !== 0}
fallback={
<div
class="StackedPage"
id={frame().rootId}
role="presentation"
>
<StaticRouter url={frame().path} {...oprops} />
</div>
}
>
<dialog
ref={onBeforeDialogMount}
class="StackedPage"
onCancel={[popFrame, 1]}
onClick={[onDialogClick, popFrame]}
onTouchStart={onDialogTouchStart}
onTouchMove={onDialogTouchMove}
onTouchEnd={onDialogTouchEnd}
onTouchCancel={onDialogTouchCancel}
id={frame().rootId}
>
<StaticRouter url={frame().path} {...oprops} />
</dialog>
</Show>
</CurrentFrameContext.Provider>
);
}}
</Index>
</NavigatorContext.Provider>
);
};
export default StackedRouter;

View file

@ -1,4 +1,6 @@
.Profile {
height: 100%;
.intro {
background-color: var(--tutu-color-surface-d);
color: var(--tutu-color-on-surface);

View file

@ -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)",
}}
>
<IconButton
color="inherit"
onClick={[navigate, -1]}
aria-label="Close"
>
<IconButton color="inherit" onClick={[pop, 1]} aria-label="Close">
<Close />
</IconButton>
<Title

View file

@ -24,10 +24,10 @@ import { Title } from "../material/typography";
import type { Template } from "@solid-primitives/i18n";
import { useStore } from "@nanostores/solid";
import { $settings } from "./stores";
import { useNavigate } from "@solidjs/router";
import { useNavigator } from "../platform/StackedRouter";
const ChooseLang: Component = () => {
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 (
<Scaffold
@ -59,7 +59,7 @@ const ChooseLang: Component = () => {
variant="dense"
sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }}
>
<IconButton color="inherit" onClick={[navigate, -1]} disableRipple>
<IconButton color="inherit" onClick={[pop, 1]} disableRipple>
<ArrowBack />
</IconButton>
<Title>{t("Choose Language")}</Title>
@ -96,7 +96,10 @@ const ChooseLang: Component = () => {
<ListItemText>{t(`lang.${c}`)}</ListItemText>
<ListItemSecondaryAction>
<Radio
checked={code() === c || (code() === undefined && matchedLangCode() == c)}
checked={
code() === c ||
(code() === undefined && matchedLangCode() == c)
}
/>
</ListItemSecondaryAction>
</ListItemButton>

View file

@ -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)" }}
>
<IconButton color="inherit" onClick={[navigate, -1]} disableRipple>
<IconButton color="inherit" onClick={[pop, 1]} disableRipple>
<ArrowBack />
</IconButton>
<Title>{t("motions")}</Title>

View file

@ -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)" }}
>
<IconButton color="inherit" onClick={[navigate, -1]} disableRipple>
<IconButton color="inherit" onClick={[pop, 1]} disableRipple>
<ArrowBack />
</IconButton>
<Title>{t("Choose Region")}</Title>

View file

@ -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<string, string | undefined>;
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)" }}
>
<IconButton color="inherit" onClick={[navigate, -1]} disableRipple>
<IconButton color="inherit" onClick={[pop, 1]} disableRipple>
<CloseIcon />
</IconButton>
<Title>{t("Settings")}</Title>
@ -208,10 +203,6 @@ const Settings: ParentComponent = (props) => {
</AppBar>
}
>
<BottomSheet open={!!subpage()} onClose={() => navigate(-1)}>
{subpage()}
</BottomSheet>
<List class="setting-list" use:solid-styled>
<li>
<ul>

View file

@ -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<HeroSource>({});
const [panelOffset, setPanelOffset] = createSignal(0);
const prefetching = () => !settings$().prefetchTootsDisabled;
const [currentFocusOn, setCurrentFocusOn] = createSignal<HTMLElement[]>([]);
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)" }}
>
<Tabs onFocusChanged={setCurrentFocusOn} offset={panelOffset()}>
<Tabs>
<Tab focus={isTabFocus(0)} onClick={[onTabClick, 0]}>
Home
</Tab>
@ -239,48 +195,40 @@ const Home: ParentComponent = (props) => {
</AppBar>
}
>
<HeroSourceProvider value={[heroSrc, setHeroSrc]}>
<TimeSourceProvider value={now}>
<Show when={!!client()}>
<div class="panel-list" ref={panelList!}>
<div class="tab-panel">
<div>
<TimelinePanel
client={client()}
name="home"
prefetch={prefetching()}
openFullScreenToot={openFullScreenToot}
/>
</div>
<TimeSourceProvider value={now}>
<Show when={!!client()}>
<div
class="panel-list"
ref={panelList!}
onScroll={requestRecalculateTabIndicator}
>
<div class="tab-panel">
<div>
<TimelinePanel
client={client()}
name="home"
prefetch={prefetching()}
/>
</div>
<div class="tab-panel">
<div>
<TrendTimelinePanel
client={client()}
openFullScreenToot={openFullScreenToot}
/>
</div>
</div>
<div class="tab-panel">
<div>
<TimelinePanel
client={client()}
name="public"
prefetch={prefetching()}
openFullScreenToot={openFullScreenToot}
/>
</div>
</div>
<div></div>
</div>
</Show>
</TimeSourceProvider>
<Suspense>
<BottomSheet open={!!child()} onClose={() => navigate(-1)}>
{child()}
</BottomSheet>
</Suspense>
</HeroSourceProvider>
<div class="tab-panel">
<div>
<TrendTimelinePanel client={client()} />
</div>
</div>
<div class="tab-panel">
<div>
<TimelinePanel
client={client()}
name="public"
prefetch={prefetching()}
/>
</div>
</div>
<div></div>
</div>
</Show>
</TimeSourceProvider>
</Scaffold>
</>
);

View file

@ -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}
<Divider />
</Show>
<MenuItem component={A} href="/settings" onClick={onClose}>
<MenuItem component={A} href="/settings">
<ListItemIcon>
<SettingsIcon />
</ListItemIcon>

View file

@ -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);

View file

@ -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<HTMLElement>();

View file

@ -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 {

View file

@ -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)" }}
>
<IconButton color="inherit" onClick={[navigate, -1]} disableRipple>
<CloseIcon />
</IconButton>
<BackButton color="inherit" />
<Title component="div" class="name" use:solid-styled>
<span
ref={(e: HTMLElement) =>
@ -246,9 +245,7 @@ const TootBottomSheet: Component = (props) => {
}
class="TootBottomSheet"
>
<div
class="Scrollable"
>
<div class="Scrollable">
<TimeSourceProvider value={time}>
<TootList
threads={ancestors.list}
@ -288,9 +285,7 @@ const TootBottomSheet: Component = (props) => {
</Show>
<Show when={tootContextErrorUncaught.loading}>
<div
class="progress-line"
>
<div class="progress-line">
<CircularProgress style="width: 1.5em; height: 1.5em;" />
</div>
</Show>

View file

@ -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<string>();
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 {

View file

@ -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<HTMLElement>();
const [tl, snapshot, { refetch: refetchTimeline }] = createTimelineSnapshot(