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
3
.env
3
.env
|
@ -1,4 +1,5 @@
|
||||||
DEV_SERVER_HTTPS_CERT_BASE=
|
DEV_SERVER_HTTPS_CERT_BASE=
|
||||||
DEV_SERVER_HTTPS_CERT_PASS=
|
DEV_SERVER_HTTPS_CERT_PASS=
|
||||||
DEV_LOCATOR_EDITOR=vscode
|
DEV_LOCATOR_EDITOR=vscode
|
||||||
VITE_DEVTOOLS_OVERLAY=true
|
VITE_DEVTOOLS_OVERLAY=true
|
||||||
|
VITE_PLATFROM_MASONRY_ALWAYS_COMPAT=
|
BIN
bun.lockb
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.
|
- For visual bugs: on you iDevice, redirect the localhost.direct to your dev computer. Now you have the hot reload on you iDevice.
|
||||||
- You can use network debugging apps like "Shadowrocket" to do such thing.
|
- You can use network debugging apps like "Shadowrocket" to do such thing.
|
||||||
|
|
||||||
## Hero Animation won't work (after hot reload)
|
|
||||||
|
|
||||||
That's a known issue. Hot reload won't refresh the module sets the hero cache. Refresh the whole page and it should work.
|
|
||||||
|
|
||||||
## The components don't react to the change as I setting the store, until the page reloaded
|
## The components don't react to the change as I setting the store, until the page reloaded
|
||||||
|
|
||||||
The `WritableAtom<unknwon>.set` might do an equals check. You must set a different object to ensure the atom sending a notify.
|
The `WritableAtom<unknwon>.set` might do an equals check. You must set a different object to ensure the atom sending a notify.
|
||||||
|
@ -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.
|
Idk why, but transition on logical directions may not work on WebKit - sometimes they work.
|
||||||
|
|
||||||
Use physical directions to avoid trouble, like "margin-top, margin-bottom".
|
Use physical directions to avoid trouble, like "margin-top, margin-bottom".
|
||||||
|
|
||||||
|
## Safe area insets
|
||||||
|
|
||||||
|
For isolating control of the UI effect, we already setup css variables `--safe-area-inset-*`. In components, you should use the variables unless you have reasons to use `env()`.
|
||||||
|
|
||||||
|
Using `--safe-area-inset-*`, you can control the global value in settings (under dev mode).
|
||||||
|
|
||||||
|
## Module Isolation
|
||||||
|
|
||||||
|
> Write the code that can be easily removed.
|
||||||
|
|
||||||
|
To limit the code impact, we organize the code based on **"topic modules"** (modules in short). Each module focus on a specific topic described by the name. Like the "accounts" contains the code about the accounts, "masto" contains the code about the masto (a library used to access mastodon) helpers.
|
||||||
|
|
||||||
|
> Sidenote: This also helps easing "the landing problem". If you need something about accounts, no longer "common/accounts" and "hooks/accounts" and "helpers/accounts" and "components/accounts". Someone says this is clean - is it even if you need to jump between 6 directories for how one simple feature works?
|
||||||
|
> And you no longer needs to think about "where to place this file (between six directories, usually)". People often optimize their code structure too early - just like how they treat the runtime performance.
|
||||||
|
> The worse is, it's very hard to solve this problem later, because you had sent your code to different places.
|
||||||
|
|
||||||
|
There are **two special modules** in this project:
|
||||||
|
|
||||||
|
One is the *platform*. This module provides foundation of this app: deals with the host platform (like SizedTextarea - auto resized textarea), provides custom platform feature (like StackedRouter - provides mobile-native navigation experience).
|
||||||
|
|
||||||
|
The another is the *material*. This module provides Material styling toolkit, the stylesheets, MUI Theme, constants and components.
|
||||||
|
|
||||||
|
They (and only them) can be accessed by special aliases: `~{module name}`, like the `~platform`.
|
||||||
|
|
||||||
|
We discourage cross referencings between two topics. Reuse is not better than duplication. Cross referencing is still possible if required.
|
||||||
|
|
||||||
|
When a tool, a file or a component is required every-elsewhere, **promoting** is required to reduce the cross referencing. Thanksfully, it's usually automated process for moving files.
|
||||||
|
|
||||||
|
But, sometimes you need a redesigned (sometimes better) tool for the generic usage. Follow the idea:
|
||||||
|
|
||||||
|
- Move slowly or crash. Only make the change if it's required.
|
||||||
|
- Try to make the original part depends on your new tool, and keep the original for awhile.
|
||||||
|
- Mark deprecated only if you think the original won't worth an existence. Reasons:
|
||||||
|
- Migrate to the new code only needs minor change.
|
||||||
|
- The original code has critical problems, like performance or compatibility.
|
||||||
|
- Make notes. Communication is important, even with the future you.
|
||||||
|
- *Why* this move is decided?
|
||||||
|
- *What* this new tool does?
|
||||||
|
- *How* this tool works?
|
||||||
|
- Clean up code regularly. Don't keep the unused code forever.
|
||||||
|
|
|
@ -8,7 +8,8 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"dist": "vite build"
|
"dist": "vite build",
|
||||||
|
"count-source-lines": "exec scripts/src-lc.sh"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "Rubicon",
|
"author": "Rubicon",
|
||||||
|
@ -17,6 +18,7 @@
|
||||||
"@solid-devtools/overlay": "^0.30.1",
|
"@solid-devtools/overlay": "^0.30.1",
|
||||||
"@suid/vite-plugin": "^0.3.1",
|
"@suid/vite-plugin": "^0.3.1",
|
||||||
"@types/hammerjs": "^2.0.46",
|
"@types/hammerjs": "^2.0.46",
|
||||||
|
"@types/masonry-layout": "^4.2.8",
|
||||||
"@vite-pwa/assets-generator": "^0.2.6",
|
"@vite-pwa/assets-generator": "^0.2.6",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
|
@ -48,6 +50,7 @@
|
||||||
"fast-average-color": "^9.4.0",
|
"fast-average-color": "^9.4.0",
|
||||||
"hammerjs": "^2.0.8",
|
"hammerjs": "^2.0.8",
|
||||||
"iso-639-1": "^3.1.3",
|
"iso-639-1": "^3.1.3",
|
||||||
|
"masonry-layout": "^4.2.2",
|
||||||
"masto": "^6.10.1",
|
"masto": "^6.10.1",
|
||||||
"nanostores": "^0.11.3",
|
"nanostores": "^0.11.3",
|
||||||
"normalize.css": "^8.0.1",
|
"normalize.css": "^8.0.1",
|
||||||
|
@ -55,7 +58,6 @@
|
||||||
"solid-js": "^1.9.3",
|
"solid-js": "^1.9.3",
|
||||||
"solid-styled": "^0.11.1",
|
"solid-styled": "^0.11.1",
|
||||||
"stacktrace-js": "^2.0.2",
|
"stacktrace-js": "^2.0.2",
|
||||||
"web-animations-js": "^2.3.2",
|
|
||||||
"workbox-core": "^7.3.0",
|
"workbox-core": "^7.3.0",
|
||||||
"workbox-precaching": "^7.3.0"
|
"workbox-precaching": "^7.3.0"
|
||||||
},
|
},
|
||||||
|
|
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 {
|
h1 {
|
||||||
margin: 0;
|
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 { ThemeProvider } from "@suid/material";
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
|
@ -17,7 +17,12 @@ import {
|
||||||
} from "./masto/clients.js";
|
} from "./masto/clients.js";
|
||||||
import { $accounts, updateAcctInf } from "./accounts/stores.js";
|
import { $accounts, updateAcctInf } from "./accounts/stores.js";
|
||||||
import { useStore } from "@nanostores/solid";
|
import { useStore } from "@nanostores/solid";
|
||||||
import { DateFnScope, useLanguage } from "./platform/i18n.jsx";
|
import {
|
||||||
|
AppLocaleProvider,
|
||||||
|
createCurrentLanguage,
|
||||||
|
createCurrentRegion,
|
||||||
|
createDateFnLocaleResource,
|
||||||
|
} from "./platform/i18n.jsx";
|
||||||
import { useRegisterSW } from "virtual:pwa-register/solid";
|
import { useRegisterSW } from "virtual:pwa-register/solid";
|
||||||
import {
|
import {
|
||||||
isJSONRPCResult,
|
isJSONRPCResult,
|
||||||
|
@ -67,7 +72,9 @@ const Routing: Component = () => {
|
||||||
const App: Component = () => {
|
const App: Component = () => {
|
||||||
const theme = useRootTheme();
|
const theme = useRootTheme();
|
||||||
const accts = useStore($accounts);
|
const accts = useStore($accounts);
|
||||||
const lang = useLanguage();
|
const lang = createCurrentLanguage();
|
||||||
|
const region = createCurrentRegion();
|
||||||
|
const dateFnLocale = createDateFnLocaleResource(region);
|
||||||
const [serviceWorker, setServiceWorker] = createSignal<
|
const [serviceWorker, setServiceWorker] = createSignal<
|
||||||
ServiceWorker | undefined
|
ServiceWorker | undefined
|
||||||
>(undefined, { name: "serviceWorker" });
|
>(undefined, { name: "serviceWorker" });
|
||||||
|
@ -150,7 +157,13 @@ const App: Component = () => {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<DateFnScope>
|
<AppLocaleProvider
|
||||||
|
value={{
|
||||||
|
language: lang,
|
||||||
|
region: region,
|
||||||
|
dateFn: dateFnLocale,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<ClientProvider value={clients}>
|
<ClientProvider value={clients}>
|
||||||
<ServiceWorkerProvider
|
<ServiceWorkerProvider
|
||||||
value={{
|
value={{
|
||||||
|
@ -162,7 +175,7 @@ const App: Component = () => {
|
||||||
<Routing />
|
<Routing />
|
||||||
</ServiceWorkerProvider>
|
</ServiceWorkerProvider>
|
||||||
</ClientProvider>
|
</ClientProvider>
|
||||||
</DateFnScope>
|
</AppLocaleProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
|
|
|
@ -42,6 +42,29 @@ const UnexpectedError: Component<{ error?: any }> = (props) => {
|
||||||
calc(var(--safe-area-inset-bottom) + 20px)
|
calc(var(--safe-area-inset-bottom) + 20px)
|
||||||
calc(var(--safe-area-inset-left) + 20px);
|
calc(var(--safe-area-inset-left) + 20px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
details {
|
||||||
|
max-width: 100vw;
|
||||||
|
max-width: 100dvw;
|
||||||
|
overflow: auto;
|
||||||
|
|
||||||
|
& * {
|
||||||
|
user-select: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
summary {
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin-top: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -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
|
You can restart the app to see if this guy is gone. If you meet this guy
|
||||||
repeatly, please report to us.
|
repeatly, please report to us.
|
||||||
</p>
|
</p>
|
||||||
<div>
|
<div class="actions">
|
||||||
<Button onClick={() => (window.location.replace("/"))}>
|
<Button
|
||||||
|
onClick={() => window.location.replace("/")}
|
||||||
|
variant="contained"
|
||||||
|
>
|
||||||
Restart App
|
Restart App
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -61,7 +87,10 @@ const UnexpectedError: Component<{ error?: any }> = (props) => {
|
||||||
<summary>
|
<summary>
|
||||||
{errorMsg.loading ? "Generating " : " "}Technical Infomation
|
{errorMsg.loading ? "Generating " : " "}Technical Infomation
|
||||||
</summary>
|
</summary>
|
||||||
<pre>{errorMsg()}</pre>
|
<pre>
|
||||||
|
On: {window.location.href} <br />
|
||||||
|
{errorMsg()}
|
||||||
|
</pre>
|
||||||
</details>
|
</details>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|
|
@ -9,12 +9,12 @@ import {
|
||||||
import { acceptAccountViaAuthCode } from "./stores";
|
import { acceptAccountViaAuthCode } from "./stores";
|
||||||
import { $settings } from "../settings/stores";
|
import { $settings } from "../settings/stores";
|
||||||
import { useDocumentTitle } from "../utils";
|
import { useDocumentTitle } from "../utils";
|
||||||
import cards from "../material/cards.module.css";
|
import cards from "~material/cards.module.css";
|
||||||
import { LinearProgress } from "@suid/material";
|
import { LinearProgress } from "@suid/material";
|
||||||
import Img from "../material/Img";
|
import Img from "~material/Img";
|
||||||
import { createRestAPIClient } from "masto";
|
import { createRestAPIClient } from "masto";
|
||||||
import { Title } from "../material/typography";
|
import { Title } from "~material/typography";
|
||||||
import { useNavigator } from "../platform/StackedRouter";
|
import { useNavigator } from "~platform/StackedRouter";
|
||||||
|
|
||||||
type OAuth2CallbackParams = {
|
type OAuth2CallbackParams = {
|
||||||
code?: string;
|
code?: string;
|
||||||
|
|
|
@ -7,11 +7,11 @@ import {
|
||||||
createUniqueId,
|
createUniqueId,
|
||||||
onMount,
|
onMount,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import cards from "../material/cards.module.css";
|
import cards from "~material/cards.module.css";
|
||||||
import TextField from "../material/TextField.js";
|
import TextField from "~material/TextField.js";
|
||||||
import Button from "../material/Button.js";
|
import Button from "~material/Button.js";
|
||||||
import { useDocumentTitle } from "../utils";
|
import { useDocumentTitle } from "../utils";
|
||||||
import { Title } from "../material/typography";
|
import { Title } from "~material/typography";
|
||||||
import { css } from "solid-styled";
|
import { css } from "solid-styled";
|
||||||
import { LinearProgress } from "@suid/material";
|
import { LinearProgress } from "@suid/material";
|
||||||
import { createRestAPIClient } from "masto";
|
import { createRestAPIClient } from "masto";
|
||||||
|
|
|
@ -167,7 +167,7 @@ export async function getOrRegisterApp(site: string, redirectUrl: string) {
|
||||||
});
|
});
|
||||||
const app = await client.v1.apps.create({
|
const app = await client.v1.apps.create({
|
||||||
clientName: "TuTu",
|
clientName: "TuTu",
|
||||||
website: "https://github.com/thislight/tutu",
|
website: "https://code.lightstands.xyz/Rubicon/tutu",
|
||||||
redirectUris: redirectUrl,
|
redirectUris: redirectUrl,
|
||||||
scopes: "read write push",
|
scopes: "read write push",
|
||||||
});
|
});
|
||||||
|
|
|
@ -9,7 +9,7 @@ import {
|
||||||
import { Account } from "../accounts/stores";
|
import { Account } from "../accounts/stores";
|
||||||
import { createRestAPIClient, mastodon } from "masto";
|
import { createRestAPIClient, mastodon } from "masto";
|
||||||
import { useLocation } from "@solidjs/router";
|
import { useLocation } from "@solidjs/router";
|
||||||
import { useNavigator } from "../platform/StackedRouter";
|
import { useNavigator } from "~platform/StackedRouter";
|
||||||
|
|
||||||
const restfulCache: Record<string, mastodon.rest.Client> = {};
|
const restfulCache: Record<string, mastodon.rest.Client> = {};
|
||||||
|
|
||||||
|
@ -64,7 +64,7 @@ export function useSessions() {
|
||||||
if (sessions().length > 0) return;
|
if (sessions().length > 0) return;
|
||||||
push(
|
push(
|
||||||
"/accounts/sign-in?back=" + encodeURIComponent(location.pathname),
|
"/accounts/sign-in?back=" + encodeURIComponent(location.pathname),
|
||||||
{ replace: true },
|
{ replace: "all" },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,25 +1,18 @@
|
||||||
import {
|
import {
|
||||||
children,
|
children,
|
||||||
createEffect,
|
createEffect,
|
||||||
createSignal,
|
|
||||||
onCleanup,
|
onCleanup,
|
||||||
useTransition,
|
useTransition,
|
||||||
type JSX,
|
type JSX,
|
||||||
type ParentComponent,
|
type ParentComponent,
|
||||||
type ResolvedChildren,
|
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import "./BottomSheet.css";
|
import "./BottomSheet.css";
|
||||||
import { useHeroSignal } from "../platform/anim";
|
|
||||||
import material from "./material.module.css";
|
import material from "./material.module.css";
|
||||||
import {
|
import { ANIM_CURVE_ACELERATION, ANIM_CURVE_DECELERATION } from "./theme";
|
||||||
ANIM_CURVE_ACELERATION,
|
|
||||||
ANIM_CURVE_DECELERATION,
|
|
||||||
ANIM_CURVE_STD,
|
|
||||||
} from "./theme";
|
|
||||||
import {
|
import {
|
||||||
animateSlideInFromRight,
|
animateSlideInFromRight,
|
||||||
animateSlideOutToRight,
|
animateSlideOutToRight,
|
||||||
} from "../platform/anim";
|
} from "~platform/anim";
|
||||||
|
|
||||||
export type BottomSheetProps = {
|
export type BottomSheetProps = {
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
|
@ -28,34 +21,38 @@ export type BottomSheetProps = {
|
||||||
onClose?(reason: "backdrop"): void;
|
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 MOVE_SPEED = 1600;
|
||||||
|
|
||||||
|
function animateSlideInFromBottom(element: HTMLElement, reverse?: boolean) {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const easing = "cubic-bezier(0.4, 0, 0.2, 1)";
|
||||||
|
element.classList.add("animated");
|
||||||
|
const oldOverflow = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
const distance = Math.abs(rect.top - window.innerHeight);
|
||||||
|
const duration = (distance / MOVE_SPEED) * 1000;
|
||||||
|
|
||||||
|
const animation = element.animate(
|
||||||
|
{
|
||||||
|
top: reverse
|
||||||
|
? [`${rect.top}px`, `${window.innerHeight}px`]
|
||||||
|
: [`${window.innerHeight}px`, `${rect.top}px`],
|
||||||
|
},
|
||||||
|
{ easing, duration },
|
||||||
|
);
|
||||||
|
const onAnimationEnd = () => {
|
||||||
|
element.classList.remove("animated");
|
||||||
|
document.body.style.overflow = oldOverflow;
|
||||||
|
};
|
||||||
|
animation.addEventListener("cancel", onAnimationEnd);
|
||||||
|
animation.addEventListener("finish", onAnimationEnd);
|
||||||
|
return animation;
|
||||||
|
}
|
||||||
|
|
||||||
const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
|
const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
|
||||||
let element: HTMLDialogElement;
|
let element: HTMLDialogElement;
|
||||||
let animation: Animation | undefined;
|
let animation: Animation | undefined;
|
||||||
const [hero, setHero] = useHeroSignal(HERO);
|
const child = children(() => props.children);
|
||||||
const [cache, setCache] = createSignal<ResolvedChildren | undefined>();
|
|
||||||
const ochildren = children(() => props.children);
|
|
||||||
|
|
||||||
const [pending] = useTransition();
|
const [pending] = useTransition();
|
||||||
|
|
||||||
|
@ -63,69 +60,45 @@ const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
|
||||||
if (props.open) {
|
if (props.open) {
|
||||||
if (!element.open && !pending()) {
|
if (!element.open && !pending()) {
|
||||||
requestAnimationFrame(animatedOpen);
|
requestAnimationFrame(animatedOpen);
|
||||||
setCache(ochildren());
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (element.open) {
|
if (element.open) {
|
||||||
animatedClose();
|
animatedClose();
|
||||||
setCache(undefined);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const onClose = () => {
|
const onClose = () => {
|
||||||
const srcElement = hero();
|
|
||||||
if (srcElement) {
|
|
||||||
srcElement.style.visibility = "unset";
|
|
||||||
}
|
|
||||||
|
|
||||||
element.close();
|
element.close();
|
||||||
setHero();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const animatedClose = () => {
|
const animatedClose = () => {
|
||||||
const srcElement = hero();
|
if (window.innerWidth > 560 && !props.bottomUp) {
|
||||||
const endRect = srcElement?.getBoundingClientRect();
|
onClose();
|
||||||
if (endRect) {
|
return;
|
||||||
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 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 = () => {
|
const animatedOpen = () => {
|
||||||
element.showModal();
|
element.showModal();
|
||||||
const srcElement = hero();
|
if (props.bottomUp) {
|
||||||
const startRect = srcElement?.getBoundingClientRect();
|
|
||||||
if (!startRect) {
|
|
||||||
console.debug("no source element");
|
|
||||||
}
|
|
||||||
if (startRect) {
|
|
||||||
srcElement!.style.visibility = "hidden";
|
|
||||||
const endRect = element.getBoundingClientRect();
|
|
||||||
animateHero(startRect, endRect, element);
|
|
||||||
} else if (props.bottomUp) {
|
|
||||||
animateSlideInFromBottom(element);
|
animateSlideInFromBottom(element);
|
||||||
} else if (window.innerWidth <= 560) {
|
} else if (window.innerWidth <= 560) {
|
||||||
element.classList.add("animated");
|
element.classList.add("animated");
|
||||||
const onAnimationEnd = () => {
|
const onAnimationEnd = () => {
|
||||||
element.classList.remove("animated");
|
element.classList.remove("animated");
|
||||||
|
animation = undefined;
|
||||||
};
|
};
|
||||||
animation = animateSlideInFromRight(element, {
|
animation = animateSlideInFromRight(element, {
|
||||||
easing: ANIM_CURVE_DECELERATION,
|
easing: ANIM_CURVE_DECELERATION,
|
||||||
|
@ -135,71 +108,6 @@ const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const animateSlideInFromBottom = (
|
|
||||||
element: HTMLElement,
|
|
||||||
reserve?: boolean,
|
|
||||||
) => {
|
|
||||||
const rect = element.getBoundingClientRect();
|
|
||||||
const easing = "cubic-bezier(0.4, 0, 0.2, 1)";
|
|
||||||
element.classList.add("animated");
|
|
||||||
const oldOverflow = document.body.style.overflow;
|
|
||||||
document.body.style.overflow = "hidden";
|
|
||||||
const distance = Math.abs(rect.top - window.innerHeight);
|
|
||||||
const duration = (distance / MOVE_SPEED) * 1000;
|
|
||||||
|
|
||||||
animation = element.animate(
|
|
||||||
{
|
|
||||||
top: reserve
|
|
||||||
? [`${rect.top}px`, `${window.innerHeight}px`]
|
|
||||||
: [`${window.innerHeight}px`, `${rect.top}px`],
|
|
||||||
},
|
|
||||||
{ easing, duration },
|
|
||||||
);
|
|
||||||
const onAnimationEnd = () => {
|
|
||||||
element.classList.remove("animated");
|
|
||||||
document.body.style.overflow = oldOverflow;
|
|
||||||
animation = undefined;
|
|
||||||
};
|
|
||||||
animation.addEventListener("cancel", onAnimationEnd);
|
|
||||||
animation.addEventListener("finish", onAnimationEnd);
|
|
||||||
return animation;
|
|
||||||
};
|
|
||||||
|
|
||||||
const animateHero = (
|
|
||||||
startRect: DOMRect,
|
|
||||||
endRect: DOMRect,
|
|
||||||
element: HTMLElement,
|
|
||||||
reserve?: boolean,
|
|
||||||
) => {
|
|
||||||
const easing = ANIM_CURVE_STD;
|
|
||||||
element.classList.add("animated");
|
|
||||||
// distance_lt = (|top_start - top_end|^2 + |left_end - left_end|^2)^(-2)
|
|
||||||
const distancelt = Math.sqrt(
|
|
||||||
Math.pow(Math.abs(startRect.top - endRect.top), 2) +
|
|
||||||
Math.pow(Math.abs(startRect.left - endRect.left), 2),
|
|
||||||
);
|
|
||||||
const distancerb = Math.sqrt(
|
|
||||||
Math.pow(Math.abs(startRect.bottom - endRect.bottom), 2) +
|
|
||||||
Math.pow(Math.abs(startRect.right - endRect.right), 2),
|
|
||||||
);
|
|
||||||
const distance = distancelt + distancerb;
|
|
||||||
const duration = distance / 1.6;
|
|
||||||
animation = element.animate(
|
|
||||||
[
|
|
||||||
composeAnimationFrame(startRect, { transform: "none" }),
|
|
||||||
composeAnimationFrame(endRect, { transform: "none" }),
|
|
||||||
],
|
|
||||||
{ easing, duration },
|
|
||||||
);
|
|
||||||
const onAnimationEnd = () => {
|
|
||||||
element.classList.remove("animated");
|
|
||||||
animation = undefined;
|
|
||||||
};
|
|
||||||
animation.addEventListener("finish", onAnimationEnd);
|
|
||||||
animation.addEventListener("cancel", onAnimationEnd);
|
|
||||||
return animation;
|
|
||||||
};
|
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
if (animation) {
|
if (animation) {
|
||||||
animation.cancel();
|
animation.cancel();
|
||||||
|
@ -209,6 +117,7 @@ const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
|
||||||
const onDialogClick = (
|
const onDialogClick = (
|
||||||
event: MouseEvent & { currentTarget: HTMLDialogElement },
|
event: MouseEvent & { currentTarget: HTMLDialogElement },
|
||||||
) => {
|
) => {
|
||||||
|
event.stopPropagation();
|
||||||
if (event.target !== event.currentTarget) return;
|
if (event.target !== event.currentTarget) return;
|
||||||
const rect = event.currentTarget.getBoundingClientRect();
|
const rect = event.currentTarget.getBoundingClientRect();
|
||||||
const isNotInDialog =
|
const isNotInDialog =
|
||||||
|
@ -239,7 +148,7 @@ const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
role="presentation"
|
role="presentation"
|
||||||
>
|
>
|
||||||
{ochildren() ?? cache()}
|
{child()}
|
||||||
</dialog>
|
</dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
width: max-content;
|
width: max-content;
|
||||||
box-shadow: var(--tutu-shadow-e8);
|
box-shadow: var(--tutu-shadow-e8);
|
||||||
contain: content;
|
contain: content;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
|
||||||
&.e1 {
|
&.e1 {
|
||||||
box-shadow: var(--tutu-shadow-e9);
|
box-shadow: var(--tutu-shadow-e9);
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { useWindowSize } from "@solid-primitives/resize-observer";
|
import { useWindowSize } from "@solid-primitives/resize-observer";
|
||||||
import { MenuList } from "@suid/material";
|
import { MenuList } from "@suid/material";
|
||||||
import {
|
import {
|
||||||
|
batch,
|
||||||
createEffect,
|
createEffect,
|
||||||
createSignal,
|
createSignal,
|
||||||
splitProps,
|
splitProps,
|
||||||
|
@ -13,7 +14,8 @@ import "./Menu.css";
|
||||||
import {
|
import {
|
||||||
animateGrowFromTopRight,
|
animateGrowFromTopRight,
|
||||||
animateShrinkToTopRight,
|
animateShrinkToTopRight,
|
||||||
} from "../platform/anim";
|
} from "~platform/anim";
|
||||||
|
import type { MenuListProps } from "@suid/material/MenuList";
|
||||||
|
|
||||||
export type Anchor = Pick<DOMRect, "top" | "left" | "right"> & { e?: number };
|
export type Anchor = Pick<DOMRect, "top" | "left" | "right"> & { e?: number };
|
||||||
|
|
||||||
|
@ -22,6 +24,7 @@ export type MenuProps = ParentProps<
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
onClose?: JSX.EventHandlerUnion<HTMLDialogElement, Event>;
|
onClose?: JSX.EventHandlerUnion<HTMLDialogElement, Event>;
|
||||||
anchor: () => Anchor;
|
anchor: () => Anchor;
|
||||||
|
MenuListProps?: MenuListProps;
|
||||||
|
|
||||||
id?: string;
|
id?: string;
|
||||||
} & JSX.AriaAttributes
|
} & JSX.AriaAttributes
|
||||||
|
@ -63,11 +66,39 @@ export function createManagedMenuState() {
|
||||||
return !!anchor();
|
return !!anchor();
|
||||||
},
|
},
|
||||||
anchor: anchor as () => Anchor,
|
anchor: anchor as () => Anchor,
|
||||||
onClose: () => setAnchor(),
|
onClose: (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
return setAnchor();
|
||||||
|
},
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function animateGrowFromTopLeft(
|
||||||
|
element: HTMLElement,
|
||||||
|
opts?: Omit<KeyframeAnimationOptions, "duration">,
|
||||||
|
) {
|
||||||
|
const rend = element.getBoundingClientRect();
|
||||||
|
const overflow = element.style.overflow;
|
||||||
|
element.style.overflow = "hidden";
|
||||||
|
const duration = (rend.height / 1600) * 1000;
|
||||||
|
const animation = element.animate(
|
||||||
|
{
|
||||||
|
height: [`${rend.height / 2}px`, `${rend.height}px`],
|
||||||
|
width: [`${(rend.width / 4) * 3}px`, `${rend.width}px`],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
duration,
|
||||||
|
...opts,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
animation.addEventListener(
|
||||||
|
"finish",
|
||||||
|
() => (element.style.overflow = overflow),
|
||||||
|
);
|
||||||
|
return animation;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Material Menu Component. This component is
|
* Material Menu Component. This component is
|
||||||
* implemented with dialog and {@link MenuList} from SUID.
|
* implemented with dialog and {@link MenuList} from SUID.
|
||||||
|
@ -76,10 +107,16 @@ export function createManagedMenuState() {
|
||||||
* - Use {@link createManagedMenuState} and you don't need to manage the open and close.
|
* - Use {@link createManagedMenuState} and you don't need to manage the open and close.
|
||||||
* - Use {@link MenuItem} from SUID as children.
|
* - Use {@link MenuItem} from SUID as children.
|
||||||
*/
|
*/
|
||||||
const Menu: Component<MenuProps> = (props) => {
|
const Menu: Component<MenuProps> = (oprops) => {
|
||||||
let root: HTMLDialogElement;
|
let root: HTMLDialogElement;
|
||||||
const windowSize = useWindowSize();
|
const windowSize = useWindowSize();
|
||||||
const [, rest] = splitProps(props, ["open", "onClose", "anchor"]);
|
const [props, rest] = splitProps(oprops, [
|
||||||
|
"open",
|
||||||
|
"onClose",
|
||||||
|
"anchor",
|
||||||
|
"MenuListProps",
|
||||||
|
"children",
|
||||||
|
]);
|
||||||
|
|
||||||
const [anchorPos, setAnchorPos] = createSignal<{
|
const [anchorPos, setAnchorPos] = createSignal<{
|
||||||
left?: number;
|
left?: number;
|
||||||
|
@ -104,51 +141,57 @@ const Menu: Component<MenuProps> = (props) => {
|
||||||
|
|
||||||
let openAnimationOrigin: "lt" | "rt" = "lt";
|
let openAnimationOrigin: "lt" | "rt" = "lt";
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.showModal();
|
||||||
|
const rend = root.getBoundingClientRect();
|
||||||
|
|
||||||
|
if (left > width / 2) {
|
||||||
|
openAnimationOrigin = "rt";
|
||||||
|
setAnchorPos({
|
||||||
|
left: right - rend.width,
|
||||||
|
top,
|
||||||
|
e,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
openAnimationOrigin = "lt";
|
||||||
|
setAnchorPos({ left, top, e });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isOpened) {
|
||||||
|
switch (openAnimationOrigin) {
|
||||||
|
case "lt":
|
||||||
|
animateGrowFromTopLeft(root, { easing: ANIM_CURVE_STD });
|
||||||
|
break;
|
||||||
|
case "rt":
|
||||||
|
animateGrowFromTopRight(root, { easing: ANIM_CURVE_STD });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (props.open) {
|
if (props.open) {
|
||||||
const a = props.anchor();
|
animateOpen();
|
||||||
|
|
||||||
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({
|
|
||||||
left: right - rend.width,
|
|
||||||
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),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// TODO: update the pos
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
animateClose();
|
animateClose();
|
||||||
}
|
}
|
||||||
|
@ -183,27 +226,42 @@ const Menu: Component<MenuProps> = (props) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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], event);
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
props.onClose as (
|
||||||
|
event: Event & { currentTarget: HTMLDialogElement },
|
||||||
|
) => void
|
||||||
|
)(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<dialog
|
<dialog
|
||||||
ref={root!}
|
ref={root!}
|
||||||
onClose={props.onClose}
|
onClose={props.onClose}
|
||||||
onClick={(e) => {
|
onCancel={props.onClose}
|
||||||
if (e.target === root) {
|
onClick={onDialogClick}
|
||||||
if (props.onClose) {
|
class={`Menu e${anchorPos().e || "0"}`}
|
||||||
if (Array.isArray(props.onClose)) {
|
|
||||||
props.onClose[0](props.onClose[1], e);
|
|
||||||
} else {
|
|
||||||
(
|
|
||||||
props.onClose as (
|
|
||||||
event: Event & { currentTarget: HTMLDialogElement },
|
|
||||||
) => void
|
|
||||||
)(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
e.stopPropagation();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
class={`Menu e${anchorPos().e || 0}`}
|
|
||||||
style={{
|
style={{
|
||||||
left: px(anchorPos().left),
|
left: px(anchorPos().left),
|
||||||
top: px(anchorPos().top),
|
top: px(anchorPos().top),
|
||||||
|
@ -213,11 +271,8 @@ const Menu: Component<MenuProps> = (props) => {
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
<div
|
<div class="container" role="presentation">
|
||||||
class="container"
|
<MenuList {...props.MenuListProps}>{props.children}</MenuList>
|
||||||
role="presentation"
|
|
||||||
>
|
|
||||||
<MenuList>{props.children}</MenuList>
|
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
);
|
);
|
||||||
|
|
|
@ -4,6 +4,9 @@
|
||||||
z-index: var(--tutu-zidx-nav, auto);
|
z-index: var(--tutu-zidx-nav, auto);
|
||||||
|
|
||||||
.MuiToolbar-root {
|
.MuiToolbar-root {
|
||||||
|
margin-left: var(--safe-area-inset-left);
|
||||||
|
margin-right: var(--safe-area-inset-right);
|
||||||
|
|
||||||
>.MuiButtonBase-root {
|
>.MuiButtonBase-root {
|
||||||
&:first-child {
|
&:first-child {
|
||||||
margin-left: -0.5em;
|
margin-left: -0.5em;
|
||||||
|
@ -32,7 +35,6 @@
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
z-index: var(--tutu-zidx-nav, auto);
|
z-index: var(--tutu-zidx-nav, auto);
|
||||||
padding-bottom: var(--safe-area-inset-bottom, 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.Scaffold {
|
.Scaffold {
|
||||||
|
|
|
@ -153,6 +153,8 @@
|
||||||
--tutu-transition-shadow: box-shadow 175ms var(--tutu-anim-curve-std);
|
--tutu-transition-shadow: box-shadow 175ms var(--tutu-anim-curve-std);
|
||||||
|
|
||||||
--tutu-zidx-nav: 1100;
|
--tutu-zidx-nav: 1100;
|
||||||
|
|
||||||
|
accent-color: var(--tutu-color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { Theme, createTheme } from "@suid/material/styles";
|
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";
|
import { Accessor } from "solid-js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -12,6 +12,9 @@ export function useRootTheme(): Accessor<Theme> {
|
||||||
primary: {
|
primary: {
|
||||||
main: deepPurple[500],
|
main: deepPurple[500],
|
||||||
},
|
},
|
||||||
|
error: {
|
||||||
|
main: red[900],
|
||||||
|
},
|
||||||
secondary: {
|
secondary: {
|
||||||
main: amber.A200,
|
main: amber.A200,
|
||||||
},
|
},
|
||||||
|
|
4
src/overrides.d.ts
vendored
4
src/overrides.d.ts
vendored
|
@ -11,6 +11,10 @@ interface ImportMetaEnv {
|
||||||
* Attach the overlay (in the dev mode) if it's `"true"`.
|
* Attach the overlay (in the dev mode) if it's `"true"`.
|
||||||
*/
|
*/
|
||||||
readonly VITE_DEVTOOLS_OVERLAY?: string;
|
readonly VITE_DEVTOOLS_OVERLAY?: string;
|
||||||
|
/**
|
||||||
|
* Always use compatible version of Masonry.
|
||||||
|
*/
|
||||||
|
readonly VITE_PLATFROM_MASONRY_ALWAYS_COMPAT?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|
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 {
|
.StackedPage {
|
||||||
container: StackedPage / size;
|
contain: strict;
|
||||||
display: contents;
|
container: StackedPage / inline-size;
|
||||||
max-width: 100vw;
|
width: 100vw;
|
||||||
max-width: 100dvw;
|
width: 100dvw;
|
||||||
|
height: 100vh;
|
||||||
contain: layout;
|
height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
dialog.StackedPage {
|
dialog.StackedPage {
|
||||||
|
@ -13,14 +13,26 @@ dialog.StackedPage {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overscroll-behavior: none;
|
overscroll-behavior: none;
|
||||||
width: 560px;
|
width: 560px;
|
||||||
|
max-width: 100vw;
|
||||||
|
max-width: 100dvw;
|
||||||
max-height: 100vh;
|
max-height: 100vh;
|
||||||
max-height: 100dvh;
|
max-height: 100dvh;
|
||||||
|
/*
|
||||||
|
* WebKit does not see contain-instric-size as the real element size.
|
||||||
|
* If the container does not have height, the child element using 100%
|
||||||
|
* height (usually Scafflod in our case) was have 0px computed height.
|
||||||
|
*
|
||||||
|
* This behaviour is different from Firefox. So we need to actually
|
||||||
|
* define the box height here. (Rubicon)
|
||||||
|
*/
|
||||||
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
background: none;
|
background: none;
|
||||||
display: none;
|
display: none;
|
||||||
|
|
||||||
contain: strict;
|
contain: strict;
|
||||||
contain-intrinsic-size: auto 560px auto 100vh;
|
contain-intrinsic-size: 560px 100vh;
|
||||||
contain-intrinsic-size: auto 560px auto 100dvh;
|
contain-intrinsic-size: 560px 100dvh;
|
||||||
content-visibility: auto;
|
content-visibility: auto;
|
||||||
|
|
||||||
background: var(--tutu-color-surface);
|
background: var(--tutu-color-surface);
|
||||||
|
@ -31,18 +43,16 @@ dialog.StackedPage {
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
& {
|
& {
|
||||||
|
margin: 0;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
width: 100dvw;
|
width: 100dvw;
|
||||||
height: 100vh;
|
|
||||||
height: 100dvh;
|
|
||||||
contain-intrinsic-size: 100vw 100vh;
|
contain-intrinsic-size: 100vw 100vh;
|
||||||
contain-intrinsic-size: 100dvw 100dvh;
|
contain-intrinsic-size: 100dvw 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&[open] {
|
&[open] {
|
||||||
display: contents;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::backdrop {
|
&::backdrop {
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { StaticRouter, type RouterProps } from "@solidjs/router";
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
createContext,
|
createContext,
|
||||||
|
createMemo,
|
||||||
createRenderEffect,
|
createRenderEffect,
|
||||||
createUniqueId,
|
createUniqueId,
|
||||||
Index,
|
Index,
|
||||||
|
@ -14,10 +15,9 @@ import {
|
||||||
import { createStore, unwrap } from "solid-js/store";
|
import { createStore, unwrap } from "solid-js/store";
|
||||||
import "./StackedRouter.css";
|
import "./StackedRouter.css";
|
||||||
import { animateSlideInFromRight, animateSlideOutToRight } from "./anim";
|
import { animateSlideInFromRight, animateSlideOutToRight } from "./anim";
|
||||||
import { ANIM_CURVE_DECELERATION, ANIM_CURVE_STD } from "../material/theme";
|
import { ANIM_CURVE_DECELERATION, ANIM_CURVE_STD } from "~material/theme";
|
||||||
import {
|
import { makeEventListener } from "@solid-primitives/event-listener";
|
||||||
makeEventListener,
|
import { useWindowSize } from "@solid-primitives/resize-observer";
|
||||||
} from "@solid-primitives/event-listener";
|
|
||||||
|
|
||||||
export type StackedRouterProps = Omit<RouterProps, "url">;
|
export type StackedRouterProps = Omit<RouterProps, "url">;
|
||||||
|
|
||||||
|
@ -36,9 +36,9 @@ export type NewFrameOptions<T> = (T extends undefined
|
||||||
}
|
}
|
||||||
: { state: T }) & {
|
: { state: T }) & {
|
||||||
/**
|
/**
|
||||||
* The new frame should replace the current frame.
|
* The new frame should replace the current frame or all the stack.
|
||||||
*/
|
*/
|
||||||
replace?: boolean;
|
replace?: boolean | "all";
|
||||||
/**
|
/**
|
||||||
* The animatedOpen phase of the life cycle.
|
* The animatedOpen phase of the life cycle.
|
||||||
*
|
*
|
||||||
|
@ -79,6 +79,11 @@ export type Navigator<PushGuide = Record<string, any>> = {
|
||||||
|
|
||||||
const NavigatorContext = /* @__PURE__ */ createContext<Navigator>();
|
const NavigatorContext = /* @__PURE__ */ createContext<Navigator>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the possible navigator of the {@link StackedRouter}.
|
||||||
|
*
|
||||||
|
* @see useNavigator for the navigator usage.
|
||||||
|
*/
|
||||||
export function useMaybeNavigator() {
|
export function useMaybeNavigator() {
|
||||||
return useContext(NavigatorContext);
|
return useContext(NavigatorContext);
|
||||||
}
|
}
|
||||||
|
@ -91,6 +96,8 @@ export function useMaybeNavigator() {
|
||||||
* path and its state. If you need push guide, you may want to
|
* path and its state. If you need push guide, you may want to
|
||||||
* define your own function (like `useAppNavigator`) and cast the
|
* define your own function (like `useAppNavigator`) and cast the
|
||||||
* navigator to the type you need.
|
* navigator to the type you need.
|
||||||
|
*
|
||||||
|
* @see {@link useMaybeNavigator} if you are not sure you are under a {@link StackedRouter}.
|
||||||
*/
|
*/
|
||||||
export function useNavigator() {
|
export function useNavigator() {
|
||||||
const navigator = useMaybeNavigator();
|
const navigator = useMaybeNavigator();
|
||||||
|
@ -110,10 +117,20 @@ export type CurrentFrame = {
|
||||||
const CurrentFrameContext =
|
const CurrentFrameContext =
|
||||||
/* @__PURE__ */ createContext<Accessor<Readonly<CurrentFrame>>>();
|
/* @__PURE__ */ createContext<Accessor<Readonly<CurrentFrame>>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the current, if possible.
|
||||||
|
*
|
||||||
|
* @see {@link useCurrentFrame} asserts the frame exists
|
||||||
|
*/
|
||||||
export function useMaybeCurrentFrame() {
|
export function useMaybeCurrentFrame() {
|
||||||
return useContext(CurrentFrameContext);
|
return useContext(CurrentFrameContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the current frame, assert the frame exists.
|
||||||
|
*
|
||||||
|
* @see {@link useMaybeCurrentFrame} if you are not sure you are under a {@link StackedRouter}.
|
||||||
|
*/
|
||||||
export function useCurrentFrame() {
|
export function useCurrentFrame() {
|
||||||
const frame = useMaybeCurrentFrame();
|
const frame = useMaybeCurrentFrame();
|
||||||
|
|
||||||
|
@ -130,8 +147,11 @@ export function useCurrentFrame() {
|
||||||
* A suspended frame is the one not on the top. "Suspended"
|
* A suspended frame is the one not on the top. "Suspended"
|
||||||
* is the description of a certain situtation, not in the life cycle
|
* is the description of a certain situtation, not in the life cycle
|
||||||
* of a frame.
|
* of a frame.
|
||||||
|
*
|
||||||
|
* If this is not called under a {@link StackedRouter}, it always
|
||||||
|
* returns `false`.
|
||||||
*/
|
*/
|
||||||
export function useMaybeIsFrameSuspended() {
|
export function useIsFrameSuspended() {
|
||||||
const { frames } = useMaybeNavigator() || {};
|
const { frames } = useMaybeNavigator() || {};
|
||||||
|
|
||||||
if (typeof frames === "undefined") {
|
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.
|
* The router that stacks the pages.
|
||||||
|
*
|
||||||
|
* **Routes** The router accepts the {@link RouterProps} excluding the "url" field.
|
||||||
|
* You can seamlessly use the `<Route />` from `@solidjs/router`.
|
||||||
|
*
|
||||||
|
* Be advised that this component is not a drop-in replacement of that router.
|
||||||
|
* These primitives from `@solidjs/router` won't work correctly:
|
||||||
|
*
|
||||||
|
* - `<A />` component - use ~platform/A instead
|
||||||
|
* - `useLocation()` - see {@link useCurrentFrame}
|
||||||
|
* - `useNavigate()` - see {@link useNavigator}
|
||||||
|
*
|
||||||
|
* The other primitives may work, as long as they don't rely on the global location.
|
||||||
|
* This component uses `@solidjs/router` {@link StaticRouter} to route.
|
||||||
|
*
|
||||||
|
* **Injecting Safe Area Insets** The router calculate correct
|
||||||
|
* `--safe-area-inset-left` and `--safe-area-inset-right` from the window
|
||||||
|
* width and `--safe-area-inset-*` from the :root element. That means
|
||||||
|
* the injected insets do not reflects the overrides that are not on the :root.
|
||||||
|
*
|
||||||
|
* The recalculation is only performed when the window size changed.
|
||||||
|
*
|
||||||
|
* **Navigation Animation** The router provides default animation for
|
||||||
|
* navigation.
|
||||||
|
*
|
||||||
|
* If the default animation does not met your requirement,
|
||||||
|
* this component is also intergated with Web Animation API.
|
||||||
|
* You can provide {@link NewFrameOptions.animateOpen} and
|
||||||
|
* {@link NewFrameOptions.animateClose} to define custom animation.
|
||||||
|
*
|
||||||
|
* **Swipe to back** For the subpages (the pages stacked on the entry),
|
||||||
|
* swipe to back gesture is provided for user experience.
|
||||||
|
*
|
||||||
|
* Navigation animations (even the custom ones) will be played during
|
||||||
|
* swipe to back, please keep in mind when designing animations.
|
||||||
|
*
|
||||||
|
* The iOS default gesture is blocked on all pages.
|
||||||
*/
|
*/
|
||||||
const StackedRouter: Component<StackedRouterProps> = (oprops) => {
|
const StackedRouter: Component<StackedRouterProps> = (oprops) => {
|
||||||
const [stack, mutStack] = createStore([] as StackFrame[], { name: "stack" });
|
const [stack, mutStack] = createStore([] as StackFrame[], { name: "stack" });
|
||||||
|
const windowSize = useWindowSize();
|
||||||
|
|
||||||
const pushFrame = (path: string, opts?: Readonly<NewFrameOptions<any>>) =>
|
const pushFrame = (path: string, opts?: Readonly<NewFrameOptions<any>>) =>
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
|
@ -219,11 +427,19 @@ const StackedRouter: Component<StackedRouterProps> = (oprops) => {
|
||||||
animateClose: opts?.animateClose,
|
animateClose: opts?.animateClose,
|
||||||
};
|
};
|
||||||
|
|
||||||
mutStack(opts?.replace ? stack.length - 1 : stack.length, frame);
|
const replace = opts?.replace;
|
||||||
if (opts?.replace) {
|
if (replace === "all") {
|
||||||
window.history.replaceState(serializableStack(stack), "", path);
|
mutStack([frame]);
|
||||||
} else {
|
} 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;
|
return frame;
|
||||||
});
|
});
|
||||||
|
@ -261,7 +477,10 @@ const StackedRouter: Component<StackedRouterProps> = (oprops) => {
|
||||||
|
|
||||||
createRenderEffect(() => {
|
createRenderEffect(() => {
|
||||||
if (stack.length === 0) {
|
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;
|
const subInsets = createMemo(() => {
|
||||||
let origX = 0,
|
const SUBPAGE_MAX_WIDTH = 560;
|
||||||
origWidth = 0;
|
const { width } = windowSize;
|
||||||
|
if (width <= SUBPAGE_MAX_WIDTH) {
|
||||||
const resetAnimation = () => {
|
// page width = 100vw, use the inset directly
|
||||||
reenterableAnimation = undefined;
|
return {};
|
||||||
};
|
|
||||||
|
|
||||||
const onDialogTouchStart = (
|
|
||||||
event: TouchEvent & { currentTarget: HTMLDialogElement },
|
|
||||||
) => {
|
|
||||||
if (event.touches.length !== 1) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
const [fig0] = event.touches;
|
const computedStyle = window.getComputedStyle(
|
||||||
const { x, width } = event.currentTarget.getBoundingClientRect();
|
document.querySelector(":root")!,
|
||||||
if (fig0.clientX < x - 22 || fig0.clientX > x + 22) {
|
);
|
||||||
return;
|
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",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
origX = x;
|
const ofs = (totalWidth - width) / 2;
|
||||||
origWidth = width;
|
return {
|
||||||
|
"--safe-area-inset-left": `${Math.max(left - ofs, 0)}px`,
|
||||||
|
"--safe-area-inset-right": `${Math.max(right - ofs, 0)}px`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const lastFr = stack[stack.length - 1];
|
const swipeToBackProps = createManagedSwipeToBack(stack, onlyPopFrame);
|
||||||
const createAnimation = lastFr.animateClose ?? animateClose;
|
|
||||||
reenterableAnimation = createAnimation(event.currentTarget);
|
|
||||||
reenterableAnimation.pause();
|
|
||||||
reenterableAnimation.addEventListener("finish", resetAnimation);
|
|
||||||
reenterableAnimation.addEventListener("cancel", resetAnimation);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDialogTouchMove = (
|
|
||||||
event: TouchEvent & { currentTarget: HTMLDialogElement },
|
|
||||||
) => {
|
|
||||||
if (event.touches.length !== 1) {
|
|
||||||
if (reenterableAnimation) {
|
|
||||||
reenterableAnimation.reverse();
|
|
||||||
reenterableAnimation.play();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!reenterableAnimation) return;
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
|
|
||||||
const [fig0] = event.touches;
|
|
||||||
const ofsX = fig0.clientX - origX;
|
|
||||||
const pc = ofsX / origWidth / window.devicePixelRatio;
|
|
||||||
|
|
||||||
const { activeDuration, delay } =
|
|
||||||
reenterableAnimation.effect!.getComputedTiming();
|
|
||||||
|
|
||||||
const totalTime = (delay || 0) + Number(activeDuration);
|
|
||||||
reenterableAnimation.currentTime = totalTime * pc;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDialogTouchEnd = (event: TouchEvent) => {
|
|
||||||
if (!reenterableAnimation) return;
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
|
|
||||||
const { activeDuration, delay } =
|
|
||||||
reenterableAnimation.effect!.getComputedTiming();
|
|
||||||
const totalTime = (delay || 0) + Number(activeDuration);
|
|
||||||
|
|
||||||
if (Number(reenterableAnimation.currentTime) / totalTime > 0.1) {
|
|
||||||
reenterableAnimation.addEventListener("finish", () => {
|
|
||||||
onlyPopFrame(1);
|
|
||||||
});
|
|
||||||
reenterableAnimation.play();
|
|
||||||
} else {
|
|
||||||
reenterableAnimation.cancel();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDialogTouchCancel = (event: TouchEvent) => {
|
|
||||||
if (!reenterableAnimation) return;
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
reenterableAnimation.cancel();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavigatorContext.Provider
|
<NavigatorContext.Provider
|
||||||
|
@ -402,6 +571,7 @@ const StackedRouter: Component<StackedRouterProps> = (oprops) => {
|
||||||
class="StackedPage"
|
class="StackedPage"
|
||||||
id={frame().rootId}
|
id={frame().rootId}
|
||||||
role="presentation"
|
role="presentation"
|
||||||
|
on:touchstart={onEntryTouchStart}
|
||||||
>
|
>
|
||||||
<StaticRouter url={frame().path} {...oprops} />
|
<StaticRouter url={frame().path} {...oprops} />
|
||||||
</div>
|
</div>
|
||||||
|
@ -412,11 +582,9 @@ const StackedRouter: Component<StackedRouterProps> = (oprops) => {
|
||||||
class="StackedPage"
|
class="StackedPage"
|
||||||
onCancel={[popFrame, 1]}
|
onCancel={[popFrame, 1]}
|
||||||
onClick={[onDialogClick, popFrame]}
|
onClick={[onDialogClick, popFrame]}
|
||||||
onTouchStart={onDialogTouchStart}
|
{...swipeToBackProps}
|
||||||
onTouchMove={onDialogTouchMove}
|
|
||||||
onTouchEnd={onDialogTouchEnd}
|
|
||||||
onTouchCancel={onDialogTouchCancel}
|
|
||||||
id={frame().rootId}
|
id={frame().rootId}
|
||||||
|
style={subInsets()}
|
||||||
>
|
>
|
||||||
<StaticRouter url={frame().path} {...oprops} />
|
<StaticRouter url={frame().path} {...oprops} />
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
|
@ -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(
|
export function animateRollOutFromTop(
|
||||||
root: HTMLElement,
|
root: HTMLElement,
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
import {
|
import {
|
||||||
ParentComponent,
|
catchError,
|
||||||
createContext,
|
createContext,
|
||||||
createMemo,
|
createMemo,
|
||||||
createResource,
|
createResource,
|
||||||
useContext,
|
useContext,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import { match } from "@formatjs/intl-localematcher";
|
import { match } from "@formatjs/intl-localematcher";
|
||||||
import { Accessor, createEffect, createSignal } from "solid-js";
|
import { Accessor } from "solid-js";
|
||||||
import { $settings } from "../settings/stores";
|
import { $settings } from "../settings/stores";
|
||||||
import { enGB } from "date-fns/locale/en-GB";
|
import { enGB } from "date-fns/locale/en-GB";
|
||||||
import { useStore } from "@nanostores/solid";
|
import { useStore } from "@nanostores/solid";
|
||||||
|
@ -17,13 +17,6 @@ import {
|
||||||
type Template,
|
type Template,
|
||||||
} from "@solid-primitives/i18n";
|
} from "@solid-primitives/i18n";
|
||||||
|
|
||||||
async function synchronised(
|
|
||||||
name: string,
|
|
||||||
callback: () => Promise<void> | void,
|
|
||||||
): Promise<void> {
|
|
||||||
await navigator.locks.request(name, callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SUPPORTED_LANGS = ["en", "zh-Hans"] as const;
|
export const SUPPORTED_LANGS = ["en", "zh-Hans"] as const;
|
||||||
|
|
||||||
export const SUPPORTED_REGIONS = ["en_US", "en_GB", "zh_CN"] as const;
|
export const SUPPORTED_REGIONS = ["en_US", "en_GB", "zh_CN"] as const;
|
||||||
|
@ -38,14 +31,6 @@ export function autoMatchLangTag() {
|
||||||
return match(Array.from(navigator.languages), SUPPORTED_LANGS, DEFAULT_LANG);
|
return match(Array.from(navigator.languages), SUPPORTED_LANGS, DEFAULT_LANG);
|
||||||
}
|
}
|
||||||
|
|
||||||
const DateFnLocaleCx = /* __@PURE__ */ createContext<Accessor<Locale>>(
|
|
||||||
() => enGB,
|
|
||||||
);
|
|
||||||
|
|
||||||
const cachedDateFnLocale: Record<string, Locale> = {
|
|
||||||
enGB,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function autoMatchRegion() {
|
export function autoMatchRegion() {
|
||||||
const specifiers = navigator.languages.map((x) => x.split("-"));
|
const specifiers = navigator.languages.map((x) => x.split("-"));
|
||||||
|
|
||||||
|
@ -58,7 +43,7 @@ export function autoMatchRegion() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (s.length === 2) {
|
} else if (s.length === 2) {
|
||||||
const [lang, region] = s[1];
|
const [lang, region] = s;
|
||||||
for (const available of SUPPORTED_REGIONS) {
|
for (const available of SUPPORTED_REGIONS) {
|
||||||
if (available.toLowerCase() === `${lang}_${region}`.toLowerCase()) {
|
if (available.toLowerCase() === `${lang}_${region}`.toLowerCase()) {
|
||||||
return available;
|
return available;
|
||||||
|
@ -70,7 +55,7 @@ export function autoMatchRegion() {
|
||||||
return "en_GB";
|
return "en_GB";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useRegion() {
|
export function createCurrentRegion() {
|
||||||
const appSettings = useStore($settings);
|
const appSettings = useStore($settings);
|
||||||
|
|
||||||
return createMemo(
|
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.
|
* Get the {@link Locale} object for date-fns.
|
||||||
*
|
*
|
||||||
|
@ -155,11 +93,11 @@ export const DateFnScope: ParentComponent = (props) => {
|
||||||
* @returns Accessor for Locale
|
* @returns Accessor for Locale
|
||||||
*/
|
*/
|
||||||
export function useDateFnLocale(): Accessor<Locale> {
|
export function useDateFnLocale(): Accessor<Locale> {
|
||||||
const cx = useContext(DateFnLocaleCx);
|
const { dateFn } = useAppLocale();
|
||||||
return cx;
|
return dateFn;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useLanguage() {
|
export function createCurrentLanguage() {
|
||||||
const settings = useStore($settings);
|
const settings = useStore($settings);
|
||||||
return () => settings().language || autoMatchLangTag();
|
return () => settings().language || autoMatchLangTag();
|
||||||
}
|
}
|
||||||
|
@ -179,7 +117,7 @@ type MergedImportedModule<T> = T extends []
|
||||||
export function createStringResource<
|
export function createStringResource<
|
||||||
T extends ImportFn<Record<string, string | Template<any> | undefined>>[],
|
T extends ImportFn<Record<string, string | Template<any> | undefined>>[],
|
||||||
>(...importFns: T) {
|
>(...importFns: T) {
|
||||||
const language = useLanguage();
|
const language = createCurrentLanguage();
|
||||||
const cache: Record<string, MergedImportedModule<T>> = {};
|
const cache: Record<string, MergedImportedModule<T>> = {};
|
||||||
|
|
||||||
return createResource(
|
return createResource(
|
||||||
|
@ -209,3 +147,38 @@ export function createTranslator<
|
||||||
|
|
||||||
return [translator(res[0], resolveTemplate), res] as const;
|
return [translator(res[0], resolveTemplate), res] as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AppLocale = {
|
||||||
|
dateFn: () => Locale;
|
||||||
|
language: () => string;
|
||||||
|
region: () => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AppLocaleContext = /* @__PURE__ */ createContext<AppLocale>();
|
||||||
|
|
||||||
|
export const AppLocaleProvider = AppLocaleContext.Provider;
|
||||||
|
|
||||||
|
export function useAppLocale() {
|
||||||
|
const l = useContext(AppLocaleContext);
|
||||||
|
if (!l) {
|
||||||
|
throw new TypeError("app locale not found");
|
||||||
|
}
|
||||||
|
return l;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDateFnLocaleResource(region: () => string) {
|
||||||
|
const [localeUncaught] = createResource(
|
||||||
|
region,
|
||||||
|
async (region) => {
|
||||||
|
return await importDateFnLocale(region);
|
||||||
|
},
|
||||||
|
{ initialValue: enGB },
|
||||||
|
);
|
||||||
|
|
||||||
|
return createMemo(
|
||||||
|
() =>
|
||||||
|
catchError(localeUncaught, (reason) => {
|
||||||
|
console.error("fetch date-fns locale", reason);
|
||||||
|
}) ?? enGB,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -1,14 +1,7 @@
|
||||||
//! This module has side effect.
|
//! This module has side effect.
|
||||||
//! It recommended to include the module by <script> tag.
|
//! It recommended to include the module by <script> tag.
|
||||||
if (typeof document.body.animate === "undefined") {
|
|
||||||
// @ts-ignore: this file is polyfill, no exposed decls
|
|
||||||
import("web-animations-js").then(() => {
|
|
||||||
// all target platforms supported, prepared to remove
|
|
||||||
console.warn("web animation polyfill is included");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof window.crypto.randomUUID === "undefined") {
|
if (typeof window.crypto.randomUUID === "undefined") {
|
||||||
|
// TODO: this polyfill can be removed in 2.0, see https://code.lightstands.xyz/Rubicon/tutu/issues/36
|
||||||
// Chrome/Edge 92+
|
// Chrome/Edge 92+
|
||||||
// https://stackoverflow.com/a/2117523/2800218
|
// https://stackoverflow.com/a/2117523/2800218
|
||||||
// LICENSE: https://creativecommons.org/licenses/by-sa/4.0/legalcode
|
// LICENSE: https://creativecommons.org/licenses/by-sa/4.0/legalcode
|
||||||
|
|
|
@ -4,8 +4,8 @@ import {
|
||||||
onCleanup,
|
onCleanup,
|
||||||
type Component,
|
type Component,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import Scaffold from "../material/Scaffold";
|
import Scaffold from "~material/Scaffold";
|
||||||
import BottomSheet from "../material/BottomSheet";
|
import BottomSheet from "~material/BottomSheet";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
IconButton,
|
IconButton,
|
||||||
|
@ -14,9 +14,9 @@ import {
|
||||||
Toolbar,
|
Toolbar,
|
||||||
} from "@suid/material";
|
} from "@suid/material";
|
||||||
import { Close as CloseIcon, ContentCopy } from "@suid/icons-material";
|
import { Close as CloseIcon, ContentCopy } from "@suid/icons-material";
|
||||||
import { Title } from "../material/typography";
|
import { Title } from "~material/typography";
|
||||||
import { render } from "solid-js/web";
|
import { render } from "solid-js/web";
|
||||||
import { useRootTheme } from "../material/theme";
|
import { useRootTheme } from "~material/theme";
|
||||||
|
|
||||||
const ShareBottomSheet: Component<{
|
const ShareBottomSheet: Component<{
|
||||||
data?: ShareData;
|
data?: ShareData;
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
.Profile {
|
.Profile {
|
||||||
height: 100%;
|
overflow: hidden auto;
|
||||||
|
|
||||||
.intro {
|
.intro {
|
||||||
background-color: var(--tutu-color-surface-d);
|
background-color: var(--tutu-color-surface-d);
|
||||||
|
@ -8,20 +8,17 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-flow: column nowrap;
|
flex-flow: column nowrap;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
contain: layout style;
|
||||||
}
|
}
|
||||||
|
|
||||||
.banner {
|
.banner {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: calc(-1 * (var(--scaffold-topbar-height) + var(--safe-area-inset-top)));
|
margin-top: calc(-1 * var(--scaffold-topbar-height));
|
||||||
|
|
||||||
>img {
|
>img {
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
&::before {
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -32,6 +29,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
|
|
||||||
|
& * {
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.acct-grp {
|
.acct-grp {
|
||||||
|
@ -53,6 +54,18 @@
|
||||||
.name-grp {
|
.name-grp {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-flow: column nowrap;
|
flex-flow: column nowrap;
|
||||||
|
|
||||||
|
& * {
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-name {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username {
|
||||||
|
user-select: all;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.acct-mark {
|
.acct-mark {
|
||||||
|
@ -61,9 +74,7 @@
|
||||||
margin-right: 0.25em;
|
margin-right: 0.25em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.display-name {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
table.acct-fields {
|
table.acct-fields {
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
|
@ -82,6 +93,10 @@
|
||||||
& svg {
|
& svg {
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
& * {
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.toot-list-toolbar {
|
.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 {
|
.Profile__page-title {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
|
@ -12,7 +12,7 @@ import {
|
||||||
type Component,
|
type Component,
|
||||||
createMemo,
|
createMemo,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import Scaffold from "../material/Scaffold";
|
import Scaffold from "~material/Scaffold";
|
||||||
import {
|
import {
|
||||||
AppBar,
|
AppBar,
|
||||||
Avatar,
|
Avatar,
|
||||||
|
@ -44,7 +44,7 @@ import {
|
||||||
Subject,
|
Subject,
|
||||||
Verified,
|
Verified,
|
||||||
} from "@suid/icons-material";
|
} from "@suid/icons-material";
|
||||||
import { Body2, Title } from "../material/typography";
|
import { Body2, Title } from "~material/typography";
|
||||||
import { useParams } from "@solidjs/router";
|
import { useParams } from "@solidjs/router";
|
||||||
import { useSessionForAcctStr } from "../masto/clients";
|
import { useSessionForAcctStr } from "../masto/clients";
|
||||||
import { resolveCustomEmoji } from "../masto/toot";
|
import { resolveCustomEmoji } from "../masto/toot";
|
||||||
|
@ -52,12 +52,12 @@ import { FastAverageColor } from "fast-average-color";
|
||||||
import { useWindowSize } from "@solid-primitives/resize-observer";
|
import { useWindowSize } from "@solid-primitives/resize-observer";
|
||||||
import { createTimeline, createTimelineSnapshot } from "../masto/timelines";
|
import { createTimeline, createTimelineSnapshot } from "../masto/timelines";
|
||||||
import TootList from "../timelines/TootList";
|
import TootList from "../timelines/TootList";
|
||||||
import { createTimeSource, TimeSourceProvider } from "../platform/timesrc";
|
import { createTimeSource, TimeSourceProvider } from "~platform/timesrc";
|
||||||
import TootFilterButton from "./TootFilterButton";
|
import TootFilterButton from "./TootFilterButton";
|
||||||
import Menu, { createManagedMenuState } from "../material/Menu";
|
import Menu, { createManagedMenuState } from "~material/Menu";
|
||||||
import { share } from "../platform/share";
|
import { share } from "~platform/share";
|
||||||
import "./Profile.css";
|
import "./Profile.css";
|
||||||
import { useNavigator } from "../platform/StackedRouter";
|
import { useNavigator } from "~platform/StackedRouter";
|
||||||
|
|
||||||
const Profile: Component = () => {
|
const Profile: Component = () => {
|
||||||
const { pop } = useNavigator();
|
const { pop } = useNavigator();
|
||||||
|
@ -175,12 +175,12 @@ const Profile: Component = () => {
|
||||||
createRenderEffect(() => (e.innerHTML = sessionDisplayName()));
|
createRenderEffect(() => (e.innerHTML = sessionDisplayName()));
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleSubscribeHome = async () => {
|
const toggleSubscribeHome = async (event: Event) => {
|
||||||
const client = session().client;
|
const client = session().client;
|
||||||
if (!session().account) return;
|
if (!session().account) return;
|
||||||
const isSubscribed = relationship()?.following ?? false;
|
const isSubscribed = relationship()?.following ?? false;
|
||||||
mutateRelationship((x) => Object.assign({ following: !isSubscribed }, x));
|
mutateRelationship((x) => Object.assign({ following: !isSubscribed }, x));
|
||||||
subscribeMenuState.onClose();
|
subscribeMenuState.onClose(event);
|
||||||
|
|
||||||
if (isSubscribed) {
|
if (isSubscribed) {
|
||||||
const nrel = await client.v1.accounts.$select(params.id).unfollow();
|
const nrel = await client.v1.accounts.$select(params.id).unfollow();
|
||||||
|
@ -237,315 +237,321 @@ const Profile: Component = () => {
|
||||||
}
|
}
|
||||||
class="Profile"
|
class="Profile"
|
||||||
>
|
>
|
||||||
<Menu
|
<div class="details" role="presentation">
|
||||||
id={optMenuId}
|
<Menu
|
||||||
open={menuOpen()}
|
id={optMenuId}
|
||||||
onClose={[setMenuOpen, false]}
|
open={menuOpen()}
|
||||||
anchor={() =>
|
onClose={[setMenuOpen, false]}
|
||||||
document.getElementById(menuButId)!.getBoundingClientRect()
|
anchor={() =>
|
||||||
}
|
document.getElementById(menuButId)!.getBoundingClientRect()
|
||||||
aria-label="Options for the Profile"
|
}
|
||||||
>
|
aria-label="Options for the Profile"
|
||||||
<Show when={session().account}>
|
>
|
||||||
<MenuItem>
|
<Show when={session().account}>
|
||||||
<ListItemAvatar>
|
<MenuItem>
|
||||||
<Avatar src={session().account?.inf?.avatar} />
|
<ListItemAvatar>
|
||||||
</ListItemAvatar>
|
<Avatar src={session().account?.inf?.avatar} />
|
||||||
<ListItemText secondary={"Default account"}>
|
</ListItemAvatar>
|
||||||
<span ref={useSessionDisplayName}></span>
|
<ListItemText secondary={"Default account"}>
|
||||||
</ListItemText>
|
<span ref={useSessionDisplayName}></span>
|
||||||
{/* <ArrowRight /> // for future */}
|
</ListItemText>
|
||||||
</MenuItem>
|
{/* <ArrowRight /> // for future */}
|
||||||
</Show>
|
|
||||||
<Show when={session().account && profile()}>
|
|
||||||
<Show
|
|
||||||
when={isCurrentSessionProfile()}
|
|
||||||
fallback={
|
|
||||||
<MenuItem
|
|
||||||
onClick={(event) => {
|
|
||||||
const { left, right, top } =
|
|
||||||
event.currentTarget.getBoundingClientRect();
|
|
||||||
openSubscribeMenu({
|
|
||||||
left,
|
|
||||||
right,
|
|
||||||
top,
|
|
||||||
e: 1,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ListItemIcon>
|
|
||||||
<PlaylistAdd />
|
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText>Subscribe...</ListItemText>
|
|
||||||
</MenuItem>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<MenuItem disabled>
|
|
||||||
<ListItemIcon>
|
|
||||||
<Edit />
|
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText>Edit...</ListItemText>
|
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</Show>
|
</Show>
|
||||||
<Divider />
|
<Show when={session().account && profile()}>
|
||||||
</Show>
|
<Show
|
||||||
<MenuItem disabled>
|
when={isCurrentSessionProfile()}
|
||||||
<ListItemIcon>
|
fallback={
|
||||||
<Group />
|
<MenuItem
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText>Followers</ListItemText>
|
|
||||||
<ListItemSecondaryAction>
|
|
||||||
<span aria-label="The number of the account follower">
|
|
||||||
{profile()?.followersCount ?? ""}
|
|
||||||
</span>
|
|
||||||
</ListItemSecondaryAction>
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem disabled>
|
|
||||||
<ListItemIcon>
|
|
||||||
<Subject />
|
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText>Following</ListItemText>
|
|
||||||
<ListItemSecondaryAction>
|
|
||||||
<span aria-label="The number the account following">
|
|
||||||
{profile()?.followingCount ?? ""}
|
|
||||||
</span>
|
|
||||||
</ListItemSecondaryAction>
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem disabled>
|
|
||||||
<ListItemIcon>
|
|
||||||
<PersonOff />
|
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText>Blocklist</ListItemText>
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem disabled>
|
|
||||||
<ListItemIcon>
|
|
||||||
<Send />
|
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText>Mention in...</ListItemText>
|
|
||||||
</MenuItem>
|
|
||||||
<Divider />
|
|
||||||
<MenuItem
|
|
||||||
component={"a"}
|
|
||||||
href={profile()?.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<ListItemIcon>
|
|
||||||
<OpenInBrowser />
|
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText>Open in browser...</ListItemText>
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem onClick={() => share({ url: profile()?.url })}>
|
|
||||||
<ListItemIcon>
|
|
||||||
<Share />
|
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText>Share...</ListItemText>
|
|
||||||
</MenuItem>
|
|
||||||
</Menu>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: `${268 * (Math.min(560, windowSize.width) / 560)}px`,
|
|
||||||
}}
|
|
||||||
class="banner"
|
|
||||||
role="presentation"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
ref={(e) => obx.observe(e)}
|
|
||||||
src={bannerImg()}
|
|
||||||
crossOrigin="anonymous"
|
|
||||||
alt={`Banner image for ${profile()?.displayName || "the user"}`}
|
|
||||||
onLoad={(event) => {
|
|
||||||
const ins = new FastAverageColor();
|
|
||||||
const colors = ins.getColor(event.currentTarget);
|
|
||||||
setBannerSampledColors({
|
|
||||||
average: colors.hex,
|
|
||||||
text: colors.isDark ? "white" : "black",
|
|
||||||
});
|
|
||||||
ins.destroy();
|
|
||||||
}}
|
|
||||||
></img>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Menu {...subscribeMenuState}>
|
|
||||||
<MenuItem
|
|
||||||
onClick={toggleSubscribeHome}
|
|
||||||
aria-label={`${relationship()?.following ? "Unfollow" : "Follow"} on your home timeline`}
|
|
||||||
>
|
|
||||||
<ListItemAvatar>
|
|
||||||
<Avatar src={session().account?.inf?.avatar}></Avatar>
|
|
||||||
</ListItemAvatar>
|
|
||||||
<ListItemText
|
|
||||||
secondary={
|
|
||||||
relationship()?.following
|
|
||||||
? undefined
|
|
||||||
: profile()?.locked
|
|
||||||
? "A request will be sent"
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span ref={useSessionDisplayName}></span>
|
|
||||||
<span>'s Home</span>
|
|
||||||
</ListItemText>
|
|
||||||
|
|
||||||
<Checkbox checked={relationship()?.following ?? false} />
|
|
||||||
</MenuItem>
|
|
||||||
</Menu>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="intro"
|
|
||||||
style={{
|
|
||||||
"background-color": bannerSampledColors()?.average,
|
|
||||||
color: bannerSampledColors()?.text,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<section class="acct-grp">
|
|
||||||
<Avatar
|
|
||||||
src={avatarImg()}
|
|
||||||
alt={`${profile()?.displayName || "the user"}'s avatar`}
|
|
||||||
sx={{
|
|
||||||
marginTop: "calc(-16px - 72px / 2)",
|
|
||||||
width: "72px",
|
|
||||||
height: "72px",
|
|
||||||
}}
|
|
||||||
></Avatar>
|
|
||||||
<div class="name-grp">
|
|
||||||
<div class="display-name">
|
|
||||||
<Show when={profile()?.bot}>
|
|
||||||
<SmartToySharp class="acct-mark" aria-label="Bot" />
|
|
||||||
</Show>
|
|
||||||
<Show when={profile()?.locked}>
|
|
||||||
<Lock class="acct-mark" aria-label="Locked" />
|
|
||||||
</Show>
|
|
||||||
<Body2
|
|
||||||
component="span"
|
|
||||||
ref={(e: HTMLElement) =>
|
|
||||||
createRenderEffect(() => (e.innerHTML = displayName()))
|
|
||||||
}
|
|
||||||
aria-label="Display name"
|
|
||||||
></Body2>
|
|
||||||
</div>
|
|
||||||
<span aria-label="Complete username">{fullUsername()}</span>
|
|
||||||
</div>
|
|
||||||
<div role="presentation">
|
|
||||||
<Switch>
|
|
||||||
<Match
|
|
||||||
when={
|
|
||||||
!session().account ||
|
|
||||||
profileUncaught.loading ||
|
|
||||||
profileUncaught.error
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{<></>}
|
|
||||||
</Match>
|
|
||||||
<Match when={isCurrentSessionProfile()}>
|
|
||||||
<IconButton color="inherit">
|
|
||||||
<Edit />
|
|
||||||
</IconButton>
|
|
||||||
</Match>
|
|
||||||
<Match when={true}>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
color="secondary"
|
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
openSubscribeMenu(
|
const { left, right, top } =
|
||||||
event.currentTarget.getBoundingClientRect(),
|
event.currentTarget.getBoundingClientRect();
|
||||||
);
|
openSubscribeMenu({
|
||||||
|
left,
|
||||||
|
right,
|
||||||
|
top,
|
||||||
|
e: 1,
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{relationship()?.following ? "Subscribed" : "Subscribe"}
|
<ListItemIcon>
|
||||||
</Button>
|
<PlaylistAdd />
|
||||||
</Match>
|
</ListItemIcon>
|
||||||
</Switch>
|
<ListItemText>Subscribe...</ListItemText>
|
||||||
</div>
|
</MenuItem>
|
||||||
</section>
|
}
|
||||||
<section
|
>
|
||||||
class="description"
|
<MenuItem disabled>
|
||||||
aria-label={`${profile()?.displayName || "the user"}'s description`}
|
<ListItemIcon>
|
||||||
ref={(e) =>
|
<Edit />
|
||||||
createRenderEffect(() => (e.innerHTML = description() || ""))
|
</ListItemIcon>
|
||||||
}
|
<ListItemText>Edit...</ListItemText>
|
||||||
></section>
|
</MenuItem>
|
||||||
|
</Show>
|
||||||
<table
|
<Divider />
|
||||||
class="acct-fields"
|
</Show>
|
||||||
aria-label={`${profile()?.displayName || "the user"}'s fields`}
|
<MenuItem disabled>
|
||||||
>
|
<ListItemIcon>
|
||||||
<tbody>
|
<Group />
|
||||||
<For each={profile()?.fields ?? []}>
|
</ListItemIcon>
|
||||||
{(item, index) => {
|
<ListItemText>Followers</ListItemText>
|
||||||
return (
|
<ListItemSecondaryAction>
|
||||||
<tr data-field-index={index()}>
|
<span aria-label="The number of the account follower">
|
||||||
<td>{item.name}</td>
|
{profile()?.followersCount ?? ""}
|
||||||
<td>
|
</span>
|
||||||
<Show when={item.verifiedAt}>
|
</ListItemSecondaryAction>
|
||||||
<Verified />
|
</MenuItem>
|
||||||
</Show>
|
<MenuItem disabled>
|
||||||
</td>
|
<ListItemIcon>
|
||||||
<td
|
<Subject />
|
||||||
ref={(e) => {
|
</ListItemIcon>
|
||||||
createRenderEffect(() => (e.innerHTML = item.value));
|
<ListItemText>Following</ListItemText>
|
||||||
}}
|
<ListItemSecondaryAction>
|
||||||
></td>
|
<span aria-label="The number the account following">
|
||||||
</tr>
|
{profile()?.followingCount ?? ""}
|
||||||
);
|
</span>
|
||||||
}}
|
</ListItemSecondaryAction>
|
||||||
</For>
|
</MenuItem>
|
||||||
</tbody>
|
<MenuItem disabled>
|
||||||
</table>
|
<ListItemIcon>
|
||||||
</div>
|
<PersonOff />
|
||||||
|
</ListItemIcon>
|
||||||
<div class="toot-list-toolbar">
|
<ListItemText>Blocklist</ListItemText>
|
||||||
<TootFilterButton
|
</MenuItem>
|
||||||
options={{
|
<MenuItem disabled>
|
||||||
pinned: "Pinneds",
|
<ListItemIcon>
|
||||||
boost: "Boosts",
|
<Send />
|
||||||
reply: "Replies",
|
</ListItemIcon>
|
||||||
original: "Originals",
|
<ListItemText>Mention in...</ListItemText>
|
||||||
}}
|
</MenuItem>
|
||||||
applied={recentTootFilter()}
|
|
||||||
onApply={setRecentTootFilter}
|
|
||||||
disabledKeys={["original"]}
|
|
||||||
></TootFilterButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TimeSourceProvider value={time}>
|
|
||||||
<Show when={recentTootFilter().pinned && pinnedToots.list.length > 0}>
|
|
||||||
<TootList
|
|
||||||
threads={pinnedToots.list}
|
|
||||||
onUnknownThread={pinnedToots.getPath}
|
|
||||||
onChangeToot={pinnedToots.set}
|
|
||||||
/>
|
|
||||||
<Divider />
|
<Divider />
|
||||||
</Show>
|
<MenuItem
|
||||||
<TootList
|
component={"a"}
|
||||||
id={recentTootListId}
|
href={profile()?.url}
|
||||||
threads={recentToots.list}
|
target="_blank"
|
||||||
onUnknownThread={recentToots.getPath}
|
rel="noopener noreferrer"
|
||||||
onChangeToot={recentToots.set}
|
>
|
||||||
/>
|
<ListItemIcon>
|
||||||
</TimeSourceProvider>
|
<OpenInBrowser />
|
||||||
|
</ListItemIcon>
|
||||||
<Show when={!recentTootChunk()?.done}>
|
<ListItemText>Open in browser...</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onClick={() => share({ url: profile()?.url })}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<Share />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText>Share...</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
"text-align": "center",
|
height: `${268 * (Math.min(560, windowSize.width) / 560)}px`,
|
||||||
"padding-bottom": "var(--safe-area-inset-bottom)",
|
}}
|
||||||
|
class="banner"
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
ref={(e) => obx.observe(e)}
|
||||||
|
src={bannerImg()}
|
||||||
|
crossOrigin="anonymous"
|
||||||
|
alt={`Banner image for ${profile()?.displayName || "the user"}`}
|
||||||
|
onLoad={(event) => {
|
||||||
|
const ins = new FastAverageColor();
|
||||||
|
const colors = ins.getColor(event.currentTarget);
|
||||||
|
setBannerSampledColors({
|
||||||
|
average: colors.hex,
|
||||||
|
text: colors.isDark ? "white" : "black",
|
||||||
|
});
|
||||||
|
ins.destroy();
|
||||||
|
}}
|
||||||
|
></img>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Menu {...subscribeMenuState}>
|
||||||
|
<MenuItem
|
||||||
|
onClick={toggleSubscribeHome}
|
||||||
|
aria-label={`${relationship()?.following ? "Unfollow" : "Follow"} on your home timeline`}
|
||||||
|
>
|
||||||
|
<ListItemAvatar>
|
||||||
|
<Avatar src={session().account?.inf?.avatar}></Avatar>
|
||||||
|
</ListItemAvatar>
|
||||||
|
<ListItemText
|
||||||
|
secondary={
|
||||||
|
relationship()?.following
|
||||||
|
? undefined
|
||||||
|
: profile()?.locked
|
||||||
|
? "A request will be sent"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span ref={useSessionDisplayName}></span>
|
||||||
|
<span>'s Home</span>
|
||||||
|
</ListItemText>
|
||||||
|
|
||||||
|
<Checkbox checked={relationship()?.following ?? false} />
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="intro"
|
||||||
|
style={{
|
||||||
|
"background-color": bannerSampledColors()?.average,
|
||||||
|
color: bannerSampledColors()?.text,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconButton
|
<section class="acct-grp">
|
||||||
aria-label="Load More"
|
<Avatar
|
||||||
aria-controls={recentTootListId}
|
src={avatarImg()}
|
||||||
size="large"
|
alt={`${profile()?.displayName || "the user"}'s avatar`}
|
||||||
color="primary"
|
sx={{
|
||||||
onClick={[refetchRecentToots, "prev"]}
|
marginTop: "calc(-16px - 72px / 2)",
|
||||||
disabled={isTootListLoading()}
|
width: "72px",
|
||||||
|
height: "72px",
|
||||||
|
}}
|
||||||
|
></Avatar>
|
||||||
|
<div class="name-grp">
|
||||||
|
<div class="display-name">
|
||||||
|
<Show when={profile()?.bot}>
|
||||||
|
<SmartToySharp class="acct-mark" aria-label="Bot" />
|
||||||
|
</Show>
|
||||||
|
<Show when={profile()?.locked}>
|
||||||
|
<Lock class="acct-mark" aria-label="Locked" />
|
||||||
|
</Show>
|
||||||
|
<Body2
|
||||||
|
component="span"
|
||||||
|
ref={(e: HTMLElement) =>
|
||||||
|
createRenderEffect(() => (e.innerHTML = displayName()))
|
||||||
|
}
|
||||||
|
aria-label="Display name"
|
||||||
|
></Body2>
|
||||||
|
</div>
|
||||||
|
<span aria-label="Complete username" class="username">
|
||||||
|
{fullUsername()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div role="presentation">
|
||||||
|
<Switch>
|
||||||
|
<Match
|
||||||
|
when={
|
||||||
|
!session().account ||
|
||||||
|
profileUncaught.loading ||
|
||||||
|
profileUncaught.error
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{<></>}
|
||||||
|
</Match>
|
||||||
|
<Match when={isCurrentSessionProfile()}>
|
||||||
|
<IconButton color="inherit">
|
||||||
|
<Edit />
|
||||||
|
</IconButton>
|
||||||
|
</Match>
|
||||||
|
<Match when={true}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="secondary"
|
||||||
|
onClick={(event) => {
|
||||||
|
openSubscribeMenu(
|
||||||
|
event.currentTarget.getBoundingClientRect(),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{relationship()?.following ? "Subscribed" : "Subscribe"}
|
||||||
|
</Button>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section
|
||||||
|
class="description"
|
||||||
|
aria-label={`${profile()?.displayName || "the user"}'s description`}
|
||||||
|
ref={(e) =>
|
||||||
|
createRenderEffect(() => (e.innerHTML = description() || ""))
|
||||||
|
}
|
||||||
|
></section>
|
||||||
|
|
||||||
|
<table
|
||||||
|
class="acct-fields"
|
||||||
|
aria-label={`${profile()?.displayName || "the user"}'s fields`}
|
||||||
>
|
>
|
||||||
<Show when={isTootListLoading()} fallback={<ExpandMore />}>
|
<tbody>
|
||||||
<CircularProgress sx={{ width: "24px", height: "24px" }} />
|
<For each={profile()?.fields ?? []}>
|
||||||
</Show>
|
{(item, index) => {
|
||||||
</IconButton>
|
return (
|
||||||
|
<tr data-field-index={index()}>
|
||||||
|
<td>{item.name}</td>
|
||||||
|
<td>
|
||||||
|
<Show when={item.verifiedAt}>
|
||||||
|
<Verified />
|
||||||
|
</Show>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
ref={(e) => {
|
||||||
|
createRenderEffect(() => (e.innerHTML = item.value));
|
||||||
|
}}
|
||||||
|
></td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</div>
|
||||||
|
|
||||||
|
<div class="recent-toots" role="presentation">
|
||||||
|
<div class="toot-list-toolbar">
|
||||||
|
<TootFilterButton
|
||||||
|
options={{
|
||||||
|
pinned: "Pinneds",
|
||||||
|
boost: "Boosts",
|
||||||
|
reply: "Replies",
|
||||||
|
original: "Originals",
|
||||||
|
}}
|
||||||
|
applied={recentTootFilter()}
|
||||||
|
onApply={setRecentTootFilter}
|
||||||
|
disabledKeys={["original"]}
|
||||||
|
></TootFilterButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TimeSourceProvider value={time}>
|
||||||
|
<Show when={recentTootFilter().pinned && pinnedToots.list.length > 0}>
|
||||||
|
<TootList
|
||||||
|
threads={pinnedToots.list}
|
||||||
|
onUnknownThread={pinnedToots.getPath}
|
||||||
|
onChangeToot={pinnedToots.set}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
</Show>
|
||||||
|
<TootList
|
||||||
|
id={recentTootListId}
|
||||||
|
threads={recentToots.list}
|
||||||
|
onUnknownThread={recentToots.getPath}
|
||||||
|
onChangeToot={recentToots.set}
|
||||||
|
/>
|
||||||
|
</TimeSourceProvider>
|
||||||
|
|
||||||
|
<Show when={!recentTootChunk()?.done}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
"text-align": "center",
|
||||||
|
"padding-bottom": "var(--safe-area-inset-bottom)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
aria-label="Load More"
|
||||||
|
aria-controls={recentTootListId}
|
||||||
|
size="large"
|
||||||
|
color="primary"
|
||||||
|
onClick={[refetchRecentToots, "prev"]}
|
||||||
|
disabled={isTootListLoading()}
|
||||||
|
>
|
||||||
|
<Show when={isTootListLoading()} fallback={<ExpandMore />}>
|
||||||
|
<CircularProgress sx={{ width: "24px", height: "24px" }} />
|
||||||
|
</Show>
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
</Scaffold>
|
</Scaffold>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Button, MenuItem, Checkbox, ListItemText } from "@suid/material";
|
import { Button, MenuItem, Checkbox, ListItemText } from "@suid/material";
|
||||||
import { createMemo, createSignal, createUniqueId, For } from "solid-js";
|
import { createMemo, createSignal, createUniqueId, For } from "solid-js";
|
||||||
import Menu from "../material/Menu";
|
import Menu from "~material/Menu";
|
||||||
import { FilterList, FilterListOff } from "@suid/icons-material";
|
import { FilterList, FilterListOff } from "@suid/icons-material";
|
||||||
|
|
||||||
type Props<Filters extends Record<string, string>> = {
|
type Props<Filters extends Record<string, string>> = {
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { createMemo, For, type Component, type JSX } from "solid-js";
|
import { createMemo, For, type Component, type JSX } from "solid-js";
|
||||||
import Scaffold from "../material/Scaffold";
|
import Scaffold from "~material/Scaffold";
|
||||||
import {
|
import {
|
||||||
AppBar,
|
AppBar,
|
||||||
IconButton,
|
IconButton,
|
||||||
|
@ -19,17 +19,17 @@ import {
|
||||||
autoMatchLangTag,
|
autoMatchLangTag,
|
||||||
createTranslator,
|
createTranslator,
|
||||||
SUPPORTED_LANGS,
|
SUPPORTED_LANGS,
|
||||||
} from "../platform/i18n";
|
} from "~platform/i18n";
|
||||||
import { Title } from "../material/typography";
|
import { Title } from "~material/typography";
|
||||||
import type { Template } from "@solid-primitives/i18n";
|
import type { Template } from "@solid-primitives/i18n";
|
||||||
import { useStore } from "@nanostores/solid";
|
import { useStore } from "@nanostores/solid";
|
||||||
import { $settings } from "./stores";
|
import { $settings } from "./stores";
|
||||||
import { useNavigator } from "../platform/StackedRouter";
|
import { useNavigator } from "~platform/StackedRouter";
|
||||||
|
|
||||||
const ChooseLang: Component = () => {
|
const ChooseLang: Component = () => {
|
||||||
const { pop } = useNavigator();
|
const { pop } = useNavigator();
|
||||||
const [t] = createTranslator(
|
const [t] = createTranslator(
|
||||||
() => import("./i18n/lang-names.json"),
|
() => import("./i18n/generic.json"),
|
||||||
(code) =>
|
(code) =>
|
||||||
import(`./i18n/${code}.json`) as Promise<{
|
import(`./i18n/${code}.json`) as Promise<{
|
||||||
default: Record<string, string | undefined> & {
|
default: Record<string, string | undefined> & {
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Component } from "solid-js";
|
import type { Component } from "solid-js";
|
||||||
import Scaffold from "../material/Scaffold";
|
import Scaffold from "~material/Scaffold";
|
||||||
import {
|
import {
|
||||||
AppBar,
|
AppBar,
|
||||||
Divider,
|
Divider,
|
||||||
|
@ -12,12 +12,12 @@ import {
|
||||||
Switch,
|
Switch,
|
||||||
Toolbar,
|
Toolbar,
|
||||||
} from "@suid/material";
|
} from "@suid/material";
|
||||||
import { Title } from "../material/typography";
|
import { Title } from "~material/typography";
|
||||||
import { ArrowBack } from "@suid/icons-material";
|
import { ArrowBack } from "@suid/icons-material";
|
||||||
import { createTranslator } from "../platform/i18n";
|
import { createTranslator } from "~platform/i18n";
|
||||||
import { useStore } from "@nanostores/solid";
|
import { useStore } from "@nanostores/solid";
|
||||||
import { $settings } from "./stores";
|
import { $settings } from "./stores";
|
||||||
import { useNavigator } from "../platform/StackedRouter";
|
import { useNavigator } from "~platform/StackedRouter";
|
||||||
|
|
||||||
const Motions: Component = () => {
|
const Motions: Component = () => {
|
||||||
const {pop} = useNavigator();
|
const {pop} = useNavigator();
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { createMemo, For, type Component, type JSX } from "solid-js";
|
import { createMemo, For, type Component, type JSX } from "solid-js";
|
||||||
import Scaffold from "../material/Scaffold";
|
import Scaffold from "~material/Scaffold";
|
||||||
import {
|
import {
|
||||||
AppBar,
|
AppBar,
|
||||||
IconButton,
|
IconButton,
|
||||||
|
@ -17,17 +17,17 @@ import {
|
||||||
autoMatchRegion,
|
autoMatchRegion,
|
||||||
createTranslator,
|
createTranslator,
|
||||||
SUPPORTED_REGIONS,
|
SUPPORTED_REGIONS,
|
||||||
} from "../platform/i18n";
|
} from "~platform/i18n";
|
||||||
import { Title } from "../material/typography";
|
import { Title } from "~material/typography";
|
||||||
import type { Template } from "@solid-primitives/i18n";
|
import type { Template } from "@solid-primitives/i18n";
|
||||||
import { $settings } from "./stores";
|
import { $settings } from "./stores";
|
||||||
import { useStore } from "@nanostores/solid";
|
import { useStore } from "@nanostores/solid";
|
||||||
import { useNavigator } from "../platform/StackedRouter";
|
import { useNavigator } from "~platform/StackedRouter";
|
||||||
|
|
||||||
const ChooseRegion: Component = () => {
|
const ChooseRegion: Component = () => {
|
||||||
const {pop} = useNavigator();
|
const {pop} = useNavigator();
|
||||||
const [t] = createTranslator(
|
const [t] = createTranslator(
|
||||||
() => import("./i18n/lang-names.json"),
|
() => import("./i18n/generic.json"),
|
||||||
(code) =>
|
(code) =>
|
||||||
import(`./i18n/${code}.json`) as Promise<{
|
import(`./i18n/${code}.json`) as Promise<{
|
||||||
default: Record<string, string | undefined> & {
|
default: Record<string, string | undefined> & {
|
||||||
|
|
|
@ -1,9 +1,5 @@
|
||||||
import {
|
import { For, Show, type Component } from "solid-js";
|
||||||
For,
|
import Scaffold from "~material/Scaffold.js";
|
||||||
Show,
|
|
||||||
type Component,
|
|
||||||
} from "solid-js";
|
|
||||||
import Scaffold from "../material/Scaffold.js";
|
|
||||||
import {
|
import {
|
||||||
AppBar,
|
AppBar,
|
||||||
Divider,
|
Divider,
|
||||||
|
@ -27,8 +23,8 @@ import {
|
||||||
Refresh as RefreshIcon,
|
Refresh as RefreshIcon,
|
||||||
Translate as TranslateIcon,
|
Translate as TranslateIcon,
|
||||||
} from "@suid/icons-material";
|
} from "@suid/icons-material";
|
||||||
import A from "../platform/A.js";
|
import A from "~platform/A.js";
|
||||||
import { Title } from "../material/typography.jsx";
|
import { Title } from "~material/typography.js";
|
||||||
import { css } from "solid-styled";
|
import { css } from "solid-styled";
|
||||||
import { signOut, type Account } from "../accounts/stores.js";
|
import { signOut, type Account } from "../accounts/stores.js";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
|
@ -39,11 +35,11 @@ import {
|
||||||
autoMatchRegion,
|
autoMatchRegion,
|
||||||
createTranslator,
|
createTranslator,
|
||||||
useDateFnLocale,
|
useDateFnLocale,
|
||||||
} from "../platform/i18n.jsx";
|
} from "~platform/i18n.jsx";
|
||||||
import { type Template } from "@solid-primitives/i18n";
|
import { type Template } from "@solid-primitives/i18n";
|
||||||
import { useServiceWorker } from "../platform/host.js";
|
import { useServiceWorker } from "~platform/host.js";
|
||||||
import { useSessions } from "../masto/clients.js";
|
import { useSessions } from "../masto/clients.js";
|
||||||
import { useNavigator } from "../platform/StackedRouter.jsx";
|
import { useNavigator } from "~platform/StackedRouter.jsx";
|
||||||
|
|
||||||
type Inset = {
|
type Inset = {
|
||||||
top?: number;
|
top?: number;
|
||||||
|
@ -165,9 +161,9 @@ const Settings: Component = () => {
|
||||||
import(`./i18n/${code}.json`) as Promise<{
|
import(`./i18n/${code}.json`) as Promise<{
|
||||||
default: Strings;
|
default: Strings;
|
||||||
}>,
|
}>,
|
||||||
() => import(`./i18n/lang-names.json`),
|
() => import(`./i18n/generic.json`),
|
||||||
);
|
);
|
||||||
const {pop} = useNavigator();
|
const { pop } = useNavigator();
|
||||||
const settings$ = useStore($settings);
|
const settings$ = useStore($settings);
|
||||||
const { needRefresh } = useServiceWorker();
|
const { needRefresh } = useServiceWorker();
|
||||||
const dateFnLocale = useDateFnLocale();
|
const dateFnLocale = useDateFnLocale();
|
||||||
|
@ -185,6 +181,9 @@ const Settings: Component = () => {
|
||||||
|
|
||||||
.setting-list {
|
.setting-list {
|
||||||
padding-bottom: calc(var(--safe-area-inset-bottom, 0px) + 16px);
|
padding-bottom: calc(var(--safe-area-inset-bottom, 0px) + 16px);
|
||||||
|
overflow: hidden auto;
|
||||||
|
height: calc(100vh - var(--scaffold-topbar-height, 0));
|
||||||
|
height: calc(100dvh - var(--scaffold-topbar-height, 0));
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -4,7 +4,7 @@ import {
|
||||||
type Component,
|
type Component,
|
||||||
type JSX,
|
type JSX,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import Scaffold from "../material/Scaffold";
|
import Scaffold from "~material/Scaffold";
|
||||||
import {
|
import {
|
||||||
AppBar,
|
AppBar,
|
||||||
IconButton,
|
IconButton,
|
||||||
|
@ -17,8 +17,8 @@ import {
|
||||||
} from "@suid/material";
|
} from "@suid/material";
|
||||||
import { Close as CloseIcon } from "@suid/icons-material";
|
import { Close as CloseIcon } from "@suid/icons-material";
|
||||||
import iso639_1 from "iso-639-1";
|
import iso639_1 from "iso-639-1";
|
||||||
import { createTranslator } from "../platform/i18n";
|
import { createTranslator } from "~platform/i18n";
|
||||||
import { Title } from "../material/typography";
|
import { Title } from "~material/typography";
|
||||||
|
|
||||||
type ChooseTootLangProps = {
|
type ChooseTootLangProps = {
|
||||||
code: string;
|
code: string;
|
||||||
|
|
|
@ -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,
|
createRenderEffect,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import { useDocumentTitle } from "../utils";
|
import { useDocumentTitle } from "../utils";
|
||||||
import Scaffold from "../material/Scaffold";
|
import Scaffold from "~material/Scaffold";
|
||||||
import {
|
import {
|
||||||
AppBar,
|
AppBar,
|
||||||
ListItemSecondaryAction,
|
ListItemSecondaryAction,
|
||||||
|
@ -16,10 +16,10 @@ import {
|
||||||
Toolbar,
|
Toolbar,
|
||||||
} from "@suid/material";
|
} from "@suid/material";
|
||||||
import { css } from "solid-styled";
|
import { css } from "solid-styled";
|
||||||
import { TimeSourceProvider, createTimeSource } from "../platform/timesrc";
|
import { TimeSourceProvider, createTimeSource } from "~platform/timesrc";
|
||||||
import ProfileMenuButton from "./ProfileMenuButton";
|
import ProfileMenuButton from "./ProfileMenuButton";
|
||||||
import Tabs from "../material/Tabs";
|
import Tabs from "~material/Tabs";
|
||||||
import Tab from "../material/Tab";
|
import Tab from "~material/Tab";
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener";
|
import { makeEventListener } from "@solid-primitives/event-listener";
|
||||||
import { $settings } from "../settings/stores";
|
import { $settings } from "../settings/stores";
|
||||||
import { useStore } from "@nanostores/solid";
|
import { useStore } from "@nanostores/solid";
|
||||||
|
|
|
@ -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,
|
ListItemAvatar,
|
||||||
ListItemIcon,
|
ListItemIcon,
|
||||||
ListItemText,
|
ListItemText,
|
||||||
Menu,
|
|
||||||
MenuItem,
|
MenuItem,
|
||||||
} from "@suid/material";
|
} from "@suid/material";
|
||||||
import {
|
import { Show, createUniqueId, type ParentComponent } from "solid-js";
|
||||||
ErrorBoundary,
|
|
||||||
Show,
|
|
||||||
createSignal,
|
|
||||||
createUniqueId,
|
|
||||||
type ParentComponent,
|
|
||||||
} from "solid-js";
|
|
||||||
import {
|
import {
|
||||||
Settings as SettingsIcon,
|
Settings as SettingsIcon,
|
||||||
Bookmark as BookmarkIcon,
|
Bookmark as BookmarkIcon,
|
||||||
Star as LikeIcon,
|
Star as LikeIcon,
|
||||||
FeaturedPlayList as ListIcon,
|
FeaturedPlayList as ListIcon,
|
||||||
} from "@suid/icons-material";
|
} from "@suid/icons-material";
|
||||||
import A from "../platform/A";
|
import A from "~platform/A";
|
||||||
|
import Menu, { createManagedMenuState } from "~material/Menu";
|
||||||
|
|
||||||
const ProfileMenuButton: ParentComponent<{
|
const ProfileMenuButton: ParentComponent<{
|
||||||
profile?: {
|
profile?: {
|
||||||
|
@ -35,29 +29,18 @@ const ProfileMenuButton: ParentComponent<{
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
onClick?: () => void;
|
|
||||||
onClose?: () => void;
|
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const menuId = createUniqueId();
|
const menuId = createUniqueId();
|
||||||
const buttonId = createUniqueId();
|
const buttonId = createUniqueId();
|
||||||
|
|
||||||
let [anchor, setAnchor] = createSignal<HTMLButtonElement | null>(null);
|
const [open, state] = createManagedMenuState();
|
||||||
const open = () => !!anchor();
|
|
||||||
|
|
||||||
const onClick = (
|
const onClick = (event: { currentTarget: HTMLElement }) => {
|
||||||
event: MouseEvent & { currentTarget: HTMLButtonElement },
|
open(event.currentTarget.getBoundingClientRect());
|
||||||
) => {
|
|
||||||
setAnchor(event.currentTarget);
|
|
||||||
props.onClick?.();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const inf = () => props.profile?.account.inf;
|
const inf = () => props.profile?.account.inf;
|
||||||
|
|
||||||
const onClose = () => {
|
|
||||||
props.onClick?.();
|
|
||||||
setAnchor(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ButtonBase
|
<ButtonBase
|
||||||
|
@ -65,8 +48,8 @@ const ProfileMenuButton: ParentComponent<{
|
||||||
sx={{ borderRadius: "50%" }}
|
sx={{ borderRadius: "50%" }}
|
||||||
id={buttonId}
|
id={buttonId}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
aria-controls={open() ? menuId : undefined}
|
aria-controls={state.open ? menuId : undefined}
|
||||||
aria-expanded={open() ? "true" : undefined}
|
aria-expanded={state.open ? "true" : "false"}
|
||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
alt={`${inf()?.displayName}'s avatar`}
|
alt={`${inf()?.displayName}'s avatar`}
|
||||||
|
@ -75,23 +58,13 @@ const ProfileMenuButton: ParentComponent<{
|
||||||
</ButtonBase>
|
</ButtonBase>
|
||||||
<Menu
|
<Menu
|
||||||
id={menuId}
|
id={menuId}
|
||||||
anchorEl={anchor()}
|
|
||||||
open={open()}
|
|
||||||
onClose={onClose}
|
|
||||||
MenuListProps={{
|
MenuListProps={{
|
||||||
"aria-labelledby": buttonId,
|
"aria-labelledby": menuId,
|
||||||
sx: {
|
style: {
|
||||||
minWidth: "220px",
|
"min-width": "220px",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
anchorOrigin={{
|
{...state}
|
||||||
vertical: "top",
|
|
||||||
horizontal: "right",
|
|
||||||
}}
|
|
||||||
transformOrigin={{
|
|
||||||
vertical: "top",
|
|
||||||
horizontal: "right",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
component={A}
|
component={A}
|
||||||
|
|
|
@ -10,7 +10,7 @@ import { Refresh as RefreshIcon } from "@suid/icons-material";
|
||||||
import { CircularProgress } from "@suid/material";
|
import { CircularProgress } from "@suid/material";
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener";
|
import { makeEventListener } from "@solid-primitives/event-listener";
|
||||||
import { createVisibilityObserver } from "@solid-primitives/intersection-observer";
|
import { createVisibilityObserver } from "@solid-primitives/intersection-observer";
|
||||||
import { useMaybeIsFrameSuspended } from "../platform/StackedRouter";
|
import { useIsFrameSuspended } from "~platform/StackedRouter";
|
||||||
|
|
||||||
const PullDownToRefresh: Component<{
|
const PullDownToRefresh: Component<{
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
@ -34,7 +34,7 @@ const PullDownToRefresh: Component<{
|
||||||
});
|
});
|
||||||
|
|
||||||
const rootVisible = obvx(() => rootElement);
|
const rootVisible = obvx(() => rootElement);
|
||||||
const isFrameSuspended = useMaybeIsFrameSuspended()
|
const isFrameSuspended = useIsFrameSuspended()
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!rootVisible()) setPullDown(0);
|
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,
|
type JSX,
|
||||||
Show,
|
Show,
|
||||||
createRenderEffect,
|
createRenderEffect,
|
||||||
|
createSignal,
|
||||||
|
type Setter,
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import tootStyle from "./toot.module.css";
|
import tootStyle from "./toot.module.css";
|
||||||
import { formatRelative } from "date-fns";
|
import { formatRelative } from "date-fns";
|
||||||
import Img from "../material/Img.js";
|
import Img from "~material/Img.js";
|
||||||
import { Body2 } from "../material/typography.js";
|
import { Body2 } from "~material/typography.js";
|
||||||
import { css } from "solid-styled";
|
import { SmartToySharp, Lock } from "@suid/icons-material";
|
||||||
import {
|
import { useTimeSource } from "~platform/timesrc.js";
|
||||||
BookmarkAddOutlined,
|
|
||||||
Repeat,
|
|
||||||
ReplyAll,
|
|
||||||
Star,
|
|
||||||
StarOutline,
|
|
||||||
Bookmark,
|
|
||||||
Share,
|
|
||||||
SmartToySharp,
|
|
||||||
Lock,
|
|
||||||
} from "@suid/icons-material";
|
|
||||||
import { useTimeSource } from "../platform/timesrc.js";
|
|
||||||
import { resolveCustomEmoji } from "../masto/toot.js";
|
import { resolveCustomEmoji } from "../masto/toot.js";
|
||||||
import { Divider } from "@suid/material";
|
import { Divider } from "@suid/material";
|
||||||
import cardStyle from "../material/cards.module.css";
|
import cardStyle from "~material/cards.module.css";
|
||||||
import Button from "../material/Button.js";
|
import MediaAttachmentGrid from "./toots/MediaAttachmentGrid.jsx";
|
||||||
import MediaAttachmentGrid from "./MediaAttachmentGrid.js";
|
import { useDateFnLocale } from "~platform/i18n";
|
||||||
import { useDateFnLocale } from "../platform/i18n";
|
|
||||||
import { canShare, share } from "../platform/share";
|
|
||||||
import { makeAcctText, useDefaultSession } from "../masto/clients";
|
import { makeAcctText, useDefaultSession } from "../masto/clients";
|
||||||
import TootContent from "./toot-components/TootContent";
|
import TootContent from "./toots/TootContent";
|
||||||
import BoostIcon from "./toot-components/BoostIcon";
|
import BoostIcon from "./toots/BoostIcon";
|
||||||
import PreviewCard from "./toot-components/PreviewCard";
|
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> = {
|
export type TootEnv = {
|
||||||
onRetoot?: (value: T) => void;
|
boost: (value: mastodon.v1.Status) => void;
|
||||||
onFavourite?: (value: T) => void;
|
favourite: (value: mastodon.v1.Status) => void;
|
||||||
onBookmark?: (value: T) => void;
|
bookmark: (value: mastodon.v1.Status) => void;
|
||||||
onReply?: (
|
reply?: (
|
||||||
value: T,
|
value: mastodon.v1.Status,
|
||||||
event: MouseEvent & { currentTarget: HTMLButtonElement },
|
event: MouseEvent & { currentTarget: HTMLButtonElement },
|
||||||
) => void;
|
) => 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;
|
status: mastodon.v1.Status;
|
||||||
actionable?: boolean;
|
actionable?: boolean;
|
||||||
evaluated?: boolean;
|
evaluated?: boolean;
|
||||||
thread?: "top" | "bottom" | "middle";
|
thread?: "top" | "bottom" | "middle";
|
||||||
} & TootActionGroupProps<mastodon.v1.Status> &
|
} & JSX.HTMLElementTags["article"];
|
||||||
JSX.HTMLElementTags["article"];
|
|
||||||
|
|
||||||
function isolatedCallback(e: MouseEvent) {
|
|
||||||
e.stopPropagation();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function findRootToot(element: HTMLElement) {
|
export function findRootToot(element: HTMLElement) {
|
||||||
let current: HTMLElement | null = element;
|
let current: HTMLElement | null = element;
|
||||||
|
@ -70,73 +77,6 @@ export function findRootToot(element: HTMLElement) {
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TootActionGroup<T extends mastodon.v1.Status>(
|
|
||||||
props: TootActionGroupProps<T> & { value: T },
|
|
||||||
) {
|
|
||||||
let actGrpElement: HTMLDivElement;
|
|
||||||
const toot = () => props.value;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={actGrpElement!}
|
|
||||||
class={tootStyle.tootBottomActionGrp}
|
|
||||||
onClick={isolatedCallback}
|
|
||||||
>
|
|
||||||
<Show when={props.onReply}>
|
|
||||||
<Button
|
|
||||||
class={tootStyle.tootActionWithCount}
|
|
||||||
onClick={[props.onReply!, props.value]}
|
|
||||||
>
|
|
||||||
<ReplyAll />
|
|
||||||
<span>{toot().repliesCount}</span>
|
|
||||||
</Button>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
class={tootStyle.tootActionWithCount}
|
|
||||||
style={{
|
|
||||||
color: toot().reblogged ? "var(--tutu-color-primary)" : undefined,
|
|
||||||
}}
|
|
||||||
onClick={() => props.onRetoot?.(toot())}
|
|
||||||
>
|
|
||||||
<Repeat />
|
|
||||||
<span>{toot().reblogsCount}</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
class={tootStyle.tootActionWithCount}
|
|
||||||
style={{
|
|
||||||
color: toot().favourited ? "var(--tutu-color-primary)" : undefined,
|
|
||||||
}}
|
|
||||||
onClick={() => props.onFavourite?.(toot())}
|
|
||||||
>
|
|
||||||
{toot().favourited ? <Star /> : <StarOutline />}
|
|
||||||
<span>{toot().favouritesCount}</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
class={tootStyle.tootAction}
|
|
||||||
style={{
|
|
||||||
color: toot().bookmarked ? "var(--tutu-color-primary)" : undefined,
|
|
||||||
}}
|
|
||||||
onClick={() => props.onBookmark?.(toot())}
|
|
||||||
>
|
|
||||||
{toot().bookmarked ? <Bookmark /> : <BookmarkAddOutlined />}
|
|
||||||
</Button>
|
|
||||||
<Show when={canShare({ url: toot().url ?? undefined })}>
|
|
||||||
<Button
|
|
||||||
class={tootStyle.tootAction}
|
|
||||||
aria-label="Share"
|
|
||||||
onClick={async () => {
|
|
||||||
await share({
|
|
||||||
url: toot().url ?? undefined,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Share />
|
|
||||||
</Button>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TootAuthorGroup(
|
function TootAuthorGroup(
|
||||||
props: {
|
props: {
|
||||||
status: mastodon.v1.Status;
|
status: mastodon.v1.Status;
|
||||||
|
@ -200,6 +140,11 @@ export function findElementActionable(
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onToggleReveal(setValue: Setter<boolean>, event: Event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
setValue((x) => !x);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Component for a toot.
|
* Component for a toot.
|
||||||
*
|
*
|
||||||
|
@ -207,6 +152,8 @@ export function findElementActionable(
|
||||||
* this component under a `<DefaultSessionProvier />` with correct
|
* this component under a `<DefaultSessionProvier />` with correct
|
||||||
* session.
|
* session.
|
||||||
*
|
*
|
||||||
|
* This component requires be under `<TootEnvProvider />`.
|
||||||
|
*
|
||||||
* **Handling Clicks**
|
* **Handling Clicks**
|
||||||
* There are multiple actions supported in the component. Some handlers
|
* There are multiple actions supported in the component. Some handlers
|
||||||
* are passed in, some should be handled as the click event.
|
* are passed in, some should be handled as the click event.
|
||||||
|
@ -228,78 +175,39 @@ export function findElementActionable(
|
||||||
* You can extract the intent from the attributes of the "actionable" element.
|
* You can extract the intent from the attributes of the "actionable" element.
|
||||||
* The action type is the dataset's `action`.
|
* The action type is the dataset's `action`.
|
||||||
*/
|
*/
|
||||||
const RegularToot: Component<TootCardProps> = (props) => {
|
const RegularToot: Component<RegularTootProps> = (oprops) => {
|
||||||
let rootRef: HTMLElement;
|
let rootRef: HTMLElement;
|
||||||
const [managed, managedActionGroup, rest] = splitProps(
|
const [props, rest] = splitProps(oprops, [
|
||||||
props,
|
"status",
|
||||||
["status", "lang", "class", "actionable", "evaluated", "thread"],
|
"lang",
|
||||||
["onRetoot", "onFavourite", "onBookmark", "onReply"],
|
"class",
|
||||||
);
|
"actionable",
|
||||||
|
"evaluated",
|
||||||
|
"thread",
|
||||||
|
]);
|
||||||
const now = useTimeSource();
|
const now = useTimeSource();
|
||||||
const status = () => managed.status;
|
const status = () => props.status;
|
||||||
const toot = () => status().reblog ?? status();
|
const toot = () => status().reblog ?? status();
|
||||||
const session = useDefaultSession();
|
const session = useDefaultSession();
|
||||||
|
const [reveal, setReveal] = createSignal(false);
|
||||||
css`
|
|
||||||
.reply-sep {
|
|
||||||
margin-left: calc(var(--toot-avatar-size) + var(--card-pad) + 8px);
|
|
||||||
margin-block: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thread-top,
|
|
||||||
.thread-mid,
|
|
||||||
.thread-btm {
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
left: 36px;
|
|
||||||
background-color: var(--tutu-color-secondary);
|
|
||||||
width: 2px;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.thread-mid {
|
|
||||||
&::before {
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.thread-top {
|
|
||||||
&::before {
|
|
||||||
top: 16px;
|
|
||||||
bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.thread-btm {
|
|
||||||
&::before {
|
|
||||||
top: 0;
|
|
||||||
height: 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section
|
<article
|
||||||
classList={{
|
classList={{
|
||||||
[tootStyle.toot]: true,
|
"RegularToot": true,
|
||||||
[tootStyle.expanded]: managed.evaluated,
|
"expanded": props.evaluated,
|
||||||
"thread-top": managed.thread === "top",
|
"thread-top": props.thread === "top",
|
||||||
"thread-mid": managed.thread === "middle",
|
"thread-mid": props.thread === "middle",
|
||||||
"thread-btm": managed.thread === "bottom",
|
"thread-btm": props.thread === "bottom",
|
||||||
[managed.class || ""]: true,
|
[props.class || ""]: true,
|
||||||
}}
|
}}
|
||||||
ref={rootRef!}
|
ref={rootRef!}
|
||||||
lang={toot().language || managed.lang}
|
lang={toot().language || props.lang}
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
<Show when={!!status().reblog}>
|
<Show when={!!status().reblog}>
|
||||||
<div class={tootStyle.tootRetootGrp}>
|
<div class="retoot-grp">
|
||||||
<BoostIcon />
|
<BoostIcon />
|
||||||
<Body2
|
<Body2
|
||||||
ref={(e: { innerHTML: string }) => {
|
ref={(e: { innerHTML: string }) => {
|
||||||
|
@ -325,22 +233,36 @@ const RegularToot: Component<TootCardProps> = (props) => {
|
||||||
source={toot().content}
|
source={toot().content}
|
||||||
emojis={toot().emojis}
|
emojis={toot().emojis}
|
||||||
mentions={toot().mentions}
|
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!} />
|
<PreviewCard src={toot().card!} />
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={toot().mediaAttachments.length > 0}>
|
<Show when={toot().mediaAttachments.length > 0}>
|
||||||
<MediaAttachmentGrid attachments={toot().mediaAttachments} />
|
<MediaAttachmentGrid
|
||||||
|
attachments={toot().mediaAttachments}
|
||||||
|
sensitive={toot().sensitive}
|
||||||
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={managed.actionable}>
|
<Show when={toot().poll}>
|
||||||
|
<TootPoll value={toot().poll!} status={toot()} />
|
||||||
|
</Show>
|
||||||
|
<Show when={props.actionable}>
|
||||||
<Divider
|
<Divider
|
||||||
class={cardStyle.cardNoPad}
|
class={cardStyle.cardNoPad}
|
||||||
style={{ "margin-top": "8px" }}
|
style={{ "margin-top": "8px" }}
|
||||||
/>
|
/>
|
||||||
<TootActionGroup value={toot()} {...managedActionGroup} />
|
<TootActionGroup value={toot()} class={cardStyle.cardGutSkip} />
|
||||||
</Show>
|
</Show>
|
||||||
</section>
|
</article>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -7,25 +7,28 @@ import {
|
||||||
Show,
|
Show,
|
||||||
type Component,
|
type Component,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import Scaffold from "../material/Scaffold";
|
import Scaffold from "~material/Scaffold";
|
||||||
import { AppBar, CircularProgress, IconButton, Toolbar } from "@suid/material";
|
import { AppBar, CircularProgress, IconButton, Toolbar } from "@suid/material";
|
||||||
import { Title } from "../material/typography";
|
import { Title } from "~material/typography";
|
||||||
import { Close as CloseIcon } from "@suid/icons-material";
|
import { Close as CloseIcon } from "@suid/icons-material";
|
||||||
import { useSessionForAcctStr } from "../masto/clients";
|
import { useSessionForAcctStr } from "../masto/clients";
|
||||||
import { resolveCustomEmoji } from "../masto/toot";
|
import { resolveCustomEmoji } from "../masto/toot";
|
||||||
import RegularToot, { findElementActionable } from "./RegularToot";
|
import RegularToot, {
|
||||||
|
findElementActionable,
|
||||||
|
TootEnvProvider,
|
||||||
|
} from "./RegularToot";
|
||||||
import type { mastodon } from "masto";
|
import type { mastodon } from "masto";
|
||||||
import cards from "../material/cards.module.css";
|
import cards from "~material/cards.module.css";
|
||||||
import { css } from "solid-styled";
|
import { css } from "solid-styled";
|
||||||
import { vibrate } from "../platform/hardware";
|
import { vibrate } from "~platform/hardware";
|
||||||
import { createTimeSource, TimeSourceProvider } from "../platform/timesrc";
|
import { createTimeSource, TimeSourceProvider } from "~platform/timesrc";
|
||||||
import TootComposer from "./TootComposer";
|
import TootComposer from "./TootComposer";
|
||||||
import { useDocumentTitle } from "../utils";
|
import { useDocumentTitle } from "../utils";
|
||||||
import { createTimelineControlsForArray } from "../masto/timelines";
|
import { createTimelineControlsForArray } from "../masto/timelines";
|
||||||
import TootList from "./TootList";
|
import TootList from "./TootList";
|
||||||
import "./TootBottomSheet.css";
|
import "./TootBottomSheet.css";
|
||||||
import { useNavigator } from "../platform/StackedRouter";
|
import { useNavigator } from "~platform/StackedRouter";
|
||||||
import BackButton from "../platform/BackButton";
|
import BackButton from "~platform/BackButton";
|
||||||
|
|
||||||
let cachedEntry: [string, mastodon.v1.Status] | undefined;
|
let cachedEntry: [string, mastodon.v1.Status] | undefined;
|
||||||
|
|
||||||
|
@ -169,6 +172,33 @@ const TootBottomSheet: Component = (props) => {
|
||||||
return Array.from(new Set(values).keys());
|
return Array.from(new Set(values).keys());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const vote = async (status: mastodon.v1.Status, votes: readonly number[]) => {
|
||||||
|
const client = session()?.client;
|
||||||
|
if (!client) return;
|
||||||
|
|
||||||
|
const toot = status.reblog ?? status;
|
||||||
|
if (!toot.poll) return;
|
||||||
|
|
||||||
|
const npoll = await client.v1.polls.$select(toot.poll.id).votes.create({
|
||||||
|
choices: votes,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (status.reblog) {
|
||||||
|
setRemoteToot({
|
||||||
|
...status,
|
||||||
|
reblog: {
|
||||||
|
...status.reblog,
|
||||||
|
poll: npoll,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setRemoteToot({
|
||||||
|
...status,
|
||||||
|
poll: npoll,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleMainTootClick = (
|
const handleMainTootClick = (
|
||||||
event: MouseEvent & { currentTarget: HTMLElement },
|
event: MouseEvent & { currentTarget: HTMLElement },
|
||||||
) => {
|
) => {
|
||||||
|
@ -255,21 +285,29 @@ const TootBottomSheet: Component = (props) => {
|
||||||
|
|
||||||
<article>
|
<article>
|
||||||
<Show when={toot()}>
|
<Show when={toot()}>
|
||||||
<RegularToot
|
<TootEnvProvider
|
||||||
id={`toot-${toot()!.id}`}
|
value={{
|
||||||
class={cards.card}
|
bookmark: onBookmark,
|
||||||
style={{
|
boost: onBoost,
|
||||||
"scroll-margin-top":
|
favourite: onFav,
|
||||||
"calc(var(--scaffold-topbar-height) + 20px)",
|
vote,
|
||||||
}}
|
}}
|
||||||
status={toot()!}
|
>
|
||||||
actionable={!!actSession()}
|
<RegularToot
|
||||||
evaluated={true}
|
id={`toot-${toot()!.id}`}
|
||||||
onBookmark={onBookmark}
|
class={cards.card}
|
||||||
onRetoot={onBoost}
|
style={{
|
||||||
onFavourite={onFav}
|
"scroll-margin-top":
|
||||||
onClick={handleMainTootClick}
|
"calc(var(--scaffold-topbar-height) + 20px)",
|
||||||
></RegularToot>
|
cursor: "auto",
|
||||||
|
"user-select": "auto",
|
||||||
|
}}
|
||||||
|
status={toot()!}
|
||||||
|
actionable={!!actSession()}
|
||||||
|
evaluated={true}
|
||||||
|
onClick={handleMainTootClick}
|
||||||
|
></RegularToot>
|
||||||
|
</TootEnvProvider>
|
||||||
</Show>
|
</Show>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
|
|
@ -9,7 +9,7 @@ import {
|
||||||
type JSX,
|
type JSX,
|
||||||
type Ref,
|
type Ref,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import Scaffold from "../material/Scaffold";
|
import Scaffold from "~material/Scaffold";
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Button,
|
Button,
|
||||||
|
@ -41,16 +41,16 @@ import {
|
||||||
} from "@suid/icons-material";
|
} from "@suid/icons-material";
|
||||||
import type { Account } from "../accounts/stores";
|
import type { Account } from "../accounts/stores";
|
||||||
import "./TootComposer.css";
|
import "./TootComposer.css";
|
||||||
import BottomSheet from "../material/BottomSheet";
|
import BottomSheet from "~material/BottomSheet";
|
||||||
import { useLanguage } from "../platform/i18n";
|
import { useAppLocale } from "~platform/i18n";
|
||||||
import iso639_1 from "iso-639-1";
|
import iso639_1 from "iso-639-1";
|
||||||
import ChooseTootLang from "./ChooseTootLang";
|
import ChooseTootLang from "./ChooseTootLang";
|
||||||
import type { mastodon } from "masto";
|
import type { mastodon } from "masto";
|
||||||
import cardStyles from "../material/cards.module.css";
|
import cardStyles from "~material/cards.module.css";
|
||||||
import Menu, { createManagedMenuState } from "../material/Menu";
|
import Menu, { createManagedMenuState } from "~material/Menu";
|
||||||
import { useDefaultSession } from "../masto/clients";
|
import { useDefaultSession } from "../masto/clients";
|
||||||
import { resolveCustomEmoji } from "../masto/toot";
|
import { resolveCustomEmoji } from "../masto/toot";
|
||||||
import SizedTextarea from "../platform/SizedTextarea";
|
import SizedTextarea from "~platform/SizedTextarea";
|
||||||
|
|
||||||
type TootVisibility = "public" | "unlisted" | "private" | "direct";
|
type TootVisibility = "public" | "unlisted" | "private" | "direct";
|
||||||
|
|
||||||
|
@ -98,7 +98,8 @@ const TootVisibilityPickerDialog: Component<{
|
||||||
style={{
|
style={{
|
||||||
"border-top": "1px solid #ddd",
|
"border-top": "1px solid #ddd",
|
||||||
background: "var(--tutu-color-surface)",
|
background: "var(--tutu-color-surface)",
|
||||||
padding: "8px 16px",
|
padding:
|
||||||
|
"8px 16px calc(8px + var(--safe-area-inset-bottom, 0px))",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
"text-align": "end",
|
"text-align": "end",
|
||||||
}}
|
}}
|
||||||
|
@ -232,7 +233,7 @@ const TootComposer: Component<{
|
||||||
const [permPicker, setPermPicker] = createSignal(false);
|
const [permPicker, setPermPicker] = createSignal(false);
|
||||||
const [language, setLanguage] = createSignal("en");
|
const [language, setLanguage] = createSignal("en");
|
||||||
const [langPickerOpen, setLangPickerOpen] = createSignal(false);
|
const [langPickerOpen, setLangPickerOpen] = createSignal(false);
|
||||||
const appLanguage = useLanguage();
|
const { language: appLanguage } = useAppLocale();
|
||||||
const [openMenu, menuState] = createManagedMenuState();
|
const [openMenu, menuState] = createManagedMenuState();
|
||||||
|
|
||||||
const randomPlaceholder = useRandomChoice(() => [
|
const randomPlaceholder = useRandomChoice(() => [
|
||||||
|
|
|
@ -6,21 +6,21 @@ import {
|
||||||
createSelector,
|
createSelector,
|
||||||
Index,
|
Index,
|
||||||
createMemo,
|
createMemo,
|
||||||
|
For,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import { type mastodon } from "masto";
|
import { type mastodon } from "masto";
|
||||||
import { vibrate } from "../platform/hardware";
|
import { vibrate } from "~platform/hardware";
|
||||||
import { useDefaultSession } from "../masto/clients";
|
import { useDefaultSession } from "../masto/clients";
|
||||||
import { useHeroSource } from "../platform/anim";
|
|
||||||
import { HERO as BOTTOM_SHEET_HERO } from "../material/BottomSheet";
|
|
||||||
import { setCache as setTootBottomSheetCache } from "./TootBottomSheet";
|
import { setCache as setTootBottomSheetCache } from "./TootBottomSheet";
|
||||||
import RegularToot, {
|
import RegularToot, {
|
||||||
findElementActionable,
|
findElementActionable,
|
||||||
findRootToot,
|
findRootToot,
|
||||||
|
TootEnvProvider,
|
||||||
} from "./RegularToot";
|
} from "./RegularToot";
|
||||||
import cardStyle from "../material/cards.module.css";
|
import cardStyle from "~material/cards.module.css";
|
||||||
import type { ThreadNode } from "../masto/timelines";
|
import type { ThreadNode } from "../masto/timelines";
|
||||||
import { useNavigator } from "../platform/StackedRouter";
|
import { useNavigator } from "~platform/StackedRouter";
|
||||||
import { ANIM_CURVE_STD } from "../material/theme";
|
import { ANIM_CURVE_STD } from "~material/theme";
|
||||||
|
|
||||||
function durationOf(rect0: DOMRect, rect1: DOMRect) {
|
function durationOf(rect0: DOMRect, rect1: DOMRect) {
|
||||||
const distancelt = Math.sqrt(
|
const distancelt = Math.sqrt(
|
||||||
|
@ -53,7 +53,6 @@ const TootList: Component<{
|
||||||
onChangeToot: (id: string, value: mastodon.v1.Status) => void;
|
onChangeToot: (id: string, value: mastodon.v1.Status) => void;
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const session = useDefaultSession();
|
const session = useDefaultSession();
|
||||||
const heroSrc = useHeroSource();
|
|
||||||
const [expandedThreadId, setExpandedThreadId] = createSignal<string>();
|
const [expandedThreadId, setExpandedThreadId] = createSignal<string>();
|
||||||
const { push } = useNavigator();
|
const { push } = useNavigator();
|
||||||
|
|
||||||
|
@ -124,9 +123,6 @@ const TootList: Component<{
|
||||||
console.warn("no account info?");
|
console.warn("no account info?");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (heroSrc) {
|
|
||||||
heroSrc[1]((x) => ({ ...x, [BOTTOM_SHEET_HERO]: srcElement }));
|
|
||||||
}
|
|
||||||
|
|
||||||
const acct = `${inf.username}@${p.site}`;
|
const acct = `${inf.username}@${p.site}`;
|
||||||
setTootBottomSheetCache(acct, toot);
|
setTootBottomSheetCache(acct, toot);
|
||||||
|
@ -238,6 +234,36 @@ const TootList: Component<{
|
||||||
openFullScreenToot(status, element, true);
|
openFullScreenToot(status, element, true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const vote = async (
|
||||||
|
status: mastodon.v1.Status,
|
||||||
|
votes: readonly number[]
|
||||||
|
) => {
|
||||||
|
const client = session()?.client;
|
||||||
|
if (!client) return;
|
||||||
|
|
||||||
|
const toot = status.reblog ?? status;
|
||||||
|
if (!toot.poll) return;
|
||||||
|
|
||||||
|
const npoll = await client.v1.polls.$select(toot.poll.id).votes.create({
|
||||||
|
choices: votes,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (status.reblog) {
|
||||||
|
props.onChangeToot(status.id, {
|
||||||
|
...status,
|
||||||
|
reblog: {
|
||||||
|
...status.reblog,
|
||||||
|
poll: npoll,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
props.onChangeToot(status.id, {
|
||||||
|
...status,
|
||||||
|
poll: npoll,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary
|
<ErrorBoundary
|
||||||
fallback={(err, reset) => {
|
fallback={(err, reset) => {
|
||||||
|
@ -245,48 +271,50 @@ const TootList: Component<{
|
||||||
return <p>Oops: {String(err)}</p>;
|
return <p>Oops: {String(err)}</p>;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div ref={props.ref} id={props.id} class="toot-list">
|
<TootEnvProvider value={{
|
||||||
<Index each={props.threads}>
|
boost: toggleBoost,
|
||||||
{(threadId, threadIdx) => {
|
bookmark: onBookmark,
|
||||||
const thread = createMemo(() =>
|
favourite: toggleFavourite,
|
||||||
props.onUnknownThread(threadId())?.reverse(),
|
reply: reply,
|
||||||
);
|
vote: vote
|
||||||
|
}}>
|
||||||
|
<div ref={props.ref} id={props.id} class="toot-list">
|
||||||
|
<For each={props.threads}>
|
||||||
|
{(threadId, threadIdx) => {
|
||||||
|
const thread = createMemo(() =>
|
||||||
|
props.onUnknownThread(threadId)?.reverse(),
|
||||||
|
);
|
||||||
|
|
||||||
const threadLength = () => thread()?.length ?? 0;
|
const threadLength = () => thread()?.length ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Index each={thread()}>
|
<Index each={thread()}>
|
||||||
{(threadNode, index) => {
|
{(threadNode, index) => {
|
||||||
const status = () => threadNode().value;
|
const status = () => threadNode().value;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RegularToot
|
<RegularToot
|
||||||
data-status-id={status().id}
|
data-status-id={status().id}
|
||||||
data-thread={threadIdx}
|
data-thread-sort={index}
|
||||||
data-thread-len={threadLength()}
|
status={status()}
|
||||||
data-thread-sort={index}
|
thread={
|
||||||
status={status()}
|
threadLength() > 1
|
||||||
thread={
|
? positionTootInThread(index, threadLength())
|
||||||
threadLength() > 1
|
: undefined
|
||||||
? positionTootInThread(index, threadLength())
|
}
|
||||||
: undefined
|
class={cardStyle.card}
|
||||||
}
|
evaluated={checkIsExpended(status())}
|
||||||
class={cardStyle.card}
|
actionable={checkIsExpended(status())}
|
||||||
evaluated={checkIsExpended(status())}
|
onClick={[onItemClick, status()]}
|
||||||
actionable={checkIsExpended(status())}
|
/>
|
||||||
onBookmark={onBookmark}
|
);
|
||||||
onRetoot={toggleBoost}
|
}}
|
||||||
onFavourite={toggleFavourite}
|
</Index>
|
||||||
onReply={reply}
|
);
|
||||||
onClick={[onItemClick, status()]}
|
}}
|
||||||
/>
|
</For>
|
||||||
);
|
</div>
|
||||||
}}
|
</TootEnvProvider>
|
||||||
</Index>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</Index>
|
|
||||||
</div>
|
|
||||||
</ErrorBoundary>
|
</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 {
|
.tootAuthorGrp {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
|
contain: layout style;
|
||||||
|
|
||||||
> :not(:first-child) {
|
> :not(:first-child) {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
@ -92,189 +50,3 @@
|
||||||
border: 1px solid var(--tutu-color-surface);
|
border: 1px solid var(--tutu-color-surface);
|
||||||
background-color: var(--tutu-color-surface-d);
|
background-color: var(--tutu-color-surface-d);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tootContent {
|
|
||||||
composes: cardNoPad from "../material/cards.module.css";
|
|
||||||
margin-left: calc(var(--card-pad, 0) + var(--toot-avatar-size, 0) + 8px);
|
|
||||||
margin-right: var(--card-pad, 0);
|
|
||||||
line-height: 1.5;
|
|
||||||
|
|
||||||
& a {
|
|
||||||
color: var(--tutu-color-primary-d);
|
|
||||||
}
|
|
||||||
|
|
||||||
& :global(a[target="_blank"]) {
|
|
||||||
> :global(.invisible) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
> :global(.ellipsis) {
|
|
||||||
&::after {
|
|
||||||
display: inline;
|
|
||||||
content: "...";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.previewCard {
|
|
||||||
composes: cardGutSkip from "../material/cards.module.css";
|
|
||||||
display: block;
|
|
||||||
border: 1px solid #eeeeee;
|
|
||||||
background-color: var(--tutu-color-surface);
|
|
||||||
text-decoration: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
margin-top: 1em;
|
|
||||||
margin-bottom: 1.5em;
|
|
||||||
color: var(--tutu-color-secondary-text-on-surface);
|
|
||||||
transition: color 220ms var(--tutu-anim-curve-std), background-color 220ms var(--tutu-anim-curve-std);
|
|
||||||
padding-bottom: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
z-index: 1;
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
>img {
|
|
||||||
background-color: #eeeeee;
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&:focus-visible {
|
|
||||||
background-color: var(--tutu-color-surface-d);
|
|
||||||
color: var(--tutu-color-on-surface);
|
|
||||||
|
|
||||||
>h1 {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
>h1 {
|
|
||||||
color: var(--tutu-color-on-surface);
|
|
||||||
max-height: calc(4 * var(--title-line-height) * var(--title-size));
|
|
||||||
}
|
|
||||||
|
|
||||||
>p {
|
|
||||||
max-height: calc(8 * var(--body-line-height) * var(--body-size));
|
|
||||||
}
|
|
||||||
|
|
||||||
>h1,
|
|
||||||
>p {
|
|
||||||
margin-left: 16px;
|
|
||||||
margin-right: 16px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.compact {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(10%, 30%) 1fr;
|
|
||||||
padding-left: 16px;
|
|
||||||
padding-right: 16px;
|
|
||||||
padding-top: 8px;
|
|
||||||
|
|
||||||
>img:first-child {
|
|
||||||
grid-row: 1 / 3;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
>h1,
|
|
||||||
>p {
|
|
||||||
margin-right: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.toot.compact {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto 1fr;
|
|
||||||
gap: 8px;
|
|
||||||
row-gap: 0;
|
|
||||||
padding-block: var(--card-gut, 16px);
|
|
||||||
padding-inline: var(--card-pad, 16px);
|
|
||||||
|
|
||||||
> :first-child {
|
|
||||||
grid-row: 1/3;
|
|
||||||
}
|
|
||||||
|
|
||||||
> :last-child {
|
|
||||||
grid-column: 2 /3;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.compactAuthorGroup {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
flex-flow: row wrap;
|
|
||||||
justify-content: flex-end;
|
|
||||||
|
|
||||||
>.compactAuthorUsername {
|
|
||||||
color: var(--tutu-color-secondary-text-on-surface);
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
>time {
|
|
||||||
color: var(--tutu-color-secondary-text-on-surface);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.compactTootContent {
|
|
||||||
composes: tootContent;
|
|
||||||
margin-left: 0;
|
|
||||||
margin-right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tootRetootGrp {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.25em;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
> :first-child {
|
|
||||||
margin-right: 0.25em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tootBottomActionGrp {
|
|
||||||
composes: cardGutSkip from "../material/cards.module.css";
|
|
||||||
padding-block: calc((var(--card-gut) - 10px) / 2);
|
|
||||||
|
|
||||||
animation: 225ms var(--tutu-anim-curve-std) tootBottomExpanding;
|
|
||||||
display: flex;
|
|
||||||
flex-flow: row wrap;
|
|
||||||
justify-content: space-evenly;
|
|
||||||
|
|
||||||
>button {
|
|
||||||
color: var(--tutu-color-on-surface);
|
|
||||||
padding: 10px 8px;
|
|
||||||
|
|
||||||
>svg {
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tootActionWithCount {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tootAction {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes tootBottomExpanding {
|
|
||||||
0% {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,27 +1,48 @@
|
||||||
.MediaAttachmentGrid {
|
.MediaAttachmentGrid {
|
||||||
/* Note: MeidaAttachmentGrid has hard-coded layout calcalation */
|
/* Note: MeidaAttachmentGrid has hard-coded layout calcalation */
|
||||||
margin-top: 1em;
|
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);
|
margin-right: var(--card-pad, 0);
|
||||||
|
contain: layout style;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
|
||||||
> :where(img, video) {
|
>* {
|
||||||
max-height: 35vh;
|
max-height: 35vh;
|
||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
min-width: 40px;
|
min-width: 40px;
|
||||||
object-fit: contain;
|
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
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-radius: 2px;
|
||||||
border: 1px solid var(--tutu-color-surface-d);
|
border: 1px solid var(--tutu-color-surface-d);
|
||||||
transition: outline-width 60ms var(--tutu-anim-curve-std), border-color 60ms var(--tutu-anim-curve-std);
|
transition: outline-width 60ms var(--tutu-anim-curve-std), border-color 60ms var(--tutu-anim-curve-std);
|
||||||
contain: strict;
|
|
||||||
content-visibility: auto;
|
|
||||||
|
|
||||||
&:hover,
|
&:hover,
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
outline: 8px solid var(--media-color-accent, var(--tutu-color-surface-d));
|
outline: 8px solid var(--media-color-accent, var(--tutu-color-surface-d));
|
||||||
border-color: var(--media-color-accent, var(--tutu-color-surface-d));
|
border-color: var(--media-color-accent, var(--tutu-color-surface-d));
|
||||||
|
z-index: calc(var(--tutu-zidx-nav) - 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
>*>* {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
>*> :where(img, video) {
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
>*>.sensitive-placeholder {
|
||||||
|
display: inline-flex;
|
||||||
|
display: inline flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(.thread-top, .thread-mid)>.MediaAttachmentGrid {
|
||||||
|
margin-left: calc(var(--card-pad, 0) + var(--toot-avatar-size, 0) + 8px);
|
||||||
}
|
}
|
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 {
|
.PreviewCard {
|
||||||
display: block;
|
display: block;
|
||||||
border: 1px solid #eeeeee;
|
border: 1px solid #eeeeee;
|
||||||
background-color: var(--tutu-color-surface);
|
background-color: var(--tutu-color-surface-d);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
@ -13,16 +13,20 @@
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
contain: layout style;
|
||||||
|
|
||||||
>img {
|
>img {
|
||||||
background-color: #eeeeee;
|
background-color: var(--tutu-color-surface);
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
|
|
||||||
|
&.loaded {
|
||||||
|
background-color: #eeeeee;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover,
|
&:hover,
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
background-color: var(--tutu-color-surface-d);
|
|
||||||
color: var(--tutu-color-on-surface);
|
color: var(--tutu-color-on-surface);
|
||||||
|
|
||||||
>h1 {
|
>h1 {
|
|
@ -1,10 +1,18 @@
|
||||||
import Color from "colorjs.io";
|
import Color from "colorjs.io";
|
||||||
import type { mastodon } from "masto";
|
import type { mastodon } from "masto";
|
||||||
import { createEffect, createMemo, Show } from "solid-js";
|
import { createEffect, createMemo, Show } from "solid-js";
|
||||||
import { Title, Body1 } from "../../material/typography";
|
import { Title, Body1 } from "~material/typography";
|
||||||
import { averageColorHex } from "../../platform/blurhash";
|
import { averageColorHex } from "~platform/blurhash";
|
||||||
import "./PreviewCard.css";
|
import "./PreviewCard.css";
|
||||||
|
|
||||||
|
function onResetImg(event: Event & { currentTarget: HTMLImageElement }) {
|
||||||
|
event.currentTarget.classList.remove("loaded");
|
||||||
|
}
|
||||||
|
|
||||||
|
function onImgLoaded(event: Event & { currentTarget: HTMLImageElement }) {
|
||||||
|
event.currentTarget.classList.add("loaded");
|
||||||
|
}
|
||||||
|
|
||||||
export function PreviewCard(props: {
|
export function PreviewCard(props: {
|
||||||
src: mastodon.v1.PreviewCard;
|
src: mastodon.v1.PreviewCard;
|
||||||
alwaysCompact?: boolean;
|
alwaysCompact?: boolean;
|
||||||
|
@ -81,6 +89,8 @@ export function PreviewCard(props: {
|
||||||
>
|
>
|
||||||
<Show when={props.src.image}>
|
<Show when={props.src.image}>
|
||||||
<img
|
<img
|
||||||
|
onLoadStart={onResetImg}
|
||||||
|
onLoad={onImgLoaded}
|
||||||
crossOrigin="anonymous"
|
crossOrigin="anonymous"
|
||||||
src={props.src.image!}
|
src={props.src.image!}
|
||||||
width={props.src.width || undefined}
|
width={props.src.width || undefined}
|
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,
|
"noEmit": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"resolveJsonModule": 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 manifest from "./manifest.config";
|
||||||
import { GetManualChunk } from "rollup";
|
import { GetManualChunk } from "rollup";
|
||||||
import devtools from "solid-devtools/vite";
|
import devtools from "solid-devtools/vite";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Put all strings (/i18n/{key}.<json|js|ts>) into separated chunks based on the key.
|
* Put all strings (/i18n/{key}.<json|js|ts>) into separated chunks based on the key.
|
||||||
|
@ -107,6 +108,23 @@ export default defineConfig(({ mode }) => {
|
||||||
}),
|
}),
|
||||||
version(),
|
version(),
|
||||||
],
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
/* We don't allow directly acessing the source root,
|
||||||
|
because this encourage cross referencing between different
|
||||||
|
module and loose the isolation. (Cross referencing is still
|
||||||
|
possible, we don't stop it in any technical way.)
|
||||||
|
|
||||||
|
If the module is so important and is being referencing
|
||||||
|
everywhere in the app. Consider promoting it to the top
|
||||||
|
dir.
|
||||||
|
|
||||||
|
see docs/devnotes.md#module-isolation for details.
|
||||||
|
*/
|
||||||
|
"~platform": resolve(__dirname, "src/platform"),
|
||||||
|
"~material": resolve(__dirname, "src/material"),
|
||||||
|
},
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
https: serverHttpCertBase
|
https: serverHttpCertBase
|
||||||
? {
|
? {
|
||||||
|
|
Loading…
Reference in a new issue