Rewrite timelines #25

Closed
Rubicon wants to merge 0 commits from rewrite-timeline into master
147 changed files with 3356 additions and 8784 deletions

1
.browserlist Normal file
View file

@ -0,0 +1 @@
>0.3% and not dead, firefox>=98, safari>=15.4, chrome>=84

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_PLATFORM_MASONRY_ALWAYS_COMPAT=

View file

@ -1,34 +0,0 @@
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
checkpr:
runs-on: fedora-41
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version-file: 'package.json'
- name: Cache Dependencies
id: dependencies-cache
uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install Dependencies
run: bun install
- name: Validate types
run: bun typecheck
- name: Build Dist
run: VITE_CODE_VERSION=$GITHUB_SHA bun dist

View file

@ -8,7 +8,7 @@ on:
jobs:
depoly:
runs-on: fedora-41
runs-on: fedora-40
steps:
- name: Checkout
uses: actions/checkout@v4
@ -30,15 +30,12 @@ jobs:
- name: Install Dependencies
run: bun install
- name: Validate types
run: bun typecheck
- 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'
- name: Build Dist
run: VITE_CODE_VERSION=$GITHUB_SHA bun dist
run: bun dist
if: env.GITHUB_REF_NAME != 'master'
- 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
dist/
dev-dist/
.env.local
.env.*.local
dev-dist/

View file

@ -2,24 +2,16 @@
Tutu is a comfortable experience for tooting. Designed to work on any device - desktop, phone and tablet.
[Launch Tutu](https://tutu.lightstands.xyz)
## Compatibility
The code is built against those targets and Tutu must run on those platforms:
| Firefox | Safari | iOS | Chrome & Edge |
| ------- | ------ | ----- | ------------- |
| 115 | 15.6 | 15.6 | 108 |
| Firefox | Safari | iOS | Chrome | Edge |
| ------- | ------ | ----- | ------ | ---- |
| 98 | 15.4 | 15.4 | 84 | 87 |
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 "Nightly" Branch
Tutu built on the latest code, called the nightly version. You can tatse latest change but risks your data.
[Launch Tutu (Nightly)](https://master.tututheapp.pages.dev)
## Build & Depoly
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.
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).

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.
- You can use network debugging apps like "Shadowrocket" to do such thing.
## Hero Animation won't work (after hot reload)
That's a known issue. Hot reload won't refresh the module sets the hero cache. Refresh the whole page and it should work.
## The components don't react to the change as I setting the store, until the page reloaded
The `WritableAtom<unknwon>.set` might do an equals check. You must set a different object to ensure the atom sending a notify.
@ -37,111 +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.
## `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.
## Managing CSS
Two techniques are still:
- Styled compoenent (solid-styled)
- Native CSS with CSS layering
The second is recommended for massive use. A stylesheet for a component can be placed alongside
the component's file. The stylesheet must use the same name as the component's file name, but replace the extension with
`.css`. Say there is a component file "PreviewCard.tsx", the corresponding stylesheet is "PreviewCard.css". They are imported
by the component file, so the side effect will be applied by the bundler.
The speicifc component uses a root class to scope the rulesets' scope. This convention allows the component's style can be influenced
by the other stylesheets. It works because Tutu is an end-user application, we gain the control of all stylesheets in the app (kind of).
Keep in mind that the native stylesheets will be applied globally at any time, you must carefully craft the stylesheet to avoid leaking
of style.
Three additional CSS layers are declared as:
- compat: Compatibility rules, like normalize.css
- theme: The theme rules
- material: The internal material styles
When working on the material package, if the style is intended to work with the user styles,
it must be declared under the material layer. Otherwise the unlayer, which has the
highest priority in the author's, can be used.
Styled component is still existing. Though styled component, using attributes for scoping,
may not be as performant as the techniques with CSS class names;
it's still provided in the code infrastructure for its ease.
The following is an example of the recommended usage of solid-styled:
```tsx
// An example of using solid-styled
import { css } from "solid-styled";
import { createSignal } from "solid-js";
const Component = () => {
const [width, setWidth] = createSignal(100);
css`
.root {
width: ${width()}%;
}
`
return <div class="root"></div>
};
```
When developing new component, you can use styled component at first, and migrate
to native css slowly.
Before v2.0.0, there are CSS modules in use, but they are removed:
- Duplicated loads
- Unaware of order (failed composing)
- Not-ready for hot reload
In short, CSS module does not works well if the stylesheet will be accessed from more than one component.

View file

@ -1,41 +0,0 @@
# Optimizing Tutu
Topic Index:
- Time to first byte
- Time to first draw: [Load size](#load-size)
- CLS
- Framerate: [Algorithm](#algorithm), [CSS containment](#css-containment)
## 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. And, even it's available on targets, it's not always
available on user's platform, so fallback is always required.
## CSS containment
`contain` property is a powerful tool that hints user agents to utilise specific conditions can only be easily identified by developers. [This article from MDN can help you undetstand this property](https://developer.mozilla.org/en-US/docs/Web/Performance/How_browsers_work).
But it comes with cost. Modern browsers are already very smart on rendering. Using `contain`, you are trading onething off for another:
- `layout` affects the reflow. This property usually won't make large change: mainline browsers already can incrementally reflow.
- `style` affects the style computation, is automatically enabled when using `container` property. Usually won't make large change too, unless you frequently change the styles (and/or your stylesheet is large and/or with complex selectors), containing the computation may help performance.
- `paint` affects the shading, the pixel-filling process. This is useful - the shading is resource-heavy - but the browser may need more buffers and more time to compose the final frame.
- This containment may increase memory usage.
- `size` says the size is not affected by outside elements and is defined. It hints the user agent can use the pre-defined size and/or cache the computed size (with `auto` keyword).
- Must be used with `contain-intrinsic-size`.
- You can use `content-visibility: auto`, a stonger hint for browsers to skip elements if possible. You can see this like built-in "virtual list", which is used for rendering infinite size of dataset.

View file

@ -1,32 +0,0 @@
# Versioning & Development Cycle
The versioning policy follows the [Semantic Versioning](https://semver.org/).
Since Tutu is an app for the end user, we redefine the some words in the policy:
- API changes: the app is no longer available on certain platforms.
## Development Cycle
Dependency Freeze -> Development -> Release
### Dependency Freeze
This step is for:
- Update dependencies
- Prepare the new version (like, bump the version number).
New dependencies should not be added in this step.
### Development
In this step, dependencies can only be updated if it's required to fix bugs.
New dependencies should be added as their use, in this step.
### Release
The version is released to production in this step.
Before the next development step, new versions can still be released to
fix bugs.

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,77 +1,55 @@
{
"$schema": "https://json.schemastore.org/package",
"name": "tutu",
"version": "2.0.0",
"version": "1.0.8",
"description": "",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"preview": "vite preview",
"dist": "vite build",
"count-source-lines": "exec scripts/src-lc.sh",
"typecheck": "tsc --noEmit --skipLibCheck",
"wdio": "wdio run ./wdio.conf.ts"
"dist": "vite build"
},
"keywords": [],
"author": "Rubicon",
"license": "Apache-2.0",
"devDependencies": {
"@solid-devtools/overlay": "^0.33.0",
"@suid/vite-plugin": "^0.3.1",
"@testing-library/webdriverio": "^3.2.1",
"@types/hammerjs": "^2.0.46",
"@types/masonry-layout": "^4.2.8",
"@vite-pwa/assets-generator": "^0.2.6",
"@wdio/cli": "^9.5.1",
"@wdio/lighthouse-service": "^9.5.1",
"@wdio/local-runner": "^9.5.1",
"@wdio/mocha-framework": "^9.5.0",
"@wdio/spec-reporter": "^9.5.0",
"postcss": "^8.4.49",
"prettier": "^3.4.2",
"tsx": "^4.19.2",
"typescript": "^5.7.2",
"vite": "^6.0.7",
"@suid/vite-plugin": "^0.3.0",
"@types/hammerjs": "^2.0.45",
"postcss": "^8.4.45",
"prettier": "^3.3.3",
"typescript": "^5.6.2",
"vite": "^5.4.5",
"vite-plugin-package-version": "^1.1.0",
"vite-plugin-pwa": "^0.21.1",
"vite-plugin-solid": "^2.11.0",
"vite-plugin-pwa": "^0.20.5",
"vite-plugin-solid": "^2.10.2",
"vite-plugin-solid-styled": "^0.11.1",
"wdio-vite-service": "^2.0.0",
"wdio-wait-for": "^3.0.11",
"workbox-build": "^7.3.0",
"wrangler": "^3.99.0"
"wrangler": "^3.78.2"
},
"dependencies": {
"@formatjs/intl-localematcher": "^0.5.10",
"@formatjs/intl-localematcher": "^0.5.4",
"@nanostores/persistent": "^0.10.2",
"@nanostores/solid": "^0.5.0",
"@nanostores/solid": "^0.4.2",
"@solid-primitives/event-listener": "^2.3.3",
"@solid-primitives/i18n": "^2.1.1",
"@solid-primitives/intersection-observer": "^2.1.6",
"@solid-primitives/map": "^0.4.13",
"@solid-primitives/page-visibility": "^2.0.17",
"@solid-primitives/resize-observer": "^2.0.26",
"@solidjs/router": "^0.15.2",
"@suid/icons-material": "^0.8.1",
"@suid/material": "^0.18.0",
"@solidjs/router": "^0.14.5",
"@suid/icons-material": "^0.8.0",
"@suid/material": "^0.17.0",
"blurhash": "^2.0.5",
"colorjs.io": "^0.5.2",
"date-fns": "^4.1.0",
"date-fns": "^3.6.0",
"fast-average-color": "^9.4.0",
"hammerjs": "^2.0.8",
"iso-639-1": "^3.1.3",
"masonry-layout": "^4.2.2",
"masto": "^6.10.1",
"masto": "^6.8.0",
"nanostores": "^0.11.3",
"normalize.css": "^8.0.1",
"solid-devtools": "^0.33.0",
"solid-js": "^1.9.3",
"solid-js": "^1.8.22",
"solid-styled": "^0.11.1",
"solid-transition-group": "^0.2.3",
"stacktrace-js": "^2.0.2",
"workbox-core": "^7.3.0",
"workbox-precaching": "^7.3.0"
"web-animations-js": "^2.3.2"
},
"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,46 +1,12 @@
@layer compat, theme, material;
@import "normalize.css/normalize.css" layer(compat);
@import "./material/theme.css" layer(theme);
@import "./material/material.css" layer(material);
@layer compat {
:root {
--safe-area-inset-top: env(safe-area-inset-top);
--safe-area-inset-left: env(safe-area-inset-left);
--safe-area-inset-bottom: env(safe-area-inset-bottom);
--safe-area-inset-right: env(safe-area-inset-right);
background-color: var(--tutu-color-surface, transparent);
}
/*
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;
}
}
h1 {
margin: 0;
}
* {
user-select: none;
}
:root {
--safe-area-inset-top: env(safe-area-inset-top);
--safe-area-inset-left: env(safe-area-inset-left);
--safe-area-inset-bottom: env(safe-area-inset-bottom);
--safe-area-inset-right: env(safe-area-inset-right);
background-color: var(--tutu-color-surface, transparent);
overscroll-behavior-block: none;
}
.custom-emoji {
width: 1em;
}
}

View file

@ -1,38 +1,22 @@
import { Route } from "@solidjs/router";
import { Route, Router } from "@solidjs/router";
import { ThemeProvider } from "@suid/material";
import {
Component,
createEffect,
createMemo,
createRenderEffect,
createSignal,
ErrorBoundary,
lazy,
onCleanup,
} from "solid-js";
import { createRootTheme } from "./material/theme.js";
import { useRootTheme } from "./material/mui.js";
import {
Provider as ClientProvider,
createMastoClientFor,
} from "./masto/clients.js";
import { $accounts, updateAcctInf } from "./accounts/stores.js";
import { useStore } from "@nanostores/solid";
import {
AppLocaleProvider,
createCurrentLanguage,
createCurrentRegion,
createDateFnLocaleResource,
} 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";
import { DateFnScope, useLanguage } from "./platform/i18n.jsx";
const AccountSignIn = lazy(() => import("./accounts/SignIn.js"));
const AccountMastodonOAuth2Callback = lazy(
@ -43,21 +27,22 @@ const Settings = lazy(() => import("./settings/Settings.js"));
const TootBottomSheet = lazy(() => import("./timelines/TootBottomSheet.js"));
const MotionSettings = lazy(() => import("./settings/Motions.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 Profile = lazy(() => import("./profiles/Profile.js"));
const Routing: Component = () => {
return (
<StackedRouter>
<Route path="/" component={TimelineHome} />
<Route path="/settings/language" component={LanguageSettings} />
<Route path="/settings/region" component={RegionSettings} />
<Route path="/settings/motions" component={MotionSettings} />
<Route path="/settings" component={Settings} />
<Route path="/:acct/toot/:id" component={TootBottomSheet} />
<Route path="/:acct/profile/:id" component={Profile} />
<Router>
<Route path="/" component={TimelineHome}>
<Route path=""></Route>
<Route path="/settings" component={Settings}>
<Route path=""></Route>
<Route path="/language" component={LanguageSettings}></Route>
<Route path="/region" component={RegionSettings}></Route>
<Route path="/motions" component={MotionSettings}></Route>
</Route>
<Route path="/:acct/:id" component={TootBottomSheet}></Route>
</Route>
<Route path={"/accounts"}>
<Route path={"/sign-in"} component={AccountSignIn} />
<Route
@ -65,55 +50,14 @@ const Routing: Component = () => {
component={AccountMastodonOAuth2Callback}
/>
</Route>
</StackedRouter>
</Router>
);
};
const App: Component = () => {
const theme = createRootTheme();
const theme = useRootTheme();
const accts = useStore($accounts);
const lang = createCurrentLanguage();
const region = createCurrentRegion();
const dateFnLocale = createDateFnLocaleResource(region);
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 lang = useLanguage();
const clients = createMemo(() => {
return accts().map((x) => ({
@ -156,26 +100,12 @@ const App: Component = () => {
return <UnexpectedError error={err} />;
}}
>
<ThemeProvider theme={theme}>
<AppLocaleProvider
value={{
language: lang,
region: region,
dateFn: dateFnLocale,
}}
>
<ThemeProvider theme={theme()}>
<DateFnScope>
<ClientProvider value={clients}>
<ServiceWorkerProvider
value={{
needRefresh,
offlineReady,
serviceWorker,
}}
>
<Routing />
</ServiceWorkerProvider>
<Routing />
</ClientProvider>
</AppLocaleProvider>
</DateFnScope>
</ThemeProvider>
</ErrorBoundary>
);

View file

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

View file

@ -1,22 +0,0 @@
.MastodonOAuth2Callback {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 448px;
@media (max-width: 600px) {
& {
position: static;
height: 100%;
width: 100%;
left: 0;
right: 0;
transform: none;
display: grid;
grid-template-rows: 1fr auto;
height: 100vh;
overflow: auto;
}
}
}

View file

@ -1,4 +1,4 @@
import { useSearchParams } from "@solidjs/router";
import { useNavigate, useSearchParams } from "@solidjs/router";
import {
Component,
Show,
@ -8,13 +8,12 @@ import {
} from "solid-js";
import { acceptAccountViaAuthCode } from "./stores";
import { $settings } from "../settings/stores";
import "~material/cards.css";
import { useDocumentTitle } from "../utils";
import cards from "../material/cards.module.css";
import { LinearProgress } from "@suid/material";
import Img from "~material/Img";
import Img from "../material/Img";
import { createRestAPIClient } from "masto";
import { Title } from "~material/typography";
import { useNavigator } from "~platform/StackedRouter";
import DocumentTitle from "~platform/DocumentTitle";
import { Title } from "../material/typography";
type OAuth2CallbackParams = {
code?: string;
@ -26,13 +25,14 @@ const MastodonOAuth2Callback: Component = () => {
const progressId = createUniqueId();
const titleId = createUniqueId();
const [params] = useSearchParams<OAuth2CallbackParams>();
const { push: navigate } = useNavigator();
const navigate = useNavigate();
const setDocumentTitle = useDocumentTitle("Back from Mastodon...");
const [siteImg, setSiteImg] = createSignal<{
src: string;
srcset?: string;
blurhash: string;
}>();
const [siteTitle, setSiteTitle] = createSignal("Mastodon");
const [siteTitle, setSiteTitle] = createSignal("the Mastodon server");
onMount(async () => {
const onGoingOAuth2Process = $settings.get().onGoingOAuth2Process;
@ -41,6 +41,7 @@ const MastodonOAuth2Callback: Component = () => {
url: onGoingOAuth2Process,
});
const ins = await client.v2.instance.fetch();
setDocumentTitle(`Back from ${ins.title}...`);
setSiteTitle(ins.title);
const srcset = [];
@ -91,45 +92,42 @@ const MastodonOAuth2Callback: Component = () => {
});
});
return (
<>
<DocumentTitle>Back from {siteTitle()}</DocumentTitle>
<main class="MastodonOAuth2Callback">
<div class="card card-auto-margin" aria-busy="true" aria-describedby={progressId}>
<LinearProgress
class="card-no-pad card-gut-skip"
id={progressId}
aria-labelledby={titleId}
/>
<Show
when={siteImg()}
fallback={
<i
aria-busy="true"
aria-label="Preparing image..."
style={{ height: "235px", display: "block" }}
></i>
}
>
<Img
src={siteImg()?.src}
srcset={siteImg()?.srcset}
blurhash={siteImg()?.blurhash}
class="card-no-pad card-gut-skip"
alt={`Banner image for ${siteTitle()}`}
<div class={cards.layoutCentered}>
<div class={cards.card} aria-busy="true" aria-describedby={progressId}>
<LinearProgress
class={[cards.cardNoPad, cards.cardGutSkip].join(" ")}
id={progressId}
aria-labelledby={titleId}
/>
<Show
when={siteImg()}
fallback={
<i
aria-busy="true"
aria-label="Preparing image..."
style={{ height: "235px", display: "block" }}
/>
</Show>
></i>
}
>
<Img
src={siteImg()?.src}
srcset={siteImg()?.srcset}
blurhash={siteImg()?.blurhash}
class={[cards.cardNoPad, cards.cardGutSkip].join(" ")}
alt={`Banner image for ${siteTitle()}`}
style={{ height: "235px", display: "block" }}
/>
</Show>
<Title component="h6" id={titleId}>
Contracting {siteTitle}...
</Title>
<p>
If this page stays too long, you can close this page and sign in
again.
</p>
</div>
</main>
</>
<Title component="h6" id={titleId}>
Contracting {siteTitle}...
</Title>
<p>
If this page stays too long, you can close this page and sign in
again.
</p>
</div>
</div>
);
};

View file

@ -1,27 +0,0 @@
.SignIn {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 448px;
@media (max-width: 600px) {
& {
position: static;
height: 100vh;
width: 100%;
transform: none;
overflow: auto;
}
}
>.key-content {
height: 100%;
>form {
display: flex;
flex-flow: column;
gap: 16px;
}
}
}

View file

@ -2,21 +2,22 @@ import {
Component,
Show,
createEffect,
createSelector,
createSignal,
createUniqueId,
onMount,
} from "solid-js";
import "~material/cards.css";
import TextField from "~material/TextField.js";
import Button from "~material/Button.js";
import { Title } from "~material/typography";
import cards from "../material/cards.module.css";
import TextField from "../material/TextField.js";
import Button from "../material/Button.js";
import { useDocumentTitle } from "../utils";
import { Title } from "../material/typography";
import { css } from "solid-styled";
import { LinearProgress } from "@suid/material";
import { createRestAPIClient } from "masto";
import { getOrRegisterApp } from "./stores";
import { useSearchParams } from "@solidjs/router";
import { $settings } from "../settings/stores";
import "./SignIn.css";
import DocumentTitle from "~platform/DocumentTitle";
type ErrorParams = {
error: string;
@ -34,6 +35,15 @@ const SignIn: Component = () => {
const [serverUrlError, setServerUrlError] = createSignal(false);
const [targetSiteTitle, setTargetSiteTitle] = createSignal("");
useDocumentTitle("Sign In");
css`
form {
display: flex;
flex-flow: column;
gap: 16px;
}
`;
const serverUrl = () => {
const url = rawServerUrl();
if (url.length === 0 || /^%w:/.test(url)) {
@ -111,58 +121,55 @@ const SignIn: Component = () => {
};
return (
<>
<DocumentTitle>Sign In</DocumentTitle>
<main class="SignIn">
<Show when={params.error || params.errorDescription}>
<div class="card card-auto-margin" style={{ "margin-bottom": "20px" }}>
<p>Authorization is failed.</p>
<p>{params.errorDescription}</p>
<p>
Please try again later. If the problem persists, you can ask for
help from the server administrator.
</p>
</div>
</Show>
<div
class="card card-auto-margin key-content"
aria-busy={currentState() !== "inactive" ? "true" : "false"}
aria-describedby={
currentState() !== "inactive" ? progressId : undefined
}
style={{
padding: `var(--safe-area-inset-top) var(--safe-area-inset-right) var(--safe-area-inset-bottom) var(--safe-area-inset-left)`,
}}
>
<LinearProgress
class={"card-no-pad card-gut-skip"}
id={progressId}
sx={currentState() === "inactive" ? { display: "none" } : undefined}
/>
<form onSubmit={onStartOAuth2}>
<Title component="h6">Sign in with Your Mastodon Account</Title>
<TextField
label="Mastodon Server"
name="serverUrl"
onInput={setRawServerUrl}
required
helperText={serverUrlHelperText()}
error={!!serverUrlError()}
/>
<div style={{ display: "flex", "justify-content": "end" }}>
<Button type="submit" disabled={currentState() !== "inactive"}>
{currentState() == "inactive"
? "Continue"
: currentState() == "contracting"
? `Contracting ${new URL(serverUrl()).host}...`
: `Moving to ${targetSiteTitle}`}
</Button>
</div>
</form>
<div class={cards.layoutCentered}>
<Show when={params.error || params.errorDescription}>
<div class={cards.card} style={{ "margin-bottom": "20px" }}>
<p>Authorization is failed.</p>
<p>{params.errorDescription}</p>
<p>
Please try again later. If the problem persist, you can seek for
help from the server administrator.
</p>
</div>
</main>
</>
</Show>
<div
class={/*once*/ cards.card}
aria-busy={currentState() !== "inactive" ? "true" : "false"}
aria-describedby={
currentState() !== "inactive" ? progressId : undefined
}
style={{
padding: `var(--safe-area-inset-top) var(--safe-area-inset-right) var(--safe-area-inset-bottom) var(--safe-area-inset-left)`,
}}
>
<LinearProgress
class={[cards.cardNoPad, cards.cardGutSkip].join(" ")}
id={progressId}
sx={currentState() === "inactive" ? { display: "none" } : undefined}
/>
<form onSubmit={onStartOAuth2}>
<Title component="h6">Sign in with Your Mastodon Account</Title>
<TextField
label="Mastodon Server"
name="serverUrl"
onInput={setRawServerUrl}
required
helperText={serverUrlHelperText()}
error={!!serverUrlError()}
/>
<div style={{ display: "flex", "justify-content": "end" }}>
<Button type="submit" disabled={currentState() !== "inactive"}>
{currentState() == "inactive"
? "Continue"
: currentState() == "contracting"
? `Contracting ${new URL(serverUrl()).host}...`
: `Moving to ${targetSiteTitle}`}
</Button>
</div>
</form>
</div>
</div>
);
};

View file

@ -6,19 +6,10 @@ import {
} from "masto";
import { createMastoClientFor } from "../masto/clients";
export type RemoteServer = {
export type Account = {
site: string;
};
export type AccountKey = RemoteServer & {
accessToken: string;
};
export function isAccountKey(object: RemoteServer): object is AccountKey {
return !!(object as Record<string, unknown>)["accessToken"];
}
export type Account = AccountKey & {
tokenType: string;
scope: string;
createdAt: number;
@ -26,10 +17,6 @@ export type Account = AccountKey & {
inf?: mastodon.v1.AccountCredentials;
};
export function isAccount(object: RemoteServer) {
return isAccountKey(object) && !!(object as Record<string, unknown>)["tokenType"];
}
export const $accounts = persistentAtom<Account[]>("accounts", [], {
encode: JSON.stringify,
decode: JSON.parse,
@ -180,7 +167,7 @@ export async function getOrRegisterApp(site: string, redirectUrl: string) {
});
const app = await client.v1.apps.create({
clientName: "TuTu",
website: "https://code.lightstands.xyz/Rubicon/tutu",
website: "https://github.com/thislight/tutu",
redirectUris: redirectUrl,
scopes: "read write push",
});

View file

@ -1,10 +1,5 @@
import { render } from "solid-js/web";
import App from "./App.js";
import "solid-devtools";
import { attachDevtoolsOverlay } from "@solid-devtools/overlay";
import "./material/theme.css";
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,84 +0,0 @@
import { createCacheBucket } from "~platform/cache";
import { MastoHttpError, MastoTimeoutError, MastoUnexpectedError } from "masto";
export const CACHE_BUCKET_NAME = "mastodon";
export const cacheBucket = /* @__PURE__ */ createCacheBucket(CACHE_BUCKET_NAME);
export function toSmallCamelCase<T>(object: T): T {
if (!object || typeof object !== "object") {
return object;
} else if (Array.isArray(object)) {
return object.map(toSmallCamelCase) as T;
}
const result = {} as Record<keyof any, unknown>;
for (const k in object) {
const value = toSmallCamelCase(object[k]);
const nk =
typeof k === "string"
? k.replace(/_(.)/g, (_, match) => match.toUpperCase())
: k;
result[nk] = value;
}
return result as T;
}
function contentTypeOf(headers: Headers) {
const raw = headers.get("Content-Type")?.replace(/\s*;.*$/, "");
if (!raw) {
return;
}
return raw;
}
/**
* Wrpas the error reason into masto's errors:
* {@link MastoHttpError} if the reason is a {@link Response},
* {@link MastoTimeoutError} if the reason is a timeout error.
*
* @throws If the reason is unexpected, {@link MastoUnexpectedError} will be thrown.
*/
export async function wrapsError(reason: Response): Promise<MastoHttpError>;
export async function wrapsError(reason: {
name: "TimeoutError";
}): Promise<MastoTimeoutError>;
export async function wrapsError<T>(reason: T): Promise<T>;
export async function wrapsError(reason: unknown) {
if (reason instanceof Response) {
const contentType = contentTypeOf(reason.headers);
if (!contentType) {
throw new MastoUnexpectedError(
"The server returned data with an unknown encoding. The server may be down",
);
}
const data = await reason.json();
const {
error: message,
errorDescription,
details,
...additionalProperties
} = data;
return new MastoHttpError(
{
statusCode: reason.status,
message,
description: errorDescription,
details,
additionalProperties,
},
{ cause: reason },
);
}
if (reason && (reason as { name?: string }).name === "TimeoutError") {
return new MastoTimeoutError("Request timed out", { cause: reason });
}
return reason;
}

View file

@ -1,16 +1,14 @@
import {
Accessor,
createContext,
createMemo,
createRenderEffect,
createResource,
untrack,
Signal,
useContext,
} from "solid-js";
import { Account, type RemoteServer } from "../accounts/stores";
import { Account } from "../accounts/stores";
import { createRestAPIClient, mastodon } from "masto";
import { useLocation } from "@solidjs/router";
import { useNavigator } from "~platform/StackedRouter";
import { useLocation, useNavigate } from "@solidjs/router";
const restfulCache: Record<string, mastodon.rest.Client> = {};
@ -51,22 +49,21 @@ export type Session = {
client: mastodon.rest.Client;
};
const Context =
/* @__PURE__ */ createContext<Accessor<readonly Readonly<Session>[]>>();
const Context = /* @__PURE__ */ createContext<Accessor<readonly Readonly<Session>[]>>();
export const Provider = Context.Provider;
export function useSessions() {
const sessions = useSessionsRaw();
const { push } = useNavigator();
const navigate = useNavigate();
const location = useLocation();
createRenderEffect(() => {
if (untrack(() => sessions().length) > 0) return;
push("/accounts/sign-in?back=" + encodeURIComponent(location.pathname), {
replace: true,
});
if (sessions().length > 0) return;
navigate(
"/accounts/sign-in?back=" + encodeURIComponent(location.pathname),
{ replace: true },
);
});
return sessions;
@ -79,64 +76,3 @@ function useSessionsRaw() {
}
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 {@link RemoteServer} 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: { site: inputSite } as RemoteServer, // TODO: we need some security checks here?
} as const
);
});
}
export function makeAcctText(session: Session) {
return `${session.account.inf?.username}@${session.account.site}`;
}

View file

@ -1,32 +0,0 @@
import { CachedFetch } from "~platform/cache";
import { cacheBucket, toSmallCamelCase, wrapsError } from "./base";
import { isAccountKey, type RemoteServer } from "../accounts/stores";
import { type mastodon } from "masto";
export const fetchStatus = /* @__PURE__ */ new CachedFetch(
cacheBucket,
(session: RemoteServer, id: string) => {
const headers = new Headers({
Accept: "application/json",
});
if (isAccountKey(session)) {
headers.set("Authorization", `Bearer ${session.accessToken}`);
}
return {
url: new URL(`./api/v1/statuses/${id}`, session.site).toString(),
headers,
};
},
async (response) => {
try {
if (!response.ok) {
throw response;
}
return toSmallCamelCase(
await response.json(),
) as unknown as mastodon.v1.Status;
} catch (reason) {
throw wrapsError(reason);
}
},
);

View file

@ -7,132 +7,86 @@ import {
createEffect,
createResource,
untrack,
type Resource,
type ResourceFetcherInfo,
} from "solid-js";
import { createStore } from "solid-js/store";
type Timeline<T extends mastodon.DefaultPaginationParams> = {
list(params?: T): mastodon.Paginator<mastodon.v1.Status[], unknown>;
type Timeline = {
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 type ThreadNode = TreeNode<mastodon.v1.Status>;
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> {
export function createTimelineSnapshot(
timeline: Accessor<Timeline>,
limit: Accessor<number>,
) {
const [shot, { refetch }] = createResource(
() => [timeline(), params()] as const,
() => [timeline(), limit()] as const,
async ([tl, limit]) => {
const ls = await tl.list(limit).next();
return ls.value;
const ls = await tl.list({ limit }).next();
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 [
controls,
snapshot,
shot,
{
refetch,
mutate: setSnapshot,
},
] as const;
}
export type TimelineFetchDirection = mastodon.Direction;
export type TimelineChunk<T extends mastodon.DefaultPaginationParams> = {
tl: Timeline<T>;
export type TimelineChunk = {
tl: Timeline;
rebuilt: boolean;
chunk: readonly mastodon.v1.Status[];
done?: boolean;
direction: TimelineFetchDirection;
params: T;
limit: number;
};
type TreeNode<T> = {
@ -154,20 +108,21 @@ function collectPath<T>(node: TreeNode<T>) {
return path;
}
function createTimelineChunk<
T extends Timeline<mastodon.DefaultPaginationParams>,
>(timeline: Accessor<T>, params: Accessor<TimelineParamsOf<T>>) {
function createTimelineChunk(
timeline: Accessor<Timeline>,
limit: Accessor<number>,
) {
let vpMaxId: string | undefined, vpMinId: string | undefined;
const fetchExtendingPage = async (
tl: T,
tl: Timeline,
direction: TimelineFetchDirection,
params: TimelineParamsOf<T>,
limit: number,
) => {
switch (direction) {
case "next": {
const page = await tl
.list({ ...params, sinceId: vpMaxId })
.list({ limit, sinceId: vpMaxId })
.setDirection(direction)
.next();
if ((page.value?.length ?? 0) > 0) {
@ -178,7 +133,7 @@ function createTimelineChunk<
case "prev": {
const page = await tl
.list({ ...params, maxId: vpMinId })
.list({ limit, maxId: vpMinId })
.setDirection(direction)
.next();
if ((page.value?.length ?? 0) > 0) {
@ -190,11 +145,11 @@ function createTimelineChunk<
};
return createResource(
() => [timeline(), params()] as const,
() => [timeline(), limit()] as const,
async (
[tl, params],
[tl, limit],
info: ResourceFetcherInfo<
Readonly<TimelineChunk<TimelineParamsOf<T>>>,
Readonly<TimelineChunk>,
TimelineFetchDirection
>,
) => {
@ -205,124 +160,27 @@ function createTimelineChunk<
vpMaxId = undefined;
vpMinId = undefined;
}
const posts = await fetchExtendingPage(tl, direction, params);
const posts = await fetchExtendingPage(tl, direction, limit);
return {
tl,
rebuilt: rebuildTimeline,
chunk: posts.value ?? [],
done: posts.done,
direction,
params,
limit,
};
},
);
}
export type TimelineControls = {
/**
* The threads.
*
* 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 },
];
export const emptyTimeline = {
list() {
return emptyTimeline;
},
setDirection() {
return emptyTimeline;
},
async next(): Promise<IteratorResult<any, undefined>> {
return {
value: undefined,
done: true,
};
},
getDirection(): TimelineFetchDirection {
return "next";
},
clone() {
return emptyTimeline;
},
async return(): Promise<IteratorResult<any, undefined>> {
return {
value: undefined,
done: true,
};
},
async throw(e?: unknown) {
throw e;
},
async *values() {},
async *[Symbol.asyncIterator](): AsyncIterator<any[], undefined> {
return undefined;
},
async then<TNext, ENext>(
onresolve?: null | ((value: any[]) => TNext | PromiseLike<TNext>),
onrejected?: null | ((reason: unknown) => ENext | PromiseLike<ENext>),
) {
try {
if (!onresolve) {
throw new TypeError("no onresolve");
}
return await onresolve([]);
} catch (reason) {
if (!onrejected) {
throw reason;
}
return await onrejected(reason);
}
},
};
/**
* 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> {
export function createTimeline(
timeline: Accessor<Timeline>,
limit: Accessor<number>,
) {
const lookup = new ReactiveMap<string, TreeNode<mastodon.v1.Status>>();
const [threads, setThreads] = createStore([] as mastodon.v1.Status["id"][]);
const [chunk, { refetch }] = createTimelineChunk(timeline, params);
const [chunk, { refetch }] = createTimelineChunk(timeline, limit);
createEffect(() => {
const chk = catchError(chunk, (e) => console.error(e));
@ -330,29 +188,24 @@ export function createTimeline<
return;
}
if (chk.rebuilt) {
lookup.clear();
setThreads([]);
}
const existence = [] as boolean[];
batch(() => {
if (chk.rebuilt) {
lookup.clear();
setThreads([]);
}
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 [idx, status] of chk.chunk.entries()) {
existence[idx] = !!untrack(() => lookup.get(status.id));
lookup.set(status.id, {
value: status,
});
}
for (const status of chk.chunk) {
const node = untrack(() => lookup.get(status.id))!;
if (status.inReplyToId) {
const parent = lookup.get(status.inReplyToId);
if (parent) {
const children = parent.children ?? [];
if (!children.find((x) => x.value.id == status.id)) {
@ -362,7 +215,7 @@ export function createTimeline<
node.parent = parent;
}
}
});
}
const nThreadIds = chk.chunk
.filter((x, i) => !existence[i])
@ -375,18 +228,29 @@ export function createTimeline<
setThreads((threads) => [...nThreadIds, ...threads]);
}
untrack(() => {
setThreads((threads) =>
threads.filter((id) => (lookup.get(id)!.children?.length ?? 0) === 0),
);
});
setThreads((threads) =>
threads.filter((id) => (lookup.get(id)!.children?.length ?? 0) === 0),
);
});
});
return [
{
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,
{ refetch },

View file

@ -20,6 +20,17 @@ export function resolveCustomEmoji(
});
}
export function appliedCustomEmoji(
target: { innerHTML: string },
content: string,
emojis?: mastodon.v1.CustomEmoji[],
) {
createRenderEffect(() => {
const result = emojis ? resolveCustomEmoji(content, emojis) : content;
target.innerHTML = result;
});
}
export function hasCustomEmoji(s: string) {
return CUSTOM_EMOJI_REGEX.test(s);
}

View file

@ -1,27 +0,0 @@
.AppTopBar {
&::before {
contain: strict;
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: var(--safe-area-inset-top, 0);
background-color: rgba(0, 0, 0, 0.2);
}
&>.MuiToolbar-root {
padding-top: var(--safe-area-inset-top, 0px);
gap: 8px;
&>button:first-child,
&>.MuiButtonBase-root:first-child {
margin-left: -0.15em;
}
&>button:last-child,
&>.MuiButtonBase-root:last-child {
margin-right: -0.15em;
}
}
}

View file

@ -1,31 +0,0 @@
import { AppBar, Toolbar } from "@suid/material";
import { splitProps, type JSX, type ParentComponent } from "solid-js";
import "./AppTopBar.css";
import { useWindowSize } from "@solid-primitives/resize-observer";
import type { AppBarProps } from "@suid/material/AppBar";
const AppTopBar: ParentComponent<{
class?: string;
style?: JSX.HTMLAttributes<HTMLElement>["style"];
} & AppBarProps> = (oprops) => {
const [props, rest] = splitProps(oprops, ["children", "class"]);
const windowSize = useWindowSize();
return (
<AppBar
class={`AppTopBar ${props.class || ""}`}
elevation={1}
position="static"
{...rest}
>
<Toolbar
variant={windowSize.width > windowSize.height ? "dense" : "regular"}
sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }}
>
{props.children}
</Toolbar>
</AppBar>
);
};
export default AppTopBar;

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;
position: fixed;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
@ -11,20 +14,20 @@
overscroll-behavior: none;
max-height: 100vh;
max-height: 100dvh;
height: 95%;
contain: strict;
contain-intrinsic-size: auto 560px auto 95vh;
&::backdrop {
background: transparent;
transition: background-color 120ms var(--tutu-anim-curve-std);
transition-behavior: allow-discrete;
background-color: black;
opacity: 0.5;
transition: opacity 220ms var(--tutu-anim-curve-std);
}
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) {
& {
left: 0;
@ -33,38 +36,42 @@
bottom: 0;
height: 100vh;
height: 100dvh;
contain-intrinsic-size: auto 100vw 100vh;
contain-intrinsic-size: auto 100dvw 100dvh;
max-height: 100vh;
max-height: 100dvh;
}
}
&.animated {
position: fixed;
position: absolute;
transform: translateY(-50%);
overflow: hidden;
will-change: width, height, top, left;
&::backdrop {
opacity: 0;
}
& * {
overflow: hidden;
}
@media (max-width: 560px) {
& {
transform: none;
}
}
}
&.bottom {
top: unset;
transform: translateX(-50%);
bottom: 0;
height: auto;
contain: content;
contain-intrinsic-size: unset;
&[open]::backdrop {
background: var(--tutu-color-shadow-l1);
}
@media (max-width: 560px) {
& {
transform: none;
height: auto;
height: unset;
}
}
}

View file

@ -1,58 +1,49 @@
import {
children,
createEffect,
createSignal,
onCleanup,
useTransition,
type JSX,
type ParentComponent,
type ResolvedChildren,
} from "solid-js";
import "./BottomSheet.css";
import { ANIM_CURVE_ACELERATION, ANIM_CURVE_DECELERATION } from "./theme";
import {
animateSlideInFromRight,
animateSlideOutToRight,
} from "~platform/anim";
import { isPointNotInRect } from "~platform/dom";
import styles from "./BottomSheet.module.css";
import { useHeroSignal } from "../platform/anim";
export type BottomSheetProps = {
open?: boolean;
bottomUp?: boolean;
class?: JSX.HTMLAttributes<HTMLElement>["class"];
onClose?(reason: "backdrop"): void;
};
const MOVE_SPEED = 1600;
export const HERO = Symbol("BottomSheet Hero Symbol");
function animateSlideInFromBottom(element: HTMLElement, reverse?: boolean) {
const rect = element.getBoundingClientRect();
const easing = "cubic-bezier(0.4, 0, 0.2, 1)";
element.classList.add("animated");
const oldOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const distance = Math.abs(rect.top - window.innerHeight);
const duration = (distance / MOVE_SPEED) * 1000;
const animation = element.animate(
{
top: reverse
? [`${rect.top}px`, `${window.innerHeight}px`]
: [`${window.innerHeight}px`, `${rect.top}px`],
},
{ easing, duration },
);
const onAnimationEnd = () => {
element.classList.remove("animated");
document.body.style.overflow = oldOverflow;
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,
};
animation.addEventListener("cancel", onAnimationEnd);
animation.addEventListener("finish", onAnimationEnd);
return animation;
}
const MOVE_SPEED = 1200;
const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
let element!: HTMLDialogElement;
let element: HTMLDialogElement;
let animation: Animation | undefined;
const child = children(() => props.children);
const [hero, setHero] = useHeroSignal(HERO);
const [cache, setCache] = createSignal<ResolvedChildren | undefined>();
const ochildren = children(() => props.children);
const [pending] = useTransition();
@ -60,54 +51,154 @@ const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
if (props.open) {
if (!element.open && !pending()) {
requestAnimationFrame(animatedOpen);
setCache(ochildren());
}
} else {
if (element.open) {
animatedClose();
setCache(undefined);
}
}
});
const onClose = () => {
const srcElement = hero();
if (srcElement) {
srcElement.style.visibility = "unset";
}
element.close();
setHero();
};
const animatedClose = () => {
if (window.innerWidth > 560 && !props.bottomUp) {
onClose();
return;
const srcElement = hero();
const endRect = srcElement?.getBoundingClientRect();
if (endRect) {
const startRect = element.getBoundingClientRect();
const animation = animateHero(startRect, endRect, element, true);
animation.addEventListener("finish", onClose);
animation.addEventListener("cancel", onClose);
} else {
if (window.innerWidth > 560 && !props.bottomUp) {
onClose();
return;
}
const animation = props.bottomUp
? animateSlideInFromBottom(element, true)
: animateSlideInFromRight(element, true);
animation.addEventListener("finish", onClose);
animation.addEventListener("cancel", onClose);
}
const onAnimationEnd = () => {
element.classList.remove("animated");
animation = undefined;
onClose();
};
element.classList.add("animated");
animation = props.bottomUp
? animateSlideInFromBottom(element, true)
: animateSlideOutToRight(element, { easing: ANIM_CURVE_ACELERATION });
animation.addEventListener("finish", onAnimationEnd);
animation.addEventListener("cancel", onAnimationEnd);
};
const animatedOpen = () => {
element.showModal();
if (props.bottomUp) {
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);
} else if (window.innerWidth <= 560) {
element.classList.add("animated");
const onAnimationEnd = () => {
element.classList.remove("animated");
animation = undefined;
};
animation = animateSlideInFromRight(element, {
easing: ANIM_CURVE_DECELERATION,
});
animation.addEventListener("finish", onAnimationEnd);
animation.addEventListener("cancel", onAnimationEnd);
animateSlideInFromRight(element);
}
};
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 = (
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.top - window.innerHeight);
const duration = (distance / MOVE_SPEED) * 1000;
animation = element.animate(
{
left: [`${rect.left}px`, `${rect.left}px`],
top: reserve
? [`${rect.top}px`, `${window.innerHeight}px`]
: [`${window.innerHeight}px`, `${rect.top}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 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(() => {
if (animation) {
animation.cancel();
@ -117,33 +208,27 @@ const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
const onDialogClick = (
event: MouseEvent & { currentTarget: HTMLDialogElement },
) => {
event.stopPropagation();
if (event.target !== event.currentTarget) return;
const rect = event.currentTarget.getBoundingClientRect();
if (isPointNotInRect(rect, event.clientX, event.clientY)) {
const isInDialog =
rect.top <= event.clientY &&
event.clientY <= rect.top + rect.height &&
rect.left <= event.clientX &&
event.clientX <= rect.left + rect.width;
if (!isInDialog) {
props.onClose?.("backdrop");
}
};
const onDialogCancel = (event: Event) => {
event.preventDefault();
props.onClose?.("backdrop");
};
return (
<dialog
class={`BottomSheet surface ${props.class || ""}`}
classList={{
["bottom"]: props.bottomUp,
[styles.bottomSheet]: true,
[styles.bottom]: props.bottomUp,
}}
onClick={onDialogClick}
onCancel={onDialogCancel}
ref={element!}
tabIndex={-1}
role="presentation"
>
{child()}
{ochildren() ?? cache()}
</dialog>
);
};

View file

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

View file

@ -3,12 +3,14 @@ import {
splitProps,
Component,
createSignal,
createEffect,
onMount,
createRenderEffect,
Show,
} from "solid-js";
import { css } from "solid-styled";
import { decode } from "blurhash";
import { mergeClass } from "../utils";
type ImgProps = {
blurhash?: string;
@ -22,7 +24,6 @@ const Img: Component<ImgProps> = (props) => {
"blurhash",
"keepBlur",
"class",
"classList",
"style",
]);
const [isImgLoaded, setIsImgLoaded] = createSignal(false);
@ -60,21 +61,21 @@ const Img: Component<ImgProps> = (props) => {
const onImgLoaded = () => {
setIsImgLoaded(true);
setImgSize({
width: imgE!.width,
height: imgE!.height,
width: imgE.width,
height: imgE.height,
});
};
const onMetadataLoaded = () => {
setImgSize({
width: imgE!.width,
height: imgE!.height,
width: imgE.width,
height: imgE.height,
});
};
onMount(() => {
setImgSize((x) => {
const parent = imgE!.parentElement;
const parent = imgE.parentElement;
if (!parent) return x;
return x
? x
@ -86,14 +87,7 @@ const Img: Component<ImgProps> = (props) => {
});
return (
<div
classList={{
...managed.classList,
[managed.class ?? ""]: true,
"img-root": true,
}}
style={managed.style}
>
<div class={mergeClass(managed.class, "img-root")} style={managed.style}>
<Show when={managed.blurhash}>
<canvas
ref={(canvas) => {

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,32 +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);
}
}
.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%;
display: contents;
background-color: var(--tutu-color-surface);
}

View file

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

View file

@ -1,32 +0,0 @@
.Tab {
cursor: pointer;
background: none;
border: none;
height: 100%;
max-width: min(calc(100% - 56px), 264px);
padding: 10px 24px;
font-size: 0.8135rem;
font-weight: 600;
text-transform: uppercase;
transition: color 120ms var(--tutu-anim-curve-std);
:root:where([lang^="zh"],
[lang="zh"],
[lang^="kr"],
[lang="kr"],
[lang^="ja"],
[lang="ja"]) & {
font-size: 0.85rem;
}
}
.MuiToolbar-root .Tab {
color: rgba(255, 255, 255, 0.7);
&:hover,
&:focus,
&.focus,
&.Tabs-focus {
color: white;
}
}

View file

@ -1,18 +1,26 @@
import {
Component,
createEffect,
splitProps,
type JSX,
type ParentComponent,
} from "solid-js";
import { css } from "solid-styled";
import { useTabListContext } from "./Tabs";
import "./Tab.css";
const Tab: ParentComponent<
{
focus?: boolean;
large?: boolean;
} & JSX.ButtonHTMLAttributes<HTMLButtonElement>
> = (props) => {
const [managed, rest] = splitProps(props, ["focus", "type", "role", "ref"]);
const [managed, rest] = splitProps(props, [
"focus",
"large",
"type",
"role",
"ref",
]);
let self: HTMLButtonElement;
const {
focusOn: [, setFocusOn],
@ -27,7 +35,32 @@ const Tab: ParentComponent<
}
return managed.focus;
});
css`
.tab {
cursor: pointer;
background: none;
border: none;
min-width: ${managed.large ? "160px" : "72px"};
height: 48px;
max-width: min(calc(100% - 56px), 264px);
padding: 10px 24px;
font-size: 0.8135rem;
font-weight: 600;
text-transform: uppercase;
transition: color 120ms var(--tutu-anim-curve-std);
}
:global(.MuiToolbar-root) .tab {
color: rgba(255, 255, 255, 0.7);
&:hover,
&:focus,
&.focus,
&:global(.tablist-focus) {
color: white;
}
}
`;
return (
<button
ref={(x) => {
@ -35,7 +68,7 @@ const Tab: ParentComponent<
(managed.ref as (e: HTMLButtonElement) => void)?.(x);
}}
type={managed.type ?? "button"}
classList={{ Tab: true, focus: managed.focus }}
classList={{ tab: true, focus: managed.focus }}
role={managed.role ?? "tab"}
{...rest}
>

View file

@ -1,21 +0,0 @@
.Tabs {
width: 100%;
position: relative;
white-space: nowrap;
overflow-x: auto;
align-self: stretch;
&::after {
transition:
left var(--tabs-indkt-movspeed-offset, 0) var(--tutu-anim-curve-std),
width var(--tabs-indkt-movspeed-width, 0) var(--tutu-anim-curve-std);
position: absolute;
content: "";
display: block;
background-color: white;
height: 2px;
width: var(--tabs-indkt-width, 0);
left: var(--tabs-indkt-offset, 0);
bottom: 0;
}
}

View file

@ -1,12 +1,14 @@
import {
ParentComponent,
createContext,
createEffect,
createMemo,
createRenderEffect,
createSignal,
useContext,
type Signal,
} from "solid-js";
import "./Tabs.css"
import { css } from "solid-styled";
const TabListContext = /* @__PURE__ */ createContext<{
focusOn: Signal<HTMLElement[]>;
@ -22,7 +24,7 @@ export function useTabListContext() {
const ANIM_SPEED = 160 / 110; // 160px/110ms
const TABS_FOCUS_CLASS = "Tabs-focus";
const TABLIST_FOCUS_CLASS = "tablist-focus";
const Tabs: ParentComponent<{
offset?: number;
@ -35,11 +37,11 @@ const Tabs: ParentComponent<{
const current = focusOn();
if (lastFocusElement) {
for (const e of lastFocusElement) {
e.classList.remove(TABS_FOCUS_CLASS);
e.classList.remove(TABLIST_FOCUS_CLASS);
}
}
for (const e of current) {
e.classList.add(TABS_FOCUS_CLASS);
e.classList.add("tablist-focus");
}
return current;
});
@ -107,7 +109,7 @@ const Tabs: ParentComponent<{
return ["0px", "0px", "110ms", "110ms"] as const;
}
const rect = focusBoundingClientRect();
const rootRect = self!.getBoundingClientRect();
const rootRect = self.getBoundingClientRect();
const left = rect.x - rootRect.x;
const width = rect.width;
const [prevEl, nextEl] = focusSiblings();
@ -128,14 +130,32 @@ const Tabs: ParentComponent<{
return result;
};
css`
.tablist {
width: 100%;
position: relative;
white-space: nowrap;
overflow-x: auto;
&::after {
transition:
left ${indicator()[2]} var(--tutu-anim-curve-std),
width ${indicator()[3]} var(--tutu-anim-curve-std);
position: absolute;
content: "";
display: block;
background-color: white;
height: 2px;
width: ${indicator()[1]};
left: ${indicator()[0]};
bottom: 0;
}
}
`;
return (
<TabListContext.Provider value={{ focusOn: [focusOn, setFocusOn] }}>
<div ref={self!} class="Tabs" style={{
"--tabs-indkt-width": indicator()[1],
"--tabs-indkt-offset": indicator()[0],
"--tabs-indkt-movspeed-offset": indicator()[2],
"--tabs-indkt-movspeed-width": indicator()[3]
}} role="tablist">
<div ref={self!} class="tablist" role="tablist">
{props.children}
</div>
</TabListContext.Provider>

View file

@ -6,7 +6,7 @@ import {
onMount,
Show,
} from "solid-js";
import "./TextField.css";
import formStyles from "./form.module.css";
export type TextFieldProps = {
label?: string;
@ -28,14 +28,14 @@ const TextField: Component<TextFieldProps> = (props) => {
createEffect(() => {
if (hasContent()) {
field!.classList.add("float-label");
field.classList.add("float-label");
} else {
field!.classList.remove("float-label");
field.classList.remove("float-label");
}
});
onMount(() => {
setHasContent(input!.value.length > 0);
setHasContent(input.value.length > 0);
});
const onInputChange = (e: { currentTarget: HTMLInputElement }) => {
@ -47,12 +47,12 @@ const TextField: Component<TextFieldProps> = (props) => {
const inputId = () => props.inputId ?? altInputId;
const fieldClass = () => {
const cls = ["TextField"];
const cls = [formStyles.textfield];
if (typeof props.helperText !== "undefined") {
cls.push("withHelperText");
cls.push(formStyles.withHelperText);
}
if (props.error) {
cls.push("error");
cls.push(formStyles.error);
}
return cls.join(" ");
};
@ -71,7 +71,7 @@ const TextField: Component<TextFieldProps> = (props) => {
name={props.name}
/>
<Show when={typeof props.helperText !== "undefined"}>
<span class="helperText">{props.helperText}</span>
<span class={formStyles.helperText}>{props.helperText}</span>
</Show>
</div>
);

View file

@ -1,57 +0,0 @@
@layer material {
.card {
--card-pad: 20px;
--card-gut: 20px;
background-color: var(--tutu-color-surface);
color: var(--tutu-color-on-surface);
border-radius: 2px;
box-shadow: var(--tutu-shadow-e2);
transition: var(--tutu-transition-shadow);
overflow: hidden;
background-color: var(--tutu-color-surface-l);
&:focus-within,
&:focus-visible {
box-shadow: var(--tutu-shadow-e8);
}
&>.card-pad {
margin-left: var(--card-pad);
margin-right: var(--card-pad);
}
&>.card-gut {
&:first-child {
margin-top: var(--card-gut);
}
&+.card-gut {
margin-top: var(--card-gut);
}
&:last-child {
margin-bottom: var(--card-gut);
}
}
&.card-auto-margin {
&> :not(.card-no-pad) {
margin-inline: var(--card-pad, 20px);
}
> :not(.card-gut-skip):first-child {
margin-top: var(--card-gut, 20px);
}
>.card-gut-skip+*:not(.card-gut-skip) {
margin-top: var(--card-gut, 20px);
}
> :not(.card-gut-skip):last-child {
margin-bottom: var(--card-gut, 20px);
}
}
}
}

View file

@ -0,0 +1,54 @@
.card {
composes: surface from "material.module.css";
border-radius: 2px;
box-shadow: var(--tutu-shadow-e2);
transition: var(--tutu-transition-shadow);
overflow: hidden;
background-color: var(--tutu-color-surface-l);
&:focus-within,
&:focus-visible {
box-shadow: var(--tutu-shadow-e8);
}
&:not(.manualMargin) {
&> :not(.cardNoPad) {
margin-inline: var(--card-pad, 20px);
}
> :not(.cardGutSkip):first-child {
margin-top: var(--card-gut, 20px);
}
>.cardGutSkip+*:not(.cardGutSkip) {
margin-top: var(--card-gut, 20px);
}
> :not(.cardGutSkip):last-child {
margin-bottom: var(--card-gut, 20px);
}
}
}
.layoutCentered {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 448px;
@media (max-width: 600px) {
& {
position: static;
height: 100%;
width: 100%;
left: 0;
right: 0;
transform: none;
display: grid;
grid-template-rows: 1fr auto;
height: 100vh;
overflow: auto;
}
}
}

View file

@ -1,7 +1,5 @@
.TextField {
min-width: 44px;
min-height: 44px;
cursor: pointer;
.textfield {
composes: touchTarget from "material.module.css";
--border-color: var(--tutu-color-inactive-on-surface);
--active-border-color: var(--tutu-color-primary);

View file

@ -1,14 +1,17 @@
@import "./typography.css";
.surface {
background-color: var(--tutu-color-surface);
color: var(--tutu-color-on-surface);
}
button {
.touchTarget {
min-width: 44px;
min-height: 44px;
cursor: pointer;
}
.button {
composes: buttonText from "./typography.module.css";
composes: touchTarget;
border: none;
background-color: transparent;

17
src/material/mui.ts Normal file
View file

@ -0,0 +1,17 @@
import { Theme, createTheme } from "@suid/material/styles";
import { deepPurple, amber } from "@suid/material/colors";
import { Accessor } from "solid-js";
export function useRootTheme(): Accessor<Theme> {
return () =>
createTheme({
palette: {
primary: {
main: deepPurple[500],
},
secondary: {
main: amber.A200,
},
},
});
}

View file

@ -82,44 +82,27 @@
--tutu-color-error-on-surface: #d32f2f;
--tutu-color-inactive-on-surface: #757575;
--tutu-color-shadow: rgba(0, 0, 0, 0.45);
--tutu-color-shadow-l1: rgba(0, 0, 0, 0.4);
--tutu-color-shadow-l2: rgba(0, 0, 0, 0.35);
--tutu-shadow-e1: 0px 1px 2px 0px #9e9e9e;
/* 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 */
--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) */
--tutu-shadow-e3: 0px 3px 6px 0px var(--tutu-color-shadow);
--tutu-shadow-e4: 0px 4px 8px 0px #9e9e9e;
/* 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) */
--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 */
--tutu-shadow-e8: 0px 8px 16px 0px var(--tutu-color-shadow);
--tutu-shadow-e9: 0px 9px 18px 0px #9e9e9e;
/* Submenu (+1dp for each submenu) */
--tutu-shadow-e9: 0px 9px 18px 0px var(--tutu-color-shadow);
--tutu-shadow-e10: 0px 10px 18px 0px var(--tutu-color-shadow);
--tutu-shadow-e11: 0px 11px 18px 0px var(--tutu-color-shadow-l1);
--tutu-shadow-e12: 0px 12px 24px 0px #9e9e9e;
/* (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 */
--tutu-shadow-e16: 0px 16px 32px 0px var(--tutu-color-shadow-l1);
--tutu-shadow-e24: 0px 24px 48px 0px #9e9e9e;
/* 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-deceleration: cubic-bezier(0, 0, 0.2, 1);
--tutu-anim-curve-aceleration: cubic-bezier(0.4, 0, 1, 1);
@ -153,8 +136,6 @@
--tutu-transition-shadow: box-shadow 175ms var(--tutu-anim-curve-std);
--tutu-zidx-nav: 1100;
accent-color: var(--tutu-color-primary);
}
* {

View file

@ -1,29 +0,0 @@
import { Theme, createTheme } from "@suid/material/styles";
import { deepPurple, amber, red } from "@suid/material/colors";
import { Accessor } from "solid-js";
/**
* The MUI theme.
*/
export function createRootTheme(): Accessor<Theme> {
const theme = createTheme({
palette: {
primary: {
main: deepPurple[500],
},
error: {
main: red[900],
},
secondary: {
main: amber.A200,
},
},
});
return () => 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

View file

@ -1,6 +1,3 @@
/* Don't import this file directly. This file is already included in material.css */
.display4 {
font-size: 7rem;
font-weight: 300;
@ -32,11 +29,12 @@
font-size: var(--subheading-size);
}
.body1, .body2 {
.body1 {
font-size: var(--body-size);
}
.body2 {
composes: body1;
font-weight: var(--body2-weight);
}

View file

@ -1,11 +1,22 @@
import { splitProps, type Ref, ComponentProps, ValidComponent } from "solid-js";
import { JSX, ParentComponent, splitProps, type Ref } from "solid-js";
import { Dynamic } from "solid-js/web";
import typography from "./typography.module.css";
import { mergeClass } from "../utils";
export type TypographyProps<E extends ValidComponent> = {
type AnyElement = keyof JSX.IntrinsicElements | ParentComponent<any>;
type PropsOf<E extends AnyElement> =
E extends ParentComponent<infer Props>
? Props
: E extends keyof JSX.IntrinsicElements
? JSX.IntrinsicElements[E]
: JSX.HTMLAttributes<HTMLElement>;
export type TypographyProps<E extends AnyElement> = {
ref?: Ref<E>;
component?: E;
class?: string;
} & ComponentProps<E>;
} & PropsOf<E>;
type TypographyKind =
| "display4"
@ -20,7 +31,7 @@ type TypographyKind =
| "caption"
| "buttonText";
export function Typography<T extends ValidComponent>(
export function Typography<T extends AnyElement>(
props: { typography: TypographyKind } & TypographyProps<T>,
) {
const [managed, passthough] = splitProps(props, [
@ -29,46 +40,48 @@ export function Typography<T extends ValidComponent>(
"class",
"typography",
]);
const classes = () =>
mergeClass(managed.class, typography[managed.typography]);
return (
<Dynamic
ref={managed.ref}
component={managed.component ?? "span"}
class={`${managed.class || ""} ${managed.typography}`}
class={classes()}
{...passthough}
></Dynamic>
);
}
export function Display4<E extends ValidComponent>(props: TypographyProps<E>) {
export function Display4<E extends AnyElement>(props: TypographyProps<E>) {
return <Typography typography={"display4"} {...props}></Typography>;
}
export function Display3<E extends ValidComponent>(props: TypographyProps<E>) {
export function Display3<E extends AnyElement>(props: TypographyProps<E>) {
return <Typography typography={"display3"} {...props}></Typography>;
}
export function Display2<E extends ValidComponent>(props: TypographyProps<E>) {
export function Display2<E extends AnyElement>(props: TypographyProps<E>) {
return <Typography typography={"display2"} {...props}></Typography>;
}
export function Display1<E extends ValidComponent>(props: TypographyProps<E>) {
export function Display1<E extends AnyElement>(props: TypographyProps<E>) {
return <Typography typography={"display1"} {...props}></Typography>;
}
export function Headline<E extends ValidComponent>(props: TypographyProps<E>) {
export function Headline<E extends AnyElement>(props: TypographyProps<E>) {
return <Typography typography={"headline"} {...props}></Typography>;
}
export function Title<E extends ValidComponent>(props: TypographyProps<E>) {
export function Title<E extends AnyElement>(props: TypographyProps<E>) {
return <Typography typography={"title"} {...props}></Typography>;
}
export function Subheading<E extends ValidComponent>(props: TypographyProps<E>) {
export function Subheading<E extends AnyElement>(props: TypographyProps<E>) {
return <Typography typography={"subheading"} {...props}></Typography>;
}
export function Body1<E extends ValidComponent>(props: TypographyProps<E>) {
export function Body1<E extends AnyElement>(props: TypographyProps<E>) {
return <Typography typography={"body1"} {...props}></Typography>;
}
export function Body2<E extends ValidComponent>(props: TypographyProps<E>) {
export function Body2<E extends AnyElement>(props: TypographyProps<E>) {
return <Typography typography={"body2"} {...props}></Typography>;
}
export function Caption<E extends ValidComponent>(props: TypographyProps<E>) {
export function Caption<E extends AnyElement>(props: TypographyProps<E>) {
return <Typography typography={"caption"} {...props}></Typography>;
}
export function ButtonText<E extends ValidComponent>(props: TypographyProps<E>) {
export function ButtonText<E extends AnyElement>(props: TypographyProps<E>) {
return <Typography typography={"buttonText"} {...props}></Typography>;
}

12
src/overrides.d.ts vendored
View file

@ -3,18 +3,6 @@
interface ImportMetaEnv {
readonly BUILT_AT: 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_PLATFORM_MASONRY_ALWAYS_COMPAT?: string
}
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,22 +0,0 @@
import { children, createRenderEffect, onCleanup, type JSX } from "solid-js";
/**
* Document title.
*
* The `children` must be plain text.
*/
export default function (props: { children?: JSX.Element }) {
let otitle: string | undefined;
createRenderEffect(() => (otitle = document.title));
const title = children(() => props.children);
createRenderEffect(
() => (document.title = (title.toArray() as string[]).join("")),
);
onCleanup(() => (document.title = otitle!));
return <></>;
}

View file

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

View file

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

View file

@ -1,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,69 +0,0 @@
.StackedPage {
contain: strict;
container: StackedPage / inline-size;
width: 100vw;
width: 100dvw;
height: 100vh;
height: 100dvh;
}
dialog.StackedPage {
border: none;
position: fixed;
padding: 0;
overscroll-behavior: none;
width: 560px;
max-width: 100vw;
max-width: 100dvw;
max-height: 100vh;
max-height: 100dvh;
/*
* WebKit does not see contain-instric-size as the real element size.
* If the container does not have height, the child element using 100%
* height (usually Scafflod in our case) was have 0px computed height.
*
* This behaviour is different from Firefox. So we need to actually
* define the box height here. (Rubicon)
*/
height: 100vh;
height: 100dvh;
background: none;
display: none;
contain: strict;
contain-intrinsic-size: 560px 100vh;
contain-intrinsic-size: 560px 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: block;
}
&::backdrop {
background: none;
}
&.animating {
overflow: hidden;
* {
overflow: hidden;
}
}
}

View file

@ -1,724 +0,0 @@
import {
type RouterProps,
type StaticRouterProps,
createRouter,
} from "@solidjs/router";
import {
Component,
createContext,
createMemo,
createRenderEffect,
Index,
onMount,
Show,
untrack,
useContext,
onCleanup,
type Accessor,
useTransition,
getOwner,
} 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";
import { isPointNotInRect } from "./dom";
let uniqueCounter = 0;
function createUniqueId() {
return `sr-${uniqueCounter++}`;
}
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 or all the stack.
*/
replace?: boolean | "all";
/**
* 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]>>) => Promise<Readonly<StackFrame>>
: (path: K, state: Readonly<NewFrameOptions<T[K]>>) => Promise<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();
if (isPointNotInRect(rect, event.clientX, event.clientY)) {
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,
};
}
function animateUntil(
stepfn: (onCreated: (animation: Animation) => void) => void,
) {
const execStep = () => {
requestAnimationFrame(() => {
stepfn((step) => {
step.addEventListener("finish", () => {
execStep();
});
});
});
};
execStep();
}
function noOp() {}
function StaticRouter(props: StaticRouterProps) {
const url = () => props.url || "";
// TODO: support onBeforeLeave, see
// https://github.com/solidjs/solid-router/blob/main/src/routers/Router.ts
return createRouter({
get: url,
set: noOp,
init(notify) {
createRenderEffect(() => notify(url()));
return noOp;
},
})(props);
}
/**
* The cache key of saved stack for hot reload.
*
* We could not use symbols because every time the hot reload the `Symbol()`
* call creates a new symbol.
*/
const $StackedRouterSavedStack = "$StackedRouterSavedStack";
/**
* 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 [, startTransition] = useTransition();
if (import.meta.hot) {
const saveStack = () => {
import.meta.hot!.data[$StackedRouterSavedStack] = unwrap(stack);
console.debug("stack saved");
};
import.meta.hot.on("vite:beforeUpdate", saveStack);
onCleanup(() => import.meta.hot!.off("vite:beforeUpdate", saveStack));
const loadStack = () => {
const savedStack = import.meta.hot!.data[$StackedRouterSavedStack];
if (savedStack) {
mutStack(savedStack);
console.debug("stack loaded");
}
delete import.meta.hot!.data[$StackedRouterSavedStack];
};
createRenderEffect(() => {
loadStack();
});
}
const pushFrame = async (path: string, opts?: Readonly<NewFrameOptions<any>>) =>
await untrack(async () => {
const frame = {
path,
state: opts?.state,
rootId: createUniqueId(),
animateOpen: opts?.animateOpen,
animateClose: opts?.animateClose,
};
const replace = opts?.replace;
const length = stack.length;
await startTransition(() => {
if (replace === "all" || length === 0) {
mutStack([frame]);
} else if (replace) {
const idx = length - 1;
mutStack(idx, frame);
} else {
mutStack(length, frame);
}
});
const savedStack = serializableStack(stack);
if (replace) {
window.history.replaceState(savedStack, "", path);
} else {
window.history.pushState(savedStack, "", path);
}
return frame;
});
const onlyPopFrameOnStack = (depth: number) => {
mutStack((o) => o.toSpliced(o.length - depth, depth));
};
const onlyPopFrame = (depth: number) => {
onlyPopFrameOnStack(depth);
window.history.go(-depth);
};
const animatePopOneFrame = (onCreated: (animation: Animation) => void) => {
const lastFrame = stack[stack.length - 1];
const element = document.getElementById(
lastFrame.rootId,
)! as HTMLDialogElement;
const createAnimation = lastFrame.animateClose ?? animateClose;
element.classList.add("animating");
const onNavAnimEnd = () => {
element.classList.remove("animating");
};
requestAnimationFrame(() => {
const animation = createAnimation(element);
animation.addEventListener("finish", onNavAnimEnd);
animation.addEventListener("cancel", onNavAnimEnd);
onCreated(animation);
});
};
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) {
let count = depth;
animateUntil((created) => {
if (count > 0) {
animatePopOneFrame((a) => {
a.addEventListener("finish", () => onlyPopFrame(1));
created(a);
});
}
count--;
});
} else {
onlyPopFrame(1);
}
});
createRenderEffect(() =>
untrack(() => {
if (stack.length === 0) {
const parts = [window.location.pathname] as string[];
if (window.location.search) {
parts.push(window.location.search);
}
pushFrame(parts.join(""), {
replace: "all",
});
}
}),
);
createRenderEffect(() => {
makeEventListener(window, "popstate", (event) => {
if (!event.state) return;
// TODO: verify the stack in state and handling forwards
if (stack.length === 0) {
mutStack(event.state || []);
} else if (stack.length > event.state.length) {
let count = stack.length - event.state.length;
animateUntil((created) => {
if (count > 0) {
animatePopOneFrame((a) => {
a.addEventListener("finish", () => {
onlyPopFrameOnStack(1);
created(a);
});
});
}
count--;
});
}
});
});
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,
get frame() {
return 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(
root: HTMLElement,
options?: Omit<KeyframeAnimationOptions, "duration">,
) {
const overflow = root.style.overflow;
root.style.overflow = "hidden";
export type HeroSource = {
[key: string | symbol | number]: HTMLElement | undefined;
};
const { height } = root.getBoundingClientRect();
const HeroSourceContext = createContext<Signal<HeroSource>>(
/* __@PURE__ */ undefined,
);
const opts = Object.assign(
{
duration: Math.floor((height / 1600) * 1000),
},
options,
);
export const HeroSourceProvider = HeroSourceContext.Provider;
const animation = root.animate(
{
height: ["0px", `${height}px`],
},
opts,
);
const restore = () => (root.style.overflow = overflow);
animation.addEventListener("finish", restore);
animation.addEventListener("cancel", restore);
return animation;
function useHeroSource() {
return useContext(HeroSourceContext);
}
export function animateRollInFromBottom(
root: HTMLElement,
options?: Omit<KeyframeAnimationOptions, "duration">,
) {
const overflow = root.style.overflow;
root.style.overflow = "hidden";
/**
* Use hero value for the {@link key}.
*/
export function useHeroSignal(
key: string | symbol | number,
): 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(
{
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;
return [get, set];
} 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,145 +0,0 @@
import { addMinutes, formatRFC7231 } from "date-fns";
import {
createRenderEffect,
createResource,
untrack,
} from "solid-js";
export function createCacheBucket(name: string) {
let bucket: Cache | undefined;
return async () => {
if (bucket) {
return bucket;
}
bucket = await self.caches.open(name);
return bucket;
};
}
export type FetchRequest = {
url: string;
headers?: HeadersInit | Headers;
};
async function searchCache(request: Request) {
return await self.caches.match(request);
}
/**
* Create a {@link fetch} helper with additional caching support.
*/
export class CachedFetch<
Transformer extends (response: Response) => any,
Keyer extends (...args: any[]) => FetchRequest,
> {
private cacheBucket: () => Promise<Cache>;
keyFor: Keyer;
private transform: Transformer;
constructor(
cacheBucket: () => Promise<Cache>,
keyFor: Keyer,
tranformer: Transformer,
) {
this.cacheBucket = cacheBucket;
this.keyFor = keyFor;
this.transform = tranformer;
}
private async validateCache(request: Request) {
const buk = await this.cacheBucket();
const response = await fetch(request);
buk.put(request, response.clone());
return response;
}
private request(...args: Parameters<Keyer>) {
const { url, ...init } = this.keyFor(...args);
const request = new Request(url, init);
return request;
}
/**
* Race between the cache and the network result,
* use the fastest result.
*
* The cache will be revalidated.
*/
async fastest(
...args: Parameters<Keyer>
): Promise<Awaited<ReturnType<Transformer>>> {
const request = this.request(...args);
const validating = this.validateCache(request);
const searching = searchCache(request);
const earlyResult = await Promise.race([validating, searching]);
if (earlyResult) {
return await this.transform(earlyResult);
}
return await this.transform(await validating);
}
/**
* Validate and return the result.
*/
async validate(
...args: Parameters<Keyer>
): Promise<Awaited<ReturnType<Transformer>>> {
return await this.transform(
await this.validateCache(this.request(...args)),
);
}
/** Set a response as the cache.
* Recommend to set `Expires` or `Cache-Control` to limit its live time.
*/
async set(key: Parameters<Keyer>, response: Response) {
const buk = await this.cacheBucket();
await buk.put(this.request(...key), response);
}
/** Set a json object as the cache.
* Only available for 5 minutes.
*/
async setJson(key: Parameters<Keyer>, object: unknown) {
const response = new Response(JSON.stringify(object), {
status: 200,
headers: {
"Content-Type": "application/json",
Expires: formatRFC7231(addMinutes(new Date(), 5)),
"X-Cache-Src": "set",
},
});
await this.set(key, response);
}
/**
* Return a resource, using the cache at first, and revalidate
* later.
*/
cachedAndRevalidate(args: () => Parameters<Keyer>) {
const res = createResource(args, (p) => this.validate(...p));
const checkCacheIfStillLoading = async () => {
const saved = await searchCache(this.request(...args()));
if (!saved) {
return;
}
const transformed = await this.transform(saved);
if (res[0].loading) {
res[1].mutate(transformed);
}
};
createRenderEffect(() => void untrack(() => checkCacheIfStillLoading()));
return res;
}
}

View file

@ -1,5 +0,0 @@
export function isPointNotInRect(rect: DOMRect, ptX: number, ptY: number) {
return (
ptY < rect.top || ptY > rect.bottom || ptX < rect.left || ptX > rect.right
);
}

View file

@ -1,37 +1,12 @@
import { createContext, useContext, type Accessor } from "solid-js";
import { useRegisterSW } from "virtual:pwa-register/solid";
export function isiOS() {
return (
[
"iPad Simulator",
"iPhone Simulator",
"iPod Simulator",
"iPad",
"iPhone",
"iPod",
].includes(navigator.platform) ||
// iPad on iOS 13 detection
(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);
}
return [
'iPad Simulator',
'iPhone Simulator',
'iPod Simulator',
'iPad',
'iPhone',
'iPod'
].includes(navigator.platform)
// iPad on iOS 13 detection
|| (navigator.userAgent.includes("Mac") && "ontouchend" in document)
}

View file

@ -1,21 +1,24 @@
import {
catchError,
ParentComponent,
createContext,
createMemo,
createResource,
useContext,
} from "solid-js";
import { match } from "@formatjs/intl-localematcher";
import { Accessor } from "solid-js";
import { Accessor, createEffect, createSignal } from "solid-js";
import { $settings } from "../settings/stores";
import { enGB } from "date-fns/locale/en-GB";
import { useStore } from "@nanostores/solid";
import type { Locale } from "date-fns";
import {
resolveTemplate,
translator,
type Template,
} from "@solid-primitives/i18n";
import { resolveTemplate, translator, type Template } from "@solid-primitives/i18n";
async function synchronised(
name: string,
callback: () => Promise<void> | void,
): Promise<void> {
await navigator.locks.request(name, callback);
}
export const SUPPORTED_LANGS = ["en", "zh-Hans"] as const;
@ -25,61 +28,49 @@ const DEFAULT_LANG = "en";
/**
* Decide the using language for the user.
*
* **Performance**: This function is costy, make sure you cache the result.
* In the app, you should use {@link useAppLocale} instead.
*
* @returns the selected language tag
*/
export function autoMatchLangTag() {
return match(Array.from(navigator.languages), SUPPORTED_LANGS, DEFAULT_LANG);
}
/**
* Decide the using region for the user.
*
* **Performance**: This function is costy, make sure you cache the result.
* In the app, you should use {@link useAppLocale} instead.
*/
export function autoMatchRegion() {
const specifiers = navigator.languages.map((x) => x.split("-"));
const DateFnLocaleCx = /* __@PURE__ */createContext<Accessor<Locale>>(() => enGB);
for (const s of specifiers) {
if (s.length === 1) {
const lang = s[0];
for (const available of SUPPORTED_REGIONS) {
if (available.toLowerCase().startsWith(lang.toLowerCase())) {
return available;
}
const cachedDateFnLocale: Record<string, Locale> = {
enGB,
};
export function autoMatchRegion() {
const regions = navigator.languages
.map((x) => {
const parts = x.split("_");
if (parts.length > 1) {
return parts[1];
}
} else if (s.length === 2) {
const [lang, region] = s;
for (const available of SUPPORTED_REGIONS) {
if (available.toLowerCase() === `${lang}_${region}`.toLowerCase()) {
return available;
}
return undefined;
})
.filter((x): x is string => !!x);
for (const r of regions) {
for (const available of SUPPORTED_REGIONS) {
if (available.toLowerCase().endsWith(r.toLowerCase())) {
return available;
}
}
}
return "en_GB";
}
export function createCurrentRegion() {
export function useRegion() {
const appSettings = useStore($settings);
return createMemo(
() => {
const settings = appSettings();
if (typeof settings.region !== "undefined") {
return settings.region;
} else {
return autoMatchRegion();
}
},
"en_GB",
{ name: "region" },
);
return createMemo(() => {
const settings = appSettings();
if (typeof settings.region !== "undefined") {
return settings.region;
} else {
return autoMatchRegion();
}
});
}
async function importDateFnLocale(tag: string): Promise<Locale> {
@ -87,7 +78,7 @@ async function importDateFnLocale(tag: string): Promise<Locale> {
case "en_us":
return (await import("date-fns/locale/en-US")).enUS;
case "en_gb":
return enGB;
return (await import("date-fns/locale/en-GB")).enGB;
case "zh_cn":
return (await import("date-fns/locale/zh-CN")).zhCN;
default:
@ -95,6 +86,51 @@ async function importDateFnLocale(tag: string): Promise<Locale> {
}
}
/**
* Provides runtime values and fetch dependencies for date-fns locale
*/
export const DateFnScope: ParentComponent = (props) => {
const [dateFnLocale, setDateFnLocale] = createSignal(enGB);
const region = useRegion();
createEffect(() => {
const dateFnLocaleName = region();
if (cachedDateFnLocale[dateFnLocaleName]) {
setDateFnLocale(cachedDateFnLocale[dateFnLocaleName]);
} else {
synchronised("i18n-wrapper-load-date-fns-locale", async () => {
if (cachedDateFnLocale[dateFnLocaleName]) {
setDateFnLocale(cachedDateFnLocale[dateFnLocaleName]);
return;
}
const target = `date-fns/locale/${dateFnLocaleName}`;
try {
const mod = await importDateFnLocale(dateFnLocaleName);
cachedDateFnLocale[dateFnLocaleName] = mod;
setDateFnLocale(mod);
} catch (reason) {
console.error(
{
act: "load-date-fns-locale",
stat: "failed",
reason,
target,
},
"failed to load date-fns locale",
);
}
});
}
});
return (
<DateFnLocaleCx.Provider value={dateFnLocale}>
{props.children}
</DateFnLocaleCx.Provider>
);
};
/**
* Get the {@link Locale} object for date-fns.
*
@ -103,51 +139,28 @@ async function importDateFnLocale(tag: string): Promise<Locale> {
* @returns Accessor for Locale
*/
export function useDateFnLocale(): Accessor<Locale> {
const { dateFn } = useAppLocale();
return dateFn;
const cx = useContext(DateFnLocaleCx);
return cx;
}
export function createCurrentLanguage() {
export function useLanguage() {
const settings = useStore($settings);
return createMemo(() => 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 []
? {}
: T extends [infer I]
? ImportedModule<I>
: T extends [infer I, ...infer J]
? ImportedModule<I> & MergedImportedModule<J>
: never;
type MergedImportedModule<T> =
T extends [] ? {} :
T extends [infer I] ? ImportedModule<I> :
T extends [infer I, ...infer J] ? ImportedModule<I> & MergedImportedModule<J> : never
/**
* Create a resource that combines all I18N strings into one object.
*
* The result is combined in the order of the argument functions.
* The formers will be overrided by the latter.
*
* @param importFns a series of functions imports the string modules
* based on the specified language code.
*
* **Context**: This function must be used under {@link AppLocaleProvider}.
*
* @example ````ts
* const [strings] = createStringResource(
* async (code) => await import(`./i18n/${code}.json`), // Vite can handle the bundling
* async () => import("./i18n/generic.json"), // You can also ignore the code.
* );
* ````
*
* @see {@link createTranslator} if you need a Translator from "@solid-primitives/i18n"
*/
export function createStringResource<
T extends ImportFn<Record<string, string | Template<any> | undefined>>[],
>(...importFns: T) {
const { language } = useAppLocale();
const language = useLanguage();
const cache: Record<string, MergedImportedModule<T>> = {};
return createResource(
@ -157,11 +170,9 @@ export function createStringResource<
return cache[nlang];
}
const results = await Promise.all(
importFns.map((x) => x(nlang).then((v) => v.default)),
);
const results = await Promise.all(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;
@ -170,57 +181,8 @@ export function createStringResource<
);
}
/**
* Create the Translator from "@solid-primitives/i18n" based on
* the {@link createStringResource}.
*
* @param importFns same to {@link createStringResource}
*
* @returns the first element is the translator, the second is the result from
* {@link createStringResource}.
*
* @see {@link translator} for the translator usage
* @see {@link createStringResource} for the raw strings
*/
export function createTranslator<
T extends ImportFn<Record<string, string | Template<any> | undefined>>[],
>(...importFns: T) {
const res = createStringResource(...importFns);
export function createTranslator<T extends ImportFn<Record<string, string | Template<any> | undefined>>[],>(...importFns: T) {
const res = createStringResource(...importFns)
return [translator(res[0], resolveTemplate), res] as const;
}
export type AppLocale = {
dateFn: () => Locale;
language: () => string;
region: () => string;
};
const AppLocaleContext = /* @__PURE__ */ createContext<AppLocale>();
export const AppLocaleProvider = AppLocaleContext.Provider;
export function useAppLocale() {
const l = useContext(AppLocaleContext);
if (!l) {
throw new TypeError("app locale not found");
}
return l;
}
export function createDateFnLocaleResource(region: () => string) {
const [localeUncaught] = createResource(
region,
async (region) => {
return await importDateFnLocale(region);
},
{ initialValue: enGB },
);
return createMemo(
() =>
catchError(localeUncaught, (reason) => {
console.error("fetch date-fns locale", reason);
}) ?? enGB,
);
return [translator(res[0], resolveTemplate), res] as const
}

View file

@ -1,27 +1,24 @@
//! This module has side effect.
//! It recommended to include the module by <script> tag.
if (typeof Promise.withResolvers === "undefined") {
// Chrome/Edge 119, Firefox 121, Safari/iOS 17.4
// Promise.withResolvers is generic and works with subclasses - the typescript built-in decl
// could not handle the subclassing case.
(Promise.prototype as any).withResolvers = function <T>(
this: AnyPromiseConstructor<T>,
) {
let resolve!: PromiseWithResolvers<T>["resolve"],
reject!: PromiseWithResolvers<T>["reject"];
// These variables are expected to be set after `new this()`
const promise = new this((resolve0, reject0) => {
resolve = resolve0;
reject = reject0;
});
return {
promise,
resolve,
reject,
};
};
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") {
// Chrome/Edge 92+
// https://stackoverflow.com/a/2117523/2800218
// LICENSE: https://creativecommons.org/licenses/by-sa/4.0/legalcode
window.crypto.randomUUID =
function randomUUID(): `${string}-${string}-${string}-${string}-${string}` {
return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c) =>
(
+c ^
(crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (+c / 4)))
).toString(16),
) as `${string}-${string}-${string}-${string}-${string}`;
};
}

View file

@ -4,8 +4,8 @@ import {
onCleanup,
type Component,
} from "solid-js";
import Scaffold from "~material/Scaffold";
import BottomSheet from "~material/BottomSheet";
import Scaffold from "../material/Scaffold";
import BottomSheet from "../material/BottomSheet";
import {
Button,
IconButton,
@ -14,9 +14,9 @@ import {
Toolbar,
} from "@suid/material";
import { Close as CloseIcon, ContentCopy } from "@suid/icons-material";
import { Title } from "~material/typography";
import { Title } from "../material/typography";
import { render } from "solid-js/web";
import { createRootTheme } from "~material/theme";
import { useRootTheme } from "../material/mui";
const ShareBottomSheet: Component<{
data?: ShareData;
@ -78,7 +78,7 @@ export async function share(data?: ShareData): Promise<void> {
const dispose = render(() => {
const [open, setOpen] = createSignal(true);
const theme = createRootTheme();
const theme = useRootTheme();
onCleanup(() => {
element.remove();
resolve();

View file

@ -1,4 +1,3 @@
import { usePageVisibility } from "@solid-primitives/page-visibility";
import {
Accessor,
createContext,
@ -16,30 +15,19 @@ export const TimeSourceProvider = TimeSourceContext.Provider;
export function createTimeSource() {
let id: ReturnType<typeof setTimeout> | undefined;
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") {
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;

View file

@ -1,166 +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;
contain: layout style;
}
.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);
}
}
@supports (container-type: inline-size) {
@container StackedPage (inline-size >=960px) {
.Profile {
display: grid;
grid-template-columns: auto 560px;
grid-template-rows: min-content 1fr;
height: 100cqh;
>.topbar {
grid-column: 1 / 3;
grid-row: 1 /2;
.MuiToolbar-root {
padding-right: calc(560px + 24px);
}
}
>.details {
height: 100%;
display: flex;
flex-flow: column nowrap;
>.intro {
flex-grow: 1;
}
}
>.recent-toots {
overflow-y: auto;
margin-top: calc(-1 * var(--scaffold-topbar-height));
z-index: calc(var(--tutu-zidx-nav, 1) + 1);
>.toot-list-toolbar {
top: 0;
}
}
}
}
}
.Profile__page-title {
flex-grow: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View file

@ -1,577 +0,0 @@
import {
catchError,
createResource,
createSignal,
createUniqueId,
For,
Switch,
Match,
onCleanup,
Show,
type Component,
createMemo,
} from "solid-js";
import Scaffold from "~material/Scaffold";
import {
Avatar,
Button,
Checkbox,
CircularProgress,
Divider,
IconButton,
ListItemAvatar,
ListItemIcon,
ListItemSecondaryAction,
ListItemText,
MenuItem,
} 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,
emptyTimeline,
} 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";
import {
createSingluarItemSelection,
default as ItemSelectionProvider,
} from "../timelines/toots/ItemSelectionProvider";
import AppTopBar from "~material/AppTopBar";
import type { Account } from "../accounts/stores";
import DocumentTitle from "~platform/DocumentTitle";
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 [, selectionState] = createSingluarItemSelection();
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]) => {
if (id.startsWith("@")) {
return await client.v1.accounts.lookup({ acct: id.slice(1) });
}
return await client.v1.accounts.$select(id).fetch();
},
);
const profile = () => {
try {
return profileUncaught();
} catch (reason) {
console.error(reason);
}
};
const profileAcctId = () => {
if (params.id.startsWith("@")) {
// Webfinger
return profile()?.id;
} else {
return params.id;
}
};
const isCurrentSessionProfile = () => {
return (session().account as Account).inf?.url === profile()?.url;
};
const [recentTootFilter, setRecentTootFilter] = createSignal({
pinned: true,
boost: false,
reply: true,
original: true,
});
const recentTimeline = () => {
const id = profileAcctId();
if (id) {
return session().client.v1.accounts.$select(id).statuses;
} else {
return emptyTimeline;
}
};
const [recentToots, recentTootChunk, { refetch: refetchRecentToots }] =
createTimeline(recentTimeline, () => {
const { boost, reply } = recentTootFilter();
return { limit: 20, excludeReblogs: !boost, excludeReplies: !reply };
});
const [pinnedToots, pinnedTootChunk] = createTimelineSnapshot(
recentTimeline,
() => {
return { limit: 20, pinned: true };
},
);
const [relationshipUncaught, { mutate: mutateRelationship }] = createResource(
() => [session(), profileAcctId()] as const,
async ([sess, id]) => {
if (!sess.account || !id) 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 as Account).inf?.displayName || "",
(session().account as Account).inf?.emojis ?? [],
),
);
const toggleSubscribeHome = async (event: Event) => {
const client = session().client;
const acctId = profileAcctId();
if (!session().account || !acctId) 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(acctId).unfollow();
mutateRelationship(nrel);
} else {
const nrel = await client.v1.accounts.$select(acctId).follow();
mutateRelationship(nrel);
}
};
return (
<>
<DocumentTitle>{profile()?.displayName ?? "Someone"}</DocumentTitle>
<Scaffold
topbar={
<AppTopBar
role="navigation"
position="static"
color={scrolledPastBanner() ? "primary" : "transparent"}
elevation={scrolledPastBanner() ? undefined : 0}
style={{
color: scrolledPastBanner()
? undefined
: bannerSampledColors()?.text,
}}
>
<IconButton color="inherit" onClick={[pop, 1]} aria-label="Close">
<Close />
</IconButton>
<Title
class="Profile__page-title"
style={{
visibility: scrolledPastBanner() ? undefined : "hidden",
}}
innerHTML={displayName()}
></Title>
<IconButton
id={menuButId}
aria-controls={optMenuId}
color="inherit"
onClick={[setMenuOpen, true]}
aria-label="Open Options for the Profile"
>
<MoreVert />
</IconButton>
</AppTopBar>
}
class="Profile"
>
<div class="details" role="presentation">
<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 as Account).inf?.avatar} />
</ListItemAvatar>
<ListItemText secondary={"Default account"}>
<span innerHTML={sessionDisplayName()}></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 as Account).inf?.avatar}
></Avatar>
</ListItemAvatar>
<ListItemText
secondary={
relationship()?.following
? undefined
: profile()?.locked
? "A request will be sent"
: undefined
}
>
<span innerHTML={sessionDisplayName()}></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"
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`}
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 innerHTML={item.value}></td>
</tr>
);
}}
</For>
</tbody>
</table>
</div>
</div>
<div class="recent-toots" role="presentation">
<div class="toot-list-toolbar">
<TootFilterButton
options={{
pinned: "Pinneds",
boost: "Boosts",
reply: "Replies",
original: "Originals",
}}
applied={recentTootFilter()}
onApply={setRecentTootFilter}
disabledKeys={["original"]}
></TootFilterButton>
</div>
<ItemSelectionProvider value={selectionState}>
<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>
</ItemSelectionProvider>
<Show when={!recentTootChunk()?.done}>
<div
style={{
"text-align": "center",
"padding-bottom": "var(--safe-area-inset-bottom)",
}}
>
<IconButton
aria-label="Load More"
aria-controls={recentTootListId}
size="large"
color="primary"
onClick={[refetchRecentToots, "prev"]}
disabled={isTootListLoading()}
>
<Show when={isTootListLoading()} fallback={<ExpandMore />}>
<CircularProgress sx={{ width: "24px", height: "24px" }} />
</Show>
</IconButton>
</div>
</Show>
</div>
</Scaffold>
</>
);
};
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,41 +0,0 @@
import { cleanupOutdatedCaches, precacheAndRoute } from "workbox-precaching";
import { clientsClaim } from "workbox-core";
import { dispatchCall, isJSONRPCCall, type Call } from "./workerrpc";
function isServiceWorker(
self: WorkerGlobalScope,
// @ts-ignore: workaround for workbox logger.d.ts decl
): self is ServiceWorkerGlobalScope {
return (
(self as unknown as Record<string, unknown>)["serviceWorker"] instanceof
ServiceWorker
);
}
if (!isServiceWorker(self)) {
throw new TypeError("This entry point must be run in a service worker");
}
cleanupOutdatedCaches();
precacheAndRoute(self.__WB_MANIFEST, {
cleanURLs: false,
});
// auto update
self.skipWaiting();
clientsClaim();
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>> & { source: MessageEventSource },
);
}
});

View file

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

View file

@ -1,7 +0,0 @@
{
"compilerOptions": {
"lib": ["WebWorker", "ESNext"],
},
"extends": ["../../tsconfig.super.json"],
"include": ["./**/*.ts"],
}

View file

@ -1,223 +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;
// Now the callback is not undefined
// Fast path
if (typeof callback !== "boolean") {
callback(message);
this.map.delete(id);
return Promise.resolve();
}
}
const { promise, resolve } = Promise.withResolvers<undefined>();
let retried = 0;
const checkAndDispatch = () => {
const callback = this.map.get(id);
if (typeof callback !== "boolean") {
callback!(message); // the nullability is already checked before
this.map.delete(id);
resolve(undefined);
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();
return promise;
}
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>;
export async function dispatchCall<S extends Partial<AnyService>>(
service: S,
event: MessageEvent<Call<unknown>> & { source: MessageEventSource },
) {
try {
const fn = service[event.data.method];
if (!fn) {
console.warn("requested unknown method", event.data.method, event.data);
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>);
return;
}
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 Scaffold from "~material/Scaffold";
import Scaffold from "../material/Scaffold";
import {
AppBar,
IconButton,
@ -19,19 +19,17 @@ import {
autoMatchLangTag,
createTranslator,
SUPPORTED_LANGS,
} from "~platform/i18n";
import { Title } from "~material/typography";
} from "../platform/i18n";
import { Title } from "../material/typography";
import type { Template } from "@solid-primitives/i18n";
import { useStore } from "@nanostores/solid";
import { $settings } from "./stores";
import { useNavigator } from "~platform/StackedRouter";
import AppTopBar from "~material/AppTopBar";
import DocumentTitle from "~platform/DocumentTitle";
import { useNavigate } from "@solidjs/router";
const ChooseLang: Component = () => {
const { pop } = useNavigator();
const navigate = useNavigate()
const [t] = createTranslator(
() => import("./i18n/generic.json"),
() => import("./i18n/lang-names.json"),
(code) =>
import(`./i18n/${code}.json`) as Promise<{
default: Record<string, string | undefined> & {
@ -39,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(() => {
return iso639_1.getAllCodes().filter((x) => !["zh", "en"].includes(x));
@ -50,74 +48,73 @@ const ChooseLang: Component = () => {
const matchedLangCode = createMemo(() => autoMatchLangTag());
const onCodeChange = (code?: string) => {
$settings.setKey("language", code);
};
$settings.setKey("language", code)
}
return (
<>
<DocumentTitle>{t("Choose Language")}</DocumentTitle>
<Scaffold
topbar={
<AppTopBar>
<IconButton color="inherit" onClick={[pop, 1]} disableRipple>
<Scaffold
topbar={
<AppBar position="static">
<Toolbar
variant="dense"
sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }}
>
<IconButton color="inherit" onClick={[navigate, -1]} disableRipple>
<ArrowBack />
</IconButton>
<Title>{t("Choose Language")}</Title>
</AppTopBar>
}
</Toolbar>
</AppBar>
}
>
<List
sx={{
paddingBottom: "var(--safe-area-inset-bottom, 0)",
}}
>
<List
sx={{
paddingBottom: "var(--safe-area-inset-bottom, 0)",
<ListItemButton
onClick={() => {
onCodeChange(code() ? undefined : matchedLangCode());
}}
>
<ListItemButton
onClick={() => {
onCodeChange(code() ? undefined : matchedLangCode());
}}
>
<ListItemText>
{t("lang.auto", {
detected: t(`lang.${matchedLangCode()}`) ?? matchedLangCode(),
})}
</ListItemText>
<ListItemSecondaryAction>
<Switch checked={typeof code() === "undefined"} />
</ListItemSecondaryAction>
</ListItemButton>
<List subheader={<ListSubheader>{t("Supported")}</ListSubheader>}>
<For each={SUPPORTED_LANGS}>
{(c) => (
<ListItemButton
disabled={typeof code() === "undefined"}
onClick={[onCodeChange, c]}
>
<ListItemText>{t(`lang.${c}`)}</ListItemText>
<ListItemSecondaryAction>
<Radio
checked={
code() === c ||
(code() === undefined && matchedLangCode() == c)
}
/>
</ListItemSecondaryAction>
</ListItemButton>
)}
</For>
</List>
<List subheader={<ListSubheader>{t("Unsupported")}</ListSubheader>}>
<For each={unsupportedLangCodes()}>
{(code) => (
<ListItem>
<ListItemText>{iso639_1.getNativeName(code)}</ListItemText>
</ListItem>
)}
</For>
</List>
<ListItemText>
{t("lang.auto", {
detected: t(`lang.${matchedLangCode()}`) ?? matchedLangCode(),
})}
</ListItemText>
<ListItemSecondaryAction>
<Switch checked={typeof code() === "undefined"} />
</ListItemSecondaryAction>
</ListItemButton>
<List subheader={<ListSubheader>{t("Supported")}</ListSubheader>}>
<For each={SUPPORTED_LANGS}>
{(c) => (
<ListItemButton
disabled={typeof code() === "undefined"}
onClick={[onCodeChange, c]}
>
<ListItemText>{t(`lang.${c}`)}</ListItemText>
<ListItemSecondaryAction>
<Radio
checked={code() === c || (code() === undefined && matchedLangCode() == c)}
/>
</ListItemSecondaryAction>
</ListItemButton>
)}
</For>
</List>
</Scaffold>
</>
<List subheader={<ListSubheader>{t("Unsupported")}</ListSubheader>}>
<For each={unsupportedLangCodes()}>
{(code) => (
<ListItem>
<ListItemText>{iso639_1.getNativeName(code)}</ListItemText>
</ListItem>
)}
</For>
</List>
</List>
</Scaffold>
);
};

View file

@ -1,35 +0,0 @@
import { SvgIcon } from "@suid/material";
export default function () {
return (
<SvgIcon
width="75"
height="79"
viewBox="0 0 75 79"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M73.8393 17.4898C72.6973 9.00165 65.2994 2.31235 56.5296 1.01614C55.05 0.797115 49.4441 0 36.4582 0H36.3612C23.3717 0 20.585 0.797115 19.1054 1.01614C10.5798 2.27644 2.79399 8.28712 0.904997 16.8758C-0.00358524 21.1056 -0.100549 25.7949 0.0682394 30.0965C0.308852 36.2651 0.355538 42.423 0.91577 48.5665C1.30307 52.6474 1.97872 56.6957 2.93763 60.6812C4.73325 68.042 12.0019 74.1676 19.1233 76.6666C26.7478 79.2728 34.9474 79.7055 42.8039 77.9162C43.6682 77.7151 44.5217 77.4817 45.3645 77.216C47.275 76.6092 49.5123 75.9305 51.1571 74.7385C51.1797 74.7217 51.1982 74.7001 51.2112 74.6753C51.2243 74.6504 51.2316 74.6229 51.2325 74.5948V68.6416C51.2321 68.6154 51.2259 68.5896 51.2142 68.5661C51.2025 68.5426 51.1858 68.522 51.1651 68.5058C51.1444 68.4896 51.1204 68.4783 51.0948 68.4726C51.0692 68.4669 51.0426 68.467 51.0171 68.4729C45.9835 69.675 40.8254 70.2777 35.6502 70.2682C26.7439 70.2682 24.3486 66.042 23.6626 64.2826C23.1113 62.762 22.7612 61.1759 22.6212 59.5646C22.6197 59.5375 22.6247 59.5105 22.6357 59.4857C22.6466 59.4609 22.6633 59.4391 22.6843 59.422C22.7053 59.4048 22.73 59.3929 22.7565 59.3871C22.783 59.3813 22.8104 59.3818 22.8367 59.3886C27.7864 60.5826 32.8604 61.1853 37.9522 61.1839C39.1768 61.1839 40.3978 61.1839 41.6224 61.1516C46.7435 61.008 52.1411 60.7459 57.1796 59.7621C57.3053 59.7369 57.431 59.7154 57.5387 59.6831C65.4861 58.157 73.0493 53.3672 73.8178 41.2381C73.8465 40.7606 73.9184 36.2364 73.9184 35.7409C73.9219 34.0569 74.4606 23.7949 73.8393 17.4898Z"
fill="url(#paint0_linear_549_34)"
/>
<path
d="M61.2484 27.0263V48.114H52.8916V27.6475C52.8916 23.3388 51.096 21.1413 47.4437 21.1413C43.4287 21.1413 41.4177 23.7409 41.4177 28.8755V40.0782H33.1111V28.8755C33.1111 23.7409 31.0965 21.1413 27.0815 21.1413C23.4507 21.1413 21.6371 23.3388 21.6371 27.6475V48.114H13.2839V27.0263C13.2839 22.7176 14.384 19.2946 16.5843 16.7572C18.8539 14.2258 21.8311 12.926 25.5264 12.926C29.8036 12.926 33.0357 14.5705 35.1905 17.8559L37.2698 21.346L39.3527 17.8559C41.5074 14.5705 44.7395 12.926 49.0095 12.926C52.7013 12.926 55.6784 14.2258 57.9553 16.7572C60.1531 19.2922 61.2508 22.7152 61.2484 27.0263Z"
fill="white"
/>
<defs>
<linearGradient
id="paint0_linear_549_34"
x1="37.0692"
y1="0"
x2="37.0692"
y2="79"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#6364FF" />
<stop offset="1" stop-color="#563ACC" />
</linearGradient>
</defs>
</SvgIcon>
);
}

View file

@ -1,5 +1,5 @@
import type { Component } from "solid-js";
import Scaffold from "~material/Scaffold";
import Scaffold from "../material/Scaffold";
import {
AppBar,
Divider,
@ -12,17 +12,15 @@ import {
Switch,
Toolbar,
} 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 { createTranslator } from "~platform/i18n";
import { createTranslator } from "../platform/i18n";
import { useStore } from "@nanostores/solid";
import { $settings } from "./stores";
import { useNavigator } from "~platform/StackedRouter";
import AppTopBar from "~material/AppTopBar";
import DocumentTitle from "~platform/DocumentTitle";
const Motions: Component = () => {
const { pop } = useNavigator();
const navigate = useNavigate();
const [t] = createTranslator(
(code) =>
import(`./i18n/${code}.json`) as Promise<{
@ -31,58 +29,60 @@ const Motions: Component = () => {
);
const settings = useStore($settings);
return (
<>
<DocumentTitle>{t("motions")}</DocumentTitle>
<Scaffold
topbar={
<AppTopBar>
<IconButton color="inherit" onClick={[pop, 1]} disableRipple>
<Scaffold
topbar={
<AppBar position="static">
<Toolbar
variant="dense"
sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }}
>
<IconButton color="inherit" onClick={[navigate, -1]} disableRipple>
<ArrowBack />
</IconButton>
<Title>{t("motions")}</Title>
</AppTopBar>
}
</Toolbar>
</AppBar>
}
>
<List
sx={{
paddingBottom: "calc(var(--safe-area-inset-bottom, 0px) + 16px)",
}}
>
<List
sx={{
paddingBottom: "calc(var(--safe-area-inset-bottom, 0px) + 16px)",
}}
>
<li>
<ul style={{ "padding-left": 0 }}>
<ListSubheader>{t("motions.gifs")}</ListSubheader>
<ListItemButton
onClick={() =>
$settings.setKey("autoPlayGIFs", !settings().autoPlayGIFs)
}
>
<ListItemText>{t("motions.gifs.autoplay")}</ListItemText>
<ListItemSecondaryAction>
<Switch checked={settings().autoPlayGIFs}></Switch>
</ListItemSecondaryAction>
</ListItemButton>
<Divider />
</ul>
</li>
<li>
<ul style={{ "padding-left": 0 }}>
<ListSubheader>{t("motions.vids")}</ListSubheader>
<ListItemButton
onClick={() =>
$settings.setKey("autoPlayVideos", !settings().autoPlayVideos)
}
>
<ListItemText>{t("motions.vids.autoplay")}</ListItemText>
<ListItemSecondaryAction>
<Switch checked={settings().autoPlayVideos}></Switch>
</ListItemSecondaryAction>
</ListItemButton>
<Divider />
</ul>
</li>
</List>
</Scaffold>
</>
<li>
<ul style={{ "padding-left": 0 }}>
<ListSubheader>{t("motions.gifs")}</ListSubheader>
<ListItemButton
onClick={() =>
$settings.setKey("autoPlayGIFs", !settings().autoPlayGIFs)
}
>
<ListItemText>{t("motions.gifs.autoplay")}</ListItemText>
<ListItemSecondaryAction>
<Switch checked={settings().autoPlayGIFs}></Switch>
</ListItemSecondaryAction>
</ListItemButton>
<Divider />
</ul>
</li>
<li>
<ul style={{ "padding-left": 0 }}>
<ListSubheader>{t("motions.vids")}</ListSubheader>
<ListItemButton
onClick={() =>
$settings.setKey("autoPlayVideos", !settings().autoPlayVideos)
}
>
<ListItemText>{t("motions.vids.autoplay")}</ListItemText>
<ListItemSecondaryAction>
<Switch checked={settings().autoPlayVideos}></Switch>
</ListItemSecondaryAction>
</ListItemButton>
<Divider />
</ul>
</li>
</List>
</Scaffold>
);
};

View file

@ -1,5 +1,5 @@
import { createMemo, For, type Component, type JSX } from "solid-js";
import Scaffold from "~material/Scaffold";
import Scaffold from "../material/Scaffold";
import {
AppBar,
IconButton,
@ -17,19 +17,17 @@ import {
autoMatchRegion,
createTranslator,
SUPPORTED_REGIONS,
} from "~platform/i18n";
import { Title } from "~material/typography";
} from "../platform/i18n";
import { Title } from "../material/typography";
import type { Template } from "@solid-primitives/i18n";
import { useNavigate } from "@solidjs/router";
import { $settings } from "./stores";
import { useStore } from "@nanostores/solid";
import { useNavigator } from "~platform/StackedRouter";
import AppTopBar from "~material/AppTopBar";
import DocumentTitle from "~platform/DocumentTitle";
const ChooseRegion: Component = () => {
const { pop } = useNavigator();
const navigate = useNavigate();
const [t] = createTranslator(
() => import("./i18n/generic.json"),
() => import("./i18n/lang-names.json"),
(code) =>
import(`./i18n/${code}.json`) as Promise<{
default: Record<string, string | undefined> & {
@ -49,62 +47,64 @@ const ChooseRegion: Component = () => {
};
return (
<>
<DocumentTitle>{t("Choose Region")}</DocumentTitle>
<Scaffold
topbar={
<AppTopBar>
<IconButton color="inherit" onClick={[pop, 1]} disableRipple>
<Scaffold
topbar={
<AppBar position="static">
<Toolbar
variant="dense"
sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }}
>
<IconButton color="inherit" onClick={[navigate, -1]} disableRipple>
<ArrowBack />
</IconButton>
<Title>{t("Choose Region")}</Title>
</AppTopBar>
}
<Title>{t("Choose Language")}</Title>
</Toolbar>
</AppBar>
}
>
<List
sx={{
paddingBottom: "var(--safe-area-inset-bottom, 0)",
}}
>
<List
sx={{
paddingBottom: "var(--safe-area-inset-bottom, 0)",
<ListItemButton
onClick={() => {
onCodeChange(region() ? undefined : matchedRegionCode());
}}
>
<ListItemButton
onClick={() => {
onCodeChange(region() ? undefined : matchedRegionCode());
}}
>
<ListItemText>
{t("region.auto", {
detected:
t(`region.${matchedRegionCode()}`) ?? matchedRegionCode(),
})}
</ListItemText>
<ListItemSecondaryAction>
<Switch checked={typeof region() === "undefined"} />
</ListItemSecondaryAction>
</ListItemButton>
<ListItemText>
{t("region.auto", {
detected:
t(`region.${matchedRegionCode()}`) ?? matchedRegionCode(),
})}
</ListItemText>
<ListItemSecondaryAction>
<Switch checked={typeof region() === "undefined"} />
</ListItemSecondaryAction>
</ListItemButton>
<List subheader={<ListSubheader>{t("Supported")}</ListSubheader>}>
<For each={SUPPORTED_REGIONS}>
{(code) => (
<ListItemButton
disabled={typeof region() === "undefined"}
onClick={[onCodeChange, code]}
>
<ListItemText>{t(`region.${code}`)}</ListItemText>
<ListItemSecondaryAction>
<Radio
checked={
region() === code ||
(region() === undefined && matchedRegionCode() == code)
}
/>
</ListItemSecondaryAction>
</ListItemButton>
)}
</For>
</List>
<List subheader={<ListSubheader>{t("Supported")}</ListSubheader>}>
<For each={SUPPORTED_REGIONS}>
{(code) => (
<ListItemButton
disabled={typeof region() === "undefined"}
onClick={[onCodeChange, code]}
>
<ListItemText>{t(`region.${code}`)}</ListItemText>
<ListItemSecondaryAction>
<Radio
checked={
region() === code ||
(region() === undefined && matchedRegionCode() == code)
}
/>
</ListItemSecondaryAction>
</ListItemButton>
)}
</For>
</List>
</Scaffold>
</>
</List>
</Scaffold>
);
};

View file

@ -1,6 +1,13 @@
import { For, Show, type Component } from "solid-js";
import Scaffold from "~material/Scaffold.js";
import {
children,
createSignal,
For,
Show,
type ParentComponent,
} from "solid-js";
import Scaffold from "../material/Scaffold.js";
import {
AppBar,
Divider,
IconButton,
List,
@ -10,181 +17,62 @@ import {
ListItemSecondaryAction,
ListItemText,
ListSubheader,
NativeSelect,
Switch,
Toolbar,
} from "@suid/material";
import {
Animation as AnimationIcon,
Close as CloseIcon,
DeleteForever,
Logout,
Public as PublicIcon,
Refresh as RefreshIcon,
Translate as TranslateIcon,
} from "@suid/icons-material";
import A from "~platform/A.js";
import { Title } from "~material/typography.js";
import { A, useNavigate } from "@solidjs/router";
import { Title } from "../material/typography.jsx";
import { css } from "solid-styled";
import { useSignedInProfiles } from "../masto/acct.js";
import { signOut, type Account } from "../accounts/stores.js";
import { format } from "date-fns";
import { useStore } from "@nanostores/solid";
import { $settings } from "./stores.js";
import { useRegisterSW } from "virtual:pwa-register/solid";
import {
autoMatchLangTag,
autoMatchRegion,
createTranslator,
useDateFnLocale,
} from "~platform/i18n.jsx";
} from "../platform/i18n.jsx";
import { type Template } from "@solid-primitives/i18n";
import { useServiceWorker } from "~platform/host.js";
import { makeAcctText, useSessions } from "../masto/clients.js";
import { useNavigator } from "~platform/StackedRouter.jsx";
import AppTopBar from "~material/AppTopBar.jsx";
import MastodonLogo from "./MastodonLogo.jsx";
import DocumentTitle from "~platform/DocumentTitle.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();
}
}
const $$SAFE_AREA_EMU = "$$SAFE_AREA_EMU";
if (import.meta.hot) {
import.meta.hot.on("vite:beforeUpdate", () => {
import.meta.hot!.data[$$SAFE_AREA_EMU] = screenOrientationCallback;
});
import.meta.hot.on("vite:afterUpdate", () => {
screenOrientationCallback = import.meta.hot?.data?.[$$SAFE_AREA_EMU];
if (screenOrientationCallback) {
setTimeout(screenOrientationCallback, 0);
}
});
}
import BottomSheet from "../material/BottomSheet.jsx";
type Strings = {
["lang.auto"]: Template<{ detected: string }>;
} & Record<string, string | undefined>;
const Settings: Component = () => {
const Settings: ParentComponent = (props) => {
const [t] = createTranslator(
(code) =>
import(`./i18n/${code}.json`) as Promise<{
default: Strings;
}>,
() => import(`./i18n/generic.json`),
() => import(`./i18n/lang-names.json`),
);
const { pop, push } = useNavigator();
const navigate = useNavigate();
const settings$ = useStore($settings);
const { needRefresh } = useServiceWorker();
const {
needRefresh: [needRefresh],
} = useRegisterSW();
const dateFnLocale = useDateFnLocale();
const profiles = useSessions();
const [profiles] = useSignedInProfiles();
const doSignOut = (acct: Account) => {
signOut((a) => a.site === acct.site && a.accessToken === acct.accessToken);
if (profiles().length == 0) {
push("/accounts/sign-in", { replace: "all" });
}
};
const subpage = children(() => props.children);
css`
ul {
padding: 0;
@ -192,234 +80,165 @@ const Settings: Component = () => {
.setting-list {
padding-bottom: calc(var(--safe-area-inset-bottom, 0px) + 16px);
overflow: hidden auto;
height: calc(100vh - var(--scaffold-topbar-height, 0));
height: calc(100dvh - var(--scaffold-topbar-height, 0));
}
`;
return (
<>
<DocumentTitle>{t("Settings")}</DocumentTitle>
<Scaffold
topbar={
<AppTopBar>
<IconButton color="inherit" onClick={[pop, 1]} disableRipple>
<Scaffold
topbar={
<AppBar position="static">
<Toolbar
variant="dense"
sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }}
>
<IconButton color="inherit" onClick={[navigate, -1]} disableRipple>
<CloseIcon />
</IconButton>
<Title>{t("Settings")}</Title>
</AppTopBar>
}
>
<List class="setting-list" use:solid-styled>
<li>
<ul>
<ListSubheader>{t("Accounts")}</ListSubheader>
<ListItemButton disabled>
<ListItemText>{t("All Notifications")}</ListItemText>
<ListItemSecondaryAction>
<Switch value={false} disabled />
</ListItemSecondaryAction>
</ListItemButton>
<Divider />
<ListItemButton disabled>
<ListItemText>{t("Sign in...")}</ListItemText>
</ListItemButton>
<Divider />
</ul>
<For each={profiles()}>
{({ account: acct }) => (
<ul data-site={acct.site} data-username={acct.inf?.username}>
<ListSubheader>{`@${acct.inf?.username ?? "..."}@${new URL(acct.site).host}`}</ListSubheader>
<ListItemButton disabled>
<ListItemText>{t("Notifications")}</ListItemText>
<ListItemSecondaryAction>
<Switch value={false} disabled />
</ListItemSecondaryAction>
</ListItemButton>
<Divider />
<ListItemButton onClick={[doSignOut, acct]}>
<ListItemIcon>
<Logout />
</ListItemIcon>
<ListItemText>{t("Sign out")}</ListItemText>
</ListItemButton>
<Divider />
</ul>
)}
</For>
</li>
<li>
<ListSubheader>{t("timelines")}</ListSubheader>
<ListItemButton
onClick={(e) =>
$settings.setKey(
"prefetchTootsDisabled",
!settings$().prefetchTootsDisabled,
)
}
>
<ListItemText secondary={t("Prefetch Toots.2nd")}>
{t("Prefetch Toots")}
</ListItemText>
</Toolbar>
</AppBar>
}
>
<BottomSheet open={!!subpage()} onClose={() => navigate(-1)}>
{subpage()}
</BottomSheet>
<List class="setting-list" use:solid-styled>
<li>
<ul>
<ListSubheader>{t("Accounts")}</ListSubheader>
<ListItemButton disabled>
<ListItemText>{t("All Notifications")}</ListItemText>
<ListItemSecondaryAction>
<Switch checked={!settings$().prefetchTootsDisabled} />
<Switch value={false} disabled />
</ListItemSecondaryAction>
</ListItemButton>
<Divider />
<ListItemButton component={A} href="./motions">
<ListItemIcon>
<AnimationIcon></AnimationIcon>
</ListItemIcon>
<ListItemText>{t("motions")}</ListItemText>
</ListItemButton>
<Divider />
</li>
<li>
<ListSubheader>{t("storage")}</ListSubheader>
<ListItemButton disabled>
<ListItemIcon>
<DeleteForever />
</ListItemIcon>
<ListItemText secondary={t("storage.cache.UA-managed")}>
{t("storage.cache.clear")}
</ListItemText>
</ListItemButton>
</li>
<li>
<ListSubheader>{t("This Application")}</ListSubheader>
<ListItemButton component={A} href="./language">
<ListItemIcon>
<TranslateIcon />
</ListItemIcon>
<ListItemText
secondary={
settings$().language === undefined
? t("lang.auto", {
detected:
t("lang." + autoMatchLangTag()) ?? autoMatchLangTag(),
})
: t("lang." + settings$().language)
}
>
{t("Language")}
</ListItemText>
<ListItemText>{t("Sign in...")}</ListItemText>
</ListItemButton>
<Divider />
<ListItemButton component={A} href="./region">
<ListItemIcon>
<PublicIcon />
</ListItemIcon>
<ListItemText
secondary={
settings$().region === undefined
? t("region.auto", {
detected:
t("region." + autoMatchRegion()) ?? autoMatchRegion(),
})
: t("region." + settings$().region)
}
>
{t("Region")}
</ListItemText>
</ListItemButton>
<Divider />
<ListItem
secondaryAction={
<IconButton
component={A}
aria-label={t("mastodonlink.open")}
href={`/${encodeURIComponent(profiles().length > 0 ? makeAcctText(profiles()[0]) : "@")}/profile/@tutu@indieweb.social`}
>
<MastodonLogo />
</IconButton>
</ul>
<For each={profiles()}>
{({ account: acct, inf }) => (
<ul data-site={acct.site} data-username={inf?.username}>
<ListSubheader>{`@${inf?.username ?? "..."}@${new URL(acct.site).host}`}</ListSubheader>
<ListItemButton disabled>
<ListItemText>{t("Notifications")}</ListItemText>
<ListItemSecondaryAction>
<Switch value={false} disabled />
</ListItemSecondaryAction>
</ListItemButton>
<Divider />
<ListItemButton onClick={[doSignOut, acct]}>
<ListItemIcon>
<Logout />
</ListItemIcon>
<ListItemText>{t("Sign out")}</ListItemText>
</ListItemButton>
<Divider />
</ul>
)}
</For>
</li>
<li>
<ListSubheader>{t("timelines")}</ListSubheader>
<ListItemButton
onClick={(e) =>
$settings.setKey(
"prefetchTootsDisabled",
!settings$().prefetchTootsDisabled,
)
}
>
<ListItemText secondary={t("Prefetch Toots.2nd")}>
{t("Prefetch Toots")}
</ListItemText>
<ListItemSecondaryAction>
<Switch checked={!settings$().prefetchTootsDisabled} />
</ListItemSecondaryAction>
</ListItemButton>
<Divider />
<ListItemButton component={A} href="./motions">
<ListItemIcon>
<AnimationIcon></AnimationIcon>
</ListItemIcon>
<ListItemText>{t("motions")}</ListItemText>
</ListItemButton>
<Divider />
</li>
<li>
<ListSubheader>{t("This Application")}</ListSubheader>
<ListItemButton component={A} href="./language">
<ListItemIcon>
<TranslateIcon />
</ListItemIcon>
<ListItemText
secondary={
settings$().language === undefined
? t("lang.auto", {
detected:
t("lang." + autoMatchLangTag()) ?? autoMatchLangTag(),
})
: t("lang." + settings$().language)
}
>
<ListItemText secondary={t("About Tutu.2nd")}>
{t("About Tutu")}
</ListItemText>
</ListItem>
<Divider />
<ListItem>
<ListItemText
secondary={t("version", {
packageVersion: import.meta.env.PACKAGE_VERSION,
builtAt: format(
import.meta.env.BUILT_AT,
t("datefmt") || "yyyy/MM/dd",
{ locale: dateFnLocale() },
),
buildMode: import.meta.env.MODE,
})}
>
{needRefresh() ? t("updates.ready") : t("updates.no")}
</ListItemText>
<Show when={needRefresh()}>
<ListItemSecondaryAction>
<IconButton
aria-label="Restart Now"
onClick={() => window.location.reload()}
>
<RefreshIcon />
</IconButton>
</ListItemSecondaryAction>
</Show>
</ListItem>
<Divider />
{import.meta.env.VITE_CODE_VERSION ? (
<>
<ListItem>
<ListItemText secondary={import.meta.env.VITE_CODE_VERSION}>
{t("version.code")}
</ListItemText>
</ListItem>
<Divider />
</>
) : (
<></>
)}
</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"
}
{t("Language")}
</ListItemText>
</ListItemButton>
<Divider />
<ListItemButton component={A} href="./region">
<ListItemIcon>
<PublicIcon />
</ListItemIcon>
<ListItemText
secondary={
settings$().region === undefined
? t("region.auto", {
detected:
t("region." + autoMatchRegion()) ?? autoMatchRegion(),
})
: t("region." + settings$().region)
}
>
{t("Region")}
</ListItemText>
</ListItemButton>
<Divider />
<ListItem>
<ListItemText secondary={t("About Tutu.2nd")}>
{t("About Tutu")}
</ListItemText>
</ListItem>
<Divider />
<ListItem>
<ListItemText
secondary={t("version", {
packageVersion: import.meta.env.PACKAGE_VERSION,
builtAt: format(
import.meta.env.BUILT_AT,
t("datefmt") || "yyyy/MM/dd",
{ locale: dateFnLocale() },
),
buildMode: import.meta.env.MODE,
})}
>
{needRefresh() ? t("updates.ready") : t("updates.no")}
</ListItemText>
<Show when={needRefresh()}>
<ListItemSecondaryAction>
<IconButton
aria-label="Restart Now"
onClick={() => window.location.reload()}
>
Safe Area Insets
</ListItemText>
</ListItem>
<Divider />
</li>
) : (
<></>
)}
</List>
</Scaffold>
</>
<RefreshIcon />
</IconButton>
</ListItemSecondaryAction>
</Show>
</ListItem>
<Divider />
</li>
</List>
</Scaffold>
);
};

View file

@ -12,7 +12,6 @@
"updates.ready": "An update is ready, restart the Tutu to apply",
"updates.no": "No updates",
"version": "Using v{{packageVersion}} (built on {{builtAt}}, {{buildMode}})",
"version.code": "Code Version",
"Language": "Language",
"Region": "Region",
"lang.auto": "(Auto) {{detected}}",
@ -33,11 +32,5 @@
"motions.gifs": "GIFs",
"motions.gifs.autoplay": "Auto-play GIFs",
"motions.vids": "Videos",
"motions.vids.autoplay": "Auto-play Videos",
"storage": "Storage",
"storage.cache.clear": "Clear Cache",
"storage.cache.UA-managed": "Cache is managed by your browser.",
"mastodonlink.open": "Open Tutu's Mastodon"
"motions.vids.autoplay": "Auto-play Videos"
}

View file

@ -12,7 +12,6 @@
"updates.ready": "更新已准备好,下次开启会启动新版本",
"updates.no": "已是最新版本",
"version": "正在使用 v{{packageVersion}} ({{builtAt}}构建, {{buildMode}})",
"version.code": "代码版本",
"Language": "语言",
"Region": "区域",
"lang.auto": "(自动){{detected}}",
@ -33,11 +32,5 @@
"motions.gifs": "动图",
"motions.gifs.autoplay": "自动播放动图",
"motions.vids": "视频",
"motions.vids.autoplay": "自动播放视频",
"storage": "存储空间",
"storage.cache.clear": "清除缓存",
"storage.cache.UA-managed": "缓存由你的浏览器管理。",
"mastodonlink.open": "打开图图的Mastodon账户"
"motions.vids.autoplay": "自动播放视频"
}

View file

@ -1,5 +1,10 @@
import { For, onMount, type Component, type JSX } from "solid-js";
import Scaffold from "~material/Scaffold";
import {
For,
onMount,
type Component,
type JSX,
} from "solid-js";
import Scaffold from "../material/Scaffold";
import {
AppBar,
IconButton,
@ -12,9 +17,8 @@ import {
} from "@suid/material";
import { Close as CloseIcon } from "@suid/icons-material";
import iso639_1 from "iso-639-1";
import { createTranslator } from "~platform/i18n";
import { Title } from "~material/typography";
import "./TootLangPicker.css";
import { createTranslator } from "../platform/i18n";
import { Title } from "../material/typography";
type ChooseTootLangProps = {
code: string;
@ -23,7 +27,7 @@ type ChooseTootLangProps = {
};
const ChooseTootLang: Component<ChooseTootLangProps> = (props) => {
let listRef!: HTMLUListElement;
let listRef: HTMLUListElement;
const [t] = createTranslator(
(code) =>
import(`./i18n/${code}.json`) as Promise<{
@ -54,7 +58,6 @@ const ChooseTootLang: Component<ChooseTootLangProps> = (props) => {
</Toolbar>
</AppBar>
}
class="TootLangPicker"
>
<List
ref={listRef!}

View file

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

View file

@ -1,71 +1,76 @@
import {
createSignal,
Show,
onMount,
type ParentComponent,
createEffect,
useTransition,
children,
Suspense,
} from "solid-js";
import Scaffold from "~material/Scaffold";
import { useDocumentTitle } from "../utils";
import { type mastodon } from "masto";
import Scaffold from "../material/Scaffold";
import {
AppBar,
ListItemSecondaryAction,
ListItemText,
MenuItem,
Switch,
Toolbar,
} from "@suid/material";
import { css } from "solid-styled";
import { TimeSourceProvider, createTimeSource } from "~platform/timesrc";
import { TimeSourceProvider, createTimeSource } from "../platform/timesrc";
import ProfileMenuButton from "./ProfileMenuButton";
import Tabs from "~material/Tabs";
import Tab from "~material/Tab";
import Tabs from "../material/Tabs";
import Tab from "../material/Tab";
import { makeEventListener } from "@solid-primitives/event-listener";
import BottomSheet, {
HERO as BOTTOM_SHEET_HERO,
} from "../material/BottomSheet";
import { $settings } from "../settings/stores";
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 TimelinePanel from "./TimelinePanel";
import { useSessions } from "../masto/clients";
import {
createSingluarItemSelection,
default as ItemSelectionProvider,
} from "./toots/ItemSelectionProvider";
import AppTopBar from "~material/AppTopBar";
import { createTranslator } from "~platform/i18n";
import { useWindowSize } from "@solid-primitives/resize-observer";
import DocumentTitle from "~platform/DocumentTitle";
type StringRes = Record<
"tabs.home" | "tabs.trending" | "tabs.public" | "set.prefetch-toots",
string
>;
const Home: ParentComponent = (props) => {
let panelList: HTMLDivElement;
const [t, [stringRes]] = createTranslator(
(code) => import(`./i18n/${code}.json`) as Promise<{ default: StringRes }>,
);
useDocumentTitle("Timelines");
const now = createTimeSource();
const [, selectionState] = createSingluarItemSelection(
undefined as string | undefined,
);
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 all = profiles();
return all?.[0]?.client;
};
const navigate = useNavigate();
const [heroSrc, setHeroSrc] = createSignal<HeroSource>({});
const [panelOffset, setPanelOffset] = createSignal(0);
const prefetching = () => !settings$().prefetchTootsDisabled;
const [currentFocusOn, setCurrentFocusOn] = createSignal<HTMLElement[]>([]);
const [focusRange, setFocusRange] = createSignal([0, 0] as readonly [
number,
number,
]);
const child = children(() => props.children);
let scrollEventLockReleased = true;
const recalculateTabIndicator = () => {
scrollEventLockReleased = false;
try {
if (!panelList!) return;
const { x: panelX, width: panelWidth } =
panelList.getBoundingClientRect();
let minIdx = +Infinity,
@ -97,23 +102,18 @@ const Home: ParentComponent = (props) => {
}
};
const requestRecalculateTabIndicator = () => {
if (scrollEventLockReleased) {
requestAnimationFrame(recalculateTabIndicator);
}
};
const windowSize = useWindowSize();
createEffect((last) => {
const { width } = windowSize;
if (last !== width) {
requestRecalculateTabIndicator();
}
return width;
});
createEffect(() => {
requestRecalculateTabIndicator();
onMount(() => {
makeEventListener(panelList, "scroll", () => {
if (scrollEventLockReleased) {
requestAnimationFrame(recalculateTabIndicator);
}
});
makeEventListener(window, "resize", () => {
if (scrollEventLockReleased) {
requestAnimationFrame(recalculateTabIndicator);
}
});
requestAnimationFrame(recalculateTabIndicator);
});
const isTabFocus = (idx: number) => {
@ -123,7 +123,7 @@ const Home: ParentComponent = (props) => {
};
const onTabClick = (idx: number) => {
const items = panelList!.querySelectorAll(".tab-panel");
const items = panelList.querySelectorAll(".tab-panel");
if (items.length > idx) {
items.item(idx).scrollIntoView({ block: "start", behavior: "smooth" });
}
@ -135,6 +135,31 @@ 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`
.tab-panel {
overflow: visible auto;
@ -143,9 +168,6 @@ const Home: ParentComponent = (props) => {
padding: 0 16px;
scroll-snap-align: center;
overscroll-behavior-block: none;
contain: strict;
contain-intrinsic-size: auto 560px auto 100vh;
contain-intrinsic-size: auto 560px auto 100dvh;
@media (max-width: 600px) {
padding: 0;
@ -156,7 +178,7 @@ const Home: ParentComponent = (props) => {
display: grid;
grid-auto-columns: 560px;
grid-auto-flow: column;
overflow: auto hidden;
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-snap-stop: always;
height: calc(100vh - var(--scaffold-topbar-height, 0px));
@ -164,10 +186,6 @@ const Home: ParentComponent = (props) => {
padding-left: var(--safe-area-inset-left, 0);
padding-right: var(--safe-area-inset-right, 0);
& > * {
content-visibility: auto;
}
@media (max-width: 600px) {
grid-auto-columns: 100%;
}
@ -176,75 +194,87 @@ const Home: ParentComponent = (props) => {
return (
<>
<DocumentTitle>Timelines</DocumentTitle>
<Scaffold
topbar={
<AppTopBar>
<Tabs>
<Tab focus={isTabFocus(0)} onClick={[onTabClick, 0]}>
{t("tabs.home")}
</Tab>
<Tab focus={isTabFocus(1)} onClick={[onTabClick, 1]}>
{t("tabs.trending")}
</Tab>
<Tab focus={isTabFocus(2)} onClick={[onTabClick, 2]}>
{t("tabs.public")}
</Tab>
</Tabs>
<ProfileMenuButton profile={profiles()[0]}>
<MenuItem
onClick={(e) =>
$settings.setKey(
"prefetchTootsDisabled",
!$settings.get().prefetchTootsDisabled,
)
}
>
<ListItemText>{t("set.prefetch-toots")}</ListItemText>
<ListItemSecondaryAction>
<Switch checked={prefetching()}></Switch>
</ListItemSecondaryAction>
</MenuItem>
</ProfileMenuButton>
</AppTopBar>
<AppBar position="static">
<Toolbar
variant="dense"
class="responsive"
sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }}
>
<Tabs onFocusChanged={setCurrentFocusOn} offset={panelOffset()}>
<Tab focus={isTabFocus(0)} onClick={[onTabClick, 0]}>
Home
</Tab>
<Tab focus={isTabFocus(1)} onClick={[onTabClick, 1]}>
Trending
</Tab>
<Tab focus={isTabFocus(2)} onClick={[onTabClick, 2]}>
Public
</Tab>
</Tabs>
<ProfileMenuButton profile={profile()}>
<MenuItem
onClick={(e) =>
$settings.setKey(
"prefetchTootsDisabled",
!$settings.get().prefetchTootsDisabled,
)
}
>
<ListItemText>Prefetch Toots</ListItemText>
<ListItemSecondaryAction>
<Switch checked={prefetching()}></Switch>
</ListItemSecondaryAction>
</MenuItem>
</ProfileMenuButton>
</Toolbar>
</AppBar>
}
>
<ItemSelectionProvider value={selectionState}>
<TimeSourceProvider value={now}>
<Show when={!!client()}>
<div
class="panel-list"
ref={panelList!}
onScroll={requestRecalculateTabIndicator}
>
<div class="tab-panel">
<div>
<TimelinePanel
client={client()}
name="home"
prefetch={prefetching()}
/>
</div>
<TimeSourceProvider value={now}>
<Show when={!!client()}>
<div class="panel-list" ref={panelList!}>
<div class="tab-panel">
<div>
<TimelinePanel
client={client()}
name="home"
prefetch={prefetching()}
openFullScreenToot={openFullScreenToot}
/>
</div>
<div class="tab-panel">
<div>
<TrendTimelinePanel client={client()} />
</div>
</div>
<div class="tab-panel">
<div>
<TimelinePanel
client={client()}
name="public"
prefetch={prefetching()}
/>
</div>
</div>
<div></div>
</div>
</Show>
</TimeSourceProvider>
</ItemSelectionProvider>
<div class="tab-panel">
<div>
<TrendTimelinePanel
client={client()}
prefetch={prefetching()}
openFullScreenToot={openFullScreenToot}
/>
</div>
</div>
<div class="tab-panel">
<div>
<TimelinePanel
client={client()}
name="public"
prefetch={prefetching()}
openFullScreenToot={openFullScreenToot}
/>
</div>
</div>
<div></div>
</div>
</Show>
</TimeSourceProvider>
<Suspense>
<HeroSourceProvider value={[heroSrc, setHeroSrc]}>
<BottomSheet open={!!child()} onClose={() => navigate(-1)}>
{child()}
</BottomSheet>
</HeroSourceProvider>
</Suspense>
</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

@ -38,7 +38,7 @@ function clamp(input: number, min: number, max: number) {
}
const MediaViewer: ParentComponent<MediaViewerProps> = (props) => {
let rootRef!: HTMLDialogElement;
let rootRef: HTMLDialogElement;
type State = {
ref?: HTMLElement;

View file

@ -5,121 +5,122 @@ import {
ListItemAvatar,
ListItemIcon,
ListItemText,
Menu,
MenuItem,
} from "@suid/material";
import { Show, createUniqueId, type ParentComponent } from "solid-js";
import {
ErrorBoundary,
Show,
createSignal,
createUniqueId,
type ParentComponent,
} from "solid-js";
import {
Settings as SettingsIcon,
Bookmark as BookmarkIcon,
Star as LikeIcon,
FeaturedPlayList as ListIcon,
} from "@suid/icons-material";
import A from "~platform/A";
import Menu, { createManagedMenuState } from "~material/Menu";
import { createTranslator } from "~platform/i18n";
type StringRes = Record<
"nav.bookmarks" | "nav.likes" | "nav.lists" | "nav.settings",
string
>;
import { A } from "@solidjs/router";
const ProfileMenuButton: ParentComponent<{
profile?: {
account: {
site: string;
inf?: {
displayName: string;
avatar: string;
username: string;
id: string;
};
};
};
profile?: { displayName: string; avatar: string; username: string };
onClick?: () => void;
onClose?: () => void;
}> = (props) => {
const menuId = createUniqueId();
const buttonId = createUniqueId();
const [t] = createTranslator(
async (code) =>
(await import(`./i18n/${code}.json`)) as { default: StringRes },
);
const [open, state] = createManagedMenuState();
let [anchor, setAnchor] = createSignal<HTMLButtonElement | null>(null);
const open = () => !!anchor();
const onClick = (event: { currentTarget: HTMLElement }) => {
open(event.currentTarget.getBoundingClientRect());
const onClick = (
event: MouseEvent & { currentTarget: HTMLButtonElement },
) => {
setAnchor(event.currentTarget);
props.onClick?.();
};
const inf = () => props.profile?.account.inf;
const onClose = () => {
props.onClick?.();
setAnchor(null);
};
return (
<>
<ButtonBase
aria-haspopup="true"
sx={{ borderRadius: "50%" }}
id={buttonId}
onClick={onClick}
aria-controls={state.open ? menuId : undefined}
aria-expanded={state.open ? "true" : "false"}
>
<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}
<ButtonBase
aria-haspopup="true"
sx={{ borderRadius: "50%" }}
id={buttonId}
onClick={onClick}
aria-controls={open() ? menuId : undefined}
aria-expanded={open() ? "true" : undefined}
>
<ListItemAvatar>
<Avatar src={inf()?.avatar}></Avatar>
</ListItemAvatar>
<ListItemText
primary={inf()?.displayName}
secondary={`@${inf()?.username}`}
></ListItemText>
</MenuItem>
<Avatar
alt={`${props.profile?.displayName}'s avatar`}
src={props.profile?.avatar}
></Avatar>
</ButtonBase>
<Menu
id={menuId}
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>
<ListItemIcon>
<BookmarkIcon />
</ListItemIcon>
<ListItemText>{t("nav.bookmarks")}</ListItemText>
</MenuItem>
<MenuItem disabled>
<ListItemIcon>
<LikeIcon />
</ListItemIcon>
<ListItemText>{t("nav.likes")}</ListItemText>
</MenuItem>
<MenuItem disabled>
<ListItemIcon>
<ListIcon />
</ListItemIcon>
<ListItemText>{t("nav.lists")}</ListItemText>
</MenuItem>
<Divider />
<Show when={props.children}>
{props.children}
<MenuItem>
<ListItemIcon>
<BookmarkIcon />
</ListItemIcon>
<ListItemText>Bookmarks</ListItemText>
</MenuItem>
<MenuItem>
<ListItemIcon>
<LikeIcon />
</ListItemIcon>
<ListItemText>Likes</ListItemText>
</MenuItem>
<MenuItem>
<ListItemIcon>
<ListIcon />
</ListItemIcon>
<ListItemText>Lists</ListItemText>
</MenuItem>
<Divider />
</Show>
<MenuItem component={A} href="/settings">
<ListItemIcon>
<SettingsIcon />
</ListItemIcon>
<ListItemText>{t("nav.settings")}</ListItemText>
</MenuItem>
</Menu>
<Show when={props.children}>
{props.children}
<Divider />
</Show>
<MenuItem component={A} href="/settings" onClick={onClose}>
<ListItemIcon>
<SettingsIcon />
</ListItemIcon>
<ListItemText>Settings</ListItemText>
</MenuItem>
</Menu>
</>
);
};

View file

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

View file

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

Some files were not shown because too many files have changed in this diff Show more