Compare commits

..

No commits in common. "master" and "stacky" have entirely different histories.

66 changed files with 1684 additions and 2508 deletions

1
.env
View file

@ -2,4 +2,3 @@ DEV_SERVER_HTTPS_CERT_BASE=
DEV_SERVER_HTTPS_CERT_PASS= DEV_SERVER_HTTPS_CERT_PASS=
DEV_LOCATOR_EDITOR=vscode DEV_LOCATOR_EDITOR=vscode
VITE_DEVTOOLS_OVERLAY=true VITE_DEVTOOLS_OVERLAY=true
VITE_PLATFROM_MASONRY_ALWAYS_COMPAT=

BIN
bun.lockb

Binary file not shown.

View file

@ -10,6 +10,10 @@ You can debug on the Safari on iOS only if you have mac (and run macOS). The cer
- For visual bugs: on you iDevice, redirect the localhost.direct to your dev computer. Now you have the hot reload on you iDevice. - For visual bugs: on you iDevice, redirect the localhost.direct to your dev computer. Now you have the hot reload on you iDevice.
- You can use network debugging apps like "Shadowrocket" to do such thing. - You can use network debugging apps like "Shadowrocket" to do such thing.
## Hero Animation won't work (after hot reload)
That's a known issue. Hot reload won't refresh the module sets the hero cache. Refresh the whole page and it should work.
## The components don't react to the change as I setting the store, until the page reloaded ## The components don't react to the change as I setting the store, until the page reloaded
The `WritableAtom<unknwon>.set` might do an equals check. You must set a different object to ensure the atom sending a notify. The `WritableAtom<unknwon>.set` might do an equals check. You must set a different object to ensure the atom sending a notify.
@ -43,44 +47,3 @@ Ja, the code is weird, but that's the best we know. Anyway, you need new object
Idk why, but transition on logical directions may not work on WebKit - sometimes they work. Idk why, but transition on logical directions may not work on WebKit - sometimes they work.
Use physical directions to avoid trouble, like "margin-top, margin-bottom". Use physical directions to avoid trouble, like "margin-top, margin-bottom".
## Safe area insets
For isolating control of the UI effect, we already setup css variables `--safe-area-inset-*`. In components, you should use the variables unless you have reasons to use `env()`.
Using `--safe-area-inset-*`, you can control the global value in settings (under dev mode).
## Module Isolation
> Write the code that can be easily removed.
To limit the code impact, we organize the code based on **"topic modules"** (modules in short). Each module focus on a specific topic described by the name. Like the "accounts" contains the code about the accounts, "masto" contains the code about the masto (a library used to access mastodon) helpers.
> Sidenote: This also helps easing "the landing problem". If you need something about accounts, no longer "common/accounts" and "hooks/accounts" and "helpers/accounts" and "components/accounts". Someone says this is clean - is it even if you need to jump between 6 directories for how one simple feature works?
> And you no longer needs to think about "where to place this file (between six directories, usually)". People often optimize their code structure too early - just like how they treat the runtime performance.
> The worse is, it's very hard to solve this problem later, because you had sent your code to different places.
There are **two special modules** in this project:
One is the *platform*. This module provides foundation of this app: deals with the host platform (like SizedTextarea - auto resized textarea), provides custom platform feature (like StackedRouter - provides mobile-native navigation experience).
The another is the *material*. This module provides Material styling toolkit, the stylesheets, MUI Theme, constants and components.
They (and only them) can be accessed by special aliases: `~{module name}`, like the `~platform`.
We discourage cross referencings between two topics. Reuse is not better than duplication. Cross referencing is still possible if required.
When a tool, a file or a component is required every-elsewhere, **promoting** is required to reduce the cross referencing. Thanksfully, it's usually automated process for moving files.
But, sometimes you need a redesigned (sometimes better) tool for the generic usage. Follow the idea:
- Move slowly or crash. Only make the change if it's required.
- Try to make the original part depends on your new tool, and keep the original for awhile.
- Mark deprecated only if you think the original won't worth an existence. Reasons:
- Migrate to the new code only needs minor change.
- The original code has critical problems, like performance or compatibility.
- Make notes. Communication is important, even with the future you.
- *Why* this move is decided?
- *What* this new tool does?
- *How* this tool works?
- Clean up code regularly. Don't keep the unused code forever.

View file

@ -8,8 +8,7 @@
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
"preview": "vite preview", "preview": "vite preview",
"dist": "vite build", "dist": "vite build"
"count-source-lines": "exec scripts/src-lc.sh"
}, },
"keywords": [], "keywords": [],
"author": "Rubicon", "author": "Rubicon",
@ -18,7 +17,6 @@
"@solid-devtools/overlay": "^0.30.1", "@solid-devtools/overlay": "^0.30.1",
"@suid/vite-plugin": "^0.3.1", "@suid/vite-plugin": "^0.3.1",
"@types/hammerjs": "^2.0.46", "@types/hammerjs": "^2.0.46",
"@types/masonry-layout": "^4.2.8",
"@vite-pwa/assets-generator": "^0.2.6", "@vite-pwa/assets-generator": "^0.2.6",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"prettier": "^3.3.3", "prettier": "^3.3.3",
@ -50,7 +48,6 @@
"fast-average-color": "^9.4.0", "fast-average-color": "^9.4.0",
"hammerjs": "^2.0.8", "hammerjs": "^2.0.8",
"iso-639-1": "^3.1.3", "iso-639-1": "^3.1.3",
"masonry-layout": "^4.2.2",
"masto": "^6.10.1", "masto": "^6.10.1",
"nanostores": "^0.11.3", "nanostores": "^0.11.3",
"normalize.css": "^8.0.1", "normalize.css": "^8.0.1",
@ -58,6 +55,7 @@
"solid-js": "^1.9.3", "solid-js": "^1.9.3",
"solid-styled": "^0.11.1", "solid-styled": "^0.11.1",
"stacktrace-js": "^2.0.2", "stacktrace-js": "^2.0.2",
"web-animations-js": "^2.3.2",
"workbox-core": "^7.3.0", "workbox-core": "^7.3.0",
"workbox-precaching": "^7.3.0" "workbox-precaching": "^7.3.0"
}, },

View file

@ -1,10 +0,0 @@
#!/bin/sh
# Count the source lines.
find . '(' ! -path "./node_modules/**" ')' \
-and '(' ! -path "./.git/**" ')' \
-and '(' ! -path "./*dist/**" ')' \
-and '(' ! -path "./bun.lockb" ')' \
-and '(' ! -path "./docs/**" ')' \
-type f -print0 \
| wc -l --files0-from=-

View file

@ -35,7 +35,3 @@ https://stackoverflow.com/questions/66005655/pwa-ios-child-of-body-not-taking-10
h1 { h1 {
margin: 0; margin: 0;
} }
* {
user-select: none;
}

View file

@ -1,4 +1,4 @@
import { Route } from "@solidjs/router"; import { Route, Router } from "@solidjs/router";
import { ThemeProvider } from "@suid/material"; import { ThemeProvider } from "@suid/material";
import { import {
Component, Component,
@ -17,12 +17,7 @@ import {
} from "./masto/clients.js"; } from "./masto/clients.js";
import { $accounts, updateAcctInf } from "./accounts/stores.js"; import { $accounts, updateAcctInf } from "./accounts/stores.js";
import { useStore } from "@nanostores/solid"; import { useStore } from "@nanostores/solid";
import { import { DateFnScope, useLanguage } from "./platform/i18n.jsx";
AppLocaleProvider,
createCurrentLanguage,
createCurrentRegion,
createDateFnLocaleResource,
} from "./platform/i18n.jsx";
import { useRegisterSW } from "virtual:pwa-register/solid"; import { useRegisterSW } from "virtual:pwa-register/solid";
import { import {
isJSONRPCResult, isJSONRPCResult,
@ -72,9 +67,7 @@ const Routing: Component = () => {
const App: Component = () => { const App: Component = () => {
const theme = useRootTheme(); const theme = useRootTheme();
const accts = useStore($accounts); const accts = useStore($accounts);
const lang = createCurrentLanguage(); const lang = useLanguage();
const region = createCurrentRegion();
const dateFnLocale = createDateFnLocaleResource(region);
const [serviceWorker, setServiceWorker] = createSignal< const [serviceWorker, setServiceWorker] = createSignal<
ServiceWorker | undefined ServiceWorker | undefined
>(undefined, { name: "serviceWorker" }); >(undefined, { name: "serviceWorker" });
@ -157,13 +150,7 @@ const App: Component = () => {
}} }}
> >
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<AppLocaleProvider <DateFnScope>
value={{
language: lang,
region: region,
dateFn: dateFnLocale,
}}
>
<ClientProvider value={clients}> <ClientProvider value={clients}>
<ServiceWorkerProvider <ServiceWorkerProvider
value={{ value={{
@ -175,7 +162,7 @@ const App: Component = () => {
<Routing /> <Routing />
</ServiceWorkerProvider> </ServiceWorkerProvider>
</ClientProvider> </ClientProvider>
</AppLocaleProvider> </DateFnScope>
</ThemeProvider> </ThemeProvider>
</ErrorBoundary> </ErrorBoundary>
); );

View file

@ -42,29 +42,6 @@ const UnexpectedError: Component<{ error?: any }> = (props) => {
calc(var(--safe-area-inset-bottom) + 20px) calc(var(--safe-area-inset-bottom) + 20px)
calc(var(--safe-area-inset-left) + 20px); calc(var(--safe-area-inset-left) + 20px);
} }
details {
max-width: 100vw;
max-width: 100dvw;
overflow: auto;
& * {
user-select: all;
}
summary {
position: sticky;
left: 0;
top: 0;
user-select: none;
}
}
.actions {
margin-top: 20px;
margin-bottom: 20px;
}
`; `;
return ( return (
@ -75,11 +52,8 @@ const UnexpectedError: Component<{ error?: any }> = (props) => {
You can restart the app 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 class="actions"> <div>
<Button <Button onClick={() => (window.location.replace("/"))}>
onClick={() => window.location.replace("/")}
variant="contained"
>
Restart App Restart App
</Button> </Button>
</div> </div>
@ -87,10 +61,7 @@ const UnexpectedError: Component<{ error?: any }> = (props) => {
<summary> <summary>
{errorMsg.loading ? "Generating " : " "}Technical Infomation {errorMsg.loading ? "Generating " : " "}Technical Infomation
</summary> </summary>
<pre> <pre>{errorMsg()}</pre>
On: {window.location.href} <br />
{errorMsg()}
</pre>
</details> </details>
</main> </main>
); );

View file

@ -9,12 +9,12 @@ import {
import { acceptAccountViaAuthCode } from "./stores"; import { acceptAccountViaAuthCode } from "./stores";
import { $settings } from "../settings/stores"; import { $settings } from "../settings/stores";
import { useDocumentTitle } from "../utils"; import { useDocumentTitle } from "../utils";
import cards from "~material/cards.module.css"; import cards from "../material/cards.module.css";
import { LinearProgress } from "@suid/material"; 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"; import { useNavigator } from "../platform/StackedRouter";
type OAuth2CallbackParams = { type OAuth2CallbackParams = {
code?: string; code?: string;

View file

@ -7,11 +7,11 @@ import {
createUniqueId, createUniqueId,
onMount, onMount,
} from "solid-js"; } from "solid-js";
import cards from "~material/cards.module.css"; import cards from "../material/cards.module.css";
import TextField from "~material/TextField.js"; import TextField from "../material/TextField.js";
import Button from "~material/Button.js"; import Button from "../material/Button.js";
import { useDocumentTitle } from "../utils"; import { useDocumentTitle } from "../utils";
import { Title } from "~material/typography"; import { Title } from "../material/typography";
import { css } from "solid-styled"; import { css } from "solid-styled";
import { LinearProgress } from "@suid/material"; import { LinearProgress } from "@suid/material";
import { createRestAPIClient } from "masto"; import { createRestAPIClient } from "masto";

View file

@ -167,7 +167,7 @@ export async function getOrRegisterApp(site: string, redirectUrl: string) {
}); });
const app = await client.v1.apps.create({ const app = await client.v1.apps.create({
clientName: "TuTu", clientName: "TuTu",
website: "https://code.lightstands.xyz/Rubicon/tutu", website: "https://github.com/thislight/tutu",
redirectUris: redirectUrl, redirectUris: redirectUrl,
scopes: "read write push", scopes: "read write push",
}); });

View file

@ -9,7 +9,7 @@ import {
import { Account } from "../accounts/stores"; import { Account } from "../accounts/stores";
import { createRestAPIClient, mastodon } from "masto"; import { createRestAPIClient, mastodon } from "masto";
import { useLocation } from "@solidjs/router"; import { useLocation } from "@solidjs/router";
import { useNavigator } from "~platform/StackedRouter"; import { useNavigator } from "../platform/StackedRouter";
const restfulCache: Record<string, mastodon.rest.Client> = {}; const restfulCache: Record<string, mastodon.rest.Client> = {};
@ -64,7 +64,7 @@ export function useSessions() {
if (sessions().length > 0) return; if (sessions().length > 0) return;
push( push(
"/accounts/sign-in?back=" + encodeURIComponent(location.pathname), "/accounts/sign-in?back=" + encodeURIComponent(location.pathname),
{ replace: "all" }, { replace: true },
); );
}); });

View file

@ -1,18 +1,25 @@
import { import {
children, children,
createEffect, createEffect,
createSignal,
onCleanup, onCleanup,
useTransition, useTransition,
type JSX, type JSX,
type ParentComponent, type ParentComponent,
type ResolvedChildren,
} from "solid-js"; } from "solid-js";
import "./BottomSheet.css"; import "./BottomSheet.css";
import { useHeroSignal } from "../platform/anim";
import material from "./material.module.css"; import material from "./material.module.css";
import { ANIM_CURVE_ACELERATION, ANIM_CURVE_DECELERATION } from "./theme"; import {
ANIM_CURVE_ACELERATION,
ANIM_CURVE_DECELERATION,
ANIM_CURVE_STD,
} from "./theme";
import { import {
animateSlideInFromRight, animateSlideInFromRight,
animateSlideOutToRight, animateSlideOutToRight,
} from "~platform/anim"; } from "../platform/anim";
export type BottomSheetProps = { export type BottomSheetProps = {
open?: boolean; open?: boolean;
@ -21,38 +28,34 @@ export type BottomSheetProps = {
onClose?(reason: "backdrop"): void; onClose?(reason: "backdrop"): void;
}; };
const MOVE_SPEED = 1600; export const HERO = Symbol("BottomSheet Hero Symbol");
function animateSlideInFromBottom(element: HTMLElement, reverse?: boolean) { function composeAnimationFrame(
const rect = element.getBoundingClientRect();
const easing = "cubic-bezier(0.4, 0, 0.2, 1)";
element.classList.add("animated");
const oldOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const distance = Math.abs(rect.top - window.innerHeight);
const duration = (distance / MOVE_SPEED) * 1000;
const animation = element.animate(
{ {
top: reverse top,
? [`${rect.top}px`, `${window.innerHeight}px`] left,
: [`${window.innerHeight}px`, `${rect.top}px`], height,
}, width,
{ easing, duration }, }: Record<"top" | "left" | "height" | "width", number>,
); x: Record<string, unknown>,
const onAnimationEnd = () => { ) {
element.classList.remove("animated"); return {
document.body.style.overflow = oldOverflow; top: `${top}px`,
left: `${left}px`,
height: `${height}px`,
width: `${width}px`,
...x,
}; };
animation.addEventListener("cancel", onAnimationEnd);
animation.addEventListener("finish", onAnimationEnd);
return animation;
} }
const MOVE_SPEED = 1600;
const BottomSheet: ParentComponent<BottomSheetProps> = (props) => { const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
let element: HTMLDialogElement; let element: HTMLDialogElement;
let animation: Animation | undefined; let animation: Animation | undefined;
const child = children(() => props.children); const [hero, setHero] = useHeroSignal(HERO);
const [cache, setCache] = createSignal<ResolvedChildren | undefined>();
const ochildren = children(() => props.children);
const [pending] = useTransition(); const [pending] = useTransition();
@ -60,26 +63,41 @@ const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
if (props.open) { if (props.open) {
if (!element.open && !pending()) { if (!element.open && !pending()) {
requestAnimationFrame(animatedOpen); requestAnimationFrame(animatedOpen);
setCache(ochildren());
} }
} else { } else {
if (element.open) { if (element.open) {
animatedClose(); animatedClose();
setCache(undefined);
} }
} }
}); });
const onClose = () => { const onClose = () => {
const srcElement = hero();
if (srcElement) {
srcElement.style.visibility = "unset";
}
element.close(); element.close();
setHero();
}; };
const animatedClose = () => { const animatedClose = () => {
const srcElement = hero();
const endRect = srcElement?.getBoundingClientRect();
if (endRect) {
const startRect = element.getBoundingClientRect();
const animation = animateHero(startRect, endRect, element, true);
animation.addEventListener("finish", onClose);
animation.addEventListener("cancel", onClose);
} else {
if (window.innerWidth > 560 && !props.bottomUp) { if (window.innerWidth > 560 && !props.bottomUp) {
onClose(); onClose();
return; return;
} }
const onAnimationEnd = () => { const onAnimationEnd = () => {
element.classList.remove("animated"); element.classList.remove("animated");
animation = undefined;
onClose(); onClose();
}; };
element.classList.add("animated"); element.classList.add("animated");
@ -88,17 +106,26 @@ const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
: animateSlideOutToRight(element, { easing: ANIM_CURVE_ACELERATION }); : animateSlideOutToRight(element, { easing: ANIM_CURVE_ACELERATION });
animation.addEventListener("finish", onAnimationEnd); animation.addEventListener("finish", onAnimationEnd);
animation.addEventListener("cancel", onAnimationEnd); animation.addEventListener("cancel", onAnimationEnd);
}
}; };
const animatedOpen = () => { const animatedOpen = () => {
element.showModal(); element.showModal();
if (props.bottomUp) { const srcElement = hero();
const startRect = srcElement?.getBoundingClientRect();
if (!startRect) {
console.debug("no source element");
}
if (startRect) {
srcElement!.style.visibility = "hidden";
const endRect = element.getBoundingClientRect();
animateHero(startRect, endRect, element);
} else if (props.bottomUp) {
animateSlideInFromBottom(element); animateSlideInFromBottom(element);
} else if (window.innerWidth <= 560) { } else if (window.innerWidth <= 560) {
element.classList.add("animated"); element.classList.add("animated");
const onAnimationEnd = () => { const onAnimationEnd = () => {
element.classList.remove("animated"); element.classList.remove("animated");
animation = undefined;
}; };
animation = animateSlideInFromRight(element, { animation = animateSlideInFromRight(element, {
easing: ANIM_CURVE_DECELERATION, easing: ANIM_CURVE_DECELERATION,
@ -108,6 +135,71 @@ const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
} }
}; };
const animateSlideInFromBottom = (
element: HTMLElement,
reserve?: boolean,
) => {
const rect = element.getBoundingClientRect();
const easing = "cubic-bezier(0.4, 0, 0.2, 1)";
element.classList.add("animated");
const oldOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const distance = Math.abs(rect.top - window.innerHeight);
const duration = (distance / MOVE_SPEED) * 1000;
animation = element.animate(
{
top: reserve
? [`${rect.top}px`, `${window.innerHeight}px`]
: [`${window.innerHeight}px`, `${rect.top}px`],
},
{ easing, duration },
);
const onAnimationEnd = () => {
element.classList.remove("animated");
document.body.style.overflow = oldOverflow;
animation = undefined;
};
animation.addEventListener("cancel", onAnimationEnd);
animation.addEventListener("finish", onAnimationEnd);
return animation;
};
const animateHero = (
startRect: DOMRect,
endRect: DOMRect,
element: HTMLElement,
reserve?: boolean,
) => {
const easing = ANIM_CURVE_STD;
element.classList.add("animated");
// distance_lt = (|top_start - top_end|^2 + |left_end - left_end|^2)^(-2)
const distancelt = Math.sqrt(
Math.pow(Math.abs(startRect.top - endRect.top), 2) +
Math.pow(Math.abs(startRect.left - endRect.left), 2),
);
const distancerb = Math.sqrt(
Math.pow(Math.abs(startRect.bottom - endRect.bottom), 2) +
Math.pow(Math.abs(startRect.right - endRect.right), 2),
);
const distance = distancelt + distancerb;
const duration = distance / 1.6;
animation = element.animate(
[
composeAnimationFrame(startRect, { transform: "none" }),
composeAnimationFrame(endRect, { transform: "none" }),
],
{ easing, duration },
);
const onAnimationEnd = () => {
element.classList.remove("animated");
animation = undefined;
};
animation.addEventListener("finish", onAnimationEnd);
animation.addEventListener("cancel", onAnimationEnd);
return animation;
};
onCleanup(() => { onCleanup(() => {
if (animation) { if (animation) {
animation.cancel(); animation.cancel();
@ -117,7 +209,6 @@ const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
const onDialogClick = ( const onDialogClick = (
event: MouseEvent & { currentTarget: HTMLDialogElement }, event: MouseEvent & { currentTarget: HTMLDialogElement },
) => { ) => {
event.stopPropagation();
if (event.target !== event.currentTarget) return; if (event.target !== event.currentTarget) return;
const rect = event.currentTarget.getBoundingClientRect(); const rect = event.currentTarget.getBoundingClientRect();
const isNotInDialog = const isNotInDialog =
@ -148,7 +239,7 @@ const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
tabIndex={-1} tabIndex={-1}
role="presentation" role="presentation"
> >
{child()} {ochildren() ?? cache()}
</dialog> </dialog>
); );
}; };

View file

@ -7,7 +7,6 @@
width: max-content; width: max-content;
box-shadow: var(--tutu-shadow-e8); box-shadow: var(--tutu-shadow-e8);
contain: content; contain: content;
overscroll-behavior: contain;
&.e1 { &.e1 {
box-shadow: var(--tutu-shadow-e9); box-shadow: var(--tutu-shadow-e9);

View file

@ -1,7 +1,6 @@
import { useWindowSize } from "@solid-primitives/resize-observer"; import { useWindowSize } from "@solid-primitives/resize-observer";
import { MenuList } from "@suid/material"; import { MenuList } from "@suid/material";
import { import {
batch,
createEffect, createEffect,
createSignal, createSignal,
splitProps, splitProps,
@ -14,8 +13,7 @@ import "./Menu.css";
import { import {
animateGrowFromTopRight, animateGrowFromTopRight,
animateShrinkToTopRight, animateShrinkToTopRight,
} from "~platform/anim"; } from "../platform/anim";
import type { MenuListProps } from "@suid/material/MenuList";
export type Anchor = Pick<DOMRect, "top" | "left" | "right"> & { e?: number }; export type Anchor = Pick<DOMRect, "top" | "left" | "right"> & { e?: number };
@ -24,7 +22,6 @@ export type MenuProps = ParentProps<
open?: boolean; open?: boolean;
onClose?: JSX.EventHandlerUnion<HTMLDialogElement, Event>; onClose?: JSX.EventHandlerUnion<HTMLDialogElement, Event>;
anchor: () => Anchor; anchor: () => Anchor;
MenuListProps?: MenuListProps;
id?: string; id?: string;
} & JSX.AriaAttributes } & JSX.AriaAttributes
@ -66,39 +63,11 @@ export function createManagedMenuState() {
return !!anchor(); return !!anchor();
}, },
anchor: anchor as () => Anchor, anchor: anchor as () => Anchor,
onClose: (event: Event) => { onClose: () => setAnchor(),
event.preventDefault();
return setAnchor();
},
}, },
] as const; ] as const;
} }
function animateGrowFromTopLeft(
element: HTMLElement,
opts?: Omit<KeyframeAnimationOptions, "duration">,
) {
const rend = element.getBoundingClientRect();
const overflow = element.style.overflow;
element.style.overflow = "hidden";
const duration = (rend.height / 1600) * 1000;
const animation = element.animate(
{
height: [`${rend.height / 2}px`, `${rend.height}px`],
width: [`${(rend.width / 4) * 3}px`, `${rend.width}px`],
},
{
duration,
...opts,
},
);
animation.addEventListener(
"finish",
() => (element.style.overflow = overflow),
);
return animation;
}
/** /**
* Material Menu Component. This component is * Material Menu Component. This component is
* implemented with dialog and {@link MenuList} from SUID. * implemented with dialog and {@link MenuList} from SUID.
@ -107,16 +76,10 @@ function animateGrowFromTopLeft(
* - Use {@link createManagedMenuState} and you don't need to manage the open and close. * - Use {@link createManagedMenuState} and you don't need to manage the open and close.
* - Use {@link MenuItem} from SUID as children. * - Use {@link MenuItem} from SUID as children.
*/ */
const Menu: Component<MenuProps> = (oprops) => { const Menu: Component<MenuProps> = (props) => {
let root: HTMLDialogElement; let root: HTMLDialogElement;
const windowSize = useWindowSize(); const windowSize = useWindowSize();
const [props, rest] = splitProps(oprops, [ const [, rest] = splitProps(props, ["open", "onClose", "anchor"]);
"open",
"onClose",
"anchor",
"MenuListProps",
"children",
]);
const [anchorPos, setAnchorPos] = createSignal<{ const [anchorPos, setAnchorPos] = createSignal<{
left?: number; left?: number;
@ -141,30 +104,16 @@ const Menu: Component<MenuProps> = (oprops) => {
let openAnimationOrigin: "lt" | "rt" = "lt"; let openAnimationOrigin: "lt" | "rt" = "lt";
const animateOpen = () => { createEffect(() => {
if (props.open) {
const a = props.anchor(); const a = props.anchor();
const { width } = windowSize;
const { left, top, right, e } = a;
const isOpened = root.open;
// There are incomplete animations.
// For `getBoundingClientRect()`, WebKit reports the initial state
// of the element, whilst Firefox reports the final state.
//
// We skip if animations are still on the element
// to avoid the problem on WebKit.
// Here use the final state.
//
// This is a dirty workaround. It's here because the feature is still
// works with it.
// I am curious that why the ones on the other parts are works. (Rubicon)
if (root.getAnimations().length > 0) {
return;
}
if (!root.open) {
root.showModal(); root.showModal();
const rend = root.getBoundingClientRect(); const rend = root.getBoundingClientRect();
const { width } = windowSize;
const { left, top, right, e } = a;
if (left > width / 2) { if (left > width / 2) {
openAnimationOrigin = "rt"; openAnimationOrigin = "rt";
setAnchorPos({ setAnchorPos({
@ -172,26 +121,34 @@ const Menu: Component<MenuProps> = (oprops) => {
top, top,
e, e,
}); });
animateGrowFromTopRight(root, { easing: ANIM_CURVE_STD });
} else { } else {
openAnimationOrigin = "lt"; openAnimationOrigin = "lt";
setAnchorPos({ left, top, e }); setAnchorPos({ left, top, e });
}
if (!isOpened) { const overflow = root.style.overflow;
switch (openAnimationOrigin) { root.style.overflow = "hidden";
case "lt": const duration = (rend.height / 1600) * 1000;
animateGrowFromTopLeft(root, { easing: ANIM_CURVE_STD }); const easing = ANIM_CURVE_STD;
break; const animation = root.animate(
case "rt": {
animateGrowFromTopRight(root, { easing: ANIM_CURVE_STD }); height: [`${rend.height / 2}px`, `${rend.height}px`],
break; width: [`${(rend.width / 4) * 3}px`, `${rend.width}px`],
},
{
duration,
easing,
},
);
animation.addEventListener(
"finish",
() => (root.style.overflow = overflow),
);
} }
} else {
// TODO: update the pos
} }
};
createEffect(() => {
if (props.open) {
animateOpen();
} else { } else {
animateClose(); animateClose();
} }
@ -226,42 +183,27 @@ const Menu: Component<MenuProps> = (oprops) => {
} }
}; };
const onDialogClick = ( return (
event: MouseEvent & { currentTarget: HTMLDialogElement }, <dialog
) => { ref={root!}
event.stopPropagation(); onClose={props.onClose}
if (event.currentTarget !== event.target) return; onClick={(e) => {
if (!event.currentTarget.open) return; if (e.target === root) {
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) {
if (props.onClose) { if (props.onClose) {
if (Array.isArray(props.onClose)) { if (Array.isArray(props.onClose)) {
props.onClose[0](props.onClose[1], event); props.onClose[0](props.onClose[1], e);
} else { } else {
( (
props.onClose as ( props.onClose as (
event: Event & { currentTarget: HTMLDialogElement }, event: Event & { currentTarget: HTMLDialogElement },
) => void ) => void
)(event); )(e);
} }
} }
e.stopPropagation();
} }
}; }}
class={`Menu e${anchorPos().e || 0}`}
return (
<dialog
ref={root!}
onClose={props.onClose}
onCancel={props.onClose}
onClick={onDialogClick}
class={`Menu e${anchorPos().e || "0"}`}
style={{ style={{
left: px(anchorPos().left), left: px(anchorPos().left),
top: px(anchorPos().top), top: px(anchorPos().top),
@ -271,8 +213,11 @@ const Menu: Component<MenuProps> = (oprops) => {
tabIndex={-1} tabIndex={-1}
{...rest} {...rest}
> >
<div class="container" role="presentation"> <div
<MenuList {...props.MenuListProps}>{props.children}</MenuList> class="container"
role="presentation"
>
<MenuList>{props.children}</MenuList>
</div> </div>
</dialog> </dialog>
); );

View file

@ -4,9 +4,6 @@
z-index: var(--tutu-zidx-nav, auto); z-index: var(--tutu-zidx-nav, auto);
.MuiToolbar-root { .MuiToolbar-root {
margin-left: var(--safe-area-inset-left);
margin-right: var(--safe-area-inset-right);
>.MuiButtonBase-root { >.MuiButtonBase-root {
&:first-child { &:first-child {
margin-left: -0.5em; margin-left: -0.5em;
@ -35,6 +32,7 @@
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);
} }
.Scaffold { .Scaffold {

View file

@ -153,8 +153,6 @@
--tutu-transition-shadow: box-shadow 175ms var(--tutu-anim-curve-std); --tutu-transition-shadow: box-shadow 175ms var(--tutu-anim-curve-std);
--tutu-zidx-nav: 1100; --tutu-zidx-nav: 1100;
accent-color: var(--tutu-color-primary);
} }
* { * {

View file

@ -1,5 +1,5 @@
import { Theme, createTheme } from "@suid/material/styles"; import { Theme, createTheme } from "@suid/material/styles";
import { deepPurple, amber, red } from "@suid/material/colors"; import { deepPurple, amber } from "@suid/material/colors";
import { Accessor } from "solid-js"; import { Accessor } from "solid-js";
/** /**
@ -12,9 +12,6 @@ export function useRootTheme(): Accessor<Theme> {
primary: { primary: {
main: deepPurple[500], main: deepPurple[500],
}, },
error: {
main: red[900],
},
secondary: { secondary: {
main: amber.A200, main: amber.A200,
}, },

4
src/overrides.d.ts vendored
View file

@ -11,10 +11,6 @@ interface ImportMetaEnv {
* Attach the overlay (in the dev mode) if it's `"true"`. * Attach the overlay (in the dev mode) if it's `"true"`.
*/ */
readonly VITE_DEVTOOLS_OVERLAY?: string; readonly VITE_DEVTOOLS_OVERLAY?: string;
/**
* Always use compatible version of Masonry.
*/
readonly VITE_PLATFROM_MASONRY_ALWAYS_COMPAT?: string
} }
interface ImportMeta { interface ImportMeta {

View file

@ -1,11 +0,0 @@
.CompatMasonry>* {
margin-bottom: var(--Masonry-row-gap);
}
@supports (grid-template-rows: masonry) {
.NativeMasonry {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(33%, min-content));
grid-template-rows: masonry;
}
}

View file

@ -1,162 +0,0 @@
import {
type Component,
type JSX,
splitProps,
type Ref,
createRenderEffect,
onCleanup,
createEffect,
createSignal,
} from "solid-js";
import { Dynamic, type DynamicProps } from "solid-js/web";
import MasonryLayout from "masonry-layout";
import { createElementSize } from "@solid-primitives/resize-observer";
import "./Masonry.css";
type MasonryContainer =
| Exclude<keyof JSX.IntrinsicElements, keyof JSX.SVGElementTags>
| Component<{
ref?: Ref<Element>;
class?: string;
}>;
type ElementOf<T extends MasonryContainer> =
T extends Exclude<keyof JSX.IntrinsicElements, keyof JSX.SVGElementTags>
? JSX.IntrinsicElements[T] extends { ref?: Ref<infer E> }
? E
: never
: T extends Component<{ ref?: Ref<infer E> }>
? E
: never;
function forwardRef<T>(value: T, ref?: Ref<T>) {
if (!ref) return;
(ref as (value: T) => void)(value);
}
function createCompatMasonry(
element: Element,
options: () => MasonryLayout.Options,
) {
const layout = new MasonryLayout(element, {
initLayout: false,
});
onCleanup(() => layout.destroy?.());
const size = createElementSize(element);
const treeMutObx = new MutationObserver(() => {
layout.reloadItems?.();
});
onCleanup(() => treeMutObx.disconnect());
createRenderEffect(() => {
const opts = options();
layout.option?.(opts);
});
createRenderEffect(() => {
treeMutObx.observe(element, { childList: true });
});
createRenderEffect(() => {
const width = size.width; // only tracking width
layout.layout?.();
});
if (import.meta.hot) {
import.meta.hot.on("vite:afterUpdate", () => {
layout.layout?.();
});
}
}
const supportsCSSMasonryLayout = /* @__PURE__ */ CSS.supports(
"grid-template-rows",
"masonry",
);
console.debug("supports css masonry layout", supportsCSSMasonryLayout);
const useNativeImpl = import.meta.env.VITE_PLATFROM_MASONRY_ALWAYS_COMPAT
? false
: supportsCSSMasonryLayout;
if (import.meta.env.VITE_PLATFROM_MASONRY_ALWAYS_COMPAT) {
console.warn(
"Masonry is in compat mode because VITE_PLATFORM_MASONRY_ALWAYS_COMPAT is enabled",
);
}
function MasonryCompat<T extends MasonryContainer>(
oprops: DynamicProps<T> & { class?: string },
) {
const [props, rest] = splitProps(oprops, ["ref", "class"]);
return (
<Dynamic
ref={(element: ElementOf<T>) => {
forwardRef(element, props.ref as Ref<typeof element> | undefined);
const [columnGap, setColumnGap] = createSignal<number>();
createCompatMasonry(element, () => {
return {
gutter: columnGap(),
};
});
createEffect(() => {
const computedStyle = window.getComputedStyle(element);
const rowGap = computedStyle.rowGap;
if (element instanceof HTMLElement) {
element.style.setProperty("--Masonry-row-gap", rowGap);
}
const colGap = computedStyle.columnGap;
if (colGap) {
setColumnGap(Number(colGap.slice(0, colGap.length - 2)));
}
});
}}
class={`Masonry CompatMasonry ${props.class || ""}`}
{...rest}
/>
);
}
function MasonryNative<T extends MasonryContainer>(
oprops: DynamicProps<T> & { class?: string },
) {
const [props, rest] = splitProps(oprops, ["class"]);
return (
<Dynamic class={`Masonry NativeMasonry ${props.class || ""}`} {...rest} />
);
}
/**
* Masonry Layout Container.
*
* **Native if possible** This component uses css masonry layout
* and fallback to masonry-layout if not supported. The children
* must have specified width and height.
*
* Testing native behaviour:
* - Firefox: in `about:config`, search for `layout.css.grid-template-masonry-value.enabled`
*
* Class `NativeMasonry` will be added to the element if it's under the
* css masonry layout, otherwise it's `CompatMasonry`. `Masonry` is always
* added.
*
* **Children Changes** As the children changed, reflow will be triggered,
* and there is might be a blink (or transition) for user. If it's not your
* intention, don't remove/add the direct children. Instead wraps them under
* containers and set the width and height on the container.
*
* **CSS compatibility** This component compatible to "gap" "row-gap"
* "column-gap" property. But they are read only once after the element mounted.
*/
export default useNativeImpl ? MasonryNative : MasonryCompat;

View file

@ -1,10 +1,10 @@
.StackedPage { .StackedPage {
contain: strict; container: StackedPage / size;
container: StackedPage / inline-size; display: contents;
width: 100vw; max-width: 100vw;
width: 100dvw; max-width: 100dvw;
height: 100vh;
height: 100dvh; contain: layout;
} }
dialog.StackedPage { dialog.StackedPage {
@ -13,26 +13,14 @@ dialog.StackedPage {
padding: 0; padding: 0;
overscroll-behavior: none; overscroll-behavior: none;
width: 560px; width: 560px;
max-width: 100vw;
max-width: 100dvw;
max-height: 100vh; max-height: 100vh;
max-height: 100dvh; max-height: 100dvh;
/*
* WebKit does not see contain-instric-size as the real element size.
* If the container does not have height, the child element using 100%
* height (usually Scafflod in our case) was have 0px computed height.
*
* This behaviour is different from Firefox. So we need to actually
* define the box height here. (Rubicon)
*/
height: 100vh;
height: 100dvh;
background: none; background: none;
display: none; display: none;
contain: strict; contain: strict;
contain-intrinsic-size: 560px 100vh; contain-intrinsic-size: auto 560px auto 100vh;
contain-intrinsic-size: 560px 100dvh; contain-intrinsic-size: auto 560px auto 100dvh;
content-visibility: auto; content-visibility: auto;
background: var(--tutu-color-surface); background: var(--tutu-color-surface);
@ -43,16 +31,18 @@ dialog.StackedPage {
@media (max-width: 560px) { @media (max-width: 560px) {
& { & {
margin: 0;
width: 100vw; width: 100vw;
width: 100dvw; width: 100dvw;
height: 100vh;
height: 100dvh;
contain-intrinsic-size: 100vw 100vh; contain-intrinsic-size: 100vw 100vh;
contain-intrinsic-size: 100dvw 100dvh; contain-intrinsic-size: 100dvw 100dvh;
} }
} }
&[open] { &[open] {
display: block; display: contents;
} }
&::backdrop { &::backdrop {

View file

@ -2,7 +2,6 @@ import { StaticRouter, type RouterProps } from "@solidjs/router";
import { import {
Component, Component,
createContext, createContext,
createMemo,
createRenderEffect, createRenderEffect,
createUniqueId, createUniqueId,
Index, Index,
@ -15,9 +14,10 @@ import {
import { createStore, unwrap } from "solid-js/store"; import { createStore, unwrap } from "solid-js/store";
import "./StackedRouter.css"; import "./StackedRouter.css";
import { animateSlideInFromRight, animateSlideOutToRight } from "./anim"; import { animateSlideInFromRight, animateSlideOutToRight } from "./anim";
import { ANIM_CURVE_DECELERATION, ANIM_CURVE_STD } from "~material/theme"; import { ANIM_CURVE_DECELERATION, ANIM_CURVE_STD } from "../material/theme";
import { makeEventListener } from "@solid-primitives/event-listener"; import {
import { useWindowSize } from "@solid-primitives/resize-observer"; makeEventListener,
} from "@solid-primitives/event-listener";
export type StackedRouterProps = Omit<RouterProps, "url">; export type StackedRouterProps = Omit<RouterProps, "url">;
@ -36,9 +36,9 @@ export type NewFrameOptions<T> = (T extends undefined
} }
: { state: T }) & { : { state: T }) & {
/** /**
* The new frame should replace the current frame or all the stack. * The new frame should replace the current frame.
*/ */
replace?: boolean | "all"; replace?: boolean;
/** /**
* The animatedOpen phase of the life cycle. * The animatedOpen phase of the life cycle.
* *
@ -79,11 +79,6 @@ export type Navigator<PushGuide = Record<string, any>> = {
const NavigatorContext = /* @__PURE__ */ createContext<Navigator>(); const NavigatorContext = /* @__PURE__ */ createContext<Navigator>();
/**
* Get the possible navigator of the {@link StackedRouter}.
*
* @see useNavigator for the navigator usage.
*/
export function useMaybeNavigator() { export function useMaybeNavigator() {
return useContext(NavigatorContext); return useContext(NavigatorContext);
} }
@ -96,8 +91,6 @@ export function useMaybeNavigator() {
* path and its state. If you need push guide, you may want to * path and its state. If you need push guide, you may want to
* define your own function (like `useAppNavigator`) and cast the * define your own function (like `useAppNavigator`) and cast the
* navigator to the type you need. * navigator to the type you need.
*
* @see {@link useMaybeNavigator} if you are not sure you are under a {@link StackedRouter}.
*/ */
export function useNavigator() { export function useNavigator() {
const navigator = useMaybeNavigator(); const navigator = useMaybeNavigator();
@ -117,20 +110,10 @@ export type CurrentFrame = {
const CurrentFrameContext = const CurrentFrameContext =
/* @__PURE__ */ createContext<Accessor<Readonly<CurrentFrame>>>(); /* @__PURE__ */ createContext<Accessor<Readonly<CurrentFrame>>>();
/**
* Return the current, if possible.
*
* @see {@link useCurrentFrame} asserts the frame exists
*/
export function useMaybeCurrentFrame() { export function useMaybeCurrentFrame() {
return useContext(CurrentFrameContext); return useContext(CurrentFrameContext);
} }
/**
* Return the current frame, assert the frame exists.
*
* @see {@link useMaybeCurrentFrame} if you are not sure you are under a {@link StackedRouter}.
*/
export function useCurrentFrame() { export function useCurrentFrame() {
const frame = useMaybeCurrentFrame(); const frame = useMaybeCurrentFrame();
@ -147,11 +130,8 @@ export function useCurrentFrame() {
* A suspended frame is the one not on the top. "Suspended" * A suspended frame is the one not on the top. "Suspended"
* is the description of a certain situtation, not in the life cycle * is the description of a certain situtation, not in the life cycle
* of a frame. * of a frame.
*
* If this is not called under a {@link StackedRouter}, it always
* returns `false`.
*/ */
export function useIsFrameSuspended() { export function useMaybeIsFrameSuspended() {
const { frames } = useMaybeNavigator() || {}; const { frames } = useMaybeNavigator() || {};
if (typeof frames === "undefined") { if (typeof frames === "undefined") {
@ -223,199 +203,11 @@ function serializableStack(stack: readonly StackFrame[]) {
}); });
} }
function isNotInIOSSwipeToBackArea(x: number) {
return (
(x > 22 && x < window.innerWidth - 22) ||
(x < -22 && x > window.innerWidth + 22)
);
}
function onEntryTouchStart(event: TouchEvent) {
if (event.touches.length !== 1) {
return;
}
const [fig0] = event.touches;
if (isNotInIOSSwipeToBackArea(fig0.clientX)) {
return;
}
event.preventDefault();
}
/**
* This function contains the state for swipe to back.
*
* @returns the props for dialogs to feature swipe to back.
*/
function createManagedSwipeToBack(
stack: readonly Readonly<StackFrame>[],
onlyPopFrame: (depth: number) => void,
) {
let reenterableAnimation: Animation | undefined;
let origWidth = 0,
origFigX = 0,
origFigY = 0;
const resetAnimation = () => {
reenterableAnimation = undefined;
};
const onDialogTouchStart = (
event: TouchEvent & { currentTarget: HTMLDialogElement },
) => {
if (event.touches.length !== 1) {
return;
}
event.stopPropagation();
const [fig0] = event.touches;
const { width } = event.currentTarget.getBoundingClientRect();
origWidth = width;
origFigX = fig0.clientX;
origFigY = fig0.clientY;
if (isNotInIOSSwipeToBackArea(fig0.clientX)) {
return;
}
// Prevent the default swipe to back/forward on iOS
event.preventDefault();
};
let animationProgressUpdateReleased = true;
let nextAnimationProgress = 0;
const updateAnimationProgress = () => {
try {
if (!reenterableAnimation) return;
const { activeDuration, delay } =
reenterableAnimation.effect!.getComputedTiming();
const totalTime = (delay || 0) + Number(activeDuration);
reenterableAnimation.currentTime = totalTime * nextAnimationProgress;
} finally {
animationProgressUpdateReleased = true;
}
};
const onDialogTouchMove = (
event: TouchEvent & { currentTarget: HTMLDialogElement },
) => {
if (event.touches.length !== 1) {
if (reenterableAnimation) {
reenterableAnimation.reverse();
reenterableAnimation.play();
}
}
const [fig0] = event.touches;
const ofsX = fig0.clientX - origFigX;
if (!reenterableAnimation) {
if (!(ofsX > 22) || !(Math.abs(fig0.clientY - origFigY) < 44)) {
return;
}
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);
}
event.preventDefault();
event.stopPropagation();
nextAnimationProgress = ofsX / origWidth / window.devicePixelRatio;
if (animationProgressUpdateReleased) {
animationProgressUpdateReleased = false;
requestAnimationFrame(updateAnimationProgress);
}
};
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 {
"on:touchstart": onDialogTouchStart,
"on:touchmove": onDialogTouchMove,
"on:touchend": onDialogTouchEnd,
"on:touchcancel": onDialogTouchCancel,
};
}
/** /**
* The router that stacks the pages. * The router that stacks the pages.
*
* **Routes** The router accepts the {@link RouterProps} excluding the "url" field.
* You can seamlessly use the `<Route />` from `@solidjs/router`.
*
* Be advised that this component is not a drop-in replacement of that router.
* These primitives from `@solidjs/router` won't work correctly:
*
* - `<A />` component - use ~platform/A instead
* - `useLocation()` - see {@link useCurrentFrame}
* - `useNavigate()` - see {@link useNavigator}
*
* The other primitives may work, as long as they don't rely on the global location.
* This component uses `@solidjs/router` {@link StaticRouter} to route.
*
* **Injecting Safe Area Insets** The router calculate correct
* `--safe-area-inset-left` and `--safe-area-inset-right` from the window
* width and `--safe-area-inset-*` from the :root element. That means
* the injected insets do not reflects the overrides that are not on the :root.
*
* The recalculation is only performed when the window size changed.
*
* **Navigation Animation** The router provides default animation for
* navigation.
*
* If the default animation does not met your requirement,
* this component is also intergated with Web Animation API.
* You can provide {@link NewFrameOptions.animateOpen} and
* {@link NewFrameOptions.animateClose} to define custom animation.
*
* **Swipe to back** For the subpages (the pages stacked on the entry),
* swipe to back gesture is provided for user experience.
*
* Navigation animations (even the custom ones) will be played during
* swipe to back, please keep in mind when designing animations.
*
* The iOS default gesture is blocked on all pages.
*/ */
const StackedRouter: Component<StackedRouterProps> = (oprops) => { const StackedRouter: Component<StackedRouterProps> = (oprops) => {
const [stack, mutStack] = createStore([] as StackFrame[], { name: "stack" }); const [stack, mutStack] = createStore([] as StackFrame[], { name: "stack" });
const windowSize = useWindowSize();
const pushFrame = (path: string, opts?: Readonly<NewFrameOptions<any>>) => const pushFrame = (path: string, opts?: Readonly<NewFrameOptions<any>>) =>
untrack(() => { untrack(() => {
@ -427,19 +219,11 @@ const StackedRouter: Component<StackedRouterProps> = (oprops) => {
animateClose: opts?.animateClose, animateClose: opts?.animateClose,
}; };
const replace = opts?.replace; mutStack(opts?.replace ? stack.length - 1 : stack.length, frame);
if (replace === "all") { if (opts?.replace) {
mutStack([frame]); window.history.replaceState(serializableStack(stack), "", path);
} else { } else {
mutStack(replace ? stack.length - 1 : stack.length, frame); window.history.pushState(serializableStack(stack), "", path);
}
const savedStack = serializableStack(stack);
if (replace) {
window.history.replaceState(savedStack, "", path);
} else {
window.history.pushState(savedStack, "", path);
} }
return frame; return frame;
}); });
@ -477,10 +261,7 @@ const StackedRouter: Component<StackedRouterProps> = (oprops) => {
createRenderEffect(() => { createRenderEffect(() => {
if (stack.length === 0) { if (stack.length === 0) {
mutStack(0, { pushFrame(window.location.pathname);
path: window.location.pathname,
rootId: createUniqueId(),
});
} }
}); });
@ -511,39 +292,89 @@ const StackedRouter: Component<StackedRouterProps> = (oprops) => {
}); });
}; };
const subInsets = createMemo(() => { let reenterableAnimation: Animation | undefined;
const SUBPAGE_MAX_WIDTH = 560; let origX = 0,
const { width } = windowSize; origWidth = 0;
if (width <= SUBPAGE_MAX_WIDTH) {
// page width = 100vw, use the inset directly
return {};
}
const computedStyle = window.getComputedStyle(
document.querySelector(":root")!,
);
const oinsetLeft = computedStyle
.getPropertyValue("--safe-area-inset-left")
.split("px", 1)[0];
const oinsetRight = computedStyle
.getPropertyValue("--safe-area-inset-right")
.split("px", 1)[0];
const left = Number(oinsetLeft),
right = Number(oinsetRight.slice(0, oinsetRight.length - 2));
const totalWidth = SUBPAGE_MAX_WIDTH + left + right;
if (width >= totalWidth) {
return {
"--safe-area-inset-left": "0px",
"--safe-area-inset-right": "0px",
};
}
const ofs = (totalWidth - width) / 2;
return {
"--safe-area-inset-left": `${Math.max(left - ofs, 0)}px`,
"--safe-area-inset-right": `${Math.max(right - ofs, 0)}px`,
};
});
const swipeToBackProps = createManagedSwipeToBack(stack, onlyPopFrame); 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 ( return (
<NavigatorContext.Provider <NavigatorContext.Provider
@ -571,7 +402,6 @@ const StackedRouter: Component<StackedRouterProps> = (oprops) => {
class="StackedPage" class="StackedPage"
id={frame().rootId} id={frame().rootId}
role="presentation" role="presentation"
on:touchstart={onEntryTouchStart}
> >
<StaticRouter url={frame().path} {...oprops} /> <StaticRouter url={frame().path} {...oprops} />
</div> </div>
@ -582,9 +412,11 @@ const StackedRouter: Component<StackedRouterProps> = (oprops) => {
class="StackedPage" class="StackedPage"
onCancel={[popFrame, 1]} onCancel={[popFrame, 1]}
onClick={[onDialogClick, popFrame]} onClick={[onDialogClick, popFrame]}
{...swipeToBackProps} onTouchStart={onDialogTouchStart}
onTouchMove={onDialogTouchMove}
onTouchEnd={onDialogTouchEnd}
onTouchCancel={onDialogTouchCancel}
id={frame().rootId} id={frame().rootId}
style={subInsets()}
> >
<StaticRouter url={frame().path} {...oprops} /> <StaticRouter url={frame().path} {...oprops} />
</dialog> </dialog>

View file

@ -1,3 +1,58 @@
import {
createContext,
createRenderEffect,
createSignal,
untrack,
useContext,
type Accessor,
type Signal,
} from "solid-js";
export type HeroSource = {
[key: string | symbol | number]: HTMLElement | undefined;
};
const HeroSourceContext = createContext<Signal<HeroSource>>(
/* __@PURE__ */ undefined,
);
export const HeroSourceProvider = HeroSourceContext.Provider;
export function useHeroSource() {
return useContext(HeroSourceContext);
}
/**
* Use hero value for the {@link key}.
*
* Note: the setter here won't set the value of the hero source.
* To set hero source, use {@link useHeroSource} and set the corresponding key.
*/
export function useHeroSignal(
key: string | symbol | number,
): Signal<HTMLElement | undefined> {
const source = useHeroSource();
if (source) {
const [get, set] = createSignal<HTMLElement>();
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, set];
} else {
console.debug("no hero source");
return [() => undefined, () => undefined];
}
}
export function animateRollOutFromTop( export function animateRollOutFromTop(
root: HTMLElement, root: HTMLElement,

View file

@ -1,12 +1,12 @@
import { import {
catchError, ParentComponent,
createContext, createContext,
createMemo, createMemo,
createResource, createResource,
useContext, useContext,
} from "solid-js"; } from "solid-js";
import { match } from "@formatjs/intl-localematcher"; import { match } from "@formatjs/intl-localematcher";
import { Accessor } from "solid-js"; import { Accessor, createEffect, createSignal } from "solid-js";
import { $settings } from "../settings/stores"; import { $settings } from "../settings/stores";
import { enGB } from "date-fns/locale/en-GB"; import { enGB } from "date-fns/locale/en-GB";
import { useStore } from "@nanostores/solid"; import { useStore } from "@nanostores/solid";
@ -17,6 +17,13 @@ import {
type Template, type Template,
} from "@solid-primitives/i18n"; } from "@solid-primitives/i18n";
async function synchronised(
name: string,
callback: () => Promise<void> | void,
): Promise<void> {
await navigator.locks.request(name, callback);
}
export const SUPPORTED_LANGS = ["en", "zh-Hans"] as const; export const SUPPORTED_LANGS = ["en", "zh-Hans"] as const;
export const SUPPORTED_REGIONS = ["en_US", "en_GB", "zh_CN"] as const; export const SUPPORTED_REGIONS = ["en_US", "en_GB", "zh_CN"] as const;
@ -31,6 +38,14 @@ export function autoMatchLangTag() {
return match(Array.from(navigator.languages), SUPPORTED_LANGS, DEFAULT_LANG); return match(Array.from(navigator.languages), SUPPORTED_LANGS, DEFAULT_LANG);
} }
const DateFnLocaleCx = /* __@PURE__ */ createContext<Accessor<Locale>>(
() => enGB,
);
const cachedDateFnLocale: Record<string, Locale> = {
enGB,
};
export function autoMatchRegion() { export function autoMatchRegion() {
const specifiers = navigator.languages.map((x) => x.split("-")); const specifiers = navigator.languages.map((x) => x.split("-"));
@ -43,7 +58,7 @@ export function autoMatchRegion() {
} }
} }
} else if (s.length === 2) { } else if (s.length === 2) {
const [lang, region] = s; const [lang, region] = s[1];
for (const available of SUPPORTED_REGIONS) { for (const available of SUPPORTED_REGIONS) {
if (available.toLowerCase() === `${lang}_${region}`.toLowerCase()) { if (available.toLowerCase() === `${lang}_${region}`.toLowerCase()) {
return available; return available;
@ -55,7 +70,7 @@ export function autoMatchRegion() {
return "en_GB"; return "en_GB";
} }
export function createCurrentRegion() { export function useRegion() {
const appSettings = useStore($settings); const appSettings = useStore($settings);
return createMemo( return createMemo(
@ -85,6 +100,53 @@ async function importDateFnLocale(tag: string): Promise<Locale> {
} }
} }
/**
* Provides runtime values and fetch dependencies for date-fns locale
*/
export const DateFnScope: ParentComponent = (props) => {
const [dateFnLocale, setDateFnLocale] = createSignal(enGB, {
name: "dateFnLocale",
});
const region = useRegion();
createEffect(() => {
const dateFnLocaleName = region();
if (cachedDateFnLocale[dateFnLocaleName]) {
setDateFnLocale(cachedDateFnLocale[dateFnLocaleName]);
} else {
synchronised("i18n-wrapper-load-date-fns-locale", async () => {
if (cachedDateFnLocale[dateFnLocaleName]) {
setDateFnLocale(cachedDateFnLocale[dateFnLocaleName]);
return;
}
const target = `date-fns/locale/${dateFnLocaleName}`;
try {
const mod = await importDateFnLocale(dateFnLocaleName);
cachedDateFnLocale[dateFnLocaleName] = mod;
setDateFnLocale(mod);
} catch (reason) {
console.error(
{
act: "load-date-fns-locale",
stat: "failed",
reason,
target,
},
"failed to load date-fns locale",
);
}
});
}
});
return (
<DateFnLocaleCx.Provider value={dateFnLocale}>
{props.children}
</DateFnLocaleCx.Provider>
);
};
/** /**
* Get the {@link Locale} object for date-fns. * Get the {@link Locale} object for date-fns.
* *
@ -93,11 +155,11 @@ async function importDateFnLocale(tag: string): Promise<Locale> {
* @returns Accessor for Locale * @returns Accessor for Locale
*/ */
export function useDateFnLocale(): Accessor<Locale> { export function useDateFnLocale(): Accessor<Locale> {
const { dateFn } = useAppLocale(); const cx = useContext(DateFnLocaleCx);
return dateFn; return cx;
} }
export function createCurrentLanguage() { export function useLanguage() {
const settings = useStore($settings); const settings = useStore($settings);
return () => settings().language || autoMatchLangTag(); return () => settings().language || autoMatchLangTag();
} }
@ -117,7 +179,7 @@ type MergedImportedModule<T> = T extends []
export function createStringResource< export function createStringResource<
T extends ImportFn<Record<string, string | Template<any> | undefined>>[], T extends ImportFn<Record<string, string | Template<any> | undefined>>[],
>(...importFns: T) { >(...importFns: T) {
const language = createCurrentLanguage(); const language = useLanguage();
const cache: Record<string, MergedImportedModule<T>> = {}; const cache: Record<string, MergedImportedModule<T>> = {};
return createResource( return createResource(
@ -147,38 +209,3 @@ export function createTranslator<
return [translator(res[0], resolveTemplate), res] as const; return [translator(res[0], resolveTemplate), res] as const;
} }
export type AppLocale = {
dateFn: () => Locale;
language: () => string;
region: () => string;
};
const AppLocaleContext = /* @__PURE__ */ createContext<AppLocale>();
export const AppLocaleProvider = AppLocaleContext.Provider;
export function useAppLocale() {
const l = useContext(AppLocaleContext);
if (!l) {
throw new TypeError("app locale not found");
}
return l;
}
export function createDateFnLocaleResource(region: () => string) {
const [localeUncaught] = createResource(
region,
async (region) => {
return await importDateFnLocale(region);
},
{ initialValue: enGB },
);
return createMemo(
() =>
catchError(localeUncaught, (reason) => {
console.error("fetch date-fns locale", reason);
}) ?? enGB,
);
}

View file

@ -1,7 +1,14 @@
//! This module has side effect. //! This module has side effect.
//! It recommended to include the module by <script> tag. //! It recommended to include the module by <script> tag.
if (typeof document.body.animate === "undefined") {
// @ts-ignore: this file is polyfill, no exposed decls
import("web-animations-js").then(() => {
// all target platforms supported, prepared to remove
console.warn("web animation polyfill is included");
});
}
if (typeof window.crypto.randomUUID === "undefined") { if (typeof window.crypto.randomUUID === "undefined") {
// TODO: this polyfill can be removed in 2.0, see https://code.lightstands.xyz/Rubicon/tutu/issues/36
// Chrome/Edge 92+ // Chrome/Edge 92+
// https://stackoverflow.com/a/2117523/2800218 // https://stackoverflow.com/a/2117523/2800218
// LICENSE: https://creativecommons.org/licenses/by-sa/4.0/legalcode // LICENSE: https://creativecommons.org/licenses/by-sa/4.0/legalcode

View file

@ -4,8 +4,8 @@ import {
onCleanup, onCleanup,
type Component, type Component,
} from "solid-js"; } from "solid-js";
import Scaffold from "~material/Scaffold"; import Scaffold from "../material/Scaffold";
import BottomSheet from "~material/BottomSheet"; import BottomSheet from "../material/BottomSheet";
import { import {
Button, Button,
IconButton, IconButton,
@ -14,9 +14,9 @@ import {
Toolbar, Toolbar,
} from "@suid/material"; } from "@suid/material";
import { Close as CloseIcon, ContentCopy } from "@suid/icons-material"; import { Close as CloseIcon, ContentCopy } from "@suid/icons-material";
import { Title } from "~material/typography"; import { Title } from "../material/typography";
import { render } from "solid-js/web"; import { render } from "solid-js/web";
import { useRootTheme } from "~material/theme"; import { useRootTheme } from "../material/theme";
const ShareBottomSheet: Component<{ const ShareBottomSheet: Component<{
data?: ShareData; data?: ShareData;

View file

@ -1,5 +1,5 @@
.Profile { .Profile {
overflow: hidden auto; height: 100%;
.intro { .intro {
background-color: var(--tutu-color-surface-d); background-color: var(--tutu-color-surface-d);
@ -8,17 +8,20 @@
display: flex; display: flex;
flex-flow: column nowrap; flex-flow: column nowrap;
gap: 16px; gap: 16px;
contain: layout style;
} }
.banner { .banner {
width: 100%; width: 100%;
margin-top: calc(-1 * var(--scaffold-topbar-height)); margin-top: calc(-1 * (var(--scaffold-topbar-height) + var(--safe-area-inset-top)));
>img { >img {
object-fit: cover; object-fit: cover;
width: 100%; width: 100%;
height: 100%; height: 100%;
&::before {
visibility: hidden;
}
} }
} }
@ -29,10 +32,6 @@
} }
word-break: break-all; word-break: break-all;
& * {
user-select: text;
}
} }
.acct-grp { .acct-grp {
@ -54,18 +53,6 @@
.name-grp { .name-grp {
display: flex; display: flex;
flex-flow: column nowrap; flex-flow: column nowrap;
& * {
user-select: text;
}
.display-name {
display: block;
}
.username {
user-select: all;
}
} }
.acct-mark { .acct-mark {
@ -74,7 +61,9 @@
margin-right: 0.25em; margin-right: 0.25em;
} }
.display-name {
display: block;
}
table.acct-fields { table.acct-fields {
word-break: break-all; word-break: break-all;
@ -93,10 +82,6 @@
& svg { & svg {
vertical-align: middle; vertical-align: middle;
} }
& * {
user-select: text;
}
} }
.toot-list-toolbar { .toot-list-toolbar {
@ -116,46 +101,6 @@
} }
} }
@supports (container-type: inline-size) {
@container StackedPage (inline-size >=960px) {
.Profile {
display: grid;
grid-template-columns: auto 560px;
grid-template-rows: min-content 1fr;
height: 100cqh;
>.topbar {
grid-column: 1 / 3;
grid-row: 1 /2;
.MuiToolbar-root {
padding-right: calc(560px + 24px);
}
}
>.details {
height: 100%;
display: flex;
flex-flow: column nowrap;
>.intro {
flex-grow: 1;
}
}
>.recent-toots {
overflow-y: auto;
margin-top: calc(-1 * var(--scaffold-topbar-height));
z-index: calc(var(--tutu-zidx-nav, 1) + 1);
>.toot-list-toolbar {
top: 0;
}
}
}
}
}
.Profile__page-title { .Profile__page-title {
flex-grow: 1; flex-grow: 1;
white-space: nowrap; white-space: nowrap;

View file

@ -12,7 +12,7 @@ import {
type Component, type Component,
createMemo, createMemo,
} from "solid-js"; } from "solid-js";
import Scaffold from "~material/Scaffold"; import Scaffold from "../material/Scaffold";
import { import {
AppBar, AppBar,
Avatar, Avatar,
@ -44,7 +44,7 @@ import {
Subject, Subject,
Verified, Verified,
} from "@suid/icons-material"; } from "@suid/icons-material";
import { Body2, Title } from "~material/typography"; import { Body2, Title } from "../material/typography";
import { 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";
@ -52,12 +52,12 @@ import { FastAverageColor } from "fast-average-color";
import { useWindowSize } from "@solid-primitives/resize-observer"; import { useWindowSize } from "@solid-primitives/resize-observer";
import { createTimeline, createTimelineSnapshot } from "../masto/timelines"; import { createTimeline, createTimelineSnapshot } from "../masto/timelines";
import TootList from "../timelines/TootList"; import TootList from "../timelines/TootList";
import { createTimeSource, TimeSourceProvider } from "~platform/timesrc"; import { createTimeSource, TimeSourceProvider } from "../platform/timesrc";
import TootFilterButton from "./TootFilterButton"; 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"; import { useNavigator } from "../platform/StackedRouter";
const Profile: Component = () => { const Profile: Component = () => {
const { pop } = useNavigator(); const { pop } = useNavigator();
@ -175,12 +175,12 @@ const Profile: Component = () => {
createRenderEffect(() => (e.innerHTML = sessionDisplayName())); createRenderEffect(() => (e.innerHTML = sessionDisplayName()));
}; };
const toggleSubscribeHome = async (event: Event) => { const toggleSubscribeHome = async () => {
const client = session().client; const client = session().client;
if (!session().account) return; if (!session().account) return;
const isSubscribed = relationship()?.following ?? false; const isSubscribed = relationship()?.following ?? false;
mutateRelationship((x) => Object.assign({ following: !isSubscribed }, x)); mutateRelationship((x) => Object.assign({ following: !isSubscribed }, x));
subscribeMenuState.onClose(event); subscribeMenuState.onClose();
if (isSubscribed) { if (isSubscribed) {
const nrel = await client.v1.accounts.$select(params.id).unfollow(); const nrel = await client.v1.accounts.$select(params.id).unfollow();
@ -237,7 +237,6 @@ const Profile: Component = () => {
} }
class="Profile" class="Profile"
> >
<div class="details" role="presentation">
<Menu <Menu
id={optMenuId} id={optMenuId}
open={menuOpen()} open={menuOpen()}
@ -425,9 +424,7 @@ const Profile: Component = () => {
aria-label="Display name" aria-label="Display name"
></Body2> ></Body2>
</div> </div>
<span aria-label="Complete username" class="username"> <span aria-label="Complete username">{fullUsername()}</span>
{fullUsername()}
</span>
</div> </div>
<div role="presentation"> <div role="presentation">
<Switch> <Switch>
@ -496,9 +493,7 @@ const Profile: Component = () => {
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
<div class="recent-toots" role="presentation">
<div class="toot-list-toolbar"> <div class="toot-list-toolbar">
<TootFilterButton <TootFilterButton
options={{ options={{
@ -551,7 +546,6 @@ const Profile: Component = () => {
</IconButton> </IconButton>
</div> </div>
</Show> </Show>
</div>
</Scaffold> </Scaffold>
); );
}; };

View file

@ -1,6 +1,6 @@
import { Button, MenuItem, Checkbox, ListItemText } from "@suid/material"; import { Button, MenuItem, Checkbox, ListItemText } from "@suid/material";
import { createMemo, createSignal, createUniqueId, For } from "solid-js"; import { createMemo, createSignal, createUniqueId, For } from "solid-js";
import Menu from "~material/Menu"; import Menu from "../material/Menu";
import { FilterList, FilterListOff } from "@suid/icons-material"; import { FilterList, FilterListOff } from "@suid/icons-material";
type Props<Filters extends Record<string, string>> = { type Props<Filters extends Record<string, string>> = {

View file

@ -1,5 +1,5 @@
import { createMemo, For, type Component, type JSX } from "solid-js"; import { createMemo, For, type Component, type JSX } from "solid-js";
import Scaffold from "~material/Scaffold"; import Scaffold from "../material/Scaffold";
import { import {
AppBar, AppBar,
IconButton, IconButton,
@ -19,17 +19,17 @@ import {
autoMatchLangTag, autoMatchLangTag,
createTranslator, createTranslator,
SUPPORTED_LANGS, SUPPORTED_LANGS,
} 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 { useStore } from "@nanostores/solid"; import { useStore } from "@nanostores/solid";
import { $settings } from "./stores"; import { $settings } from "./stores";
import { useNavigator } from "~platform/StackedRouter"; import { useNavigator } from "../platform/StackedRouter";
const ChooseLang: Component = () => { const ChooseLang: Component = () => {
const { pop } = useNavigator(); const { pop } = useNavigator();
const [t] = createTranslator( const [t] = createTranslator(
() => import("./i18n/generic.json"), () => import("./i18n/lang-names.json"),
(code) => (code) =>
import(`./i18n/${code}.json`) as Promise<{ import(`./i18n/${code}.json`) as Promise<{
default: Record<string, string | undefined> & { default: Record<string, string | undefined> & {

View file

@ -1,5 +1,5 @@
import type { Component } from "solid-js"; import type { Component } from "solid-js";
import Scaffold from "~material/Scaffold"; import Scaffold from "../material/Scaffold";
import { import {
AppBar, AppBar,
Divider, Divider,
@ -12,12 +12,12 @@ import {
Switch, Switch,
Toolbar, Toolbar,
} from "@suid/material"; } from "@suid/material";
import { Title } from "~material/typography"; import { Title } from "../material/typography";
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"; import { useNavigator } from "../platform/StackedRouter";
const Motions: Component = () => { const Motions: Component = () => {
const {pop} = useNavigator(); const {pop} = useNavigator();

View file

@ -1,5 +1,5 @@
import { createMemo, For, type Component, type JSX } from "solid-js"; import { createMemo, For, type Component, type JSX } from "solid-js";
import Scaffold from "~material/Scaffold"; import Scaffold from "../material/Scaffold";
import { import {
AppBar, AppBar,
IconButton, IconButton,
@ -17,17 +17,17 @@ import {
autoMatchRegion, autoMatchRegion,
createTranslator, createTranslator,
SUPPORTED_REGIONS, SUPPORTED_REGIONS,
} 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 { $settings } from "./stores"; import { $settings } from "./stores";
import { useStore } from "@nanostores/solid"; import { useStore } from "@nanostores/solid";
import { useNavigator } from "~platform/StackedRouter"; import { useNavigator } from "../platform/StackedRouter";
const ChooseRegion: Component = () => { const ChooseRegion: Component = () => {
const {pop} = useNavigator(); const {pop} = useNavigator();
const [t] = createTranslator( const [t] = createTranslator(
() => import("./i18n/generic.json"), () => import("./i18n/lang-names.json"),
(code) => (code) =>
import(`./i18n/${code}.json`) as Promise<{ import(`./i18n/${code}.json`) as Promise<{
default: Record<string, string | undefined> & { default: Record<string, string | undefined> & {

View file

@ -1,5 +1,9 @@
import { For, Show, type Component } from "solid-js"; import {
import Scaffold from "~material/Scaffold.js"; For,
Show,
type Component,
} from "solid-js";
import Scaffold from "../material/Scaffold.js";
import { import {
AppBar, AppBar,
Divider, Divider,
@ -23,8 +27,8 @@ import {
Refresh as RefreshIcon, Refresh as RefreshIcon,
Translate as TranslateIcon, Translate as TranslateIcon,
} from "@suid/icons-material"; } from "@suid/icons-material";
import A from "~platform/A.js"; import A from "../platform/A.js";
import { Title } from "~material/typography.js"; 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";
import { format } from "date-fns"; import { format } from "date-fns";
@ -35,11 +39,11 @@ import {
autoMatchRegion, autoMatchRegion,
createTranslator, createTranslator,
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 { 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"; import { useNavigator } from "../platform/StackedRouter.jsx";
type Inset = { type Inset = {
top?: number; top?: number;
@ -161,9 +165,9 @@ const Settings: Component = () => {
import(`./i18n/${code}.json`) as Promise<{ import(`./i18n/${code}.json`) as Promise<{
default: Strings; default: Strings;
}>, }>,
() => import(`./i18n/generic.json`), () => import(`./i18n/lang-names.json`),
); );
const { pop } = useNavigator(); const {pop} = useNavigator();
const settings$ = useStore($settings); const settings$ = useStore($settings);
const { needRefresh } = useServiceWorker(); const { needRefresh } = useServiceWorker();
const dateFnLocale = useDateFnLocale(); const dateFnLocale = useDateFnLocale();
@ -181,9 +185,6 @@ const Settings: Component = () => {
.setting-list { .setting-list {
padding-bottom: calc(var(--safe-area-inset-bottom, 0px) + 16px); padding-bottom: calc(var(--safe-area-inset-bottom, 0px) + 16px);
overflow: hidden auto;
height: calc(100vh - var(--scaffold-topbar-height, 0));
height: calc(100dvh - var(--scaffold-topbar-height, 0));
} }
`; `;
return ( return (

View file

@ -4,7 +4,7 @@ import {
type Component, type Component,
type JSX, type JSX,
} from "solid-js"; } from "solid-js";
import Scaffold from "~material/Scaffold"; import Scaffold from "../material/Scaffold";
import { import {
AppBar, AppBar,
IconButton, IconButton,
@ -17,8 +17,8 @@ import {
} from "@suid/material"; } from "@suid/material";
import { Close as CloseIcon } from "@suid/icons-material"; import { Close as CloseIcon } from "@suid/icons-material";
import iso639_1 from "iso-639-1"; import iso639_1 from "iso-639-1";
import { createTranslator } from "~platform/i18n"; import { createTranslator } from "../platform/i18n";
import { Title } from "~material/typography"; import { Title } from "../material/typography";
type ChooseTootLangProps = { type ChooseTootLangProps = {
code: string; code: string;

View file

@ -0,0 +1,57 @@
import type { mastodon } from "masto";
import { Show, type Component } from "solid-js";
import tootStyle from "./toot.module.css";
import { formatRelative } from "date-fns";
import Img from "../material/Img";
import { Body2 } from "../material/typography";
import { appliedCustomEmoji } from "../masto/toot";
import { TootPreviewCard } from "./RegularToot";
type CompactTootProps = {
status: mastodon.v1.Status;
now: Date;
class?: string;
};
const CompactToot: Component<CompactTootProps> = (props) => {
const toot = () => props.status;
return (
<section
class={[tootStyle.toot, tootStyle.compact, props.class || ""].join(" ")}
lang={toot().language || undefined}
>
<Img
src={toot().account.avatar}
class={[tootStyle.tootAvatar].join(" ")}
/>
<div class={[tootStyle.compactAuthorGroup].join(" ")}>
<Body2
ref={(e: { innerHTML: string }) => {
appliedCustomEmoji(
e,
toot().account.displayName,
toot().account.emojis,
);
}}
></Body2>
<span class={tootStyle.compactAuthorUsername}>
@{toot().account.username}@{new URL(toot().account.url).hostname}
</span>
<time datetime={toot().createdAt}>
{formatRelative(props.now, toot().createdAt)}
</time>
</div>
<div
ref={(e: { innerHTML: string }) => {
appliedCustomEmoji(e, toot().content, toot().emojis);
}}
class={[tootStyle.compactTootContent].join(" ")}
></div>
<Show when={toot().card}>
<TootPreviewCard src={toot().card!} alwaysCompact />
</Show>
</section>
);
};
export default CompactToot;

View file

@ -6,7 +6,7 @@ import {
createRenderEffect, createRenderEffect,
} from "solid-js"; } from "solid-js";
import { useDocumentTitle } from "../utils"; import { useDocumentTitle } from "../utils";
import Scaffold from "~material/Scaffold"; import Scaffold from "../material/Scaffold";
import { import {
AppBar, AppBar,
ListItemSecondaryAction, ListItemSecondaryAction,
@ -16,10 +16,10 @@ import {
Toolbar, Toolbar,
} from "@suid/material"; } from "@suid/material";
import { css } from "solid-styled"; import { css } from "solid-styled";
import { TimeSourceProvider, createTimeSource } from "~platform/timesrc"; import { TimeSourceProvider, createTimeSource } from "../platform/timesrc";
import ProfileMenuButton from "./ProfileMenuButton"; 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 { $settings } from "../settings/stores"; import { $settings } from "../settings/stores";
import { useStore } from "@nanostores/solid"; import { useStore } from "@nanostores/solid";

View file

@ -1,48 +1,27 @@
.MediaAttachmentGrid { .MediaAttachmentGrid {
/* Note: MeidaAttachmentGrid has hard-coded layout calcalation */ /* Note: MeidaAttachmentGrid has hard-coded layout calcalation */
margin-top: 1em; margin-top: 1em;
margin-left: var(--card-pad, 0); margin-left: calc(var(--card-pad, 0) + var(--toot-avatar-size, 0) + 8px);
margin-right: var(--card-pad, 0); margin-right: var(--card-pad, 0);
contain: layout style;
gap: 4px; gap: 4px;
>* { > :where(img, video) {
max-height: 35vh; max-height: 35vh;
min-height: 40px; min-height: 40px;
min-width: 40px; min-width: 40px;
object-fit: contain;
max-width: 100%; max-width: 100%;
contain: strict; background-color: var(--tutu-color-surface-d);
content-visibility: auto;
background-color: var(--media-color-accent, var(--tutu-color-surface-d));
border-radius: 2px; border-radius: 2px;
border: 1px solid var(--tutu-color-surface-d); border: 1px solid var(--tutu-color-surface-d);
transition: outline-width 60ms var(--tutu-anim-curve-std), border-color 60ms var(--tutu-anim-curve-std); transition: outline-width 60ms var(--tutu-anim-curve-std), border-color 60ms var(--tutu-anim-curve-std);
contain: strict;
content-visibility: auto;
&:hover, &:hover,
&:focus-visible { &:focus-visible {
outline: 8px solid var(--media-color-accent, var(--tutu-color-surface-d)); outline: 8px solid var(--media-color-accent, var(--tutu-color-surface-d));
border-color: var(--media-color-accent, var(--tutu-color-surface-d)); border-color: var(--media-color-accent, var(--tutu-color-surface-d));
z-index: calc(var(--tutu-zidx-nav) - 1);
} }
} }
>*>* {
width: 100%;
height: 100%;
}
>*> :where(img, video) {
object-fit: contain;
}
>*>.sensitive-placeholder {
display: inline-flex;
display: inline flex;
align-items: center;
justify-content: center;
}
}
:where(.thread-top, .thread-mid)>.MediaAttachmentGrid {
margin-left: calc(var(--card-pad, 0) + var(--toot-avatar-size, 0) + 8px);
} }

View file

@ -0,0 +1,204 @@
import type { mastodon } from "masto";
import {
type Component,
For,
Index,
Match,
Switch,
createMemo,
createRenderEffect,
createSignal,
onCleanup,
} from "solid-js";
import MediaViewer from "./MediaViewer";
import { render } from "solid-js/web";
import {
createElementSize,
useWindowSize,
} from "@solid-primitives/resize-observer";
import { useStore } from "@nanostores/solid";
import { $settings } from "../settings/stores";
import { averageColorHex } from "../platform/blurhash";
import "./MediaAttachmentGrid.css";
import cardStyle from "../material/cards.module.css";
type ElementSize = { width: number; height: number };
function constraintedSize(
{ width: owidth, height: oheight }: Readonly<ElementSize>, // originalSize
{ width: mwidth, height: mheight }: Readonly<Partial<ElementSize>>, // modifier
{ width: maxWidth, height: maxHeight }: Readonly<ElementSize>, // maxSize
) {
const ySize = owidth + (mwidth ?? 0);
const yScale = ySize > maxWidth ? ySize / maxWidth : 1;
const xSize = oheight + (mheight ?? 0);
const xScale = xSize > maxHeight ? xSize / maxHeight : 1;
const maxScale = Math.max(yScale, xScale);
const scaledWidth = owidth / maxScale;
const scaledHeight = oheight / maxScale;
return {
width: scaledWidth,
height: scaledHeight,
};
}
const MediaAttachmentGrid: Component<{
attachments: mastodon.v1.MediaAttachment[];
}> = (props) => {
const [rootRef, setRootRef] = createSignal<HTMLElement>();
const [viewerIndex, setViewerIndex] = createSignal<number>();
const viewerOpened = () => typeof viewerIndex() !== "undefined";
const settings = useStore($settings);
const windowSize = useWindowSize();
createRenderEffect((lastDispose?: () => void) => {
lastDispose?.();
const vidx = viewerIndex();
if (typeof vidx === "undefined") return;
const container = document.createElement("div");
container.setAttribute("role", "presentation");
document.body.appendChild(container);
return render(() => {
onCleanup(() => {
document.body.removeChild(container);
});
return (
<MediaViewer
show={viewerOpened()}
index={viewerIndex() || 0}
onIndexUpdated={setViewerIndex}
media={props.attachments}
onClose={() => setViewerIndex()}
/>
);
}, container);
});
const openViewerFor = (index: number) => {
setViewerIndex(index);
};
const columnCount = () => {
if (props.attachments.length === 1) {
return 1;
} else if (props.attachments.length % 2 === 0) {
return 2;
} else {
return 3;
}
};
const rawElementSize = createElementSize(rootRef);
const elementWidth = () => rawElementSize.width;
const itemMaxSize = createMemo(() => {
const ewidth = elementWidth();
const width = ewidth
? (ewidth - (columnCount() - 1) * 4) / columnCount()
: 1;
return {
height: windowSize.height * 0.35,
width,
};
});
const itemStyle = (item: mastodon.v1.MediaAttachment) => {
const { width, height } = constraintedSize(
item.meta?.small || { width: 1, height: 1 },
{ width: 2, height: 2 },
itemMaxSize(),
);
const accentColor =
item.meta?.colors?.accent ??
(item.blurhash ? averageColorHex(item.blurhash) : undefined);
return Object.assign(
{
width: `${width}px`,
height: `${height}px`,
"contain-intrinsic-size": `${width}px ${height}px`,
},
accentColor ? { "--media-color-accent": accentColor } : {},
);
};
return (
<section
ref={setRootRef}
class={`MediaAttachmentGrid ${cardStyle.cardNoPad}`}
style={{ "column-count": columnCount() }}
onClick={(e) => {
if (e.target !== e.currentTarget) {
e.stopImmediatePropagation();
}
}}
>
<Index each={props.attachments}>
{(item, index) => {
const itemType = () => item().type;
return (
<Switch>
<Match when={itemType() === "image"}>
<img
data-sort={index}
data-media-type={item().type}
src={item().previewUrl}
width={item().meta?.small?.width}
height={item().meta?.small?.height}
alt={item().description || undefined}
onClick={[openViewerFor, index]}
loading="lazy"
style={itemStyle(item())}
></img>
</Match>
<Match when={itemType() === "video"}>
<video
data-sort={index}
data-media-type={item().type}
src={item().url || undefined}
autoplay={settings().autoPlayVideos}
playsinline={settings().autoPlayVideos ? true : undefined}
controls
poster={item().previewUrl}
width={item().meta?.small?.width}
height={item().meta?.small?.height}
style={itemStyle(item())}
/>
</Match>
<Match when={itemType() === "gifv"}>
<video
data-sort={index}
data-media-type={item().type}
src={item().url || undefined}
autoplay={settings().autoPlayGIFs}
controls
playsinline /* or safari on iOS will play in full-screen */
loop
poster={item().previewUrl}
width={item().meta?.small?.width}
height={item().meta?.small?.height}
style={itemStyle(item())}
/>
</Match>
<Match when={itemType() === "audio"}>
<audio
data-sort={index}
data-media-type={item().type}
src={item().url || undefined}
controls
></audio>
</Match>
</Switch>
);
}}
</Index>
</section>
);
};
export default MediaAttachmentGrid;

View file

@ -5,17 +5,23 @@ import {
ListItemAvatar, ListItemAvatar,
ListItemIcon, ListItemIcon,
ListItemText, ListItemText,
Menu,
MenuItem, MenuItem,
} from "@suid/material"; } from "@suid/material";
import { Show, createUniqueId, type ParentComponent } from "solid-js"; import {
ErrorBoundary,
Show,
createSignal,
createUniqueId,
type ParentComponent,
} from "solid-js";
import { import {
Settings as SettingsIcon, Settings as SettingsIcon,
Bookmark as BookmarkIcon, Bookmark as BookmarkIcon,
Star as LikeIcon, Star as LikeIcon,
FeaturedPlayList as ListIcon, FeaturedPlayList as ListIcon,
} from "@suid/icons-material"; } from "@suid/icons-material";
import A from "~platform/A"; import A from "../platform/A";
import Menu, { createManagedMenuState } from "~material/Menu";
const ProfileMenuButton: ParentComponent<{ const ProfileMenuButton: ParentComponent<{
profile?: { profile?: {
@ -29,18 +35,29 @@ const ProfileMenuButton: ParentComponent<{
}; };
}; };
}; };
onClick?: () => void;
onClose?: () => void;
}> = (props) => { }> = (props) => {
const menuId = createUniqueId(); const menuId = createUniqueId();
const buttonId = createUniqueId(); const buttonId = createUniqueId();
const [open, state] = createManagedMenuState(); let [anchor, setAnchor] = createSignal<HTMLButtonElement | null>(null);
const open = () => !!anchor();
const onClick = (event: { currentTarget: HTMLElement }) => { const onClick = (
open(event.currentTarget.getBoundingClientRect()); event: MouseEvent & { currentTarget: HTMLButtonElement },
) => {
setAnchor(event.currentTarget);
props.onClick?.();
}; };
const inf = () => props.profile?.account.inf; const inf = () => props.profile?.account.inf;
const onClose = () => {
props.onClick?.();
setAnchor(null);
};
return ( return (
<> <>
<ButtonBase <ButtonBase
@ -48,8 +65,8 @@ const ProfileMenuButton: ParentComponent<{
sx={{ borderRadius: "50%" }} sx={{ borderRadius: "50%" }}
id={buttonId} id={buttonId}
onClick={onClick} onClick={onClick}
aria-controls={state.open ? menuId : undefined} aria-controls={open() ? menuId : undefined}
aria-expanded={state.open ? "true" : "false"} aria-expanded={open() ? "true" : undefined}
> >
<Avatar <Avatar
alt={`${inf()?.displayName}'s avatar`} alt={`${inf()?.displayName}'s avatar`}
@ -58,13 +75,23 @@ const ProfileMenuButton: ParentComponent<{
</ButtonBase> </ButtonBase>
<Menu <Menu
id={menuId} id={menuId}
anchorEl={anchor()}
open={open()}
onClose={onClose}
MenuListProps={{ MenuListProps={{
"aria-labelledby": menuId, "aria-labelledby": buttonId,
style: { sx: {
"min-width": "220px", minWidth: "220px",
}, },
}} }}
{...state} anchorOrigin={{
vertical: "top",
horizontal: "right",
}}
transformOrigin={{
vertical: "top",
horizontal: "right",
}}
> >
<MenuItem <MenuItem
component={A} component={A}

View file

@ -10,7 +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 { useIsFrameSuspended } from "~platform/StackedRouter"; import { useMaybeIsFrameSuspended } from "../platform/StackedRouter";
const PullDownToRefresh: Component<{ const PullDownToRefresh: Component<{
loading?: boolean; loading?: boolean;
@ -34,7 +34,7 @@ const PullDownToRefresh: Component<{
}); });
const rootVisible = obvx(() => rootElement); const rootVisible = obvx(() => rootElement);
const isFrameSuspended = useIsFrameSuspended() const isFrameSuspended = useMaybeIsFrameSuspended()
createEffect(() => { createEffect(() => {
if (!rootVisible()) setPullDown(0); if (!rootVisible()) setPullDown(0);

View file

@ -1,78 +0,0 @@
.RegularToot {
--card-pad: 16px;
--card-gut: 16px;
--toot-avatar-size: 40px;
margin-block: 0;
position: relative;
contain: layout style;
cursor: pointer;
transition:
margin-top 60ms var(--tutu-anim-curve-sharp),
margin-bottom 60ms var(--tutu-anim-curve-sharp),
height 60ms var(--tutu-anim-curve-sharp),
var(--tutu-transition-shadow);
border-radius: 0;
time {
color: var(--tutu-color-secondary-text-on-surface);
}
>.retoot-grp {
display: flex;
gap: 0.25em;
margin-bottom: 8px;
align-items: center;
> :first-child {
margin-right: 0.25em;
}
}
& .custom-emoji {
height: 1em;
object-fit: contain;
}
&.expanded {
margin-block: 20px;
box-shadow: var(--tutu-shadow-e9);
}
&.thread-top,
&.thread-mid,
&.thread-btm {
position: relative;
&::before {
content: "";
position: absolute;
left: 36px;
background-color: var(--tutu-color-secondary);
width: 2px;
display: block;
}
}
&.thread-mid {
&::before {
top: 0;
bottom: 0;
}
}
&.thread-top {
&::before {
top: 16px;
bottom: 0;
}
}
&.thread-btm {
&::before {
top: 0;
height: 16px;
}
}
}

View file

@ -5,64 +5,57 @@ import {
type JSX, type JSX,
Show, Show,
createRenderEffect, createRenderEffect,
createSignal,
type Setter,
createContext,
useContext,
} from "solid-js"; } from "solid-js";
import tootStyle from "./toot.module.css"; import tootStyle from "./toot.module.css";
import { formatRelative } from "date-fns"; import { formatRelative } from "date-fns";
import Img from "~material/Img.js"; import Img from "../material/Img.js";
import { Body2 } from "~material/typography.js"; import { Body2 } from "../material/typography.js";
import { SmartToySharp, Lock } from "@suid/icons-material"; import { css } from "solid-styled";
import { useTimeSource } from "~platform/timesrc.js"; import {
BookmarkAddOutlined,
Repeat,
ReplyAll,
Star,
StarOutline,
Bookmark,
Share,
SmartToySharp,
Lock,
} from "@suid/icons-material";
import { useTimeSource } from "../platform/timesrc.js";
import { resolveCustomEmoji } from "../masto/toot.js"; import { resolveCustomEmoji } from "../masto/toot.js";
import { Divider } from "@suid/material"; import { Divider } from "@suid/material";
import cardStyle from "~material/cards.module.css"; import cardStyle from "../material/cards.module.css";
import MediaAttachmentGrid from "./toots/MediaAttachmentGrid.jsx"; import Button from "../material/Button.js";
import { useDateFnLocale } from "~platform/i18n"; import MediaAttachmentGrid from "./MediaAttachmentGrid.js";
import { useDateFnLocale } from "../platform/i18n";
import { canShare, share } from "../platform/share";
import { makeAcctText, useDefaultSession } from "../masto/clients"; import { makeAcctText, useDefaultSession } from "../masto/clients";
import TootContent from "./toots/TootContent"; import TootContent from "./toot-components/TootContent";
import BoostIcon from "./toots/BoostIcon"; import BoostIcon from "./toot-components/BoostIcon";
import PreviewCard from "./toots/PreviewCard"; import PreviewCard from "./toot-components/PreviewCard";
import TootPoll from "./toots/TootPoll";
import TootActionGroup from "./toots/TootActionGroup.js";
import "./RegularToot.css";
export type TootEnv = { type TootActionGroupProps<T extends mastodon.v1.Status> = {
boost: (value: mastodon.v1.Status) => void; onRetoot?: (value: T) => void;
favourite: (value: mastodon.v1.Status) => void; onFavourite?: (value: T) => void;
bookmark: (value: mastodon.v1.Status) => void; onBookmark?: (value: T) => void;
reply?: ( onReply?: (
value: mastodon.v1.Status, value: T,
event: MouseEvent & { currentTarget: HTMLButtonElement }, event: MouseEvent & { currentTarget: HTMLButtonElement },
) => void; ) => void;
vote: (
status: mastodon.v1.Status,
votes: readonly number[],
) => void | Promise<void>;
}; };
const TootEnvContext = /* @__PURE__ */ createContext<TootEnv>(); type TootCardProps = {
export const TootEnvProvider = TootEnvContext.Provider;
export function useTootEnv() {
const env = useContext(TootEnvContext);
if (!env) {
throw new TypeError(
"environment not found, use TootEnvProvider to provide",
);
}
return env;
}
type RegularTootProps = {
status: mastodon.v1.Status; status: mastodon.v1.Status;
actionable?: boolean; actionable?: boolean;
evaluated?: boolean; evaluated?: boolean;
thread?: "top" | "bottom" | "middle"; thread?: "top" | "bottom" | "middle";
} & JSX.HTMLElementTags["article"]; } & TootActionGroupProps<mastodon.v1.Status> &
JSX.HTMLElementTags["article"];
function isolatedCallback(e: MouseEvent) {
e.stopPropagation();
}
export function findRootToot(element: HTMLElement) { export function findRootToot(element: HTMLElement) {
let current: HTMLElement | null = element; let current: HTMLElement | null = element;
@ -77,6 +70,73 @@ export function findRootToot(element: HTMLElement) {
return current; return current;
} }
function TootActionGroup<T extends mastodon.v1.Status>(
props: TootActionGroupProps<T> & { value: T },
) {
let actGrpElement: HTMLDivElement;
const toot = () => props.value;
return (
<div
ref={actGrpElement!}
class={tootStyle.tootBottomActionGrp}
onClick={isolatedCallback}
>
<Show when={props.onReply}>
<Button
class={tootStyle.tootActionWithCount}
onClick={[props.onReply!, props.value]}
>
<ReplyAll />
<span>{toot().repliesCount}</span>
</Button>
</Show>
<Button
class={tootStyle.tootActionWithCount}
style={{
color: toot().reblogged ? "var(--tutu-color-primary)" : undefined,
}}
onClick={() => props.onRetoot?.(toot())}
>
<Repeat />
<span>{toot().reblogsCount}</span>
</Button>
<Button
class={tootStyle.tootActionWithCount}
style={{
color: toot().favourited ? "var(--tutu-color-primary)" : undefined,
}}
onClick={() => props.onFavourite?.(toot())}
>
{toot().favourited ? <Star /> : <StarOutline />}
<span>{toot().favouritesCount}</span>
</Button>
<Button
class={tootStyle.tootAction}
style={{
color: toot().bookmarked ? "var(--tutu-color-primary)" : undefined,
}}
onClick={() => props.onBookmark?.(toot())}
>
{toot().bookmarked ? <Bookmark /> : <BookmarkAddOutlined />}
</Button>
<Show when={canShare({ url: toot().url ?? undefined })}>
<Button
class={tootStyle.tootAction}
aria-label="Share"
onClick={async () => {
await share({
url: toot().url ?? undefined,
});
}}
>
<Share />
</Button>
</Show>
</div>
);
}
function TootAuthorGroup( function TootAuthorGroup(
props: { props: {
status: mastodon.v1.Status; status: mastodon.v1.Status;
@ -140,11 +200,6 @@ export function findElementActionable(
return current; return current;
} }
function onToggleReveal(setValue: Setter<boolean>, event: Event) {
event.stopPropagation();
setValue((x) => !x);
}
/** /**
* Component for a toot. * Component for a toot.
* *
@ -152,8 +207,6 @@ function onToggleReveal(setValue: Setter<boolean>, event: Event) {
* this component under a `<DefaultSessionProvier />` with correct * this component under a `<DefaultSessionProvier />` with correct
* session. * session.
* *
* This component requires be under `<TootEnvProvider />`.
*
* **Handling Clicks** * **Handling Clicks**
* There are multiple actions supported in the component. Some handlers * There are multiple actions supported in the component. Some handlers
* are passed in, some should be handled as the click event. * are passed in, some should be handled as the click event.
@ -175,39 +228,78 @@ function onToggleReveal(setValue: Setter<boolean>, event: Event) {
* You can extract the intent from the attributes of the "actionable" element. * You can extract the intent from the attributes of the "actionable" element.
* The action type is the dataset's `action`. * The action type is the dataset's `action`.
*/ */
const RegularToot: Component<RegularTootProps> = (oprops) => { const RegularToot: Component<TootCardProps> = (props) => {
let rootRef: HTMLElement; let rootRef: HTMLElement;
const [props, rest] = splitProps(oprops, [ const [managed, managedActionGroup, rest] = splitProps(
"status", props,
"lang", ["status", "lang", "class", "actionable", "evaluated", "thread"],
"class", ["onRetoot", "onFavourite", "onBookmark", "onReply"],
"actionable", );
"evaluated",
"thread",
]);
const now = useTimeSource(); const now = useTimeSource();
const status = () => props.status; const status = () => managed.status;
const toot = () => status().reblog ?? status(); const toot = () => status().reblog ?? status();
const session = useDefaultSession(); const session = useDefaultSession();
const [reveal, setReveal] = createSignal(false);
css`
.reply-sep {
margin-left: calc(var(--toot-avatar-size) + var(--card-pad) + 8px);
margin-block: 8px;
}
.thread-top,
.thread-mid,
.thread-btm {
position: relative;
&::before {
content: "";
position: absolute;
left: 36px;
background-color: var(--tutu-color-secondary);
width: 2px;
display: block;
}
}
.thread-mid {
&::before {
top: 0;
bottom: 0;
}
}
.thread-top {
&::before {
top: 16px;
bottom: 0;
}
}
.thread-btm {
&::before {
top: 0;
height: 16px;
}
}
`;
return ( return (
<> <>
<article <section
classList={{ classList={{
"RegularToot": true, [tootStyle.toot]: true,
"expanded": props.evaluated, [tootStyle.expanded]: managed.evaluated,
"thread-top": props.thread === "top", "thread-top": managed.thread === "top",
"thread-mid": props.thread === "middle", "thread-mid": managed.thread === "middle",
"thread-btm": props.thread === "bottom", "thread-btm": managed.thread === "bottom",
[props.class || ""]: true, [managed.class || ""]: true,
}} }}
ref={rootRef!} ref={rootRef!}
lang={toot().language || props.lang} lang={toot().language || managed.lang}
{...rest} {...rest}
> >
<Show when={!!status().reblog}> <Show when={!!status().reblog}>
<div class="retoot-grp"> <div class={tootStyle.tootRetootGrp}>
<BoostIcon /> <BoostIcon />
<Body2 <Body2
ref={(e: { innerHTML: string }) => { ref={(e: { innerHTML: string }) => {
@ -233,36 +325,22 @@ const RegularToot: Component<RegularTootProps> = (oprops) => {
source={toot().content} source={toot().content}
emojis={toot().emojis} emojis={toot().emojis}
mentions={toot().mentions} mentions={toot().mentions}
class={cardStyle.cardNoPad} class={tootStyle.tootContent}
sensitive={toot().sensitive}
spoilerText={toot().spoilerText}
reveal={reveal()}
onToggleReveal={[onToggleReveal, setReveal]}
/> />
<Show <Show when={toot().card}>
when={
toot().card && (!toot().sensitive || (toot().sensitive && reveal()))
}
>
<PreviewCard src={toot().card!} /> <PreviewCard src={toot().card!} />
</Show> </Show>
<Show when={toot().mediaAttachments.length > 0}> <Show when={toot().mediaAttachments.length > 0}>
<MediaAttachmentGrid <MediaAttachmentGrid attachments={toot().mediaAttachments} />
attachments={toot().mediaAttachments}
sensitive={toot().sensitive}
/>
</Show> </Show>
<Show when={toot().poll}> <Show when={managed.actionable}>
<TootPoll value={toot().poll!} status={toot()} />
</Show>
<Show when={props.actionable}>
<Divider <Divider
class={cardStyle.cardNoPad} class={cardStyle.cardNoPad}
style={{ "margin-top": "8px" }} style={{ "margin-top": "8px" }}
/> />
<TootActionGroup value={toot()} class={cardStyle.cardGutSkip} /> <TootActionGroup value={toot()} {...managedActionGroup} />
</Show> </Show>
</article> </section>
</> </>
); );
}; };

View file

@ -7,28 +7,25 @@ import {
Show, Show,
type Component, type Component,
} from "solid-js"; } from "solid-js";
import Scaffold from "~material/Scaffold"; import Scaffold from "../material/Scaffold";
import { AppBar, CircularProgress, IconButton, Toolbar } from "@suid/material"; import { AppBar, CircularProgress, IconButton, Toolbar } from "@suid/material";
import { Title } from "~material/typography"; import { Title } from "../material/typography";
import { Close as CloseIcon } from "@suid/icons-material"; import { Close as CloseIcon } from "@suid/icons-material";
import { useSessionForAcctStr } from "../masto/clients"; import { useSessionForAcctStr } from "../masto/clients";
import { resolveCustomEmoji } from "../masto/toot"; import { resolveCustomEmoji } from "../masto/toot";
import RegularToot, { import RegularToot, { findElementActionable } from "./RegularToot";
findElementActionable,
TootEnvProvider,
} from "./RegularToot";
import type { mastodon } from "masto"; import type { mastodon } from "masto";
import cards from "~material/cards.module.css"; import cards from "../material/cards.module.css";
import { css } from "solid-styled"; import { css } from "solid-styled";
import { vibrate } from "~platform/hardware"; import { vibrate } from "../platform/hardware";
import { createTimeSource, TimeSourceProvider } from "~platform/timesrc"; import { createTimeSource, TimeSourceProvider } from "../platform/timesrc";
import TootComposer from "./TootComposer"; import TootComposer from "./TootComposer";
import { useDocumentTitle } from "../utils"; 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 { useNavigator } from "../platform/StackedRouter";
import BackButton from "~platform/BackButton"; import BackButton from "../platform/BackButton";
let cachedEntry: [string, mastodon.v1.Status] | undefined; let cachedEntry: [string, mastodon.v1.Status] | undefined;
@ -172,33 +169,6 @@ const TootBottomSheet: Component = (props) => {
return Array.from(new Set(values).keys()); return Array.from(new Set(values).keys());
}; };
const vote = async (status: mastodon.v1.Status, votes: readonly number[]) => {
const client = session()?.client;
if (!client) return;
const toot = status.reblog ?? status;
if (!toot.poll) return;
const npoll = await client.v1.polls.$select(toot.poll.id).votes.create({
choices: votes,
});
if (status.reblog) {
setRemoteToot({
...status,
reblog: {
...status.reblog,
poll: npoll,
},
});
} else {
setRemoteToot({
...status,
poll: npoll,
});
}
};
const handleMainTootClick = ( const handleMainTootClick = (
event: MouseEvent & { currentTarget: HTMLElement }, event: MouseEvent & { currentTarget: HTMLElement },
) => { ) => {
@ -285,29 +255,21 @@ const TootBottomSheet: Component = (props) => {
<article> <article>
<Show when={toot()}> <Show when={toot()}>
<TootEnvProvider
value={{
bookmark: onBookmark,
boost: onBoost,
favourite: onFav,
vote,
}}
>
<RegularToot <RegularToot
id={`toot-${toot()!.id}`} id={`toot-${toot()!.id}`}
class={cards.card} class={cards.card}
style={{ style={{
"scroll-margin-top": "scroll-margin-top":
"calc(var(--scaffold-topbar-height) + 20px)", "calc(var(--scaffold-topbar-height) + 20px)",
cursor: "auto",
"user-select": "auto",
}} }}
status={toot()!} status={toot()!}
actionable={!!actSession()} actionable={!!actSession()}
evaluated={true} evaluated={true}
onBookmark={onBookmark}
onRetoot={onBoost}
onFavourite={onFav}
onClick={handleMainTootClick} onClick={handleMainTootClick}
></RegularToot> ></RegularToot>
</TootEnvProvider>
</Show> </Show>
</article> </article>

View file

@ -9,7 +9,7 @@ import {
type JSX, type JSX,
type Ref, type Ref,
} from "solid-js"; } from "solid-js";
import Scaffold from "~material/Scaffold"; import Scaffold from "../material/Scaffold";
import { import {
Avatar, Avatar,
Button, Button,
@ -41,16 +41,16 @@ import {
} from "@suid/icons-material"; } from "@suid/icons-material";
import type { Account } from "../accounts/stores"; import type { Account } from "../accounts/stores";
import "./TootComposer.css"; import "./TootComposer.css";
import BottomSheet from "~material/BottomSheet"; import BottomSheet from "../material/BottomSheet";
import { useAppLocale } from "~platform/i18n"; import { useLanguage } from "../platform/i18n";
import iso639_1 from "iso-639-1"; import iso639_1 from "iso-639-1";
import ChooseTootLang from "./ChooseTootLang"; import ChooseTootLang from "./ChooseTootLang";
import type { mastodon } from "masto"; import type { mastodon } from "masto";
import cardStyles from "~material/cards.module.css"; import cardStyles from "../material/cards.module.css";
import Menu, { createManagedMenuState } from "~material/Menu"; import Menu, { createManagedMenuState } from "../material/Menu";
import { useDefaultSession } from "../masto/clients"; import { useDefaultSession } from "../masto/clients";
import { resolveCustomEmoji } from "../masto/toot"; import { resolveCustomEmoji } from "../masto/toot";
import SizedTextarea from "~platform/SizedTextarea"; import SizedTextarea from "../platform/SizedTextarea";
type TootVisibility = "public" | "unlisted" | "private" | "direct"; type TootVisibility = "public" | "unlisted" | "private" | "direct";
@ -98,8 +98,7 @@ const TootVisibilityPickerDialog: Component<{
style={{ style={{
"border-top": "1px solid #ddd", "border-top": "1px solid #ddd",
background: "var(--tutu-color-surface)", background: "var(--tutu-color-surface)",
padding: padding: "8px 16px",
"8px 16px calc(8px + var(--safe-area-inset-bottom, 0px))",
width: "100%", width: "100%",
"text-align": "end", "text-align": "end",
}} }}
@ -233,7 +232,7 @@ const TootComposer: Component<{
const [permPicker, setPermPicker] = createSignal(false); const [permPicker, setPermPicker] = createSignal(false);
const [language, setLanguage] = createSignal("en"); const [language, setLanguage] = createSignal("en");
const [langPickerOpen, setLangPickerOpen] = createSignal(false); const [langPickerOpen, setLangPickerOpen] = createSignal(false);
const { language: appLanguage } = useAppLocale(); const appLanguage = useLanguage();
const [openMenu, menuState] = createManagedMenuState(); const [openMenu, menuState] = createManagedMenuState();
const randomPlaceholder = useRandomChoice(() => [ const randomPlaceholder = useRandomChoice(() => [

View file

@ -6,21 +6,21 @@ import {
createSelector, createSelector,
Index, Index,
createMemo, createMemo,
For,
} from "solid-js"; } from "solid-js";
import { type mastodon } from "masto"; import { type mastodon } from "masto";
import { vibrate } from "~platform/hardware"; import { vibrate } from "../platform/hardware";
import { useDefaultSession } from "../masto/clients"; 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 { setCache as setTootBottomSheetCache } from "./TootBottomSheet";
import RegularToot, { import RegularToot, {
findElementActionable, findElementActionable,
findRootToot, findRootToot,
TootEnvProvider,
} 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 { useNavigator } from "../platform/StackedRouter";
import { ANIM_CURVE_STD } from "~material/theme"; import { ANIM_CURVE_STD } from "../material/theme";
function durationOf(rect0: DOMRect, rect1: DOMRect) { function durationOf(rect0: DOMRect, rect1: DOMRect) {
const distancelt = Math.sqrt( const distancelt = Math.sqrt(
@ -53,6 +53,7 @@ const TootList: Component<{
onChangeToot: (id: string, value: mastodon.v1.Status) => void; onChangeToot: (id: string, value: mastodon.v1.Status) => void;
}> = (props) => { }> = (props) => {
const session = useDefaultSession(); const session = useDefaultSession();
const heroSrc = useHeroSource();
const [expandedThreadId, setExpandedThreadId] = createSignal<string>(); const [expandedThreadId, setExpandedThreadId] = createSignal<string>();
const { push } = useNavigator(); const { push } = useNavigator();
@ -123,6 +124,9 @@ const TootList: Component<{
console.warn("no account info?"); console.warn("no account info?");
return; return;
} }
if (heroSrc) {
heroSrc[1]((x) => ({ ...x, [BOTTOM_SHEET_HERO]: srcElement }));
}
const acct = `${inf.username}@${p.site}`; const acct = `${inf.username}@${p.site}`;
setTootBottomSheetCache(acct, toot); setTootBottomSheetCache(acct, toot);
@ -234,36 +238,6 @@ const TootList: Component<{
openFullScreenToot(status, element, true); openFullScreenToot(status, element, true);
}; };
const vote = async (
status: mastodon.v1.Status,
votes: readonly number[]
) => {
const client = session()?.client;
if (!client) return;
const toot = status.reblog ?? status;
if (!toot.poll) return;
const npoll = await client.v1.polls.$select(toot.poll.id).votes.create({
choices: votes,
});
if (status.reblog) {
props.onChangeToot(status.id, {
...status,
reblog: {
...status.reblog,
poll: npoll,
},
});
} else {
props.onChangeToot(status.id, {
...status,
poll: npoll,
});
}
};
return ( return (
<ErrorBoundary <ErrorBoundary
fallback={(err, reset) => { fallback={(err, reset) => {
@ -271,18 +245,11 @@ const TootList: Component<{
return <p>Oops: {String(err)}</p>; return <p>Oops: {String(err)}</p>;
}} }}
> >
<TootEnvProvider value={{
boost: toggleBoost,
bookmark: onBookmark,
favourite: toggleFavourite,
reply: reply,
vote: vote
}}>
<div ref={props.ref} id={props.id} class="toot-list"> <div ref={props.ref} id={props.id} class="toot-list">
<For each={props.threads}> <Index each={props.threads}>
{(threadId, threadIdx) => { {(threadId, threadIdx) => {
const thread = createMemo(() => const thread = createMemo(() =>
props.onUnknownThread(threadId)?.reverse(), props.onUnknownThread(threadId())?.reverse(),
); );
const threadLength = () => thread()?.length ?? 0; const threadLength = () => thread()?.length ?? 0;
@ -295,6 +262,8 @@ const TootList: Component<{
return ( return (
<RegularToot <RegularToot
data-status-id={status().id} data-status-id={status().id}
data-thread={threadIdx}
data-thread-len={threadLength()}
data-thread-sort={index} data-thread-sort={index}
status={status()} status={status()}
thread={ thread={
@ -305,6 +274,10 @@ const TootList: Component<{
class={cardStyle.card} class={cardStyle.card}
evaluated={checkIsExpended(status())} evaluated={checkIsExpended(status())}
actionable={checkIsExpended(status())} actionable={checkIsExpended(status())}
onBookmark={onBookmark}
onRetoot={toggleBoost}
onFavourite={toggleFavourite}
onReply={reply}
onClick={[onItemClick, status()]} onClick={[onItemClick, status()]}
/> />
); );
@ -312,9 +285,8 @@ const TootList: Component<{
</Index> </Index>
); );
}} }}
</For> </Index>
</div> </div>
</TootEnvProvider>
</ErrorBoundary> </ErrorBoundary>
); );
}; };

View file

@ -1,7 +1,7 @@
.PreviewCard { .PreviewCard {
display: block; display: block;
border: 1px solid #eeeeee; border: 1px solid #eeeeee;
background-color: var(--tutu-color-surface-d); background-color: var(--tutu-color-surface);
text-decoration: none; text-decoration: none;
border-radius: 4px; border-radius: 4px;
overflow: hidden; overflow: hidden;
@ -13,20 +13,16 @@
overflow: hidden; overflow: hidden;
z-index: 1; z-index: 1;
position: relative; position: relative;
contain: layout style;
>img { >img {
background-color: var(--tutu-color-surface); background-color: #eeeeee;
max-width: 100%; max-width: 100%;
height: auto; height: auto;
&.loaded {
background-color: #eeeeee;
}
} }
&:hover, &:hover,
&:focus-visible { &:focus-visible {
background-color: var(--tutu-color-surface-d);
color: var(--tutu-color-on-surface); color: var(--tutu-color-on-surface);
>h1 { >h1 {

View file

@ -1,18 +1,10 @@
import Color from "colorjs.io"; import Color from "colorjs.io";
import type { mastodon } from "masto"; import type { mastodon } from "masto";
import { createEffect, createMemo, Show } from "solid-js"; import { createEffect, createMemo, Show } from "solid-js";
import { Title, Body1 } from "~material/typography"; import { Title, Body1 } from "../../material/typography";
import { averageColorHex } from "~platform/blurhash"; import { averageColorHex } from "../../platform/blurhash";
import "./PreviewCard.css"; import "./PreviewCard.css";
function onResetImg(event: Event & { currentTarget: HTMLImageElement }) {
event.currentTarget.classList.remove("loaded");
}
function onImgLoaded(event: Event & { currentTarget: HTMLImageElement }) {
event.currentTarget.classList.add("loaded");
}
export function PreviewCard(props: { export function PreviewCard(props: {
src: mastodon.v1.PreviewCard; src: mastodon.v1.PreviewCard;
alwaysCompact?: boolean; alwaysCompact?: boolean;
@ -89,8 +81,6 @@ export function PreviewCard(props: {
> >
<Show when={props.src.image}> <Show when={props.src.image}>
<img <img
onLoadStart={onResetImg}
onLoad={onImgLoaded}
crossOrigin="anonymous" crossOrigin="anonymous"
src={props.src.image!} src={props.src.image!}
width={props.src.width || undefined} width={props.src.width || undefined}

View file

@ -0,0 +1,61 @@
import type { mastodon } from "masto";
import {
splitProps,
type Component,
type JSX,
createRenderEffect,
createMemo,
} from "solid-js";
import { resolveCustomEmoji } from "../../masto/toot.js";
import { makeAcctText, useDefaultSession } from "../../masto/clients";
function preventDefault(event: Event) {
event.preventDefault();
}
export type TootContentProps = {
source?: string;
emojis?: mastodon.v1.CustomEmoji[];
mentions: mastodon.v1.StatusMention[];
} & JSX.HTMLAttributes<HTMLDivElement>;
const TootContent: Component<TootContentProps> = (props) => {
const session = useDefaultSession();
const [managed, rest] = splitProps(props, ["source", "emojis", "mentions"]);
const clientFinder = createMemo(() =>
session() ? makeAcctText(session()!) : undefined,
);
return (
<div
ref={(ref) => {
createRenderEffect(() => {
ref.innerHTML = managed.source
? managed.emojis
? resolveCustomEmoji(managed.source, managed.emojis)
: managed.source
: "";
});
createRenderEffect(() => {
const finder = clientFinder();
for (const mention of props.mentions) {
const elements = ref.querySelectorAll<HTMLAnchorElement>(
`a[href='${mention.url}']`,
);
for (const e of elements) {
e.onclick = preventDefault;
e.dataset.action = "acct";
e.dataset.client = finder;
e.dataset.acctId = mention.id.toString();
}
}
});
}}
{...rest}
></div>
);
};
export default TootContent;

View file

@ -1,9 +1,51 @@
.toot {
--card-pad: 16px;
--card-gut: 16px;
--toot-avatar-size: 40px;
margin-block: 0;
position: relative;
contain: content;
&:not(.expanded) {
user-select: none;
cursor: pointer;
}
&.toot {
/* fix composition ordering: I think the css module processor should aware the overriding and behaves, but no */
transition:
margin-top 125ms var(--tutu-anim-curve-std),
margin-bottom 125ms var(--tutu-anim-curve-std),
height 225ms var(--tutu-anim-curve-std),
var(--tutu-transition-shadow);
border-radius: 0;
}
&>.toot {
box-shadow: none;
}
time {
color: var(--tutu-color-secondary-text-on-surface);
}
& :global(.custom-emoji) {
height: 1em;
object-fit: contain;
}
&.expanded {
margin-block: 20px;
box-shadow: var(--tutu-shadow-e9);
}
}
.tootAuthorGrp { .tootAuthorGrp {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 8px; gap: 8px;
margin-bottom: 8px; margin-bottom: 8px;
contain: layout style;
> :not(:first-child) { > :not(:first-child) {
flex-grow: 1; flex-grow: 1;
@ -50,3 +92,189 @@
border: 1px solid var(--tutu-color-surface); border: 1px solid var(--tutu-color-surface);
background-color: var(--tutu-color-surface-d); background-color: var(--tutu-color-surface-d);
} }
.tootContent {
composes: cardNoPad from "../material/cards.module.css";
margin-left: calc(var(--card-pad, 0) + var(--toot-avatar-size, 0) + 8px);
margin-right: var(--card-pad, 0);
line-height: 1.5;
& a {
color: var(--tutu-color-primary-d);
}
& :global(a[target="_blank"]) {
> :global(.invisible) {
display: none;
}
> :global(.ellipsis) {
&::after {
display: inline;
content: "...";
}
}
}
}
.previewCard {
composes: cardGutSkip from "../material/cards.module.css";
display: block;
border: 1px solid #eeeeee;
background-color: var(--tutu-color-surface);
text-decoration: none;
border-radius: 4px;
overflow: hidden;
margin-top: 1em;
margin-bottom: 1.5em;
color: var(--tutu-color-secondary-text-on-surface);
transition: color 220ms var(--tutu-anim-curve-std), background-color 220ms var(--tutu-anim-curve-std);
padding-bottom: 8px;
overflow: hidden;
z-index: 1;
position: relative;
>img {
background-color: #eeeeee;
max-width: 100%;
height: auto;
}
&:hover,
&:focus-visible {
background-color: var(--tutu-color-surface-d);
color: var(--tutu-color-on-surface);
>h1 {
text-decoration: underline;
}
}
>h1 {
color: var(--tutu-color-on-surface);
max-height: calc(4 * var(--title-line-height) * var(--title-size));
}
>p {
max-height: calc(8 * var(--body-line-height) * var(--body-size));
}
>h1,
>p {
margin-left: 16px;
margin-right: 16px;
overflow: hidden;
text-overflow: ellipsis;
}
&.compact {
display: grid;
grid-template-columns: minmax(10%, 30%) 1fr;
padding-left: 16px;
padding-right: 16px;
padding-top: 8px;
>img:first-child {
grid-row: 1 / 3;
object-fit: contain;
}
>h1,
>p {
margin-right: 0;
}
}
}
.toot.compact {
display: grid;
grid-template-columns: auto 1fr;
gap: 8px;
row-gap: 0;
padding-block: var(--card-gut, 16px);
padding-inline: var(--card-pad, 16px);
> :first-child {
grid-row: 1/3;
}
> :last-child {
grid-column: 2 /3;
}
}
.compactAuthorGroup {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 8px;
flex-flow: row wrap;
justify-content: flex-end;
>.compactAuthorUsername {
color: var(--tutu-color-secondary-text-on-surface);
flex-grow: 1;
}
>time {
color: var(--tutu-color-secondary-text-on-surface);
}
}
.compactTootContent {
composes: tootContent;
margin-left: 0;
margin-right: 0;
}
.tootRetootGrp {
display: flex;
gap: 0.25em;
margin-bottom: 8px;
align-items: center;
> :first-child {
margin-right: 0.25em;
}
}
.tootBottomActionGrp {
composes: cardGutSkip from "../material/cards.module.css";
padding-block: calc((var(--card-gut) - 10px) / 2);
animation: 225ms var(--tutu-anim-curve-std) tootBottomExpanding;
display: flex;
flex-flow: row wrap;
justify-content: space-evenly;
>button {
color: var(--tutu-color-on-surface);
padding: 10px 8px;
>svg {
font-size: 20px;
}
}
}
.tootActionWithCount {
display: flex;
align-items: center;
gap: 8px;
}
.tootAction {
display: flex;
align-items: center;
justify-content: center;
}
@keyframes tootBottomExpanding {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}

View file

@ -1,244 +0,0 @@
import type { mastodon } from "masto";
import {
type Component,
Index,
Match,
Switch,
createMemo,
createRenderEffect,
createSignal,
onCleanup,
untrack,
} from "solid-js";
import MediaViewer from "../MediaViewer";
import { render } from "solid-js/web";
import {
createElementSize,
useWindowSize,
} from "@solid-primitives/resize-observer";
import { useStore } from "@nanostores/solid";
import { $settings } from "../../settings/stores";
import { averageColorHex } from "~platform/blurhash";
import "./MediaAttachmentGrid.css";
import cardStyle from "~material/cards.module.css";
import { Preview } from "@suid/icons-material";
import { IconButton } from "@suid/material";
import Masonry from "~platform/Masonry";
type ElementSize = { width: number; height: number };
function constraintedSize(
{ width: owidth, height: oheight }: Readonly<ElementSize>, // originalSize
{ width: mwidth, height: mheight }: Readonly<Partial<ElementSize>>, // modifier
{ width: maxWidth, height: maxHeight }: Readonly<ElementSize>, // maxSize
) {
const ySize = owidth + (mwidth ?? 0);
const yScale = ySize > maxWidth ? ySize / maxWidth : 1;
const xSize = oheight + (mheight ?? 0);
const xScale = xSize > maxHeight ? xSize / maxHeight : 1;
const maxScale = Math.max(yScale, xScale);
const scaledWidth = owidth / maxScale;
const scaledHeight = oheight / maxScale;
return {
width: scaledWidth,
height: scaledHeight,
};
}
function isolateCallback(event: Event) {
if (event.target !== event.currentTarget) {
event.stopPropagation();
}
}
const MediaAttachmentGrid: Component<{
attachments: mastodon.v1.MediaAttachment[];
sensitive?: boolean;
}> = (props) => {
const [rootRef, setRootRef] = createSignal<HTMLElement>();
const [viewerIndex, setViewerIndex] = createSignal<number>();
const viewerOpened = () => typeof viewerIndex() !== "undefined";
const settings = useStore($settings);
const windowSize = useWindowSize();
const [reveal, setReveal] = createSignal([] as number[]);
createRenderEffect(() => {
const vidx = viewerIndex();
if (typeof vidx === "undefined") return;
const container = document.createElement("div");
container.setAttribute("role", "presentation");
document.body.appendChild(container);
const dispose = render(() => {
onCleanup(() => {
document.body.removeChild(container);
});
return (
<MediaViewer
show={viewerOpened()}
index={viewerIndex() || 0}
onIndexUpdated={setViewerIndex}
media={props.attachments}
onClose={() => setViewerIndex()}
/>
);
}, container);
onCleanup(dispose);
});
const openViewerFor = (index: number) => {
setViewerIndex(index);
};
const columnCount = () => {
if (props.attachments.length === 1) {
return 1;
} else if (props.attachments.length % 2 === 0) {
return 2;
} else {
return 3;
}
};
const rawElementSize = createElementSize(rootRef);
const elementWidth = () => rawElementSize.width;
const itemMaxSize = createMemo(() => {
const ewidth = elementWidth();
const width = ewidth
? (ewidth - (columnCount() - 1) * 4) / columnCount()
: 1;
return {
height: windowSize.height * 0.35,
width,
};
});
const itemStyle = (item: mastodon.v1.MediaAttachment) => {
const { width, height } = constraintedSize(
item.meta?.small || { width: 1, height: 1 },
{ width: 2, height: 2 },
itemMaxSize(),
);
const accentColor =
item.meta?.colors?.accent ??
(item.blurhash ? averageColorHex(item.blurhash) : undefined);
return Object.assign(
{
width: `${width}px`,
height: `${height}px`,
"contain-intrinsic-size": `${width}px ${height}px`,
},
accentColor ? { "--media-color-accent": accentColor } : {},
);
};
const isReveal = (idx: number) => {
return reveal().includes(idx);
};
const addReveal = (idx: number) => {
if (!untrack(() => isReveal(idx))) {
setReveal((x) => [...x, idx]);
}
};
return (
<Masonry
component="section"
ref={setRootRef}
class={`MediaAttachmentGrid ${cardStyle.cardNoPad}`}
classList={{
sensitive: props.sensitive,
}}
onClick={isolateCallback}
>
<Index each={props.attachments}>
{(item, index) => {
const itemType = () => item().type;
const style = createMemo(() => itemStyle(item()));
return (
<div style={style()} role="presentation">
<Switch>
<Match when={props.sensitive && !isReveal(index)}>
<div
class="sensitive-placeholder"
data-sort={index}
data-media-type={item().type}
>
<IconButton
color="inherit"
size="large"
onClick={[addReveal, index]}
aria-label="Reveal this media"
>
<Preview />
</IconButton>
</div>
</Match>
<Match when={itemType() === "image"}>
<img
src={item().previewUrl}
width={item().meta?.small?.width}
height={item().meta?.small?.height}
alt={item().description || undefined}
onClick={[openViewerFor, index]}
loading="lazy"
data-sort={index}
data-media-type={item().type}
></img>
</Match>
<Match when={itemType() === "video"}>
<video
src={item().url || undefined}
autoplay={!props.sensitive && settings().autoPlayVideos}
playsinline={settings().autoPlayVideos ? true : undefined}
controls
poster={item().previewUrl}
width={item().meta?.small?.width}
height={item().meta?.small?.height}
data-sort={index}
data-media-type={item().type}
preload="metadata"
/>
</Match>
<Match when={itemType() === "gifv"}>
<video
src={item().url || undefined}
autoplay={!props.sensitive && settings().autoPlayGIFs}
controls
playsinline /* or safari on iOS will play in full-screen */
loop
poster={item().previewUrl}
width={item().meta?.small?.width}
height={item().meta?.small?.height}
data-sort={index}
data-media-type={item().type}
preload="metadata"
/>
</Match>
<Match when={itemType() === "audio"}>
<audio
src={item().url || undefined}
controls
data-sort={index}
data-media-type={item().type}
></audio>
</Match>
</Switch>
</div>
);
}}
</Index>
</Masonry>
);
};
export default MediaAttachmentGrid;

View file

@ -1,41 +0,0 @@
.TootActionGroup {
padding-block: calc((var(--card-gut) - 10px) / 2);
contain: layout style;
animation: 225ms var(--tutu-anim-curve-std) TootActionGroup_fade-in;
display: flex;
flex-flow: row wrap;
justify-content: space-evenly;
>button {
color: var(--tutu-color-on-surface);
padding: 10px 8px;
>svg {
font-size: 20px;
}
}
>* {
display: flex;
align-items: center;
}
>.with-count {
gap: 8px;
}
>.plain {
justify-content: center;
}
}
@keyframes TootActionGroup_fade-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}

View file

@ -1,89 +0,0 @@
import type { mastodon } from "masto";
import { useTootEnv } from "../RegularToot";
import { Button } from "@suid/material";
import { Show } from "solid-js";
import {
Bookmark,
BookmarkAddOutlined,
Repeat,
ReplyAll,
Share,
Star,
StarOutline,
} from "@suid/icons-material";
import { canShare, share } from "~platform/share";
import "./TootActionGroup.css";
async function shareContent(toot: mastodon.v1.Status) {
return await share({
url: toot.url ?? undefined,
});
}
function isolatedCallback(e: MouseEvent) {
e.stopPropagation();
}
function TootActionGroup<T extends mastodon.v1.Status>(props: {
value: T;
class?: string;
}) {
const { reply, boost, favourite, bookmark } = useTootEnv();
let actGrpElement: HTMLDivElement;
const toot = () => props.value;
return (
<div
ref={actGrpElement!}
class={`TootActionGroup ${props.class || ""}`}
onClick={isolatedCallback}
>
<Show when={reply}>
<Button class="with-count" onClick={[reply!, props.value]}>
<ReplyAll />
<span>{toot().repliesCount}</span>
</Button>
</Show>
<Button
class="with-count"
style={{
color: toot().reblogged ? "var(--tutu-color-primary)" : undefined,
}}
onClick={[boost, props.value]}
>
<Repeat />
<span>{toot().reblogsCount}</span>
</Button>
<Button
class="with-count"
style={{
color: toot().favourited ? "var(--tutu-color-primary)" : undefined,
}}
onClick={[favourite, props.value]}
>
{toot().favourited ? <Star /> : <StarOutline />}
<span>{toot().favouritesCount}</span>
</Button>
<Button
class="plain"
style={{
color: toot().bookmarked ? "var(--tutu-color-primary)" : undefined,
}}
onClick={[bookmark, props.value]}
>
{toot().bookmarked ? <Bookmark /> : <BookmarkAddOutlined />}
</Button>
<Show when={canShare({ url: toot().url ?? undefined })}>
<Button
class="plain"
aria-label="Share"
onClick={[shareContent, toot()]}
>
<Share />
</Button>
</Show>
</div>
);
}
export default TootActionGroup;

View file

@ -1,36 +0,0 @@
.TootContent {
margin-left: var(--card-pad, 0);
margin-right: var(--card-pad, 0);
line-height: 1.5;
> .content {
display: contents;
}
& * {
user-select: text;
}
& a {
color: var(--tutu-color-primary-d);
}
& a[target="_blank"] {
word-break: break-all;
>.invisible {
display: none;
}
>.ellipsis {
&::after {
display: inline;
content: "...";
}
}
}
}
:where(.thread-top, .thread-mid) > .TootContent {
margin-left: calc(var(--card-pad, 0) + var(--toot-avatar-size, 0) + 8px);
}

View file

@ -1,115 +0,0 @@
import type { mastodon } from "masto";
import {
splitProps,
type Component,
type JSX,
createRenderEffect,
createMemo,
Show,
} from "solid-js";
import { resolveCustomEmoji } from "../../masto/toot.js";
import { makeAcctText, useDefaultSession } from "../../masto/clients.js";
import "./TootContent.css";
import { Button } from "@suid/material";
import { createTranslator } from "~platform/i18n.js";
function preventDefault(event: Event) {
event.preventDefault();
}
export type TootContentProps = JSX.HTMLAttributes<HTMLDivElement> & {
source?: string;
emojis?: mastodon.v1.CustomEmoji[];
mentions: mastodon.v1.StatusMention[];
sensitive?: boolean;
spoilerText?: string;
reveal?: boolean;
onToggleReveal?: JSX.EventHandlerUnion<HTMLElement, Event>;
};
const TootContent: Component<TootContentProps> = (oprops) => {
const [t] = createTranslator(
(code) =>
import(`./i18n/${code}.json`) as Promise<{
default: {
cw: string;
};
}>,
);
const session = useDefaultSession();
const [props, rest] = splitProps(oprops, [
"source",
"emojis",
"mentions",
"class",
"sensitive",
"spoilerText",
"reveal",
"onToggleReveal",
]);
const clientFinder = createMemo(() =>
session() ? makeAcctText(session()!) : undefined,
);
const shouldRevealContent = () => {
return !props.sensitive || (props.sensitive && props.reveal);
};
return (
<div
ref={(ref) => {
createRenderEffect(() => {
const finder = clientFinder();
for (const mention of props.mentions) {
const elements = ref.querySelectorAll<HTMLAnchorElement>(
`a[href='${mention.url}']`,
);
for (const e of elements) {
e.onclick = preventDefault;
e.dataset.action = "acct";
e.dataset.client = finder;
e.dataset.acctId = mention.id.toString();
}
}
});
}}
class={`TootContent ${props.class || ""}`}
{...rest}
>
<Show when={props.sensitive}>
<div>
<span
ref={(ref) => {
createRenderEffect(() => {
ref.innerHTML = props.spoilerText
? props.emojis
? resolveCustomEmoji(props.spoilerText, props.emojis)
: props.spoilerText
: "";
});
}}
></span>
<Button onClick={props.onToggleReveal}>{t("cw")}</Button>
</div>
</Show>
<Show when={shouldRevealContent()}>
<div
class="content"
ref={(ref) =>
createRenderEffect(() => {
ref.innerHTML = props.source
? props.emojis
? resolveCustomEmoji(props.source, props.emojis)
: props.source
: "";
})
}
></div>
</Show>
</div>
);
};
export default TootContent;

View file

@ -1,22 +0,0 @@
.TootPoll {
margin-top: 12px;
border: 1px solid var(--tutu-color-surface-d);
background-color: var(--tutu-color-surface);
max-width: 560px;
contain: layout style paint;
padding-top: 8px;
padding-bottom: 8px;
>.hints,
>.trailers {
padding-right: 8px;
color: var(--tutu-color-secondary-text-on-surface);
display: flex;
justify-content: space-between;
align-items: center;
}
>.hints {
padding-left: 8px;
}
}

View file

@ -1,191 +0,0 @@
import {
batch,
createRenderEffect,
createSelector,
createSignal,
Index,
Show,
untrack,
type Component,
} from "solid-js";
import "./TootPoll.css";
import type { mastodon } from "masto";
import { resolveCustomEmoji } from "../../masto/toot";
import {
Button,
Checkbox,
Divider,
List,
ListItemButton,
ListItemText,
Radio,
} from "@suid/material";
import {
formatDistance,
isBefore,
} from "date-fns";
import { useTimeSource } from "~platform/timesrc";
import { useDateFnLocale } from "~platform/i18n";
import TootPollDialog from "./TootPollDialog";
import { ANIM_CURVE_STD } from "~material/theme";
import { useTootEnv } from "../RegularToot";
type TootPollProps = {
value: mastodon.v1.Poll
status: mastodon.v1.Status
};
const TootPoll: Component<TootPollProps> = (props) => {
let list: HTMLUListElement;
const {vote}= useTootEnv()
const now = useTimeSource();
const dateFnLocale = useDateFnLocale();
const [mustShowResult, setMustShowResult] = createSignal<boolean>();
const [showVoteDialog, setShowVoteDialog] = createSignal(false);
const [initialVote, setInitialVote] = createSignal(0);
const poll = () => props.value
const isShowResult = () => {
const n = mustShowResult();
if (typeof n !== "undefined") {
return n;
}
return poll().expired || poll().voted;
};
const isOwnVote = createSelector(
() => poll().ownVotes,
(idx: number, votes) => votes?.includes(idx) || false,
);
const openVote = (i: number, event: Event) => {
event.stopPropagation();
if (poll().expired || poll().voted) {
return;
}
batch(() => {
setInitialVote(i);
setShowVoteDialog(true);
});
};
const animateAndSetMustShow = (event: Event) => {
event.stopPropagation();
list.animate(
{
opacity: [0.5, 0, 0.5],
},
{
duration: 220,
easing: ANIM_CURVE_STD,
},
);
setMustShowResult((x) => {
if (typeof x === "undefined") {
return !untrack(isShowResult);
} else {
return undefined;
}
});
};
return (
<section class="TootPoll">
<div class="hints">
<span>{poll().votesCount} votes in total</span>
<Show when={poll().expired}>
<span>Poll is ended</span>
</Show>
</div>
<List ref={list!} disablePadding class="option-list">
<Index each={poll().options}>
{(option, index) => {
return (
<>
<Show when={index === 0}>
<Divider />
</Show>
<ListItemButton
onClick={[openVote, index]}
class="poll-item"
aria-disabled={isShowResult()}
>
<ListItemText>
<span
ref={(e) =>
createRenderEffect(() => {
e.innerHTML = resolveCustomEmoji(
option().title,
option().emojis,
);
})
}
></span>
</ListItemText>
<Show when={isShowResult()}>
<span>
<Show when={typeof option().votesCount !== "undefined"}>
{option().votesCount} votes
</Show>
</span>
</Show>
<Show
when={poll().multiple}
fallback={
<Radio
checked={isOwnVote(index)}
disabled={isShowResult()}
/>
}
>
<Checkbox
checked={isOwnVote(index)}
disabled={isShowResult()}
/>
</Show>
</ListItemButton>
<Divider />
</>
);
}}
</Index>
</List>
<div class="trailers">
<Button onClick={animateAndSetMustShow}>
{isShowResult() ? "Hide result" : "Reveal result"}
</Button>
<Show when={poll().expiresAt}>
<span>
<span style={{ "margin-inline-end": "0.5ch" }}>
{isBefore(now(), poll().expiresAt!) ? "Expire in" : "Expired"}
</span>
<time dateTime={poll().expiresAt!}>
{formatDistance(now(), poll().expiresAt!, {
locale: dateFnLocale(),
})}
</time>
</span>
</Show>
</div>
<TootPollDialog
open={showVoteDialog()}
options={poll().options}
onVote={[vote, props.status]}
onClose={() => setShowVoteDialog(false)}
initialVotes={[initialVote()]}
/>
</section>
);
};
export default TootPoll;

View file

@ -1,12 +0,0 @@
.TootPollDialog {
>.bottom-dock>.actions {
border-top: 1px solid #ddd;
background: var(--tutu-color-surface);
padding:
8px 16px calc(8px + var(--safe-area-inset-bottom, 0px));
width: 100%;
display: flex;
flex-flow: row wrap;
justify-content: space-between;
}
}

View file

@ -1,127 +0,0 @@
import {
Button,
Checkbox,
List,
ListItemButton,
ListItemText,
Radio,
} from "@suid/material";
import type { mastodon } from "masto";
import {
createEffect,
createRenderEffect,
createSignal,
Index,
Show,
type Component,
} from "solid-js";
import BottomSheet, { type BottomSheetProps } from "~material/BottomSheet";
import Scaffold from "~material/Scaffold";
import { resolveCustomEmoji } from "../../masto/toot";
import "./TootPollDialog.css";
export type TootPollDialogPoll = {
open?: boolean;
options: Readonly<mastodon.v1.Poll["options"]>;
initialVotes?: readonly number[];
multiple?: boolean;
onVote: [
(
status: mastodon.v1.Status,
votes: readonly number[],
) => void | Promise<void>,
mastodon.v1.Status,
];
onClose?: BottomSheetProps["onClose"] &
((reason: "cancel" | "success") => void);
};
const TootPollDialog: Component<TootPollDialogPoll> = (props) => {
const [votes, setVotes] = createSignal([] as readonly number[]);
const [inProgress, setInProgress] = createSignal(false);
createEffect(() => {
setVotes(props.initialVotes || []);
});
const toggleVote = (i: number) => {
if (props.multiple) {
setVotes((o) => [...o.filter((x) => x === i), i]);
} else {
setVotes([i]);
}
};
const sendVote = async () => {
setInProgress(true);
try {
await props.onVote[0](props.onVote[1], votes());
} catch (reason) {
console.error(reason);
props.onClose?.("cancel");
return;
} finally {
setInProgress(false);
}
props.onClose?.("success");
};
return (
<BottomSheet open={props.open} onClose={props.onClose} bottomUp>
<Scaffold
class="TootPollDialog"
bottom={
<div class="actions">
<Button
color="error"
onClick={props.onClose ? [props.onClose, "cancel"] : undefined}
disabled={inProgress()}
>
Cancel
</Button>
<Button onClick={sendVote} disabled={inProgress()}>
Confirm
</Button>
</div>
}
>
<List>
<Index each={props.options}>
{(option, index) => {
return (
<ListItemButton
onClick={[toggleVote, index]}
disabled={inProgress()}
>
<ListItemText>
<span
ref={(e) =>
createRenderEffect(
() =>
(e.innerHTML = resolveCustomEmoji(
option().title,
option().emojis,
)),
)
}
></span>
</ListItemText>
<Show
when={props.multiple}
fallback={<Radio checked={votes().includes(index)} />}
>
<Checkbox checked={votes().includes(index)} />
</Show>
</ListItemButton>
);
}}
</Index>
</List>
</Scaffold>
</BottomSheet>
);
};
export default TootPollDialog;

View file

@ -1,3 +0,0 @@
{
"cw": "\"Content Warning\""
}

View file

@ -1,3 +0,0 @@
{
"cw": "“内容警告”"
}

View file

@ -12,9 +12,5 @@
"noEmit": true, "noEmit": true,
"isolatedModules": true, "isolatedModules": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"paths": {
"~platform/*": ["./src/platform/*"],
"~material/*": ["./src/material/*"]
}
} }
} }

View file

@ -7,7 +7,6 @@ import version from "vite-plugin-package-version";
import manifest from "./manifest.config"; import manifest from "./manifest.config";
import { GetManualChunk } from "rollup"; import { GetManualChunk } from "rollup";
import devtools from "solid-devtools/vite"; import devtools from "solid-devtools/vite";
import { resolve } from "node:path";
/** /**
* Put all strings (/i18n/{key}.<json|js|ts>) into separated chunks based on the key. * Put all strings (/i18n/{key}.<json|js|ts>) into separated chunks based on the key.
@ -108,23 +107,6 @@ export default defineConfig(({ mode }) => {
}), }),
version(), version(),
], ],
resolve: {
alias: {
/* We don't allow directly acessing the source root,
because this encourage cross referencing between different
module and loose the isolation. (Cross referencing is still
possible, we don't stop it in any technical way.)
If the module is so important and is being referencing
everywhere in the app. Consider promoting it to the top
dir.
see docs/devnotes.md#module-isolation for details.
*/
"~platform": resolve(__dirname, "src/platform"),
"~material": resolve(__dirname, "src/material"),
},
},
server: { server: {
https: serverHttpCertBase https: serverHttpCertBase
? { ? {