Compare commits
66 commits
Author | SHA1 | Date | |
---|---|---|---|
|
4c0925a6a1 | ||
|
83e2e6e169 | ||
|
4c717a0cb7 | ||
|
6895367fad | ||
|
9fe86d12b0 | ||
|
296de7d23b | ||
|
ad7db8e865 | ||
|
cbdf5e667d | ||
|
25ceb46911 | ||
|
c85cffc03e | ||
|
9bf957188c | ||
|
f50ed8d907 | ||
|
62aaaeee9a | ||
|
66d0bc8d84 | ||
|
bb3ba32dc5 | ||
|
fbbac36b4a | ||
|
487de9237b | ||
|
18fa2810c4 | ||
|
3a3fa40437 | ||
|
246771e8a0 | ||
|
ade7df2234 | ||
|
cac0abeb6b | ||
|
57b242c93f | ||
|
7e5692549d | ||
|
b58e2a50e3 | ||
|
37b38be1d2 | ||
|
76f7e08e78 | ||
|
6726ffe664 | ||
|
5ecba144f0 | ||
|
8e8554331b | ||
|
df5a976ec3 | ||
|
147c9fbce1 | ||
|
8d8d2a8fb1 | ||
|
8cd95b9e90 | ||
|
1047a3b10d | ||
|
b1f6033cc8 | ||
|
6313827b1e | ||
|
737d63f88a | ||
|
cff0c2880a | ||
|
4c1b189ca0 | ||
|
0b586b17e6 | ||
|
7205fa5775 | ||
|
9a7710c070 | ||
|
a69a5c31e5 | ||
|
9e9a831785 | ||
|
e174b7aafd | ||
|
5f504024a3 | ||
|
8d24ffec29 | ||
|
fd1c6fae99 | ||
|
15974af792 | ||
|
8588a17bd0 | ||
|
33fab7e655 | ||
|
4190b8847d | ||
|
0a9d833f09 | ||
|
4d3f5c911b | ||
|
e124d3e5b8 | ||
|
ed53f24ede | ||
|
9b446aa846 | ||
|
840ad2cf00 | ||
|
81325e2b22 | ||
|
c363753884 | ||
|
e8c3271af1 | ||
|
59b413dace | ||
|
5ec9b96504 | ||
|
9065a18061 | ||
1a7a52da22 |
66 changed files with 2503 additions and 1679 deletions
1
.env
1
.env
|
@ -2,3 +2,4 @@ DEV_SERVER_HTTPS_CERT_BASE=
|
|||
DEV_SERVER_HTTPS_CERT_PASS=
|
||||
DEV_LOCATOR_EDITOR=vscode
|
||||
VITE_DEVTOOLS_OVERLAY=true
|
||||
VITE_PLATFROM_MASONRY_ALWAYS_COMPAT=
|
BIN
bun.lockb
BIN
bun.lockb
Binary file not shown.
|
@ -10,10 +10,6 @@ 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.
|
||||
- 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 `WritableAtom<unknwon>.set` might do an equals check. You must set a different object to ensure the atom sending a notify.
|
||||
|
@ -47,3 +43,44 @@ 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.
|
||||
|
||||
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.
|
||||
|
|
|
@ -8,7 +8,8 @@
|
|||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"preview": "vite preview",
|
||||
"dist": "vite build"
|
||||
"dist": "vite build",
|
||||
"count-source-lines": "exec scripts/src-lc.sh"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Rubicon",
|
||||
|
@ -17,6 +18,7 @@
|
|||
"@solid-devtools/overlay": "^0.30.1",
|
||||
"@suid/vite-plugin": "^0.3.1",
|
||||
"@types/hammerjs": "^2.0.46",
|
||||
"@types/masonry-layout": "^4.2.8",
|
||||
"@vite-pwa/assets-generator": "^0.2.6",
|
||||
"postcss": "^8.4.49",
|
||||
"prettier": "^3.3.3",
|
||||
|
@ -48,6 +50,7 @@
|
|||
"fast-average-color": "^9.4.0",
|
||||
"hammerjs": "^2.0.8",
|
||||
"iso-639-1": "^3.1.3",
|
||||
"masonry-layout": "^4.2.2",
|
||||
"masto": "^6.10.1",
|
||||
"nanostores": "^0.11.3",
|
||||
"normalize.css": "^8.0.1",
|
||||
|
@ -55,7 +58,6 @@
|
|||
"solid-js": "^1.9.3",
|
||||
"solid-styled": "^0.11.1",
|
||||
"stacktrace-js": "^2.0.2",
|
||||
"web-animations-js": "^2.3.2",
|
||||
"workbox-core": "^7.3.0",
|
||||
"workbox-precaching": "^7.3.0"
|
||||
},
|
||||
|
|
10
scripts/src-lc.sh
Executable file
10
scripts/src-lc.sh
Executable file
|
@ -0,0 +1,10 @@
|
|||
#!/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=-
|
|
@ -35,3 +35,7 @@ https://stackoverflow.com/questions/66005655/pwa-ios-child-of-body-not-taking-10
|
|||
h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
* {
|
||||
user-select: none;
|
||||
}
|
||||
|
|
23
src/App.tsx
23
src/App.tsx
|
@ -1,4 +1,4 @@
|
|||
import { Route, Router } from "@solidjs/router";
|
||||
import { Route } from "@solidjs/router";
|
||||
import { ThemeProvider } from "@suid/material";
|
||||
import {
|
||||
Component,
|
||||
|
@ -17,7 +17,12 @@ import {
|
|||
} from "./masto/clients.js";
|
||||
import { $accounts, updateAcctInf } from "./accounts/stores.js";
|
||||
import { useStore } from "@nanostores/solid";
|
||||
import { DateFnScope, useLanguage } from "./platform/i18n.jsx";
|
||||
import {
|
||||
AppLocaleProvider,
|
||||
createCurrentLanguage,
|
||||
createCurrentRegion,
|
||||
createDateFnLocaleResource,
|
||||
} from "./platform/i18n.jsx";
|
||||
import { useRegisterSW } from "virtual:pwa-register/solid";
|
||||
import {
|
||||
isJSONRPCResult,
|
||||
|
@ -67,7 +72,9 @@ const Routing: Component = () => {
|
|||
const App: Component = () => {
|
||||
const theme = useRootTheme();
|
||||
const accts = useStore($accounts);
|
||||
const lang = useLanguage();
|
||||
const lang = createCurrentLanguage();
|
||||
const region = createCurrentRegion();
|
||||
const dateFnLocale = createDateFnLocaleResource(region);
|
||||
const [serviceWorker, setServiceWorker] = createSignal<
|
||||
ServiceWorker | undefined
|
||||
>(undefined, { name: "serviceWorker" });
|
||||
|
@ -150,7 +157,13 @@ const App: Component = () => {
|
|||
}}
|
||||
>
|
||||
<ThemeProvider theme={theme}>
|
||||
<DateFnScope>
|
||||
<AppLocaleProvider
|
||||
value={{
|
||||
language: lang,
|
||||
region: region,
|
||||
dateFn: dateFnLocale,
|
||||
}}
|
||||
>
|
||||
<ClientProvider value={clients}>
|
||||
<ServiceWorkerProvider
|
||||
value={{
|
||||
|
@ -162,7 +175,7 @@ const App: Component = () => {
|
|||
<Routing />
|
||||
</ServiceWorkerProvider>
|
||||
</ClientProvider>
|
||||
</DateFnScope>
|
||||
</AppLocaleProvider>
|
||||
</ThemeProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
|
|
@ -42,6 +42,29 @@ const UnexpectedError: Component<{ error?: any }> = (props) => {
|
|||
calc(var(--safe-area-inset-bottom) + 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 (
|
||||
|
@ -52,8 +75,11 @@ const UnexpectedError: Component<{ error?: any }> = (props) => {
|
|||
You can restart the app to see if this guy is gone. If you meet this guy
|
||||
repeatly, please report to us.
|
||||
</p>
|
||||
<div>
|
||||
<Button onClick={() => (window.location.replace("/"))}>
|
||||
<div class="actions">
|
||||
<Button
|
||||
onClick={() => window.location.replace("/")}
|
||||
variant="contained"
|
||||
>
|
||||
Restart App
|
||||
</Button>
|
||||
</div>
|
||||
|
@ -61,7 +87,10 @@ const UnexpectedError: Component<{ error?: any }> = (props) => {
|
|||
<summary>
|
||||
{errorMsg.loading ? "Generating " : " "}Technical Infomation
|
||||
</summary>
|
||||
<pre>{errorMsg()}</pre>
|
||||
<pre>
|
||||
On: {window.location.href} <br />
|
||||
{errorMsg()}
|
||||
</pre>
|
||||
</details>
|
||||
</main>
|
||||
);
|
||||
|
|
|
@ -9,12 +9,12 @@ import {
|
|||
import { acceptAccountViaAuthCode } from "./stores";
|
||||
import { $settings } from "../settings/stores";
|
||||
import { useDocumentTitle } from "../utils";
|
||||
import cards from "../material/cards.module.css";
|
||||
import cards from "~material/cards.module.css";
|
||||
import { LinearProgress } from "@suid/material";
|
||||
import Img from "../material/Img";
|
||||
import Img from "~material/Img";
|
||||
import { createRestAPIClient } from "masto";
|
||||
import { Title } from "../material/typography";
|
||||
import { useNavigator } from "../platform/StackedRouter";
|
||||
import { Title } from "~material/typography";
|
||||
import { useNavigator } from "~platform/StackedRouter";
|
||||
|
||||
type OAuth2CallbackParams = {
|
||||
code?: string;
|
||||
|
|
|
@ -7,11 +7,11 @@ import {
|
|||
createUniqueId,
|
||||
onMount,
|
||||
} from "solid-js";
|
||||
import cards from "../material/cards.module.css";
|
||||
import TextField from "../material/TextField.js";
|
||||
import Button from "../material/Button.js";
|
||||
import cards from "~material/cards.module.css";
|
||||
import TextField from "~material/TextField.js";
|
||||
import Button from "~material/Button.js";
|
||||
import { useDocumentTitle } from "../utils";
|
||||
import { Title } from "../material/typography";
|
||||
import { Title } from "~material/typography";
|
||||
import { css } from "solid-styled";
|
||||
import { LinearProgress } from "@suid/material";
|
||||
import { createRestAPIClient } from "masto";
|
||||
|
|
|
@ -167,7 +167,7 @@ export async function getOrRegisterApp(site: string, redirectUrl: string) {
|
|||
});
|
||||
const app = await client.v1.apps.create({
|
||||
clientName: "TuTu",
|
||||
website: "https://github.com/thislight/tutu",
|
||||
website: "https://code.lightstands.xyz/Rubicon/tutu",
|
||||
redirectUris: redirectUrl,
|
||||
scopes: "read write push",
|
||||
});
|
||||
|
|
|
@ -9,7 +9,7 @@ import {
|
|||
import { Account } from "../accounts/stores";
|
||||
import { createRestAPIClient, mastodon } from "masto";
|
||||
import { useLocation } from "@solidjs/router";
|
||||
import { useNavigator } from "../platform/StackedRouter";
|
||||
import { useNavigator } from "~platform/StackedRouter";
|
||||
|
||||
const restfulCache: Record<string, mastodon.rest.Client> = {};
|
||||
|
||||
|
@ -64,7 +64,7 @@ export function useSessions() {
|
|||
if (sessions().length > 0) return;
|
||||
push(
|
||||
"/accounts/sign-in?back=" + encodeURIComponent(location.pathname),
|
||||
{ replace: true },
|
||||
{ replace: "all" },
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
@ -1,25 +1,18 @@
|
|||
import {
|
||||
children,
|
||||
createEffect,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
useTransition,
|
||||
type JSX,
|
||||
type ParentComponent,
|
||||
type ResolvedChildren,
|
||||
} from "solid-js";
|
||||
import "./BottomSheet.css";
|
||||
import { useHeroSignal } from "../platform/anim";
|
||||
import material from "./material.module.css";
|
||||
import {
|
||||
ANIM_CURVE_ACELERATION,
|
||||
ANIM_CURVE_DECELERATION,
|
||||
ANIM_CURVE_STD,
|
||||
} from "./theme";
|
||||
import { ANIM_CURVE_ACELERATION, ANIM_CURVE_DECELERATION } from "./theme";
|
||||
import {
|
||||
animateSlideInFromRight,
|
||||
animateSlideOutToRight,
|
||||
} from "../platform/anim";
|
||||
} from "~platform/anim";
|
||||
|
||||
export type BottomSheetProps = {
|
||||
open?: boolean;
|
||||
|
@ -28,117 +21,9 @@ export type BottomSheetProps = {
|
|||
onClose?(reason: "backdrop"): void;
|
||||
};
|
||||
|
||||
export const HERO = Symbol("BottomSheet Hero Symbol");
|
||||
|
||||
function composeAnimationFrame(
|
||||
{
|
||||
top,
|
||||
left,
|
||||
height,
|
||||
width,
|
||||
}: Record<"top" | "left" | "height" | "width", number>,
|
||||
x: Record<string, unknown>,
|
||||
) {
|
||||
return {
|
||||
top: `${top}px`,
|
||||
left: `${left}px`,
|
||||
height: `${height}px`,
|
||||
width: `${width}px`,
|
||||
...x,
|
||||
};
|
||||
}
|
||||
|
||||
const MOVE_SPEED = 1600;
|
||||
|
||||
const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
|
||||
let element: HTMLDialogElement;
|
||||
let animation: Animation | undefined;
|
||||
const [hero, setHero] = useHeroSignal(HERO);
|
||||
const [cache, setCache] = createSignal<ResolvedChildren | undefined>();
|
||||
const ochildren = children(() => props.children);
|
||||
|
||||
const [pending] = useTransition();
|
||||
|
||||
createEffect(() => {
|
||||
if (props.open) {
|
||||
if (!element.open && !pending()) {
|
||||
requestAnimationFrame(animatedOpen);
|
||||
setCache(ochildren());
|
||||
}
|
||||
} else {
|
||||
if (element.open) {
|
||||
animatedClose();
|
||||
setCache(undefined);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const onClose = () => {
|
||||
const srcElement = hero();
|
||||
if (srcElement) {
|
||||
srcElement.style.visibility = "unset";
|
||||
}
|
||||
|
||||
element.close();
|
||||
setHero();
|
||||
};
|
||||
|
||||
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) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
const onAnimationEnd = () => {
|
||||
element.classList.remove("animated");
|
||||
onClose();
|
||||
};
|
||||
element.classList.add("animated");
|
||||
animation = props.bottomUp
|
||||
? animateSlideInFromBottom(element, true)
|
||||
: animateSlideOutToRight(element, { easing: ANIM_CURVE_ACELERATION });
|
||||
animation.addEventListener("finish", onAnimationEnd);
|
||||
animation.addEventListener("cancel", onAnimationEnd);
|
||||
}
|
||||
};
|
||||
|
||||
const animatedOpen = () => {
|
||||
element.showModal();
|
||||
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);
|
||||
} else if (window.innerWidth <= 560) {
|
||||
element.classList.add("animated");
|
||||
const onAnimationEnd = () => {
|
||||
element.classList.remove("animated");
|
||||
};
|
||||
animation = animateSlideInFromRight(element, {
|
||||
easing: ANIM_CURVE_DECELERATION,
|
||||
});
|
||||
animation.addEventListener("finish", onAnimationEnd);
|
||||
animation.addEventListener("cancel", onAnimationEnd);
|
||||
}
|
||||
};
|
||||
|
||||
const animateSlideInFromBottom = (
|
||||
element: HTMLElement,
|
||||
reserve?: boolean,
|
||||
) => {
|
||||
function animateSlideInFromBottom(element: HTMLElement, reverse?: boolean) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const easing = "cubic-bezier(0.4, 0, 0.2, 1)";
|
||||
element.classList.add("animated");
|
||||
|
@ -147,9 +32,9 @@ const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
|
|||
const distance = Math.abs(rect.top - window.innerHeight);
|
||||
const duration = (distance / MOVE_SPEED) * 1000;
|
||||
|
||||
animation = element.animate(
|
||||
const animation = element.animate(
|
||||
{
|
||||
top: reserve
|
||||
top: reverse
|
||||
? [`${rect.top}px`, `${window.innerHeight}px`]
|
||||
: [`${window.innerHeight}px`, `${rect.top}px`],
|
||||
},
|
||||
|
@ -158,46 +43,69 @@ const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
|
|||
const onAnimationEnd = () => {
|
||||
element.classList.remove("animated");
|
||||
document.body.style.overflow = oldOverflow;
|
||||
animation = undefined;
|
||||
};
|
||||
animation.addEventListener("cancel", onAnimationEnd);
|
||||
animation.addEventListener("finish", onAnimationEnd);
|
||||
return animation;
|
||||
}
|
||||
|
||||
const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
|
||||
let element: HTMLDialogElement;
|
||||
let animation: Animation | undefined;
|
||||
const child = children(() => props.children);
|
||||
|
||||
const [pending] = useTransition();
|
||||
|
||||
createEffect(() => {
|
||||
if (props.open) {
|
||||
if (!element.open && !pending()) {
|
||||
requestAnimationFrame(animatedOpen);
|
||||
}
|
||||
} else {
|
||||
if (element.open) {
|
||||
animatedClose();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const onClose = () => {
|
||||
element.close();
|
||||
};
|
||||
|
||||
const animateHero = (
|
||||
startRect: DOMRect,
|
||||
endRect: DOMRect,
|
||||
element: HTMLElement,
|
||||
reserve?: boolean,
|
||||
) => {
|
||||
const easing = ANIM_CURVE_STD;
|
||||
const animatedClose = () => {
|
||||
if (window.innerWidth > 560 && !props.bottomUp) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
const onAnimationEnd = () => {
|
||||
element.classList.remove("animated");
|
||||
animation = undefined;
|
||||
onClose();
|
||||
};
|
||||
element.classList.add("animated");
|
||||
animation = props.bottomUp
|
||||
? animateSlideInFromBottom(element, true)
|
||||
: animateSlideOutToRight(element, { easing: ANIM_CURVE_ACELERATION });
|
||||
animation.addEventListener("finish", onAnimationEnd);
|
||||
animation.addEventListener("cancel", onAnimationEnd);
|
||||
};
|
||||
|
||||
const animatedOpen = () => {
|
||||
element.showModal();
|
||||
if (props.bottomUp) {
|
||||
animateSlideInFromBottom(element);
|
||||
} else if (window.innerWidth <= 560) {
|
||||
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 = animateSlideInFromRight(element, {
|
||||
easing: ANIM_CURVE_DECELERATION,
|
||||
});
|
||||
animation.addEventListener("finish", onAnimationEnd);
|
||||
animation.addEventListener("cancel", onAnimationEnd);
|
||||
return animation;
|
||||
}
|
||||
};
|
||||
|
||||
onCleanup(() => {
|
||||
|
@ -209,6 +117,7 @@ const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
|
|||
const onDialogClick = (
|
||||
event: MouseEvent & { currentTarget: HTMLDialogElement },
|
||||
) => {
|
||||
event.stopPropagation();
|
||||
if (event.target !== event.currentTarget) return;
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const isNotInDialog =
|
||||
|
@ -239,7 +148,7 @@ const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
|
|||
tabIndex={-1}
|
||||
role="presentation"
|
||||
>
|
||||
{ochildren() ?? cache()}
|
||||
{child()}
|
||||
</dialog>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
width: max-content;
|
||||
box-shadow: var(--tutu-shadow-e8);
|
||||
contain: content;
|
||||
overscroll-behavior: contain;
|
||||
|
||||
&.e1 {
|
||||
box-shadow: var(--tutu-shadow-e9);
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { useWindowSize } from "@solid-primitives/resize-observer";
|
||||
import { MenuList } from "@suid/material";
|
||||
import {
|
||||
batch,
|
||||
createEffect,
|
||||
createSignal,
|
||||
splitProps,
|
||||
|
@ -13,7 +14,8 @@ import "./Menu.css";
|
|||
import {
|
||||
animateGrowFromTopRight,
|
||||
animateShrinkToTopRight,
|
||||
} from "../platform/anim";
|
||||
} from "~platform/anim";
|
||||
import type { MenuListProps } from "@suid/material/MenuList";
|
||||
|
||||
export type Anchor = Pick<DOMRect, "top" | "left" | "right"> & { e?: number };
|
||||
|
||||
|
@ -22,6 +24,7 @@ export type MenuProps = ParentProps<
|
|||
open?: boolean;
|
||||
onClose?: JSX.EventHandlerUnion<HTMLDialogElement, Event>;
|
||||
anchor: () => Anchor;
|
||||
MenuListProps?: MenuListProps;
|
||||
|
||||
id?: string;
|
||||
} & JSX.AriaAttributes
|
||||
|
@ -63,11 +66,39 @@ export function createManagedMenuState() {
|
|||
return !!anchor();
|
||||
},
|
||||
anchor: anchor as () => Anchor,
|
||||
onClose: () => setAnchor(),
|
||||
onClose: (event: Event) => {
|
||||
event.preventDefault();
|
||||
return setAnchor();
|
||||
},
|
||||
},
|
||||
] 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
|
||||
* implemented with dialog and {@link MenuList} from SUID.
|
||||
|
@ -76,10 +107,16 @@ export function createManagedMenuState() {
|
|||
* - Use {@link createManagedMenuState} and you don't need to manage the open and close.
|
||||
* - Use {@link MenuItem} from SUID as children.
|
||||
*/
|
||||
const Menu: Component<MenuProps> = (props) => {
|
||||
const Menu: Component<MenuProps> = (oprops) => {
|
||||
let root: HTMLDialogElement;
|
||||
const windowSize = useWindowSize();
|
||||
const [, rest] = splitProps(props, ["open", "onClose", "anchor"]);
|
||||
const [props, rest] = splitProps(oprops, [
|
||||
"open",
|
||||
"onClose",
|
||||
"anchor",
|
||||
"MenuListProps",
|
||||
"children",
|
||||
]);
|
||||
|
||||
const [anchorPos, setAnchorPos] = createSignal<{
|
||||
left?: number;
|
||||
|
@ -104,16 +141,30 @@ const Menu: Component<MenuProps> = (props) => {
|
|||
|
||||
let openAnimationOrigin: "lt" | "rt" = "lt";
|
||||
|
||||
createEffect(() => {
|
||||
if (props.open) {
|
||||
const animateOpen = () => {
|
||||
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();
|
||||
const rend = root.getBoundingClientRect();
|
||||
|
||||
const { width } = windowSize;
|
||||
const { left, top, right, e } = a;
|
||||
if (left > width / 2) {
|
||||
openAnimationOrigin = "rt";
|
||||
setAnchorPos({
|
||||
|
@ -121,34 +172,26 @@ const Menu: Component<MenuProps> = (props) => {
|
|||
top,
|
||||
e,
|
||||
});
|
||||
|
||||
animateGrowFromTopRight(root, { easing: ANIM_CURVE_STD });
|
||||
} else {
|
||||
openAnimationOrigin = "lt";
|
||||
setAnchorPos({ left, top, e });
|
||||
}
|
||||
|
||||
const overflow = root.style.overflow;
|
||||
root.style.overflow = "hidden";
|
||||
const duration = (rend.height / 1600) * 1000;
|
||||
const easing = ANIM_CURVE_STD;
|
||||
const animation = root.animate(
|
||||
{
|
||||
height: [`${rend.height / 2}px`, `${rend.height}px`],
|
||||
width: [`${(rend.width / 4) * 3}px`, `${rend.width}px`],
|
||||
},
|
||||
{
|
||||
duration,
|
||||
easing,
|
||||
},
|
||||
);
|
||||
animation.addEventListener(
|
||||
"finish",
|
||||
() => (root.style.overflow = overflow),
|
||||
);
|
||||
if (!isOpened) {
|
||||
switch (openAnimationOrigin) {
|
||||
case "lt":
|
||||
animateGrowFromTopLeft(root, { easing: ANIM_CURVE_STD });
|
||||
break;
|
||||
case "rt":
|
||||
animateGrowFromTopRight(root, { easing: ANIM_CURVE_STD });
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// TODO: update the pos
|
||||
}
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
if (props.open) {
|
||||
animateOpen();
|
||||
} else {
|
||||
animateClose();
|
||||
}
|
||||
|
@ -183,27 +226,42 @@ const Menu: Component<MenuProps> = (props) => {
|
|||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={root!}
|
||||
onClose={props.onClose}
|
||||
onClick={(e) => {
|
||||
if (e.target === root) {
|
||||
const onDialogClick = (
|
||||
event: MouseEvent & { currentTarget: HTMLDialogElement },
|
||||
) => {
|
||||
event.stopPropagation();
|
||||
if (event.currentTarget !== event.target) return;
|
||||
if (!event.currentTarget.open) return;
|
||||
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const isNotInDialog =
|
||||
event.clientY < rect.top ||
|
||||
event.clientY > rect.bottom ||
|
||||
event.clientX < rect.left ||
|
||||
event.clientX > rect.right;
|
||||
|
||||
if (isNotInDialog) {
|
||||
if (props.onClose) {
|
||||
if (Array.isArray(props.onClose)) {
|
||||
props.onClose[0](props.onClose[1], e);
|
||||
props.onClose[0](props.onClose[1], event);
|
||||
} else {
|
||||
(
|
||||
props.onClose as (
|
||||
event: Event & { currentTarget: HTMLDialogElement },
|
||||
) => void
|
||||
)(e);
|
||||
)(event);
|
||||
}
|
||||
}
|
||||
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={{
|
||||
left: px(anchorPos().left),
|
||||
top: px(anchorPos().top),
|
||||
|
@ -213,11 +271,8 @@ const Menu: Component<MenuProps> = (props) => {
|
|||
tabIndex={-1}
|
||||
{...rest}
|
||||
>
|
||||
<div
|
||||
class="container"
|
||||
role="presentation"
|
||||
>
|
||||
<MenuList>{props.children}</MenuList>
|
||||
<div class="container" role="presentation">
|
||||
<MenuList {...props.MenuListProps}>{props.children}</MenuList>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
|
|
|
@ -4,6 +4,9 @@
|
|||
z-index: var(--tutu-zidx-nav, auto);
|
||||
|
||||
.MuiToolbar-root {
|
||||
margin-left: var(--safe-area-inset-left);
|
||||
margin-right: var(--safe-area-inset-right);
|
||||
|
||||
>.MuiButtonBase-root {
|
||||
&:first-child {
|
||||
margin-left: -0.5em;
|
||||
|
@ -32,7 +35,6 @@
|
|||
left: 0;
|
||||
right: 0;
|
||||
z-index: var(--tutu-zidx-nav, auto);
|
||||
padding-bottom: var(--safe-area-inset-bottom, 0);
|
||||
}
|
||||
|
||||
.Scaffold {
|
||||
|
|
|
@ -153,6 +153,8 @@
|
|||
--tutu-transition-shadow: box-shadow 175ms var(--tutu-anim-curve-std);
|
||||
|
||||
--tutu-zidx-nav: 1100;
|
||||
|
||||
accent-color: var(--tutu-color-primary);
|
||||
}
|
||||
|
||||
* {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Theme, createTheme } from "@suid/material/styles";
|
||||
import { deepPurple, amber } from "@suid/material/colors";
|
||||
import { deepPurple, amber, red } from "@suid/material/colors";
|
||||
import { Accessor } from "solid-js";
|
||||
|
||||
/**
|
||||
|
@ -12,6 +12,9 @@ export function useRootTheme(): Accessor<Theme> {
|
|||
primary: {
|
||||
main: deepPurple[500],
|
||||
},
|
||||
error: {
|
||||
main: red[900],
|
||||
},
|
||||
secondary: {
|
||||
main: amber.A200,
|
||||
},
|
||||
|
|
4
src/overrides.d.ts
vendored
4
src/overrides.d.ts
vendored
|
@ -11,6 +11,10 @@ interface ImportMetaEnv {
|
|||
* Attach the overlay (in the dev mode) if it's `"true"`.
|
||||
*/
|
||||
readonly VITE_DEVTOOLS_OVERLAY?: string;
|
||||
/**
|
||||
* Always use compatible version of Masonry.
|
||||
*/
|
||||
readonly VITE_PLATFROM_MASONRY_ALWAYS_COMPAT?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
|
11
src/platform/Masonry.css
Normal file
11
src/platform/Masonry.css
Normal file
|
@ -0,0 +1,11 @@
|
|||
.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;
|
||||
}
|
||||
}
|
162
src/platform/Masonry.tsx
Normal file
162
src/platform/Masonry.tsx
Normal file
|
@ -0,0 +1,162 @@
|
|||
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;
|
|
@ -1,10 +1,10 @@
|
|||
.StackedPage {
|
||||
container: StackedPage / size;
|
||||
display: contents;
|
||||
max-width: 100vw;
|
||||
max-width: 100dvw;
|
||||
|
||||
contain: layout;
|
||||
contain: strict;
|
||||
container: StackedPage / inline-size;
|
||||
width: 100vw;
|
||||
width: 100dvw;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
dialog.StackedPage {
|
||||
|
@ -13,14 +13,26 @@ dialog.StackedPage {
|
|||
padding: 0;
|
||||
overscroll-behavior: none;
|
||||
width: 560px;
|
||||
max-width: 100vw;
|
||||
max-width: 100dvw;
|
||||
max-height: 100vh;
|
||||
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;
|
||||
display: none;
|
||||
|
||||
contain: strict;
|
||||
contain-intrinsic-size: auto 560px auto 100vh;
|
||||
contain-intrinsic-size: auto 560px auto 100dvh;
|
||||
contain-intrinsic-size: 560px 100vh;
|
||||
contain-intrinsic-size: 560px 100dvh;
|
||||
content-visibility: auto;
|
||||
|
||||
background: var(--tutu-color-surface);
|
||||
|
@ -31,18 +43,16 @@ dialog.StackedPage {
|
|||
|
||||
@media (max-width: 560px) {
|
||||
& {
|
||||
margin: 0;
|
||||
width: 100vw;
|
||||
width: 100dvw;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
contain-intrinsic-size: 100vw 100vh;
|
||||
contain-intrinsic-size: 100dvw 100dvh;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
&[open] {
|
||||
display: contents;
|
||||
display: block;
|
||||
}
|
||||
|
||||
&::backdrop {
|
||||
|
|
|
@ -2,6 +2,7 @@ import { StaticRouter, type RouterProps } from "@solidjs/router";
|
|||
import {
|
||||
Component,
|
||||
createContext,
|
||||
createMemo,
|
||||
createRenderEffect,
|
||||
createUniqueId,
|
||||
Index,
|
||||
|
@ -14,10 +15,9 @@ import {
|
|||
import { createStore, unwrap } from "solid-js/store";
|
||||
import "./StackedRouter.css";
|
||||
import { animateSlideInFromRight, animateSlideOutToRight } from "./anim";
|
||||
import { ANIM_CURVE_DECELERATION, ANIM_CURVE_STD } from "../material/theme";
|
||||
import {
|
||||
makeEventListener,
|
||||
} from "@solid-primitives/event-listener";
|
||||
import { ANIM_CURVE_DECELERATION, ANIM_CURVE_STD } from "~material/theme";
|
||||
import { makeEventListener } from "@solid-primitives/event-listener";
|
||||
import { useWindowSize } from "@solid-primitives/resize-observer";
|
||||
|
||||
export type StackedRouterProps = Omit<RouterProps, "url">;
|
||||
|
||||
|
@ -36,9 +36,9 @@ export type NewFrameOptions<T> = (T extends undefined
|
|||
}
|
||||
: { state: T }) & {
|
||||
/**
|
||||
* The new frame should replace the current frame.
|
||||
* The new frame should replace the current frame or all the stack.
|
||||
*/
|
||||
replace?: boolean;
|
||||
replace?: boolean | "all";
|
||||
/**
|
||||
* The animatedOpen phase of the life cycle.
|
||||
*
|
||||
|
@ -79,6 +79,11 @@ export type Navigator<PushGuide = Record<string, any>> = {
|
|||
|
||||
const NavigatorContext = /* @__PURE__ */ createContext<Navigator>();
|
||||
|
||||
/**
|
||||
* Get the possible navigator of the {@link StackedRouter}.
|
||||
*
|
||||
* @see useNavigator for the navigator usage.
|
||||
*/
|
||||
export function useMaybeNavigator() {
|
||||
return useContext(NavigatorContext);
|
||||
}
|
||||
|
@ -91,6 +96,8 @@ export function useMaybeNavigator() {
|
|||
* path and its state. If you need push guide, you may want to
|
||||
* define your own function (like `useAppNavigator`) and cast the
|
||||
* navigator to the type you need.
|
||||
*
|
||||
* @see {@link useMaybeNavigator} if you are not sure you are under a {@link StackedRouter}.
|
||||
*/
|
||||
export function useNavigator() {
|
||||
const navigator = useMaybeNavigator();
|
||||
|
@ -110,10 +117,20 @@ export type CurrentFrame = {
|
|||
const CurrentFrameContext =
|
||||
/* @__PURE__ */ createContext<Accessor<Readonly<CurrentFrame>>>();
|
||||
|
||||
/**
|
||||
* Return the current, if possible.
|
||||
*
|
||||
* @see {@link useCurrentFrame} asserts the frame exists
|
||||
*/
|
||||
export function useMaybeCurrentFrame() {
|
||||
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() {
|
||||
const frame = useMaybeCurrentFrame();
|
||||
|
||||
|
@ -130,8 +147,11 @@ export function useCurrentFrame() {
|
|||
* A suspended frame is the one not on the top. "Suspended"
|
||||
* is the description of a certain situtation, not in the life cycle
|
||||
* of a frame.
|
||||
*
|
||||
* If this is not called under a {@link StackedRouter}, it always
|
||||
* returns `false`.
|
||||
*/
|
||||
export function useMaybeIsFrameSuspended() {
|
||||
export function useIsFrameSuspended() {
|
||||
const { frames } = useMaybeNavigator() || {};
|
||||
|
||||
if (typeof frames === "undefined") {
|
||||
|
@ -203,11 +223,199 @@ 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.
|
||||
*
|
||||
* **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 [stack, mutStack] = createStore([] as StackFrame[], { name: "stack" });
|
||||
const windowSize = useWindowSize();
|
||||
|
||||
const pushFrame = (path: string, opts?: Readonly<NewFrameOptions<any>>) =>
|
||||
untrack(() => {
|
||||
|
@ -219,11 +427,19 @@ const StackedRouter: Component<StackedRouterProps> = (oprops) => {
|
|||
animateClose: opts?.animateClose,
|
||||
};
|
||||
|
||||
mutStack(opts?.replace ? stack.length - 1 : stack.length, frame);
|
||||
if (opts?.replace) {
|
||||
window.history.replaceState(serializableStack(stack), "", path);
|
||||
const replace = opts?.replace;
|
||||
if (replace === "all") {
|
||||
mutStack([frame]);
|
||||
} else {
|
||||
window.history.pushState(serializableStack(stack), "", path);
|
||||
mutStack(replace ? stack.length - 1 : stack.length, frame);
|
||||
}
|
||||
|
||||
const savedStack = serializableStack(stack);
|
||||
|
||||
if (replace) {
|
||||
window.history.replaceState(savedStack, "", path);
|
||||
} else {
|
||||
window.history.pushState(savedStack, "", path);
|
||||
}
|
||||
return frame;
|
||||
});
|
||||
|
@ -261,7 +477,10 @@ const StackedRouter: Component<StackedRouterProps> = (oprops) => {
|
|||
|
||||
createRenderEffect(() => {
|
||||
if (stack.length === 0) {
|
||||
pushFrame(window.location.pathname);
|
||||
mutStack(0, {
|
||||
path: window.location.pathname,
|
||||
rootId: createUniqueId(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -292,89 +511,39 @@ const StackedRouter: Component<StackedRouterProps> = (oprops) => {
|
|||
});
|
||||
};
|
||||
|
||||
let reenterableAnimation: Animation | undefined;
|
||||
let origX = 0,
|
||||
origWidth = 0;
|
||||
|
||||
const resetAnimation = () => {
|
||||
reenterableAnimation = undefined;
|
||||
const subInsets = createMemo(() => {
|
||||
const SUBPAGE_MAX_WIDTH = 560;
|
||||
const { width } = windowSize;
|
||||
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 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 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 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();
|
||||
};
|
||||
const swipeToBackProps = createManagedSwipeToBack(stack, onlyPopFrame);
|
||||
|
||||
return (
|
||||
<NavigatorContext.Provider
|
||||
|
@ -402,6 +571,7 @@ const StackedRouter: Component<StackedRouterProps> = (oprops) => {
|
|||
class="StackedPage"
|
||||
id={frame().rootId}
|
||||
role="presentation"
|
||||
on:touchstart={onEntryTouchStart}
|
||||
>
|
||||
<StaticRouter url={frame().path} {...oprops} />
|
||||
</div>
|
||||
|
@ -412,11 +582,9 @@ const StackedRouter: Component<StackedRouterProps> = (oprops) => {
|
|||
class="StackedPage"
|
||||
onCancel={[popFrame, 1]}
|
||||
onClick={[onDialogClick, popFrame]}
|
||||
onTouchStart={onDialogTouchStart}
|
||||
onTouchMove={onDialogTouchMove}
|
||||
onTouchEnd={onDialogTouchEnd}
|
||||
onTouchCancel={onDialogTouchCancel}
|
||||
{...swipeToBackProps}
|
||||
id={frame().rootId}
|
||||
style={subInsets()}
|
||||
>
|
||||
<StaticRouter url={frame().path} {...oprops} />
|
||||
</dialog>
|
||||
|
|
|
@ -1,58 +1,3 @@
|
|||
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(
|
||||
root: HTMLElement,
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import {
|
||||
ParentComponent,
|
||||
catchError,
|
||||
createContext,
|
||||
createMemo,
|
||||
createResource,
|
||||
useContext,
|
||||
} from "solid-js";
|
||||
import { match } from "@formatjs/intl-localematcher";
|
||||
import { Accessor, createEffect, createSignal } from "solid-js";
|
||||
import { Accessor } from "solid-js";
|
||||
import { $settings } from "../settings/stores";
|
||||
import { enGB } from "date-fns/locale/en-GB";
|
||||
import { useStore } from "@nanostores/solid";
|
||||
|
@ -17,13 +17,6 @@ import {
|
|||
type Template,
|
||||
} 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_REGIONS = ["en_US", "en_GB", "zh_CN"] as const;
|
||||
|
@ -38,14 +31,6 @@ export function autoMatchLangTag() {
|
|||
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() {
|
||||
const specifiers = navigator.languages.map((x) => x.split("-"));
|
||||
|
||||
|
@ -58,7 +43,7 @@ export function autoMatchRegion() {
|
|||
}
|
||||
}
|
||||
} else if (s.length === 2) {
|
||||
const [lang, region] = s[1];
|
||||
const [lang, region] = s;
|
||||
for (const available of SUPPORTED_REGIONS) {
|
||||
if (available.toLowerCase() === `${lang}_${region}`.toLowerCase()) {
|
||||
return available;
|
||||
|
@ -70,7 +55,7 @@ export function autoMatchRegion() {
|
|||
return "en_GB";
|
||||
}
|
||||
|
||||
export function useRegion() {
|
||||
export function createCurrentRegion() {
|
||||
const appSettings = useStore($settings);
|
||||
|
||||
return createMemo(
|
||||
|
@ -100,53 +85,6 @@ 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.
|
||||
*
|
||||
|
@ -155,11 +93,11 @@ export const DateFnScope: ParentComponent = (props) => {
|
|||
* @returns Accessor for Locale
|
||||
*/
|
||||
export function useDateFnLocale(): Accessor<Locale> {
|
||||
const cx = useContext(DateFnLocaleCx);
|
||||
return cx;
|
||||
const { dateFn } = useAppLocale();
|
||||
return dateFn;
|
||||
}
|
||||
|
||||
export function useLanguage() {
|
||||
export function createCurrentLanguage() {
|
||||
const settings = useStore($settings);
|
||||
return () => settings().language || autoMatchLangTag();
|
||||
}
|
||||
|
@ -179,7 +117,7 @@ type MergedImportedModule<T> = T extends []
|
|||
export function createStringResource<
|
||||
T extends ImportFn<Record<string, string | Template<any> | undefined>>[],
|
||||
>(...importFns: T) {
|
||||
const language = useLanguage();
|
||||
const language = createCurrentLanguage();
|
||||
const cache: Record<string, MergedImportedModule<T>> = {};
|
||||
|
||||
return createResource(
|
||||
|
@ -209,3 +147,38 @@ export function createTranslator<
|
|||
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,14 +1,7 @@
|
|||
//! This module has side effect.
|
||||
//! 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") {
|
||||
// TODO: this polyfill can be removed in 2.0, see https://code.lightstands.xyz/Rubicon/tutu/issues/36
|
||||
// Chrome/Edge 92+
|
||||
// https://stackoverflow.com/a/2117523/2800218
|
||||
// LICENSE: https://creativecommons.org/licenses/by-sa/4.0/legalcode
|
||||
|
|
|
@ -4,8 +4,8 @@ import {
|
|||
onCleanup,
|
||||
type Component,
|
||||
} from "solid-js";
|
||||
import Scaffold from "../material/Scaffold";
|
||||
import BottomSheet from "../material/BottomSheet";
|
||||
import Scaffold from "~material/Scaffold";
|
||||
import BottomSheet from "~material/BottomSheet";
|
||||
import {
|
||||
Button,
|
||||
IconButton,
|
||||
|
@ -14,9 +14,9 @@ import {
|
|||
Toolbar,
|
||||
} from "@suid/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 { useRootTheme } from "../material/theme";
|
||||
import { useRootTheme } from "~material/theme";
|
||||
|
||||
const ShareBottomSheet: Component<{
|
||||
data?: ShareData;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
.Profile {
|
||||
height: 100%;
|
||||
overflow: hidden auto;
|
||||
|
||||
.intro {
|
||||
background-color: var(--tutu-color-surface-d);
|
||||
|
@ -8,20 +8,17 @@
|
|||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
gap: 16px;
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
.banner {
|
||||
width: 100%;
|
||||
margin-top: calc(-1 * (var(--scaffold-topbar-height) + var(--safe-area-inset-top)));
|
||||
margin-top: calc(-1 * var(--scaffold-topbar-height));
|
||||
|
||||
>img {
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
&::before {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -32,6 +29,10 @@
|
|||
}
|
||||
|
||||
word-break: break-all;
|
||||
|
||||
& * {
|
||||
user-select: text;
|
||||
}
|
||||
}
|
||||
|
||||
.acct-grp {
|
||||
|
@ -53,6 +54,18 @@
|
|||
.name-grp {
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
|
||||
& * {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.display-name {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.username {
|
||||
user-select: all;
|
||||
}
|
||||
}
|
||||
|
||||
.acct-mark {
|
||||
|
@ -61,9 +74,7 @@
|
|||
margin-right: 0.25em;
|
||||
}
|
||||
|
||||
.display-name {
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
table.acct-fields {
|
||||
word-break: break-all;
|
||||
|
@ -82,6 +93,10 @@
|
|||
& svg {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
& * {
|
||||
user-select: text;
|
||||
}
|
||||
}
|
||||
|
||||
.toot-list-toolbar {
|
||||
|
@ -101,6 +116,46 @@
|
|||
}
|
||||
}
|
||||
|
||||
@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 {
|
||||
flex-grow: 1;
|
||||
white-space: nowrap;
|
||||
|
|
|
@ -12,7 +12,7 @@ import {
|
|||
type Component,
|
||||
createMemo,
|
||||
} from "solid-js";
|
||||
import Scaffold from "../material/Scaffold";
|
||||
import Scaffold from "~material/Scaffold";
|
||||
import {
|
||||
AppBar,
|
||||
Avatar,
|
||||
|
@ -44,7 +44,7 @@ import {
|
|||
Subject,
|
||||
Verified,
|
||||
} from "@suid/icons-material";
|
||||
import { Body2, Title } from "../material/typography";
|
||||
import { Body2, Title } from "~material/typography";
|
||||
import { useParams } from "@solidjs/router";
|
||||
import { useSessionForAcctStr } from "../masto/clients";
|
||||
import { resolveCustomEmoji } from "../masto/toot";
|
||||
|
@ -52,12 +52,12 @@ import { FastAverageColor } from "fast-average-color";
|
|||
import { useWindowSize } from "@solid-primitives/resize-observer";
|
||||
import { createTimeline, createTimelineSnapshot } from "../masto/timelines";
|
||||
import TootList from "../timelines/TootList";
|
||||
import { createTimeSource, TimeSourceProvider } from "../platform/timesrc";
|
||||
import { createTimeSource, TimeSourceProvider } from "~platform/timesrc";
|
||||
import TootFilterButton from "./TootFilterButton";
|
||||
import Menu, { createManagedMenuState } from "../material/Menu";
|
||||
import { share } from "../platform/share";
|
||||
import Menu, { createManagedMenuState } from "~material/Menu";
|
||||
import { share } from "~platform/share";
|
||||
import "./Profile.css";
|
||||
import { useNavigator } from "../platform/StackedRouter";
|
||||
import { useNavigator } from "~platform/StackedRouter";
|
||||
|
||||
const Profile: Component = () => {
|
||||
const { pop } = useNavigator();
|
||||
|
@ -175,12 +175,12 @@ const Profile: Component = () => {
|
|||
createRenderEffect(() => (e.innerHTML = sessionDisplayName()));
|
||||
};
|
||||
|
||||
const toggleSubscribeHome = async () => {
|
||||
const toggleSubscribeHome = async (event: Event) => {
|
||||
const client = session().client;
|
||||
if (!session().account) return;
|
||||
const isSubscribed = relationship()?.following ?? false;
|
||||
mutateRelationship((x) => Object.assign({ following: !isSubscribed }, x));
|
||||
subscribeMenuState.onClose();
|
||||
subscribeMenuState.onClose(event);
|
||||
|
||||
if (isSubscribed) {
|
||||
const nrel = await client.v1.accounts.$select(params.id).unfollow();
|
||||
|
@ -237,6 +237,7 @@ const Profile: Component = () => {
|
|||
}
|
||||
class="Profile"
|
||||
>
|
||||
<div class="details" role="presentation">
|
||||
<Menu
|
||||
id={optMenuId}
|
||||
open={menuOpen()}
|
||||
|
@ -424,7 +425,9 @@ const Profile: Component = () => {
|
|||
aria-label="Display name"
|
||||
></Body2>
|
||||
</div>
|
||||
<span aria-label="Complete username">{fullUsername()}</span>
|
||||
<span aria-label="Complete username" class="username">
|
||||
{fullUsername()}
|
||||
</span>
|
||||
</div>
|
||||
<div role="presentation">
|
||||
<Switch>
|
||||
|
@ -493,7 +496,9 @@ const Profile: Component = () => {
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="recent-toots" role="presentation">
|
||||
<div class="toot-list-toolbar">
|
||||
<TootFilterButton
|
||||
options={{
|
||||
|
@ -546,6 +551,7 @@ const Profile: Component = () => {
|
|||
</IconButton>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Scaffold>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Button, MenuItem, Checkbox, ListItemText } from "@suid/material";
|
||||
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";
|
||||
|
||||
type Props<Filters extends Record<string, string>> = {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { createMemo, For, type Component, type JSX } from "solid-js";
|
||||
import Scaffold from "../material/Scaffold";
|
||||
import Scaffold from "~material/Scaffold";
|
||||
import {
|
||||
AppBar,
|
||||
IconButton,
|
||||
|
@ -19,17 +19,17 @@ import {
|
|||
autoMatchLangTag,
|
||||
createTranslator,
|
||||
SUPPORTED_LANGS,
|
||||
} from "../platform/i18n";
|
||||
import { Title } from "../material/typography";
|
||||
} from "~platform/i18n";
|
||||
import { Title } from "~material/typography";
|
||||
import type { Template } from "@solid-primitives/i18n";
|
||||
import { useStore } from "@nanostores/solid";
|
||||
import { $settings } from "./stores";
|
||||
import { useNavigator } from "../platform/StackedRouter";
|
||||
import { useNavigator } from "~platform/StackedRouter";
|
||||
|
||||
const ChooseLang: Component = () => {
|
||||
const { pop } = useNavigator();
|
||||
const [t] = createTranslator(
|
||||
() => import("./i18n/lang-names.json"),
|
||||
() => import("./i18n/generic.json"),
|
||||
(code) =>
|
||||
import(`./i18n/${code}.json`) as Promise<{
|
||||
default: Record<string, string | undefined> & {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import type { Component } from "solid-js";
|
||||
import Scaffold from "../material/Scaffold";
|
||||
import Scaffold from "~material/Scaffold";
|
||||
import {
|
||||
AppBar,
|
||||
Divider,
|
||||
|
@ -12,12 +12,12 @@ import {
|
|||
Switch,
|
||||
Toolbar,
|
||||
} from "@suid/material";
|
||||
import { Title } from "../material/typography";
|
||||
import { Title } from "~material/typography";
|
||||
import { ArrowBack } from "@suid/icons-material";
|
||||
import { createTranslator } from "../platform/i18n";
|
||||
import { createTranslator } from "~platform/i18n";
|
||||
import { useStore } from "@nanostores/solid";
|
||||
import { $settings } from "./stores";
|
||||
import { useNavigator } from "../platform/StackedRouter";
|
||||
import { useNavigator } from "~platform/StackedRouter";
|
||||
|
||||
const Motions: Component = () => {
|
||||
const {pop} = useNavigator();
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { createMemo, For, type Component, type JSX } from "solid-js";
|
||||
import Scaffold from "../material/Scaffold";
|
||||
import Scaffold from "~material/Scaffold";
|
||||
import {
|
||||
AppBar,
|
||||
IconButton,
|
||||
|
@ -17,17 +17,17 @@ import {
|
|||
autoMatchRegion,
|
||||
createTranslator,
|
||||
SUPPORTED_REGIONS,
|
||||
} from "../platform/i18n";
|
||||
import { Title } from "../material/typography";
|
||||
} from "~platform/i18n";
|
||||
import { Title } from "~material/typography";
|
||||
import type { Template } from "@solid-primitives/i18n";
|
||||
import { $settings } from "./stores";
|
||||
import { useStore } from "@nanostores/solid";
|
||||
import { useNavigator } from "../platform/StackedRouter";
|
||||
import { useNavigator } from "~platform/StackedRouter";
|
||||
|
||||
const ChooseRegion: Component = () => {
|
||||
const {pop} = useNavigator();
|
||||
const [t] = createTranslator(
|
||||
() => import("./i18n/lang-names.json"),
|
||||
() => import("./i18n/generic.json"),
|
||||
(code) =>
|
||||
import(`./i18n/${code}.json`) as Promise<{
|
||||
default: Record<string, string | undefined> & {
|
||||
|
|
|
@ -1,9 +1,5 @@
|
|||
import {
|
||||
For,
|
||||
Show,
|
||||
type Component,
|
||||
} from "solid-js";
|
||||
import Scaffold from "../material/Scaffold.js";
|
||||
import { For, Show, type Component } from "solid-js";
|
||||
import Scaffold from "~material/Scaffold.js";
|
||||
import {
|
||||
AppBar,
|
||||
Divider,
|
||||
|
@ -27,8 +23,8 @@ import {
|
|||
Refresh as RefreshIcon,
|
||||
Translate as TranslateIcon,
|
||||
} from "@suid/icons-material";
|
||||
import A from "../platform/A.js";
|
||||
import { Title } from "../material/typography.jsx";
|
||||
import A from "~platform/A.js";
|
||||
import { Title } from "~material/typography.js";
|
||||
import { css } from "solid-styled";
|
||||
import { signOut, type Account } from "../accounts/stores.js";
|
||||
import { format } from "date-fns";
|
||||
|
@ -39,11 +35,11 @@ import {
|
|||
autoMatchRegion,
|
||||
createTranslator,
|
||||
useDateFnLocale,
|
||||
} from "../platform/i18n.jsx";
|
||||
} from "~platform/i18n.jsx";
|
||||
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 { useNavigator } from "../platform/StackedRouter.jsx";
|
||||
import { useNavigator } from "~platform/StackedRouter.jsx";
|
||||
|
||||
type Inset = {
|
||||
top?: number;
|
||||
|
@ -165,7 +161,7 @@ const Settings: Component = () => {
|
|||
import(`./i18n/${code}.json`) as Promise<{
|
||||
default: Strings;
|
||||
}>,
|
||||
() => import(`./i18n/lang-names.json`),
|
||||
() => import(`./i18n/generic.json`),
|
||||
);
|
||||
const { pop } = useNavigator();
|
||||
const settings$ = useStore($settings);
|
||||
|
@ -185,6 +181,9 @@ const Settings: Component = () => {
|
|||
|
||||
.setting-list {
|
||||
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 (
|
||||
|
|
|
@ -4,7 +4,7 @@ import {
|
|||
type Component,
|
||||
type JSX,
|
||||
} from "solid-js";
|
||||
import Scaffold from "../material/Scaffold";
|
||||
import Scaffold from "~material/Scaffold";
|
||||
import {
|
||||
AppBar,
|
||||
IconButton,
|
||||
|
@ -17,8 +17,8 @@ import {
|
|||
} from "@suid/material";
|
||||
import { Close as CloseIcon } from "@suid/icons-material";
|
||||
import iso639_1 from "iso-639-1";
|
||||
import { createTranslator } from "../platform/i18n";
|
||||
import { Title } from "../material/typography";
|
||||
import { createTranslator } from "~platform/i18n";
|
||||
import { Title } from "~material/typography";
|
||||
|
||||
type ChooseTootLangProps = {
|
||||
code: string;
|
||||
|
|
|
@ -1,57 +0,0 @@
|
|||
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;
|
|
@ -6,7 +6,7 @@ import {
|
|||
createRenderEffect,
|
||||
} from "solid-js";
|
||||
import { useDocumentTitle } from "../utils";
|
||||
import Scaffold from "../material/Scaffold";
|
||||
import Scaffold from "~material/Scaffold";
|
||||
import {
|
||||
AppBar,
|
||||
ListItemSecondaryAction,
|
||||
|
@ -16,10 +16,10 @@ import {
|
|||
Toolbar,
|
||||
} from "@suid/material";
|
||||
import { css } from "solid-styled";
|
||||
import { TimeSourceProvider, createTimeSource } from "../platform/timesrc";
|
||||
import { TimeSourceProvider, createTimeSource } from "~platform/timesrc";
|
||||
import ProfileMenuButton from "./ProfileMenuButton";
|
||||
import Tabs from "../material/Tabs";
|
||||
import Tab from "../material/Tab";
|
||||
import Tabs from "~material/Tabs";
|
||||
import Tab from "~material/Tab";
|
||||
import { makeEventListener } from "@solid-primitives/event-listener";
|
||||
import { $settings } from "../settings/stores";
|
||||
import { useStore } from "@nanostores/solid";
|
||||
|
|
|
@ -1,204 +0,0 @@
|
|||
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;
|
|
@ -5,23 +5,17 @@ import {
|
|||
ListItemAvatar,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Menu,
|
||||
MenuItem,
|
||||
} from "@suid/material";
|
||||
import {
|
||||
ErrorBoundary,
|
||||
Show,
|
||||
createSignal,
|
||||
createUniqueId,
|
||||
type ParentComponent,
|
||||
} from "solid-js";
|
||||
import { Show, createUniqueId, type ParentComponent } from "solid-js";
|
||||
import {
|
||||
Settings as SettingsIcon,
|
||||
Bookmark as BookmarkIcon,
|
||||
Star as LikeIcon,
|
||||
FeaturedPlayList as ListIcon,
|
||||
} from "@suid/icons-material";
|
||||
import A from "../platform/A";
|
||||
import A from "~platform/A";
|
||||
import Menu, { createManagedMenuState } from "~material/Menu";
|
||||
|
||||
const ProfileMenuButton: ParentComponent<{
|
||||
profile?: {
|
||||
|
@ -35,29 +29,18 @@ const ProfileMenuButton: ParentComponent<{
|
|||
};
|
||||
};
|
||||
};
|
||||
onClick?: () => void;
|
||||
onClose?: () => void;
|
||||
}> = (props) => {
|
||||
const menuId = createUniqueId();
|
||||
const buttonId = createUniqueId();
|
||||
|
||||
let [anchor, setAnchor] = createSignal<HTMLButtonElement | null>(null);
|
||||
const open = () => !!anchor();
|
||||
const [open, state] = createManagedMenuState();
|
||||
|
||||
const onClick = (
|
||||
event: MouseEvent & { currentTarget: HTMLButtonElement },
|
||||
) => {
|
||||
setAnchor(event.currentTarget);
|
||||
props.onClick?.();
|
||||
const onClick = (event: { currentTarget: HTMLElement }) => {
|
||||
open(event.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
|
||||
const inf = () => props.profile?.account.inf;
|
||||
|
||||
const onClose = () => {
|
||||
props.onClick?.();
|
||||
setAnchor(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ButtonBase
|
||||
|
@ -65,8 +48,8 @@ const ProfileMenuButton: ParentComponent<{
|
|||
sx={{ borderRadius: "50%" }}
|
||||
id={buttonId}
|
||||
onClick={onClick}
|
||||
aria-controls={open() ? menuId : undefined}
|
||||
aria-expanded={open() ? "true" : undefined}
|
||||
aria-controls={state.open ? menuId : undefined}
|
||||
aria-expanded={state.open ? "true" : "false"}
|
||||
>
|
||||
<Avatar
|
||||
alt={`${inf()?.displayName}'s avatar`}
|
||||
|
@ -75,23 +58,13 @@ const ProfileMenuButton: ParentComponent<{
|
|||
</ButtonBase>
|
||||
<Menu
|
||||
id={menuId}
|
||||
anchorEl={anchor()}
|
||||
open={open()}
|
||||
onClose={onClose}
|
||||
MenuListProps={{
|
||||
"aria-labelledby": buttonId,
|
||||
sx: {
|
||||
minWidth: "220px",
|
||||
"aria-labelledby": menuId,
|
||||
style: {
|
||||
"min-width": "220px",
|
||||
},
|
||||
}}
|
||||
anchorOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "right",
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "right",
|
||||
}}
|
||||
{...state}
|
||||
>
|
||||
<MenuItem
|
||||
component={A}
|
||||
|
|
|
@ -10,7 +10,7 @@ import { Refresh as RefreshIcon } from "@suid/icons-material";
|
|||
import { CircularProgress } from "@suid/material";
|
||||
import { makeEventListener } from "@solid-primitives/event-listener";
|
||||
import { createVisibilityObserver } from "@solid-primitives/intersection-observer";
|
||||
import { useMaybeIsFrameSuspended } from "../platform/StackedRouter";
|
||||
import { useIsFrameSuspended } from "~platform/StackedRouter";
|
||||
|
||||
const PullDownToRefresh: Component<{
|
||||
loading?: boolean;
|
||||
|
@ -34,7 +34,7 @@ const PullDownToRefresh: Component<{
|
|||
});
|
||||
|
||||
const rootVisible = obvx(() => rootElement);
|
||||
const isFrameSuspended = useMaybeIsFrameSuspended()
|
||||
const isFrameSuspended = useIsFrameSuspended()
|
||||
|
||||
createEffect(() => {
|
||||
if (!rootVisible()) setPullDown(0);
|
||||
|
|
78
src/timelines/RegularToot.css
Normal file
78
src/timelines/RegularToot.css
Normal file
|
@ -0,0 +1,78 @@
|
|||
.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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -5,57 +5,64 @@ import {
|
|||
type JSX,
|
||||
Show,
|
||||
createRenderEffect,
|
||||
createSignal,
|
||||
type Setter,
|
||||
createContext,
|
||||
useContext,
|
||||
} from "solid-js";
|
||||
import tootStyle from "./toot.module.css";
|
||||
import { formatRelative } from "date-fns";
|
||||
import Img from "../material/Img.js";
|
||||
import { Body2 } from "../material/typography.js";
|
||||
import { css } from "solid-styled";
|
||||
import {
|
||||
BookmarkAddOutlined,
|
||||
Repeat,
|
||||
ReplyAll,
|
||||
Star,
|
||||
StarOutline,
|
||||
Bookmark,
|
||||
Share,
|
||||
SmartToySharp,
|
||||
Lock,
|
||||
} from "@suid/icons-material";
|
||||
import { useTimeSource } from "../platform/timesrc.js";
|
||||
import Img from "~material/Img.js";
|
||||
import { Body2 } from "~material/typography.js";
|
||||
import { SmartToySharp, Lock } from "@suid/icons-material";
|
||||
import { useTimeSource } from "~platform/timesrc.js";
|
||||
import { resolveCustomEmoji } from "../masto/toot.js";
|
||||
import { Divider } from "@suid/material";
|
||||
import cardStyle from "../material/cards.module.css";
|
||||
import Button from "../material/Button.js";
|
||||
import MediaAttachmentGrid from "./MediaAttachmentGrid.js";
|
||||
import { useDateFnLocale } from "../platform/i18n";
|
||||
import { canShare, share } from "../platform/share";
|
||||
import cardStyle from "~material/cards.module.css";
|
||||
import MediaAttachmentGrid from "./toots/MediaAttachmentGrid.jsx";
|
||||
import { useDateFnLocale } from "~platform/i18n";
|
||||
import { makeAcctText, useDefaultSession } from "../masto/clients";
|
||||
import TootContent from "./toot-components/TootContent";
|
||||
import BoostIcon from "./toot-components/BoostIcon";
|
||||
import PreviewCard from "./toot-components/PreviewCard";
|
||||
import TootContent from "./toots/TootContent";
|
||||
import BoostIcon from "./toots/BoostIcon";
|
||||
import PreviewCard from "./toots/PreviewCard";
|
||||
import TootPoll from "./toots/TootPoll";
|
||||
import TootActionGroup from "./toots/TootActionGroup.js";
|
||||
import "./RegularToot.css";
|
||||
|
||||
type TootActionGroupProps<T extends mastodon.v1.Status> = {
|
||||
onRetoot?: (value: T) => void;
|
||||
onFavourite?: (value: T) => void;
|
||||
onBookmark?: (value: T) => void;
|
||||
onReply?: (
|
||||
value: T,
|
||||
export type TootEnv = {
|
||||
boost: (value: mastodon.v1.Status) => void;
|
||||
favourite: (value: mastodon.v1.Status) => void;
|
||||
bookmark: (value: mastodon.v1.Status) => void;
|
||||
reply?: (
|
||||
value: mastodon.v1.Status,
|
||||
event: MouseEvent & { currentTarget: HTMLButtonElement },
|
||||
) => void;
|
||||
vote: (
|
||||
status: mastodon.v1.Status,
|
||||
votes: readonly number[],
|
||||
) => void | Promise<void>;
|
||||
};
|
||||
|
||||
type TootCardProps = {
|
||||
const TootEnvContext = /* @__PURE__ */ createContext<TootEnv>();
|
||||
|
||||
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;
|
||||
actionable?: boolean;
|
||||
evaluated?: boolean;
|
||||
thread?: "top" | "bottom" | "middle";
|
||||
} & TootActionGroupProps<mastodon.v1.Status> &
|
||||
JSX.HTMLElementTags["article"];
|
||||
|
||||
function isolatedCallback(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
} & JSX.HTMLElementTags["article"];
|
||||
|
||||
export function findRootToot(element: HTMLElement) {
|
||||
let current: HTMLElement | null = element;
|
||||
|
@ -70,73 +77,6 @@ export function findRootToot(element: HTMLElement) {
|
|||
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(
|
||||
props: {
|
||||
status: mastodon.v1.Status;
|
||||
|
@ -200,6 +140,11 @@ export function findElementActionable(
|
|||
return current;
|
||||
}
|
||||
|
||||
function onToggleReveal(setValue: Setter<boolean>, event: Event) {
|
||||
event.stopPropagation();
|
||||
setValue((x) => !x);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for a toot.
|
||||
*
|
||||
|
@ -207,6 +152,8 @@ export function findElementActionable(
|
|||
* this component under a `<DefaultSessionProvier />` with correct
|
||||
* session.
|
||||
*
|
||||
* This component requires be under `<TootEnvProvider />`.
|
||||
*
|
||||
* **Handling Clicks**
|
||||
* There are multiple actions supported in the component. Some handlers
|
||||
* are passed in, some should be handled as the click event.
|
||||
|
@ -228,78 +175,39 @@ export function findElementActionable(
|
|||
* You can extract the intent from the attributes of the "actionable" element.
|
||||
* The action type is the dataset's `action`.
|
||||
*/
|
||||
const RegularToot: Component<TootCardProps> = (props) => {
|
||||
const RegularToot: Component<RegularTootProps> = (oprops) => {
|
||||
let rootRef: HTMLElement;
|
||||
const [managed, managedActionGroup, rest] = splitProps(
|
||||
props,
|
||||
["status", "lang", "class", "actionable", "evaluated", "thread"],
|
||||
["onRetoot", "onFavourite", "onBookmark", "onReply"],
|
||||
);
|
||||
const [props, rest] = splitProps(oprops, [
|
||||
"status",
|
||||
"lang",
|
||||
"class",
|
||||
"actionable",
|
||||
"evaluated",
|
||||
"thread",
|
||||
]);
|
||||
const now = useTimeSource();
|
||||
const status = () => managed.status;
|
||||
const status = () => props.status;
|
||||
const toot = () => status().reblog ?? status();
|
||||
const session = useDefaultSession();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
`;
|
||||
const [reveal, setReveal] = createSignal(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section
|
||||
<article
|
||||
classList={{
|
||||
[tootStyle.toot]: true,
|
||||
[tootStyle.expanded]: managed.evaluated,
|
||||
"thread-top": managed.thread === "top",
|
||||
"thread-mid": managed.thread === "middle",
|
||||
"thread-btm": managed.thread === "bottom",
|
||||
[managed.class || ""]: true,
|
||||
"RegularToot": true,
|
||||
"expanded": props.evaluated,
|
||||
"thread-top": props.thread === "top",
|
||||
"thread-mid": props.thread === "middle",
|
||||
"thread-btm": props.thread === "bottom",
|
||||
[props.class || ""]: true,
|
||||
}}
|
||||
ref={rootRef!}
|
||||
lang={toot().language || managed.lang}
|
||||
lang={toot().language || props.lang}
|
||||
{...rest}
|
||||
>
|
||||
<Show when={!!status().reblog}>
|
||||
<div class={tootStyle.tootRetootGrp}>
|
||||
<div class="retoot-grp">
|
||||
<BoostIcon />
|
||||
<Body2
|
||||
ref={(e: { innerHTML: string }) => {
|
||||
|
@ -325,22 +233,36 @@ const RegularToot: Component<TootCardProps> = (props) => {
|
|||
source={toot().content}
|
||||
emojis={toot().emojis}
|
||||
mentions={toot().mentions}
|
||||
class={tootStyle.tootContent}
|
||||
class={cardStyle.cardNoPad}
|
||||
sensitive={toot().sensitive}
|
||||
spoilerText={toot().spoilerText}
|
||||
reveal={reveal()}
|
||||
onToggleReveal={[onToggleReveal, setReveal]}
|
||||
/>
|
||||
<Show when={toot().card}>
|
||||
<Show
|
||||
when={
|
||||
toot().card && (!toot().sensitive || (toot().sensitive && reveal()))
|
||||
}
|
||||
>
|
||||
<PreviewCard src={toot().card!} />
|
||||
</Show>
|
||||
<Show when={toot().mediaAttachments.length > 0}>
|
||||
<MediaAttachmentGrid attachments={toot().mediaAttachments} />
|
||||
<MediaAttachmentGrid
|
||||
attachments={toot().mediaAttachments}
|
||||
sensitive={toot().sensitive}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={managed.actionable}>
|
||||
<Show when={toot().poll}>
|
||||
<TootPoll value={toot().poll!} status={toot()} />
|
||||
</Show>
|
||||
<Show when={props.actionable}>
|
||||
<Divider
|
||||
class={cardStyle.cardNoPad}
|
||||
style={{ "margin-top": "8px" }}
|
||||
/>
|
||||
<TootActionGroup value={toot()} {...managedActionGroup} />
|
||||
<TootActionGroup value={toot()} class={cardStyle.cardGutSkip} />
|
||||
</Show>
|
||||
</section>
|
||||
</article>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -7,25 +7,28 @@ import {
|
|||
Show,
|
||||
type Component,
|
||||
} from "solid-js";
|
||||
import Scaffold from "../material/Scaffold";
|
||||
import Scaffold from "~material/Scaffold";
|
||||
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 { useSessionForAcctStr } from "../masto/clients";
|
||||
import { resolveCustomEmoji } from "../masto/toot";
|
||||
import RegularToot, { findElementActionable } from "./RegularToot";
|
||||
import RegularToot, {
|
||||
findElementActionable,
|
||||
TootEnvProvider,
|
||||
} from "./RegularToot";
|
||||
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 { vibrate } from "../platform/hardware";
|
||||
import { createTimeSource, TimeSourceProvider } from "../platform/timesrc";
|
||||
import { vibrate } from "~platform/hardware";
|
||||
import { createTimeSource, TimeSourceProvider } from "~platform/timesrc";
|
||||
import TootComposer from "./TootComposer";
|
||||
import { useDocumentTitle } from "../utils";
|
||||
import { createTimelineControlsForArray } from "../masto/timelines";
|
||||
import TootList from "./TootList";
|
||||
import "./TootBottomSheet.css";
|
||||
import { useNavigator } from "../platform/StackedRouter";
|
||||
import BackButton from "../platform/BackButton";
|
||||
import { useNavigator } from "~platform/StackedRouter";
|
||||
import BackButton from "~platform/BackButton";
|
||||
|
||||
let cachedEntry: [string, mastodon.v1.Status] | undefined;
|
||||
|
||||
|
@ -169,6 +172,33 @@ const TootBottomSheet: Component = (props) => {
|
|||
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 = (
|
||||
event: MouseEvent & { currentTarget: HTMLElement },
|
||||
) => {
|
||||
|
@ -255,21 +285,29 @@ const TootBottomSheet: Component = (props) => {
|
|||
|
||||
<article>
|
||||
<Show when={toot()}>
|
||||
<TootEnvProvider
|
||||
value={{
|
||||
bookmark: onBookmark,
|
||||
boost: onBoost,
|
||||
favourite: onFav,
|
||||
vote,
|
||||
}}
|
||||
>
|
||||
<RegularToot
|
||||
id={`toot-${toot()!.id}`}
|
||||
class={cards.card}
|
||||
style={{
|
||||
"scroll-margin-top":
|
||||
"calc(var(--scaffold-topbar-height) + 20px)",
|
||||
cursor: "auto",
|
||||
"user-select": "auto",
|
||||
}}
|
||||
status={toot()!}
|
||||
actionable={!!actSession()}
|
||||
evaluated={true}
|
||||
onBookmark={onBookmark}
|
||||
onRetoot={onBoost}
|
||||
onFavourite={onFav}
|
||||
onClick={handleMainTootClick}
|
||||
></RegularToot>
|
||||
</TootEnvProvider>
|
||||
</Show>
|
||||
</article>
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@ import {
|
|||
type JSX,
|
||||
type Ref,
|
||||
} from "solid-js";
|
||||
import Scaffold from "../material/Scaffold";
|
||||
import Scaffold from "~material/Scaffold";
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
|
@ -41,16 +41,16 @@ import {
|
|||
} from "@suid/icons-material";
|
||||
import type { Account } from "../accounts/stores";
|
||||
import "./TootComposer.css";
|
||||
import BottomSheet from "../material/BottomSheet";
|
||||
import { useLanguage } from "../platform/i18n";
|
||||
import BottomSheet from "~material/BottomSheet";
|
||||
import { useAppLocale } from "~platform/i18n";
|
||||
import iso639_1 from "iso-639-1";
|
||||
import ChooseTootLang from "./ChooseTootLang";
|
||||
import type { mastodon } from "masto";
|
||||
import cardStyles from "../material/cards.module.css";
|
||||
import Menu, { createManagedMenuState } from "../material/Menu";
|
||||
import cardStyles from "~material/cards.module.css";
|
||||
import Menu, { createManagedMenuState } from "~material/Menu";
|
||||
import { useDefaultSession } from "../masto/clients";
|
||||
import { resolveCustomEmoji } from "../masto/toot";
|
||||
import SizedTextarea from "../platform/SizedTextarea";
|
||||
import SizedTextarea from "~platform/SizedTextarea";
|
||||
|
||||
type TootVisibility = "public" | "unlisted" | "private" | "direct";
|
||||
|
||||
|
@ -98,7 +98,8 @@ const TootVisibilityPickerDialog: Component<{
|
|||
style={{
|
||||
"border-top": "1px solid #ddd",
|
||||
background: "var(--tutu-color-surface)",
|
||||
padding: "8px 16px",
|
||||
padding:
|
||||
"8px 16px calc(8px + var(--safe-area-inset-bottom, 0px))",
|
||||
width: "100%",
|
||||
"text-align": "end",
|
||||
}}
|
||||
|
@ -232,7 +233,7 @@ const TootComposer: Component<{
|
|||
const [permPicker, setPermPicker] = createSignal(false);
|
||||
const [language, setLanguage] = createSignal("en");
|
||||
const [langPickerOpen, setLangPickerOpen] = createSignal(false);
|
||||
const appLanguage = useLanguage();
|
||||
const { language: appLanguage } = useAppLocale();
|
||||
const [openMenu, menuState] = createManagedMenuState();
|
||||
|
||||
const randomPlaceholder = useRandomChoice(() => [
|
||||
|
|
|
@ -6,21 +6,21 @@ import {
|
|||
createSelector,
|
||||
Index,
|
||||
createMemo,
|
||||
For,
|
||||
} from "solid-js";
|
||||
import { type mastodon } from "masto";
|
||||
import { vibrate } from "../platform/hardware";
|
||||
import { vibrate } from "~platform/hardware";
|
||||
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 RegularToot, {
|
||||
findElementActionable,
|
||||
findRootToot,
|
||||
TootEnvProvider,
|
||||
} from "./RegularToot";
|
||||
import cardStyle from "../material/cards.module.css";
|
||||
import cardStyle from "~material/cards.module.css";
|
||||
import type { ThreadNode } from "../masto/timelines";
|
||||
import { useNavigator } from "../platform/StackedRouter";
|
||||
import { ANIM_CURVE_STD } from "../material/theme";
|
||||
import { useNavigator } from "~platform/StackedRouter";
|
||||
import { ANIM_CURVE_STD } from "~material/theme";
|
||||
|
||||
function durationOf(rect0: DOMRect, rect1: DOMRect) {
|
||||
const distancelt = Math.sqrt(
|
||||
|
@ -53,7 +53,6 @@ const TootList: Component<{
|
|||
onChangeToot: (id: string, value: mastodon.v1.Status) => void;
|
||||
}> = (props) => {
|
||||
const session = useDefaultSession();
|
||||
const heroSrc = useHeroSource();
|
||||
const [expandedThreadId, setExpandedThreadId] = createSignal<string>();
|
||||
const { push } = useNavigator();
|
||||
|
||||
|
@ -124,9 +123,6 @@ const TootList: Component<{
|
|||
console.warn("no account info?");
|
||||
return;
|
||||
}
|
||||
if (heroSrc) {
|
||||
heroSrc[1]((x) => ({ ...x, [BOTTOM_SHEET_HERO]: srcElement }));
|
||||
}
|
||||
|
||||
const acct = `${inf.username}@${p.site}`;
|
||||
setTootBottomSheetCache(acct, toot);
|
||||
|
@ -238,6 +234,36 @@ const TootList: Component<{
|
|||
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 (
|
||||
<ErrorBoundary
|
||||
fallback={(err, reset) => {
|
||||
|
@ -245,11 +271,18 @@ const TootList: Component<{
|
|||
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">
|
||||
<Index each={props.threads}>
|
||||
<For each={props.threads}>
|
||||
{(threadId, threadIdx) => {
|
||||
const thread = createMemo(() =>
|
||||
props.onUnknownThread(threadId())?.reverse(),
|
||||
props.onUnknownThread(threadId)?.reverse(),
|
||||
);
|
||||
|
||||
const threadLength = () => thread()?.length ?? 0;
|
||||
|
@ -262,8 +295,6 @@ const TootList: Component<{
|
|||
return (
|
||||
<RegularToot
|
||||
data-status-id={status().id}
|
||||
data-thread={threadIdx}
|
||||
data-thread-len={threadLength()}
|
||||
data-thread-sort={index}
|
||||
status={status()}
|
||||
thread={
|
||||
|
@ -274,10 +305,6 @@ const TootList: Component<{
|
|||
class={cardStyle.card}
|
||||
evaluated={checkIsExpended(status())}
|
||||
actionable={checkIsExpended(status())}
|
||||
onBookmark={onBookmark}
|
||||
onRetoot={toggleBoost}
|
||||
onFavourite={toggleFavourite}
|
||||
onReply={reply}
|
||||
onClick={[onItemClick, status()]}
|
||||
/>
|
||||
);
|
||||
|
@ -285,8 +312,9 @@ const TootList: Component<{
|
|||
</Index>
|
||||
);
|
||||
}}
|
||||
</Index>
|
||||
</For>
|
||||
</div>
|
||||
</TootEnvProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,61 +0,0 @@
|
|||
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;
|
|
@ -1,51 +1,9 @@
|
|||
.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 {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
contain: layout style;
|
||||
|
||||
> :not(:first-child) {
|
||||
flex-grow: 1;
|
||||
|
@ -92,189 +50,3 @@
|
|||
border: 1px solid var(--tutu-color-surface);
|
||||
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;
|
||||
}
|
||||
}
|
|
@ -1,27 +1,48 @@
|
|||
.MediaAttachmentGrid {
|
||||
/* Note: MeidaAttachmentGrid has hard-coded layout calcalation */
|
||||
margin-top: 1em;
|
||||
margin-left: calc(var(--card-pad, 0) + var(--toot-avatar-size, 0) + 8px);
|
||||
margin-left: var(--card-pad, 0);
|
||||
margin-right: var(--card-pad, 0);
|
||||
contain: layout style;
|
||||
gap: 4px;
|
||||
|
||||
> :where(img, video) {
|
||||
>* {
|
||||
max-height: 35vh;
|
||||
min-height: 40px;
|
||||
min-width: 40px;
|
||||
object-fit: contain;
|
||||
max-width: 100%;
|
||||
background-color: var(--tutu-color-surface-d);
|
||||
contain: strict;
|
||||
content-visibility: auto;
|
||||
background-color: var(--media-color-accent, var(--tutu-color-surface-d));
|
||||
border-radius: 2px;
|
||||
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);
|
||||
contain: strict;
|
||||
content-visibility: auto;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
outline: 8px solid 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);
|
||||
}
|
244
src/timelines/toots/MediaAttachmentGrid.tsx
Normal file
244
src/timelines/toots/MediaAttachmentGrid.tsx
Normal file
|
@ -0,0 +1,244 @@
|
|||
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;
|
|
@ -1,7 +1,7 @@
|
|||
.PreviewCard {
|
||||
display: block;
|
||||
border: 1px solid #eeeeee;
|
||||
background-color: var(--tutu-color-surface);
|
||||
background-color: var(--tutu-color-surface-d);
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
|
@ -13,16 +13,20 @@
|
|||
overflow: hidden;
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
contain: layout style;
|
||||
|
||||
>img {
|
||||
background-color: #eeeeee;
|
||||
background-color: var(--tutu-color-surface);
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
|
||||
&.loaded {
|
||||
background-color: #eeeeee;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background-color: var(--tutu-color-surface-d);
|
||||
color: var(--tutu-color-on-surface);
|
||||
|
||||
>h1 {
|
|
@ -1,10 +1,18 @@
|
|||
import Color from "colorjs.io";
|
||||
import type { mastodon } from "masto";
|
||||
import { createEffect, createMemo, Show } from "solid-js";
|
||||
import { Title, Body1 } from "../../material/typography";
|
||||
import { averageColorHex } from "../../platform/blurhash";
|
||||
import { Title, Body1 } from "~material/typography";
|
||||
import { averageColorHex } from "~platform/blurhash";
|
||||
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: {
|
||||
src: mastodon.v1.PreviewCard;
|
||||
alwaysCompact?: boolean;
|
||||
|
@ -81,6 +89,8 @@ export function PreviewCard(props: {
|
|||
>
|
||||
<Show when={props.src.image}>
|
||||
<img
|
||||
onLoadStart={onResetImg}
|
||||
onLoad={onImgLoaded}
|
||||
crossOrigin="anonymous"
|
||||
src={props.src.image!}
|
||||
width={props.src.width || undefined}
|
41
src/timelines/toots/TootActionGroup.css
Normal file
41
src/timelines/toots/TootActionGroup.css
Normal file
|
@ -0,0 +1,41 @@
|
|||
.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;
|
||||
}
|
||||
}
|
89
src/timelines/toots/TootActionGroup.tsx
Normal file
89
src/timelines/toots/TootActionGroup.tsx
Normal file
|
@ -0,0 +1,89 @@
|
|||
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;
|
36
src/timelines/toots/TootContent.css
Normal file
36
src/timelines/toots/TootContent.css
Normal file
|
@ -0,0 +1,36 @@
|
|||
.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);
|
||||
}
|
115
src/timelines/toots/TootContent.tsx
Normal file
115
src/timelines/toots/TootContent.tsx
Normal file
|
@ -0,0 +1,115 @@
|
|||
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;
|
22
src/timelines/toots/TootPoll.css
Normal file
22
src/timelines/toots/TootPoll.css
Normal file
|
@ -0,0 +1,22 @@
|
|||
.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;
|
||||
}
|
||||
}
|
191
src/timelines/toots/TootPoll.tsx
Normal file
191
src/timelines/toots/TootPoll.tsx
Normal file
|
@ -0,0 +1,191 @@
|
|||
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;
|
12
src/timelines/toots/TootPollDialog.css
Normal file
12
src/timelines/toots/TootPollDialog.css
Normal file
|
@ -0,0 +1,12 @@
|
|||
.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;
|
||||
}
|
||||
}
|
127
src/timelines/toots/TootPollDialog.tsx
Normal file
127
src/timelines/toots/TootPollDialog.tsx
Normal file
|
@ -0,0 +1,127 @@
|
|||
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;
|
3
src/timelines/toots/i18n/en.json
Normal file
3
src/timelines/toots/i18n/en.json
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"cw": "\"Content Warning\""
|
||||
}
|
3
src/timelines/toots/i18n/zh-Hans.json
Normal file
3
src/timelines/toots/i18n/zh-Hans.json
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"cw": "“内容警告”"
|
||||
}
|
|
@ -12,5 +12,9 @@
|
|||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
"paths": {
|
||||
"~platform/*": ["./src/platform/*"],
|
||||
"~material/*": ["./src/material/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import version from "vite-plugin-package-version";
|
|||
import manifest from "./manifest.config";
|
||||
import { GetManualChunk } from "rollup";
|
||||
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.
|
||||
|
@ -107,6 +108,23 @@ export default defineConfig(({ mode }) => {
|
|||
}),
|
||||
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: {
|
||||
https: serverHttpCertBase
|
||||
? {
|
||||
|
|
Loading…
Reference in a new issue