Compare commits

..

No commits in common. "c7f26053ca00e72aaf5269fcad063654b8fe5c7b" and "1c0a83dbab78758961b2081ea57a5b31723d6e66" have entirely different histories.

19 changed files with 98 additions and 351 deletions

BIN
bun.lockb

Binary file not shown.

View file

@ -7,7 +7,6 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>Tutu</title>
<link rel="stylesheet" href="/src/App.css" />
<script src="/src/platform/polyfills.ts" type="module"></script>
<script src="/src/index.tsx" type="module"></script>
</head>
<body>

View file

@ -16,22 +16,22 @@
"devDependencies": {
"@suid/vite-plugin": "^0.2.0",
"@types/hammerjs": "^2.0.45",
"postcss": "^8.4.41",
"prettier": "^3.3.3",
"typescript": "^5.5.4",
"vite": "^5.4.0",
"postcss": "^8.4.39",
"prettier": "^3.3.2",
"typescript": "^5.5.2",
"vite": "^5.3.2",
"vite-plugin-package-version": "^1.1.0",
"vite-plugin-pwa": "^0.20.1",
"vite-plugin-pwa": "^0.20.0",
"vite-plugin-solid": "^2.10.2",
"vite-plugin-solid-styled": "^0.11.1",
"wrangler": "^3.70.0"
"wrangler": "^3.64.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.26",
"@solid-primitives/resize-observer": "^2.0.25",
"@solidjs/router": "^0.11.5",
"@suid/icons-material": "^0.7.0",
"@suid/material": "^0.16.0",
@ -40,10 +40,9 @@
"hammerjs": "^2.0.8",
"masto": "^6.8.0",
"nanostores": "^0.9.5",
"solid-js": "^1.8.20",
"solid-js": "^1.8.18",
"solid-styled": "^0.11.1",
"stacktrace-js": "^2.0.2",
"web-animations-js": "^2.3.2"
"stacktrace-js": "^2.0.2"
},
"packageManager": "bun@1.1.21"
}

View file

@ -22,7 +22,6 @@ 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 (
@ -30,7 +29,6 @@ const Routing: Component = () => {
<Route path="/" component={TimelineHome}>
<Route path=""></Route>
<Route path="/settings" component={Settings}></Route>
<Route path="/:acct/:id" component={TootBottomSheet}></Route>
</Route>
<Route path={"/accounts"}>
<Route path={"/sign-in"} component={AccountSignIn} />
@ -55,7 +53,7 @@ const App: Component = () => {
);
});
const UnexpectedError = lazy(() => import("./UnexpectedError.js"));
const UnexpectedError = lazy(() => import("./UnexpectedError.js"))
return (
<ErrorBoundary

View file

@ -3,6 +3,18 @@ import type { mastodon } from "masto";
import { useSessions } from "./clients";
import { updateAcctInf } from "../accounts/stores";
export function useAcctProfile(client: Accessor<mastodon.rest.Client>) {
return createResource(
client,
(client) => {
return client.v1.accounts.verifyCredentials();
},
{
name: "MastodonAccountProfile",
},
);
}
export function useSignedInProfiles() {
const sessions = useSessions();
const [accessor, tools] = createResource(sessions, async (all) => {
@ -12,11 +24,11 @@ export function useSignedInProfiles() {
});
return [
() => {
const value = accessor();
if (!value) {
if (accessor.loading) {
accessor();
return sessions().map((x) => ({ ...x, inf: x.account.inf }));
}
return value;
return accessor();
},
tools,
] as const;

View file

@ -14,49 +14,55 @@ type Timeline = {
};
export function useTimeline(timeline: Accessor<Timeline>) {
let minId: string | undefined;
let maxId: string | undefined;
let otl: Timeline | undefined;
let npager: mastodon.Paginator<mastodon.v1.Status[], unknown> | undefined;
let opager: mastodon.Paginator<mastodon.v1.Status[], unknown> | undefined;
const idSet = new Set<string>();
const [snapshot, { refetch }] = createResource<
{
records: mastodon.v1.Status[];
direction: "new" | "old";
tlChanged: boolean;
},
{ records: mastodon.v1.Status[]; direction: "old" | "new" },
[Timeline],
TimelineFetchTips | undefined
>(
() => [timeline()] as const,
async ([tl], info) => {
let tlChanged = false;
if (otl !== tl) {
console.debug("timeline reset");
npager = opager = undefined;
minId = undefined;
maxId = undefined;
idSet.clear();
otl = tl;
tlChanged = true;
}
const direction =
typeof info.refetching !== "boolean"
? (info.refetching?.direction ?? "old")
? info.refetching?.direction
: "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") {
if (!opager) {
opager = tl.list({}).setDirection("next");
minId = pager[pager.length - 1]?.id;
if (!maxId && pager.length > 0) {
maxId = pager[0].id;
}
const next = await opager.next();
return {
direction,
records: next.value ?? [],
end: next.done,
tlChanged,
direction: "old" as const,
records: diff,
};
} else {
if (!npager) {
npager = tl.list({}).setDirection("prev");
maxId = pager.length > 0 ? pager[0].id : undefined;
if (!minId && pager.length > 0) {
minId = pager[pager.length - 1]?.id;
}
const next = await npager.next();
const page = next.value ?? [];
return { direction, records: page, end: next.done, tlChanged };
return { direction: "new" as const, records: diff };
}
},
);
@ -66,10 +72,7 @@ export function useTimeline(timeline: Accessor<Timeline>) {
createEffect(() => {
const shot = snapshot();
if (!shot) return;
const { direction, records, tlChanged } = shot;
if (tlChanged) {
setStore(() => []);
}
const { direction, records } = shot;
if (direction == "new") {
setStore((x) => [...records, ...x]);
} else if (direction == "old") {

View file

@ -1,4 +1,3 @@
import { cache } from "@solidjs/router";
import type { mastodon } from "masto";
import { createRenderEffect, createResource, type Accessor } from "solid-js";

View file

@ -11,14 +11,10 @@
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;
}
@ -34,9 +30,4 @@
max-height: 100%;
}
}
&.animated {
position: absolute;
transform: none;
}
}

View file

@ -1,115 +1,22 @@
import {
createEffect,
createRenderEffect,
onCleanup,
onMount,
startTransition,
useTransition,
type ParentComponent,
} from "solid-js";
import { createEffect, 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<string, unknown>,
) {
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<BottomSheetProps> = (props) => {
let element: HTMLDialogElement;
let animation: Animation | undefined;
const hero = useHeroSignal(HERO);
const [pending] = useTransition()
createEffect(() => {
if (props.open) {
if (!element.open && !pending()) {
animatedOpen();
if (!element.open) {
element.showModal();
}
} else {
if (element.open) {
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();
}
}
});

View file

@ -1,49 +1,13 @@
import {
createContext,
createRenderEffect,
createSignal,
untrack,
useContext,
type Accessor,
type Signal,
} from "solid-js";
import { createContext, useContext, type Accessor } from "solid-js";
export type HeroSource = {
[key: string | symbol | number]: DOMRect | undefined;
[key: string | symbol | number]: HTMLElement | undefined;
};
const HeroSourceContext = createContext<Signal<HeroSource>>(/* __@PURE__ */undefined);
const HeroSourceContext = createContext<Accessor<HeroSource>>(() => ({}));
export const HeroSourceProvider = HeroSourceContext.Provider;
function useHeroSource() {
export function useHeroSource() {
return useContext(HeroSourceContext);
}
/**
* Use hero value for the {@link key}.
*/
export function useHeroSignal(
key: string | symbol | number,
): Accessor<DOMRect | undefined> {
const source = useHeroSource();
if (source) {
const [get, set] = createSignal<DOMRect>();
createRenderEffect(() => {
const value = source[0]();
if (value[key]) {
set(value[key]);
source[1]((x) => {
const cpy = Object.assign({}, x);
delete cpy[key];
return cpy;
});
}
});
return get;
} else {
return () => undefined;
}
}

View file

@ -1,12 +0,0 @@
export function isiOS() {
return [
'iPad Simulator',
'iPhone Simulator',
'iPod Simulator',
'iPad',
'iPhone',
'iPod'
].includes(navigator.platform)
// iPad on iOS 13 detection
|| (navigator.userAgent.includes("Mac") && "ontouchend" in document)
}

View file

@ -1,8 +0,0 @@
//! This module has side effect.
//! It recommended to include the module by <script> tag.
if (!document.body.animate) {
// @ts-ignore: this file is polyfill, no exposed decls
import("web-animations-js").then(() => {
console.warn("web animation polyfill is included");
});
}

View file

@ -50,7 +50,7 @@ const Settings: ParentComponent = () => {
topbar={
<AppBar position="static">
<Toolbar variant="dense" sx={{paddingTop: "var(--safe-area-inset-top, 0px)"}}>
<IconButton color="inherit" onClick={[navigate, -1]}>
<IconButton onClick={[navigate, -1]}>
<CloseIcon />
</IconButton>
<Title>Settings</Title>

View file

@ -8,9 +8,9 @@ import {
onMount,
type ParentComponent,
children,
Suspense,
} from "solid-js";
import { useDocumentTitle } from "../utils";
import { useSessions } from "../masto/clients";
import { type mastodon } from "masto";
import Scaffold from "../material/Scaffold";
import {
@ -27,32 +27,23 @@ import {
import { css } from "solid-styled";
import { TimeSourceProvider, createTimeSource } from "../platform/timesrc";
import TootThread from "./TootThread.js";
import { useAcctProfile } from "../masto/acct";
import ProfileMenuButton from "./ProfileMenuButton";
import Tabs from "../material/Tabs";
import Tab from "../material/Tab";
import { Create as CreateTootIcon } from "@suid/icons-material";
import { useTimeline } from "../masto/timelines";
import { makeEventListener } from "@solid-primitives/event-listener";
import BottomSheet, {
HERO as BOTTOM_SHEET_HERO,
} from "../material/BottomSheet";
import BottomSheet from "../material/BottomSheet";
import { $settings } from "../settings/stores";
import { useStore } from "@nanostores/solid";
import { vibrate } from "../platform/hardware";
import PullDownToRefresh from "./PullDownToRefresh";
import { HeroSourceProvider, type HeroSource } from "../platform/anim";
import { useNavigate } from "@solidjs/router";
import { useSignedInProfiles } from "../masto/acct";
const TimelinePanel: Component<{
client: mastodon.rest.Client;
name: "home" | "public" | "trends";
prefetch?: boolean;
openFullScreenToot: (
toot: mastodon.v1.Status,
srcElement?: HTMLElement,
) => void;
}> = (props) => {
const [scrollLinked, setScrollLinked] = createSignal<HTMLElement>();
const [
@ -134,22 +125,18 @@ const TimelinePanel: Component<{
>
<For each={timeline}>
{(item, index) => {
let element: HTMLElement | undefined;
return (
<TootThread
ref={element}
status={item}
onBoost={(...args) => onBoost(index(), ...args)}
onBookmark={(...args) => onBookmark(index(), ...args)}
client={props.client}
expanded={item.id === expandedThreadId() ? 1 : 0}
onExpandChange={(x) => {
if (item.id !== expandedThreadId()) {
setExpandedThreadId(item.id);
} else if (x === 2) {
props.openFullScreenToot(item, element);
}
}}
onExpandChange={() =>
setExpandedThreadId(
item.id !== expandedThreadId() ? item.id : undefined,
)
}
/>
);
}}
@ -195,13 +182,10 @@ const Home: ParentComponent = (props) => {
const now = createTimeSource();
const settings$ = useStore($settings);
const sessions = useSessions();
const client = () => sessions()[0].client;
const [profile] = useAcctProfile(client);
const [profiles] = useSignedInProfiles();
const profile = () => profiles()[0].inf;
const client = () => profiles()[0].client;
const navigate = useNavigate();
const [heroSrc, setHeroSrc] = createSignal<HeroSource>({});
const [panelOffset, setPanelOffset] = createSignal(0);
const prefetching = () => !settings$().prefetchTootsDisabled;
const [currentFocusOn, setCurrentFocusOn] = createSignal<HTMLElement[]>([]);
@ -275,22 +259,6 @@ const Home: ParentComponent = (props) => {
}
};
const openFullScreenToot = (
toot: mastodon.v1.Status,
srcElement?: HTMLElement,
) => {
const p = profiles()[0];
const inf = p.account.inf ?? profile();
if (!inf) {
console.warn("no account info?");
return;
}
const rect = srcElement?.getBoundingClientRect();
setHeroSrc((x) => Object.assign({}, x, { [BOTTOM_SHEET_HERO]: rect }));
const acct = `${inf.username}@${p.account.site}`;
navigate(`/${encodeURIComponent(acct)}/${toot.id}`);
};
css`
.tab-panel {
overflow: visible auto;
@ -376,7 +344,6 @@ const Home: ParentComponent = (props) => {
client={client()}
name="home"
prefetch={prefetching()}
openFullScreenToot={openFullScreenToot}
/>
</div>
</div>
@ -386,7 +353,6 @@ const Home: ParentComponent = (props) => {
client={client()}
name="trends"
prefetch={prefetching()}
openFullScreenToot={openFullScreenToot}
/>
</div>
</div>
@ -396,18 +362,13 @@ const Home: ParentComponent = (props) => {
client={client()}
name="public"
prefetch={prefetching()}
openFullScreenToot={openFullScreenToot}
/>
</div>
</div>
<div></div>
</div>
</TimeSourceProvider>
<Suspense>
<HeroSourceProvider value={[heroSrc, setHeroSrc]}>
<BottomSheet open={!!child()}>{child()}</BottomSheet>
</HeroSourceProvider>
</Suspense>
<BottomSheet open={!!child()}>{child()}</BottomSheet>
</Scaffold>
</>
);

View file

@ -2,7 +2,9 @@ import type { mastodon } from "masto";
import { type Component, For, createSignal } from "solid-js";
import { css } from "solid-styled";
import tootStyle from "./toot.module.css";
import MediaViewer from "./MediaViewer";
import { Portal } from "solid-js/web";
import MediaViewer, { MEDIA_VIEWER_HEROSRC } from "./MediaViewer";
import { HeroSourceProvider } from "../platform/anim";
const MediaAttachmentGrid: Component<{
attachments: mastodon.v1.MediaAttachment[];
@ -56,6 +58,13 @@ const MediaAttachmentGrid: Component<{
}
}}
</For>
<HeroSourceProvider
value={() => ({
[MEDIA_VIEWER_HEROSRC]: rootRef.children.item(
viewerIndex() || 0,
) as HTMLElement,
})}
>
<MediaViewer
show={viewerOpened()}
index={viewerIndex() || 0}
@ -63,6 +72,7 @@ const MediaAttachmentGrid: Component<{
media={props.attachments}
onClose={() => setViewerIndex(undefined)}
/>
</HeroSourceProvider>
</section>
);
};

View file

@ -15,6 +15,8 @@ import {
untrack,
} from "solid-js";
import { css } from "solid-styled";
import { useHeroSource } from "../platform/anim";
import { Portal } from "solid-js/web";
import { createStore } from "solid-js/store";
import { IconButton, Toolbar } from "@suid/material";
import { ArrowLeft, ArrowRight, Close } from "@suid/icons-material";
@ -40,6 +42,8 @@ function clamp(input: number, min: number, max: number) {
const MediaViewer: ParentComponent<MediaViewerProps> = (props) => {
let rootRef: HTMLDialogElement;
const heroSource = useHeroSource();
const heroSourceEl = () => heroSource()[MEDIA_VIEWER_HEROSRC];
type State = {
ref?: HTMLElement;
media: mastodon.v1.MediaAttachment;

View file

@ -103,7 +103,7 @@ const PullDownToRefresh: Component<{
const handleTouch = (event: TouchEvent) => {
if (event.targetTouches.length > 1) {
lastTouchId = 0;
lastTouchScreenY = 0;
lastTouchScreenY;
return;
}
const item = event.targetTouches.item(0)!;
@ -128,12 +128,7 @@ const PullDownToRefresh: Component<{
lastTouchScreenY = 0;
holding = false;
if (untrack(pullDownDistance) >= 160 && !props.loading && props.onRefresh) {
setTimeout(props.onRefresh, 0);
} else {
if (released) {
released = false;
requestAnimationFrame(updatePullDown);
}
setTimeout(props.onRefresh, 0)
}
};

View file

@ -1,80 +1,7 @@
import { useNavigate, useParams } from "@solidjs/router";
import { createResource, Show, type Component } from "solid-js";
import Scaffold from "../material/Scaffold";
import TootThread from "./TootThread";
import { AppBar, IconButton, Toolbar } from "@suid/material";
import { Title } from "../material/typography";
import { Close as CloseIcon } from "@suid/icons-material";
import { isiOS } from "../platform/host";
import { createUnauthorizedClient, useSessions } from "../masto/clients";
import { resolveCustomEmoji } from "../masto/toot";
import RegularToot from "./RegularToot";
import type { Component } from "solid-js";
const TootBottomSheet: Component = (props) => {
const params = useParams<{ acct: string; id: string }>();
const navigate = useNavigate();
const allSession = useSessions();
const session = () => {
const [inputUsername, inputSite] = decodeURIComponent(params.acct).split(
"@",
2,
);
const authedSession = allSession().find(
(x) =>
x.account.site === inputSite &&
x.account.inf?.username === inputUsername,
);
return authedSession ?? { client: createUnauthorizedClient(inputSite) };
};
const [remoteToot] = createResource(
() => [session().client, params.id] as const,
async ([client, id]) => {
return await client.v1.statuses.$select(id).fetch();
},
);
const toot = remoteToot;
const tootTitle = () => {
const t = toot();
if (t) {
const name = resolveCustomEmoji(t.account.displayName, t.account.emojis);
return `${name}'s toot`;
}
return "A toot";
};
return (
<Scaffold
topbar={
<AppBar
sx={{
backgroundColor: "var(--tutu-color-surface)",
color: "var(--tutu-color-on-surface)",
}}
elevation={1}
position="static"
>
<Toolbar
variant="dense"
sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }}
>
<IconButton color="inherit" onClick={[navigate, -1]} disableRipple>
<CloseIcon />
</IconButton>
<Title>{tootTitle}</Title>
</Toolbar>
</AppBar>
}
>
<div>
<Show when={toot()}>
<RegularToot status={toot()!}></RegularToot>
</Show>
</div>
</Scaffold>
);
return <></>;
};
export default TootBottomSheet;

View file

@ -1,5 +1,5 @@
import type { mastodon } from "masto";
import { Show, createResource, createSignal, type Component, type Ref } from "solid-js";
import { Show, createResource, createSignal, type Component } from "solid-js";
import CompactToot from "./CompactToot";
import { useTimeSource } from "../platform/timesrc";
import RegularToot from "./RegularToot";
@ -7,7 +7,6 @@ import cardStyle from "../material/cards.module.css";
import { css } from "solid-styled";
type TootThreadProps = {
ref?: Ref<HTMLElement>,
status: mastodon.v1.Status;
client: mastodon.rest.Client;
expanded?: 0 | 1 | 2;
@ -71,7 +70,6 @@ const TootThread: Component<TootThreadProps> = (props) => {
return (
<article
ref={props.ref}
classList={{ "thread-line": !!inReplyTo(), expanded: expanded() > 0 }}
onClick={() => props.onExpandChange?.(nextExpandLevel[expanded()])}
>