Compare commits

...

66 commits

Author SHA1 Message Date
thislight
4c0925a6a1
getOrRegisterApp: update website address
All checks were successful
/ depoly (push) Successful in 1m20s
2024-11-23 23:58:38 +08:00
thislight
83e2e6e169
StackedRouter: add replace: all 2024-11-23 23:58:20 +08:00
thislight
4c717a0cb7
TootComposer: use useAppLocale
All checks were successful
/ depoly (push) Successful in 1m25s
2024-11-23 23:48:22 +08:00
thislight
6895367fad
RegularToot: remove css module 2024-11-23 23:46:18 +08:00
thislight
9fe86d12b0
i18n: optimize performance 2024-11-23 23:39:52 +08:00
thislight
296de7d23b
TootActionGroup: remove css module 2024-11-23 23:04:35 +08:00
thislight
ad7db8e865
RegularToot: refactor, add env context 2024-11-23 22:22:55 +08:00
thislight
cbdf5e667d
StackedRouter: fix page width
All checks were successful
/ depoly (push) Successful in 1m21s
2024-11-23 21:22:23 +08:00
thislight
25ceb46911
Profile: minor changes
All checks were successful
/ depoly (push) Successful in 1m20s
2024-11-23 20:56:00 +08:00
thislight
c85cffc03e
RegularToot: supports polls 2024-11-23 20:55:37 +08:00
thislight
9bf957188c
theme: add error color 2024-11-23 20:20:27 +08:00
thislight
f50ed8d907
BottomSheet: stop clicks propagation 2024-11-23 20:19:43 +08:00
thislight
62aaaeee9a
Profile: check container query supports
All checks were successful
/ depoly (push) Successful in 1m25s
2024-11-23 16:13:42 +08:00
thislight
66d0bc8d84
Profile: support wide page 2024-11-23 16:10:48 +08:00
thislight
bb3ba32dc5
Masonry: remove built-in column number condition 2024-11-23 13:53:10 +08:00
thislight
fbbac36b4a
StackedRouter: minor changes 2024-11-23 13:51:53 +08:00
thislight
487de9237b
Masonry: use MutationObserver instead
- originally tricks with the reactive system
2024-11-23 13:00:36 +08:00
thislight
18fa2810c4
MediaAttachmentGrid: use new ~platform/Masonry
All checks were successful
/ depoly (push) Successful in 1m30s
* Masonry is a component provides limited masonry
  layout support
2024-11-23 00:01:45 +08:00
thislight
3a3fa40437
Revert "RegularToot: adjust content left margin"
This reverts commit cac0abeb6b.
2024-11-22 18:34:56 +08:00
thislight
246771e8a0
add alias ~platform and ~material
All checks were successful
/ depoly (push) Successful in 1m19s
2024-11-22 17:24:58 +08:00
thislight
ade7df2234
MediaAttachmentGrid: moved into toots 2024-11-22 14:53:23 +08:00
thislight
cac0abeb6b
RegularToot: adjust content left margin 2024-11-22 14:41:04 +08:00
thislight
57b242c93f
RegularToot: remove the left margin of content
All checks were successful
/ depoly (push) Successful in 1m20s
2024-11-21 23:45:26 +08:00
thislight
7e5692549d
MediaAttachmentGrid: preload=metadata for videos 2024-11-21 23:35:56 +08:00
thislight
b58e2a50e3
TootContent: remove unused composes property 2024-11-21 22:54:00 +08:00
thislight
37b38be1d2
StackedRouter: contain swipe to back state 2024-11-21 22:53:31 +08:00
thislight
76f7e08e78
StackedRouter: blocks the ios swipe gesture
- added more docs
2024-11-21 22:31:56 +08:00
thislight
6726ffe664
StackedRouter: inject inline insets, fix #48
All checks were successful
/ depoly (push) Successful in 1m19s
2024-11-21 19:07:50 +08:00
thislight
5ecba144f0
StackedRouter: fix default prevention not work
All checks were successful
/ depoly (push) Successful in 1m19s
2024-11-21 17:23:40 +08:00
thislight
8e8554331b
StackedRouter: increase swipe to back area
All checks were successful
/ depoly (push) Successful in 1m22s
2024-11-21 16:22:22 +08:00
thislight
df5a976ec3
UnexpectedError: adjust visual, add selectable 2024-11-21 15:58:27 +08:00
thislight
147c9fbce1
MediaAttachmentGrid: fix layout on WebKit
All checks were successful
/ depoly (push) Successful in 1m22s
2024-11-20 21:51:29 +08:00
thislight
8d8d2a8fb1
RegularToot: only show preview if reveal
All checks were successful
/ depoly (push) Successful in 1m20s
2024-11-20 21:17:22 +08:00
thislight
8cd95b9e90
MediaAttachmentGrid: support click to reveal 2024-11-20 21:09:14 +08:00
thislight
1047a3b10d
MediaAttachmentGrid: add a box to each item 2024-11-20 20:45:24 +08:00
thislight
b1f6033cc8
TootContent: localized 2024-11-20 16:33:30 +08:00
thislight
6313827b1e
rename toot-components to toots 2024-11-20 16:26:05 +08:00
thislight
737d63f88a
RegularToot: support content warning 2024-11-20 16:24:57 +08:00
thislight
cff0c2880a
TootContent: remove css modules
All checks were successful
/ depoly (push) Successful in 1m19s
2024-11-20 15:51:14 +08:00
thislight
4c1b189ca0
Settings/i18n: rename lang-names to generic 2024-11-20 15:35:26 +08:00
thislight
0b586b17e6
RegularToot: asjust transition duration 2024-11-20 15:12:08 +08:00
thislight
7205fa5775
Scaffold: add safe area padding for top bar
All checks were successful
/ depoly (push) Successful in 1m18s
2024-11-20 00:31:09 +08:00
thislight
9a7710c070
Menu: fix #47, skip animateOpen if animating 2024-11-20 00:30:49 +08:00
thislight
a69a5c31e5
StackedRouter: fix content height is 0 on webkit
* for WebKit, fix the content height is 0 if the
  window width > 560px
2024-11-20 00:26:05 +08:00
thislight
9e9a831785
scripts: add count-source-lines 2024-11-19 21:12:28 +08:00
thislight
e174b7aafd
Settings: fix sticky headers
All checks were successful
/ depoly (push) Successful in 1m23s
2024-11-19 18:10:13 +08:00
thislight
5f504024a3
i18n: fix auto match wrong region
All checks were successful
/ depoly (push) Successful in 1m16s
2024-11-19 18:07:11 +08:00
thislight
8d24ffec29
devnotes: fix typo
All checks were successful
/ depoly (push) Successful in 1m23s
2024-11-19 17:58:01 +08:00
thislight
fd1c6fae99
Scaffold: remove bottom dock padding 2024-11-19 17:55:36 +08:00
thislight
15974af792
anim: remove hero signal
* BottomSheet: remove hero support
* use StackedRouter's hero support instead
2024-11-19 17:49:49 +08:00
thislight
8588a17bd0
devnotes: update for safe area insets
- remove hero animation related
2024-11-19 17:46:14 +08:00
thislight
33fab7e655
polyfills: remove web-animations-js
All checks were successful
/ depoly (push) Successful in 1m25s
2024-11-19 17:17:56 +08:00
thislight
4190b8847d
ProfileMenuButton: use self-built Menu
All checks were successful
/ depoly (push) Successful in 1m20s
2024-11-19 17:03:56 +08:00
thislight
0a9d833f09
StackedRouter: don't push frame if the stack empty 2024-11-19 16:46:44 +08:00
thislight
4d3f5c911b
StackedRouter: try to fix the swipe to back
All checks were successful
/ depoly (push) Successful in 1m21s
2024-11-18 23:59:14 +08:00
thislight
e124d3e5b8
MediaAttachmentGrid: indicates loading 2024-11-18 23:31:47 +08:00
thislight
ed53f24ede
PreviewCard: indicates loading 2024-11-18 23:29:44 +08:00
thislight
9b446aa846
StackedRouter: use touch radius to check
All checks were successful
/ depoly (push) Successful in 1m18s
2024-11-18 23:14:30 +08:00
thislight
840ad2cf00
StackedRouter: increse swipe to back detection
All checks were successful
/ depoly (push) Successful in 1m26s
2024-11-18 23:02:53 +08:00
thislight
81325e2b22
revise user-select property
All checks were successful
/ depoly (push) Successful in 1m20s
2024-11-18 22:41:11 +08:00
thislight
c363753884
Profile: fix too small banner image on iPhone
All checks were successful
/ depoly (push) Successful in 1m19s
2024-11-18 22:24:46 +08:00
thislight
e8c3271af1
minor css adjustments 2024-11-18 22:14:59 +08:00
thislight
59b413dace
Settings: fix overscroll effect
All checks were successful
/ depoly (push) Successful in 1m18s
2024-11-18 21:57:46 +08:00
thislight
5ec9b96504
Profile: fix auto hide toolbar 2024-11-18 21:48:10 +08:00
thislight
9065a18061
StackedRouter: prevent ios default swipe to back
All checks were successful
/ depoly (push) Successful in 1m22s
2024-11-18 21:42:40 +08:00
1a7a52da22 Merge pull request 'StackedRouter: new router simulates app behaviour' (#45) from stacky into master
All checks were successful
/ depoly (push) Successful in 1m20s
Reviewed-on: #45
2024-11-18 10:35:30 +00:00
66 changed files with 2503 additions and 1679 deletions

1
.env
View file

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

BIN
bun.lockb

Binary file not shown.

View file

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

View file

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

10
scripts/src-lc.sh Executable file
View 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=-

View file

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

View file

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

View file

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

View file

@ -9,12 +9,12 @@ import {
import { acceptAccountViaAuthCode } from "./stores";
import { $settings } from "../settings/stores";
import { useDocumentTitle } from "../utils";
import cards from "../material/cards.module.css";
import cards from "~material/cards.module.css";
import { LinearProgress } from "@suid/material";
import Img from "../material/Img";
import Img from "~material/Img";
import { createRestAPIClient } from "masto";
import { Title } from "../material/typography";
import { useNavigator } from "../platform/StackedRouter";
import { Title } from "~material/typography";
import { useNavigator } from "~platform/StackedRouter";
type OAuth2CallbackParams = {
code?: string;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

4
src/overrides.d.ts vendored
View file

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

11
src/platform/Masonry.css Normal file
View 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
View 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;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
import type { Component } from "solid-js";
import Scaffold from "../material/Scaffold";
import Scaffold from "~material/Scaffold";
import {
AppBar,
Divider,
@ -12,12 +12,12 @@ import {
Switch,
Toolbar,
} from "@suid/material";
import { Title } from "../material/typography";
import { Title } from "~material/typography";
import { ArrowBack } from "@suid/icons-material";
import { createTranslator } from "../platform/i18n";
import { createTranslator } from "~platform/i18n";
import { useStore } from "@nanostores/solid";
import { $settings } from "./stores";
import { useNavigator } from "../platform/StackedRouter";
import { useNavigator } from "~platform/StackedRouter";
const Motions: Component = () => {
const {pop} = useNavigator();

View file

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

View file

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

View file

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

View file

@ -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;

View file

@ -6,7 +6,7 @@ import {
createRenderEffect,
} from "solid-js";
import { useDocumentTitle } from "../utils";
import Scaffold from "../material/Scaffold";
import Scaffold from "~material/Scaffold";
import {
AppBar,
ListItemSecondaryAction,
@ -16,10 +16,10 @@ import {
Toolbar,
} from "@suid/material";
import { css } from "solid-styled";
import { TimeSourceProvider, createTimeSource } from "../platform/timesrc";
import { TimeSourceProvider, createTimeSource } from "~platform/timesrc";
import ProfileMenuButton from "./ProfileMenuButton";
import Tabs from "../material/Tabs";
import Tab from "../material/Tab";
import Tabs from "~material/Tabs";
import Tab from "~material/Tab";
import { makeEventListener } from "@solid-primitives/event-listener";
import { $settings } from "../settings/stores";
import { useStore } from "@nanostores/solid";

View file

@ -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;

View file

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

View file

@ -10,7 +10,7 @@ import { Refresh as RefreshIcon } from "@suid/icons-material";
import { CircularProgress } from "@suid/material";
import { makeEventListener } from "@solid-primitives/event-listener";
import { createVisibilityObserver } from "@solid-primitives/intersection-observer";
import { useMaybeIsFrameSuspended } from "../platform/StackedRouter";
import { useIsFrameSuspended } from "~platform/StackedRouter";
const PullDownToRefresh: Component<{
loading?: boolean;
@ -34,7 +34,7 @@ const PullDownToRefresh: Component<{
});
const rootVisible = obvx(() => rootElement);
const isFrameSuspended = useMaybeIsFrameSuspended()
const isFrameSuspended = useIsFrameSuspended()
createEffect(() => {
if (!rootVisible()) setPullDown(0);

View 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;
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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;

View file

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

View file

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

View 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;

View file

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

View file

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

View 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;
}
}

View 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;

View 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);
}

View 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;

View 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;
}
}

View 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;

View 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;
}
}

View 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;

View file

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

View file

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

View file

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

View file

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