Compare commits

...

19 commits

Author SHA1 Message Date
1a7a52da22 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
2024-11-18 10:35:30 +00:00
thislight
44b7354e74
StackedRouter: minor cleanup 2024-11-18 18:34:25 +08:00
thislight
3b5cc1e64e
StackedRouter: fix backward does not pop page 2024-11-18 18:32:06 +08:00
thislight
00554a045f
Settings: minor cleanup 2024-11-18 18:12:32 +08:00
thislight
a3ef5d9cf5
StackedRouter: prototype of generic swipe to back 2024-11-18 18:08:14 +08:00
thislight
6fbd198021
UnexpectedError: restart the app instead of reload 2024-11-17 21:00:36 +08:00
thislight
3c50f150dc
PullDownToRefresh: adpats useMaybeIsFrameSuspended 2024-11-17 20:58:23 +08:00
thislight
169aa91e73
StackedRouter: add useMaybeIsFrameSuspended() 2024-11-17 20:57:56 +08:00
thislight
f0dadebfb6
Home: minor cleanup
- remove unused prop openFullScreenToot from
  TimelinePanel and TrendTimelinePanel
2024-11-17 20:27:49 +08:00
thislight
6879eb5292
TootList: add hero animation 2024-11-17 17:38:38 +08:00
thislight
1641f3e75b
StackedRouter: hero animation support 2024-11-17 17:37:42 +08:00
thislight
63fe4acc98
Settings: fix back button pop too much frames 2024-11-16 22:38:27 +08:00
thislight
32dffaaa3d
StackedRouter: intergrated with web history API 2024-11-16 22:38:00 +08:00
thislight
5be56bb80e
A: fix missolved path 2024-11-16 22:37:22 +08:00
thislight
e3d9d0c4ba
StackedRouter: add default background 2024-11-16 22:37:05 +08:00
thislight
2da7bf134e
App: fix path for motion settings 2024-11-16 22:27:35 +08:00
thislight
ab37a280e7
ProfileMenuButton: fix open settings in new page 2024-11-16 22:27:04 +08:00
thislight
9dfcfa3868
StackedRouter: add default open animation 2024-11-16 21:50:18 +08:00
thislight
0710aaf4f3
first prototype of StackedRouter 2024-11-16 20:04:55 +08: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(