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