Merge pull request 'StackedRouter: new router simulates app behaviour' (#45) from stacky into master
All checks were successful
/ depoly (push) Successful in 1m20s

Reviewed-on: #45
This commit is contained in:
Rubicon 2024-11-18 10:35:30 +00:00
commit 1a7a52da22
25 changed files with 775 additions and 236 deletions

View file

@ -27,6 +27,7 @@ import {
import { Service } from "./serviceworker/services.js"; import { Service } from "./serviceworker/services.js";
import { makeEventListener } from "@solid-primitives/event-listener"; import { makeEventListener } from "@solid-primitives/event-listener";
import { ServiceWorkerProvider } from "./platform/host.js"; import { ServiceWorkerProvider } from "./platform/host.js";
import StackedRouter from "./platform/StackedRouter.js";
const AccountSignIn = lazy(() => import("./accounts/SignIn.js")); const AccountSignIn = lazy(() => import("./accounts/SignIn.js"));
const AccountMastodonOAuth2Callback = lazy( const AccountMastodonOAuth2Callback = lazy(
@ -37,24 +38,21 @@ const Settings = lazy(() => import("./settings/Settings.js"));
const TootBottomSheet = lazy(() => import("./timelines/TootBottomSheet.js")); const TootBottomSheet = lazy(() => import("./timelines/TootBottomSheet.js"));
const MotionSettings = lazy(() => import("./settings/Motions.js")); const MotionSettings = lazy(() => import("./settings/Motions.js"));
const LanguageSettings = lazy(() => import("./settings/Language.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 UnexpectedError = lazy(() => import("./UnexpectedError.js"));
const Profile = lazy(() => import("./profiles/Profile.js")); const Profile = lazy(() => import("./profiles/Profile.js"));
const Routing: Component = () => { const Routing: Component = () => {
return ( return (
<Router> <StackedRouter>
<Route path="/" component={TimelineHome}> <Route path="/" component={TimelineHome} />
<Route path=""></Route> <Route path="/settings/language" component={LanguageSettings} />
<Route path="/settings" component={Settings}> <Route path="/settings/region" component={RegionSettings} />
<Route path=""></Route> <Route path="/settings/motions" component={MotionSettings} />
<Route path="/language" component={LanguageSettings}></Route> <Route path="/settings" component={Settings} />
<Route path="/region" component={RegionSettings}></Route> <Route path="/:acct/toot/:id" component={TootBottomSheet} />
<Route path="/motions" component={MotionSettings}></Route> <Route path="/:acct/profile/:id" component={Profile} />
</Route>
<Route path="/:acct/toot/:id" component={TootBottomSheet}></Route>
<Route path="/:acct/profile/:id" component={Profile}></Route>
</Route>
<Route path={"/accounts"}> <Route path={"/accounts"}>
<Route path={"/sign-in"} component={AccountSignIn} /> <Route path={"/sign-in"} component={AccountSignIn} />
<Route <Route
@ -62,7 +60,7 @@ const Routing: Component = () => {
component={AccountMastodonOAuth2Callback} component={AccountMastodonOAuth2Callback}
/> />
</Route> </Route>
</Router> </StackedRouter>
); );
}; };
@ -70,7 +68,9 @@ const App: Component = () => {
const theme = useRootTheme(); const theme = useRootTheme();
const accts = useStore($accounts); const accts = useStore($accounts);
const lang = useLanguage(); const lang = useLanguage();
const [serviceWorker, setServiceWorker] = createSignal<ServiceWorker>(); const [serviceWorker, setServiceWorker] = createSignal<
ServiceWorker | undefined
>(undefined, { name: "serviceWorker" });
const dispatcher = new ResultDispatcher(); const dispatcher = new ResultDispatcher();
let checkAge = 0; let checkAge = 0;

View file

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

View file

@ -1,4 +1,4 @@
import { useNavigate, useSearchParams } from "@solidjs/router"; import { useSearchParams } from "@solidjs/router";
import { import {
Component, Component,
Show, Show,
@ -14,6 +14,7 @@ import { LinearProgress } from "@suid/material";
import Img from "../material/Img"; import Img from "../material/Img";
import { createRestAPIClient } from "masto"; import { createRestAPIClient } from "masto";
import { Title } from "../material/typography"; import { Title } from "../material/typography";
import { useNavigator } from "../platform/StackedRouter";
type OAuth2CallbackParams = { type OAuth2CallbackParams = {
code?: string; code?: string;
@ -25,7 +26,7 @@ const MastodonOAuth2Callback: Component = () => {
const progressId = createUniqueId(); const progressId = createUniqueId();
const titleId = createUniqueId(); const titleId = createUniqueId();
const [params] = useSearchParams<OAuth2CallbackParams>(); const [params] = useSearchParams<OAuth2CallbackParams>();
const navigate = useNavigate(); const { push: navigate } = useNavigator();
const setDocumentTitle = useDocumentTitle("Back from Mastodon..."); const setDocumentTitle = useDocumentTitle("Back from Mastodon...");
const [siteImg, setSiteImg] = createSignal<{ const [siteImg, setSiteImg] = createSignal<{
src: string; src: string;

View file

@ -8,7 +8,8 @@ import {
} from "solid-js"; } from "solid-js";
import { Account } from "../accounts/stores"; import { Account } from "../accounts/stores";
import { createRestAPIClient, mastodon } from "masto"; 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> = {}; const restfulCache: Record<string, mastodon.rest.Client> = {};
@ -56,12 +57,12 @@ export const Provider = Context.Provider;
export function useSessions() { export function useSessions() {
const sessions = useSessionsRaw(); const sessions = useSessionsRaw();
const navigate = useNavigate(); const {push} = useNavigator();
const location = useLocation(); const location = useLocation();
createRenderEffect(() => { createRenderEffect(() => {
if (sessions().length > 0) return; if (sessions().length > 0) return;
navigate( push(
"/accounts/sign-in?back=" + encodeURIComponent(location.pathname), "/accounts/sign-in?back=" + encodeURIComponent(location.pathname),
{ replace: true }, { replace: true },
); );

View file

@ -25,22 +25,6 @@
box-shadow: var(--tutu-shadow-e16); 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) { @media (max-width: 560px) {
& { & {
left: 0; left: 0;

View file

@ -1,22 +1,42 @@
.Scaffold>.topbar {
.Scaffold__topbar {
position: sticky; position: sticky;
top: 0px; top: 0px;
z-index: var(--tutu-zidx-nav, auto); 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; position: fixed;
bottom: 40px; bottom: 40px;
right: 40px; right: 40px;
z-index: var(--tutu-zidx-nav, auto); z-index: var(--tutu-zidx-nav, auto);
} }
.Scaffold__bottom-dock { .Scaffold>.bottom-dock {
position: sticky; position: sticky;
bottom: 0; bottom: 0;
left: 0; left: 0;
right: 0; right: 0;
z-index: var(--tutu-zidx-nav, auto); z-index: var(--tutu-zidx-nav, auto);
padding-bottom: var(--safe-area-inset-bottom, 0); 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", "bottom",
"children", "children",
"ref", "ref",
"class",
]); ]);
const [topbarElement, setTopbarElement] = createSignal<HTMLElement>(); const [topbarElement, setTopbarElement] = createSignal<HTMLElement>();
const topbarSize = createElementSize(topbarElement); const topbarSize = createElementSize(topbarElement);
return ( 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}> <Show when={props.topbar}>
<div class="Scaffold__topbar" ref={setTopbarElement} role="presentation"> <div class="topbar" ref={setTopbarElement} role="presentation">
{props.topbar} {props.topbar}
</div> </div>
</Show> </Show>
<Show when={props.fab}> <Show when={props.fab}>
<div class="Scaffold__fab-dock" role="presentation">{props.fab}</div> <div class="fab-dock" role="presentation">
{props.fab}
</div>
</Show> </Show>
<div
ref={(e) => {
createRenderEffect(() => {
e.style.setProperty(
"--scaffold-topbar-height",
(topbarSize.height?.toString() ?? 0) + "px",
);
});
if (managed.ref) { {managed.children}
(managed.ref as (val: typeof e) => void)(e);
}
}}
{...rest}
>
{managed.children}
</div>
<Show when={props.bottom}> <Show when={props.bottom}>
<div class="Scaffold__bottom-dock" role="presentation">{props.bottom}</div> <div class="bottom-dock" role="presentation">
{props.bottom}
</div>
</Show> </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 { .Profile {
height: 100%;
.intro { .intro {
background-color: var(--tutu-color-surface-d); background-color: var(--tutu-color-surface-d);
color: var(--tutu-color-on-surface); color: var(--tutu-color-on-surface);

View file

@ -45,7 +45,7 @@ import {
Verified, Verified,
} from "@suid/icons-material"; } from "@suid/icons-material";
import { Body2, Title } from "../material/typography"; import { Body2, Title } from "../material/typography";
import { useNavigate, useParams } from "@solidjs/router"; import { useParams } from "@solidjs/router";
import { useSessionForAcctStr } from "../masto/clients"; import { useSessionForAcctStr } from "../masto/clients";
import { resolveCustomEmoji } from "../masto/toot"; import { resolveCustomEmoji } from "../masto/toot";
import { FastAverageColor } from "fast-average-color"; import { FastAverageColor } from "fast-average-color";
@ -57,9 +57,10 @@ import TootFilterButton from "./TootFilterButton";
import Menu, { createManagedMenuState } from "../material/Menu"; import Menu, { createManagedMenuState } from "../material/Menu";
import { share } from "../platform/share"; import { share } from "../platform/share";
import "./Profile.css"; import "./Profile.css";
import { useNavigator } from "../platform/StackedRouter";
const Profile: Component = () => { const Profile: Component = () => {
const navigate = useNavigate(); const { pop } = useNavigator();
const params = useParams<{ acct: string; id: string }>(); const params = useParams<{ acct: string; id: string }>();
const acctText = () => decodeURIComponent(params.acct); const acctText = () => decodeURIComponent(params.acct);
const session = useSessionForAcctStr(acctText); const session = useSessionForAcctStr(acctText);
@ -209,11 +210,7 @@ const Profile: Component = () => {
paddingTop: "var(--safe-area-inset-top)", paddingTop: "var(--safe-area-inset-top)",
}} }}
> >
<IconButton <IconButton color="inherit" onClick={[pop, 1]} aria-label="Close">
color="inherit"
onClick={[navigate, -1]}
aria-label="Close"
>
<Close /> <Close />
</IconButton> </IconButton>
<Title <Title

View file

@ -24,10 +24,10 @@ import { Title } from "../material/typography";
import type { Template } from "@solid-primitives/i18n"; import type { Template } from "@solid-primitives/i18n";
import { useStore } from "@nanostores/solid"; import { useStore } from "@nanostores/solid";
import { $settings } from "./stores"; import { $settings } from "./stores";
import { useNavigate } from "@solidjs/router"; import { useNavigator } from "../platform/StackedRouter";
const ChooseLang: Component = () => { const ChooseLang: Component = () => {
const navigate = useNavigate() const { pop } = useNavigator();
const [t] = createTranslator( const [t] = createTranslator(
() => import("./i18n/lang-names.json"), () => import("./i18n/lang-names.json"),
(code) => (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(() => { const unsupportedLangCodes = createMemo(() => {
return iso639_1.getAllCodes().filter((x) => !["zh", "en"].includes(x)); return iso639_1.getAllCodes().filter((x) => !["zh", "en"].includes(x));
@ -48,8 +48,8 @@ const ChooseLang: Component = () => {
const matchedLangCode = createMemo(() => autoMatchLangTag()); const matchedLangCode = createMemo(() => autoMatchLangTag());
const onCodeChange = (code?: string) => { const onCodeChange = (code?: string) => {
$settings.setKey("language", code) $settings.setKey("language", code);
} };
return ( return (
<Scaffold <Scaffold
@ -59,7 +59,7 @@ const ChooseLang: Component = () => {
variant="dense" variant="dense"
sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }} sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }}
> >
<IconButton color="inherit" onClick={[navigate, -1]} disableRipple> <IconButton color="inherit" onClick={[pop, 1]} disableRipple>
<ArrowBack /> <ArrowBack />
</IconButton> </IconButton>
<Title>{t("Choose Language")}</Title> <Title>{t("Choose Language")}</Title>
@ -96,7 +96,10 @@ const ChooseLang: Component = () => {
<ListItemText>{t(`lang.${c}`)}</ListItemText> <ListItemText>{t(`lang.${c}`)}</ListItemText>
<ListItemSecondaryAction> <ListItemSecondaryAction>
<Radio <Radio
checked={code() === c || (code() === undefined && matchedLangCode() == c)} checked={
code() === c ||
(code() === undefined && matchedLangCode() == c)
}
/> />
</ListItemSecondaryAction> </ListItemSecondaryAction>
</ListItemButton> </ListItemButton>

View file

@ -13,14 +13,14 @@ import {
Toolbar, Toolbar,
} from "@suid/material"; } from "@suid/material";
import { Title } from "../material/typography"; import { Title } from "../material/typography";
import { useNavigate } from "@solidjs/router";
import { ArrowBack } from "@suid/icons-material"; import { ArrowBack } from "@suid/icons-material";
import { createTranslator } from "../platform/i18n"; import { createTranslator } from "../platform/i18n";
import { useStore } from "@nanostores/solid"; import { useStore } from "@nanostores/solid";
import { $settings } from "./stores"; import { $settings } from "./stores";
import { useNavigator } from "../platform/StackedRouter";
const Motions: Component = () => { const Motions: Component = () => {
const navigate = useNavigate(); const {pop} = useNavigator();
const [t] = createTranslator( const [t] = createTranslator(
(code) => (code) =>
import(`./i18n/${code}.json`) as Promise<{ import(`./i18n/${code}.json`) as Promise<{
@ -36,7 +36,7 @@ const Motions: Component = () => {
variant="dense" variant="dense"
sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }} sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }}
> >
<IconButton color="inherit" onClick={[navigate, -1]} disableRipple> <IconButton color="inherit" onClick={[pop, 1]} disableRipple>
<ArrowBack /> <ArrowBack />
</IconButton> </IconButton>
<Title>{t("motions")}</Title> <Title>{t("motions")}</Title>

View file

@ -20,12 +20,12 @@ import {
} from "../platform/i18n"; } from "../platform/i18n";
import { Title } from "../material/typography"; import { Title } from "../material/typography";
import type { Template } from "@solid-primitives/i18n"; import type { Template } from "@solid-primitives/i18n";
import { useNavigate } from "@solidjs/router";
import { $settings } from "./stores"; import { $settings } from "./stores";
import { useStore } from "@nanostores/solid"; import { useStore } from "@nanostores/solid";
import { useNavigator } from "../platform/StackedRouter";
const ChooseRegion: Component = () => { const ChooseRegion: Component = () => {
const navigate = useNavigate(); const {pop} = useNavigator();
const [t] = createTranslator( const [t] = createTranslator(
() => import("./i18n/lang-names.json"), () => import("./i18n/lang-names.json"),
(code) => (code) =>
@ -54,7 +54,7 @@ const ChooseRegion: Component = () => {
variant="dense" variant="dense"
sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }} sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }}
> >
<IconButton color="inherit" onClick={[navigate, -1]} disableRipple> <IconButton color="inherit" onClick={[pop, 1]} disableRipple>
<ArrowBack /> <ArrowBack />
</IconButton> </IconButton>
<Title>{t("Choose Region")}</Title> <Title>{t("Choose Region")}</Title>

View file

@ -1,10 +1,7 @@
import { import {
children,
createSignal,
For, For,
Show, Show,
type JSX, type Component,
type ParentComponent,
} from "solid-js"; } from "solid-js";
import Scaffold from "../material/Scaffold.js"; import Scaffold from "../material/Scaffold.js";
import { import {
@ -30,7 +27,7 @@ import {
Refresh as RefreshIcon, Refresh as RefreshIcon,
Translate as TranslateIcon, Translate as TranslateIcon,
} from "@suid/icons-material"; } from "@suid/icons-material";
import { A, useNavigate } from "@solidjs/router"; import A from "../platform/A.js";
import { Title } from "../material/typography.jsx"; import { Title } from "../material/typography.jsx";
import { css } from "solid-styled"; import { css } from "solid-styled";
import { signOut, type Account } from "../accounts/stores.js"; import { signOut, type Account } from "../accounts/stores.js";
@ -44,9 +41,9 @@ import {
useDateFnLocale, useDateFnLocale,
} from "../platform/i18n.jsx"; } from "../platform/i18n.jsx";
import { type Template } from "@solid-primitives/i18n"; import { type Template } from "@solid-primitives/i18n";
import BottomSheet from "../material/BottomSheet.jsx";
import { useServiceWorker } from "../platform/host.js"; import { useServiceWorker } from "../platform/host.js";
import { useSessions } from "../masto/clients.js"; import { useSessions } from "../masto/clients.js";
import { useNavigator } from "../platform/StackedRouter.jsx";
type Inset = { type Inset = {
top?: number; top?: number;
@ -162,7 +159,7 @@ type Strings = {
["lang.auto"]: Template<{ detected: string }>; ["lang.auto"]: Template<{ detected: string }>;
} & Record<string, string | undefined>; } & Record<string, string | undefined>;
const Settings: ParentComponent = (props) => { const Settings: Component = () => {
const [t] = createTranslator( const [t] = createTranslator(
(code) => (code) =>
import(`./i18n/${code}.json`) as Promise<{ import(`./i18n/${code}.json`) as Promise<{
@ -170,9 +167,9 @@ const Settings: ParentComponent = (props) => {
}>, }>,
() => import(`./i18n/lang-names.json`), () => import(`./i18n/lang-names.json`),
); );
const navigate = useNavigate(); const {pop} = useNavigator();
const settings$ = useStore($settings); const settings$ = useStore($settings);
const { needRefresh, offlineReady } = useServiceWorker(); const { needRefresh } = useServiceWorker();
const dateFnLocale = useDateFnLocale(); const dateFnLocale = useDateFnLocale();
const profiles = useSessions(); const profiles = useSessions();
@ -181,8 +178,6 @@ const Settings: ParentComponent = (props) => {
signOut((a) => a.site === acct.site && a.accessToken === acct.accessToken); signOut((a) => a.site === acct.site && a.accessToken === acct.accessToken);
}; };
const subpage = children(() => props.children);
css` css`
ul { ul {
padding: 0; padding: 0;
@ -200,7 +195,7 @@ const Settings: ParentComponent = (props) => {
variant="dense" variant="dense"
sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }} sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }}
> >
<IconButton color="inherit" onClick={[navigate, -1]} disableRipple> <IconButton color="inherit" onClick={[pop, 1]} disableRipple>
<CloseIcon /> <CloseIcon />
</IconButton> </IconButton>
<Title>{t("Settings")}</Title> <Title>{t("Settings")}</Title>
@ -208,10 +203,6 @@ const Settings: ParentComponent = (props) => {
</AppBar> </AppBar>
} }
> >
<BottomSheet open={!!subpage()} onClose={() => navigate(-1)}>
{subpage()}
</BottomSheet>
<List class="setting-list" use:solid-styled> <List class="setting-list" use:solid-styled>
<li> <li>
<ul> <ul>

View file

@ -3,11 +3,9 @@ import {
Show, Show,
onMount, onMount,
type ParentComponent, type ParentComponent,
children, createRenderEffect,
Suspense,
} from "solid-js"; } from "solid-js";
import { useDocumentTitle } from "../utils"; import { useDocumentTitle } from "../utils";
import { type mastodon } from "masto";
import Scaffold from "../material/Scaffold"; import Scaffold from "../material/Scaffold";
import { import {
AppBar, AppBar,
@ -23,14 +21,8 @@ import ProfileMenuButton from "./ProfileMenuButton";
import Tabs from "../material/Tabs"; import Tabs from "../material/Tabs";
import Tab from "../material/Tab"; import Tab from "../material/Tab";
import { makeEventListener } from "@solid-primitives/event-listener"; import { makeEventListener } from "@solid-primitives/event-listener";
import BottomSheet, {
HERO as BOTTOM_SHEET_HERO,
} from "../material/BottomSheet";
import { $settings } from "../settings/stores"; import { $settings } from "../settings/stores";
import { useStore } from "@nanostores/solid"; 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 TrendTimelinePanel from "./TrendTimelinePanel";
import TimelinePanel from "./TimelinePanel"; import TimelinePanel from "./TimelinePanel";
import { useSessions } from "../masto/clients"; import { useSessions } from "../masto/clients";
@ -43,29 +35,17 @@ const Home: ParentComponent = (props) => {
const settings$ = useStore($settings); const settings$ = useStore($settings);
const profiles = useSessions(); const profiles = useSessions();
const profile = () => {
const all = profiles();
if (all.length > 0) {
return all[0].account.inf;
}
};
const client = () => { const client = () => {
const all = profiles(); const all = profiles();
return all?.[0]?.client; return all?.[0]?.client;
}; };
const navigate = useNavigate();
const [heroSrc, setHeroSrc] = createSignal<HeroSource>({});
const [panelOffset, setPanelOffset] = createSignal(0);
const prefetching = () => !settings$().prefetchTootsDisabled; const prefetching = () => !settings$().prefetchTootsDisabled;
const [currentFocusOn, setCurrentFocusOn] = createSignal<HTMLElement[]>([]);
const [focusRange, setFocusRange] = createSignal([0, 0] as readonly [ const [focusRange, setFocusRange] = createSignal([0, 0] as readonly [
number, number,
number, number,
]); ]);
const child = children(() => props.children);
let scrollEventLockReleased = true; let scrollEventLockReleased = true;
const recalculateTabIndicator = () => { const recalculateTabIndicator = () => {
@ -102,17 +82,17 @@ const Home: ParentComponent = (props) => {
} }
}; };
const requestRecalculateTabIndicator = () => {
if (scrollEventLockReleased) {
requestAnimationFrame(recalculateTabIndicator);
}
};
createRenderEffect(() => {
makeEventListener(window, "resize", requestRecalculateTabIndicator);
});
onMount(() => { onMount(() => {
makeEventListener(panelList, "scroll", () => {
if (scrollEventLockReleased) {
requestAnimationFrame(recalculateTabIndicator);
}
});
makeEventListener(window, "resize", () => {
if (scrollEventLockReleased) {
requestAnimationFrame(recalculateTabIndicator);
}
});
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` css`
.tab-panel { .tab-panel {
@ -209,7 +165,7 @@ const Home: ParentComponent = (props) => {
class="responsive" class="responsive"
sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }} sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }}
> >
<Tabs onFocusChanged={setCurrentFocusOn} offset={panelOffset()}> <Tabs>
<Tab focus={isTabFocus(0)} onClick={[onTabClick, 0]}> <Tab focus={isTabFocus(0)} onClick={[onTabClick, 0]}>
Home Home
</Tab> </Tab>
@ -239,48 +195,40 @@ const Home: ParentComponent = (props) => {
</AppBar> </AppBar>
} }
> >
<HeroSourceProvider value={[heroSrc, setHeroSrc]}> <TimeSourceProvider value={now}>
<TimeSourceProvider value={now}> <Show when={!!client()}>
<Show when={!!client()}> <div
<div class="panel-list" ref={panelList!}> class="panel-list"
<div class="tab-panel"> ref={panelList!}
<div> onScroll={requestRecalculateTabIndicator}
<TimelinePanel >
client={client()} <div class="tab-panel">
name="home" <div>
prefetch={prefetching()} <TimelinePanel
openFullScreenToot={openFullScreenToot} client={client()}
/> name="home"
</div> prefetch={prefetching()}
/>
</div> </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> </div>
</Show> <div class="tab-panel">
</TimeSourceProvider> <div>
<Suspense> <TrendTimelinePanel client={client()} />
<BottomSheet open={!!child()} onClose={() => navigate(-1)}> </div>
{child()} </div>
</BottomSheet> <div class="tab-panel">
</Suspense> <div>
</HeroSourceProvider> <TimelinePanel
client={client()}
name="public"
prefetch={prefetching()}
/>
</div>
</div>
<div></div>
</div>
</Show>
</TimeSourceProvider>
</Scaffold> </Scaffold>
</> </>
); );

View file

@ -21,7 +21,7 @@ import {
Star as LikeIcon, Star as LikeIcon,
FeaturedPlayList as ListIcon, FeaturedPlayList as ListIcon,
} from "@suid/icons-material"; } from "@suid/icons-material";
import { A } from "@solidjs/router"; import A from "../platform/A";
const ProfileMenuButton: ParentComponent<{ const ProfileMenuButton: ParentComponent<{
profile?: { profile?: {
@ -51,7 +51,7 @@ const ProfileMenuButton: ParentComponent<{
props.onClick?.(); props.onClick?.();
}; };
const inf = () => props.profile?.account.inf const inf = () => props.profile?.account.inf;
const onClose = () => { const onClose = () => {
props.onClick?.(); props.onClick?.();
@ -130,7 +130,7 @@ const ProfileMenuButton: ParentComponent<{
{props.children} {props.children}
<Divider /> <Divider />
</Show> </Show>
<MenuItem component={A} href="/settings" onClick={onClose}> <MenuItem component={A} href="/settings">
<ListItemIcon> <ListItemIcon>
<SettingsIcon /> <SettingsIcon />
</ListItemIcon> </ListItemIcon>

View file

@ -10,6 +10,7 @@ import { Refresh as RefreshIcon } from "@suid/icons-material";
import { CircularProgress } from "@suid/material"; import { CircularProgress } from "@suid/material";
import { makeEventListener } from "@solid-primitives/event-listener"; import { makeEventListener } from "@solid-primitives/event-listener";
import { createVisibilityObserver } from "@solid-primitives/intersection-observer"; import { createVisibilityObserver } from "@solid-primitives/intersection-observer";
import { useMaybeIsFrameSuspended } from "../platform/StackedRouter";
const PullDownToRefresh: Component<{ const PullDownToRefresh: Component<{
loading?: boolean; loading?: boolean;
@ -33,6 +34,7 @@ const PullDownToRefresh: Component<{
}); });
const rootVisible = obvx(() => rootElement); const rootVisible = obvx(() => rootElement);
const isFrameSuspended = useMaybeIsFrameSuspended()
createEffect(() => { createEffect(() => {
if (!rootVisible()) setPullDown(0); if (!rootVisible()) setPullDown(0);
@ -109,6 +111,9 @@ const PullDownToRefresh: Component<{
if (!rootVisible()) { if (!rootVisible()) {
return; return;
} }
if (isFrameSuspended()) {
return;
}
const element = props.linkedElement; const element = props.linkedElement;
if (!element) return; if (!element) return;
makeEventListener(element, "wheel", handleLinkedWheel); makeEventListener(element, "wheel", handleLinkedWheel);
@ -159,6 +164,9 @@ const PullDownToRefresh: Component<{
if (!rootVisible()) { if (!rootVisible()) {
return; return;
} }
if (isFrameSuspended()) {
return;
}
const element = props.linkedElement; const element = props.linkedElement;
if (!element) return; if (!element) return;
makeEventListener(element, "touchmove", handleTouch); makeEventListener(element, "touchmove", handleTouch);

View file

@ -20,12 +20,6 @@ const TimelinePanel: Component<{
client: mastodon.rest.Client; client: mastodon.rest.Client;
name: "home" | "public"; name: "home" | "public";
prefetch?: boolean; prefetch?: boolean;
openFullScreenToot: (
toot: mastodon.v1.Status,
srcElement?: HTMLElement,
reply?: boolean,
) => void;
}> = (props) => { }> = (props) => {
const [scrollLinked, setScrollLinked] = createSignal<HTMLElement>(); const [scrollLinked, setScrollLinked] = createSignal<HTMLElement>();

View file

@ -1,12 +1,11 @@
.TootBottomSheet { .TootBottomSheet {
overflow: hidden; overflow: hidden;
height: calc(100% - var(--scaffold-topbar-height, 0px));
.Scrollable { .Scrollable {
padding-bottom: var(--safe-area-inset-bottom, 0); padding-bottom: var(--safe-area-inset-bottom, 0);
overflow-y: auto; overflow-y: auto;
overscroll-behavior-y: contain; overscroll-behavior-y: contain;
height: 100%; height: calc(100% - var(--scaffold-topbar-height, 0px));
} }
.progress-line { .progress-line {

View file

@ -1,10 +1,9 @@
import { useLocation, useNavigate, useParams } from "@solidjs/router"; import { useLocation, useParams } from "@solidjs/router";
import { import {
catchError, catchError,
createEffect, createEffect,
createRenderEffect, createRenderEffect,
createResource, createResource,
createSignal,
Show, Show,
type Component, type Component,
} from "solid-js"; } from "solid-js";
@ -25,6 +24,8 @@ import { useDocumentTitle } from "../utils";
import { createTimelineControlsForArray } from "../masto/timelines"; import { createTimelineControlsForArray } from "../masto/timelines";
import TootList from "./TootList"; import TootList from "./TootList";
import "./TootBottomSheet.css"; import "./TootBottomSheet.css";
import { useNavigator } from "../platform/StackedRouter";
import BackButton from "../platform/BackButton";
let cachedEntry: [string, mastodon.v1.Status] | undefined; let cachedEntry: [string, mastodon.v1.Status] | undefined;
@ -43,7 +44,7 @@ const TootBottomSheet: Component = (props) => {
const location = useLocation<{ const location = useLocation<{
tootReply?: boolean; tootReply?: boolean;
}>(); }>();
const navigate = useNavigate(); const { pop, push } = useNavigator();
const time = createTimeSource(); const time = createTimeSource();
const acctText = () => decodeURIComponent(params.acct); const acctText = () => decodeURIComponent(params.acct);
const session = useSessionForAcctStr(acctText); const session = useSessionForAcctStr(acctText);
@ -186,7 +187,7 @@ const TootBottomSheet: Component = (props) => {
target.dataset.client || `@${new URL(target.href).origin}`, target.dataset.client || `@${new URL(target.href).origin}`,
); );
navigate(`/${acct}/profile/${target.dataset.acctId}`); push(`/${acct}/profile/${target.dataset.acctId}`);
return; return;
} else { } else {
@ -228,9 +229,7 @@ const TootBottomSheet: Component = (props) => {
variant="dense" variant="dense"
sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }} sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }}
> >
<IconButton color="inherit" onClick={[navigate, -1]} disableRipple> <BackButton color="inherit" />
<CloseIcon />
</IconButton>
<Title component="div" class="name" use:solid-styled> <Title component="div" class="name" use:solid-styled>
<span <span
ref={(e: HTMLElement) => ref={(e: HTMLElement) =>
@ -246,9 +245,7 @@ const TootBottomSheet: Component = (props) => {
} }
class="TootBottomSheet" class="TootBottomSheet"
> >
<div <div class="Scrollable">
class="Scrollable"
>
<TimeSourceProvider value={time}> <TimeSourceProvider value={time}>
<TootList <TootList
threads={ancestors.list} threads={ancestors.list}
@ -288,9 +285,7 @@ const TootBottomSheet: Component = (props) => {
</Show> </Show>
<Show when={tootContextErrorUncaught.loading}> <Show when={tootContextErrorUncaught.loading}>
<div <div class="progress-line">
class="progress-line"
>
<CircularProgress style="width: 1.5em; height: 1.5em;" /> <CircularProgress style="width: 1.5em; height: 1.5em;" />
</div> </div>
</Show> </Show>

View file

@ -13,13 +13,28 @@ import { useDefaultSession } from "../masto/clients";
import { useHeroSource } from "../platform/anim"; import { useHeroSource } from "../platform/anim";
import { HERO as BOTTOM_SHEET_HERO } from "../material/BottomSheet"; import { HERO as BOTTOM_SHEET_HERO } from "../material/BottomSheet";
import { setCache as setTootBottomSheetCache } from "./TootBottomSheet"; import { setCache as setTootBottomSheetCache } from "./TootBottomSheet";
import { useNavigate } from "@solidjs/router";
import RegularToot, { import RegularToot, {
findElementActionable, findElementActionable,
findRootToot, findRootToot,
} from "./RegularToot"; } from "./RegularToot";
import cardStyle from "../material/cards.module.css"; import cardStyle from "../material/cards.module.css";
import type { ThreadNode } from "../masto/timelines"; 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) { function positionTootInThread(index: number, threadLength: number) {
if (index === 0) { if (index === 0) {
@ -40,7 +55,7 @@ const TootList: Component<{
const session = useDefaultSession(); const session = useDefaultSession();
const heroSrc = useHeroSource(); const heroSrc = useHeroSource();
const [expandedThreadId, setExpandedThreadId] = createSignal<string>(); const [expandedThreadId, setExpandedThreadId] = createSignal<string>();
const navigate = useNavigate(); const { push } = useNavigator();
const onBookmark = async (status: mastodon.v1.Status) => { const onBookmark = async (status: mastodon.v1.Status) => {
const client = session()?.client; const client = session()?.client;
@ -99,7 +114,7 @@ const TootList: Component<{
const openFullScreenToot = ( const openFullScreenToot = (
toot: mastodon.v1.Status, toot: mastodon.v1.Status,
srcElement?: HTMLElement, srcElement: HTMLElement,
reply?: boolean, reply?: boolean,
) => { ) => {
const p = session()?.account; const p = session()?.account;
@ -115,12 +130,55 @@ const TootList: Component<{
const acct = `${inf.username}@${p.site}`; const acct = `${inf.username}@${p.site}`;
setTootBottomSheetCache(acct, toot); setTootBottomSheetCache(acct, toot);
navigate(`/${encodeURIComponent(acct)}/toot/${toot.id}`, {
state: reply push(`/${encodeURIComponent(acct)}/toot/${toot.id}`, {
? { animateOpen(element) {
tootReply: true, const rect0 = srcElement.getBoundingClientRect(); // the start rect
} const rect1 = element.getBoundingClientRect(); // the end rect
: undefined,
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}`, target.dataset.client || `@${new URL(target.href).origin}`,
); );
navigate(`/${acct}/profile/${target.dataset.acctId}`); push(`/${acct}/profile/${target.dataset.acctId}`);
return; return;
} else { } else {

View file

@ -13,12 +13,6 @@ import TootList from "./TootList.jsx";
const TrendTimelinePanel: Component<{ const TrendTimelinePanel: Component<{
client: mastodon.rest.Client; client: mastodon.rest.Client;
openFullScreenToot: (
toot: mastodon.v1.Status,
srcElement?: HTMLElement,
reply?: boolean,
) => void;
}> = (props) => { }> = (props) => {
const [scrollLinked, setScrollLinked] = createSignal<HTMLElement>(); const [scrollLinked, setScrollLinked] = createSignal<HTMLElement>();
const [tl, snapshot, { refetch: refetchTimeline }] = createTimelineSnapshot( const [tl, snapshot, { refetch: refetchTimeline }] = createTimelineSnapshot(