Rewrite timelines #25

Closed
Rubicon wants to merge 0 commits from rewrite-timeline into master
99 changed files with 2018 additions and 5736 deletions

5
.env
View file

@ -1,5 +0,0 @@
DEV_SERVER_HTTPS_CERT_BASE=
DEV_SERVER_HTTPS_CERT_PASS=
DEV_LOCATOR_EDITOR=vscode
VITE_DEVTOOLS_OVERLAY=true
VITE_PLATFROM_MASONRY_ALWAYS_COMPAT=

View file

@ -31,11 +31,11 @@ jobs:
run: bun install run: bun install
- name: Build Dist (Staging) - name: Build Dist (Staging)
run: VITE_CODE_VERSION=$GITHUB_SHA bun dist -m staging run: bun dist -m staging
if: env.GITHUB_REF_NAME == 'master' if: env.GITHUB_REF_NAME == 'master'
- name: Build Dist - name: Build Dist
run: VITE_CODE_VERSION=$GITHUB_SHA bun dist run: bun dist
if: env.GITHUB_REF_NAME != 'master' if: env.GITHUB_REF_NAME != 'master'
- name: Depoly to Preview - name: Depoly to Preview

1
.gitattributes vendored
View file

@ -1 +0,0 @@
*.lockb binary diff=lockb

4
.gitignore vendored
View file

@ -1,5 +1,3 @@
node_modules node_modules
dist/ dist/
dev-dist/ dev-dist/
.env.local
.env.*.local

View file

@ -2,8 +2,6 @@
Tutu is a comfortable experience for tooting. Designed to work on any device - desktop, phone and tablet. Tutu is a comfortable experience for tooting. Designed to work on any device - desktop, phone and tablet.
[Launch Tutu](https://tutu.lightstands.xyz)
## Compatibility ## Compatibility
The code is built against those targets and Tutu must run on those platforms: The code is built against those targets and Tutu must run on those platforms:
@ -14,12 +12,6 @@ The code is built against those targets and Tutu must run on those platforms:
Tutu trys to push the Web technology to its limit. Some features might not be available on the platform does not meet the requirement. Tutu trys to push the Web technology to its limit. Some features might not be available on the platform does not meet the requirement.
## The "Next" Branch
The "next" branch of the app is built on every commit pushed into "master". You can tatse latest change but risks your data.
[Launch Tutu (Next)](https://master.tututheapp.pages.dev)
## Build & Depoly ## Build & Depoly
Tutu uses [bun](https://bun.sh) as the package manager. Run Tutu uses [bun](https://bun.sh) as the package manager. Run
@ -32,6 +24,6 @@ to build the distribution, saved at "dist" directory.
Tutu must be loaded in a secure context - that means serving under localhost (`127.0.0.1` or `::1`) or HTTPS. Tutu must be loaded in a secure context - that means serving under localhost (`127.0.0.1` or `::1`) or HTTPS.
Use `bun dev` to run the dev server on localhost. If you need HTTPS dev server, see [*Set up HTTPS for the dev server*](docs/dev-https.md). Use `bun dev` to run the dev server. By default, the dev server is run on the domain <https://localhost.direct> and the port you defined. The cert is provided by localhost.direct, see <https://get.localhost.direct> for detail.
You can also change the config to make it run on localhost - but localhost.direct can be visted from the other device on the same network (as you do a little DNS magic). You can also change the config to make it run on localhost - but localhost.direct can be visted from the other device on the same network (as you do a little DNS magic).

BIN
bun.lockb

Binary file not shown.

View file

@ -1,27 +0,0 @@
# Set up HTTPS for the dev server
With a valid HTTP server, you can let the other devices access your dev server, with features only available to HTTPS.
You can use [localhost.direct](https://get.localhost.direct) certificate for set up local HTTPS server. Any vaild certificate is also allowed.
Download the certs and unpack them. In this document we put them under "tools/cert/".
Create or edit the file ".env.local", this file is ignored by git. Copy the content from ".env":
```env
DEV_SERVER_HTTPS_CERT_BASE=
DEV_SERVER_HTTPS_CERT_PASS=
```
The `DEV_SERVER_HTTPS_CERT_BASE` is the basename for your cert. The cert includes two files to work: one's suffix is `.key`, the another is `.crt`. The base is the common part of them.
If you have files "tools/cert/localhost.direct.key" and "tools/cert/localhost.direct.crt", the value you need is "tools/cert/localhost.direct".
The `DEV_SERVER_HTTPS_CERT_PASS` is the password to unlock the key. For the localhost.direct, it's `localhost`.
Here is an example:
```env
DEV_SERVER_HTTPS_CERT_BASE=tools/cert/localhost.direct
DEV_SERVER_HTTPS_CERT_PASS=localhost
```

View file

@ -10,6 +10,10 @@ You can debug on the Safari on iOS only if you have mac (and run macOS). The cer
- For visual bugs: on you iDevice, redirect the localhost.direct to your dev computer. Now you have the hot reload on you iDevice. - For visual bugs: on you iDevice, redirect the localhost.direct to your dev computer. Now you have the hot reload on you iDevice.
- You can use network debugging apps like "Shadowrocket" to do such thing. - You can use network debugging apps like "Shadowrocket" to do such thing.
## Hero Animation won't work (after hot reload)
That's a known issue. Hot reload won't refresh the module sets the hero cache. Refresh the whole page and it should work.
## The components don't react to the change as I setting the store, until the page reloaded ## The components don't react to the change as I setting the store, until the page reloaded
The `WritableAtom<unknwon>.set` might do an equals check. You must set a different object to ensure the atom sending a notify. The `WritableAtom<unknwon>.set` might do an equals check. You must set a different object to ensure the atom sending a notify.
@ -37,50 +41,3 @@ export function updateAcctInf(idx: number) {
``` ```
Ja, the code is weird, but that's the best we know. Anyway, you need new object on the path of your changed value. Ja, the code is weird, but that's the best we know. Anyway, you need new object on the path of your changed value.
## `transition: *-block or *-inline` does not work on WebKit
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

@ -1,26 +0,0 @@
# Optimizing Tutu
Topic Index:
- Time to first byte
- Time to first draw: [Load size](#load-size)
- CLS
- Framerate: [Algorithm](#algorithm)
## Load size
The baseline for the load size is lowest 3G download bandwidth in 2s, typically 1.1Mbps (or ~137 kilobytes/s) * 2s = 274 kilobytes.
In another words there is 274 kilobytes budget for an interaction without further notice. Notice and progress are needed if the interaction needs than that.
The service worker can use 1 chunk of size.
## Algorithm
Don't choose algorithm solely on the time complexity. GUI app needs smooth, not fast. The priority:
- On the main thread: batching. Batching is usually required to spread the work to multiple frames.
- Think in Map-Reduce framework if you don't have any idea.
- On the worker thread: balance the speed and the memory usage.
- Arrays are usually faster and use less memory.
- Worker is always available on our target platforms, but workers introduce latency in the starting and the communication.

View file

@ -1,10 +0,0 @@
import { ManifestOptions } from "vite-plugin-pwa";
const manifest: Partial<ManifestOptions> = {
name: "Tutu for Mastodon",
short_name: "Tutu",
description: "Tutu is an app to read, post, dog and cat on the mastodon.",
theme_color: "#673ab7"
};
export default manifest;

View file

@ -1,65 +1,55 @@
{ {
"$schema": "https://json.schemastore.org/package", "$schema": "https://json.schemastore.org/package",
"name": "tutu", "name": "tutu",
"version": "1.1.0", "version": "1.0.8",
"description": "", "description": "",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
"preview": "vite preview", "preview": "vite preview",
"dist": "vite build", "dist": "vite build"
"count-source-lines": "exec scripts/src-lc.sh"
}, },
"keywords": [], "keywords": [],
"author": "Rubicon", "author": "Rubicon",
"license": "Apache-2.0", "license": "Apache-2.0",
"devDependencies": { "devDependencies": {
"@solid-devtools/overlay": "^0.30.1", "@suid/vite-plugin": "^0.3.0",
"@suid/vite-plugin": "^0.3.1", "@types/hammerjs": "^2.0.45",
"@types/hammerjs": "^2.0.46", "postcss": "^8.4.45",
"@types/masonry-layout": "^4.2.8",
"@vite-pwa/assets-generator": "^0.2.6",
"postcss": "^8.4.49",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"typescript": "^5.6.3", "typescript": "^5.6.2",
"vite": "^5.4.11", "vite": "^5.4.5",
"vite-plugin-package-version": "^1.1.0", "vite-plugin-package-version": "^1.1.0",
"vite-plugin-pwa": "^0.20.5", "vite-plugin-pwa": "^0.20.5",
"vite-plugin-solid": "^2.10.2", "vite-plugin-solid": "^2.10.2",
"vite-plugin-solid-styled": "^0.11.1", "vite-plugin-solid-styled": "^0.11.1",
"workbox-build": "^7.3.0", "wrangler": "^3.78.2"
"wrangler": "^3.86.1"
}, },
"dependencies": { "dependencies": {
"@formatjs/intl-localematcher": "^0.5.7", "@formatjs/intl-localematcher": "^0.5.4",
"@nanostores/persistent": "^0.10.2", "@nanostores/persistent": "^0.10.2",
"@nanostores/solid": "^0.5.0", "@nanostores/solid": "^0.4.2",
"@solid-primitives/event-listener": "^2.3.3", "@solid-primitives/event-listener": "^2.3.3",
"@solid-primitives/i18n": "^2.1.1", "@solid-primitives/i18n": "^2.1.1",
"@solid-primitives/intersection-observer": "^2.1.6", "@solid-primitives/intersection-observer": "^2.1.6",
"@solid-primitives/map": "^0.4.13", "@solid-primitives/map": "^0.4.13",
"@solid-primitives/page-visibility": "^2.0.17",
"@solid-primitives/resize-observer": "^2.0.26", "@solid-primitives/resize-observer": "^2.0.26",
"@solidjs/router": "^0.15.1", "@solidjs/router": "^0.14.5",
"@suid/icons-material": "^0.8.1", "@suid/icons-material": "^0.8.0",
"@suid/material": "^0.18.0", "@suid/material": "^0.17.0",
"blurhash": "^2.0.5", "blurhash": "^2.0.5",
"colorjs.io": "^0.5.2", "colorjs.io": "^0.5.2",
"date-fns": "^4.1.0", "date-fns": "^3.6.0",
"fast-average-color": "^9.4.0", "fast-average-color": "^9.4.0",
"hammerjs": "^2.0.8", "hammerjs": "^2.0.8",
"iso-639-1": "^3.1.3", "iso-639-1": "^3.1.3",
"masonry-layout": "^4.2.2", "masto": "^6.8.0",
"masto": "^6.10.1",
"nanostores": "^0.11.3", "nanostores": "^0.11.3",
"normalize.css": "^8.0.1", "solid-js": "^1.8.22",
"solid-devtools": "^0.30.1",
"solid-js": "^1.9.3",
"solid-styled": "^0.11.1", "solid-styled": "^0.11.1",
"stacktrace-js": "^2.0.2", "stacktrace-js": "^2.0.2",
"workbox-core": "^7.3.0", "web-animations-js": "^2.3.2"
"workbox-precaching": "^7.3.0"
}, },
"packageManager": "bun@1.1.34" "packageManager": "bun@1.1.21"
} }

View file

@ -1,133 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="448.06656"
height="443.66376"
viewBox="0 0 448.06656 443.66376"
version="1.1"
id="svg1"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<title
id="title6">Tutu's Icon</title>
<defs
id="defs1">
<linearGradient
id="linearGradient4">
<stop
style="stop-color:#fac8a3;stop-opacity:1;"
offset="0"
id="stop4" />
<stop
style="stop-color:#fac8a3;stop-opacity:1;"
offset="0.72011906"
id="stop6" />
<stop
style="stop-color:#f48d8a;stop-opacity:1;"
offset="1"
id="stop5" />
</linearGradient>
<linearGradient
xlink:href="#linearGradient4"
id="linearGradient5"
x1="244.74585"
y1="430.05423"
x2="281.31232"
y2="82.147469"
gradientUnits="userSpaceOnUse" />
<linearGradient
xlink:href="#linearGradient4"
id="linearGradient9"
gradientUnits="userSpaceOnUse"
x1="244.74585"
y1="430.05423"
x2="281.31232"
y2="82.147469" />
</defs>
<g
id="layer1"
transform="matrix(0,1.4786237,-1.4786237,0,611.53939,-132.05234)"
style="display:none">
<path
style="fill:url(#linearGradient5);stroke:#000000;stroke-width:2;stroke-dasharray:none"
d="m 142.83262,404.32618 c 82.40343,-32.96137 141.81198,28.9112 141.81198,28.9112 0,0 21.1442,-23.1068 65.90935,-81.91599 51.82799,-68.08783 41.673,-112.8227 41.673,-112.8227 0,0 2.09565,-72.92272 -51.48333,-125.1428 C 299.2268,72.892041 192.11851,96.043081 192.11851,96.043081 c 0,0 -84.44469,24.815289 -100.925375,105.021299 -16.480686,80.20601 51.639485,203.2618 51.639485,203.2618 z"
id="path2" />
<g
id="g6"
style="stroke-width:2;stroke-dasharray:none">
<path
style="fill:none;stroke:#000000;stroke-width:2.3;stroke-dasharray:none"
d="m 210.50439,396.31338 c 0,0 -29.4511,-105.3688 -28.96641,-141.50635 0.34942,-26.05202 -0.47344,-34.47943 3.80644,-54.3634 5.96795,-27.72652 12.70659,-35.21402 12.70659,-35.21402"
id="path3" />
<path
style="fill:none;stroke:#000000;stroke-width:1.8;stroke-dasharray:none"
d="m 185.10879,292.14608 c 2.88624,-0.57725 6.83376,-30.94178 44.70393,-42.39467"
id="path4" />
</g>
</g>
<g
id="g9"
transform="matrix(0,1.5553086,-1.5553086,0,637.18063,-128.31863)">
<path
style="fill:url(#linearGradient9);stroke:#000000;stroke-width:15.431;stroke-dasharray:none"
d="m 117.92992,367.86152 c 25.48298,38.39529 93.78536,33.94838 93.78536,33.94838 0,0 58.98001,-7.82594 105.93153,-57.60357 51.75,-54.86494 41.67301,-98.59259 41.67301,-98.59259 0,0 0.85581,-49.23349 -57.70901,-95.79319 -44.18497,-35.12756 -115.71798,-13.75528 -115.71798,-13.75528 0,0 -87.11283,22.14714 -94.699695,109.46821 -7.087568,81.5744 26.736785,122.32804 26.736785,122.32804 z"
id="path6" />
<g
id="g8"
style="stroke-width:2.00025;stroke-dasharray:none"
transform="translate(-1.7787639,5.3362916)">
<path
style="fill:none;stroke:#000000;stroke-width:15.431;stroke-dasharray:none"
d="m 210.50439,396.31338 c 0,0 -29.4511,-105.3688 -28.96641,-141.50635 0.34942,-26.05202 -0.47344,-34.47943 3.80644,-54.3634 5.96795,-27.72652 12.70659,-35.21402 12.70659,-35.21402"
id="path7" />
<path
style="fill:none;stroke:#000000;stroke-width:12.8592;stroke-dasharray:none"
d="m 185.10879,292.14608 c 2.88624,-0.57725 6.83376,-30.94178 44.70393,-42.39467"
id="path8" />
</g>
</g>
<metadata
id="metadata6">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:title>Tutu's Icon</dc:title>
<dc:date>2024/10/22</dc:date>
<dc:creator>
<cc:Agent>
<dc:title>Rubicon</dc:title>
</cc:Agent>
</dc:creator>
<dc:rights>
<cc:Agent>
<dc:title>Rubicon</dc:title>
</cc:Agent>
</dc:rights>
<cc:license
rdf:resource="http://creativecommons.org/licenses/by-nc-sa/4.0/" />
</cc:Work>
<cc:License
rdf:about="http://creativecommons.org/licenses/by-nc-sa/4.0/">
<cc:permits
rdf:resource="http://creativecommons.org/ns#Reproduction" />
<cc:permits
rdf:resource="http://creativecommons.org/ns#Distribution" />
<cc:requires
rdf:resource="http://creativecommons.org/ns#Notice" />
<cc:requires
rdf:resource="http://creativecommons.org/ns#Attribution" />
<cc:prohibits
rdf:resource="http://creativecommons.org/ns#CommercialUse" />
<cc:permits
rdf:resource="http://creativecommons.org/ns#DerivativeWorks" />
<cc:requires
rdf:resource="http://creativecommons.org/ns#ShareAlike" />
</cc:License>
</rdf:RDF>
</metadata>
</svg>

Before

Width:  |  Height:  |  Size: 5.1 KiB

View file

@ -1,2 +0,0 @@
User-agent: *
Allow: /

View file

@ -1,12 +0,0 @@
import {
defineConfig,
minimal2023Preset as preset
} from '@vite-pwa/assets-generator/config'
export default defineConfig({
headLinkOptions: {
preset: '2023'
},
preset,
images: ['public/logo.svg']
})

View file

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

View file

@ -1,41 +1,12 @@
@import "normalize.css/normalize.css";
@import "./material/theme.css";
:root { :root {
--safe-area-inset-top: env(safe-area-inset-top); --safe-area-inset-top: env(safe-area-inset-top);
--safe-area-inset-left: env(safe-area-inset-left); --safe-area-inset-left: env(safe-area-inset-left);
--safe-area-inset-bottom: env(safe-area-inset-bottom); --safe-area-inset-bottom: env(safe-area-inset-bottom);
--safe-area-inset-right: env(safe-area-inset-right); --safe-area-inset-right: env(safe-area-inset-right);
background-color: var(--tutu-color-surface, transparent); background-color: var(--tutu-color-surface, transparent);
} overscroll-behavior-block: none;
/*
Fix the bottom gap on iOS standalone.
https://stackoverflow.com/questions/66005655/pwa-ios-child-of-body-not-taking-100-height-gap-on-bottom
*/
@media screen and (display-mode: standalone) {
body {
width: 100%;
height: 100vh;
}
#root {
position: fixed;
top: 0;
left: 0;
height: 100vh;
width: 100vw;
}
} }
.custom-emoji { .custom-emoji {
width: 1em; width: 1em;
} }
h1 {
margin: 0;
}
* {
user-select: none;
}

View file

@ -5,12 +5,11 @@ import {
createEffect, createEffect,
createMemo, createMemo,
createRenderEffect, createRenderEffect,
createSignal,
ErrorBoundary, ErrorBoundary,
lazy, lazy,
onCleanup, onCleanup,
} from "solid-js"; } from "solid-js";
import { useRootTheme } from "./material/theme.js"; import { useRootTheme } from "./material/mui.js";
import { import {
Provider as ClientProvider, Provider as ClientProvider,
createMastoClientFor, createMastoClientFor,
@ -18,16 +17,6 @@ import {
import { $accounts, updateAcctInf } from "./accounts/stores.js"; import { $accounts, updateAcctInf } from "./accounts/stores.js";
import { useStore } from "@nanostores/solid"; import { useStore } from "@nanostores/solid";
import { DateFnScope, useLanguage } from "./platform/i18n.jsx"; import { DateFnScope, useLanguage } from "./platform/i18n.jsx";
import { useRegisterSW } from "virtual:pwa-register/solid";
import {
isJSONRPCResult,
ResultDispatcher,
type JSONRPC,
} from "./serviceworker/workerrpc.js";
import { Service } from "./serviceworker/services.js";
import { makeEventListener } from "@solid-primitives/event-listener";
import { ServiceWorkerProvider } from "./platform/host.js";
import StackedRouter from "./platform/StackedRouter.js";
const AccountSignIn = lazy(() => import("./accounts/SignIn.js")); const AccountSignIn = lazy(() => import("./accounts/SignIn.js"));
const AccountMastodonOAuth2Callback = lazy( const AccountMastodonOAuth2Callback = lazy(
@ -38,21 +27,22 @@ const Settings = lazy(() => import("./settings/Settings.js"));
const TootBottomSheet = lazy(() => import("./timelines/TootBottomSheet.js")); const TootBottomSheet = lazy(() => import("./timelines/TootBottomSheet.js"));
const MotionSettings = lazy(() => import("./settings/Motions.js")); const MotionSettings = lazy(() => import("./settings/Motions.js"));
const LanguageSettings = lazy(() => import("./settings/Language.js")); const LanguageSettings = lazy(() => import("./settings/Language.js"));
const RegionSettings = lazy(() => import("./settings/Region.js")); const RegionSettings = lazy(() => import("./settings/Region.jsx"));
const UnexpectedError = lazy(() => import("./UnexpectedError.js")); const UnexpectedError = lazy(() => import("./UnexpectedError.js"));
const Profile = lazy(() => import("./profiles/Profile.js"));
const Routing: Component = () => { const Routing: Component = () => {
return ( return (
<StackedRouter> <Router>
<Route path="/" component={TimelineHome} /> <Route path="/" component={TimelineHome}>
<Route path="/settings/language" component={LanguageSettings} /> <Route path=""></Route>
<Route path="/settings/region" component={RegionSettings} /> <Route path="/settings" component={Settings}>
<Route path="/settings/motions" component={MotionSettings} /> <Route path=""></Route>
<Route path="/settings" component={Settings} /> <Route path="/language" component={LanguageSettings}></Route>
<Route path="/:acct/toot/:id" component={TootBottomSheet} /> <Route path="/region" component={RegionSettings}></Route>
<Route path="/:acct/profile/:id" component={Profile} /> <Route path="/motions" component={MotionSettings}></Route>
</Route>
<Route path="/:acct/:id" component={TootBottomSheet}></Route>
</Route>
<Route path={"/accounts"}> <Route path={"/accounts"}>
<Route path={"/sign-in"} component={AccountSignIn} /> <Route path={"/sign-in"} component={AccountSignIn} />
<Route <Route
@ -60,7 +50,7 @@ const Routing: Component = () => {
component={AccountMastodonOAuth2Callback} component={AccountMastodonOAuth2Callback}
/> />
</Route> </Route>
</StackedRouter> </Router>
); );
}; };
@ -68,45 +58,6 @@ const App: Component = () => {
const theme = useRootTheme(); const theme = useRootTheme();
const accts = useStore($accounts); const accts = useStore($accounts);
const lang = useLanguage(); const lang = useLanguage();
const [serviceWorker, setServiceWorker] = createSignal<
ServiceWorker | undefined
>(undefined, { name: "serviceWorker" });
const dispatcher = new ResultDispatcher();
let checkAge = 0;
const untilServiceWorkerAlive = async (
worker: ServiceWorker,
expectedAge: number,
) => {
const [call, ret] = dispatcher.createTypedCall<Service>("ping");
worker.postMessage(await call);
const result = await ret;
console.assert(!result.error, result);
if (expectedAge === checkAge) {
setServiceWorker(worker);
}
};
makeEventListener(window, "message", (event: MessageEvent<JSONRPC>) => {
if (isJSONRPCResult(event.data)) {
dispatcher.dispatch(event.data.id, event.data);
}
});
const {
needRefresh: [needRefresh],
offlineReady: [offlineReady],
} = useRegisterSW({
onRegisteredSW(scriptUrl, reg) {
console.info("service worker is registered from %s", scriptUrl);
const active = reg?.active;
if (!active) {
console.warn("No service is in activating or activated");
return;
}
untilServiceWorkerAlive(active, checkAge++);
},
});
const clients = createMemo(() => { const clients = createMemo(() => {
return accts().map((x) => ({ return accts().map((x) => ({
@ -149,18 +100,10 @@ const App: Component = () => {
return <UnexpectedError error={err} />; return <UnexpectedError error={err} />;
}} }}
> >
<ThemeProvider theme={theme}> <ThemeProvider theme={theme()}>
<DateFnScope> <DateFnScope>
<ClientProvider value={clients}> <ClientProvider value={clients}>
<ServiceWorkerProvider <Routing />
value={{
needRefresh,
offlineReady,
serviceWorker,
}}
>
<Routing />
</ServiceWorkerProvider>
</ClientProvider> </ClientProvider>
</DateFnScope> </DateFnScope>
</ThemeProvider> </ThemeProvider>

View file

@ -18,16 +18,7 @@ const UnexpectedError: Component<{ error?: any }> = (props) => {
.join("\n"); .join("\n");
return `${err.name}: ${err.message}\n${strackMsg}`; return `${err.name}: ${err.message}\n${strackMsg}`;
} catch (reason) { } catch (reason) {
return `<failed to build the stacktrace of "${err}"...>\n${reason}\n${JSON.stringify( return `<failed to build the stacktrace of "${err}"...>\n${reason}`;
{
name: err.name,
stack: err.stack,
cause: err.cause,
message: err.message,
},
undefined,
2,
)}`;
} }
} }
@ -42,29 +33,6 @@ const UnexpectedError: Component<{ error?: any }> = (props) => {
calc(var(--safe-area-inset-bottom) + 20px) calc(var(--safe-area-inset-bottom) + 20px)
calc(var(--safe-area-inset-left) + 20px); calc(var(--safe-area-inset-left) + 20px);
} }
details {
max-width: 100vw;
max-width: 100dvw;
overflow: auto;
& * {
user-select: all;
}
summary {
position: sticky;
left: 0;
top: 0;
user-select: none;
}
}
.actions {
margin-top: 20px;
margin-bottom: 20px;
}
`; `;
return ( return (
@ -72,25 +40,17 @@ const UnexpectedError: Component<{ error?: any }> = (props) => {
<h1>Oh, it is our fault.</h1> <h1>Oh, it is our fault.</h1>
<p>There is an unexpected error in our app, and it's not your fault.</p> <p>There is an unexpected error in our app, and it's not your fault.</p>
<p> <p>
You can restart the app to see if this guy is gone. If you meet this guy You can reload to see if this guy is gone. If you meet this guy
repeatly, please report to us. repeatly, please report to us.
</p> </p>
<div class="actions"> <div>
<Button <Button onClick={() => window.location.reload()}>Reload</Button>
onClick={() => window.location.replace("/")}
variant="contained"
>
Restart App
</Button>
</div> </div>
<details> <details>
<summary> <summary>
{errorMsg.loading ? "Generating " : " "}Technical Infomation {errorMsg.loading ? "Generating " : " "}Technical Infomation
</summary> </summary>
<pre> <pre>{errorMsg()}</pre>
On: {window.location.href} <br />
{errorMsg()}
</pre>
</details> </details>
</main> </main>
); );

View file

@ -1,4 +1,4 @@
import { useSearchParams } from "@solidjs/router"; import { useNavigate, useSearchParams } from "@solidjs/router";
import { import {
Component, Component,
Show, Show,
@ -9,12 +9,11 @@ import {
import { acceptAccountViaAuthCode } from "./stores"; import { acceptAccountViaAuthCode } from "./stores";
import { $settings } from "../settings/stores"; import { $settings } from "../settings/stores";
import { useDocumentTitle } from "../utils"; import { useDocumentTitle } from "../utils";
import cards from "~material/cards.module.css"; import cards from "../material/cards.module.css";
import { LinearProgress } from "@suid/material"; import { LinearProgress } from "@suid/material";
import Img from "~material/Img"; import Img from "../material/Img";
import { createRestAPIClient } from "masto"; import { createRestAPIClient } from "masto";
import { Title } from "~material/typography"; import { Title } from "../material/typography";
import { useNavigator } from "~platform/StackedRouter";
type OAuth2CallbackParams = { type OAuth2CallbackParams = {
code?: string; code?: string;
@ -26,7 +25,7 @@ const MastodonOAuth2Callback: Component = () => {
const progressId = createUniqueId(); const progressId = createUniqueId();
const titleId = createUniqueId(); const titleId = createUniqueId();
const [params] = useSearchParams<OAuth2CallbackParams>(); const [params] = useSearchParams<OAuth2CallbackParams>();
const { push: navigate } = useNavigator(); const navigate = useNavigate();
const setDocumentTitle = useDocumentTitle("Back from Mastodon..."); const setDocumentTitle = useDocumentTitle("Back from Mastodon...");
const [siteImg, setSiteImg] = createSignal<{ const [siteImg, setSiteImg] = createSignal<{
src: string; src: string;

View file

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

View file

@ -1,10 +1,5 @@
import { render } from "solid-js/web"; import { render } from "solid-js/web";
import App from "./App.js"; import App from "./App.js";
import "solid-devtools"; import "./material/theme.css";
import { attachDevtoolsOverlay } from "@solid-devtools/overlay";
render(() => <App />, document.getElementById("root")!); render(() => <App />, document.getElementById("root")!);
if (import.meta.env.VITE_DEVTOOLS_OVERLAY === "true") {
attachDevtoolsOverlay();
}

28
src/masto/acct.ts Normal file
View file

@ -0,0 +1,28 @@
import { Accessor, createResource } from "solid-js";
import type { mastodon } from "masto";
import { useSessions } from "./clients";
import { updateAcctInf } from "../accounts/stores";
export function useSignedInProfiles() {
const sessions = useSessions();
const [accessor, tools] = createResource(sessions, async (all) => {
return Promise.all(
all.map(async (x, i) => ({ ...x, inf: await updateAcctInf(i) })),
);
});
return [
() => {
try {
const value = accessor();
if (value) {
return value;
}
} catch (reason) {
console.error("useSignedInProfiles: update acct info failed", reason);
}
return sessions().map((x) => ({ ...x, inf: x.account.inf }));
},
tools,
] as const;
}

View file

@ -1,15 +1,14 @@
import { import {
Accessor, Accessor,
createContext, createContext,
createMemo,
createRenderEffect, createRenderEffect,
createResource, createResource,
Signal,
useContext, useContext,
} from "solid-js"; } from "solid-js";
import { Account } from "../accounts/stores"; import { Account } from "../accounts/stores";
import { createRestAPIClient, mastodon } from "masto"; import { createRestAPIClient, mastodon } from "masto";
import { useLocation } from "@solidjs/router"; import { useLocation, useNavigate } from "@solidjs/router";
import { useNavigator } from "~platform/StackedRouter";
const restfulCache: Record<string, mastodon.rest.Client> = {}; const restfulCache: Record<string, mastodon.rest.Client> = {};
@ -50,19 +49,18 @@ export type Session = {
client: mastodon.rest.Client; client: mastodon.rest.Client;
}; };
const Context = const Context = /* @__PURE__ */ createContext<Accessor<readonly Readonly<Session>[]>>();
/* @__PURE__ */ createContext<Accessor<readonly Readonly<Session>[]>>();
export const Provider = Context.Provider; export const Provider = Context.Provider;
export function useSessions() { export function useSessions() {
const sessions = useSessionsRaw(); const sessions = useSessionsRaw();
const {push} = useNavigator(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
createRenderEffect(() => { createRenderEffect(() => {
if (sessions().length > 0) return; if (sessions().length > 0) return;
push( navigate(
"/accounts/sign-in?back=" + encodeURIComponent(location.pathname), "/accounts/sign-in?back=" + encodeURIComponent(location.pathname),
{ replace: true }, { replace: true },
); );
@ -78,64 +76,3 @@ function useSessionsRaw() {
} }
return store; return store;
} }
const DefaultSessionContext = /* @__PURE__ */ createContext<Accessor<number>>(
() => 0,
);
export const DefaultSessionProvider = DefaultSessionContext.Provider;
/**
* Return the default session (the first session).
*
* This function may return `undefined`, but it will try to redirect the user to the sign in.
*/
export function useDefaultSession() {
const sessions = useSessions();
const sessionIndex = useContext(DefaultSessionContext);
return () => {
if (sessions().length > 0) {
return sessions()[sessionIndex()];
}
};
}
/**
* Get a session for the specific acct string.
*
* Acct string is a string in the pattern of `{username}@{site_with_protocol}`,
* like `@thislight@https://mastodon.social`, can be used to identify (tempoarily)
* an session on the tutu instance.
*
* The `site_with_protocol` is required.
*
* - If the username is present, the session matches the username and the site is returned; or,
* - If the username is not present, any session on the site is returned; or,
* - If no available session available for the pattern, an unauthorised session is returned.
*
* In an unauthorised session, the `.account` is `undefined` and the `client` is an
* unauthorised client for the site. This client may not available for some operations.
*/
export function useSessionForAcctStr(acct: Accessor<string>) {
const allSessions = useSessions();
return createMemo(() => {
const [inputUsername, inputSite] = acct().split("@", 2);
const authedSession = allSessions().find(
(x) =>
x.account.site === inputSite &&
x.account.inf?.username === inputUsername,
);
return (
authedSession ?? {
client: createUnauthorizedClient(inputSite),
account: undefined,
}
);
});
}
export function makeAcctText(session: Session) {
return `${session.account.inf?.username}@${session.account.site}`;
}

View file

@ -7,132 +7,86 @@ import {
createEffect, createEffect,
createResource, createResource,
untrack, untrack,
type Resource,
type ResourceFetcherInfo, type ResourceFetcherInfo,
} from "solid-js"; } from "solid-js";
import { createStore } from "solid-js/store"; import { createStore } from "solid-js/store";
type Timeline<T extends mastodon.DefaultPaginationParams> = { type Timeline = {
list(params?: T): mastodon.Paginator<mastodon.v1.Status[], unknown>; list(params: {
/** Return results older than this ID. */
readonly maxId?: string;
/** Return results newer than this ID. */
readonly sinceId?: string;
/** Get a list of items with ID greater than this value excluding this ID */
readonly minId?: string;
/** Maximum number of results to return per page. Defaults to 40. NOTE: Pagination is done with the Link header from the response. */
readonly limit?: number;
}): mastodon.Paginator<mastodon.v1.Status[], unknown>;
}; };
type TimelineParamsOf<T> = T extends Timeline<infer P> ? P : never; export function createTimelineSnapshot(
timeline: Accessor<Timeline>,
export type ThreadNode = TreeNode<mastodon.v1.Status>; limit: Accessor<number>,
) {
function createControlsForLookup(lookup: ReactiveMap<string, ThreadNode>) {
return {
get(id: string) {
return lookup.get(id);
},
getPath(id: string) {
const node = lookup.get(id);
if (!node) return;
const path = collectPath(node);
for (const sym of path) {
lookup.get(sym.value.id); // Track every node on the path
}
return path;
},
set(id: string, value: mastodon.v1.Status) {
const node = untrack(() => lookup.get(id));
if (!node) return;
lookup.set(id, { ...node, value });
},
};
}
export function createTimelineControlsForArray(
status: () => mastodon.v1.Status[] | undefined,
): TimelineControls {
const lookup = new ReactiveMap<string, ThreadNode>();
const [threads, setThreads] = createStore([] as mastodon.v1.Status["id"][]);
createEffect(() => {
const nls = catchError(status, (e) => {
console.error(e);
});
if (!nls) return;
batch(() => {
setThreads([]);
lookup.clear();
for (const status of nls) {
lookup.set(status.id, {
value: status,
});
}
});
untrack(() => {
for (const status of nls) {
const node = lookup.get(status.id)!;
const parent = status.inReplyToId
? lookup.get(status.inReplyToId)
: undefined;
if (parent) {
const children = parent.children ?? [];
if (!children.find((x) => x.value.id == status.id)) {
children.push(node);
}
parent.children = children;
node.parent = parent;
}
}
});
const newThreads = untrack(() =>
nls
.map((x) => x.id)
.filter((id) => (lookup.get(id)!.children?.length ?? 0) === 0),
);
setThreads(newThreads);
});
return {
list: threads,
...createControlsForLookup(lookup),
};
}
export function createTimelineSnapshot<
T extends Timeline<mastodon.DefaultPaginationParams>,
>(
timeline: Accessor<T>,
params: Accessor<TimelineParamsOf<T>>,
): TimelineResource<mastodon.v1.Status[] | undefined> {
const [shot, { refetch }] = createResource( const [shot, { refetch }] = createResource(
() => [timeline(), params()] as const, () => [timeline(), limit()] as const,
async ([tl, limit]) => { async ([tl, limit]) => {
const ls = await tl.list(limit).next(); const ls = await tl.list({ limit }).next();
return ls.value; return ls.value?.map((x) => [x]) ?? [];
}, },
); );
const controls = createTimelineControlsForArray(shot); const [snapshot, setSnapshot] = createStore([] as mastodon.v1.Status[][]);
createEffect(() => {
const nls = catchError(shot, (e) => console.error(e));
if (!nls) return;
const ols = Array.from(snapshot);
// The algorithm below assumes the snapshot is not changing
for (let i = 0; i < nls.length; i++) {
if (i >= ols.length) {
setSnapshot(i, nls[i]);
} else {
if (nls[i].length !== ols[i].length) {
setSnapshot(i, nls[i]);
} else {
const oth = ols[i],
nth = nls[i];
for (let j = 0; j < oth.length; j++) {
const ost = oth[j],
nst = nth[j];
for (const key of Object.keys(
nst,
) as unknown as (keyof mastodon.v1.Status)[]) {
if (ost[key] !== nst[key]) {
setSnapshot(i, j, key, nst[key]);
}
}
}
}
}
}
});
return [ return [
controls, snapshot,
shot, shot,
{ {
refetch, refetch,
mutate: setSnapshot,
}, },
] as const; ] as const;
} }
export type TimelineFetchDirection = mastodon.Direction; export type TimelineFetchDirection = mastodon.Direction;
export type TimelineChunk<T extends mastodon.DefaultPaginationParams> = { export type TimelineChunk = {
tl: Timeline<T>; tl: Timeline;
rebuilt: boolean; rebuilt: boolean;
chunk: readonly mastodon.v1.Status[]; chunk: readonly mastodon.v1.Status[];
done?: boolean; done?: boolean;
direction: TimelineFetchDirection; direction: TimelineFetchDirection;
params: T; limit: number;
}; };
type TreeNode<T> = { type TreeNode<T> = {
@ -154,20 +108,21 @@ function collectPath<T>(node: TreeNode<T>) {
return path; return path;
} }
function createTimelineChunk< function createTimelineChunk(
T extends Timeline<mastodon.DefaultPaginationParams>, timeline: Accessor<Timeline>,
>(timeline: Accessor<T>, params: Accessor<TimelineParamsOf<T>>) { limit: Accessor<number>,
) {
let vpMaxId: string | undefined, vpMinId: string | undefined; let vpMaxId: string | undefined, vpMinId: string | undefined;
const fetchExtendingPage = async ( const fetchExtendingPage = async (
tl: T, tl: Timeline,
direction: TimelineFetchDirection, direction: TimelineFetchDirection,
params: TimelineParamsOf<T>, limit: number,
) => { ) => {
switch (direction) { switch (direction) {
case "next": { case "next": {
const page = await tl const page = await tl
.list({ ...params, sinceId: vpMaxId }) .list({ limit, sinceId: vpMaxId })
.setDirection(direction) .setDirection(direction)
.next(); .next();
if ((page.value?.length ?? 0) > 0) { if ((page.value?.length ?? 0) > 0) {
@ -178,7 +133,7 @@ function createTimelineChunk<
case "prev": { case "prev": {
const page = await tl const page = await tl
.list({ ...params, maxId: vpMinId }) .list({ limit, maxId: vpMinId })
.setDirection(direction) .setDirection(direction)
.next(); .next();
if ((page.value?.length ?? 0) > 0) { if ((page.value?.length ?? 0) > 0) {
@ -190,11 +145,11 @@ function createTimelineChunk<
}; };
return createResource( return createResource(
() => [timeline(), params()] as const, () => [timeline(), limit()] as const,
async ( async (
[tl, params], [tl, limit],
info: ResourceFetcherInfo< info: ResourceFetcherInfo<
Readonly<TimelineChunk<TimelineParamsOf<T>>>, Readonly<TimelineChunk>,
TimelineFetchDirection TimelineFetchDirection
>, >,
) => { ) => {
@ -205,66 +160,27 @@ function createTimelineChunk<
vpMaxId = undefined; vpMaxId = undefined;
vpMinId = undefined; vpMinId = undefined;
} }
const posts = await fetchExtendingPage(tl, direction, params); const posts = await fetchExtendingPage(tl, direction, limit);
return { return {
tl, tl,
rebuilt: rebuildTimeline, rebuilt: rebuildTimeline,
chunk: posts.value ?? [], chunk: posts.value ?? [],
done: posts.done, done: posts.done,
direction, direction,
params, limit,
}; };
}, },
); );
} }
export type TimelineControls = { export function createTimeline(
/** timeline: Accessor<Timeline>,
* The threads. limit: Accessor<number>,
* ) {
* The identifiers here is the most-bottom toot id in the thread.
*
* @see You can use {@link TimelineControls.get} and {@link TimelineControls.getPath} to resolve them if
* the context is needed.
*/
list: readonly mastodon.v1.Status["id"][];
/**
* Get the single node.
*/
get(id: string): TreeNode<mastodon.v1.Status> | undefined;
/**
* Collect the path from the node to the most-top node.
*/
getPath(id: string): TreeNode<mastodon.v1.Status>[] | undefined;
/**
* Set the node value.
*/
set(id: string, value: mastodon.v1.Status): void;
};
export type TimelineResource<R> = [
TimelineControls,
Resource<R>,
{ refetch(info?: TimelineFetchDirection): void },
];
/**
* Create auto managed timeline controls.
*
* The error from the resource is not thrown in the
* {@link TimelineControls["list"]} and {@link TimelineControls}.get*.
* Use the second value from {@link TimelineResource} to catch the error.
*/
export function createTimeline<
T extends Timeline<mastodon.DefaultPaginationParams>,
>(
timeline: Accessor<T>,
params: Accessor<TimelineParamsOf<T>>,
): TimelineResource<TimelineChunk<TimelineParamsOf<T>> | undefined> {
const lookup = new ReactiveMap<string, TreeNode<mastodon.v1.Status>>(); const lookup = new ReactiveMap<string, TreeNode<mastodon.v1.Status>>();
const [threads, setThreads] = createStore([] as mastodon.v1.Status["id"][]); const [threads, setThreads] = createStore([] as mastodon.v1.Status["id"][]);
const [chunk, { refetch }] = createTimelineChunk(timeline, params); const [chunk, { refetch }] = createTimelineChunk(timeline, limit);
createEffect(() => { createEffect(() => {
const chk = catchError(chunk, (e) => console.error(e)); const chk = catchError(chunk, (e) => console.error(e));
@ -272,29 +188,24 @@ export function createTimeline<
return; return;
} }
if (chk.rebuilt) {
lookup.clear();
setThreads([]);
}
const existence = [] as boolean[]; const existence = [] as boolean[];
batch(() => { for (const [idx, status] of chk.chunk.entries()) {
if (chk.rebuilt) { existence[idx] = !!untrack(() => lookup.get(status.id));
lookup.clear(); lookup.set(status.id, {
setThreads([]); value: status,
} });
}
for (const [idx, status] of chk.chunk.entries()) {
existence[idx] = !!untrack(() => lookup.get(status.id));
lookup.set(status.id, {
value: status,
});
}
});
untrack(() => {
for (const status of chk.chunk) {
const node = lookup.get(status.id)!;
const parent = status.inReplyToId
? lookup.get(status.inReplyToId)
: undefined;
for (const status of chk.chunk) {
const node = untrack(() => lookup.get(status.id))!;
if (status.inReplyToId) {
const parent = lookup.get(status.inReplyToId);
if (parent) { if (parent) {
const children = parent.children ?? []; const children = parent.children ?? [];
if (!children.find((x) => x.value.id == status.id)) { if (!children.find((x) => x.value.id == status.id)) {
@ -304,7 +215,7 @@ export function createTimeline<
node.parent = parent; node.parent = parent;
} }
} }
}); }
const nThreadIds = chk.chunk const nThreadIds = chk.chunk
.filter((x, i) => !existence[i]) .filter((x, i) => !existence[i])
@ -317,18 +228,29 @@ export function createTimeline<
setThreads((threads) => [...nThreadIds, ...threads]); setThreads((threads) => [...nThreadIds, ...threads]);
} }
untrack(() => { setThreads((threads) =>
setThreads((threads) => threads.filter((id) => (lookup.get(id)!.children?.length ?? 0) === 0),
threads.filter((id) => (lookup.get(id)!.children?.length ?? 0) === 0), );
);
});
}); });
}); });
return [ return [
{ {
list: threads, list: threads,
...createControlsForLookup(lookup), get(id: string) {
return lookup.get(id);
},
getPath(id: string) {
const node = lookup.get(id);
if (!node) return;
return collectPath(node);
},
set(id: string, value: mastodon.v1.Status) {
const node = untrack(() => lookup.get(id));
if (!node) return;
node.value = value;
lookup.set(id, node);
},
}, },
chunk, chunk,
{ refetch }, { refetch },

View file

@ -1,6 +1,9 @@
.BottomSheet { .bottomSheet {
composes: surface from "./material.module.css";
composes: cardGutSkip from "./cards.module.css";
composes: cardNoPad from "./cards.module.css";
border: none; border: none;
position: fixed; position: absolute;
left: 50%; left: 50%;
top: 50%; top: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
@ -11,20 +14,20 @@
overscroll-behavior: none; overscroll-behavior: none;
max-height: 100vh; max-height: 100vh;
max-height: 100dvh; max-height: 100dvh;
height: 95%;
contain: strict;
contain-intrinsic-size: auto 560px auto 95vh;
&::backdrop { &::backdrop {
background: transparent; background-color: black;
opacity: 0.5;
transition: background-color 120ms var(--tutu-anim-curve-std); transition: opacity 220ms var(--tutu-anim-curve-std);
transition-behavior: allow-discrete;
} }
box-shadow: var(--tutu-shadow-e16); box-shadow: var(--tutu-shadow-e16);
:global(.MuiToolbar-root) > :global(.MuiButtonBase-root):first-child {
margin-left: -0.5em;
margin-right: 24px;
}
@media (max-width: 560px) { @media (max-width: 560px) {
& { & {
left: 0; left: 0;
@ -33,38 +36,42 @@
bottom: 0; bottom: 0;
height: 100vh; height: 100vh;
height: 100dvh; height: 100dvh;
max-height: 100vh;
contain-intrinsic-size: auto 100vw 100vh; max-height: 100dvh;
contain-intrinsic-size: auto 100dvw 100dvh;
} }
} }
&.animated { &.animated {
position: fixed; position: absolute;
transform: translateY(-50%);
overflow: hidden; overflow: hidden;
will-change: width, height, top, left; will-change: width, height, top, left;
&::backdrop {
opacity: 0;
}
& * { & * {
overflow: hidden; overflow: hidden;
} }
@media (max-width: 560px) {
& {
transform: none;
}
}
} }
&.bottom { &.bottom {
top: unset; top: unset;
transform: translateX(-50%); transform: translateX(-50%);
bottom: 0; bottom: 0;
height: auto;
contain: content;
contain-intrinsic-size: unset;
&[open]::backdrop {
background: var(--tutu-color-shadow-l1);
}
@media (max-width: 560px) { @media (max-width: 560px) {
& { & {
transform: none; transform: none;
height: auto; height: unset;
} }
} }
} }

View file

@ -4,33 +4,44 @@ import {
createSignal, createSignal,
onCleanup, onCleanup,
useTransition, useTransition,
type JSX,
type ParentComponent, type ParentComponent,
type ResolvedChildren, type ResolvedChildren,
} from "solid-js"; } from "solid-js";
import "./BottomSheet.css"; import styles from "./BottomSheet.module.css";
import material from "./material.module.css"; import { useHeroSignal } from "../platform/anim";
import {
ANIM_CURVE_ACELERATION,
ANIM_CURVE_DECELERATION,
} from "./theme";
import {
animateSlideInFromRight,
animateSlideOutToRight,
} from "~platform/anim";
export type BottomSheetProps = { export type BottomSheetProps = {
open?: boolean; open?: boolean;
bottomUp?: boolean; bottomUp?: boolean;
class?: JSX.HTMLAttributes<HTMLElement>["class"];
onClose?(reason: "backdrop"): void; onClose?(reason: "backdrop"): void;
}; };
const MOVE_SPEED = 1600; 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 = 1200;
const BottomSheet: ParentComponent<BottomSheetProps> = (props) => { const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
let element: HTMLDialogElement; let element: HTMLDialogElement;
let animation: Animation | undefined; let animation: Animation | undefined;
const [hero, setHero] = useHeroSignal(HERO);
const [cache, setCache] = createSignal<ResolvedChildren | undefined>(); const [cache, setCache] = createSignal<ResolvedChildren | undefined>();
const ochildren = children(() => props.children); const ochildren = children(() => props.children);
@ -51,52 +62,88 @@ const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
}); });
const onClose = () => { const onClose = () => {
const srcElement = hero();
if (srcElement) {
srcElement.style.visibility = "unset";
}
element.close(); element.close();
setHero();
}; };
const animatedClose = () => { const animatedClose = () => {
const srcElement = hero();
const endRect = srcElement?.getBoundingClientRect();
if (endRect) {
const startRect = element.getBoundingClientRect();
const animation = animateHero(startRect, endRect, element, true);
animation.addEventListener("finish", onClose);
animation.addEventListener("cancel", onClose);
} else {
if (window.innerWidth > 560 && !props.bottomUp) { if (window.innerWidth > 560 && !props.bottomUp) {
onClose(); onClose();
return; return;
} }
const onAnimationEnd = () => { const animation = props.bottomUp
element.classList.remove("animated");
onClose();
};
element.classList.add("animated");
animation = props.bottomUp
? animateSlideInFromBottom(element, true) ? animateSlideInFromBottom(element, true)
: animateSlideOutToRight(element, { easing: ANIM_CURVE_ACELERATION }); : animateSlideInFromRight(element, true);
animation.addEventListener("finish", onAnimationEnd); animation.addEventListener("finish", onClose);
animation.addEventListener("cancel", onAnimationEnd); animation.addEventListener("cancel", onClose);
}
}; };
const animatedOpen = () => { const animatedOpen = () => {
element.showModal(); element.showModal();
if (props.bottomUp) { const srcElement = hero();
const startRect = srcElement?.getBoundingClientRect();
if (startRect) {
srcElement!.style.visibility = "hidden";
const endRect = element.getBoundingClientRect();
animateHero(startRect, endRect, element);
} else if (props.bottomUp) {
animateSlideInFromBottom(element); animateSlideInFromBottom(element);
} else if (window.innerWidth <= 560) { } else if (window.innerWidth <= 560) {
element.classList.add("animated"); animateSlideInFromRight(element);
const onAnimationEnd = () => {
element.classList.remove("animated");
};
animation = animateSlideInFromRight(element, {
easing: ANIM_CURVE_DECELERATION,
});
animation.addEventListener("finish", onAnimationEnd);
animation.addEventListener("cancel", onAnimationEnd);
} }
}; };
const animateSlideInFromRight = (element: HTMLElement, reserve?: boolean) => {
const rect = element.getBoundingClientRect();
const easing = "cubic-bezier(0.4, 0, 0.2, 1)";
element.classList.add(styles.animated);
const oldOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const distance = Math.abs(rect.left - window.innerWidth);
const duration = (distance / MOVE_SPEED) * 1000;
animation = element.animate(
{
top: [`${rect.top}px`, `${rect.top}px`],
left: reserve
? [`${rect.left}px`, `${window.innerWidth}px`]
: [`${window.innerWidth}px`, `${rect.left}px`],
width: [`${rect.width}px`, `${rect.width}px`],
height: [`${rect.height}px`, `${rect.height}px`],
},
{ easing, duration },
);
const onAnimationEnd = () => {
element.classList.remove(styles.animated);
document.body.style.overflow = oldOverflow;
animation = undefined;
};
animation.addEventListener("cancel", onAnimationEnd);
animation.addEventListener("finish", onAnimationEnd);
return animation;
};
const animateSlideInFromBottom = ( const animateSlideInFromBottom = (
element: HTMLElement, element: HTMLElement,
reserve?: boolean, reserve?: boolean,
) => { ) => {
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
const easing = "cubic-bezier(0.4, 0, 0.2, 1)"; const easing = "cubic-bezier(0.4, 0, 0.2, 1)";
element.classList.add("animated"); element.classList.add(styles.animated);
const oldOverflow = document.body.style.overflow; const oldOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden"; document.body.style.overflow = "hidden";
const distance = Math.abs(rect.top - window.innerHeight); const distance = Math.abs(rect.top - window.innerHeight);
@ -104,14 +151,17 @@ const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
animation = element.animate( animation = element.animate(
{ {
left: [`${rect.left}px`, `${rect.left}px`],
top: reserve top: reserve
? [`${rect.top}px`, `${window.innerHeight}px`] ? [`${rect.top}px`, `${window.innerHeight}px`]
: [`${window.innerHeight}px`, `${rect.top}px`], : [`${window.innerHeight}px`, `${rect.top}px`],
width: [`${rect.width}px`, `${rect.width}px`],
height: [`${rect.height}px`, `${rect.height}px`],
}, },
{ easing, duration }, { easing, duration },
); );
const onAnimationEnd = () => { const onAnimationEnd = () => {
element.classList.remove("animated"); element.classList.remove(styles.animated);
document.body.style.overflow = oldOverflow; document.body.style.overflow = oldOverflow;
animation = undefined; animation = undefined;
}; };
@ -120,6 +170,35 @@ const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
return animation; return animation;
}; };
const animateHero = (
startRect: DOMRect,
endRect: DOMRect,
element: HTMLElement,
reserve?: boolean,
) => {
const easing = "cubic-bezier(0.4, 0, 0.2, 1)";
element.classList.add(styles.animated);
const distance = Math.sqrt(
Math.pow(Math.abs(startRect.top - endRect.top), 2) +
Math.pow(Math.abs(startRect.left - startRect.top), 2),
);
const duration = (distance / MOVE_SPEED) * 1000;
animation = element.animate(
[
composeAnimationFrame(startRect, { transform: "none" }),
composeAnimationFrame(endRect, { transform: "none" }),
],
{ easing, duration },
);
const onAnimationEnd = () => {
element.classList.remove(styles.animated);
animation = undefined;
};
animation.addEventListener("finish", onAnimationEnd);
animation.addEventListener("cancel", onAnimationEnd);
return animation;
};
onCleanup(() => { onCleanup(() => {
if (animation) { if (animation) {
animation.cancel(); animation.cancel();
@ -129,35 +208,25 @@ const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
const onDialogClick = ( const onDialogClick = (
event: MouseEvent & { currentTarget: HTMLDialogElement }, event: MouseEvent & { currentTarget: HTMLDialogElement },
) => { ) => {
if (event.target !== event.currentTarget) return;
const rect = event.currentTarget.getBoundingClientRect(); const rect = event.currentTarget.getBoundingClientRect();
const isNotInDialog = const isInDialog =
event.clientY < rect.top || rect.top <= event.clientY &&
event.clientY > rect.bottom || event.clientY <= rect.top + rect.height &&
event.clientX < rect.left || rect.left <= event.clientX &&
event.clientX > rect.right; event.clientX <= rect.left + rect.width;
if (isNotInDialog) { if (!isInDialog) {
props.onClose?.("backdrop"); props.onClose?.("backdrop");
} }
}; };
const onDialogCancel = (event: Event) => {
event.preventDefault();
props.onClose?.("backdrop");
};
return ( return (
<dialog <dialog
class={`BottomSheet ${material.surface} ${props.class || ""}`}
classList={{ classList={{
["bottom"]: props.bottomUp, [styles.bottomSheet]: true,
[styles.bottom]: props.bottomUp,
}} }}
onClick={onDialogClick} onClick={onDialogClick}
onCancel={onDialogCancel}
ref={element!} ref={element!}
tabIndex={-1}
role="presentation"
> >
{ochildren() ?? cache()} {ochildren() ?? cache()}
</dialog> </dialog>

View file

@ -1,6 +1,5 @@
import { Component, JSX, splitProps } from "solid-js"; import { Component, JSX, splitProps } from "solid-js";
import materialStyles from "./material.module.css"; import materialStyles from "./material.module.css";
import "./typography.css";
/** /**
* Material-styled button. * Material-styled button.
@ -11,15 +10,12 @@ const Button: Component<JSX.ButtonHTMLAttributes<HTMLButtonElement>> = (
props, props,
) => { ) => {
const [managed, passthough] = splitProps(props, ["class", "type"]); const [managed, passthough] = splitProps(props, ["class", "type"]);
const classes = () =>
managed.class
? [materialStyles.button, managed.class].join(" ")
: materialStyles.button;
const type = () => managed.type ?? "button"; const type = () => managed.type ?? "button";
return <button type={type()} class={classes()} {...passthough}></button>;
return (
<button
type={type()}
class={`${materialStyles.button} buttonText ${managed.class || ""}`}
{...passthough}
></button>
);
}; };
export default Button; export default Button;

View file

@ -1,36 +0,0 @@
.Menu {
position: fixed;
border: 1px solid var(--tutu-color-surface-d);
border-radius: 2px;
padding: 0;
max-width: 560px;
width: max-content;
box-shadow: var(--tutu-shadow-e8);
contain: content;
overscroll-behavior: contain;
&.e1 {
box-shadow: var(--tutu-shadow-e9);
}
&.e2 {
box-shadow: var(--tutu-shadow-e10);
}
&.e3 {
box-shadow: var(--tutu-shadow-e11);
}
&.e4 {
box-shadow: var(--tutu-shadow-e12);
}
&>.container {
background: var(--tutu-color-surface);
display: contents;
}
}
dialog.Menu::backdrop {
background: none;
}

View file

@ -1,281 +0,0 @@
import { useWindowSize } from "@solid-primitives/resize-observer";
import { MenuList } from "@suid/material";
import {
batch,
createEffect,
createSignal,
splitProps,
type Component,
type JSX,
type ParentProps,
} from "solid-js";
import { ANIM_CURVE_STD } from "./theme";
import "./Menu.css";
import {
animateGrowFromTopRight,
animateShrinkToTopRight,
} from "~platform/anim";
import type { MenuListProps } from "@suid/material/MenuList";
export type Anchor = Pick<DOMRect, "top" | "left" | "right"> & { e?: number };
export type MenuProps = ParentProps<
{
open?: boolean;
onClose?: JSX.EventHandlerUnion<HTMLDialogElement, Event>;
anchor: () => Anchor;
MenuListProps?: MenuListProps;
id?: string;
} & JSX.AriaAttributes
>;
function px(n?: number) {
if (n) {
return `${n}px`;
} else {
return undefined;
}
}
/**
* Create managed state for {@link Menu}. This function
* expose an "open" closure for you to open the menu. The
* opening and closing is automatically managed internally.
*
* @returns The first element is the "open" closure, calls
* with anchor infomation to open the menu.
* The second element is the state props for {@link Menu}, use
* spread syntax to set the props.
* @example
* ````tsx
* const [openMenu, menuState] = createManagedMenuState();
*
* <Menu {...menuState}></Menu>
*
* <Button onClick={event => openMenu(event.currectTarget.getBoundingClientRect())} />
* ````
*/
export function createManagedMenuState() {
const [anchor, setAnchor] = createSignal<Anchor>();
return [
setAnchor,
{
get open() {
return !!anchor();
},
anchor: anchor as () => Anchor,
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.
*
* Notes:
* - 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> = (oprops) => {
let root: HTMLDialogElement;
const windowSize = useWindowSize();
const [props, rest] = splitProps(oprops, [
"open",
"onClose",
"anchor",
"MenuListProps",
"children",
]);
const [anchorPos, setAnchorPos] = createSignal<{
left?: number;
top?: number;
e?: number;
}>({});
if (import.meta.env.DEV) {
createEffect(() => {
if (anchorPos().e)
switch (anchorPos().e) {
case 1:
case 2:
case 3:
case 4:
return;
default:
console.warn('value %s is invalid for param "e"', anchorPos().e);
}
});
}
let openAnimationOrigin: "lt" | "rt" = "lt";
const animateOpen = () => {
const a = props.anchor();
const { width } = windowSize;
const { left, top, right, e } = a;
const isOpened = root.open;
// There are incomplete animations.
// For `getBoundingClientRect()`, WebKit reports the initial state
// of the element, whilst Firefox reports the final state.
//
// We skip if animations are still on the element
// to avoid the problem on WebKit.
// Here use the final state.
//
// This is a dirty workaround. It's here because the feature is still
// works with it.
// I am curious that why the ones on the other parts are works. (Rubicon)
if (root.getAnimations().length > 0) {
return;
}
root.showModal();
const rend = root.getBoundingClientRect();
if (left > width / 2) {
openAnimationOrigin = "rt";
setAnchorPos({
left: right - rend.width,
top,
e,
});
} else {
openAnimationOrigin = "lt";
setAnchorPos({ left, top, e });
}
if (!isOpened) {
switch (openAnimationOrigin) {
case "lt":
animateGrowFromTopLeft(root, { easing: ANIM_CURVE_STD });
break;
case "rt":
animateGrowFromTopRight(root, { easing: ANIM_CURVE_STD });
break;
}
}
};
createEffect(() => {
if (props.open) {
animateOpen();
} else {
animateClose();
}
});
const animateClose = () => {
const rend = root.getBoundingClientRect();
if (openAnimationOrigin === "lt") {
const overflow = root.style.overflow;
root.style.overflow = "hidden";
const animation = root.animate(
{
height: [`${rend.height}px`, `${rend.height / 2}px`],
width: [`${rend.width}px`, `${(rend.width / 4) * 3}px`],
},
{
duration: (rend.height / 2 / 1600) * 1000,
easing: ANIM_CURVE_STD,
},
);
animation.addEventListener("finish", () => {
root.style.overflow = overflow;
root.close();
});
} else {
const animation = animateShrinkToTopRight(root, {
easing: ANIM_CURVE_STD,
});
animation.addEventListener("finish", () => {
root.close();
});
}
};
const onDialogClick = (
event: MouseEvent & { currentTarget: HTMLDialogElement },
) => {
event.stopPropagation();
if (event.currentTarget !== event.target) return;
if (!event.currentTarget.open) return;
const rect = event.currentTarget.getBoundingClientRect();
const isNotInDialog =
event.clientY < rect.top ||
event.clientY > rect.bottom ||
event.clientX < rect.left ||
event.clientX > rect.right;
if (isNotInDialog) {
if (props.onClose) {
if (Array.isArray(props.onClose)) {
props.onClose[0](props.onClose[1], event);
} else {
(
props.onClose as (
event: Event & { currentTarget: HTMLDialogElement },
) => void
)(event);
}
}
}
};
return (
<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),
/* FIXME: the content may be overflow */
}}
role="presentation"
tabIndex={-1}
{...rest}
>
<div class="container" role="presentation">
<MenuList {...props.MenuListProps}>{props.children}</MenuList>
</div>
</dialog>
);
};
export default Menu;

View file

@ -1,44 +0,0 @@
.Scaffold>.topbar {
position: sticky;
top: 0px;
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;
margin-right: 24px;
}
&:last-child {
margin-right: -0.5em;
margin-left: 24px;
}
}
}
}
.Scaffold>.fab-dock {
position: fixed;
bottom: 40px;
right: 40px;
z-index: var(--tutu-zidx-nav, auto);
}
.Scaffold>.bottom-dock {
position: sticky;
bottom: 0;
left: 0;
right: 0;
z-index: var(--tutu-zidx-nav, auto);
}
.Scaffold {
height: 100%;
width: 100%;
background-color: var(--tutu-color-surface);
}

View file

@ -1,75 +1,68 @@
import { createElementSize } from "@solid-primitives/resize-observer"; import { createElementSize } from "@solid-primitives/resize-observer";
import { import {
JSX,
Show, Show,
createRenderEffect, createRenderEffect,
createSignal, createSignal,
splitProps, onCleanup,
type Component, type JSX,
type ParentProps, type ParentComponent,
} from "solid-js"; } from "solid-js";
import "./Scaffold.css"; import { css } from "solid-styled";
type ScaffoldProps = ParentProps< interface ScaffoldProps {
{ topbar?: JSX.Element;
topbar?: JSX.Element; fab?: JSX.Element;
fab?: JSX.Element; bottom?: JSX.Element;
bottom?: JSX.Element; }
} & JSX.HTMLElementTags["div"]
>;
/** const Scaffold: ParentComponent<ScaffoldProps> = (props) => {
* The passthrough props are passed to the content container.
*/
const Scaffold: Component<ScaffoldProps> = (props) => {
const [managed, rest] = splitProps(props, [
"topbar",
"fab",
"bottom",
"children",
"ref",
"class",
]);
const [topbarElement, setTopbarElement] = createSignal<HTMLElement>(); const [topbarElement, setTopbarElement] = createSignal<HTMLElement>();
const topbarSize = createElementSize(topbarElement); const topbarSize = createElementSize(topbarElement);
return ( css`
<div .scaffold-content {
class={`Scaffold ${managed.class || ""}`} --scaffold-topbar-height: ${(topbarSize.height?.toString() ?? 0) + "px"};
ref={(e) => { }
createRenderEffect(() => {
e.style.setProperty(
"--scaffold-topbar-height",
(topbarSize.height?.toString() ?? 0) + "px",
);
});
if (managed.ref) { .topbar {
(managed.ref as (val: typeof e) => void)(e); position: sticky;
} top: 0px;
}} z-index: var(--tutu-zidx-nav, auto);
{...rest} }
>
.fab-dock {
position: fixed;
bottom: 40px;
right: 40px;
z-index: var(--tutu-zidx-nav, auto);
}
.bottom-dock {
position: sticky;
bottom: 0;
left: 0;
right: 0;
z-index: var(--tutu-zidx-nav, auto);
padding-bottom: var(--safe-area-inset-bottom, 0);
}
`;
return (
<>
<Show when={props.topbar}> <Show when={props.topbar}>
<div class="topbar" ref={setTopbarElement} role="presentation"> <div class="topbar" ref={setTopbarElement}>
{props.topbar} {props.topbar}
</div> </div>
</Show> </Show>
<Show when={props.fab}> <Show when={props.fab}>
<div class="fab-dock" role="presentation"> <div class="fab-dock">{props.fab}</div>
{props.fab}
</div>
</Show> </Show>
<div class="scaffold-content">{props.children}</div>
{managed.children}
<Show when={props.bottom}> <Show when={props.bottom}>
<div class="bottom-dock" role="presentation"> <div class="bottom-dock">{props.bottom}</div>
{props.bottom}
</div>
</Show> </Show>
</div> </>
); );
}; };

View file

@ -10,6 +10,7 @@
} }
.button { .button {
composes: buttonText from "./typography.module.css";
composes: touchTarget; composes: touchTarget;
border: none; border: none;

View file

@ -2,9 +2,6 @@ import { Theme, createTheme } from "@suid/material/styles";
import { deepPurple, amber } from "@suid/material/colors"; import { deepPurple, amber } from "@suid/material/colors";
import { Accessor } from "solid-js"; import { Accessor } from "solid-js";
/**
* The MUI theme.
*/
export function useRootTheme(): Accessor<Theme> { export function useRootTheme(): Accessor<Theme> {
return () => return () =>
createTheme({ createTheme({
@ -18,8 +15,3 @@ export function useRootTheme(): Accessor<Theme> {
}, },
}); });
} }
export const ANIM_CURVE_STD = "cubic-bezier(0.4, 0, 0.2, 1)";
export const ANIM_CURVE_DECELERATION = "cubic-bezier(0, 0, 0.2, 1)";
export const ANIM_CURVE_ACELERATION = "cubic-bezier(0.4, 0, 1, 1)";
export const ANIM_CURVE_SHARP = "cubic-bezier(0.4, 0, 0.6, 1)";

View file

@ -82,44 +82,27 @@
--tutu-color-error-on-surface: #d32f2f; --tutu-color-error-on-surface: #d32f2f;
--tutu-color-inactive-on-surface: #757575; --tutu-color-inactive-on-surface: #757575;
--tutu-color-shadow: rgba(0, 0, 0, 0.45); --tutu-shadow-e1: 0px 1px 2px 0px #9e9e9e;
--tutu-color-shadow-l1: rgba(0, 0, 0, 0.4);
--tutu-color-shadow-l2: rgba(0, 0, 0, 0.35);
/* Switch */ /* Switch */
--tutu-shadow-e1: 0px 1px 2px 0px var(--tutu-color-shadow); --tutu-shadow-e2: 0px 2px 4px 0px #9e9e9e;
/* (Resting) cards, raised button, quick entry / search bar */ /* (Resting) cards, raised button, quick entry / search bar */
--tutu-shadow-e2: 0px 2px 4px 0px var(--tutu-color-shadow); --tutu-shadow-e3: 0px 3px 6px 0px #9e9e9e;
/* Refresh indicator, quick entry / search bar (scrolled) */ /* Refresh indicator, quick entry / search bar (scrolled) */
--tutu-shadow-e3: 0px 3px 6px 0px var(--tutu-color-shadow); --tutu-shadow-e4: 0px 4px 8px 0px #9e9e9e;
/* App bar */ /* App bar */
--tutu-shadow-e4: 0px 4px 8px 0px var(--tutu-color-shadow); --tutu-shadow-e6: 0px 6px 12px 0px #9e9e9e;
/* Snack bar, FAB (resting) */ /* Snack bar, FAB (resting) */
--tutu-shadow-e6: 0px 6px 12px 0px var(--tutu-color-shadow); --tutu-shadow-e8: 0px 8px 16px 0px #9e9e9e;
/* Menu, (picked-up) cards, (pressed) raise button */ /* Menu, (picked-up) cards, (pressed) raise button */
--tutu-shadow-e8: 0px 8px 16px 0px var(--tutu-color-shadow); --tutu-shadow-e9: 0px 9px 18px 0px #9e9e9e;
/* Submenu (+1dp for each submenu) */ /* Submenu (+1dp for each submenu) */
--tutu-shadow-e9: 0px 9px 18px 0px var(--tutu-color-shadow); --tutu-shadow-e12: 0px 12px 24px 0px #9e9e9e;
--tutu-shadow-e10: 0px 10px 18px 0px var(--tutu-color-shadow);
--tutu-shadow-e11: 0px 11px 18px 0px var(--tutu-color-shadow-l1);
/* (pressed) FAB */ /* (pressed) FAB */
--tutu-shadow-e12: 0px 12px 24px 0px var(--tutu-color-shadow-l1); --tutu-shadow-e16: 0px 16px 32px 0px #9e9e9e;
/* Nav drawer, right drawer, modal bottom sheet */ /* Nav drawer, right drawer, modal bottom sheet */
--tutu-shadow-e16: 0px 16px 32px 0px var(--tutu-color-shadow-l1); --tutu-shadow-e24: 0px 24px 48px 0px #9e9e9e;
/* Dialog, picker */ /* Dialog, picker */
--tutu-shadow-e24: 0px 24px 48px 0px var(--tutu-color-shadow-l2);
/* curves are also hard-coded in theme.ts */
--tutu-anim-curve-std: cubic-bezier(0.4, 0, 0.2, 1); --tutu-anim-curve-std: cubic-bezier(0.4, 0, 0.2, 1);
--tutu-anim-curve-deceleration: cubic-bezier(0, 0, 0.2, 1); --tutu-anim-curve-deceleration: cubic-bezier(0, 0, 0.2, 1);
--tutu-anim-curve-aceleration: cubic-bezier(0.4, 0, 1, 1); --tutu-anim-curve-aceleration: cubic-bezier(0.4, 0, 1, 1);

View file

@ -29,11 +29,12 @@
font-size: var(--subheading-size); font-size: var(--subheading-size);
} }
.body1, .body2 { .body1 {
font-size: var(--body-size); font-size: var(--body-size);
} }
.body2 { .body2 {
composes: body1;
font-weight: var(--body2-weight); font-weight: var(--body2-weight);
} }

View file

@ -1,6 +1,7 @@
import { JSX, ParentComponent, splitProps, type Ref } from "solid-js"; import { JSX, ParentComponent, splitProps, type Ref } from "solid-js";
import { Dynamic } from "solid-js/web"; import { Dynamic } from "solid-js/web";
import "./typography.css"; import typography from "./typography.module.css";
import { mergeClass } from "../utils";
type AnyElement = keyof JSX.IntrinsicElements | ParentComponent<any>; type AnyElement = keyof JSX.IntrinsicElements | ParentComponent<any>;
@ -39,11 +40,13 @@ export function Typography<T extends AnyElement>(
"class", "class",
"typography", "typography",
]); ]);
const classes = () =>
mergeClass(managed.class, typography[managed.typography]);
return ( return (
<Dynamic <Dynamic
ref={managed.ref} ref={managed.ref}
component={managed.component ?? "span"} component={managed.component ?? "span"}
class={`${managed.class || ""} ${managed.typography}`} class={classes()}
{...passthough} {...passthough}
></Dynamic> ></Dynamic>
); );

12
src/overrides.d.ts vendored
View file

@ -3,18 +3,6 @@
interface ImportMetaEnv { interface ImportMetaEnv {
readonly BUILT_AT: string; readonly BUILT_AT: string;
readonly PACKAGE_VERSION: string; readonly PACKAGE_VERSION: string;
/**
* The code reversion. It's recommended to be the git commit sha.
*/
readonly VITE_CODE_VERSION?: string;
/**
* 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 { interface ImportMeta {

View file

@ -1,21 +0,0 @@
import { splitProps, type JSX } from "solid-js";
import { useNavigator } from "./StackedRouter";
import { useResolvedPath } from "@solidjs/router";
function handleClick(
push: (name: string, state: unknown) => void,
event: MouseEvent & { currentTarget: HTMLAnchorElement },
) {
const target = event.currentTarget;
event.preventDefault();
push(target.href, { state: target.getAttribute("state") || undefined });
}
const A = (oprops: Omit<JSX.HTMLElementTags["a"], "onClick" | "onclick">) => {
const [props, rest] = splitProps(oprops, ["href"]);
const resolvedPath = useResolvedPath(() => props.href || "#");
const { push } = useNavigator();
return <a onClick={[handleClick, push]} href={resolvedPath()} {...rest}></a>;
};
export default A;

View file

@ -1,24 +0,0 @@
import type { IconButtonProps } from "@suid/material/IconButton";
import IconButton from "@suid/material/IconButton";
import { Show, type Component } from "solid-js";
import { useCurrentFrame, useNavigator } from "./StackedRouter";
import { ArrowBack, Close } from "@suid/icons-material";
export type BackButtonProps = Omit<IconButtonProps, "onClick" | "children">;
const BackButton: Component<BackButtonProps> = (props) => {
const currentFrame = useCurrentFrame();
const { pop } = useNavigator();
const hasPrevSubPage = () => currentFrame().index > 1;
return (
<IconButton onClick={[pop, 1]} {...props}>
<Show when={hasPrevSubPage()} fallback={<Close />}>
<ArrowBack />
</Show>
</IconButton>
);
};
export default BackButton;

View file

@ -1,16 +0,0 @@
.CompatMasonry>* {
margin-bottom: var(--Masonry-row-gap);
}
@supports (grid-template-rows: masonry) {
.NativeMasonry {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(44px, min-content));
grid-template-rows: masonry;
&:has(> :last-child:nth-child(2n)) {
grid-template-columns: repeat(2, minmax(auto, min-content));
}
}
}

View file

@ -1,158 +0,0 @@
import {
type Component,
type JSX,
splitProps,
type Ref,
createRenderEffect,
onCleanup,
children,
createEffect,
createSignal,
onMount,
} 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;
children?: JSX.Element;
}>;
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 createMasonry(element: Element, options: () => MasonryLayout.Options) {
const layout = new MasonryLayout(element, {
initLayout: false,
});
onCleanup(() => layout.destroy?.());
const size = createElementSize(element);
createRenderEffect(() => {
const opts = options();
layout.option?.(opts);
});
createRenderEffect(() => {
const width = size.width; // only tracking width
layout.layout?.();
});
if (import.meta.hot) {
import.meta.hot.on("vite:afterUpdate", () => {
layout.layout?.();
});
}
return 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", "children", "class"]);
const childrenComponents = children(() => props.children);
return (
<Dynamic
ref={(element: ElementOf<T>) => {
forwardRef(element, props.ref as Ref<typeof element> | undefined);
const [columnGap, setColumnGap] = createSignal<number>();
const layout = createMasonry(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)));
}
});
createRenderEffect(() => {
childrenComponents(); // just tracks
setTimeout(() => {
layout.reloadItems?.();
layout.layout?.();
}, 0);
});
}}
class={`Masonry CompatMasonry ${props.class || ""}`}
{...rest}
children={childrenComponents}
/>
);
}
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.
*
* **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,5 +0,0 @@
.SizedTextarea {
overflow-y: hidden;
width: 100%;
resize: vertical;
}

View file

@ -1,63 +0,0 @@
import { splitProps, type Component, type JSX } from "solid-js";
import "./SizedTextarea.css";
function isBoundEventHandler<T, E extends Event>(
handler: JSX.EventHandlerUnion<T, E>,
): handler is JSX.BoundEventHandler<T, E> {
return Array.isArray(handler);
}
function callEventHandlerUnion<T extends EventTarget, E extends Event>(
handler: JSX.EventHandlerUnion<T, E>,
event: E & { currentTarget: T; target: Element },
) {
if (isBoundEventHandler(handler)) {
const fn = handler[0],
value = handler[1];
fn(value, event);
} else {
(handler as (e: typeof event) => void).bind(event.target)(event);
}
}
function onTextareaRefreshHeight<
E extends Event & {
currentTarget: HTMLTextAreaElement;
target: HTMLTextAreaElement;
},
>(
ocallback: JSX.EventHandlerUnion<HTMLTextAreaElement, E> | undefined,
event: E,
) {
const element = event.currentTarget;
element.style.removeProperty("height");
element.style.height = `${element.scrollHeight + 2}px`;
if (ocallback) {
callEventHandlerUnion(ocallback, event);
}
}
/**
* The <textarea /> automatically vertically sized as the content.
*
* Note: listens the "focus" and "input" event using `addEventListener()`
* may not work - use the event listening syntax on the component instead.
* If you find it work, tell Rubicon to remove this note.
*/
const SizedTextarea: Component<JSX.HTMLElementTags["textarea"]> = (oprops) => {
const [props, rest] = splitProps(oprops, ["onInput", "onFocus", "class"]);
return (
<textarea
onInput={(event) =>
onTextareaRefreshHeight<typeof event>(props.onInput, event)
}
onFocus={[onTextareaRefreshHeight, props.onFocus]}
class={`SizedTextarea ${props.class || ""}`}
{...rest}
></textarea>
);
};
export default SizedTextarea;

View file

@ -1,68 +0,0 @@
.StackedPage {
container: StackedPage / size;
display: contents;
max-width: 100vw;
max-width: 100dvw;
contain: layout;
}
dialog.StackedPage {
border: none;
position: fixed;
padding: 0;
overscroll-behavior: none;
width: 560px;
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;
content-visibility: auto;
background: var(--tutu-color-surface);
box-shadow: var(--tutu-shadow-e16);
margin-left: auto;
margin-right: auto;
@media (max-width: 560px) {
& {
margin: 0;
width: 100vw;
width: 100dvw;
contain-intrinsic-size: 100vw 100vh;
contain-intrinsic-size: 100dvw 100dvh;
}
}
&[open] {
display: contents;
}
&::backdrop {
background: none;
}
&.animating {
overflow: hidden;
* {
overflow: hidden;
}
}
}

View file

@ -1,592 +0,0 @@
import { StaticRouter, type RouterProps } from "@solidjs/router";
import {
Component,
createContext,
createMemo,
createRenderEffect,
createUniqueId,
Index,
onMount,
Show,
untrack,
useContext,
type Accessor,
} from "solid-js";
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 { useWindowSize } from "@solid-primitives/resize-observer";
export type StackedRouterProps = Omit<RouterProps, "url">;
export type StackFrame = {
path: string;
rootId: string;
state: unknown;
animateOpen?: (element: HTMLElement) => Animation;
animateClose?: (element: HTMLElement) => Animation;
};
export type NewFrameOptions<T> = (T extends undefined
? {
state?: T;
}
: { state: T }) & {
/**
* The new frame should replace the current frame.
*/
replace?: boolean;
/**
* The animatedOpen phase of the life cycle.
*
* You can use this hook to animate the opening
* of the frame. In this phase, the frame content is created
* and is mounted to the document.
*
* You must return an {@link Animation}. This function must be
* without side effects. This phase is ended after the {@link Animation}
* finished.
*/
animateOpen?: StackFrame["animateOpen"];
/**
* The animatedClose phase of the life cycle.
*
* You can use this hook to animate the closing of the frame.
* In this phase, the frame content is still mounted in the
* document and will be unmounted after this phase.
*
* You must return an {@link Animation}. This function must be
* without side effects. This phase is ended after the
* {@link Animation} finished.
*/
animateClose?: StackFrame["animateClose"];
};
export type FramePusher<T, K extends keyof T = keyof T> = T[K] extends
| undefined
| any
? (path: K, state?: Readonly<NewFrameOptions<T[K]>>) => Readonly<StackFrame>
: (path: K, state: Readonly<NewFrameOptions<T[K]>>) => Readonly<StackFrame>;
export type Navigator<PushGuide = Record<string, any>> = {
frames: readonly StackFrame[];
push: FramePusher<PushGuide>;
pop: (depth?: number) => void;
};
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);
}
/**
* Get the navigator of the {@link StackedRouter}.
*
* This function returns a {@link Navigator} without available
* push guide. Push guide is a record type contains available
* 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();
if (!navigator) {
throw new TypeError("not in available scope of StackedRouter");
}
return navigator;
}
export type CurrentFrame = {
index: number;
frame: Readonly<StackFrame>;
};
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();
if (!frame) {
throw new TypeError("not in available scope of StackedRouter");
}
return frame;
}
/**
* Return an accessor of is current frame is suspended.
*
* 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 useIsFrameSuspended() {
const { frames } = useMaybeNavigator() || {};
if (typeof frames === "undefined") {
return () => false;
}
const thisFrame = useCurrentFrame();
return () => {
const idx = thisFrame().index;
return frames.length - 1 > idx;
};
}
function onDialogClick(
onClose: () => void,
event: MouseEvent & { currentTarget: HTMLDialogElement },
) {
if (event.target !== event.currentTarget) 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) {
onClose();
}
}
function animateClose(element: HTMLElement) {
if (window.innerWidth <= 560) {
return animateSlideOutToRight(element, { easing: ANIM_CURVE_DECELERATION });
} else {
return element.animate(
{
opacity: [0.5, 0],
},
{ easing: ANIM_CURVE_STD, duration: 220 },
);
}
}
function animateOpen(element: HTMLElement) {
if (window.innerWidth <= 560) {
return animateSlideInFromRight(element, {
easing: ANIM_CURVE_DECELERATION,
});
} else {
return element.animate(
{
opacity: [0.5, 1],
},
{ easing: ANIM_CURVE_STD, duration: 220 },
);
}
}
function serializableStack(stack: readonly StackFrame[]) {
const frames = unwrap(stack);
return frames.map((fr) => {
return fr.animateClose || fr.animateOpen
? {
path: fr.path,
rootId: fr.rootId,
state: fr.state,
}
: fr;
});
}
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(() => {
const frame = {
path,
state: opts?.state,
rootId: createUniqueId(),
animateOpen: opts?.animateOpen,
animateClose: opts?.animateClose,
};
mutStack(opts?.replace ? stack.length - 1 : stack.length, frame);
if (opts?.replace) {
window.history.replaceState(serializableStack(stack), "", path);
} else {
window.history.pushState(serializableStack(stack), "", path);
}
return frame;
});
const onlyPopFrame = (depth: number) => {
mutStack((o) => o.toSpliced(o.length - depth, depth));
window.history.go(-depth);
};
const popFrame = (depth: number = 1) =>
untrack(() => {
if (import.meta.env.DEV) {
if (depth < 0) {
console.warn("the depth to pop should not < 0, now is", depth);
}
}
if (stack.length > 1) {
const lastFrame = stack[stack.length - 1];
const element = document.getElementById(
lastFrame.rootId,
)! as HTMLDialogElement;
const createAnimation = lastFrame.animateClose ?? animateClose;
requestAnimationFrame(() => {
element.classList.add("animating");
const animation = createAnimation(element);
animation.addEventListener("finish", () => {
element.classList.remove("animating");
onlyPopFrame(depth);
});
});
} else {
onlyPopFrame(depth);
}
});
createRenderEffect(() => {
if (stack.length === 0) {
mutStack(0, {
path: window.location.pathname,
rootId: createUniqueId(),
});
}
});
createRenderEffect(() => {
makeEventListener(window, "popstate", (event) => {
if (!event.state) return;
if (stack.length === 0) {
mutStack(event.state);
} else if (stack.length > event.state.length) {
popFrame(stack.length - event.state.length);
}
});
});
const onBeforeDialogMount = (element: HTMLDialogElement) => {
onMount(() => {
const lastFr = untrack(() => stack[stack.length - 1]);
const createAnimation = lastFr.animateOpen ?? animateOpen;
requestAnimationFrame(() => {
element.showModal();
element.classList.add("animating");
const animation = createAnimation(element);
animation.addEventListener("finish", () =>
element.classList.remove("animating"),
);
});
});
};
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 ofs = (totalWidth - width) / 2;
return {
"--safe-area-inset-left": `${Math.max(left - ofs, 0)}px`,
"--safe-area-inset-right": `${Math.max(right - ofs, 0)}px`,
};
});
const swipeToBackProps = createManagedSwipeToBack(stack, onlyPopFrame);
return (
<NavigatorContext.Provider
value={{
push: pushFrame,
pop: popFrame,
frames: stack,
}}
>
<Index each={stack}>
{(frame, index) => {
const currentFrame = () => {
return {
index,
frame: frame(),
};
};
return (
<CurrentFrameContext.Provider value={currentFrame}>
<Show
when={index !== 0}
fallback={
<div
class="StackedPage"
id={frame().rootId}
role="presentation"
on:touchstart={onEntryTouchStart}
>
<StaticRouter url={frame().path} {...oprops} />
</div>
}
>
<dialog
ref={onBeforeDialogMount}
class="StackedPage"
onCancel={[popFrame, 1]}
onClick={[onDialogClick, popFrame]}
{...swipeToBackProps}
id={frame().rootId}
style={subInsets()}
>
<StaticRouter url={frame().path} {...oprops} />
</dialog>
</Show>
</CurrentFrameContext.Provider>
);
}}
</Index>
</NavigatorContext.Provider>
);
};
export default StackedRouter;

View file

@ -1,244 +1,51 @@
import {
createContext,
createRenderEffect,
createSignal,
untrack,
useContext,
type Accessor,
type Signal,
} from "solid-js";
export function animateRollOutFromTop( export type HeroSource = {
root: HTMLElement, [key: string | symbol | number]: HTMLElement | undefined;
options?: Omit<KeyframeAnimationOptions, "duration">, };
) {
const overflow = root.style.overflow;
root.style.overflow = "hidden";
const { height } = root.getBoundingClientRect(); const HeroSourceContext = createContext<Signal<HeroSource>>(
/* __@PURE__ */ undefined,
);
const opts = Object.assign( export const HeroSourceProvider = HeroSourceContext.Provider;
{
duration: Math.floor((height / 1600) * 1000),
},
options,
);
const animation = root.animate( function useHeroSource() {
{ return useContext(HeroSourceContext);
height: ["0px", `${height}px`],
},
opts,
);
const restore = () => (root.style.overflow = overflow);
animation.addEventListener("finish", restore);
animation.addEventListener("cancel", restore);
return animation;
} }
export function animateRollInFromBottom( /**
root: HTMLElement, * Use hero value for the {@link key}.
options?: Omit<KeyframeAnimationOptions, "duration">, */
) { export function useHeroSignal(
const overflow = root.style.overflow; key: string | symbol | number,
root.style.overflow = "hidden"; ): Signal<HTMLElement | undefined> {
const source = useHeroSource();
if (source) {
const [get, set] = createSignal<HTMLElement>();
const { height } = root.getBoundingClientRect(); createRenderEffect(() => {
const value = source[0]();
if (value[key]) {
set(value[key]);
source[1]((x) => {
const cpy = Object.assign({}, x);
delete cpy[key];
return cpy;
});
}
});
const opts = Object.assign( return [get, set];
{
duration: Math.floor((height / 1600) * 1000),
},
options,
);
const animation = root.animate(
{
height: [`${height}px`, "0px"],
},
opts,
);
const restore = () => (root.style.overflow = overflow);
animation.addEventListener("finish", restore);
animation.addEventListener("cancel", restore);
return animation;
}
export function animateGrowFromTopRight(
root: HTMLElement,
options?: KeyframeAnimationOptions,
) {
const transformOrigin = root.style.transformOrigin;
root.style.transformOrigin = "top right";
const { width, height } = root.getBoundingClientRect();
const speed = transitionSpeedForEnter(window.innerHeight);
const durationX = Math.floor(height / speed);
const durationY = Math.floor(width / speed);
// finds the offset for the center frame,
// it will stops at the (minDuration / maxDuration)%
const minDuration = Math.min(durationX, durationY);
const maxDuration = Math.max(durationX, durationY);
const centerOffset = minDuration / maxDuration;
const keyframes = [
{ transform: "scaleX(0.5)", opacity: 0, height: "0px", offset: 0 },
{
transform: `scaleX(${minDuration === durationX ? "1" : centerOffset / 2 + 0.5})`,
height: `${(minDuration === durationY ? 1 : centerOffset) * height}px`,
offset: centerOffset,
},
{ transform: "scaleX(1)", height: `${height}px`, opacity: 1, offset: 1 },
];
const animation = root.animate(keyframes, {
...options,
duration: maxDuration,
});
const restore = () => {
root.style.transformOrigin = transformOrigin;
};
animation.addEventListener("cancel", restore);
animation.addEventListener("finish", restore);
return animation;
}
export function animateShrinkToTopRight(
root: HTMLElement,
options?: KeyframeAnimationOptions,
) {
const overflow = root.style.overflow;
root.style.overflow = "hidden";
const transformOrigin = root.style.transformOrigin;
root.style.transformOrigin = "top right";
const { width, height } = root.getBoundingClientRect();
const speed = transitionSpeedForLeave(window.innerWidth);
const duration = Math.floor(Math.max(width / speed, height / speed));
const animation = root.animate(
{
transform: ["scale(1)", "scale(0.5)"],
opacity: [1, 0],
},
{ ...options, duration },
);
const restore = () => {
root.style.overflow = overflow;
root.style.transformOrigin = transformOrigin;
};
animation.addEventListener("cancel", restore);
animation.addEventListener("finish", restore);
return animation;
}
// Contribution to the animation speed:
// - the screen size: mobiles should have longer transition,
// the transition time should be longer as the travelling distance longer,
// but it's not linear. The larger screen should have higher velocity,
// to avoid the transition is too long.
// As the screen larger, on desktops, the transition should be simpler and
// signficantly faster.
// On much smaller screens, like wearables, the transition should be shorter
// than on mobiles.
// - Animation complexity: On mobile:
// - large, complex, full-screen transitions may have longer durations, over 375ms
// - entering screen over 225ms
// - leaving screen over 195ms
function transitionSpeedForEnter(innerWidth: number) {
if (innerWidth < 300) {
return 2.4;
} else if (innerWidth < 560) {
return 1.6;
} else if (innerWidth < 1200) {
return 2.4;
} else { } else {
return 2.55; return [() => undefined, () => undefined];
} }
} }
function transitionSpeedForLeave(innerWidth: number) {
if (innerWidth < 300) {
return 2.8;
} else if (innerWidth < 560) {
return 1.96;
} else if (innerWidth < 1200) {
return 2.8;
} else {
return 2.55;
}
}
export function animateSlideInFromRight(
root: HTMLElement,
options?: Omit<KeyframeAnimationOptions, "duration">,
) {
const { left } = root.getBoundingClientRect();
const { innerWidth } = window;
const oldOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const distance = Math.abs(left - innerWidth);
const duration = Math.floor(distance / transitionSpeedForEnter(innerWidth));
const opts = Object.assign({ duration }, options);
const animation = root.animate(
{
left: [`${innerWidth}px`, `${left}px`],
},
opts,
);
const restore = () => {
document.body.style.overflow = oldOverflow;
};
animation.addEventListener("cancel", restore);
animation.addEventListener("finish", restore);
return animation;
}
export function animateSlideOutToRight(
root: HTMLElement,
options?: Omit<KeyframeAnimationOptions, "duration">,
) {
const { left } = root.getBoundingClientRect();
const { innerWidth } = window;
const oldOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const distance = Math.abs(left - innerWidth);
const duration = Math.floor(distance / transitionSpeedForLeave(innerWidth));
const opts = Object.assign({ duration }, options);
const animation = root.animate(
{
left: [`${left}px`, `${innerWidth}px`],
},
opts,
);
const restore = () => {
document.body.style.overflow = oldOverflow;
};
animation.addEventListener("cancel", restore);
animation.addEventListener("finish", restore);
return animation;
}

View file

@ -1,164 +0,0 @@
/*
Blurhash toolkit.
base83 decoder/encoder is copied from
https://github.com/woltapp/blurhash/blob/master/TypeScript/src/base83.ts,
which is MIT Licensed: https://github.com/woltapp/blurhash?tab=MIT-1-ov-file#readme
*/
const digitCharacters = [
"0",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"A",
"B",
"C",
"D",
"E",
"F",
"G",
"H",
"I",
"J",
"K",
"L",
"M",
"N",
"O",
"P",
"Q",
"R",
"S",
"T",
"U",
"V",
"W",
"X",
"Y",
"Z",
"a",
"b",
"c",
"d",
"e",
"f",
"g",
"h",
"i",
"j",
"k",
"l",
"m",
"n",
"o",
"p",
"q",
"r",
"s",
"t",
"u",
"v",
"w",
"x",
"y",
"z",
"#",
"$",
"%",
"*",
"+",
",",
"-",
".",
":",
";",
"=",
"?",
"@",
"[",
"]",
"^",
"_",
"{",
"|",
"}",
"~",
];
function decode83(str: string) {
let value = 0;
for (let i = 0; i < str.length; i++) {
const c = str[i];
const digit = digitCharacters.indexOf(c);
value = value * 83 + digit;
}
return value;
}
function encode83(n: number, length: number): string {
var result = "";
for (let i = 1; i <= length; i++) {
let digit = (Math.floor(n) / Math.pow(83, length - i)) % 83;
result += digitCharacters[Math.floor(digit)];
}
return result;
}
/* toColorHex() is modified from
https://www.xaymar.com/articles/2020/12/08/fastest-uint8array-to-hex-string-conversion-in-javascript/,
licensed BSD-3. */
// Pre-Init
const LUT_HEX_4b = [
"0",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"A",
"B",
"C",
"D",
"E",
"F",
];
const LUT_HEX_8b = new Array(0x100);
for (let n = 0; n < 0x100; n++) {
LUT_HEX_8b[n] = `${LUT_HEX_4b[(n >>> 4) & 0xf]}${LUT_HEX_4b[n & 0xf]}`;
}
// End Pre-Init
function toColorHex(buffer: Uint8ClampedArray): `#${string}` {
let out = "#";
for (let idx = 0, edx = buffer.length; idx < edx; idx++) {
out += LUT_HEX_8b[buffer[idx]];
}
return out as `#${string}`;
}
export function averageColor(blurhash: string) {
const v = decode83(blurhash.substring(2, 6)); // 24-bit RGB
return [v >> 16, (v >> 8) & 255, v & 255] as const;
}
export function averageColorHex(blurhash: string) : `#${string}` {
const [r, g, b] = averageColor(blurhash);
const buf = new Uint8ClampedArray(3);
buf[0] = r;
buf[1] = g;
buf[2] = b;
return toColorHex(buf);
}

View file

@ -1,37 +1,12 @@
import { createContext, useContext, type Accessor } from "solid-js";
import { useRegisterSW } from "virtual:pwa-register/solid";
export function isiOS() { export function isiOS() {
return ( return [
[ 'iPad Simulator',
"iPad Simulator", 'iPhone Simulator',
"iPhone Simulator", 'iPod Simulator',
"iPod Simulator", 'iPad',
"iPad", 'iPhone',
"iPhone", 'iPod'
"iPod", ].includes(navigator.platform)
].includes(navigator.platform) || // iPad on iOS 13 detection
// iPad on iOS 13 detection || (navigator.userAgent.includes("Mac") && "ontouchend" in document)
(navigator.userAgent.includes("Mac") && "ontouchend" in document) }
);
}
export type ServiceWorkerService = {
needRefresh: Accessor<boolean>;
offlineReady: Accessor<boolean>;
serviceWorker: Accessor<ServiceWorker | undefined>;
};
const ServiceWorkerContext = /* @__PURE__ */ createContext<
ServiceWorkerService
>(({
needRefresh: () => false,
offlineReady: () => false,
serviceWorker: () => undefined
}));
export const ServiceWorkerProvider = ServiceWorkerContext.Provider;
export function useServiceWorker(): ServiceWorkerService {
return useContext(ServiceWorkerContext);
}

View file

@ -11,11 +11,7 @@ import { $settings } from "../settings/stores";
import { enGB } from "date-fns/locale/en-GB"; import { enGB } from "date-fns/locale/en-GB";
import { useStore } from "@nanostores/solid"; import { useStore } from "@nanostores/solid";
import type { Locale } from "date-fns"; import type { Locale } from "date-fns";
import { import { resolveTemplate, translator, type Template } from "@solid-primitives/i18n";
resolveTemplate,
translator,
type Template,
} from "@solid-primitives/i18n";
async function synchronised( async function synchronised(
name: string, name: string,
@ -38,53 +34,43 @@ export function autoMatchLangTag() {
return match(Array.from(navigator.languages), SUPPORTED_LANGS, DEFAULT_LANG); return match(Array.from(navigator.languages), SUPPORTED_LANGS, DEFAULT_LANG);
} }
const DateFnLocaleCx = /* __@PURE__ */ createContext<Accessor<Locale>>( const DateFnLocaleCx = /* __@PURE__ */createContext<Accessor<Locale>>(() => enGB);
() => enGB,
);
const cachedDateFnLocale: Record<string, Locale> = { const cachedDateFnLocale: Record<string, Locale> = {
enGB, enGB,
}; };
export function autoMatchRegion() { export function autoMatchRegion() {
const specifiers = navigator.languages.map((x) => x.split("-")); const regions = navigator.languages
.map((x) => {
for (const s of specifiers) { const parts = x.split("_");
if (s.length === 1) { if (parts.length > 1) {
const lang = s[0]; return parts[1];
for (const available of SUPPORTED_REGIONS) {
if (available.toLowerCase().startsWith(lang.toLowerCase())) {
return available;
}
} }
} else if (s.length === 2) { return undefined;
const [lang, region] = s; })
for (const available of SUPPORTED_REGIONS) { .filter((x): x is string => !!x);
if (available.toLowerCase() === `${lang}_${region}`.toLowerCase()) { for (const r of regions) {
return available; for (const available of SUPPORTED_REGIONS) {
} if (available.toLowerCase().endsWith(r.toLowerCase())) {
return available;
} }
} }
} }
return "en_GB"; return "en_GB";
} }
export function useRegion() { export function useRegion() {
const appSettings = useStore($settings); const appSettings = useStore($settings);
return createMemo( return createMemo(() => {
() => { const settings = appSettings();
const settings = appSettings(); if (typeof settings.region !== "undefined") {
if (typeof settings.region !== "undefined") { return settings.region;
return settings.region; } else {
} else { return autoMatchRegion();
return autoMatchRegion(); }
} });
},
"en_GB",
{ name: "region" },
);
} }
async function importDateFnLocale(tag: string): Promise<Locale> { async function importDateFnLocale(tag: string): Promise<Locale> {
@ -92,7 +78,7 @@ async function importDateFnLocale(tag: string): Promise<Locale> {
case "en_us": case "en_us":
return (await import("date-fns/locale/en-US")).enUS; return (await import("date-fns/locale/en-US")).enUS;
case "en_gb": case "en_gb":
return enGB; return (await import("date-fns/locale/en-GB")).enGB;
case "zh_cn": case "zh_cn":
return (await import("date-fns/locale/zh-CN")).zhCN; return (await import("date-fns/locale/zh-CN")).zhCN;
default: default:
@ -104,9 +90,7 @@ async function importDateFnLocale(tag: string): Promise<Locale> {
* Provides runtime values and fetch dependencies for date-fns locale * Provides runtime values and fetch dependencies for date-fns locale
*/ */
export const DateFnScope: ParentComponent = (props) => { export const DateFnScope: ParentComponent = (props) => {
const [dateFnLocale, setDateFnLocale] = createSignal(enGB, { const [dateFnLocale, setDateFnLocale] = createSignal(enGB);
name: "dateFnLocale",
});
const region = useRegion(); const region = useRegion();
createEffect(() => { createEffect(() => {
@ -164,22 +148,19 @@ export function useLanguage() {
return () => settings().language || autoMatchLangTag(); return () => settings().language || autoMatchLangTag();
} }
type ImportFn<T> = (name: string) => Promise<{ default: T }>; type ImportFn<T> = (name: string) => Promise<{default: T}>
type ImportedModule<F> = F extends ImportFn<infer T> ? T : never; type ImportedModule<F> = F extends ImportFn<infer T> ? T: never
type MergedImportedModule<T> = T extends [] type MergedImportedModule<T> =
? {} T extends [] ? {} :
: T extends [infer I] T extends [infer I] ? ImportedModule<I> :
? ImportedModule<I> T extends [infer I, ...infer J] ? ImportedModule<I> & MergedImportedModule<J> : never
: T extends [infer I, ...infer J]
? ImportedModule<I> & MergedImportedModule<J>
: never;
export function createStringResource< export function createStringResource<
T extends ImportFn<Record<string, string | Template<any> | undefined>>[], T extends ImportFn<Record<string, string | Template<any> | undefined>>[],
>(...importFns: T) { >(...importFns: T) {
const language = useLanguage(); // TODO: this function costs to much, provide a global cache const language = useLanguage();
const cache: Record<string, MergedImportedModule<T>> = {}; const cache: Record<string, MergedImportedModule<T>> = {};
return createResource( return createResource(
@ -189,11 +170,9 @@ export function createStringResource<
return cache[nlang]; return cache[nlang];
} }
const results = await Promise.all( const results = await Promise.all(importFns.map(x => x(nlang).then(v => v.default)))
importFns.map((x) => x(nlang).then((v) => v.default)),
);
const merged: MergedImportedModule<T> = Object.assign({}, ...results); const merged: MergedImportedModule<T> = Object.assign({}, ...results)
cache[nlang] = merged; cache[nlang] = merged;
@ -202,10 +181,8 @@ export function createStringResource<
); );
} }
export function createTranslator< export function createTranslator<T extends ImportFn<Record<string, string | Template<any> | undefined>>[],>(...importFns: T) {
T extends ImportFn<Record<string, string | Template<any> | undefined>>[], const res = createStringResource(...importFns)
>(...importFns: T) {
const res = createStringResource(...importFns);
return [translator(res[0], resolveTemplate), res] as const; return [translator(res[0], resolveTemplate), res] as const
} }

View file

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

View file

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

View file

@ -1,4 +1,3 @@
import { usePageVisibility } from "@solid-primitives/page-visibility";
import { import {
Accessor, Accessor,
createContext, createContext,
@ -16,30 +15,19 @@ export const TimeSourceProvider = TimeSourceContext.Provider;
export function createTimeSource() { export function createTimeSource() {
let id: ReturnType<typeof setTimeout> | undefined; let id: ReturnType<typeof setTimeout> | undefined;
const [get, set] = createSignal(new Date()); const [get, set] = createSignal(new Date());
const visible = usePageVisibility();
const cancelTimer = () => { createRenderEffect(() =>
untrack(() => {
id = setTimeout(() => {
set(new Date());
}, 30 * 1000);
}),
);
onCleanup(() => {
if (typeof id !== "undefined") { if (typeof id !== "undefined") {
clearInterval(id); clearInterval(id);
} }
id = undefined;
};
const resetTimer = () => {
cancelTimer();
set(new Date());
id = setTimeout(() => {
set(new Date());
}, 30 * 1000); // refresh rate: 30s
};
createRenderEffect(() => {
onCleanup(cancelTimer);
if (visible()) {
resetTimer();
} else {
console.debug("createTimeSource: page is invisible, cancel the timer")
}
}); });
return get; return get;

View file

@ -1,123 +0,0 @@
.Profile {
overflow: hidden auto;
.intro {
background-color: var(--tutu-color-surface-d);
color: var(--tutu-color-on-surface);
padding: 16px 12px;
display: flex;
flex-flow: column nowrap;
gap: 16px;
}
.banner {
width: 100%;
margin-top: calc(-1 * var(--scaffold-topbar-height));
>img {
object-fit: cover;
width: 100%;
height: 100%;
}
}
.description {
& a {
color: inherit;
font-style: italic;
}
word-break: break-all;
& * {
user-select: text;
}
}
.acct-grp {
display: flex;
flex-flow: row wrap;
gap: 16px;
align-items: center;
&> :nth-child(2) {
flex-grow: 1;
}
&> :last-child {
flex-grow: 1;
text-align: right;
}
}
.name-grp {
display: flex;
flex-flow: column nowrap;
& * {
user-select: text;
}
.display-name {
display: block;
}
.username {
user-select: all;
}
}
.acct-mark {
font-size: 1.2em;
vertical-align: sub;
margin-right: 0.25em;
}
table.acct-fields {
word-break: break-all;
& td>a {
display: inline-flex;
align-items: center;
color: inherit;
min-height: 44px;
}
& a>.invisible {
display: none;
}
& svg {
vertical-align: middle;
}
& * {
user-select: text;
}
}
.toot-list-toolbar {
position: sticky;
top: var(--scaffold-topbar-height);
z-index: calc(var(--tutu-zidx-nav, 1) - 1);
background: var(--tutu-color-surface);
border-bottom: 1px solid var(--tutu-color-surface-d);
contain: content;
/* TODO: box-shadow is needed here (same as app bar, e6).
There is no good way to detect if the sticky is "sticked" -
so let's leave it for future.
For now we use a trick to make it looks better.
*/
box-shadow: 0px -2px 4px 0px var(--tutu-color-shadow);
}
}
.Profile__page-title {
flex-grow: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View file

@ -1,553 +0,0 @@
import {
catchError,
createRenderEffect,
createResource,
createSignal,
createUniqueId,
For,
Switch,
Match,
onCleanup,
Show,
type Component,
createMemo,
} from "solid-js";
import Scaffold from "~material/Scaffold";
import {
AppBar,
Avatar,
Button,
Checkbox,
CircularProgress,
Divider,
IconButton,
ListItemAvatar,
ListItemIcon,
ListItemSecondaryAction,
ListItemText,
MenuItem,
Toolbar,
} from "@suid/material";
import {
Close,
Edit,
ExpandMore,
Group,
Lock,
MoreVert,
OpenInBrowser,
PersonOff,
PlaylistAdd,
Send,
Share,
SmartToySharp,
Subject,
Verified,
} from "@suid/icons-material";
import { Body2, Title } from "~material/typography";
import { useParams } from "@solidjs/router";
import { useSessionForAcctStr } from "../masto/clients";
import { resolveCustomEmoji } from "../masto/toot";
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 TootFilterButton from "./TootFilterButton";
import Menu, { createManagedMenuState } from "~material/Menu";
import { share } from "~platform/share";
import "./Profile.css";
import { useNavigator } from "~platform/StackedRouter";
const Profile: Component = () => {
const { pop } = useNavigator();
const params = useParams<{ acct: string; id: string }>();
const acctText = () => decodeURIComponent(params.acct);
const session = useSessionForAcctStr(acctText);
const [bannerSampledColors, setBannerSampledColors] = createSignal<{
average: string;
text: string;
}>();
const windowSize = useWindowSize();
const time = createTimeSource();
const menuButId = createUniqueId();
const recentTootListId = createUniqueId();
const optMenuId = createUniqueId();
const [menuOpen, setMenuOpen] = createSignal(false);
const [openSubscribeMenu, subscribeMenuState] = createManagedMenuState();
const [scrolledPastBanner, setScrolledPastBanner] = createSignal(false);
const obx = new IntersectionObserver(
(entries) => {
const ent = entries[0];
if (ent.intersectionRatio < 0.1) {
setScrolledPastBanner(true);
} else {
setScrolledPastBanner(false);
}
},
{
threshold: 0.1,
},
);
onCleanup(() => obx.disconnect());
const [profileUncaught] = createResource(
() => [session().client, params.id] as const,
async ([client, id]) => {
return await client.v1.accounts.$select(id).fetch();
},
);
const profile = () => {
try {
return profileUncaught();
} catch (reason) {
console.error(reason);
}
};
const isCurrentSessionProfile = () => {
return session().account?.inf?.url === profile()?.url;
};
const [recentTootFilter, setRecentTootFilter] = createSignal({
pinned: true,
boost: false,
reply: true,
original: true,
});
const [recentToots, recentTootChunk, { refetch: refetchRecentToots }] =
createTimeline(
() => session().client.v1.accounts.$select(params.id).statuses,
() => {
const { boost, reply } = recentTootFilter();
return { limit: 20, excludeReblogs: !boost, excludeReplies: !reply };
},
);
const [pinnedToots, pinnedTootChunk] = createTimelineSnapshot(
() => session().client.v1.accounts.$select(params.id).statuses,
() => {
return { limit: 20, pinned: true };
},
);
const [relationshipUncaught, { mutate: mutateRelationship }] = createResource(
() => [session(), params.id] as const,
async ([sess, id]) => {
if (!sess.account) return; // No account, no relation
const relations = await session().client.v1.accounts.relationships.fetch({
id: [id],
});
return relations.length > 0 ? relations[0] : undefined;
},
);
const relationship = () =>
catchError(relationshipUncaught, (reason) => {
console.error(reason);
});
const bannerImg = () => profile()?.header;
const avatarImg = () => profile()?.avatar;
const displayName = () =>
resolveCustomEmoji(profile()?.displayName || "", profile()?.emojis ?? []);
const fullUsername = () => (profile()?.acct ? `@${profile()!.acct!}` : ""); // TODO: full user name
const description = () => profile()?.note;
const isTootListLoading = () =>
recentTootChunk.loading ||
(recentTootFilter().pinned && pinnedTootChunk.loading);
const sessionDisplayName = createMemo(() =>
resolveCustomEmoji(
session().account?.inf?.displayName || "",
session().account?.inf?.emojis ?? [],
),
);
const useSessionDisplayName = (e: HTMLElement) => {
createRenderEffect(() => (e.innerHTML = sessionDisplayName()));
};
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(event);
if (isSubscribed) {
const nrel = await client.v1.accounts.$select(params.id).unfollow();
mutateRelationship(nrel);
} else {
const nrel = await client.v1.accounts.$select(params.id).follow();
mutateRelationship(nrel);
}
};
return (
<Scaffold
topbar={
<AppBar
role="navigation"
position="static"
color={scrolledPastBanner() ? "primary" : "transparent"}
elevation={scrolledPastBanner() ? undefined : 0}
>
<Toolbar
variant="dense"
sx={{
display: "flex",
color: scrolledPastBanner()
? undefined
: bannerSampledColors()?.text,
paddingTop: "var(--safe-area-inset-top)",
}}
>
<IconButton color="inherit" onClick={[pop, 1]} aria-label="Close">
<Close />
</IconButton>
<Title
class="Profile__page-title"
style={{
visibility: scrolledPastBanner() ? undefined : "hidden",
}}
ref={(e: HTMLElement) =>
createRenderEffect(() => (e.innerHTML = displayName()))
}
></Title>
<IconButton
id={menuButId}
aria-controls={optMenuId}
color="inherit"
onClick={[setMenuOpen, true]}
aria-label="Open Options for the Profile"
>
<MoreVert />
</IconButton>
</Toolbar>
</AppBar>
}
class="Profile"
>
<Menu
id={optMenuId}
open={menuOpen()}
onClose={[setMenuOpen, false]}
anchor={() =>
document.getElementById(menuButId)!.getBoundingClientRect()
}
aria-label="Options for the Profile"
>
<Show when={session().account}>
<MenuItem>
<ListItemAvatar>
<Avatar src={session().account?.inf?.avatar} />
</ListItemAvatar>
<ListItemText secondary={"Default account"}>
<span ref={useSessionDisplayName}></span>
</ListItemText>
{/* <ArrowRight /> // for future */}
</MenuItem>
</Show>
<Show when={session().account && profile()}>
<Show
when={isCurrentSessionProfile()}
fallback={
<MenuItem
onClick={(event) => {
const { left, right, top } =
event.currentTarget.getBoundingClientRect();
openSubscribeMenu({
left,
right,
top,
e: 1,
});
}}
>
<ListItemIcon>
<PlaylistAdd />
</ListItemIcon>
<ListItemText>Subscribe...</ListItemText>
</MenuItem>
}
>
<MenuItem disabled>
<ListItemIcon>
<Edit />
</ListItemIcon>
<ListItemText>Edit...</ListItemText>
</MenuItem>
</Show>
<Divider />
</Show>
<MenuItem disabled>
<ListItemIcon>
<Group />
</ListItemIcon>
<ListItemText>Followers</ListItemText>
<ListItemSecondaryAction>
<span aria-label="The number of the account follower">
{profile()?.followersCount ?? ""}
</span>
</ListItemSecondaryAction>
</MenuItem>
<MenuItem disabled>
<ListItemIcon>
<Subject />
</ListItemIcon>
<ListItemText>Following</ListItemText>
<ListItemSecondaryAction>
<span aria-label="The number the account following">
{profile()?.followingCount ?? ""}
</span>
</ListItemSecondaryAction>
</MenuItem>
<MenuItem disabled>
<ListItemIcon>
<PersonOff />
</ListItemIcon>
<ListItemText>Blocklist</ListItemText>
</MenuItem>
<MenuItem disabled>
<ListItemIcon>
<Send />
</ListItemIcon>
<ListItemText>Mention in...</ListItemText>
</MenuItem>
<Divider />
<MenuItem
component={"a"}
href={profile()?.url}
target="_blank"
rel="noopener noreferrer"
>
<ListItemIcon>
<OpenInBrowser />
</ListItemIcon>
<ListItemText>Open in browser...</ListItemText>
</MenuItem>
<MenuItem onClick={() => share({ url: profile()?.url })}>
<ListItemIcon>
<Share />
</ListItemIcon>
<ListItemText>Share...</ListItemText>
</MenuItem>
</Menu>
<div
style={{
height: `${268 * (Math.min(560, windowSize.width) / 560)}px`,
}}
class="banner"
role="presentation"
>
<img
ref={(e) => obx.observe(e)}
src={bannerImg()}
crossOrigin="anonymous"
alt={`Banner image for ${profile()?.displayName || "the user"}`}
onLoad={(event) => {
const ins = new FastAverageColor();
const colors = ins.getColor(event.currentTarget);
setBannerSampledColors({
average: colors.hex,
text: colors.isDark ? "white" : "black",
});
ins.destroy();
}}
></img>
</div>
<Menu {...subscribeMenuState}>
<MenuItem
onClick={toggleSubscribeHome}
aria-label={`${relationship()?.following ? "Unfollow" : "Follow"} on your home timeline`}
>
<ListItemAvatar>
<Avatar src={session().account?.inf?.avatar}></Avatar>
</ListItemAvatar>
<ListItemText
secondary={
relationship()?.following
? undefined
: profile()?.locked
? "A request will be sent"
: undefined
}
>
<span ref={useSessionDisplayName}></span>
<span>'s Home</span>
</ListItemText>
<Checkbox checked={relationship()?.following ?? false} />
</MenuItem>
</Menu>
<div
class="intro"
style={{
"background-color": bannerSampledColors()?.average,
color: bannerSampledColors()?.text,
}}
>
<section class="acct-grp">
<Avatar
src={avatarImg()}
alt={`${profile()?.displayName || "the user"}'s avatar`}
sx={{
marginTop: "calc(-16px - 72px / 2)",
width: "72px",
height: "72px",
}}
></Avatar>
<div class="name-grp">
<div class="display-name">
<Show when={profile()?.bot}>
<SmartToySharp class="acct-mark" aria-label="Bot" />
</Show>
<Show when={profile()?.locked}>
<Lock class="acct-mark" aria-label="Locked" />
</Show>
<Body2
component="span"
ref={(e: HTMLElement) =>
createRenderEffect(() => (e.innerHTML = displayName()))
}
aria-label="Display name"
></Body2>
</div>
<span aria-label="Complete username" class="username">{fullUsername()}</span>
</div>
<div role="presentation">
<Switch>
<Match
when={
!session().account ||
profileUncaught.loading ||
profileUncaught.error
}
>
{<></>}
</Match>
<Match when={isCurrentSessionProfile()}>
<IconButton color="inherit">
<Edit />
</IconButton>
</Match>
<Match when={true}>
<Button
variant="contained"
color="secondary"
onClick={(event) => {
openSubscribeMenu(
event.currentTarget.getBoundingClientRect(),
);
}}
>
{relationship()?.following ? "Subscribed" : "Subscribe"}
</Button>
</Match>
</Switch>
</div>
</section>
<section
class="description"
aria-label={`${profile()?.displayName || "the user"}'s description`}
ref={(e) =>
createRenderEffect(() => (e.innerHTML = description() || ""))
}
></section>
<table
class="acct-fields"
aria-label={`${profile()?.displayName || "the user"}'s fields`}
>
<tbody>
<For each={profile()?.fields ?? []}>
{(item, index) => {
return (
<tr data-field-index={index()}>
<td>{item.name}</td>
<td>
<Show when={item.verifiedAt}>
<Verified />
</Show>
</td>
<td
ref={(e) => {
createRenderEffect(() => (e.innerHTML = item.value));
}}
></td>
</tr>
);
}}
</For>
</tbody>
</table>
</div>
<div class="toot-list-toolbar">
<TootFilterButton
options={{
pinned: "Pinneds",
boost: "Boosts",
reply: "Replies",
original: "Originals",
}}
applied={recentTootFilter()}
onApply={setRecentTootFilter}
disabledKeys={["original"]}
></TootFilterButton>
</div>
<TimeSourceProvider value={time}>
<Show when={recentTootFilter().pinned && pinnedToots.list.length > 0}>
<TootList
threads={pinnedToots.list}
onUnknownThread={pinnedToots.getPath}
onChangeToot={pinnedToots.set}
/>
<Divider />
</Show>
<TootList
id={recentTootListId}
threads={recentToots.list}
onUnknownThread={recentToots.getPath}
onChangeToot={recentToots.set}
/>
</TimeSourceProvider>
<Show when={!recentTootChunk()?.done}>
<div
style={{
"text-align": "center",
"padding-bottom": "var(--safe-area-inset-bottom)",
}}
>
<IconButton
aria-label="Load More"
aria-controls={recentTootListId}
size="large"
color="primary"
onClick={[refetchRecentToots, "prev"]}
disabled={isTootListLoading()}
>
<Show when={isTootListLoading()} fallback={<ExpandMore />}>
<CircularProgress sx={{ width: "24px", height: "24px" }} />
</Show>
</IconButton>
</div>
</Show>
</Scaffold>
);
};
export default Profile;

View file

@ -1,107 +0,0 @@
import { Button, MenuItem, Checkbox, ListItemText } from "@suid/material";
import { createMemo, createSignal, createUniqueId, For } from "solid-js";
import Menu from "~material/Menu";
import { FilterList, FilterListOff } from "@suid/icons-material";
type Props<Filters extends Record<string, string>> = {
options: Filters;
applied: Record<keyof Filters, boolean | undefined>;
disabledKeys?: (keyof Filters)[];
onApply(value: Record<keyof Filters, boolean | undefined>): void;
};
function TootFilterButton<F extends Record<string, string>>(props: Props<F>) {
const buttonId = createUniqueId();
const [open, setOpen] = createSignal(false);
const getTextForMultipleEntities = (texts: string[]) => {
switch (texts.length) {
case 0:
return "Nothing";
case 1:
return texts[0];
case 2:
return `${texts[0]} and ${texts[1]}`;
case 3:
return `${texts[0]}, ${texts[1]} and ${texts[2]}`;
default:
return `${texts[0]} and ${texts.length - 1} other${texts.length > 2 ? "s" : ""}`;
}
};
const optionKeys = () => Object.keys(props.options);
const appliedKeys = createMemo(() => {
const applied = props.applied;
return optionKeys().filter((k) => applied[k]);
});
const text = () => {
const keys = optionKeys();
const napplied = appliedKeys().length;
switch (napplied) {
case keys.length:
return "All";
default:
return getTextForMultipleEntities(
appliedKeys().map((k) => props.options[k]),
);
}
};
const toggleKey = (key: keyof F) => {
props.onApply(
Object.assign({}, props.applied, {
[key]: !props.applied[key],
}),
);
};
let anchor: { left: number; top: number; right: number };
const onClick = (event: MouseEvent) => {
anchor = {
left: event.clientX,
right: event.clientX,
top: event.clientY,
};
setOpen(true);
};
return (
<>
<Button size="large" onClick={onClick} id={buttonId}>
{appliedKeys().length === optionKeys().length ? (
<FilterListOff />
) : (
<FilterList />
)}
<span style={{ "margin-left": "0.5em" }}>{text()}</span>
</Button>
<Menu open={open()} onClose={[setOpen, false]} anchor={() => anchor}>
<For each={Object.keys(props.options)}>
{(item, idx) => (
<>
<MenuItem
data-sort={idx()}
onClick={[toggleKey, item]}
disabled={props.disabledKeys?.includes(item)}
>
<ListItemText>{props.options[item]}</ListItemText>
<Checkbox
checked={props.applied[item]}
sx={{ marginRight: "-8px" }}
disabled={props.disabledKeys?.includes(item)}
></Checkbox>
</MenuItem>
</>
)}
</For>
</Menu>
</>
);
}
export default TootFilterButton;

View file

@ -1,34 +0,0 @@
import { cleanupOutdatedCaches, precacheAndRoute } from "workbox-precaching";
import { clientsClaim } from "workbox-core";
import { dispatchCall, isJSONRPCCall, type Call } from "./workerrpc";
function isServiceWorker(
self: WorkerGlobalScope,
): self is ServiceWorkerGlobalScope {
return !!(self as unknown as ServiceWorkerGlobalScope).registration;
}
if (isServiceWorker(self)) {
cleanupOutdatedCaches();
precacheAndRoute(self.__WB_MANIFEST, {
cleanURLs: false,
});
// auto update
self.skipWaiting();
clientsClaim();
} else {
throw new TypeError("This entry point must be run in a service worker");
}
export const Service = {
ping() {},
};
self.addEventListener("message", (event: MessageEvent<unknown>) => {
const payload = event.data;
if (typeof payload !== "object") return;
if (isJSONRPCCall(payload as Record<string, unknown>)) {
dispatchCall(Service, event as MessageEvent<Call<unknown>>);
}
});

View file

@ -1,3 +0,0 @@
export type Service = {
ping(): void
};

View file

@ -1,5 +0,0 @@
{
"compilerOptions": {
"lib": ["ESNext", "WebWorker"],
},
}

View file

@ -1,214 +0,0 @@
export type JSONRPC = {
jsonrpc: "2.0";
id?: string | number;
};
export type Call<T = undefined> = JSONRPC & {
method: string;
params: T;
};
export type RemoteError<E = undefined> = { data: E } & (
| {
code: number;
message: string;
}
| {
code: -32700;
message: "Parse Error";
}
| {
code: -32600;
message: "Invalid Request";
}
| {
code: -32601;
message: "Method not found";
}
| {
code: -32602;
message: "Invalid params";
}
| {
code: -32603;
message: "Internal error";
}
);
export type Result<T, E> = JSONRPC & { id: string | number } & (
| {
result: T;
error: undefined;
}
| { error: RemoteError<E>; result: undefined }
);
export function isJSONRPCResult(
object: Record<string, unknown>,
): object is Result<unknown, unknown> {
return object["jsonrpc"] === "2.0" && object["id"] && !object["method"];
}
export function isJSONRPCCall(
object: Record<string, unknown>,
): object is Call<unknown> {
return object["jsonrpc"] === "2.0" && !!object["method"];
}
export class ResultDispatcher {
private map: Map<
number | string,
((value: Result<unknown, unknown>) => void) | true // `true` = a call is generated, but the promise not created
>;
private nextId: number = Number.MIN_SAFE_INTEGER;
constructor() {
this.map = new Map();
}
private rollId() {
let id = 0;
while (this.map.get((id = this.nextId++))) {
if (this.nextId >= Number.MAX_SAFE_INTEGER) {
this.nextId = Number.MIN_SAFE_INTEGER;
}
}
return id;
}
createCall<T>(
method: string,
params: T,
): [Promise<Call<T>>, Promise<Result<unknown, unknown>>] {
const id = this.rollId();
const p = new Promise<Result<unknown, unknown>>((resolve) =>
this.map.set(id, resolve),
);
this.map.set(id, true);
const call: Call<T> = {
jsonrpc: "2.0",
id,
method,
params,
};
return [
new Promise((resolve) => {
const waitUntilTheIdSet = () => {
// We must do this check to make sure the id is set before the call made.
// or the dispatching may lost the callback
if (this.map.get(id)) {
resolve(call);
} else {
setTimeout(waitUntilTheIdSet, 0);
}
};
waitUntilTheIdSet();
}),
p,
];
}
dispatch(id: string | number, message: Result<unknown, unknown>) {
{
const callback = this.map.get(id);
if (!callback) return;
if (typeof callback !== "boolean") {
callback(message);
this.map.delete(id);
return Promise.resolve();
}
}
return new Promise<void>((resolve) => {
let retried = 0;
const checkAndDispatch = () => {
const callback = this.map.get(id);
if (typeof callback !== "boolean") {
callback(message);
this.map.delete(id);
resolve();
return;
}
setTimeout(checkAndDispatch, 0);
if (++retried > 3) {
console.warn(
`retried ${retried} time(s) but the callback is still disappeared, id is "${id}"`,
);
}
};
// start the loop
checkAndDispatch();
});
}
createTypedCall<
S extends AnyService,
E = unknown,
K extends keyof S = keyof S,
P extends Parameters<S[K]> = Parameters<S[K]>,
R extends ReturnType<S[K]> = ReturnType<S[K]>,
>(method: K, ...params: P): [Promise<Call<P>>, Promise<Result<R, E>>] {
return this.createCall(method as string, params) as [
Promise<Call<P>>,
Promise<Result<R, E>>,
];
}
}
type AnyService = Record<string, ((...args: unknown[]) => unknown) | undefined>
export async function dispatchCall<
S extends AnyService,
>(service: S, event: MessageEvent<Call<unknown>>) {
try {
const fn = service[event.data.method];
if (!fn) {
if (event.data.id)
return event.source.postMessage({
jsonrpc: "2.0",
id: event.data.id,
error: {
code: -30601,
message: "Method not found",
},
} as Result<void, void>);
}
try {
const result = await fn(...event.data.params as unknown[]);
if (!event.data.id) return;
event.source.postMessage({
jsonrpc: "2.0",
id: event.data.id,
result: result,
} as Result<unknown, void>);
} catch (reason) {
event.source.postMessage({
jsonrpc: "2.0",
id: event.data.id,
error: {
code: 0,
message: String(reason),
data: reason,
},
} as Result<unknown, unknown>);
}
} catch (reason) {
if (event.data.id)
event.source.postMessage({
jsonrpc: "2.0",
id: event.data.id,
error: {
code: -32603,
message: "Internal error",
data: reason,
},
} as Result<void, unknown>);
}
}

View file

@ -1,5 +1,5 @@
import { createMemo, For, type Component, type JSX } from "solid-js"; import { createMemo, For, type Component, type JSX } from "solid-js";
import Scaffold from "~material/Scaffold"; import Scaffold from "../material/Scaffold";
import { import {
AppBar, AppBar,
IconButton, IconButton,
@ -19,17 +19,17 @@ import {
autoMatchLangTag, autoMatchLangTag,
createTranslator, createTranslator,
SUPPORTED_LANGS, SUPPORTED_LANGS,
} from "~platform/i18n"; } from "../platform/i18n";
import { Title } from "~material/typography"; import { Title } from "../material/typography";
import type { Template } from "@solid-primitives/i18n"; import type { Template } from "@solid-primitives/i18n";
import { useStore } from "@nanostores/solid"; import { useStore } from "@nanostores/solid";
import { $settings } from "./stores"; import { $settings } from "./stores";
import { useNavigator } from "~platform/StackedRouter"; import { useNavigate } from "@solidjs/router";
const ChooseLang: Component = () => { const ChooseLang: Component = () => {
const { pop } = useNavigator(); const navigate = useNavigate()
const [t] = createTranslator( const [t] = createTranslator(
() => import("./i18n/generic.json"), () => import("./i18n/lang-names.json"),
(code) => (code) =>
import(`./i18n/${code}.json`) as Promise<{ import(`./i18n/${code}.json`) as Promise<{
default: Record<string, string | undefined> & { default: Record<string, string | undefined> & {
@ -37,9 +37,9 @@ const ChooseLang: Component = () => {
}; };
}>, }>,
); );
const settings = useStore($settings); const settings = useStore($settings)
const code = () => settings().language; const code = () => settings().language
const unsupportedLangCodes = createMemo(() => { const unsupportedLangCodes = createMemo(() => {
return iso639_1.getAllCodes().filter((x) => !["zh", "en"].includes(x)); return iso639_1.getAllCodes().filter((x) => !["zh", "en"].includes(x));
@ -48,8 +48,8 @@ const ChooseLang: Component = () => {
const matchedLangCode = createMemo(() => autoMatchLangTag()); const matchedLangCode = createMemo(() => autoMatchLangTag());
const onCodeChange = (code?: string) => { const onCodeChange = (code?: string) => {
$settings.setKey("language", code); $settings.setKey("language", code)
}; }
return ( return (
<Scaffold <Scaffold
@ -59,7 +59,7 @@ const ChooseLang: Component = () => {
variant="dense" variant="dense"
sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }} sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }}
> >
<IconButton color="inherit" onClick={[pop, 1]} disableRipple> <IconButton color="inherit" onClick={[navigate, -1]} disableRipple>
<ArrowBack /> <ArrowBack />
</IconButton> </IconButton>
<Title>{t("Choose Language")}</Title> <Title>{t("Choose Language")}</Title>
@ -96,10 +96,7 @@ const ChooseLang: Component = () => {
<ListItemText>{t(`lang.${c}`)}</ListItemText> <ListItemText>{t(`lang.${c}`)}</ListItemText>
<ListItemSecondaryAction> <ListItemSecondaryAction>
<Radio <Radio
checked={ checked={code() === c || (code() === undefined && matchedLangCode() == c)}
code() === c ||
(code() === undefined && matchedLangCode() == c)
}
/> />
</ListItemSecondaryAction> </ListItemSecondaryAction>
</ListItemButton> </ListItemButton>

View file

@ -1,5 +1,5 @@
import type { Component } from "solid-js"; import type { Component } from "solid-js";
import Scaffold from "~material/Scaffold"; import Scaffold from "../material/Scaffold";
import { import {
AppBar, AppBar,
Divider, Divider,
@ -12,15 +12,15 @@ import {
Switch, Switch,
Toolbar, Toolbar,
} from "@suid/material"; } from "@suid/material";
import { Title } from "~material/typography"; import { Title } from "../material/typography";
import { useNavigate } from "@solidjs/router";
import { ArrowBack } from "@suid/icons-material"; import { ArrowBack } from "@suid/icons-material";
import { createTranslator } from "~platform/i18n"; import { createTranslator } from "../platform/i18n";
import { useStore } from "@nanostores/solid"; import { useStore } from "@nanostores/solid";
import { $settings } from "./stores"; import { $settings } from "./stores";
import { useNavigator } from "~platform/StackedRouter";
const Motions: Component = () => { const Motions: Component = () => {
const {pop} = useNavigator(); const navigate = useNavigate();
const [t] = createTranslator( const [t] = createTranslator(
(code) => (code) =>
import(`./i18n/${code}.json`) as Promise<{ import(`./i18n/${code}.json`) as Promise<{
@ -36,7 +36,7 @@ const Motions: Component = () => {
variant="dense" variant="dense"
sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }} sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }}
> >
<IconButton color="inherit" onClick={[pop, 1]} disableRipple> <IconButton color="inherit" onClick={[navigate, -1]} disableRipple>
<ArrowBack /> <ArrowBack />
</IconButton> </IconButton>
<Title>{t("motions")}</Title> <Title>{t("motions")}</Title>

View file

@ -1,5 +1,5 @@
import { createMemo, For, type Component, type JSX } from "solid-js"; import { createMemo, For, type Component, type JSX } from "solid-js";
import Scaffold from "~material/Scaffold"; import Scaffold from "../material/Scaffold";
import { import {
AppBar, AppBar,
IconButton, IconButton,
@ -17,17 +17,17 @@ import {
autoMatchRegion, autoMatchRegion,
createTranslator, createTranslator,
SUPPORTED_REGIONS, SUPPORTED_REGIONS,
} from "~platform/i18n"; } from "../platform/i18n";
import { Title } from "~material/typography"; import { Title } from "../material/typography";
import type { Template } from "@solid-primitives/i18n"; import type { Template } from "@solid-primitives/i18n";
import { useNavigate } from "@solidjs/router";
import { $settings } from "./stores"; import { $settings } from "./stores";
import { useStore } from "@nanostores/solid"; import { useStore } from "@nanostores/solid";
import { useNavigator } from "~platform/StackedRouter";
const ChooseRegion: Component = () => { const ChooseRegion: Component = () => {
const {pop} = useNavigator(); const navigate = useNavigate();
const [t] = createTranslator( const [t] = createTranslator(
() => import("./i18n/generic.json"), () => import("./i18n/lang-names.json"),
(code) => (code) =>
import(`./i18n/${code}.json`) as Promise<{ import(`./i18n/${code}.json`) as Promise<{
default: Record<string, string | undefined> & { default: Record<string, string | undefined> & {
@ -54,10 +54,10 @@ const ChooseRegion: Component = () => {
variant="dense" variant="dense"
sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }} sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }}
> >
<IconButton color="inherit" onClick={[pop, 1]} disableRipple> <IconButton color="inherit" onClick={[navigate, -1]} disableRipple>
<ArrowBack /> <ArrowBack />
</IconButton> </IconButton>
<Title>{t("Choose Region")}</Title> <Title>{t("Choose Language")}</Title>
</Toolbar> </Toolbar>
</AppBar> </AppBar>
} }

View file

@ -1,5 +1,11 @@
import { For, Show, type Component } from "solid-js"; import {
import Scaffold from "~material/Scaffold.js"; children,
createSignal,
For,
Show,
type ParentComponent,
} from "solid-js";
import Scaffold from "../material/Scaffold.js";
import { import {
AppBar, AppBar,
Divider, Divider,
@ -11,7 +17,6 @@ import {
ListItemSecondaryAction, ListItemSecondaryAction,
ListItemText, ListItemText,
ListSubheader, ListSubheader,
NativeSelect,
Switch, Switch,
Toolbar, Toolbar,
} from "@suid/material"; } from "@suid/material";
@ -23,157 +28,51 @@ import {
Refresh as RefreshIcon, Refresh as RefreshIcon,
Translate as TranslateIcon, Translate as TranslateIcon,
} from "@suid/icons-material"; } from "@suid/icons-material";
import A from "~platform/A.js"; import { A, useNavigate } from "@solidjs/router";
import { Title } from "~material/typography.js"; import { Title } from "../material/typography.jsx";
import { css } from "solid-styled"; import { css } from "solid-styled";
import { useSignedInProfiles } from "../masto/acct.js";
import { signOut, type Account } from "../accounts/stores.js"; import { signOut, type Account } from "../accounts/stores.js";
import { format } from "date-fns"; import { format } from "date-fns";
import { useStore } from "@nanostores/solid"; import { useStore } from "@nanostores/solid";
import { $settings } from "./stores.js"; import { $settings } from "./stores.js";
import { useRegisterSW } from "virtual:pwa-register/solid";
import { import {
autoMatchLangTag, autoMatchLangTag,
autoMatchRegion, autoMatchRegion,
createTranslator, createTranslator,
useDateFnLocale, useDateFnLocale,
} from "~platform/i18n.jsx"; } from "../platform/i18n.jsx";
import { type Template } from "@solid-primitives/i18n"; import { type Template } from "@solid-primitives/i18n";
import { useServiceWorker } from "~platform/host.js"; import BottomSheet from "../material/BottomSheet.jsx";
import { useSessions } from "../masto/clients.js";
import { useNavigator } from "~platform/StackedRouter.jsx";
type Inset = {
top?: number;
right?: number;
bottom?: number;
left?: number;
};
type SafeAreaInsets = {
landscape: Inset;
protrait: Inset;
};
const safeAreaInsets: Record<string, SafeAreaInsets> = {
iphone15: {
protrait: {
top: 59,
bottom: 34,
},
landscape: {
bottom: 21,
left: 59,
right: 59,
},
},
iphone12: {
protrait: {
top: 47,
bottom: 34,
},
landscape: {
bottom: 21,
left: 47,
right: 47,
},
},
iphone13mini: {
protrait: {
top: 50,
bottom: 34,
},
landscape: {
bottom: 21,
left: 50,
right: 50,
},
},
};
let screenOrientationCallback: (() => void) | undefined;
function removeSafeAreaEmulation(root: HTMLElement) {
for (const name of ["top", "right", "bottom", "left"]) {
root.style.removeProperty(`--safe-area-inset-${name}`);
}
}
function applySafeAreaEmulation(root: HTMLElement, insets: Inset) {
removeSafeAreaEmulation(root);
for (const key of Object.keys(insets) as (keyof Inset)[]) {
const value = insets[key];
if (!value || value === 0) continue;
root.style.setProperty(`--safe-area-inset-${key}`, `${value}px`);
}
}
function setupSafeAreaEmulation(name: string) {
const insets = safeAreaInsets[name];
const root = document.querySelector(":root")! as HTMLElement;
if (screenOrientationCallback) {
window.screen.orientation.removeEventListener(
"change",
screenOrientationCallback,
);
screenOrientationCallback = undefined;
}
removeSafeAreaEmulation(root);
if (insets) {
screenOrientationCallback = () => {
console.debug(
`safe area emulation target: ${window.screen.orientation.type}`,
);
if (window.screen.orientation.type === "portrait-primary") {
console.debug("safe area emulation: protrait");
applySafeAreaEmulation(root, insets.protrait);
} else if (window.screen.orientation.type === "landscape-primary") {
console.debug("safe area emulation: landscape");
applySafeAreaEmulation(root, insets.landscape);
}
};
window.screen.orientation.addEventListener(
"change",
screenOrientationCallback,
);
screenOrientationCallback();
}
}
if (import.meta.hot) {
import.meta.hot.accept((mod) => {
if (!mod) return;
if (screenOrientationCallback) {
mod["screenOrientationCallback"] = screenOrientationCallback;
setTimeout(screenOrientationCallback, 0);
}
});
}
type Strings = { type Strings = {
["lang.auto"]: Template<{ detected: string }>; ["lang.auto"]: Template<{ detected: string }>;
} & Record<string, string | undefined>; } & Record<string, string | undefined>;
const Settings: Component = () => { const Settings: ParentComponent = (props) => {
const [t] = createTranslator( const [t] = createTranslator(
(code) => (code) =>
import(`./i18n/${code}.json`) as Promise<{ import(`./i18n/${code}.json`) as Promise<{
default: Strings; default: Strings;
}>, }>,
() => import(`./i18n/generic.json`), () => import(`./i18n/lang-names.json`),
); );
const { pop } = useNavigator(); const navigate = useNavigate();
const settings$ = useStore($settings); const settings$ = useStore($settings);
const { needRefresh } = useServiceWorker(); const {
needRefresh: [needRefresh],
} = useRegisterSW();
const dateFnLocale = useDateFnLocale(); const dateFnLocale = useDateFnLocale();
const profiles = useSessions(); const [profiles] = useSignedInProfiles();
const doSignOut = (acct: Account) => { const doSignOut = (acct: Account) => {
signOut((a) => a.site === acct.site && a.accessToken === acct.accessToken); signOut((a) => a.site === acct.site && a.accessToken === acct.accessToken);
}; };
const subpage = children(() => props.children);
css` css`
ul { ul {
padding: 0; padding: 0;
@ -181,9 +80,6 @@ const Settings: Component = () => {
.setting-list { .setting-list {
padding-bottom: calc(var(--safe-area-inset-bottom, 0px) + 16px); padding-bottom: calc(var(--safe-area-inset-bottom, 0px) + 16px);
overflow: hidden auto;
height: calc(100vh - var(--scaffold-topbar-height, 0));
height: calc(100dvh - var(--scaffold-topbar-height, 0));
} }
`; `;
return ( return (
@ -194,7 +90,7 @@ const Settings: Component = () => {
variant="dense" variant="dense"
sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }} sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }}
> >
<IconButton color="inherit" onClick={[pop, 1]} disableRipple> <IconButton color="inherit" onClick={[navigate, -1]} disableRipple>
<CloseIcon /> <CloseIcon />
</IconButton> </IconButton>
<Title>{t("Settings")}</Title> <Title>{t("Settings")}</Title>
@ -202,6 +98,10 @@ const Settings: Component = () => {
</AppBar> </AppBar>
} }
> >
<BottomSheet open={!!subpage()} onClose={() => navigate(-1)}>
{subpage()}
</BottomSheet>
<List class="setting-list" use:solid-styled> <List class="setting-list" use:solid-styled>
<li> <li>
<ul> <ul>
@ -219,9 +119,9 @@ const Settings: Component = () => {
<Divider /> <Divider />
</ul> </ul>
<For each={profiles()}> <For each={profiles()}>
{({ account: acct }) => ( {({ account: acct, inf }) => (
<ul data-site={acct.site} data-username={acct.inf?.username}> <ul data-site={acct.site} data-username={inf?.username}>
<ListSubheader>{`@${acct.inf?.username ?? "..."}@${new URL(acct.site).host}`}</ListSubheader> <ListSubheader>{`@${inf?.username ?? "..."}@${new URL(acct.site).host}`}</ListSubheader>
<ListItemButton disabled> <ListItemButton disabled>
<ListItemText>{t("Notifications")}</ListItemText> <ListItemText>{t("Notifications")}</ListItemText>
<ListItemSecondaryAction> <ListItemSecondaryAction>
@ -336,58 +236,7 @@ const Settings: Component = () => {
</Show> </Show>
</ListItem> </ListItem>
<Divider /> <Divider />
{import.meta.env.VITE_CODE_VERSION ? (
<>
<ListItem>
<ListItemText secondary={import.meta.env.VITE_CODE_VERSION}>
{t("version.code")}
</ListItemText>
</ListItem>
<Divider />
</>
) : (
<></>
)}
</li> </li>
{import.meta.env.DEV ? (
<li>
<ListSubheader>Developer Tools</ListSubheader>
<ListItem
secondaryAction={
window.screen?.orientation ? (
<NativeSelect
sx={{ maxWidth: "40vw" }}
onChange={(event) => {
const k = event.currentTarget.value;
setupSafeAreaEmulation(k);
}}
>
<option>Don't change</option>
<option value={"ua"}>User agent</option>
<option value={"iphone15"}>
iPhone 15 and Plus, Pro, Pro Max
</option>
<option value={"iphone12"}>iPhone 12, 13 and 14</option>
<option value={"iphone13mini"}>iPhone 13 mini</option>
</NativeSelect>
) : undefined
}
>
<ListItemText
secondary={
window.screen?.orientation
? undefined
: "Unsupported on This Platform"
}
>
Safe Area Insets
</ListItemText>
</ListItem>
<Divider />
</li>
) : (
<></>
)}
</List> </List>
</Scaffold> </Scaffold>
); );

View file

@ -12,7 +12,6 @@
"updates.ready": "An update is ready, restart the Tutu to apply", "updates.ready": "An update is ready, restart the Tutu to apply",
"updates.no": "No updates", "updates.no": "No updates",
"version": "Using v{{packageVersion}} (built on {{builtAt}}, {{buildMode}})", "version": "Using v{{packageVersion}} (built on {{builtAt}}, {{buildMode}})",
"version.code": "Code Version",
"Language": "Language", "Language": "Language",
"Region": "Region", "Region": "Region",
"lang.auto": "(Auto) {{detected}}", "lang.auto": "(Auto) {{detected}}",

View file

@ -12,7 +12,6 @@
"updates.ready": "更新已准备好,下次开启会启动新版本", "updates.ready": "更新已准备好,下次开启会启动新版本",
"updates.no": "已是最新版本", "updates.no": "已是最新版本",
"version": "正在使用 v{{packageVersion}} ({{builtAt}}构建, {{buildMode}})", "version": "正在使用 v{{packageVersion}} ({{builtAt}}构建, {{buildMode}})",
"version.code": "代码版本",
"Language": "语言", "Language": "语言",
"Region": "区域", "Region": "区域",
"lang.auto": "(自动){{detected}}", "lang.auto": "(自动){{detected}}",

View file

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

View file

@ -2,8 +2,8 @@ import type { mastodon } from "masto";
import { Show, type Component } from "solid-js"; import { Show, type Component } from "solid-js";
import tootStyle from "./toot.module.css"; import tootStyle from "./toot.module.css";
import { formatRelative } from "date-fns"; import { formatRelative } from "date-fns";
import Img from "~material/Img"; import Img from "../material/Img";
import { Body2 } from "~material/typography"; import { Body2 } from "../material/typography";
import { appliedCustomEmoji } from "../masto/toot"; import { appliedCustomEmoji } from "../masto/toot";
import { TootPreviewCard } from "./RegularToot"; import { TootPreviewCard } from "./RegularToot";

View file

@ -3,10 +3,12 @@ import {
Show, Show,
onMount, onMount,
type ParentComponent, type ParentComponent,
createRenderEffect, children,
Suspense,
} from "solid-js"; } from "solid-js";
import { useDocumentTitle } from "../utils"; import { useDocumentTitle } from "../utils";
import Scaffold from "~material/Scaffold"; import { type mastodon } from "masto";
import Scaffold from "../material/Scaffold";
import { import {
AppBar, AppBar,
ListItemSecondaryAction, ListItemSecondaryAction,
@ -16,16 +18,22 @@ import {
Toolbar, Toolbar,
} from "@suid/material"; } from "@suid/material";
import { css } from "solid-styled"; import { css } from "solid-styled";
import { TimeSourceProvider, createTimeSource } from "~platform/timesrc"; import { TimeSourceProvider, createTimeSource } from "../platform/timesrc";
import ProfileMenuButton from "./ProfileMenuButton"; import ProfileMenuButton from "./ProfileMenuButton";
import Tabs from "~material/Tabs"; import Tabs from "../material/Tabs";
import Tab from "~material/Tab"; import Tab from "../material/Tab";
import { makeEventListener } from "@solid-primitives/event-listener"; import { makeEventListener } from "@solid-primitives/event-listener";
import BottomSheet, {
HERO as BOTTOM_SHEET_HERO,
} from "../material/BottomSheet";
import { $settings } from "../settings/stores"; import { $settings } from "../settings/stores";
import { useStore } from "@nanostores/solid"; import { useStore } from "@nanostores/solid";
import { HeroSourceProvider, type HeroSource } from "../platform/anim";
import { useNavigate } from "@solidjs/router";
import { useSignedInProfiles } from "../masto/acct";
import { setCache as setTootBottomSheetCache } from "./TootBottomSheet";
import TrendTimelinePanel from "./TrendTimelinePanel"; import TrendTimelinePanel from "./TrendTimelinePanel";
import TimelinePanel from "./TimelinePanel"; import TimelinePanel from "./TimelinePanel";
import { useSessions } from "../masto/clients";
const Home: ParentComponent = (props) => { const Home: ParentComponent = (props) => {
let panelList: HTMLDivElement; let panelList: HTMLDivElement;
@ -34,18 +42,30 @@ const Home: ParentComponent = (props) => {
const settings$ = useStore($settings); const settings$ = useStore($settings);
const profiles = useSessions(); const [profiles] = useSignedInProfiles();
const profile = () => {
const all = profiles();
if (all.length > 0) {
return all[0].inf;
}
};
const client = () => { const client = () => {
const all = profiles(); const all = profiles();
return all?.[0]?.client; return all?.[0]?.client;
}; };
const navigate = useNavigate();
const [heroSrc, setHeroSrc] = createSignal<HeroSource>({});
const [panelOffset, setPanelOffset] = createSignal(0);
const prefetching = () => !settings$().prefetchTootsDisabled; const prefetching = () => !settings$().prefetchTootsDisabled;
const [currentFocusOn, setCurrentFocusOn] = createSignal<HTMLElement[]>([]);
const [focusRange, setFocusRange] = createSignal([0, 0] as readonly [ const [focusRange, setFocusRange] = createSignal([0, 0] as readonly [
number, number,
number, number,
]); ]);
const child = children(() => props.children);
let scrollEventLockReleased = true; let scrollEventLockReleased = true;
const recalculateTabIndicator = () => { const recalculateTabIndicator = () => {
@ -82,17 +102,17 @@ const Home: ParentComponent = (props) => {
} }
}; };
const requestRecalculateTabIndicator = () => {
if (scrollEventLockReleased) {
requestAnimationFrame(recalculateTabIndicator);
}
};
createRenderEffect(() => {
makeEventListener(window, "resize", requestRecalculateTabIndicator);
});
onMount(() => { onMount(() => {
makeEventListener(panelList, "scroll", () => {
if (scrollEventLockReleased) {
requestAnimationFrame(recalculateTabIndicator);
}
});
makeEventListener(window, "resize", () => {
if (scrollEventLockReleased) {
requestAnimationFrame(recalculateTabIndicator);
}
});
requestAnimationFrame(recalculateTabIndicator); requestAnimationFrame(recalculateTabIndicator);
}); });
@ -115,6 +135,30 @@ const Home: ParentComponent = (props) => {
} }
}; };
const openFullScreenToot = (
toot: mastodon.v1.Status,
srcElement?: HTMLElement,
reply?: boolean,
) => {
const p = profiles()[0];
const inf = p.account.inf ?? profile();
if (!inf) {
console.warn("no account info?");
return;
}
setHeroSrc((x) =>
Object.assign({}, x, { [BOTTOM_SHEET_HERO]: srcElement }),
);
const acct = `${inf.username}@${p.account.site}`;
setTootBottomSheetCache(acct, toot);
navigate(`/${encodeURIComponent(acct)}/${toot.id}`, {
state: reply
? {
tootReply: true,
}
: undefined,
});
};
css` css`
.tab-panel { .tab-panel {
@ -124,9 +168,6 @@ const Home: ParentComponent = (props) => {
padding: 0 16px; padding: 0 16px;
scroll-snap-align: center; scroll-snap-align: center;
overscroll-behavior-block: none; overscroll-behavior-block: none;
contain: strict;
contain-intrinsic-size: auto 560px auto 100vh;
contain-intrinsic-size: auto 560px auto 100dvh;
@media (max-width: 600px) { @media (max-width: 600px) {
padding: 0; padding: 0;
@ -137,7 +178,7 @@ const Home: ParentComponent = (props) => {
display: grid; display: grid;
grid-auto-columns: 560px; grid-auto-columns: 560px;
grid-auto-flow: column; grid-auto-flow: column;
overflow: auto hidden; overflow-x: auto;
scroll-snap-type: x mandatory; scroll-snap-type: x mandatory;
scroll-snap-stop: always; scroll-snap-stop: always;
height: calc(100vh - var(--scaffold-topbar-height, 0px)); height: calc(100vh - var(--scaffold-topbar-height, 0px));
@ -145,10 +186,6 @@ const Home: ParentComponent = (props) => {
padding-left: var(--safe-area-inset-left, 0); padding-left: var(--safe-area-inset-left, 0);
padding-right: var(--safe-area-inset-right, 0); padding-right: var(--safe-area-inset-right, 0);
& > * {
content-visibility: auto;
}
@media (max-width: 600px) { @media (max-width: 600px) {
grid-auto-columns: 100%; grid-auto-columns: 100%;
} }
@ -165,7 +202,7 @@ const Home: ParentComponent = (props) => {
class="responsive" class="responsive"
sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }} sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }}
> >
<Tabs> <Tabs onFocusChanged={setCurrentFocusOn} offset={panelOffset()}>
<Tab focus={isTabFocus(0)} onClick={[onTabClick, 0]}> <Tab focus={isTabFocus(0)} onClick={[onTabClick, 0]}>
Home Home
</Tab> </Tab>
@ -176,7 +213,7 @@ const Home: ParentComponent = (props) => {
Public Public
</Tab> </Tab>
</Tabs> </Tabs>
<ProfileMenuButton profile={profiles()[0]}> <ProfileMenuButton profile={profile()}>
<MenuItem <MenuItem
onClick={(e) => onClick={(e) =>
$settings.setKey( $settings.setKey(
@ -197,23 +234,24 @@ const Home: ParentComponent = (props) => {
> >
<TimeSourceProvider value={now}> <TimeSourceProvider value={now}>
<Show when={!!client()}> <Show when={!!client()}>
<div <div class="panel-list" ref={panelList!}>
class="panel-list"
ref={panelList!}
onScroll={requestRecalculateTabIndicator}
>
<div class="tab-panel"> <div class="tab-panel">
<div> <div>
<TimelinePanel <TimelinePanel
client={client()} client={client()}
name="home" name="home"
prefetch={prefetching()} prefetch={prefetching()}
openFullScreenToot={openFullScreenToot}
/> />
</div> </div>
</div> </div>
<div class="tab-panel"> <div class="tab-panel">
<div> <div>
<TrendTimelinePanel client={client()} /> <TrendTimelinePanel
client={client()}
prefetch={prefetching()}
openFullScreenToot={openFullScreenToot}
/>
</div> </div>
</div> </div>
<div class="tab-panel"> <div class="tab-panel">
@ -222,6 +260,7 @@ const Home: ParentComponent = (props) => {
client={client()} client={client()}
name="public" name="public"
prefetch={prefetching()} prefetch={prefetching()}
openFullScreenToot={openFullScreenToot}
/> />
</div> </div>
</div> </div>
@ -229,6 +268,13 @@ const Home: ParentComponent = (props) => {
</div> </div>
</Show> </Show>
</TimeSourceProvider> </TimeSourceProvider>
<Suspense>
<HeroSourceProvider value={[heroSrc, setHeroSrc]}>
<BottomSheet open={!!child()} onClose={() => navigate(-1)}>
{child()}
</BottomSheet>
</HeroSourceProvider>
</Suspense>
</Scaffold> </Scaffold>
</> </>
); );

View file

@ -0,0 +1,149 @@
import type { mastodon } from "masto";
import {
type Component,
For,
createEffect,
createRenderEffect,
createSignal,
onCleanup,
onMount,
} from "solid-js";
import { css } from "solid-styled";
import tootStyle from "./toot.module.css";
import MediaViewer from "./MediaViewer";
import { render } from "solid-js/web";
import { useWindowSize } from "@solid-primitives/resize-observer";
import { useStore } from "@nanostores/solid";
import { $settings } from "../settings/stores";
const MediaAttachmentGrid: Component<{
attachments: mastodon.v1.MediaAttachment[];
}> = (props) => {
let rootRef: HTMLElement;
const [viewerIndex, setViewerIndex] = createSignal<number>();
const viewerOpened = () => typeof viewerIndex() !== "undefined";
const windowSize = useWindowSize();
const vh35 = () => Math.floor(windowSize.height * 0.35);
const settings = useStore($settings);
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;
}
};
css`
.attachments {
column-count: ${columnCount().toString()};
}
`;
return (
<section
ref={rootRef!}
class={[tootStyle.tootAttachmentGrp, "attachments"].join(" ")}
onClick={(e) => {
if (e.target !== e.currentTarget) {
e.stopImmediatePropagation();
}
}}
>
<For each={props.attachments}>
{(item, index) => {
const [loaded, setLoaded] = createSignal(false);
const width = item.meta?.small?.width;
const height = item.meta?.small?.height;
const aspectRatio = item.meta?.small?.aspect;
const maxHeight = vh35();
const realHeight = height && height > maxHeight ? maxHeight : height;
const realWidth =
width && height && height > maxHeight
? maxHeight * (aspectRatio ?? 1)
: width;
const style = () =>
loaded()
? undefined
: {
width: realWidth ? `${realWidth}px` : undefined,
height: realHeight ? `${realHeight}px` : undefined,
};
switch (item.type) {
case "image":
return (
<img
src={item.previewUrl}
style={style()}
alt={item.description || undefined}
onClick={[openViewerFor, index()]}
onLoad={[setLoaded, true]}
loading="lazy"
></img>
);
case "video":
return (
<video
src={item.url || undefined}
style={style()}
onLoadedMetadata={[setLoaded, true]}
autoplay={settings().autoPlayVideos}
playsinline={settings().autoPlayVideos ? true : undefined}
controls
poster={item.previewUrl}
/>
);
case "gifv":
return (
<video
src={item.url || undefined}
style={style()}
onLoadedMetadata={[setLoaded, true]}
autoplay={settings().autoPlayGIFs}
controls
playsinline /* or safari on iOS will play in full-screen */
loop
poster={item.previewUrl}
/>
);
case "audio":
case "unknown":
return <div></div>;
}
}}
</For>
</section>
);
};
export default MediaAttachmentGrid;

View file

@ -5,111 +5,122 @@ import {
ListItemAvatar, ListItemAvatar,
ListItemIcon, ListItemIcon,
ListItemText, ListItemText,
Menu,
MenuItem, MenuItem,
} from "@suid/material"; } from "@suid/material";
import { Show, createUniqueId, type ParentComponent } from "solid-js"; import {
ErrorBoundary,
Show,
createSignal,
createUniqueId,
type ParentComponent,
} from "solid-js";
import { import {
Settings as SettingsIcon, Settings as SettingsIcon,
Bookmark as BookmarkIcon, Bookmark as BookmarkIcon,
Star as LikeIcon, Star as LikeIcon,
FeaturedPlayList as ListIcon, FeaturedPlayList as ListIcon,
} from "@suid/icons-material"; } from "@suid/icons-material";
import A from "~platform/A"; import { A } from "@solidjs/router";
import Menu, { createManagedMenuState } from "~material/Menu";
const ProfileMenuButton: ParentComponent<{ const ProfileMenuButton: ParentComponent<{
profile?: { profile?: { displayName: string; avatar: string; username: string };
account: { onClick?: () => void;
site: string; onClose?: () => void;
inf?: {
displayName: string;
avatar: string;
username: string;
id: string;
};
};
};
}> = (props) => { }> = (props) => {
const menuId = createUniqueId(); const menuId = createUniqueId();
const buttonId = createUniqueId(); const buttonId = createUniqueId();
const [open, state] = createManagedMenuState(); let [anchor, setAnchor] = createSignal<HTMLButtonElement | null>(null);
const open = () => !!anchor();
const onClick = (event: { currentTarget: HTMLElement }) => { const onClick = (
open(event.currentTarget.getBoundingClientRect()); event: MouseEvent & { currentTarget: HTMLButtonElement },
) => {
setAnchor(event.currentTarget);
props.onClick?.();
}; };
const inf = () => props.profile?.account.inf; const onClose = () => {
props.onClick?.();
setAnchor(null);
};
return ( return (
<> <>
<ButtonBase <ButtonBase
aria-haspopup="true" aria-haspopup="true"
sx={{ borderRadius: "50%" }} sx={{ borderRadius: "50%" }}
id={buttonId} id={buttonId}
onClick={onClick} onClick={onClick}
aria-controls={state.open ? menuId : undefined} aria-controls={open() ? menuId : undefined}
aria-expanded={state.open ? "true" : "false"} aria-expanded={open() ? "true" : undefined}
>
<Avatar
alt={`${inf()?.displayName}'s avatar`}
src={inf()?.avatar}
></Avatar>
</ButtonBase>
<Menu
id={menuId}
MenuListProps={{
"aria-labelledby": menuId,
style: {
"min-width": "220px",
},
}}
{...state}
>
<MenuItem
component={A}
href={`/${encodeURIComponent(`${inf()?.username}@${props.profile?.account.site}`)}/profile/${inf()?.id}`}
disabled={!props.profile}
> >
<ListItemAvatar> <Avatar
<Avatar src={inf()?.avatar}></Avatar> alt={`${props.profile?.displayName}'s avatar`}
</ListItemAvatar> src={props.profile?.avatar}
<ListItemText ></Avatar>
primary={inf()?.displayName} </ButtonBase>
secondary={`@${inf()?.username}`} <Menu
></ListItemText> id={menuId}
</MenuItem> anchorEl={anchor()}
open={open()}
onClose={onClose}
MenuListProps={{
"aria-labelledby": buttonId,
sx: {
minWidth: "220px",
},
}}
anchorOrigin={{
vertical: "top",
horizontal: "right",
}}
transformOrigin={{
vertical: "top",
horizontal: "right",
}}
>
<MenuItem>
<ListItemAvatar>
<Avatar src={props.profile?.avatar}></Avatar>
</ListItemAvatar>
<ListItemText
primary={props.profile?.displayName}
secondary={`@${props.profile?.username}`}
></ListItemText>
</MenuItem>
<MenuItem disabled> <MenuItem>
<ListItemIcon> <ListItemIcon>
<BookmarkIcon /> <BookmarkIcon />
</ListItemIcon> </ListItemIcon>
<ListItemText>Bookmarks</ListItemText> <ListItemText>Bookmarks</ListItemText>
</MenuItem> </MenuItem>
<MenuItem disabled> <MenuItem>
<ListItemIcon> <ListItemIcon>
<LikeIcon /> <LikeIcon />
</ListItemIcon> </ListItemIcon>
<ListItemText>Likes</ListItemText> <ListItemText>Likes</ListItemText>
</MenuItem> </MenuItem>
<MenuItem disabled> <MenuItem>
<ListItemIcon> <ListItemIcon>
<ListIcon /> <ListIcon />
</ListItemIcon> </ListItemIcon>
<ListItemText>Lists</ListItemText> <ListItemText>Lists</ListItemText>
</MenuItem> </MenuItem>
<Divider />
<Show when={props.children}>
{props.children}
<Divider /> <Divider />
</Show> <Show when={props.children}>
<MenuItem component={A} href="/settings"> {props.children}
<ListItemIcon> <Divider />
<SettingsIcon /> </Show>
</ListItemIcon> <MenuItem component={A} href="/settings" onClick={onClose}>
<ListItemText>Settings</ListItemText> <ListItemIcon>
</MenuItem> <SettingsIcon />
</Menu> </ListItemIcon>
<ListItemText>Settings</ListItemText>
</MenuItem>
</Menu>
</> </>
); );
}; };

View file

@ -1,16 +1,24 @@
import { import {
createEffect, createEffect,
createRenderEffect,
createSignal, createSignal,
onCleanup,
Show, Show,
untrack, untrack,
type Component, type Component,
type Signal,
} from "solid-js"; } from "solid-js";
import { css } from "solid-styled"; import { css } from "solid-styled";
import { Refresh as RefreshIcon } from "@suid/icons-material"; import { Refresh as RefreshIcon } from "@suid/icons-material";
import { CircularProgress } from "@suid/material"; import { CircularProgress } from "@suid/material";
import { makeEventListener } from "@solid-primitives/event-listener"; import {
import { createVisibilityObserver } from "@solid-primitives/intersection-observer"; createEventListener,
import { useIsFrameSuspended } from "~platform/StackedRouter"; makeEventListener,
} from "@solid-primitives/event-listener";
import {
createViewportObserver,
createVisibilityObserver,
} from "@solid-primitives/intersection-observer";
const PullDownToRefresh: Component<{ const PullDownToRefresh: Component<{
loading?: boolean; loading?: boolean;
@ -34,7 +42,6 @@ const PullDownToRefresh: Component<{
}); });
const rootVisible = obvx(() => rootElement); const rootVisible = obvx(() => rootElement);
const isFrameSuspended = useIsFrameSuspended()
createEffect(() => { createEffect(() => {
if (!rootVisible()) setPullDown(0); if (!rootVisible()) setPullDown(0);
@ -107,16 +114,14 @@ const PullDownToRefresh: Component<{
} }
}; };
createEffect(() => { createEffect((cleanup?: () => void) => {
if (!rootVisible()) { if (!rootVisible()) {
return; return;
} }
if (isFrameSuspended()) { cleanup?.();
return;
}
const element = props.linkedElement; const element = props.linkedElement;
if (!element) return; if (!element) return;
makeEventListener(element, "wheel", handleLinkedWheel); return makeEventListener(element, "wheel", handleLinkedWheel);
}); });
let lastTouchId: number | undefined = undefined; let lastTouchId: number | undefined = undefined;
@ -160,17 +165,16 @@ const PullDownToRefresh: Component<{
} }
}; };
createEffect(() => { createEffect((cleanup?: () => void) => {
if (!rootVisible()) { if (!rootVisible()) {
return; return;
} }
if (isFrameSuspended()) { cleanup?.();
return;
}
const element = props.linkedElement; const element = props.linkedElement;
if (!element) return; if (!element) return;
makeEventListener(element, "touchmove", handleTouch); const cleanup0 = makeEventListener(element, "touchmove", handleTouch);
makeEventListener(element, "touchend", handleTouchEnd); const cleanup1 = makeEventListener(element, "touchend", handleTouchEnd);
return () => (cleanup0(), cleanup1());
}); });
css` css`

View file

@ -6,12 +6,18 @@ import {
Show, Show,
createRenderEffect, createRenderEffect,
createSignal, createSignal,
type Setter, createEffect,
} from "solid-js"; } from "solid-js";
import tootStyle from "./toot.module.css"; import tootStyle from "./toot.module.css";
import { formatRelative } from "date-fns"; import { formatRelative } from "date-fns";
import Img from "~material/Img.js"; import Img from "../material/Img.js";
import { Body2 } from "~material/typography.js"; import {
Body1,
Body2,
Caption,
Subheading,
Title,
} from "../material/typography.js";
import { css } from "solid-styled"; import { css } from "solid-styled";
import { import {
BookmarkAddOutlined, BookmarkAddOutlined,
@ -20,22 +26,86 @@ import {
Star, Star,
StarOutline, StarOutline,
Bookmark, Bookmark,
Reply,
Share, Share,
SmartToySharp,
Lock,
} from "@suid/icons-material"; } from "@suid/icons-material";
import { useTimeSource } from "~platform/timesrc.js"; import { useTimeSource } from "../platform/timesrc.js";
import { resolveCustomEmoji } from "../masto/toot.js"; import { resolveCustomEmoji } from "../masto/toot.js";
import { Divider } from "@suid/material"; import { Divider, IconButton } from "@suid/material";
import cardStyle from "~material/cards.module.css"; import cardStyle from "../material/cards.module.css";
import Button from "~material/Button.js"; import Button from "../material/Button.js";
import MediaAttachmentGrid from "./toots/MediaAttachmentGrid.jsx"; import MediaAttachmentGrid from "./MediaAttachmentGrid.js";
import { useDateFnLocale } from "~platform/i18n"; import { FastAverageColor } from "fast-average-color";
import { canShare, share } from "~platform/share"; import Color from "colorjs.io";
import { makeAcctText, useDefaultSession } from "../masto/clients"; import { useDateFnLocale } from "../platform/i18n";
import TootContent from "./toots/TootContent"; import { canShare, share } from "../platform/share";
import BoostIcon from "./toots/BoostIcon";
import PreviewCard from "./toots/PreviewCard"; type TootContentViewProps = {
source?: string;
emojis?: mastodon.v1.CustomEmoji[];
} & JSX.HTMLAttributes<HTMLDivElement>;
const TootContentView: Component<TootContentViewProps> = (props) => {
const [managed, rest] = splitProps(props, ["source", "emojis"]);
return (
<div
ref={(ref) => {
createRenderEffect(() => {
ref.innerHTML = managed.source
? managed.emojis
? resolveCustomEmoji(managed.source, managed.emojis)
: managed.source
: "";
});
}}
{...rest}
></div>
);
};
const RetootIcon: Component<JSX.HTMLElementTags["i"]> = (props) => {
const [managed, rest] = splitProps(props, ["class"]);
css`
.retoot-icon {
padding: 0;
display: inline-block;
border-radius: 2px;
> :global(svg) {
color: green;
font-size: 1rem;
vertical-align: middle;
}
}
`;
return (
<i class={["retoot-icon", managed.class].join(" ")} {...rest}>
<Repeat />
</i>
);
};
const ReplyIcon: Component<JSX.HTMLElementTags["i"]> = (props) => {
const [managed, rest] = splitProps(props, ["class"]);
css`
.retoot-icon {
padding: 0;
display: inline-block;
border-radius: 2px;
> :global(svg) {
color: var(--tutu-color-primary);
font-size: 1rem;
vertical-align: middle;
}
}
`;
return (
<i class={["retoot-icon", managed.class].join(" ")} {...rest}>
<Reply />
</i>
);
};
type TootActionGroupProps<T extends mastodon.v1.Status> = { type TootActionGroupProps<T extends mastodon.v1.Status> = {
onRetoot?: (value: T) => void; onRetoot?: (value: T) => void;
@ -47,7 +117,7 @@ type TootActionGroupProps<T extends mastodon.v1.Status> = {
) => void; ) => void;
}; };
type RegularTootProps = { type TootCardProps = {
status: mastodon.v1.Status; status: mastodon.v1.Status;
actionable?: boolean; actionable?: boolean;
evaluated?: boolean; evaluated?: boolean;
@ -139,41 +209,27 @@ function TootActionGroup<T extends mastodon.v1.Status>(
); );
} }
function TootAuthorGroup( function TootAuthorGroup(props: { status: mastodon.v1.Status; now: Date }) {
props: { const toot = () => props.status;
status: mastodon.v1.Status;
now: Date;
} & JSX.HTMLElementTags["div"],
) {
const [managed, rest] = splitProps(props, ["status", "now"]);
const toot = () => managed.status;
const dateFnLocale = useDateFnLocale(); const dateFnLocale = useDateFnLocale();
return ( return (
<div class={tootStyle.tootAuthorGrp} {...rest}> <div class={tootStyle.tootAuthorGrp}>
<Img src={toot().account.avatar} class={tootStyle.tootAvatar} /> <Img src={toot().account.avatar} class={tootStyle.tootAvatar} />
<div class={tootStyle.tootAuthorNameGrp}> <div class={tootStyle.tootAuthorNameGrp}>
<div class={tootStyle.tootAuthorNamePrimary}> <Body2
<Show when={toot().account.bot}> class={tootStyle.tootAuthorNamePrimary}
<SmartToySharp class="acct-mark" aria-label="Bot" /> ref={(e: { innerHTML: string }) => {
</Show> createRenderEffect(() => {
<Show when={toot().account.locked}> e.innerHTML = resolveCustomEmoji(
<Lock class="acct-mark" aria-label="Locked" /> toot().account.displayName,
</Show> toot().account.emojis,
<Body2 );
component="span" });
ref={(e: { innerHTML: string }) => { }}
createRenderEffect(() => { />
e.innerHTML = resolveCustomEmoji(
toot().account.displayName,
toot().account.emojis,
);
});
}}
/>
</div>
<time datetime={toot().createdAt}> <time datetime={toot().createdAt}>
{formatRelative(toot().createdAt, managed.now, { {formatRelative(toot().createdAt, props.now, {
locale: dateFnLocale(), locale: dateFnLocale(),
})} })}
</time> </time>
@ -185,57 +241,75 @@ function TootAuthorGroup(
); );
} }
/** export function TootPreviewCard(props: {
* find bottom-to-top the element with `data-action`. src: mastodon.v1.PreviewCard;
*/ alwaysCompact?: boolean;
export function findElementActionable( }) {
element: HTMLElement, let root: HTMLAnchorElement;
top: HTMLElement,
): HTMLElement | undefined { createEffect(() => {
let current = element; if (props.alwaysCompact) {
while (!current.dataset.action) { root.classList.add(tootStyle.compact);
if (!current.parentElement || current.parentElement === top) { return;
return undefined;
} }
current = current.parentElement; if (!props.src.width) return;
} const width = root.getBoundingClientRect().width;
return current; if (width > props.src.width) {
root.classList.add(tootStyle.compact);
} else {
root.classList.remove(tootStyle.compact);
}
});
const onImgLoad = (event: Event & { currentTarget: HTMLImageElement }) => {
// TODO: better extraction algorithm
// I'd like to use a pattern panel and match one in the panel from the extracted color
const fac = new FastAverageColor();
const result = fac.getColor(event.currentTarget);
if (result.error) {
console.error(result.error);
fac.destroy();
return;
}
root.style.setProperty("--tutu-color-surface", result.hex);
const focusSurface = result.isDark
? new Color(result.hex).darken(0.2)
: new Color(result.hex).lighten(0.2);
root.style.setProperty("--tutu-color-surface-d", focusSurface.toString());
const textColor = result.isDark ? "white" : "black";
const secondaryTextColor = new Color(textColor);
secondaryTextColor.alpha = 0.75;
root.style.setProperty("--tutu-color-on-surface", textColor);
root.style.setProperty(
"--tutu-color-secondary-text-on-surface",
secondaryTextColor.toString(),
);
fac.destroy();
};
return (
<a
ref={root!}
class={tootStyle.previewCard}
href={props.src.url}
target="_blank"
referrerPolicy="unsafe-url"
>
<Show when={props.src.image}>
<img
crossOrigin="anonymous"
src={props.src.image!}
onLoad={onImgLoad}
loading="lazy"
/>
</Show>
<Title component="h1">{props.src.title}</Title>
<Body1 component="p">{props.src.description}</Body1>
</a>
);
} }
function onToggleReveal(setValue: Setter<boolean>, event: Event) { const RegularToot: Component<TootCardProps> = (props) => {
event.stopPropagation();
setValue((x) => !x);
}
/**
* Component for a toot.
*
* If the session involved is not the first session, you must wrap
* this component under a `<DefaultSessionProvier />` with correct
* session.
*
* **Handling Clicks**
* There are multiple actions supported in the component. Some handlers
* are passed in, some should be handled as the click event.
*
* For those handler directly passed in, see the props starts with "on".
* We are moving to the new method below.
*
* The following actions are handled by the click event:
* - `[data-action="acct"]`: open the profile page of a account
* - `[data-acct-id]` is the account id for the client
* - `[data-client]` is the client perferred
* - `[href]` is the url of the account
*
* Handling the click event for this component, you should use
* {@link findElementActionable} to find out if the click event has
* additional intent. If the event's target is any from
* the subtree of any "actionable" element, the function returns the element.
*
* You can extract the intent from the attributes of the "actionable" element.
* The action type is the dataset's `action`.
*/
const RegularToot: Component<RegularTootProps> = (props) => {
let rootRef: HTMLElement; let rootRef: HTMLElement;
const [managed, managedActionGroup, rest] = splitProps( const [managed, managedActionGroup, rest] = splitProps(
props, props,
@ -245,8 +319,6 @@ const RegularToot: Component<RegularTootProps> = (props) => {
const now = useTimeSource(); const now = useTimeSource();
const status = () => managed.status; const status = () => managed.status;
const toot = () => status().reblog ?? status(); const toot = () => status().reblog ?? status();
const session = useDefaultSession();
const [reveal, setReveal] = createSignal(false);
css` css`
.reply-sep { .reply-sep {
@ -293,7 +365,7 @@ const RegularToot: Component<RegularTootProps> = (props) => {
return ( return (
<> <>
<article <section
classList={{ classList={{
[tootStyle.toot]: true, [tootStyle.toot]: true,
[tootStyle.expanded]: managed.evaluated, [tootStyle.expanded]: managed.evaluated,
@ -308,49 +380,33 @@ const RegularToot: Component<RegularTootProps> = (props) => {
> >
<Show when={!!status().reblog}> <Show when={!!status().reblog}>
<div class={tootStyle.tootRetootGrp}> <div class={tootStyle.tootRetootGrp}>
<BoostIcon /> <RetootIcon />
<Body2 <span>
ref={(e: { innerHTML: string }) => { <Body2
createRenderEffect(() => { ref={(e: { innerHTML: string }) => {
e.innerHTML = resolveCustomEmoji( createRenderEffect(() => {
status().account.displayName, e.innerHTML = resolveCustomEmoji(
toot().emojis, status().account.displayName,
); toot().emojis,
}); );
}} });
></Body2> }}
<span>boosts</span> ></Body2>{" "}
boosted
</span>
</div> </div>
</Show> </Show>
<TootAuthorGroup <TootAuthorGroup status={toot()} now={now()} />
status={toot()} <TootContentView
now={now()}
data-action="acct"
data-client={session() ? makeAcctText(session()!) : undefined}
data-acct-id={toot().account.id}
/>
<TootContent
source={toot().content} source={toot().content}
emojis={toot().emojis} emojis={toot().emojis}
mentions={toot().mentions} class={tootStyle.tootContent}
class={cardStyle.cardNoPad}
sensitive={toot().sensitive}
spoilerText={toot().spoilerText}
reveal={reveal()}
onToggleReveal={[onToggleReveal, setReveal]}
/> />
<Show <Show when={toot().card}>
when={ <TootPreviewCard src={toot().card!} />
toot().card && (!toot().sensitive || (toot().sensitive && reveal()))
}
>
<PreviewCard src={toot().card!} />
</Show> </Show>
<Show when={toot().mediaAttachments.length > 0}> <Show when={toot().mediaAttachments.length > 0}>
<MediaAttachmentGrid <MediaAttachmentGrid attachments={toot().mediaAttachments} />
attachments={toot().mediaAttachments}
sensitive={toot().sensitive}
/>
</Show> </Show>
<Show when={managed.actionable}> <Show when={managed.actionable}>
<Divider <Divider
@ -359,7 +415,7 @@ const RegularToot: Component<RegularTootProps> = (props) => {
/> />
<TootActionGroup value={toot()} {...managedActionGroup} /> <TootActionGroup value={toot()} {...managedActionGroup} />
</Show> </Show>
</article> </section>
</> </>
); );
}; };

92
src/timelines/Thread.tsx Normal file
View file

@ -0,0 +1,92 @@
import type { mastodon } from "masto";
import {
For,
Show,
createResource,
createSignal,
type Component,
type Ref,
} from "solid-js";
import CompactToot from "./CompactToot";
import { useTimeSource } from "../platform/timesrc";
import RegularToot, { findRootToot } from "./RegularToot";
import cardStyle from "../material/cards.module.css";
import { css } from "solid-styled";
type TootActionTarget = {
client: mastodon.rest.Client;
status: mastodon.v1.Status;
};
type TootActions = {
onBoost(client: mastodon.rest.Client, status: mastodon.v1.Status): void;
onBookmark(client: mastodon.rest.Client, status: mastodon.v1.Status): void;
onReply(target: TootActionTarget, element: HTMLElement): void;
};
type ThreadProps = {
ref?: Ref<HTMLElement>;
client: mastodon.rest.Client;
toots: readonly mastodon.v1.Status[];
isExpended: (status: mastodon.v1.Status) => boolean;
onItemClick(status: mastodon.v1.Status, event: MouseEvent): void;
} & TootActions;
const Thread: Component<ThreadProps> = (props) => {
const boost = (status: mastodon.v1.Status) => {
props.onBoost(props.client, status);
};
const bookmark = (status: mastodon.v1.Status) => {
props.onBookmark(props.client, status);
};
const reply = (
status: mastodon.v1.Status,
event: MouseEvent & { currentTarget: HTMLElement },
) => {
const element = findRootToot(event.currentTarget);
props.onReply({ client: props.client, status }, element);
};
css`
.thread {
user-select: none;
cursor: pointer;
}
`
return (
<article ref={props.ref} class="thread">
<For each={props.toots}>
{(status, index) => {
const useThread = props.toots.length > 1;
const threadPosition = useThread
? index() === 0
? "top"
: index() === props.toots.length - 1
? "bottom"
: "middle"
: undefined;
return (
<RegularToot
data-status-id={status.id}
data-thread-sort={index()}
status={status}
thread={threadPosition}
class={cardStyle.card}
evaluated={props.isExpended(status)}
actionable={props.isExpended(status)}
onBookmark={(s) => bookmark(s)}
onRetoot={(s) => boost(s)}
onReply={reply}
onClick={[props.onItemClick, status]}
/>
);
}}
</For>
</article>
);
};
export default Thread;

View file

@ -1,5 +1,6 @@
import { import {
Component, Component,
For,
onCleanup, onCleanup,
createSignal, createSignal,
Show, Show,
@ -11,30 +12,75 @@ import {
import { type mastodon } from "masto"; import { type mastodon } from "masto";
import { Button, LinearProgress } from "@suid/material"; import { Button, LinearProgress } from "@suid/material";
import { createTimeline } from "../masto/timelines"; import { createTimeline } from "../masto/timelines";
import { vibrate } from "../platform/hardware";
import PullDownToRefresh from "./PullDownToRefresh"; import PullDownToRefresh from "./PullDownToRefresh";
import TootComposer from "./TootComposer"; import TootComposer from "./TootComposer";
import TootList from "./TootList"; import Thread from "./Thread.jsx";
const TimelinePanel: Component<{ const TimelinePanel: Component<{
client: mastodon.rest.Client; client: mastodon.rest.Client;
name: "home" | "public"; name: "home" | "public";
prefetch?: boolean; prefetch?: boolean;
openFullScreenToot: (
toot: mastodon.v1.Status,
srcElement?: HTMLElement,
reply?: boolean,
) => void;
}> = (props) => { }> = (props) => {
const [scrollLinked, setScrollLinked] = createSignal<HTMLElement>(); const [scrollLinked, setScrollLinked] = createSignal<HTMLElement>();
const [timeline, snapshot, { refetch: refetchTimeline }] = createTimeline( const [timeline, snapshot, { refetch: refetchTimeline }] = createTimeline(
() => props.client.v1.timelines[props.name], () => props.client.v1.timelines[props.name],
() => ({ limit: 20 }), () => 20,
); );
const [expandedThreadId, setExpandedThreadId] = createSignal<string>();
const [typing, setTyping] = createSignal(false);
const tlEndObserver = new IntersectionObserver(() => { const tlEndObserver = new IntersectionObserver(() => {
if (untrack(() => props.prefetch) && !snapshot.loading) if (untrack(() => props.prefetch) && !snapshot.loading)
refetchTimeline("prev"); refetchTimeline("next");
}); });
onCleanup(() => tlEndObserver.disconnect()); onCleanup(() => tlEndObserver.disconnect());
const onBookmark = async (
client: mastodon.rest.Client,
status: mastodon.v1.Status,
) => {
const result = await (status.bookmarked
? client.v1.statuses.$select(status.id).unbookmark()
: client.v1.statuses.$select(status.id).bookmark());
timeline.set(result.id, result);
};
const onBoost = async (
client: mastodon.rest.Client,
status: mastodon.v1.Status,
) => {
vibrate(50);
const rootStatus = status.reblog ? status.reblog : status;
const reblogged = rootStatus.reblogged;
if (status.reblog) {
status.reblog = { ...status.reblog, reblogged: !reblogged };
timeline.set(status.id, status);
} else {
timeline.set(
status.id,
Object.assign(status, {
reblogged: !reblogged,
}),
);
}
const result = reblogged
? await client.v1.statuses.$select(status.id).unreblog()
: (await client.v1.statuses.$select(status.id).reblog()).reblog!;
timeline.set(
status.id,
Object.assign(status.reblog ?? status, result.reblog),
);
};
return ( return (
<ErrorBoundary <ErrorBoundary
fallback={(err, reset) => { fallback={(err, reset) => {
@ -58,16 +104,42 @@ const TimelinePanel: Component<{
style={{ style={{
"--scaffold-topbar-height": "0px", "--scaffold-topbar-height": "0px",
}} }}
isTyping={typing()}
onTypingChange={setTyping}
client={props.client} client={props.client}
onSent={() => refetchTimeline("prev")} onSent={() => refetchTimeline("prev")}
/> />
</Show> </Show>
<For each={timeline.list}>
{(itemId, index) => {
const path = timeline.getPath(itemId)!;
const toots = path.reverse().map((x) => x.value);
<TootList return (
threads={timeline.list} <Thread
onUnknownThread={timeline.getPath} toots={toots}
onChangeToot={timeline.set} onBoost={onBoost}
></TootList> onBookmark={onBookmark}
onReply={({ status }, element) =>
props.openFullScreenToot(status, element, true)
}
client={props.client}
isExpended={(status) => status.id === expandedThreadId()}
onItemClick={(status, event) => {
setTyping(false);
if (status.id !== expandedThreadId()) {
setExpandedThreadId((x) => (x ? undefined : status.id));
} else {
props.openFullScreenToot(
status,
event.currentTarget as HTMLElement,
);
}
}}
/>
);
}}
</For>
</div> </div>
<div ref={(e) => tlEndObserver.observe(e)}></div> <div ref={(e) => tlEndObserver.observe(e)}></div>

View file

@ -1,17 +0,0 @@
.TootBottomSheet {
overflow: hidden;
.Scrollable {
padding-bottom: var(--safe-area-inset-bottom, 0);
overflow-y: auto;
overscroll-behavior-y: contain;
height: calc(100% - var(--scaffold-topbar-height, 0px));
}
.progress-line {
display: flex;
justify-content: center;
margin-top: 12px;
margin-bottom: 12px;
}
}

View file

@ -1,31 +1,29 @@
import { useLocation, useParams } from "@solidjs/router"; import { useLocation, useNavigate, useParams } from "@solidjs/router";
import { import {
catchError,
createEffect, createEffect,
createRenderEffect, createRenderEffect,
createResource, createResource,
createSignal,
For,
Show, Show,
type Component, type Component,
} from "solid-js"; } from "solid-js";
import Scaffold from "~material/Scaffold"; import Scaffold from "../material/Scaffold";
import { AppBar, CircularProgress, IconButton, Toolbar } from "@suid/material"; import { AppBar, CircularProgress, IconButton, Toolbar } from "@suid/material";
import { Title } from "~material/typography"; import { Title } from "../material/typography";
import { Close as CloseIcon } from "@suid/icons-material"; import {
import { useSessionForAcctStr } from "../masto/clients"; ArrowBack as BackIcon,
Close as CloseIcon,
} from "@suid/icons-material";
import { createUnauthorizedClient, useSessions } from "../masto/clients";
import { resolveCustomEmoji } from "../masto/toot"; import { resolveCustomEmoji } from "../masto/toot";
import RegularToot, { findElementActionable } from "./RegularToot"; import RegularToot from "./RegularToot";
import type { mastodon } from "masto"; import type { mastodon } from "masto";
import cards from "~material/cards.module.css"; import cards from "../material/cards.module.css";
import { css } from "solid-styled"; import { css } from "solid-styled";
import { vibrate } from "~platform/hardware"; import { vibrate } from "../platform/hardware";
import { createTimeSource, TimeSourceProvider } from "~platform/timesrc"; import { createTimeSource, TimeSourceProvider } from "../platform/timesrc";
import TootComposer from "./TootComposer"; import TootComposer from "./TootComposer";
import { useDocumentTitle } from "../utils";
import { createTimelineControlsForArray } from "../masto/timelines";
import TootList from "./TootList";
import "./TootBottomSheet.css";
import { useNavigator } from "~platform/StackedRouter";
import BackButton from "~platform/BackButton";
let cachedEntry: [string, mastodon.v1.Status] | undefined; let cachedEntry: [string, mastodon.v1.Status] | undefined;
@ -42,12 +40,32 @@ function getCache(acct: string, id: string) {
const TootBottomSheet: Component = (props) => { const TootBottomSheet: Component = (props) => {
const params = useParams<{ acct: string; id: string }>(); const params = useParams<{ acct: string; id: string }>();
const location = useLocation<{ const location = useLocation<{
tootBottomSheetPushedCount?: number;
tootReply?: boolean; tootReply?: boolean;
}>(); }>();
const { pop, push } = useNavigator(); const navigate = useNavigate();
const allSession = useSessions();
const time = createTimeSource(); const time = createTimeSource();
const [isInTyping, setInTyping] = createSignal(false);
const acctText = () => decodeURIComponent(params.acct); const acctText = () => decodeURIComponent(params.acct);
const session = useSessionForAcctStr(acctText); const session = () => {
const [inputUsername, inputSite] = acctText().split("@", 2);
const authedSession = allSession().find(
(x) =>
x.account.site === inputSite &&
x.account.inf?.username === inputUsername,
);
return (
authedSession ?? {
client: createUnauthorizedClient(inputSite),
account: undefined,
}
);
};
const pushedCount = () => {
return location.state?.tootBottomSheetPushedCount || 0;
};
const [remoteToot, { mutate: setRemoteToot }] = createResource( const [remoteToot, { mutate: setRemoteToot }] = createResource(
() => [session().client, params.id] as const, () => [session().client, params.id] as const,
@ -56,10 +74,7 @@ const TootBottomSheet: Component = (props) => {
}, },
); );
const toot = () => const toot = () => remoteToot() ?? getCache(acctText(), params.id);
catchError(remoteToot, (error) => {
console.error(error);
}) ?? getCache(acctText(), params.id);
createEffect((lastTootId?: string) => { createEffect((lastTootId?: string) => {
const tootId = toot()?.id; const tootId = toot()?.id;
@ -69,40 +84,30 @@ const TootBottomSheet: Component = (props) => {
return tootId; return tootId;
}); });
const [tootContextErrorUncaught, { refetch: refetchContext }] = createEffect(() => {
createResource( if (location.state?.tootReply) {
() => [session().client, params.id] as const, setInTyping(true);
async ([client, id]) => { }
return await client.v1.statuses.$select(id).context.fetch(); });
},
);
const tootContext = () => const [tootContext, { refetch: refetchContext }] = createResource(
catchError(tootContextErrorUncaught, (error) => { () => [session().client, params.id] as const,
console.error(error); async ([client, id]) => {
}); return await client.v1.statuses.$select(id).context.fetch();
},
);
const ancestors = createTimelineControlsForArray( const ancestors = () => tootContext()?.ancestors ?? [];
() => tootContext()?.ancestors, const descendants = () => tootContext()?.descendants ?? [];
);
const descendants = createTimelineControlsForArray(
() => tootContext()?.descendants,
);
createEffect(() => { createEffect(() => {
if (ancestors.list.length > 0) { if (ancestors().length > 0) {
document.querySelector(`#toot-${toot()!.id}`)?.scrollIntoView(); document.querySelector(`#toot-${toot()!.id}`)?.scrollIntoView();
} }
}); });
useDocumentTitle(() => {
const t = toot()?.reblog ?? toot();
const name = t?.account.displayName ?? "Someone";
return `${name}'s toot`;
});
const tootDisplayName = () => { const tootDisplayName = () => {
const t = toot()?.reblog ?? toot(); const t = toot();
if (t) { if (t) {
return resolveCustomEmoji(t.account.displayName, t.account.emojis); return resolveCustomEmoji(t.account.displayName, t.account.emojis);
} }
@ -157,45 +162,31 @@ const TootBottomSheet: Component = (props) => {
setRemoteToot(result); setRemoteToot(result);
}; };
const switchContext = (status: mastodon.v1.Status) => {
if (isInTyping()) {
setInTyping(false);
return;
}
setCache(params.acct, status);
navigate(`/${params.acct}/${status.id}`, {
state: {
tootBottomSheetPushedCount: pushedCount() + 1,
},
});
};
const defaultMentions = () => { const defaultMentions = () => {
const tootAcct = remoteToot()?.reblog?.account ?? remoteToot()?.account; const tootAcct = remoteToot()?.reblog?.account ?? remoteToot()?.account;
if (!tootAcct) { if (!tootAcct) {
return; return;
} }
const others = ancestors.list.map((x) => ancestors.get(x)!.value.account); const others = ancestors().map((x) => x.account);
const values = [tootAcct, ...others].map((x) => `@${x.acct}`); const values = [tootAcct, ...others].map((x) => `@${x.acct}`);
return Array.from(new Set(values).keys()); return Array.from(new Set(values).keys());
}; };
const handleMainTootClick = (
event: MouseEvent & { currentTarget: HTMLElement },
) => {
const actionableElement = findElementActionable(
event.target as HTMLElement,
event.currentTarget,
);
if (actionableElement) {
if (actionableElement.dataset.action === "acct") {
event.stopPropagation();
const target = actionableElement as HTMLAnchorElement;
const acct = encodeURIComponent(
target.dataset.client || `@${new URL(target.href).origin}`,
);
push(`/${acct}/profile/${target.dataset.acctId}`);
return;
} else {
console.warn("unknown action", actionableElement.dataset.rel);
}
}
};
css` css`
.name :global(img) { .name :global(img) {
max-height: 1em; max-height: 1em;
@ -229,8 +220,14 @@ const TootBottomSheet: Component = (props) => {
variant="dense" variant="dense"
sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }} sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }}
> >
<BackButton color="inherit" /> <IconButton color="inherit" onClick={[navigate, -1]} disableRipple>
<Title component="div" class="name" use:solid-styled> {pushedCount() > 0 ? <BackIcon /> : <CloseIcon />}
</IconButton>
<Title
component="div"
class="name"
use:solid-styled
>
<span <span
ref={(e: HTMLElement) => ref={(e: HTMLElement) =>
createRenderEffect( createRenderEffect(
@ -243,62 +240,77 @@ const TootBottomSheet: Component = (props) => {
</Toolbar> </Toolbar>
</AppBar> </AppBar>
} }
class="TootBottomSheet"
> >
<div class="Scrollable"> <TimeSourceProvider value={time}>
<TimeSourceProvider value={time}> <For each={ancestors()}>
<TootList {(item) => (
threads={ancestors.list} <RegularToot
onUnknownThread={ancestors.getPath} id={`toot-${item.id}`}
onChangeToot={ancestors.set} class={cards.card}
/> status={item}
actionable={false}
onClick={[switchContext, item]}
></RegularToot>
)}
</For>
<article> <article>
<Show when={toot()}> <Show when={toot()}>
<RegularToot <RegularToot
id={`toot-${toot()!.id}`} id={`toot-${toot()!.id}`}
class={cards.card} class={cards.card}
style={{ style={{
"scroll-margin-top": "scroll-margin-top":
"calc(var(--scaffold-topbar-height) + 20px)", "calc(var(--scaffold-topbar-height) + 20px)",
"cursor": "auto", }}
"user-select": "auto", status={toot()!}
}} actionable={!!actSession()}
status={toot()!} evaluated={true}
actionable={!!actSession()} onBookmark={onBookmark}
evaluated={true} onRetoot={onBoost}
onBookmark={onBookmark} onFavourite={onFav}
onRetoot={onBoost} ></RegularToot>
onFavourite={onFav}
onClick={handleMainTootClick}
></RegularToot>
</Show>
</article>
<Show when={session()!.account}>
<TootComposer
mentions={defaultMentions()}
profile={session().account!}
replyToDisplayName={toot()?.account?.displayName || ""}
client={session().client}
onSent={() => refetchContext()}
inReplyToId={remoteToot()?.reblog?.id ?? remoteToot()?.id}
/>
</Show> </Show>
</article>
<Show when={tootContextErrorUncaught.loading}> <Show when={session()!.account}>
<div class="progress-line"> <TootComposer
<CircularProgress style="width: 1.5em; height: 1.5em;" /> isTyping={isInTyping()}
</div> onTypingChange={setInTyping}
</Show> mentions={defaultMentions()}
profile={session().account!}
<TootList replyToDisplayName={toot()?.account?.displayName || ""}
threads={descendants.list} client={session().client}
onUnknownThread={descendants.getPath} onSent={() => refetchContext()}
onChangeToot={descendants.set} inReplyToId={remoteToot()?.reblog?.id ?? remoteToot()?.id}
/> />
</TimeSourceProvider> </Show>
</div>
<Show when={tootContext.loading}>
<div
style={{
display: "flex",
"justify-content": "center",
"margin-block": "12px",
}}
>
<CircularProgress style="width: 1.5em; height: 1.5em;" />
</div>
</Show>
<For each={descendants()}>
{(item) => (
<RegularToot
id={`toot-${item.id}`}
class={cards.card}
status={item}
actionable={false}
onClick={[switchContext, item]}
></RegularToot>
)}
</For>
</TimeSourceProvider>
<div style={{ height: "var(--safe-area-inset-bottom, 0)" }}></div>
</Scaffold> </Scaffold>
); );
}; };

View file

@ -1,45 +0,0 @@
.TootComposer {
--card-gut: 8px;
contain: content;
> .MuiToolbar-root {
justify-content: space-between;
> :first-child {
margin-left: -0.5em;
}
> :last-child {
margin-right: -0.5em;
}
}
.reply-input {
display: flex;
align-items: flex-start;
gap: 8px;
}
.options {
display: flex;
justify-content: flex-end;
gap: 16px;
flex-flow: row wrap;
padding-top: 16px;
padding-bottom: 8px;
margin-left: -0.5em;
margin-right: -0.5em;
animation: TootComposerFadeIn 110ms var(--tutu-anim-curve-sharp) both;
}
}
@keyframes TootComposerFadeIn {
0% {
opacity: 0.5;
}
100% {
opacity: 1;
}
}

View file

@ -0,0 +1,11 @@
.composer {
composes: card from "../material/cards.module.css";
--card-gut: 8px;
}
.replyInput {
display: flex;
align-items: flex-start;
gap: 8px;
}

View file

@ -1,15 +1,15 @@
import { import {
createEffect, createEffect,
createMemo, createMemo,
createRenderEffect,
createSignal, createSignal,
createUniqueId,
onMount,
Show, Show,
type Accessor,
type Component, type Component,
type JSX, type JSX,
type Ref, type Ref,
} from "solid-js"; } from "solid-js";
import Scaffold from "~material/Scaffold"; import Scaffold from "../material/Scaffold";
import { import {
Avatar, Avatar,
Button, Button,
@ -23,9 +23,6 @@ import {
Switch, Switch,
Divider, Divider,
CircularProgress, CircularProgress,
Toolbar,
MenuItem,
ListItemAvatar,
} from "@suid/material"; } from "@suid/material";
import { import {
ArrowDropDown, ArrowDropDown,
@ -36,27 +33,20 @@ import {
ListAlt as ListAltIcon, ListAlt as ListAltIcon,
Visibility, Visibility,
Translate, Translate,
Close,
MoreVert,
} from "@suid/icons-material"; } from "@suid/icons-material";
import type { Account } from "../accounts/stores"; import type { Account } from "../accounts/stores";
import "./TootComposer.css"; import tootComposers from "./TootComposer.module.css";
import BottomSheet from "~material/BottomSheet"; import { makeEventListener } from "@solid-primitives/event-listener";
import { useLanguage } from "~platform/i18n"; import BottomSheet from "../material/BottomSheet";
import { useLanguage } from "../platform/i18n";
import iso639_1 from "iso-639-1"; import iso639_1 from "iso-639-1";
import ChooseTootLang from "./ChooseTootLang"; import ChooseTootLang from "./ChooseTootLang";
import type { mastodon } from "masto"; import type { mastodon } from "masto";
import cardStyles from "~material/cards.module.css";
import Menu, { createManagedMenuState } from "~material/Menu";
import { useDefaultSession } from "../masto/clients";
import { resolveCustomEmoji } from "../masto/toot";
import SizedTextarea from "~platform/SizedTextarea";
type TootVisibility = "public" | "unlisted" | "private" | "direct"; type TootVisibility = "public" | "unlisted" | "private" | "direct";
const TootVisibilityPickerDialog: Component<{ const TootVisibilityPickerDialog: Component<{
open?: boolean; open?: boolean;
class?: string;
onClose: () => void; onClose: () => void;
visibility: TootVisibility; visibility: TootVisibility;
onVisibilityChange: (value: TootVisibility) => void; onVisibilityChange: (value: TootVisibility) => void;
@ -86,19 +76,14 @@ const TootVisibilityPickerDialog: Component<{
}; };
return ( return (
<BottomSheet <BottomSheet open={props.open} onClose={props.onClose} bottomUp>
open={props.open}
onClose={props.onClose}
bottomUp
class={props.class}
>
<Scaffold <Scaffold
bottom={ bottom={
<div <div
style={{ style={{
"border-top": "1px solid #ddd", "border-top": "1px solid #ddd",
background: "var(--tutu-color-surface)", background: "var(--tutu-color-surface)",
padding: "8px 16px calc(8px + var(--safe-area-inset-bottom, 0px))", padding: "8px 16px",
width: "100%", width: "100%",
"text-align": "end", "text-align": "end",
}} }}
@ -178,13 +163,12 @@ const TootVisibilityPickerDialog: Component<{
const TootLanguagePickerDialog: Component<{ const TootLanguagePickerDialog: Component<{
open?: boolean; open?: boolean;
class?: string;
onClose: () => void; onClose: () => void;
code: string; code: string;
onCodeChange: (nval: string) => void; onCodeChange: (nval: string) => void;
}> = (props) => { }> = (props) => {
return ( return (
<BottomSheet open={props.open} onClose={props.onClose} class={props.class}> <BottomSheet open={props.open} onClose={props.onClose}>
<Show when={props.open}> <Show when={props.open}>
<ChooseTootLang <ChooseTootLang
code={props.code} code={props.code}
@ -204,41 +188,33 @@ function randomChoose<T extends any[]>(
return K[idx]; return K[idx];
} }
function useRandomChoice<T>(choices: () => T[]): Accessor<T> {
return createMemo(() => randomChoose(Math.random(), choices()));
}
function cancelEvent(event: Event) {
event.stopPropagation();
}
const TootComposer: Component<{ const TootComposer: Component<{
ref?: Ref<HTMLDivElement>; ref?: Ref<HTMLDivElement>;
style?: JSX.CSSProperties; style?: JSX.CSSProperties;
profile?: Account; profile?: Account;
replyToDisplayName?: string; replyToDisplayName?: string;
mentions?: readonly string[]; mentions?: readonly string[];
isTyping?: boolean;
onTypingChange: (value: boolean) => void;
client?: mastodon.rest.Client; client?: mastodon.rest.Client;
inReplyToId?: string; inReplyToId?: string;
onSent?: (status: mastodon.v1.Status) => void; onSent?: (status: mastodon.v1.Status) => void;
}> = (props) => { }> = (props) => {
let inputRef: HTMLTextAreaElement; let inputRef: HTMLTextAreaElement;
let sendKey: string | undefined;
const session = useDefaultSession(); const typing = () => props.isTyping;
const setTyping = (v: boolean) => props.onTypingChange(v);
const [active, setActive] = createSignal(false);
const [sending, setSending] = createSignal(false); const [sending, setSending] = createSignal(false);
const [visibility, setVisibility] = createSignal<TootVisibility>("public"); const [visibility, setVisibility] = createSignal<TootVisibility>("public");
const [permPicker, setPermPicker] = createSignal(false); const [permPicker, setPermPicker] = createSignal(false);
const [language, setLanguage] = createSignal("en"); const [language, setLanguage] = createSignal("en");
const [langPickerOpen, setLangPickerOpen] = createSignal(false); const [langPickerOpen, setLangPickerOpen] = createSignal(false);
const appLanguage = useLanguage(); const appLanguage = useLanguage();
const [openMenu, menuState] = createManagedMenuState();
const randomPlaceholder = useRandomChoice(() => [ const randomPlaceholder = createMemo(() =>
"What's happening?", randomChoose(Math.random(), ["What's happening?", "What do your think?"]),
"What do you think?", );
]);
createEffect(() => { createEffect(() => {
const lang = appLanguage().split("-")[0]; const lang = appLanguage().split("-")[0];
@ -246,11 +222,15 @@ const TootComposer: Component<{
}); });
createEffect(() => { createEffect(() => {
if (active()) { if (typing()) {
setTimeout(() => inputRef.focus(), 0); setTimeout(() => inputRef.focus(), 0);
} }
}); });
onMount(() => {
makeEventListener(inputRef, "focus", () => setTyping(true));
});
createEffect(() => { createEffect(() => {
if (inputRef.value !== "") return; if (inputRef.value !== "") return;
if (props.mentions) { if (props.mentions) {
@ -260,7 +240,7 @@ const TootComposer: Component<{
}); });
const containerStyle = () => const containerStyle = () =>
active() || permPicker() typing() || permPicker()
? { ? {
position: "sticky" as const, position: "sticky" as const,
top: "var(--scaffold-topbar-height, 0)", top: "var(--scaffold-topbar-height, 0)",
@ -283,15 +263,17 @@ const TootComposer: Component<{
} }
}; };
const idempotencyKey = createMemo(() => window.crypto.randomUUID()); const getOrGenSendKey = () => {
if (sendKey === undefined) {
sendKey = window.crypto.randomUUID();
}
return sendKey;
};
const send = async () => { const send = async () => {
const client = session()?.client;
if (!client) return;
setSending(true); setSending(true);
try { try {
const status = await client.v1.statuses.create( const status = await props.client!.v1.statuses.create(
{ {
status: inputRef.value, status: inputRef.value,
language: language(), language: language(),
@ -301,7 +283,7 @@ const TootComposer: Component<{
{ {
requestInit: { requestInit: {
headers: { headers: {
["Idempotency-Key"]: idempotencyKey(), ["Idempotency-Key"]: getOrGenSendKey(),
}, },
}, },
}, },
@ -317,72 +299,27 @@ const TootComposer: Component<{
return ( return (
<div <div
ref={props.ref} ref={props.ref}
class={/* @once */ `TootComposer ${cardStyles.card}`} class={tootComposers.composer}
style={containerStyle()} style={containerStyle()}
on:touchend={ onClick={(e) => inputRef.focus()}
cancelEvent
/* on: is required to register the event handler on the exact element */
}
on:touchmove={cancelEvent}
on:wheel={cancelEvent}
> >
<Show when={active()}> <div class={tootComposers.replyInput}>
<Toolbar class={cardStyles.cardNoPad}>
<IconButton
onClick={[setActive, false]}
aria-label="Close the composer"
>
<Close />
</IconButton>
<IconButton
onClick={(e) => openMenu(e.currentTarget.getBoundingClientRect())}
>
<MoreVert />
</IconButton>
</Toolbar>
<div class={cardStyles.cardNoPad}>
<Menu {...menuState}>
<MenuItem>
<ListItemAvatar>
<Avatar src={session()?.account.inf?.avatar}></Avatar>
</ListItemAvatar>
<ListItemText secondary={"Default account"}>
<span
ref={(e) => {
createRenderEffect(() => {
const inf = session()?.account.inf;
return (e.innerHTML = resolveCustomEmoji(
inf?.displayName || "",
inf?.emojis ?? [],
));
});
}}
></span>
</ListItemText>
</MenuItem>
</Menu>
</div>
</Show>
<div class="reply-input">
<Show when={props.profile}> <Show when={props.profile}>
<Avatar <Avatar
src={props.profile!.inf?.avatar} src={props.profile!.inf?.avatar}
sx={{ marginLeft: "-0.25em" }} sx={{ marginLeft: "-0.25em" }}
/> />
</Show> </Show>
<SizedTextarea <textarea
ref={inputRef!} ref={inputRef!}
placeholder={ placeholder={
props.replyToDisplayName props.replyToDisplayName
? `Reply to ${props.replyToDisplayName}...` ? `Reply to ${props.replyToDisplayName}...`
: randomPlaceholder() : randomPlaceholder()
} }
onFocus={[setActive, true]}
style={{ width: "100%", border: "none" }} style={{ width: "100%", border: "none" }}
disabled={sending()} disabled={sending()}
autocomplete="off" ></textarea>
></SizedTextarea>
<Show when={props.client}> <Show when={props.client}>
<Show <Show
when={!sending()} when={!sending()}
@ -398,43 +335,36 @@ const TootComposer: Component<{
</div> </div>
} }
> >
<IconButton <IconButton sx={{ marginRight: "-0.5em" }} onClick={send}>
sx={{ marginRight: "-0.5em" }}
onClick={send}
aria-label="Send"
>
<Send /> <Send />
</IconButton> </IconButton>
</Show> </Show>
</Show> </Show>
</div> </div>
<Show when={active()}> <Show when={typing()}>
<div class="options"> <div
<Button style={{
startIcon={<Translate />} display: "flex",
endIcon={<ArrowDropDown />} "justify-content": "flex-end",
onClick={[setLangPickerOpen, true]} "margin-top": "8px",
disabled={sending()} gap: "16px",
> "flex-flow": "row wrap",
<span style={{ "vertical-align": "bottom" }}> }}
{iso639_1.getNativeName(language())} >
</span> <Button onClick={[setLangPickerOpen, true]} disabled={sending()}>
<Translate sx={{ marginTop: "-0.25em", marginRight: "0.25em" }} />
{iso639_1.getNativeName(language())}
<ArrowDropDown sx={{ marginTop: "-0.25em" }} />
</Button> </Button>
<Button <Button onClick={[setPermPicker, true]} disabled={sending()}>
startIcon={<Visibility />} <Visibility sx={{ marginTop: "-0.15em", marginRight: "0.25em" }} />
endIcon={<ArrowDropDown />} {visibilityText()}
onClick={[setPermPicker, true]} <ArrowDropDown sx={{ marginTop: "-0.25em" }} />
disabled={sending()}
>
<span style={{ "vertical-align": "bottom" }}>
{visibilityText()}
</span>
</Button> </Button>
</div> </div>
<TootVisibilityPickerDialog <TootVisibilityPickerDialog
class={cardStyles.cardNoPad}
open={permPicker()} open={permPicker()}
onClose={() => setPermPicker(false)} onClose={() => setPermPicker(false)}
visibility={visibility()} visibility={visibility()}
@ -442,7 +372,6 @@ const TootComposer: Component<{
/> />
<TootLanguagePickerDialog <TootLanguagePickerDialog
class={cardStyles.cardNoPad}
open={langPickerOpen()} open={langPickerOpen()}
onClose={() => setLangPickerOpen(false)} onClose={() => setLangPickerOpen(false)}
code={language()} code={language()}

View file

@ -1,288 +0,0 @@
import {
Component,
createSignal,
ErrorBoundary,
type Ref,
createSelector,
Index,
createMemo,
} from "solid-js";
import { type mastodon } from "masto";
import { vibrate } from "~platform/hardware";
import { useDefaultSession } from "../masto/clients";
import { setCache as setTootBottomSheetCache } from "./TootBottomSheet";
import RegularToot, {
findElementActionable,
findRootToot,
} from "./RegularToot";
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";
function durationOf(rect0: DOMRect, rect1: DOMRect) {
const distancelt = Math.sqrt(
Math.pow(Math.abs(rect0.top - rect1.top), 2) +
Math.pow(Math.abs(rect0.left - rect1.left), 2),
);
const distancerb = Math.sqrt(
Math.pow(Math.abs(rect0.bottom - rect1.bottom), 2) +
Math.pow(Math.abs(rect0.right - rect1.right), 2),
);
const distance = distancelt + distancerb;
const duration = distance / 1.6;
return duration;
}
function positionTootInThread(index: number, threadLength: number) {
if (index === 0) {
return "top";
} else if (index === threadLength - 1) {
return "bottom";
}
return "middle";
}
const TootList: Component<{
ref?: Ref<HTMLDivElement>;
id?: string;
threads: readonly string[];
onUnknownThread: (id: string) => ThreadNode[] | undefined;
onChangeToot: (id: string, value: mastodon.v1.Status) => void;
}> = (props) => {
const session = useDefaultSession();
const [expandedThreadId, setExpandedThreadId] = createSignal<string>();
const { push } = useNavigator();
const onBookmark = async (status: mastodon.v1.Status) => {
const client = session()?.client;
if (!client) return;
const result = await (status.bookmarked
? client.v1.statuses.$select(status.id).unbookmark()
: client.v1.statuses.$select(status.id).bookmark());
props.onChangeToot(result.id, result);
};
const toggleBoost = async (status: mastodon.v1.Status) => {
const client = session()?.client;
if (!client) return;
vibrate(50);
const rootStatus = status.reblog ? status.reblog : status;
const reblogged = rootStatus.reblogged;
if (status.reblog) {
props.onChangeToot(status.id, {
...status,
reblog: { ...status.reblog, reblogged: !reblogged },
});
} else {
props.onChangeToot(status.id, {
...status,
reblogged: !reblogged,
});
}
const result = reblogged
? await client.v1.statuses.$select(status.id).unreblog()
: (await client.v1.statuses.$select(status.id).reblog()).reblog!;
if (status.reblog) {
props.onChangeToot(status.id, {
...status,
reblog: result,
});
} else {
props.onChangeToot(status.id, result);
}
};
const toggleFavourite = async (status: mastodon.v1.Status) => {
const client = session()?.client;
if (!client) return;
const ovalue = status.favourited;
props.onChangeToot(status.id, { ...status, favourited: !ovalue });
const result = ovalue
? await client.v1.statuses.$select(status.id).unfavourite()
: await client.v1.statuses.$select(status.id).favourite();
props.onChangeToot(status.id, result);
};
const openFullScreenToot = (
toot: mastodon.v1.Status,
srcElement: HTMLElement,
reply?: boolean,
) => {
const p = session()?.account;
if (!p) return;
const inf = p.inf;
if (!inf) {
console.warn("no account info?");
return;
}
const acct = `${inf.username}@${p.site}`;
setTootBottomSheetCache(acct, toot);
push(`/${encodeURIComponent(acct)}/toot/${toot.id}`, {
animateOpen(element) {
const rect0 = srcElement.getBoundingClientRect(); // the start rect
const rect1 = element.getBoundingClientRect(); // the end rect
const duration = durationOf(rect0, rect1);
const keyframes = {
top: [`${rect0.top}px`, `${rect1.top}px`],
bottom: [`${rect0.bottom}px`, `${rect1.bottom}px`],
left: [`${rect0.left}px`, `${rect1.left}px`],
right: [`${rect0.right}px`, `${rect1.right}px`],
height: [`${rect0.height}px`, `${rect1.height}px`],
margin: 0,
};
srcElement.style.visibility = "hidden";
const animation = element.animate(keyframes, {
duration,
easing: ANIM_CURVE_STD,
});
return animation;
},
animateClose(element) {
const rect0 = element.getBoundingClientRect(); // the start rect
const rect1 = srcElement.getBoundingClientRect(); // the end rect
const duration = durationOf(rect0, rect1);
const keyframes = {
top: [`${rect0.top}px`, `${rect1.top}px`],
bottom: [`${rect0.bottom}px`, `${rect1.bottom}px`],
left: [`${rect0.left}px`, `${rect1.left}px`],
right: [`${rect0.right}px`, `${rect1.right}px`],
height: [`${rect0.height}px`, `${rect1.height}px`],
margin: 0,
};
srcElement.style.visibility = "";
const animation = element.animate(keyframes, {
duration,
easing: ANIM_CURVE_STD,
});
return animation;
},
});
};
const onItemClick = (
status: mastodon.v1.Status,
event: MouseEvent & { target: EventTarget; currentTarget: HTMLElement },
) => {
if (!(event.target instanceof HTMLElement)) {
throw new Error("target is not an element");
}
const actionableElement = findElementActionable(
event.target,
event.currentTarget,
);
if (actionableElement && checkIsExpended(status)) {
if (actionableElement.dataset.action === "acct") {
event.stopPropagation();
const target = actionableElement as HTMLAnchorElement;
const acct = encodeURIComponent(
target.dataset.client || `@${new URL(target.href).origin}`,
);
push(`/${acct}/profile/${target.dataset.acctId}`);
return;
} else {
console.warn("unknown action", actionableElement.dataset.rel);
}
} else if (
event.target.parentElement &&
event.target.parentElement.tagName === "A"
) {
return;
}
// else if (!actionableElement || !checkIsExpended(status) || <rel is not one of known action>)
if (status.id !== expandedThreadId()) {
setExpandedThreadId((x) => (x ? undefined : status.id));
} else {
openFullScreenToot(status, event.currentTarget as HTMLElement);
}
};
const checkIsExpendedId = createSelector(expandedThreadId);
const checkIsExpended = (status: mastodon.v1.Status) =>
checkIsExpendedId(status.id);
const reply = (
status: mastodon.v1.Status,
event: { currentTarget: HTMLElement },
) => {
const element = findRootToot(event.currentTarget);
openFullScreenToot(status, element, true);
};
return (
<ErrorBoundary
fallback={(err, reset) => {
console.error(err);
return <p>Oops: {String(err)}</p>;
}}
>
<div ref={props.ref} id={props.id} class="toot-list">
<Index each={props.threads}>
{(threadId, threadIdx) => {
const thread = createMemo(() =>
props.onUnknownThread(threadId())?.reverse(),
);
const threadLength = () => thread()?.length ?? 0;
return (
<Index each={thread()}>
{(threadNode, index) => {
const status = () => threadNode().value;
return (
<RegularToot
data-status-id={status().id}
data-thread={threadIdx}
data-thread-len={threadLength()}
data-thread-sort={index}
status={status()}
thread={
threadLength() > 1
? positionTootInThread(index, threadLength())
: undefined
}
class={cardStyle.card}
evaluated={checkIsExpended(status())}
actionable={checkIsExpended(status())}
onBookmark={onBookmark}
onRetoot={toggleBoost}
onFavourite={toggleFavourite}
onReply={reply}
onClick={[onItemClick, status()]}
/>
);
}}
</Index>
);
}}
</Index>
</div>
</ErrorBoundary>
);
};
export default TootList;

View file

@ -0,0 +1,102 @@
import type { mastodon } from "masto";
import { Show, createResource, createSignal, type Component, type Ref } from "solid-js";
import CompactToot from "./CompactToot";
import { useTimeSource } from "../platform/timesrc";
import RegularToot from "./RegularToot";
import cardStyle from "../material/cards.module.css";
import { css } from "solid-styled";
type TootThreadProps = {
ref?: Ref<HTMLElement>,
status: mastodon.v1.Status;
client: mastodon.rest.Client;
expanded?: 0 | 1 | 2;
onBoost?(client: mastodon.rest.Client, status: mastodon.v1.Status): void;
onBookmark?(client: mastodon.rest.Client, status: mastodon.v1.Status): void;
onReply?(client: mastodon.rest.Client, status: mastodon.v1.Status): void
onExpandChange?(level: 0 | 1 | 2): void;
};
const TootThread: Component<TootThreadProps> = (props) => {
const status = () => props.status;
const now = useTimeSource();
const expanded = () => props.expanded ?? 0;
const [inReplyTo] = createResource(
() => [props.client, status().inReplyToId || null] as const,
async ([client, replyToId]) => {
if (!(client && replyToId)) return null;
return await client.v1.statuses.$select(replyToId).fetch();
},
);
const boost = (status: mastodon.v1.Status) => {
props.onBoost?.(props.client, status);
};
const bookmark = (status: mastodon.v1.Status) => {
props.onBookmark?.(props.client, status);
};
const reply = (status: mastodon.v1.Status) => {
props.onReply?.(props.client, status)
}
css`
article {
transition:
margin 90ms var(--tutu-anim-curve-sharp),
var(--tutu-transition-shadow);
user-select: none;
cursor: pointer;
}
.thread-line {
position: relative;
&::before {
content: "";
position: absolute;
left: 36px;
top: 16px;
bottom: 0;
background-color: var(--tutu-color-secondary);
width: 2px;
display: block;
}
}
.expanded {
margin-block: 20px;
box-shadow: var(--tutu-shadow-e9);
}
`;
const nextExpandLevel = [1, 2, 2] as const;
return (
<article
ref={props.ref}
classList={{ "thread-line": !!inReplyTo(), expanded: expanded() > 0 }}
onClick={() => props.onExpandChange?.(nextExpandLevel[expanded()])}
>
<Show when={inReplyTo()}>
<CompactToot
status={inReplyTo()!}
now={now()}
class={[cardStyle.card, cardStyle.manualMargin].join(" ")}
/>
</Show>
<RegularToot
status={status()}
class={cardStyle.card}
actionable={expanded() > 0}
onBookmark={(s) => bookmark(s)}
onRetoot={(s) => boost(s)}
onReply={s => reply(s)}
/>
</article>
);
};
export default TootThread;

View file

@ -1,24 +1,100 @@
import { import {
Component, Component,
For,
onCleanup,
createSignal, createSignal,
untrack,
Match, Match,
Switch as JsSwitch, Switch as JsSwitch,
ErrorBoundary, ErrorBoundary,
createSelector,
} from "solid-js"; } from "solid-js";
import { type mastodon } from "masto"; import { type mastodon } from "masto";
import { Button } from "@suid/material"; import { Button } from "@suid/material";
import { createTimelineSnapshot } from "../masto/timelines.js"; import { createTimelineSnapshot } from "../masto/timelines.js";
import { vibrate } from "../platform/hardware.js";
import PullDownToRefresh from "./PullDownToRefresh.jsx"; import PullDownToRefresh from "./PullDownToRefresh.jsx";
import TootList from "./TootList.jsx"; import Thread from "./Thread.jsx";
const TrendTimelinePanel: Component<{ const TrendTimelinePanel: Component<{
client: mastodon.rest.Client; client: mastodon.rest.Client;
prefetch?: boolean;
openFullScreenToot: (
toot: mastodon.v1.Status,
srcElement?: HTMLElement,
reply?: boolean,
) => void;
}> = (props) => { }> = (props) => {
const [scrollLinked, setScrollLinked] = createSignal<HTMLElement>(); const [scrollLinked, setScrollLinked] = createSignal<HTMLElement>();
const [tl, snapshot, { refetch: refetchTimeline }] = createTimelineSnapshot( const [
timeline,
snapshot,
{ refetch: refetchTimeline, mutate: mutateTimeline },
] = createTimelineSnapshot(
() => props.client.v1.trends.statuses, () => props.client.v1.trends.statuses,
() => ({ limit: 120 }), () => 120,
); );
const [expandedId, setExpandedId] = createSignal<string>();
const tlEndObserver = new IntersectionObserver(() => {
if (untrack(() => props.prefetch) && !snapshot.loading)
refetchTimeline();
});
onCleanup(() => tlEndObserver.disconnect());
const isExpandedId = createSelector(expandedId);
const isExpanded = (st: mastodon.v1.Status) => isExpandedId(st.id);
const onBookmark = async (
index: number,
client: mastodon.rest.Client,
status: mastodon.v1.Status,
) => {
const result = await (status.bookmarked
? client.v1.statuses.$select(status.id).unbookmark()
: client.v1.statuses.$select(status.id).bookmark());
mutateTimeline((o) => {
o![index] = [result];
return o;
});
};
const onBoost = async (
index: number,
client: mastodon.rest.Client,
status: mastodon.v1.Status,
) => {
const reblogged = status.reblog
? status.reblog.reblogged
: status.reblogged;
vibrate(50);
mutateTimeline(index, (th) => {
const x = th[0];
if (x.reblog) {
x.reblog = { ...x.reblog, reblogged: !reblogged };
return [Object.assign({}, x)];
} else {
return [
Object.assign({}, x, {
reblogged: !reblogged,
}),
];
}
});
const result = reblogged
? await client.v1.statuses.$select(status.id).unreblog()
: (await client.v1.statuses.$select(status.id).reblog()).reblog!;
mutateTimeline(index, (th) => {
Object.assign(th[0].reblog ?? th[0], {
reblogged: result.reblogged,
reblogsCount: result.reblogsCount,
});
return th;
});
};
return ( return (
<ErrorBoundary <ErrorBoundary
@ -29,7 +105,7 @@ const TrendTimelinePanel: Component<{
<PullDownToRefresh <PullDownToRefresh
linkedElement={scrollLinked()} linkedElement={scrollLinked()}
loading={snapshot.loading} loading={snapshot.loading}
onRefresh={() => refetchTimeline("next")} onRefresh={() => refetchTimeline({ direction: "new" })}
/> />
<div <div
ref={(e) => ref={(e) =>
@ -38,12 +114,34 @@ const TrendTimelinePanel: Component<{
}, 0) }, 0)
} }
> >
<TootList <For each={timeline}>
threads={tl.list} {(item, index) => {
onUnknownThread={tl.getPath} let element: HTMLElement | undefined;
onChangeToot={tl.set} return (
/> <Thread
ref={element}
toots={item}
onBoost={(...args) => onBoost(index(), ...args)}
onBookmark={(...args) => onBookmark(index(), ...args)}
onReply={(client, status) =>
props.openFullScreenToot(status, element, true)
}
client={props.client}
isExpended={isExpanded}
onItemClick={(x) => {
if (x.id !== expandedId()) {
setExpandedId((o) => (o ? undefined : x.id));
} else {
props.openFullScreenToot(x, element);
}
}}
/>
);
}}
</For>
</div> </div>
<div ref={(e) => tlEndObserver.observe(e)}></div>
<div <div
style={{ style={{
display: "flex", display: "flex",
@ -51,7 +149,7 @@ const TrendTimelinePanel: Component<{
"align-items": "center", "align-items": "center",
"justify-content": "center", "justify-content": "center",
"flex-flow": "column", "flex-flow": "column",
gap: "20px", gap: "20px"
}} }}
> >
<JsSwitch> <JsSwitch>
@ -64,6 +162,16 @@ const TrendTimelinePanel: Component<{
> >
Retry Retry
</Button> </Button>
</Match>
<Match when={true}>
<Button
variant="contained"
onClick={[refetchTimeline, undefined]}
disabled={snapshot.loading}
>
Refresh
</Button>
</Match> </Match>
</JsSwitch> </JsSwitch>
</div> </div>

View file

@ -4,15 +4,12 @@
--toot-avatar-size: 40px; --toot-avatar-size: 40px;
margin-block: 0; margin-block: 0;
position: relative; position: relative;
contain: content;
cursor: pointer;
&.toot { &.toot {
/* fix composition ordering: I think the css module processor should aware the overriding and behaves, but no */ /* fix composition ordering: I think the css module processor should aware the overriding and behaves, but no */
transition: transition:
margin-top 60ms var(--tutu-anim-curve-sharp), margin-block 125ms var(--tutu-anim-curve-std),
margin-bottom 60ms var(--tutu-anim-curve-sharp), height 225ms var(--tutu-anim-curve-std),
height 60ms var(--tutu-anim-curve-sharp),
var(--tutu-transition-shadow); var(--tutu-transition-shadow);
border-radius: 0; border-radius: 0;
} }
@ -41,7 +38,6 @@
align-items: flex-start; align-items: flex-start;
gap: 8px; gap: 8px;
margin-bottom: 8px; margin-bottom: 8px;
contain: layout style;
> :not(:first-child) { > :not(:first-child) {
flex-grow: 1; flex-grow: 1;
@ -51,7 +47,10 @@
.tootAuthorNameGrp { .tootAuthorNameGrp {
display: grid; display: grid;
grid-template-columns: 1fr auto; grid-template-columns: 1fr auto;
color: var(--tutu-color-secondary-text-on-surface);
>* {
color: var(--tutu-color-secondary-text-on-surface);
}
> :last-child { > :last-child {
grid-column: 1 /3; grid-column: 1 /3;
@ -69,14 +68,7 @@
} }
.tootAuthorNamePrimary { .tootAuthorNamePrimary {
color: var(--tutu-color-on-surface); color: revert;
> :global(.acct-mark) {
font-size: 1.2em;
color: var(--tutu-color-secondary-text-on-surface);
vertical-align: sub;
margin-right: 0.25em;
}
} }
.tootAvatar { .tootAvatar {
@ -89,6 +81,97 @@
background-color: var(--tutu-color-surface-d); background-color: var(--tutu-color-surface-d);
} }
.tootContent {
composes: cardNoPad from "../material/cards.module.css";
margin-left: calc(var(--card-pad, 0) + var(--toot-avatar-size, 0) + 8px);
margin-right: var(--card-pad, 0);
line-height: 1.5;
& a {
color: var(--tutu-color-primary-d);
}
& :global(a[target="_blank"]) {
> :global(.invisible) {
display: none;
}
> :global(.ellipsis) {
&::after {
display: inline;
content: "...";
}
}
}
}
.previewCard {
composes: cardGutSkip from "../material/cards.module.css";
display: block;
border: 1px solid #eeeeee;
background-color: var(--tutu-color-surface);
text-decoration: none;
border-radius: 4px;
overflow: hidden;
margin-top: 1em;
margin-bottom: 1.5em;
color: var(--tutu-color-secondary-text-on-surface);
transition: color 220ms var(--tutu-anim-curve-std), background-color 220ms var(--tutu-anim-curve-std);
padding-bottom: 8px;
overflow: hidden;
z-index: 1;
position: relative;
>img {
max-width: 100%;
}
&: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 { .toot.compact {
display: grid; display: grid;
grid-template-columns: auto 1fr; grid-template-columns: auto 1fr;
@ -124,19 +207,48 @@
} }
} }
.tootRetootGrp { .compactTootContent {
display: flex; composes: tootContent;
gap: 0.25em; margin-left: 0;
margin-bottom: 8px; margin-right: 0;
align-items: center; }
> :first-child { .tootRetootGrp {
margin-right: 0.25em; display: grid;
grid-template-columns: auto 1fr auto;
gap: 8px;
margin-bottom: 8px;
}
.tootAttachmentGrp {
composes: cardNoPad from "../material/cards.module.css";
margin-top: 1em;
margin-left: calc(var(--card-pad, 0) + var(--toot-avatar-size, 0) + 8px);
margin-right: var(--card-pad, 0);
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);
border-radius: 2px;
border: 1px solid transparent;
transition: border-color 220ms var(--tutu-anim-curve-std),
box-shadow 220ms var(--tutu-anim-curve-std);
&:hover,
&:focus-visible {
border: 1px solid var(--tutu-color-surface-dd);
box-shadow: var(--tutu-shadow-e1);
}
} }
} }
.tootBottomActionGrp { .tootBottomActionGrp {
composes: cardGutSkip from "~material/cards.module.css"; composes: cardGutSkip from "../material/cards.module.css";
padding-block: calc((var(--card-gut) - 10px) / 2); padding-block: calc((var(--card-gut) - 10px) / 2);
animation: 225ms var(--tutu-anim-curve-std) tootBottomExpanding; animation: 225ms var(--tutu-anim-curve-std) tootBottomExpanding;

View file

@ -1,13 +0,0 @@
.BoostIcon {
display: inline-flex;
border-radius: 2px;
background-color: green;
padding: 0.125em;
align-items: center;
> svg {
color: white;
font-size: 1em;
vertical-align: middle;
}
}

View file

@ -1,22 +0,0 @@
import {
splitProps,
type Component,
type JSX,
} from "solid-js";
import {
Repeat,
} from "@suid/icons-material";
import "./BoostIcon.css";
const BoostIcon: Component<JSX.HTMLElementTags["i"]> = (props) => {
const [managed, rest] = splitProps(props, ["class"]);
return (
<i class={["BoostIcon", managed.class].join(" ")} {...rest}>
<Repeat />
</i>
);
};
export default BoostIcon;

View file

@ -1,48 +0,0 @@
.MediaAttachmentGrid {
/* Note: MeidaAttachmentGrid has hard-coded layout calcalation */
margin-top: 1em;
margin-left: var(--card-pad, 0);
margin-right: var(--card-pad, 0);
contain: layout style;
gap: 4px;
> * {
max-height: 35vh;
min-height: 40px;
min-width: 40px;
max-width: 100%;
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);
&: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

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

View file

@ -1,71 +0,0 @@
.PreviewCard {
display: block;
border: 1px solid #eeeeee;
background-color: var(--tutu-color-surface-d);
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;
contain: layout style;
>img {
background-color: var(--tutu-color-surface);
max-width: 100%;
height: auto;
&.loaded {
background-color: #eeeeee;
}
}
&:hover,
&:focus-visible {
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;
}
}
}

View file

@ -1,107 +0,0 @@
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 "./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;
}) {
let root: HTMLAnchorElement;
createEffect(() => {
if (props.alwaysCompact) {
root.classList.add("compact");
return;
}
if (!props.src.width) return;
const width = root.getBoundingClientRect().width;
if (width > props.src.width) {
root.classList.add("compact");
} else {
root.classList.remove("compact");
}
});
const imgAverageColor = createMemo(() => {
if (!props.src.image) return;
return new Color(averageColorHex(props.src.blurhash));
});
const prefersWhiteText = createMemo(() => {
const oc = imgAverageColor();
if (!oc) return;
const colorWhite = new Color("white");
return colorWhite.luminance / oc.luminance > 3.5;
});
const focusSurfaceColor = createMemo(() => {
const oc = imgAverageColor();
if (!oc) return;
if (prefersWhiteText()) {
return new Color(oc).darken(0.2);
} else {
return new Color(oc).lighten(0.2);
}
});
const textColorName = createMemo(() => {
const useWhiteText = prefersWhiteText();
if (typeof useWhiteText === "undefined") {
return;
}
return useWhiteText ? "white" : "black";
});
const secondaryTextColor = createMemo(() => {
const tcn = textColorName();
if (!tcn) return;
const tc = new Color(tcn);
tc.alpha = 0.75;
return tc;
});
return (
<a
ref={root!}
class={"PreviewCard"}
href={props.src.url}
target="_blank"
referrerPolicy="unsafe-url"
style={{
"--tutu-color-surface": imgAverageColor()?.toString(),
"--tutu-color-surface-d": focusSurfaceColor()?.toString(),
"--tutu-color-on-surface": textColorName(),
"--tutu-color-secondary-text-on-surface":
secondaryTextColor()?.toString(),
}}
>
<Show when={props.src.image}>
<img
onLoadStart={onResetImg}
onLoad={onImgLoaded}
crossOrigin="anonymous"
src={props.src.image!}
width={props.src.width || undefined}
height={props.src.height || undefined}
loading="lazy"
/>
</Show>
<Title component="h1">{props.src.title}</Title>
<Body1 component="p">{props.src.description}</Body1>
</a>
);
}
export default PreviewCard;

View file

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

View file

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

View file

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

View file

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

View file

@ -1,23 +1,18 @@
import { import { createRenderEffect, createSignal, onCleanup } from "solid-js";
createRenderEffect,
onCleanup,
type Accessor,
} from "solid-js";
export function useDocumentTitle(newTitle?: string | Accessor<string>) { export function useDocumentTitle(newTitle?: string) {
const capturedTitle = document.title; const capturedTitle = document.title;
const [title, setTitle] = createSignal(newTitle ?? capturedTitle);
createRenderEffect(() => { createRenderEffect(() => {
if (newTitle) document.title = title();
document.title = typeof newTitle === "string" ? newTitle : newTitle();
}); });
onCleanup(() => { onCleanup(() => {
document.title = capturedTitle; document.title = capturedTitle;
}); });
return (x: ((x: string) => string) | string) => return setTitle;
(document.title = typeof x === "string" ? x : x(document.title));
} }
export function mergeClass(c1: string | undefined, c2: string | undefined) { export function mergeClass(c1: string | undefined, c2: string | undefined) {

View file

@ -0,0 +1,100 @@
-----BEGIN CERTIFICATE-----
MIIGaDCCBVCgAwIBAgIMcgWlFk4ihQWQO96fMA0GCSqGSIb3DQEBCwUAMFUxCzAJ
BgNVBAYTAkJFMRkwFwYDVQQKExBHbG9iYWxTaWduIG52LXNhMSswKQYDVQQDEyJH
bG9iYWxTaWduIEdDQyBSNiBBbHBoYVNTTCBDQSAyMDIzMB4XDTI0MDQxNzE5MTkz
OVoXDTI1MDUxOTE5MTkzOFowHTEbMBkGA1UEAwwSKi5sb2NhbGhvc3QuZGlyZWN0
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAl7j/nKHNPbO+9oCQyKOV
sbSe3lJLSUup2Tr/nCBgPUdBJE4ZrrhgLYz49qU9d/tXQG2thywa3bcVMq6Vv7Wl
pzPEJzsGNgAp1e8Z++aN8VoUb46BlsvAOAUEOYcfk3SVfM85orEhBVYswUfunptM
LW75zAO+kLgbgzpAVk6vtgWWEXNMVVdA6hOitNWKbR6s5Qh8wGJ+YmhYMfn+lcxX
e9e8gmPFZ6EegGSu1ZFP9KlSq8X6udSYSOZPccjdLcjbznx4opbRfgfT09O5IZw2
SSoHvRotDxY/BiPaubmQnhz/xrMoXyJm6TDibYnfPvVQD5946+euP3gS4IfA6C5O
VwIDAQABo4IDbjCCA2owDgYDVR0PAQH/BAQDAgWgMAwGA1UdEwEB/wQCMAAwgZkG
CCsGAQUFBwEBBIGMMIGJMEkGCCsGAQUFBzAChj1odHRwOi8vc2VjdXJlLmdsb2Jh
bHNpZ24uY29tL2NhY2VydC9nc2djY3I2YWxwaGFzc2xjYTIwMjMuY3J0MDwGCCsG
AQUFBzABhjBodHRwOi8vb2NzcC5nbG9iYWxzaWduLmNvbS9nc2djY3I2YWxwaGFz
c2xjYTIwMjMwVwYDVR0gBFAwTjAIBgZngQwBAgEwQgYKKwYBBAGgMgoBAzA0MDIG
CCsGAQUFBwIBFiZodHRwczovL3d3dy5nbG9iYWxzaWduLmNvbS9yZXBvc2l0b3J5
LzBEBgNVHR8EPTA7MDmgN6A1hjNodHRwOi8vY3JsLmdsb2JhbHNpZ24uY29tL2dz
Z2NjcjZhbHBoYXNzbGNhMjAyMy5jcmwwLwYDVR0RBCgwJoISKi5sb2NhbGhvc3Qu
ZGlyZWN0ghBsb2NhbGhvc3QuZGlyZWN0MB0GA1UdJQQWMBQGCCsGAQUFBwMBBggr
BgEFBQcDAjAfBgNVHSMEGDAWgBS9BbfzipM8c8t5+g+FEqF3lhiRdDAdBgNVHQ4E
FgQUKI953RG67hpYzTKIgOFfjOhCFHUwggF9BgorBgEEAdZ5AgQCBIIBbQSCAWkB
ZwB2AKLjCuRF772tm3447Udnd1PXgluElNcrXhssxLlQpEfnAAABju2AJRgAAAQD
AEcwRQIhAM6LOxsJZpalFLVL5gxcPVg/esjcs77aMy55RbEsugIcAiA+eXsLDS0l
L2LAVio6ccRujBXv4AVX8+UMEjxTCJOK8gB1ABNK3xq1mEIJeAxv70x6kaQWtyNJ
zlhXat+u2qfCq+AiAAABju2AJUoAAAQDAEYwRAIgafNBrzSDplrC/23Al8N62TGN
df6/I3sFbRdK1WjBrCsCIEXSOPPQrhEEoMZN8ZGRzkY7znL0zWuJsDA2IDj7+mUQ
AHYATnWjJ1yaEMM4W2zU3z9S6x3w4I4bjWnAsfpksWKaOd8AAAGO7YAmKAAABAMA
RzBFAiEA72BBCCM0QbJ1iN6jr9xBf51RNDjI6vV3me/v2m0CjvACICazjNaoB080
cqeQVF9ROzyHkaYUkb7vpeDd+EZeMhhWMA0GCSqGSIb3DQEBCwUAA4IBAQBBImJh
WM2CJEEALTrfPO4qiTig0jr9GoIhW0Vy31qiIfOchv8yNBTCc01Zd4LKqnpNId7K
a3TmMEyt/kf5PUSVkoVhBlk2wOdbtNvzxmc1VgUteBcng99GQNs4TJ6kOTuz9T0P
ycvgB48A7cjLtQ/bQSYWvJkn46VgYAIofBUrX7Bc4gLCs/XobADO5iLm9vvmvhlM
TigYA6vG4jgSOHnNOyAgus3FVupFA7Xsyo3lxo8BKD2/DkeJykc505i+s3xF6Tn0
sv7t7GQAukAu/AUiPIvRYYXzFBebx14/nuCjwRvhYt5O/At2dzt+ctNmyfpD/NAa
1cuNyikOi8Y/8hUQ
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIFjDCCA3SgAwIBAgIQfx8skC6D0OO2+zvuR4tegDANBgkqhkiG9w0BAQsFADBM
MSAwHgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSNjETMBEGA1UEChMKR2xv
YmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjAeFw0yMzA3MTkwMzQzMjVaFw0y
NjA3MTkwMDAwMDBaMFUxCzAJBgNVBAYTAkJFMRkwFwYDVQQKExBHbG9iYWxTaWdu
IG52LXNhMSswKQYDVQQDEyJHbG9iYWxTaWduIEdDQyBSNiBBbHBoYVNTTCBDQSAy
MDIzMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA00Jvk5ADppO0rgDn
j1M14XIb032Aas409JJFAb8cUjipFOth7ySLdaWLe3s63oSs5x3eWwzTpX4BFkzZ
bxT1eoJSHfT2M0wZ5QOPcCIjsr+YB8TAvV2yJSyq+emRrN/FtgCSTaWXSJ5jipW8
SJ/VAuXPMzuAP2yYpuPcjjQ5GyrssDXgu+FhtYxqyFP7BSvx9jQhh5QV5zhLycua
n8n+J0Uw09WRQK6JGQ5HzDZQinkNel+fZZNRG1gE9Qeh+tHBplrkalB1g85qJkPO
J7SoEvKsmDkajggk/sSq7NPyzFaa/VBGZiRRG+FkxCBniGD5618PQ4trcwHyMojS
FObOHQIDAQABo4IBXzCCAVswDgYDVR0PAQH/BAQDAgGGMB0GA1UdJQQWMBQGCCsG
AQUFBwMBBggrBgEFBQcDAjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBS9
BbfzipM8c8t5+g+FEqF3lhiRdDAfBgNVHSMEGDAWgBSubAWjkxPioufi1xzWx/B/
yGdToDB7BggrBgEFBQcBAQRvMG0wLgYIKwYBBQUHMAGGImh0dHA6Ly9vY3NwMi5n
bG9iYWxzaWduLmNvbS9yb290cjYwOwYIKwYBBQUHMAKGL2h0dHA6Ly9zZWN1cmUu
Z2xvYmFsc2lnbi5jb20vY2FjZXJ0L3Jvb3QtcjYuY3J0MDYGA1UdHwQvMC0wK6Ap
oCeGJWh0dHA6Ly9jcmwuZ2xvYmFsc2lnbi5jb20vcm9vdC1yNi5jcmwwIQYDVR0g
BBowGDAIBgZngQwBAgEwDAYKKwYBBAGgMgoBAzANBgkqhkiG9w0BAQsFAAOCAgEA
fMkkMo5g4mn1ft4d4xR2kHzYpDukhC1XYPwfSZN3A9nEBadjdKZMH7iuS1vF8uSc
g26/30DRPen2fFRsr662ECyUCR4OfeiiGNdoQvcesM9Xpew3HLQP4qHg+s774hNL
vGRD4aKSKwFqLMrcqCw6tEAfX99tFWsD4jzbC6k8tjSLzEl0fTUlfkJaWpvLVkpg
9et8tD8d51bymCg5J6J6wcXpmsSGnksBobac1+nXmgB7jQC9edU8Z41FFo87BV3k
CtrWWsdkQavObMsXUPl/AO8y/jOuAWz0wyvPnKom+o6W4vKDY6/6XPypNdebOJ6m
jyaILp0quoQvhjx87BzENh5s57AIOyIGpS0sDEChVDPzLEfRsH2FJ8/W5woF0nvs
BTqfYSCqblQbHeDDtCj7Mlf8JfqaMuqcbE4rMSyfeHyCdZQwnc/r9ujnth691AJh
xyYeCM04metJIe7cB6d4dFm+Pd5ervY4x32r0uQ1Q0spy1VjNqUJjussYuXNyMmF
HSuLQQ6PrePmH5lcSMQpYKzPoD/RiNVD/PK0O3vuO5vh3o7oKb1FfzoanDsFFTrw
0aLOdRW/tmLPWVNVlAb8ad+B80YJsL4HXYnQG8wYAFb8LhwSDyT9v+C1C1lcIHE7
nE0AAp9JSHxDYsma9pi4g0Phg3BgOm2euTRzw7R0SzU=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIFUTCCBDmgAwIBAgIQdR4/VknnTLv2nQAmtnyqjDANBgkqhkiG9w0BAQwFADBX
MQswCQYDVQQGEwJCRTEZMBcGA1UEChMQR2xvYmFsU2lnbiBudi1zYTEQMA4GA1UE
CxMHUm9vdCBDQTEbMBkGA1UEAxMSR2xvYmFsU2lnbiBSb290IENBMB4XDTE5MDYx
OTAwMDAwMFoXDTI4MDEyODEyMDAwMFowTDEgMB4GA1UECxMXR2xvYmFsU2lnbiBS
b290IENBIC0gUjYxEzARBgNVBAoTCkdsb2JhbFNpZ24xEzARBgNVBAMTCkdsb2Jh
bFNpZ24wggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCVB+hzymb57BTK
ezz3DQjxtEULLIK0SMbrWzyug7hBkjMUpG9/6SrMxrCIa8W2idHGsv8UzlEUIexK
3RtaxtaH7k06FQbtZGYLkoDKRN5zlE7zp4l/T3hjCMgSUG1CZi9NuXkoTVIaihqA
txmBDn7EirxkTCEcQ2jXPTyKxbJm1ZCatzEGxb7ibTIGph75ueuqo7i/voJjUNDw
GInf5A959eqiHyrScC5757yTu21T4kh8jBAHOP9msndhfuDqjDyqtKT285VKEgdt
/Yyyic/QoGF3yFh0sNQjOvddOsqi250J3l1ELZDxgc1Xkvp+vFAEYzTfa5MYvms2
sjnkrCQ2t/DvthwTV5O23rL44oW3c6K4NapF8uCdNqFvVIrxclZuLojFUUJEFZTu
o8U4lptOTloLR/MGNkl3MLxxN+Wm7CEIdfzmYRY/d9XZkZeECmzUAk10wBTt/Tn7
g/JeFKEEsAvp/u6P4W4LsgizYWYJarEGOmWWWcDwNf3J2iiNGhGHcIEKqJp1HZ46
hgUAntuA1iX53AWeJ1lMdjlb6vmlodiDD9H/3zAR+YXPM0j1ym1kFCx6WE/TSwhJ
xZVkGmMOeT31s4zKWK2cQkV5bg6HGVxUsWW2v4yb3BPpDW+4LtxnbsmLEbWEFIoA
GXCDeZGXkdQaJ783HjIH2BRjPChMrwIDAQABo4IBIjCCAR4wDgYDVR0PAQH/BAQD
AgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFK5sBaOTE+Ki5+LXHNbH8H/I
Z1OgMB8GA1UdIwQYMBaAFGB7ZhpFDZfKiVAvfQTNNKj//P1LMD0GCCsGAQUFBwEB
BDEwLzAtBggrBgEFBQcwAYYhaHR0cDovL29jc3AuZ2xvYmFsc2lnbi5jb20vcm9v
dHIxMDMGA1UdHwQsMCowKKAmoCSGImh0dHA6Ly9jcmwuZ2xvYmFsc2lnbi5jb20v
cm9vdC5jcmwwRwYDVR0gBEAwPjA8BgRVHSAAMDQwMgYIKwYBBQUHAgEWJmh0dHBz
Oi8vd3d3Lmdsb2JhbHNpZ24uY29tL3JlcG9zaXRvcnkvMA0GCSqGSIb3DQEBDAUA
A4IBAQDHrE3fEsZgYRw59I03e5wt03B45il4hAHmquLc33pbkGZn6r3GgoKVzvwC
aBgtl6Jp93gZD8G5UjAFLj840jWDhOP7KSX6Q7rGjOsWNFFDJJLDUKQeJpB1PTRu
HqVI15zxiCl/VCP7mbTW7X/pILaFOPO+T0Qj+TUOU37WOjk6wdeyyOFiDhKSwH2Y
VE4YlAo0R10Jo3uNnSCFBgPw7gy1xt1+ajCbnzZYpQNXFy/0Lp9h3JOClE7TGvli
FUazCjxvhHm5YWqulA51wFT2K9LRiiEWw3UJAgTTmxASitVHHLb3erkETk6SCwGv
OG1eD0qLwuSeARZmhw3xFOCvMHeQ
-----END CERTIFICATE-----

View file

@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCXuP+coc09s772
gJDIo5WxtJ7eUktJS6nZOv+cIGA9R0EkThmuuGAtjPj2pT13+1dAba2HLBrdtxUy
rpW/taWnM8QnOwY2ACnV7xn75o3xWhRvjoGWy8A4BQQ5hx+TdJV8zzmisSEFVizB
R+6em0wtbvnMA76QuBuDOkBWTq+2BZYRc0xVV0DqE6K01YptHqzlCHzAYn5iaFgx
+f6VzFd717yCY8VnoR6AZK7VkU/0qVKrxfq51JhI5k9xyN0tyNvOfHiiltF+B9PT
07khnDZJKge9Gi0PFj8GI9q5uZCeHP/GsyhfImbpMOJtid8+9VAPn3jr564/eBLg
h8DoLk5XAgMBAAECggEAEFqIcsGd9cCiHL/O21GGmRj25s/H/aaCMEADvThgJzq+
8sLYUdTdyQsg6rT04zHcPb2UrrU6UBuj1UqsKXXS3SrfQbtC+B8cY0raaiR3uEQV
X9DkdvSPS3p+8hR2etZeJo3PkJG3FXQsbsjqF351v4/urObaa47sqEBnHuZsWhol
jMEfztg5NWHz1r02UuUCPGy/uIn5MBQVcawn1LZuEepUc+El+fdwlFIC/TQj2Zuh
EZUjMTFzaPDKhoXUvNlIi9YqchRsyB72IDJf/XxAKIp1QXqMcIH4c4sgwB7rbLiD
G9xu7B68OTAhUdF0SJnuz3MhLlc0fqD0xRJoC8M5gQKBgQDBtHrPfqw61dDf3ghr
EaCLDknPtmgtcH1tED3e6YfxEaQOnP/O03eigpakMBGbxpcGsSnkAD8KkBbWE9Wp
FXo4xI4D4e4XZ0r7UciChXamxOWwwv5yUbkB+hie1WZSl5pVqc4ZS7p/1D4mDD8Y
fnbXInF+mOtSSNZJ9RL3hTKLuQKBgQDIhChy+3yibuQz4hHwrSEr7yKMYFAR9TSl
7AqjQ/Vw1uhb6dOD8nrcG9zaQhZsprKGpNuyw0Xy0U0skBfnv6nEMbKBNdP3YyQe
/T3xUu7Z2kdRnaF1Vn/+g6bECXJs3FRIioBbA9XFKfRibxuWjPPpXZk5w3YF73nx
SkpF1DlSjwKBgQCB0MNxZbJlJ8B5F6NKpiCSsLu00ckVksrsGbNtPdLWM31gMcWa
Rcxqg9wTIwfZ/whd+sNZQvT8zj4PsHFDhNpJSyjl3zciRh5ROakIGAvBjjlk8fl2
geBcO9DeOaP+fA15lXhDKaZOXt5bv19VugNJAJNRRYiHt7qtC+pvKbwLOQKBgCP+
NRSOuAygQy5dAkNlkHLGdjkkgLr4fP7bo/0ykbgzm3oEOweQWyVvivFSs5vFQH6S
0S0BiGjR0TySkPf0m5CwKw6ujuH1VeKKKrhK3r0URYEM/pKFeGxDTYga+gM4eZib
4/Zydcjygv+4WgdoPdBCEOMhhuoB1q3NXA+0zKVZAoGBAIHR7Udk8rzwuno5IGc8
AgdMhkDojqQaDJdJlAAmmX1IoqJOpyGdws+uUtQ/YnnNqQn2eTTlPxnl5ldtBYmR
iT8XWLi4jIsY0jh6fLCaZwcu4RB0Rrw1N6nsZQFr/PG61ZPa8RJBfAGRE3QWc3WN
L/Q1JdU0auJcvQn78yA/gLVp
-----END PRIVATE KEY-----

View file

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

View file

@ -1,159 +1,44 @@
import { defineConfig, loadEnv } from "vite"; import { defineConfig } from "vite";
import solid from "vite-plugin-solid"; import solid from "vite-plugin-solid";
import solidStyled from "vite-plugin-solid-styled"; import solidStyled from "vite-plugin-solid-styled";
import suid from "@suid/vite-plugin"; import suid from "@suid/vite-plugin";
import { VitePWA } from "vite-plugin-pwa"; import { VitePWA } from "vite-plugin-pwa";
import version from "vite-plugin-package-version"; 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";
/** export default defineConfig(({ mode }) => ({
* Put all strings (/i18n/{key}.<json|js|ts>) into separated chunks based on the key. plugins: [
*/ suid(),
const chunkStrs: GetManualChunk = (id, { getModuleInfo }) => { solid(),
const match = /.*\/i18n\/(.*)\.[jt]s.*$/.exec(id); solidStyled({
if (match) { filter: {
const key = match[1]; include: "src/**/*.{tsx,jsx}",
exclude: "node_modules/**/*.{ts,js,tsx,jsx}",
const dependentEntryPoints = [];
// we use a Set here so we handle each module at most once. This
// prevents infinite loops in case of circular dependencies
const idsToHandle = new Set(getModuleInfo(id)!.dynamicImporters);
for (const moduleId of idsToHandle) {
const { isEntry, dynamicImporters, importers } = getModuleInfo(moduleId)!;
if (isEntry || dynamicImporters.length > 0)
dependentEntryPoints.push(moduleId);
// The Set iterator is intelligent enough to iterate over
// elements that are added during iteration
for (const importerId of importers) idsToHandle.add(importerId);
}
// If there is a unique entry, we put it into a chunk based on the
// entry name
if (dependentEntryPoints.length === 1) {
return `${key}.${
dependentEntryPoints[0].split("/").slice(-1)[0].split(".")[0]
}.strings`;
}
// For multiple entries, we put it into a "shared" chunk
if (dependentEntryPoints.length > 1) {
return `${key}.shared.strings`;
}
}
};
const manualChunks: GetManualChunk = (id, meta) => {
return chunkStrs(id, meta);
};
export default defineConfig(({ mode }) => {
const devConf = loadEnv(mode, import.meta.dirname, "DEV");
const serverHttpCertBase = devConf["DEV_SERVER_HTTP_CERT_BASE"];
const serverHttpCertPassword = devConf["DEV_SERVER_HTTP_CERT_PASS"];
const serverHttpCertKey = serverHttpCertBase
? `${serverHttpCertBase}.key`
: undefined;
const serverHttpCertCrt = serverHttpCertBase
? `${serverHttpCertBase}.crt`
: undefined;
const isTestBuild = ["development", "staging"].includes(mode);
return {
plugins: [
devtools({
autoname: true,
locator: {
targetIDE:
(devConf["DEV_LOCATOR_EDITOR"] as
| "vscode"
| "atom"
| "webstorm"
| "vscode-insiders"
| "") || undefined,
componentLocation: true,
jsxLocation: true,
},
}),
suid(),
solid(),
solidStyled({
filter: {
include: "src/**/*.{tsx,jsx}",
exclude: "node_modules/**/*.{ts,js,tsx,jsx}",
},
}),
VitePWA({
strategies: "injectManifest",
registerType: "autoUpdate",
devOptions: {
enabled: true,
},
srcDir: "src/serviceworker",
filename: "main.ts",
manifest: manifest,
pwaAssets: {
config: true,
},
injectManifest: {
globPatterns: ["**/*.{js,wasm,css,html,svg,png,ico}"],
},
}),
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: { VitePWA({
https: serverHttpCertBase registerType: "autoUpdate",
? { devOptions: {
// This config controls https for the *dev server*. enabled: mode === "staging",
// See docs/dev-https.md for setting up https
key: serverHttpCertKey,
cert: serverHttpCertCrt,
passphrase: serverHttpCertPassword,
}
: undefined,
},
esbuild: {
pure: isTestBuild ? undefined : ["console.debug", "console.trace"],
drop: isTestBuild ? undefined : ["debugger"],
},
define: {
"import.meta.env.BUILT_AT": `"${new Date().toISOString()}"`,
},
css: {
devSourcemap: true,
},
build: {
target: ["firefox98", "safari15.4", "ios15.4", "chrome84", "edge87"],
sourcemap: true,
rollupOptions: {
output: {
manualChunks,
},
}, },
}),
version(),
],
server: {
https: {
// localhost.direct: https://github.com/Upinel/localhost.direct
key: "tools/certs/localhost.direct.key",
cert: "tools/certs/localhost.direct.crt",
passphrase: "localhost",
}, },
}; },
}); define: {
"import.meta.env.BUILT_AT": `"${new Date().toISOString()}"`,
},
css: {
devSourcemap: true,
},
build: {
target: ["firefox98", "safari15.4", "ios15.4", "chrome84", "edge87"],
sourcemap: true,
},
}));