Rewrite timelines #25
99 changed files with 2018 additions and 5736 deletions
5
.env
5
.env
|
@ -1,5 +0,0 @@
|
|||
DEV_SERVER_HTTPS_CERT_BASE=
|
||||
DEV_SERVER_HTTPS_CERT_PASS=
|
||||
DEV_LOCATOR_EDITOR=vscode
|
||||
VITE_DEVTOOLS_OVERLAY=true
|
||||
VITE_PLATFROM_MASONRY_ALWAYS_COMPAT=
|
|
@ -31,11 +31,11 @@ jobs:
|
|||
run: bun install
|
||||
|
||||
- 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
1
.gitattributes
vendored
|
@ -1 +0,0 @@
|
|||
*.lockb binary diff=lockb
|
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -1,5 +1,3 @@
|
|||
node_modules
|
||||
dist/
|
||||
dev-dist/
|
||||
.env.local
|
||||
.env.*.local
|
||||
dev-dist/
|
10
README.md
10
README.md
|
@ -2,8 +2,6 @@
|
|||
|
||||
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:
|
||||
|
@ -14,12 +12,6 @@ The code is built against those targets and Tutu must run on those platforms:
|
|||
|
||||
Tutu trys to push the Web technology to its limit. Some features might not be available on the platform does not meet the requirement.
|
||||
|
||||
## The "Next" Branch
|
||||
|
||||
The "next" branch of the app is built on every commit pushed into "master". You can tatse latest change but risks your data.
|
||||
|
||||
[Launch Tutu (Next)](https://master.tututheapp.pages.dev)
|
||||
|
||||
## Build & Depoly
|
||||
|
||||
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
BIN
bun.lockb
Binary file not shown.
|
@ -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
|
||||
```
|
|
@ -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,50 +41,3 @@ export function updateAcctInf(idx: number) {
|
|||
```
|
||||
|
||||
Ja, the code is weird, but that's the best we know. Anyway, you need new object on the path of your changed value.
|
||||
|
||||
## `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.
|
||||
|
|
|
@ -1,26 +0,0 @@
|
|||
# Optimizing Tutu
|
||||
|
||||
Topic Index:
|
||||
|
||||
- Time to first byte
|
||||
- Time to first draw: [Load size](#load-size)
|
||||
- CLS
|
||||
- Framerate: [Algorithm](#algorithm)
|
||||
|
||||
## Load size
|
||||
|
||||
The baseline for the load size is lowest 3G download bandwidth in 2s, typically 1.1Mbps (or ~137 kilobytes/s) * 2s = 274 kilobytes.
|
||||
|
||||
In another words there is 274 kilobytes budget for an interaction without further notice. Notice and progress are needed if the interaction needs than that.
|
||||
|
||||
The service worker can use 1 chunk of size.
|
||||
|
||||
## Algorithm
|
||||
|
||||
Don't choose algorithm solely on the time complexity. GUI app needs smooth, not fast. The priority:
|
||||
|
||||
- On the main thread: batching. Batching is usually required to spread the work to multiple frames.
|
||||
- Think in Map-Reduce framework if you don't have any idea.
|
||||
- On the worker thread: balance the speed and the memory usage.
|
||||
- Arrays are usually faster and use less memory.
|
||||
- Worker is always available on our target platforms, but workers introduce latency in the starting and the communication.
|
|
@ -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;
|
46
package.json
46
package.json
|
@ -1,65 +1,55 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/package",
|
||||
"name": "tutu",
|
||||
"version": "1.1.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"
|
||||
"dist": "vite build"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Rubicon",
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@solid-devtools/overlay": "^0.30.1",
|
||||
"@suid/vite-plugin": "^0.3.1",
|
||||
"@types/hammerjs": "^2.0.46",
|
||||
"@types/masonry-layout": "^4.2.8",
|
||||
"@vite-pwa/assets-generator": "^0.2.6",
|
||||
"postcss": "^8.4.49",
|
||||
"@suid/vite-plugin": "^0.3.0",
|
||||
"@types/hammerjs": "^2.0.45",
|
||||
"postcss": "^8.4.45",
|
||||
"prettier": "^3.3.3",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.11",
|
||||
"typescript": "^5.6.2",
|
||||
"vite": "^5.4.5",
|
||||
"vite-plugin-package-version": "^1.1.0",
|
||||
"vite-plugin-pwa": "^0.20.5",
|
||||
"vite-plugin-solid": "^2.10.2",
|
||||
"vite-plugin-solid-styled": "^0.11.1",
|
||||
"workbox-build": "^7.3.0",
|
||||
"wrangler": "^3.86.1"
|
||||
"wrangler": "^3.78.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formatjs/intl-localematcher": "^0.5.7",
|
||||
"@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.1",
|
||||
"@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.30.1",
|
||||
"solid-js": "^1.9.3",
|
||||
"solid-js": "^1.8.22",
|
||||
"solid-styled": "^0.11.1",
|
||||
"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"
|
||||
}
|
||||
|
|
133
public/logo.svg
133
public/logo.svg
|
@ -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 |
|
@ -1,2 +0,0 @@
|
|||
User-agent: *
|
||||
Allow: /
|
|
@ -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']
|
||||
})
|
|
@ -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=-
|
31
src/App.css
31
src/App.css
|
@ -1,41 +1,12 @@
|
|||
@import "normalize.css/normalize.css";
|
||||
@import "./material/theme.css";
|
||||
|
||||
: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;
|
||||
}
|
||||
overscroll-behavior-block: none;
|
||||
}
|
||||
|
||||
.custom-emoji {
|
||||
width: 1em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
* {
|
||||
user-select: none;
|
||||
}
|
||||
|
|
89
src/App.tsx
89
src/App.tsx
|
@ -5,12 +5,11 @@ import {
|
|||
createEffect,
|
||||
createMemo,
|
||||
createRenderEffect,
|
||||
createSignal,
|
||||
ErrorBoundary,
|
||||
lazy,
|
||||
onCleanup,
|
||||
} from "solid-js";
|
||||
import { useRootTheme } from "./material/theme.js";
|
||||
import { useRootTheme } from "./material/mui.js";
|
||||
import {
|
||||
Provider as ClientProvider,
|
||||
createMastoClientFor,
|
||||
|
@ -18,16 +17,6 @@ import {
|
|||
import { $accounts, updateAcctInf } from "./accounts/stores.js";
|
||||
import { useStore } from "@nanostores/solid";
|
||||
import { DateFnScope, useLanguage } from "./platform/i18n.jsx";
|
||||
import { useRegisterSW } from "virtual:pwa-register/solid";
|
||||
import {
|
||||
isJSONRPCResult,
|
||||
ResultDispatcher,
|
||||
type JSONRPC,
|
||||
} from "./serviceworker/workerrpc.js";
|
||||
import { Service } from "./serviceworker/services.js";
|
||||
import { makeEventListener } from "@solid-primitives/event-listener";
|
||||
import { ServiceWorkerProvider } from "./platform/host.js";
|
||||
import StackedRouter from "./platform/StackedRouter.js";
|
||||
|
||||
const AccountSignIn = lazy(() => import("./accounts/SignIn.js"));
|
||||
const AccountMastodonOAuth2Callback = lazy(
|
||||
|
@ -38,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
|
||||
|
@ -60,7 +50,7 @@ const Routing: Component = () => {
|
|||
component={AccountMastodonOAuth2Callback}
|
||||
/>
|
||||
</Route>
|
||||
</StackedRouter>
|
||||
</Router>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -68,45 +58,6 @@ const App: Component = () => {
|
|||
const theme = useRootTheme();
|
||||
const accts = useStore($accounts);
|
||||
const lang = useLanguage();
|
||||
const [serviceWorker, setServiceWorker] = createSignal<
|
||||
ServiceWorker | undefined
|
||||
>(undefined, { name: "serviceWorker" });
|
||||
const dispatcher = new ResultDispatcher();
|
||||
|
||||
let checkAge = 0;
|
||||
const untilServiceWorkerAlive = async (
|
||||
worker: ServiceWorker,
|
||||
expectedAge: number,
|
||||
) => {
|
||||
const [call, ret] = dispatcher.createTypedCall<Service>("ping");
|
||||
worker.postMessage(await call);
|
||||
const result = await ret;
|
||||
console.assert(!result.error, result);
|
||||
if (expectedAge === checkAge) {
|
||||
setServiceWorker(worker);
|
||||
}
|
||||
};
|
||||
|
||||
makeEventListener(window, "message", (event: MessageEvent<JSONRPC>) => {
|
||||
if (isJSONRPCResult(event.data)) {
|
||||
dispatcher.dispatch(event.data.id, event.data);
|
||||
}
|
||||
});
|
||||
|
||||
const {
|
||||
needRefresh: [needRefresh],
|
||||
offlineReady: [offlineReady],
|
||||
} = useRegisterSW({
|
||||
onRegisteredSW(scriptUrl, reg) {
|
||||
console.info("service worker is registered from %s", scriptUrl);
|
||||
const active = reg?.active;
|
||||
if (!active) {
|
||||
console.warn("No service is in activating or activated");
|
||||
return;
|
||||
}
|
||||
untilServiceWorkerAlive(active, checkAge++);
|
||||
},
|
||||
});
|
||||
|
||||
const clients = createMemo(() => {
|
||||
return accts().map((x) => ({
|
||||
|
@ -149,18 +100,10 @@ const App: Component = () => {
|
|||
return <UnexpectedError error={err} />;
|
||||
}}
|
||||
>
|
||||
<ThemeProvider theme={theme}>
|
||||
<ThemeProvider theme={theme()}>
|
||||
<DateFnScope>
|
||||
<ClientProvider value={clients}>
|
||||
<ServiceWorkerProvider
|
||||
value={{
|
||||
needRefresh,
|
||||
offlineReady,
|
||||
serviceWorker,
|
||||
}}
|
||||
>
|
||||
<Routing />
|
||||
</ServiceWorkerProvider>
|
||||
<Routing />
|
||||
</ClientProvider>
|
||||
</DateFnScope>
|
||||
</ThemeProvider>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { useSearchParams } from "@solidjs/router";
|
||||
import { useNavigate, useSearchParams } from "@solidjs/router";
|
||||
import {
|
||||
Component,
|
||||
Show,
|
||||
|
@ -9,12 +9,11 @@ import {
|
|||
import { acceptAccountViaAuthCode } from "./stores";
|
||||
import { $settings } from "../settings/stores";
|
||||
import { useDocumentTitle } from "../utils";
|
||||
import cards from "~material/cards.module.css";
|
||||
import cards from "../material/cards.module.css";
|
||||
import { LinearProgress } from "@suid/material";
|
||||
import Img from "~material/Img";
|
||||
import Img from "../material/Img";
|
||||
import { createRestAPIClient } from "masto";
|
||||
import { Title } from "~material/typography";
|
||||
import { useNavigator } from "~platform/StackedRouter";
|
||||
import { Title } from "../material/typography";
|
||||
|
||||
type OAuth2CallbackParams = {
|
||||
code?: string;
|
||||
|
@ -26,7 +25,7 @@ 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;
|
||||
|
|
|
@ -7,11 +7,11 @@ import {
|
|||
createUniqueId,
|
||||
onMount,
|
||||
} from "solid-js";
|
||||
import cards from "~material/cards.module.css";
|
||||
import TextField from "~material/TextField.js";
|
||||
import Button from "~material/Button.js";
|
||||
import cards from "../material/cards.module.css";
|
||||
import TextField from "../material/TextField.js";
|
||||
import Button from "../material/Button.js";
|
||||
import { useDocumentTitle } from "../utils";
|
||||
import { Title } from "~material/typography";
|
||||
import { Title } from "../material/typography";
|
||||
import { css } from "solid-styled";
|
||||
import { LinearProgress } from "@suid/material";
|
||||
import { createRestAPIClient } from "masto";
|
||||
|
|
|
@ -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
28
src/masto/acct.ts
Normal 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;
|
||||
}
|
|
@ -1,15 +1,14 @@
|
|||
import {
|
||||
Accessor,
|
||||
createContext,
|
||||
createMemo,
|
||||
createRenderEffect,
|
||||
createResource,
|
||||
Signal,
|
||||
useContext,
|
||||
} from "solid-js";
|
||||
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> = {};
|
||||
|
||||
|
@ -50,19 +49,18 @@ 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 (sessions().length > 0) return;
|
||||
push(
|
||||
navigate(
|
||||
"/accounts/sign-in?back=" + encodeURIComponent(location.pathname),
|
||||
{ replace: true },
|
||||
);
|
||||
|
@ -78,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 `undefined` and the `client` is an
|
||||
* unauthorised client for the site. This client may not available for some operations.
|
||||
*/
|
||||
export function useSessionForAcctStr(acct: Accessor<string>) {
|
||||
const allSessions = useSessions();
|
||||
|
||||
return createMemo(() => {
|
||||
const [inputUsername, inputSite] = acct().split("@", 2);
|
||||
const authedSession = allSessions().find(
|
||||
(x) =>
|
||||
x.account.site === inputSite &&
|
||||
x.account.inf?.username === inputUsername,
|
||||
);
|
||||
return (
|
||||
authedSession ?? {
|
||||
client: createUnauthorizedClient(inputSite),
|
||||
account: undefined,
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function makeAcctText(session: Session) {
|
||||
return `${session.account.inf?.username}@${session.account.site}`;
|
||||
}
|
||||
|
|
|
@ -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,66 +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 },
|
||||
];
|
||||
|
||||
/**
|
||||
* 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));
|
||||
|
@ -272,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)) {
|
||||
|
@ -304,7 +215,7 @@ export function createTimeline<
|
|||
node.parent = parent;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const nThreadIds = chk.chunk
|
||||
.filter((x, i) => !existence[i])
|
||||
|
@ -317,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 },
|
||||
|
|
|
@ -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;
|
||||
|
||||
}
|
||||
}
|
||||
}
|
|
@ -4,33 +4,44 @@ import {
|
|||
createSignal,
|
||||
onCleanup,
|
||||
useTransition,
|
||||
type JSX,
|
||||
type ParentComponent,
|
||||
type ResolvedChildren,
|
||||
} from "solid-js";
|
||||
import "./BottomSheet.css";
|
||||
import material from "./material.module.css";
|
||||
import {
|
||||
ANIM_CURVE_ACELERATION,
|
||||
ANIM_CURVE_DECELERATION,
|
||||
} from "./theme";
|
||||
import {
|
||||
animateSlideInFromRight,
|
||||
animateSlideOutToRight,
|
||||
} from "~platform/anim";
|
||||
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 composeAnimationFrame(
|
||||
{
|
||||
top,
|
||||
left,
|
||||
height,
|
||||
width,
|
||||
}: Record<"top" | "left" | "height" | "width", number>,
|
||||
x: Record<string, unknown>,
|
||||
) {
|
||||
return {
|
||||
top: `${top}px`,
|
||||
left: `${left}px`,
|
||||
height: `${height}px`,
|
||||
width: `${width}px`,
|
||||
...x,
|
||||
};
|
||||
}
|
||||
|
||||
const MOVE_SPEED = 1200;
|
||||
|
||||
const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
|
||||
let element: HTMLDialogElement;
|
||||
let animation: Animation | undefined;
|
||||
const [hero, setHero] = useHeroSignal(HERO);
|
||||
const [cache, setCache] = createSignal<ResolvedChildren | undefined>();
|
||||
const ochildren = children(() => props.children);
|
||||
|
||||
|
@ -51,52 +62,88 @@ const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
|
|||
});
|
||||
|
||||
const onClose = () => {
|
||||
const srcElement = hero();
|
||||
if (srcElement) {
|
||||
srcElement.style.visibility = "unset";
|
||||
}
|
||||
|
||||
element.close();
|
||||
setHero();
|
||||
};
|
||||
|
||||
const animatedClose = () => {
|
||||
|
||||
const srcElement = hero();
|
||||
const endRect = srcElement?.getBoundingClientRect();
|
||||
if (endRect) {
|
||||
const startRect = element.getBoundingClientRect();
|
||||
const animation = animateHero(startRect, endRect, element, true);
|
||||
animation.addEventListener("finish", onClose);
|
||||
animation.addEventListener("cancel", onClose);
|
||||
} else {
|
||||
if (window.innerWidth > 560 && !props.bottomUp) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
const onAnimationEnd = () => {
|
||||
element.classList.remove("animated");
|
||||
onClose();
|
||||
};
|
||||
element.classList.add("animated");
|
||||
animation = props.bottomUp
|
||||
const animation = props.bottomUp
|
||||
? animateSlideInFromBottom(element, true)
|
||||
: animateSlideOutToRight(element, { easing: ANIM_CURVE_ACELERATION });
|
||||
animation.addEventListener("finish", onAnimationEnd);
|
||||
animation.addEventListener("cancel", onAnimationEnd);
|
||||
|
||||
: animateSlideInFromRight(element, true);
|
||||
animation.addEventListener("finish", onClose);
|
||||
animation.addEventListener("cancel", onClose);
|
||||
}
|
||||
};
|
||||
|
||||
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 = 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("animated");
|
||||
element.classList.add(styles.animated);
|
||||
const oldOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
const distance = Math.abs(rect.top - window.innerHeight);
|
||||
|
@ -104,14 +151,17 @@ const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
|
|||
|
||||
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("animated");
|
||||
element.classList.remove(styles.animated);
|
||||
document.body.style.overflow = oldOverflow;
|
||||
animation = undefined;
|
||||
};
|
||||
|
@ -120,6 +170,35 @@ const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
|
|||
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();
|
||||
|
@ -129,35 +208,25 @@ const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
|
|||
const onDialogClick = (
|
||||
event: MouseEvent & { currentTarget: HTMLDialogElement },
|
||||
) => {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const isNotInDialog =
|
||||
event.clientY < rect.top ||
|
||||
event.clientY > rect.bottom ||
|
||||
event.clientX < rect.left ||
|
||||
event.clientX > rect.right;
|
||||
if (isNotInDialog) {
|
||||
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 ${material.surface} ${props.class || ""}`}
|
||||
classList={{
|
||||
["bottom"]: props.bottomUp,
|
||||
[styles.bottomSheet]: true,
|
||||
[styles.bottom]: props.bottomUp,
|
||||
}}
|
||||
onClick={onDialogClick}
|
||||
onCancel={onDialogCancel}
|
||||
ref={element!}
|
||||
tabIndex={-1}
|
||||
role="presentation"
|
||||
>
|
||||
{ochildren() ?? cache()}
|
||||
</dialog>
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { Component, JSX, splitProps } from "solid-js";
|
||||
import materialStyles from "./material.module.css";
|
||||
import "./typography.css";
|
||||
|
||||
/**
|
||||
* Material-styled button.
|
||||
|
@ -11,15 +10,12 @@ const Button: Component<JSX.ButtonHTMLAttributes<HTMLButtonElement>> = (
|
|||
props,
|
||||
) => {
|
||||
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()}
|
||||
class={`${materialStyles.button} buttonText ${managed.class || ""}`}
|
||||
{...passthough}
|
||||
></button>
|
||||
);
|
||||
return <button type={type()} class={classes()} {...passthough}></button>;
|
||||
};
|
||||
|
||||
export default Button;
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
|
@ -1,44 +0,0 @@
|
|||
.Scaffold>.topbar {
|
||||
position: sticky;
|
||||
top: 0px;
|
||||
z-index: var(--tutu-zidx-nav, auto);
|
||||
|
||||
.MuiToolbar-root {
|
||||
margin-left: var(--safe-area-inset-left);
|
||||
margin-right: var(--safe-area-inset-right);
|
||||
|
||||
>.MuiButtonBase-root {
|
||||
&:first-child {
|
||||
margin-left: -0.5em;
|
||||
margin-right: 24px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-right: -0.5em;
|
||||
margin-left: 24px;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.Scaffold>.fab-dock {
|
||||
position: fixed;
|
||||
bottom: 40px;
|
||||
right: 40px;
|
||||
z-index: var(--tutu-zidx-nav, auto);
|
||||
}
|
||||
|
||||
.Scaffold>.bottom-dock {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: var(--tutu-zidx-nav, auto);
|
||||
}
|
||||
|
||||
.Scaffold {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background-color: var(--tutu-color-surface);
|
||||
}
|
|
@ -1,75 +1,68 @@
|
|||
import { createElementSize } from "@solid-primitives/resize-observer";
|
||||
import {
|
||||
JSX,
|
||||
Show,
|
||||
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> = (props) => {
|
||||
const [managed, rest] = splitProps(props, [
|
||||
"topbar",
|
||||
"fab",
|
||||
"bottom",
|
||||
"children",
|
||||
"ref",
|
||||
"class",
|
||||
]);
|
||||
const Scaffold: ParentComponent<ScaffoldProps> = (props) => {
|
||||
const [topbarElement, setTopbarElement] = createSignal<HTMLElement>();
|
||||
|
||||
const topbarSize = createElementSize(topbarElement);
|
||||
|
||||
return (
|
||||
<div
|
||||
class={`Scaffold ${managed.class || ""}`}
|
||||
ref={(e) => {
|
||||
createRenderEffect(() => {
|
||||
e.style.setProperty(
|
||||
"--scaffold-topbar-height",
|
||||
(topbarSize.height?.toString() ?? 0) + "px",
|
||||
);
|
||||
});
|
||||
css`
|
||||
.scaffold-content {
|
||||
--scaffold-topbar-height: ${(topbarSize.height?.toString() ?? 0) + "px"};
|
||||
}
|
||||
|
||||
if (managed.ref) {
|
||||
(managed.ref as (val: typeof e) => void)(e);
|
||||
}
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
.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 (
|
||||
<>
|
||||
<Show when={props.topbar}>
|
||||
<div class="topbar" ref={setTopbarElement} role="presentation">
|
||||
<div class="topbar" ref={setTopbarElement}>
|
||||
{props.topbar}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={props.fab}>
|
||||
<div class="fab-dock" role="presentation">
|
||||
{props.fab}
|
||||
</div>
|
||||
<div class="fab-dock">{props.fab}</div>
|
||||
</Show>
|
||||
|
||||
{managed.children}
|
||||
|
||||
<div class="scaffold-content">{props.children}</div>
|
||||
<Show when={props.bottom}>
|
||||
<div class="bottom-dock" role="presentation">
|
||||
{props.bottom}
|
||||
</div>
|
||||
<div class="bottom-dock">{props.bottom}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
}
|
||||
|
||||
.button {
|
||||
composes: buttonText from "./typography.module.css";
|
||||
composes: touchTarget;
|
||||
|
||||
border: none;
|
||||
|
|
|
@ -2,9 +2,6 @@ import { Theme, createTheme } from "@suid/material/styles";
|
|||
import { deepPurple, amber } from "@suid/material/colors";
|
||||
import { Accessor } from "solid-js";
|
||||
|
||||
/**
|
||||
* The MUI theme.
|
||||
*/
|
||||
export function useRootTheme(): Accessor<Theme> {
|
||||
return () =>
|
||||
createTheme({
|
||||
|
@ -18,8 +15,3 @@ export function useRootTheme(): Accessor<Theme> {
|
|||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const ANIM_CURVE_STD = "cubic-bezier(0.4, 0, 0.2, 1)";
|
||||
export const ANIM_CURVE_DECELERATION = "cubic-bezier(0, 0, 0.2, 1)";
|
||||
export const ANIM_CURVE_ACELERATION = "cubic-bezier(0.4, 0, 1, 1)";
|
||||
export const ANIM_CURVE_SHARP = "cubic-bezier(0.4, 0, 0.6, 1)";
|
|
@ -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);
|
||||
|
|
|
@ -29,11 +29,12 @@
|
|||
font-size: var(--subheading-size);
|
||||
}
|
||||
|
||||
.body1, .body2 {
|
||||
.body1 {
|
||||
font-size: var(--body-size);
|
||||
}
|
||||
|
||||
.body2 {
|
||||
composes: body1;
|
||||
font-weight: var(--body2-weight);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { JSX, ParentComponent, splitProps, type Ref } from "solid-js";
|
||||
import { Dynamic } from "solid-js/web";
|
||||
import "./typography.css";
|
||||
import typography from "./typography.module.css";
|
||||
import { mergeClass } from "../utils";
|
||||
|
||||
type AnyElement = keyof JSX.IntrinsicElements | ParentComponent<any>;
|
||||
|
||||
|
@ -39,11 +40,13 @@ export function Typography<T extends AnyElement>(
|
|||
"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>
|
||||
);
|
||||
|
|
12
src/overrides.d.ts
vendored
12
src/overrides.d.ts
vendored
|
@ -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_PLATFROM_MASONRY_ALWAYS_COMPAT?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -1,16 +0,0 @@
|
|||
.CompatMasonry>* {
|
||||
margin-bottom: var(--Masonry-row-gap);
|
||||
}
|
||||
|
||||
@supports (grid-template-rows: masonry) {
|
||||
.NativeMasonry {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(44px, min-content));
|
||||
grid-template-rows: masonry;
|
||||
|
||||
&:has(> :last-child:nth-child(2n)) {
|
||||
grid-template-columns: repeat(2, minmax(auto, min-content));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,158 +0,0 @@
|
|||
import {
|
||||
type Component,
|
||||
type JSX,
|
||||
splitProps,
|
||||
type Ref,
|
||||
createRenderEffect,
|
||||
onCleanup,
|
||||
children,
|
||||
createEffect,
|
||||
createSignal,
|
||||
onMount,
|
||||
} from "solid-js";
|
||||
import { Dynamic, type DynamicProps } from "solid-js/web";
|
||||
import MasonryLayout from "masonry-layout";
|
||||
import { createElementSize } from "@solid-primitives/resize-observer";
|
||||
import "./Masonry.css";
|
||||
|
||||
type MasonryContainer =
|
||||
| Exclude<keyof JSX.IntrinsicElements, keyof JSX.SVGElementTags>
|
||||
| Component<{
|
||||
ref?: Ref<Element>;
|
||||
class?: string;
|
||||
children?: JSX.Element;
|
||||
}>;
|
||||
|
||||
type ElementOf<T extends MasonryContainer> =
|
||||
T extends Exclude<keyof JSX.IntrinsicElements, keyof JSX.SVGElementTags>
|
||||
? JSX.IntrinsicElements[T] extends { ref?: Ref<infer E> }
|
||||
? E
|
||||
: never
|
||||
: T extends Component<{ ref?: Ref<infer E> }>
|
||||
? E
|
||||
: never;
|
||||
|
||||
function forwardRef<T>(value: T, ref?: Ref<T>) {
|
||||
if (!ref) return;
|
||||
(ref as (value: T) => void)(value);
|
||||
}
|
||||
|
||||
function createMasonry(element: Element, options: () => MasonryLayout.Options) {
|
||||
const layout = new MasonryLayout(element, {
|
||||
initLayout: false,
|
||||
});
|
||||
|
||||
onCleanup(() => layout.destroy?.());
|
||||
|
||||
const size = createElementSize(element);
|
||||
|
||||
createRenderEffect(() => {
|
||||
const opts = options();
|
||||
layout.option?.(opts);
|
||||
});
|
||||
|
||||
createRenderEffect(() => {
|
||||
const width = size.width; // only tracking width
|
||||
layout.layout?.();
|
||||
});
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.on("vite:afterUpdate", () => {
|
||||
layout.layout?.();
|
||||
});
|
||||
}
|
||||
|
||||
return layout;
|
||||
}
|
||||
|
||||
const supportsCSSMasonryLayout = /* @__PURE__ */ CSS.supports(
|
||||
"grid-template-rows",
|
||||
"masonry",
|
||||
);
|
||||
|
||||
console.debug("supports css masonry layout", supportsCSSMasonryLayout);
|
||||
|
||||
const useNativeImpl = import.meta.env.VITE_PLATFROM_MASONRY_ALWAYS_COMPAT
|
||||
? false
|
||||
: supportsCSSMasonryLayout;
|
||||
|
||||
if (import.meta.env.VITE_PLATFROM_MASONRY_ALWAYS_COMPAT) {
|
||||
console.warn(
|
||||
"Masonry is in compat mode because VITE_PLATFORM_MASONRY_ALWAYS_COMPAT is enabled",
|
||||
);
|
||||
}
|
||||
|
||||
function MasonryCompat<T extends MasonryContainer>(
|
||||
oprops: DynamicProps<T> & { class?: string },
|
||||
) {
|
||||
const [props, rest] = splitProps(oprops, ["ref", "children", "class"]);
|
||||
|
||||
const childrenComponents = children(() => props.children);
|
||||
|
||||
return (
|
||||
<Dynamic
|
||||
ref={(element: ElementOf<T>) => {
|
||||
forwardRef(element, props.ref as Ref<typeof element> | undefined);
|
||||
|
||||
const [columnGap, setColumnGap] = createSignal<number>();
|
||||
|
||||
const layout = createMasonry(element, () => {
|
||||
return {
|
||||
gutter: columnGap(),
|
||||
};
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const computedStyle = window.getComputedStyle(element);
|
||||
|
||||
const rowGap = computedStyle.rowGap;
|
||||
if (element instanceof HTMLElement) {
|
||||
element.style.setProperty("--Masonry-row-gap", rowGap);
|
||||
}
|
||||
|
||||
const colGap = computedStyle.columnGap;
|
||||
if (colGap) {
|
||||
setColumnGap(Number(colGap.slice(0, colGap.length - 2)));
|
||||
}
|
||||
});
|
||||
|
||||
createRenderEffect(() => {
|
||||
childrenComponents(); // just tracks
|
||||
setTimeout(() => {
|
||||
layout.reloadItems?.();
|
||||
layout.layout?.();
|
||||
}, 0);
|
||||
});
|
||||
}}
|
||||
class={`Masonry CompatMasonry ${props.class || ""}`}
|
||||
{...rest}
|
||||
children={childrenComponents}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MasonryNative<T extends MasonryContainer>(
|
||||
oprops: DynamicProps<T> & { class?: string },
|
||||
) {
|
||||
const [props, rest] = splitProps(oprops, ["class"]);
|
||||
return (
|
||||
<Dynamic class={`Masonry NativeMasonry ${props.class || ""}`} {...rest} />
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Masonry Layout Container.
|
||||
*
|
||||
* **Native if possible** This component uses css masonry layout
|
||||
* and fallback to masonry-layout if not supported. The children
|
||||
* must have specified width and height.
|
||||
*
|
||||
* **Children Changes** As the children changed, reflow will be triggered,
|
||||
* and there is might be a blink (or transition) for user. If it's not your
|
||||
* intention, don't remove/add the direct children. Instead wraps them under
|
||||
* containers and set the width and height on the container.
|
||||
*
|
||||
* **CSS compatibility** This component compatible to "gap" "row-gap"
|
||||
* "column-gap" property. But they are read only once after the element mounted.
|
||||
*/
|
||||
export default useNativeImpl ? MasonryNative : MasonryCompat;
|
|
@ -1,5 +0,0 @@
|
|||
.SizedTextarea {
|
||||
overflow-y: hidden;
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
}
|
|
@ -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;
|
|
@ -1,68 +0,0 @@
|
|||
.StackedPage {
|
||||
container: StackedPage / size;
|
||||
display: contents;
|
||||
max-width: 100vw;
|
||||
max-width: 100dvw;
|
||||
|
||||
contain: layout;
|
||||
}
|
||||
|
||||
dialog.StackedPage {
|
||||
border: none;
|
||||
position: fixed;
|
||||
padding: 0;
|
||||
overscroll-behavior: none;
|
||||
width: 560px;
|
||||
max-height: 100vh;
|
||||
max-height: 100dvh;
|
||||
/*
|
||||
* WebKit does not see contain-instric-size as the real element size.
|
||||
* If the container does not have height, the child element using 100%
|
||||
* height (usually Scafflod in our case) was have 0px computed height.
|
||||
*
|
||||
* This behaviour is different from Firefox. So we need to actually
|
||||
* define the box height here. (Rubicon)
|
||||
*/
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
background: none;
|
||||
display: none;
|
||||
|
||||
contain: strict;
|
||||
contain-intrinsic-size: auto 560px auto 100vh;
|
||||
contain-intrinsic-size: auto 560px auto 100dvh;
|
||||
content-visibility: auto;
|
||||
|
||||
background: var(--tutu-color-surface);
|
||||
box-shadow: var(--tutu-shadow-e16);
|
||||
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
|
||||
@media (max-width: 560px) {
|
||||
& {
|
||||
margin: 0;
|
||||
width: 100vw;
|
||||
width: 100dvw;
|
||||
contain-intrinsic-size: 100vw 100vh;
|
||||
contain-intrinsic-size: 100dvw 100dvh;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
&[open] {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
&::backdrop {
|
||||
background: none;
|
||||
}
|
||||
|
||||
&.animating {
|
||||
overflow: hidden;
|
||||
|
||||
* {
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,592 +0,0 @@
|
|||
import { StaticRouter, type RouterProps } from "@solidjs/router";
|
||||
import {
|
||||
Component,
|
||||
createContext,
|
||||
createMemo,
|
||||
createRenderEffect,
|
||||
createUniqueId,
|
||||
Index,
|
||||
onMount,
|
||||
Show,
|
||||
untrack,
|
||||
useContext,
|
||||
type Accessor,
|
||||
} from "solid-js";
|
||||
import { createStore, unwrap } from "solid-js/store";
|
||||
import "./StackedRouter.css";
|
||||
import { animateSlideInFromRight, animateSlideOutToRight } from "./anim";
|
||||
import { ANIM_CURVE_DECELERATION, ANIM_CURVE_STD } from "~material/theme";
|
||||
import { makeEventListener } from "@solid-primitives/event-listener";
|
||||
import { useWindowSize } from "@solid-primitives/resize-observer";
|
||||
|
||||
export type StackedRouterProps = Omit<RouterProps, "url">;
|
||||
|
||||
export type StackFrame = {
|
||||
path: string;
|
||||
rootId: string;
|
||||
state: unknown;
|
||||
|
||||
animateOpen?: (element: HTMLElement) => Animation;
|
||||
animateClose?: (element: HTMLElement) => Animation;
|
||||
};
|
||||
|
||||
export type NewFrameOptions<T> = (T extends undefined
|
||||
? {
|
||||
state?: T;
|
||||
}
|
||||
: { state: T }) & {
|
||||
/**
|
||||
* The new frame should replace the current frame.
|
||||
*/
|
||||
replace?: boolean;
|
||||
/**
|
||||
* The animatedOpen phase of the life cycle.
|
||||
*
|
||||
* You can use this hook to animate the opening
|
||||
* of the frame. In this phase, the frame content is created
|
||||
* and is mounted to the document.
|
||||
*
|
||||
* You must return an {@link Animation}. This function must be
|
||||
* without side effects. This phase is ended after the {@link Animation}
|
||||
* finished.
|
||||
*/
|
||||
animateOpen?: StackFrame["animateOpen"];
|
||||
/**
|
||||
* The animatedClose phase of the life cycle.
|
||||
*
|
||||
* You can use this hook to animate the closing of the frame.
|
||||
* In this phase, the frame content is still mounted in the
|
||||
* document and will be unmounted after this phase.
|
||||
*
|
||||
* You must return an {@link Animation}. This function must be
|
||||
* without side effects. This phase is ended after the
|
||||
* {@link Animation} finished.
|
||||
*/
|
||||
animateClose?: StackFrame["animateClose"];
|
||||
};
|
||||
|
||||
export type FramePusher<T, K extends keyof T = keyof T> = T[K] extends
|
||||
| undefined
|
||||
| any
|
||||
? (path: K, state?: Readonly<NewFrameOptions<T[K]>>) => Readonly<StackFrame>
|
||||
: (path: K, state: Readonly<NewFrameOptions<T[K]>>) => Readonly<StackFrame>;
|
||||
|
||||
export type Navigator<PushGuide = Record<string, any>> = {
|
||||
frames: readonly StackFrame[];
|
||||
push: FramePusher<PushGuide>;
|
||||
pop: (depth?: number) => void;
|
||||
};
|
||||
|
||||
const NavigatorContext = /* @__PURE__ */ createContext<Navigator>();
|
||||
|
||||
/**
|
||||
* Get the possible navigator of the {@link StackedRouter}.
|
||||
*
|
||||
* @see useNavigator for the navigator usage.
|
||||
*/
|
||||
export function useMaybeNavigator() {
|
||||
return useContext(NavigatorContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the navigator of the {@link StackedRouter}.
|
||||
*
|
||||
* This function returns a {@link Navigator} without available
|
||||
* push guide. Push guide is a record type contains available
|
||||
* path and its state. If you need push guide, you may want to
|
||||
* define your own function (like `useAppNavigator`) and cast the
|
||||
* navigator to the type you need.
|
||||
*
|
||||
* @see {@link useMaybeNavigator} if you are not sure you are under a {@link StackedRouter}.
|
||||
*/
|
||||
export function useNavigator() {
|
||||
const navigator = useMaybeNavigator();
|
||||
|
||||
if (!navigator) {
|
||||
throw new TypeError("not in available scope of StackedRouter");
|
||||
}
|
||||
|
||||
return navigator;
|
||||
}
|
||||
|
||||
export type CurrentFrame = {
|
||||
index: number;
|
||||
frame: Readonly<StackFrame>;
|
||||
};
|
||||
|
||||
const CurrentFrameContext =
|
||||
/* @__PURE__ */ createContext<Accessor<Readonly<CurrentFrame>>>();
|
||||
|
||||
/**
|
||||
* Return the current, if possible.
|
||||
*
|
||||
* @see {@link useCurrentFrame} asserts the frame exists
|
||||
*/
|
||||
export function useMaybeCurrentFrame() {
|
||||
return useContext(CurrentFrameContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the current frame, assert the frame exists.
|
||||
*
|
||||
* @see {@link useMaybeCurrentFrame} if you are not sure you are under a {@link StackedRouter}.
|
||||
*/
|
||||
export function useCurrentFrame() {
|
||||
const frame = useMaybeCurrentFrame();
|
||||
|
||||
if (!frame) {
|
||||
throw new TypeError("not in available scope of StackedRouter");
|
||||
}
|
||||
|
||||
return frame;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an accessor of is current frame is suspended.
|
||||
*
|
||||
* A suspended frame is the one not on the top. "Suspended"
|
||||
* is the description of a certain situtation, not in the life cycle
|
||||
* of a frame.
|
||||
*
|
||||
* If this is not called under a {@link StackedRouter}, it always
|
||||
* returns `false`.
|
||||
*/
|
||||
export function useIsFrameSuspended() {
|
||||
const { frames } = useMaybeNavigator() || {};
|
||||
|
||||
if (typeof frames === "undefined") {
|
||||
return () => false;
|
||||
}
|
||||
|
||||
const thisFrame = useCurrentFrame();
|
||||
|
||||
return () => {
|
||||
const idx = thisFrame().index;
|
||||
return frames.length - 1 > idx;
|
||||
};
|
||||
}
|
||||
|
||||
function onDialogClick(
|
||||
onClose: () => void,
|
||||
event: MouseEvent & { currentTarget: HTMLDialogElement },
|
||||
) {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const isNotInDialog =
|
||||
event.clientY < rect.top ||
|
||||
event.clientY > rect.bottom ||
|
||||
event.clientX < rect.left ||
|
||||
event.clientX > rect.right;
|
||||
if (isNotInDialog) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function animateClose(element: HTMLElement) {
|
||||
if (window.innerWidth <= 560) {
|
||||
return animateSlideOutToRight(element, { easing: ANIM_CURVE_DECELERATION });
|
||||
} else {
|
||||
return element.animate(
|
||||
{
|
||||
opacity: [0.5, 0],
|
||||
},
|
||||
{ easing: ANIM_CURVE_STD, duration: 220 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function animateOpen(element: HTMLElement) {
|
||||
if (window.innerWidth <= 560) {
|
||||
return animateSlideInFromRight(element, {
|
||||
easing: ANIM_CURVE_DECELERATION,
|
||||
});
|
||||
} else {
|
||||
return element.animate(
|
||||
{
|
||||
opacity: [0.5, 1],
|
||||
},
|
||||
{ easing: ANIM_CURVE_STD, duration: 220 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function serializableStack(stack: readonly StackFrame[]) {
|
||||
const frames = unwrap(stack);
|
||||
return frames.map((fr) => {
|
||||
return fr.animateClose || fr.animateOpen
|
||||
? {
|
||||
path: fr.path,
|
||||
rootId: fr.rootId,
|
||||
state: fr.state,
|
||||
}
|
||||
: fr;
|
||||
});
|
||||
}
|
||||
|
||||
function isNotInIOSSwipeToBackArea(x: number) {
|
||||
return (
|
||||
(x > 22 && x < window.innerWidth - 22) ||
|
||||
(x < -22 && x > window.innerWidth + 22)
|
||||
);
|
||||
}
|
||||
|
||||
function onEntryTouchStart(event: TouchEvent) {
|
||||
if (event.touches.length !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [fig0] = event.touches;
|
||||
|
||||
if (isNotInIOSSwipeToBackArea(fig0.clientX)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
/**
|
||||
* This function contains the state for swipe to back.
|
||||
*
|
||||
* @returns the props for dialogs to feature swipe to back.
|
||||
*/
|
||||
function createManagedSwipeToBack(
|
||||
stack: readonly Readonly<StackFrame>[],
|
||||
onlyPopFrame: (depth: number) => void,
|
||||
) {
|
||||
let reenterableAnimation: Animation | undefined;
|
||||
let origWidth = 0,
|
||||
origFigX = 0,
|
||||
origFigY = 0;
|
||||
|
||||
const resetAnimation = () => {
|
||||
reenterableAnimation = undefined;
|
||||
};
|
||||
|
||||
const onDialogTouchStart = (
|
||||
event: TouchEvent & { currentTarget: HTMLDialogElement },
|
||||
) => {
|
||||
if (event.touches.length !== 1) {
|
||||
return;
|
||||
}
|
||||
event.stopPropagation();
|
||||
|
||||
const [fig0] = event.touches;
|
||||
const { width } = event.currentTarget.getBoundingClientRect();
|
||||
origWidth = width;
|
||||
origFigX = fig0.clientX;
|
||||
origFigY = fig0.clientY;
|
||||
|
||||
if (isNotInIOSSwipeToBackArea(fig0.clientX)) {
|
||||
return;
|
||||
}
|
||||
// Prevent the default swipe to back/forward on iOS
|
||||
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
let animationProgressUpdateReleased = true;
|
||||
let nextAnimationProgress = 0;
|
||||
|
||||
const updateAnimationProgress = () => {
|
||||
try {
|
||||
if (!reenterableAnimation) return;
|
||||
const { activeDuration, delay } =
|
||||
reenterableAnimation.effect!.getComputedTiming();
|
||||
|
||||
const totalTime = (delay || 0) + Number(activeDuration);
|
||||
reenterableAnimation.currentTime = totalTime * nextAnimationProgress;
|
||||
} finally {
|
||||
animationProgressUpdateReleased = true;
|
||||
}
|
||||
};
|
||||
|
||||
const onDialogTouchMove = (
|
||||
event: TouchEvent & { currentTarget: HTMLDialogElement },
|
||||
) => {
|
||||
if (event.touches.length !== 1) {
|
||||
if (reenterableAnimation) {
|
||||
reenterableAnimation.reverse();
|
||||
reenterableAnimation.play();
|
||||
}
|
||||
}
|
||||
|
||||
const [fig0] = event.touches;
|
||||
|
||||
const ofsX = fig0.clientX - origFigX;
|
||||
|
||||
if (!reenterableAnimation) {
|
||||
if (!(ofsX > 22) || !(Math.abs(fig0.clientY - origFigY) < 44)) {
|
||||
return;
|
||||
}
|
||||
const lastFr = stack[stack.length - 1];
|
||||
const createAnimation = lastFr.animateClose ?? animateClose;
|
||||
reenterableAnimation = createAnimation(event.currentTarget);
|
||||
reenterableAnimation.pause();
|
||||
reenterableAnimation.addEventListener("finish", resetAnimation);
|
||||
reenterableAnimation.addEventListener("cancel", resetAnimation);
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
nextAnimationProgress = ofsX / origWidth / window.devicePixelRatio;
|
||||
|
||||
if (animationProgressUpdateReleased) {
|
||||
animationProgressUpdateReleased = false;
|
||||
|
||||
requestAnimationFrame(updateAnimationProgress);
|
||||
}
|
||||
};
|
||||
|
||||
const onDialogTouchEnd = (event: TouchEvent) => {
|
||||
if (!reenterableAnimation) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const { activeDuration, delay } =
|
||||
reenterableAnimation.effect!.getComputedTiming();
|
||||
const totalTime = (delay || 0) + Number(activeDuration);
|
||||
|
||||
if (Number(reenterableAnimation.currentTime) / totalTime > 0.1) {
|
||||
reenterableAnimation.addEventListener("finish", () => {
|
||||
onlyPopFrame(1);
|
||||
});
|
||||
reenterableAnimation.play();
|
||||
} else {
|
||||
reenterableAnimation.cancel();
|
||||
}
|
||||
};
|
||||
|
||||
const onDialogTouchCancel = (event: TouchEvent) => {
|
||||
if (!reenterableAnimation) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
reenterableAnimation.cancel();
|
||||
};
|
||||
|
||||
return {
|
||||
"on:touchstart": onDialogTouchStart,
|
||||
"on:touchmove": onDialogTouchMove,
|
||||
"on:touchend": onDialogTouchEnd,
|
||||
"on:touchcancel": onDialogTouchCancel,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The router that stacks the pages.
|
||||
*
|
||||
* **Routes** The router accepts the {@link RouterProps} excluding the "url" field.
|
||||
* You can seamlessly use the `<Route />` from `@solidjs/router`.
|
||||
*
|
||||
* Be advised that this component is not a drop-in replacement of that router.
|
||||
* These primitives from `@solidjs/router` won't work correctly:
|
||||
*
|
||||
* - `<A />` component - use ~platform/A instead
|
||||
* - `useLocation()` - see {@link useCurrentFrame}
|
||||
* - `useNavigate()` - see {@link useNavigator}
|
||||
*
|
||||
* The other primitives may work, as long as they don't rely on the global location.
|
||||
* This component uses `@solidjs/router` {@link StaticRouter} to route.
|
||||
*
|
||||
* **Injecting Safe Area Insets** The router calculate correct
|
||||
* `--safe-area-inset-left` and `--safe-area-inset-right` from the window
|
||||
* width and `--safe-area-inset-*` from the :root element. That means
|
||||
* the injected insets do not reflects the overrides that are not on the :root.
|
||||
*
|
||||
* The recalculation is only performed when the window size changed.
|
||||
*
|
||||
* **Navigation Animation** The router provides default animation for
|
||||
* navigation.
|
||||
*
|
||||
* If the default animation does not met your requirement,
|
||||
* this component is also intergated with Web Animation API.
|
||||
* You can provide {@link NewFrameOptions.animateOpen} and
|
||||
* {@link NewFrameOptions.animateClose} to define custom animation.
|
||||
*
|
||||
* **Swipe to back** For the subpages (the pages stacked on the entry),
|
||||
* swipe to back gesture is provided for user experience.
|
||||
*
|
||||
* Navigation animations (even the custom ones) will be played during
|
||||
* swipe to back, please keep in mind when designing animations.
|
||||
*
|
||||
* The iOS default gesture is blocked on all pages.
|
||||
*/
|
||||
const StackedRouter: Component<StackedRouterProps> = (oprops) => {
|
||||
const [stack, mutStack] = createStore([] as StackFrame[], { name: "stack" });
|
||||
const windowSize = useWindowSize();
|
||||
|
||||
const pushFrame = (path: string, opts?: Readonly<NewFrameOptions<any>>) =>
|
||||
untrack(() => {
|
||||
const frame = {
|
||||
path,
|
||||
state: opts?.state,
|
||||
rootId: createUniqueId(),
|
||||
animateOpen: opts?.animateOpen,
|
||||
animateClose: opts?.animateClose,
|
||||
};
|
||||
|
||||
mutStack(opts?.replace ? stack.length - 1 : stack.length, frame);
|
||||
if (opts?.replace) {
|
||||
window.history.replaceState(serializableStack(stack), "", path);
|
||||
} else {
|
||||
window.history.pushState(serializableStack(stack), "", path);
|
||||
}
|
||||
return frame;
|
||||
});
|
||||
|
||||
const onlyPopFrame = (depth: number) => {
|
||||
mutStack((o) => o.toSpliced(o.length - depth, depth));
|
||||
window.history.go(-depth);
|
||||
};
|
||||
|
||||
const popFrame = (depth: number = 1) =>
|
||||
untrack(() => {
|
||||
if (import.meta.env.DEV) {
|
||||
if (depth < 0) {
|
||||
console.warn("the depth to pop should not < 0, now is", depth);
|
||||
}
|
||||
}
|
||||
if (stack.length > 1) {
|
||||
const lastFrame = stack[stack.length - 1];
|
||||
const element = document.getElementById(
|
||||
lastFrame.rootId,
|
||||
)! as HTMLDialogElement;
|
||||
const createAnimation = lastFrame.animateClose ?? animateClose;
|
||||
requestAnimationFrame(() => {
|
||||
element.classList.add("animating");
|
||||
const animation = createAnimation(element);
|
||||
animation.addEventListener("finish", () => {
|
||||
element.classList.remove("animating");
|
||||
onlyPopFrame(depth);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
onlyPopFrame(depth);
|
||||
}
|
||||
});
|
||||
|
||||
createRenderEffect(() => {
|
||||
if (stack.length === 0) {
|
||||
mutStack(0, {
|
||||
path: window.location.pathname,
|
||||
rootId: createUniqueId(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
createRenderEffect(() => {
|
||||
makeEventListener(window, "popstate", (event) => {
|
||||
if (!event.state) return;
|
||||
|
||||
if (stack.length === 0) {
|
||||
mutStack(event.state);
|
||||
} else if (stack.length > event.state.length) {
|
||||
popFrame(stack.length - event.state.length);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const onBeforeDialogMount = (element: HTMLDialogElement) => {
|
||||
onMount(() => {
|
||||
const lastFr = untrack(() => stack[stack.length - 1]);
|
||||
const createAnimation = lastFr.animateOpen ?? animateOpen;
|
||||
requestAnimationFrame(() => {
|
||||
element.showModal();
|
||||
element.classList.add("animating");
|
||||
const animation = createAnimation(element);
|
||||
animation.addEventListener("finish", () =>
|
||||
element.classList.remove("animating"),
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const subInsets = createMemo(() => {
|
||||
const SUBPAGE_MAX_WIDTH = 560;
|
||||
const { width } = windowSize;
|
||||
if (width <= SUBPAGE_MAX_WIDTH) {
|
||||
// page width = 100vw, use the inset directly
|
||||
return {};
|
||||
}
|
||||
const computedStyle = window.getComputedStyle(
|
||||
document.querySelector(":root")!,
|
||||
);
|
||||
const oinsetLeft = computedStyle
|
||||
.getPropertyValue("--safe-area-inset-left")
|
||||
.split("px", 1)[0];
|
||||
const oinsetRight = computedStyle
|
||||
.getPropertyValue("--safe-area-inset-right")
|
||||
.split("px", 1)[0];
|
||||
const left = Number(oinsetLeft),
|
||||
right = Number(oinsetRight.slice(0, oinsetRight.length - 2));
|
||||
const totalWidth = SUBPAGE_MAX_WIDTH + left + right;
|
||||
if (width >= totalWidth) {
|
||||
return {
|
||||
"--safe-area-inset-left": "0px",
|
||||
"--safe-area-inset-right": "0px",
|
||||
};
|
||||
}
|
||||
const ofs = (totalWidth - width) / 2;
|
||||
return {
|
||||
"--safe-area-inset-left": `${Math.max(left - ofs, 0)}px`,
|
||||
"--safe-area-inset-right": `${Math.max(right - ofs, 0)}px`,
|
||||
};
|
||||
});
|
||||
|
||||
const swipeToBackProps = createManagedSwipeToBack(stack, onlyPopFrame);
|
||||
|
||||
return (
|
||||
<NavigatorContext.Provider
|
||||
value={{
|
||||
push: pushFrame,
|
||||
pop: popFrame,
|
||||
frames: stack,
|
||||
}}
|
||||
>
|
||||
<Index each={stack}>
|
||||
{(frame, index) => {
|
||||
const currentFrame = () => {
|
||||
return {
|
||||
index,
|
||||
frame: frame(),
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<CurrentFrameContext.Provider value={currentFrame}>
|
||||
<Show
|
||||
when={index !== 0}
|
||||
fallback={
|
||||
<div
|
||||
class="StackedPage"
|
||||
id={frame().rootId}
|
||||
role="presentation"
|
||||
on:touchstart={onEntryTouchStart}
|
||||
>
|
||||
<StaticRouter url={frame().path} {...oprops} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<dialog
|
||||
ref={onBeforeDialogMount}
|
||||
class="StackedPage"
|
||||
onCancel={[popFrame, 1]}
|
||||
onClick={[onDialogClick, popFrame]}
|
||||
{...swipeToBackProps}
|
||||
id={frame().rootId}
|
||||
style={subInsets()}
|
||||
>
|
||||
<StaticRouter url={frame().path} {...oprops} />
|
||||
</dialog>
|
||||
</Show>
|
||||
</CurrentFrameContext.Provider>
|
||||
);
|
||||
}}
|
||||
</Index>
|
||||
</NavigatorContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default StackedRouter;
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -11,11 +11,7 @@ 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,
|
||||
|
@ -38,53 +34,43 @@ export function autoMatchLangTag() {
|
|||
return match(Array.from(navigator.languages), SUPPORTED_LANGS, DEFAULT_LANG);
|
||||
}
|
||||
|
||||
const DateFnLocaleCx = /* __@PURE__ */ createContext<Accessor<Locale>>(
|
||||
() => enGB,
|
||||
);
|
||||
const DateFnLocaleCx = /* __@PURE__ */createContext<Accessor<Locale>>(() => enGB);
|
||||
|
||||
const cachedDateFnLocale: Record<string, Locale> = {
|
||||
enGB,
|
||||
};
|
||||
|
||||
export function autoMatchRegion() {
|
||||
const specifiers = navigator.languages.map((x) => x.split("-"));
|
||||
|
||||
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 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 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> {
|
||||
|
@ -92,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:
|
||||
|
@ -104,9 +90,7 @@ async function importDateFnLocale(tag: string): Promise<Locale> {
|
|||
* Provides runtime values and fetch dependencies for date-fns locale
|
||||
*/
|
||||
export const DateFnScope: ParentComponent = (props) => {
|
||||
const [dateFnLocale, setDateFnLocale] = createSignal(enGB, {
|
||||
name: "dateFnLocale",
|
||||
});
|
||||
const [dateFnLocale, setDateFnLocale] = createSignal(enGB);
|
||||
const region = useRegion();
|
||||
|
||||
createEffect(() => {
|
||||
|
@ -164,22 +148,19 @@ export function useLanguage() {
|
|||
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
|
||||
|
||||
export function createStringResource<
|
||||
T extends ImportFn<Record<string, string | Template<any> | undefined>>[],
|
||||
>(...importFns: T) {
|
||||
const language = useLanguage(); // TODO: this function costs to much, provide a global cache
|
||||
const language = useLanguage();
|
||||
const cache: Record<string, MergedImportedModule<T>> = {};
|
||||
|
||||
return createResource(
|
||||
|
@ -189,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;
|
||||
|
||||
|
@ -202,10 +181,8 @@ export function createStringResource<
|
|||
);
|
||||
}
|
||||
|
||||
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;
|
||||
return [translator(res[0], resolveTemplate), res] as const
|
||||
}
|
||||
|
|
|
@ -1,7 +1,14 @@
|
|||
//! This module has side effect.
|
||||
//! It recommended to include the module by <script> tag.
|
||||
if (typeof document.body.animate === "undefined") {
|
||||
// @ts-ignore: this file is polyfill, no exposed decls
|
||||
import("web-animations-js").then(() => {
|
||||
// all target platforms supported, prepared to remove
|
||||
console.warn("web animation polyfill is included");
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof window.crypto.randomUUID === "undefined") {
|
||||
// TODO: this polyfill can be removed in 2.0, see https://code.lightstands.xyz/Rubicon/tutu/issues/36
|
||||
// Chrome/Edge 92+
|
||||
// https://stackoverflow.com/a/2117523/2800218
|
||||
// LICENSE: https://creativecommons.org/licenses/by-sa/4.0/legalcode
|
||||
|
|
|
@ -4,8 +4,8 @@ import {
|
|||
onCleanup,
|
||||
type Component,
|
||||
} from "solid-js";
|
||||
import Scaffold from "~material/Scaffold";
|
||||
import BottomSheet from "~material/BottomSheet";
|
||||
import Scaffold from "../material/Scaffold";
|
||||
import BottomSheet from "../material/BottomSheet";
|
||||
import {
|
||||
Button,
|
||||
IconButton,
|
||||
|
@ -14,9 +14,9 @@ import {
|
|||
Toolbar,
|
||||
} from "@suid/material";
|
||||
import { Close as CloseIcon, ContentCopy } from "@suid/icons-material";
|
||||
import { Title } from "~material/typography";
|
||||
import { Title } from "../material/typography";
|
||||
import { render } from "solid-js/web";
|
||||
import { useRootTheme } from "~material/theme";
|
||||
import { useRootTheme } from "../material/mui";
|
||||
|
||||
const ShareBottomSheet: Component<{
|
||||
data?: ShareData;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -1,123 +0,0 @@
|
|||
.Profile {
|
||||
overflow: hidden auto;
|
||||
|
||||
.intro {
|
||||
background-color: var(--tutu-color-surface-d);
|
||||
color: var(--tutu-color-on-surface);
|
||||
padding: 16px 12px;
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.banner {
|
||||
width: 100%;
|
||||
margin-top: calc(-1 * var(--scaffold-topbar-height));
|
||||
|
||||
>img {
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
& a {
|
||||
color: inherit;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
word-break: break-all;
|
||||
|
||||
& * {
|
||||
user-select: text;
|
||||
}
|
||||
}
|
||||
|
||||
.acct-grp {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
|
||||
&> :nth-child(2) {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
&> :last-child {
|
||||
flex-grow: 1;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.name-grp {
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
|
||||
& * {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.display-name {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.username {
|
||||
user-select: all;
|
||||
}
|
||||
}
|
||||
|
||||
.acct-mark {
|
||||
font-size: 1.2em;
|
||||
vertical-align: sub;
|
||||
margin-right: 0.25em;
|
||||
}
|
||||
|
||||
|
||||
|
||||
table.acct-fields {
|
||||
word-break: break-all;
|
||||
|
||||
& td>a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: inherit;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
& a>.invisible {
|
||||
display: none;
|
||||
}
|
||||
|
||||
& svg {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
& * {
|
||||
user-select: text;
|
||||
}
|
||||
}
|
||||
|
||||
.toot-list-toolbar {
|
||||
position: sticky;
|
||||
top: var(--scaffold-topbar-height);
|
||||
z-index: calc(var(--tutu-zidx-nav, 1) - 1);
|
||||
background: var(--tutu-color-surface);
|
||||
border-bottom: 1px solid var(--tutu-color-surface-d);
|
||||
contain: content;
|
||||
/* TODO: box-shadow is needed here (same as app bar, e6).
|
||||
There is no good way to detect if the sticky is "sticked" -
|
||||
so let's leave it for future.
|
||||
|
||||
For now we use a trick to make it looks better.
|
||||
*/
|
||||
box-shadow: 0px -2px 4px 0px var(--tutu-color-shadow);
|
||||
}
|
||||
}
|
||||
|
||||
.Profile__page-title {
|
||||
flex-grow: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
|
@ -1,553 +0,0 @@
|
|||
import {
|
||||
catchError,
|
||||
createRenderEffect,
|
||||
createResource,
|
||||
createSignal,
|
||||
createUniqueId,
|
||||
For,
|
||||
Switch,
|
||||
Match,
|
||||
onCleanup,
|
||||
Show,
|
||||
type Component,
|
||||
createMemo,
|
||||
} from "solid-js";
|
||||
import Scaffold from "~material/Scaffold";
|
||||
import {
|
||||
AppBar,
|
||||
Avatar,
|
||||
Button,
|
||||
Checkbox,
|
||||
CircularProgress,
|
||||
Divider,
|
||||
IconButton,
|
||||
ListItemAvatar,
|
||||
ListItemIcon,
|
||||
ListItemSecondaryAction,
|
||||
ListItemText,
|
||||
MenuItem,
|
||||
Toolbar,
|
||||
} from "@suid/material";
|
||||
import {
|
||||
Close,
|
||||
Edit,
|
||||
ExpandMore,
|
||||
Group,
|
||||
Lock,
|
||||
MoreVert,
|
||||
OpenInBrowser,
|
||||
PersonOff,
|
||||
PlaylistAdd,
|
||||
Send,
|
||||
Share,
|
||||
SmartToySharp,
|
||||
Subject,
|
||||
Verified,
|
||||
} from "@suid/icons-material";
|
||||
import { Body2, Title } from "~material/typography";
|
||||
import { useParams } from "@solidjs/router";
|
||||
import { useSessionForAcctStr } from "../masto/clients";
|
||||
import { resolveCustomEmoji } from "../masto/toot";
|
||||
import { FastAverageColor } from "fast-average-color";
|
||||
import { useWindowSize } from "@solid-primitives/resize-observer";
|
||||
import { createTimeline, createTimelineSnapshot } from "../masto/timelines";
|
||||
import TootList from "../timelines/TootList";
|
||||
import { createTimeSource, TimeSourceProvider } from "~platform/timesrc";
|
||||
import TootFilterButton from "./TootFilterButton";
|
||||
import Menu, { createManagedMenuState } from "~material/Menu";
|
||||
import { share } from "~platform/share";
|
||||
import "./Profile.css";
|
||||
import { useNavigator } from "~platform/StackedRouter";
|
||||
|
||||
const Profile: Component = () => {
|
||||
const { pop } = useNavigator();
|
||||
const params = useParams<{ acct: string; id: string }>();
|
||||
const acctText = () => decodeURIComponent(params.acct);
|
||||
const session = useSessionForAcctStr(acctText);
|
||||
const [bannerSampledColors, setBannerSampledColors] = createSignal<{
|
||||
average: string;
|
||||
text: string;
|
||||
}>();
|
||||
const windowSize = useWindowSize();
|
||||
const time = createTimeSource();
|
||||
|
||||
const menuButId = createUniqueId();
|
||||
const recentTootListId = createUniqueId();
|
||||
const optMenuId = createUniqueId();
|
||||
|
||||
const [menuOpen, setMenuOpen] = createSignal(false);
|
||||
|
||||
const [openSubscribeMenu, subscribeMenuState] = createManagedMenuState();
|
||||
|
||||
const [scrolledPastBanner, setScrolledPastBanner] = createSignal(false);
|
||||
const obx = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const ent = entries[0];
|
||||
if (ent.intersectionRatio < 0.1) {
|
||||
setScrolledPastBanner(true);
|
||||
} else {
|
||||
setScrolledPastBanner(false);
|
||||
}
|
||||
},
|
||||
{
|
||||
threshold: 0.1,
|
||||
},
|
||||
);
|
||||
onCleanup(() => obx.disconnect());
|
||||
|
||||
const [profileUncaught] = createResource(
|
||||
() => [session().client, params.id] as const,
|
||||
async ([client, id]) => {
|
||||
return await client.v1.accounts.$select(id).fetch();
|
||||
},
|
||||
);
|
||||
|
||||
const profile = () => {
|
||||
try {
|
||||
return profileUncaught();
|
||||
} catch (reason) {
|
||||
console.error(reason);
|
||||
}
|
||||
};
|
||||
|
||||
const isCurrentSessionProfile = () => {
|
||||
return session().account?.inf?.url === profile()?.url;
|
||||
};
|
||||
|
||||
const [recentTootFilter, setRecentTootFilter] = createSignal({
|
||||
pinned: true,
|
||||
boost: false,
|
||||
reply: true,
|
||||
original: true,
|
||||
});
|
||||
|
||||
const [recentToots, recentTootChunk, { refetch: refetchRecentToots }] =
|
||||
createTimeline(
|
||||
() => session().client.v1.accounts.$select(params.id).statuses,
|
||||
() => {
|
||||
const { boost, reply } = recentTootFilter();
|
||||
return { limit: 20, excludeReblogs: !boost, excludeReplies: !reply };
|
||||
},
|
||||
);
|
||||
|
||||
const [pinnedToots, pinnedTootChunk] = createTimelineSnapshot(
|
||||
() => session().client.v1.accounts.$select(params.id).statuses,
|
||||
() => {
|
||||
return { limit: 20, pinned: true };
|
||||
},
|
||||
);
|
||||
|
||||
const [relationshipUncaught, { mutate: mutateRelationship }] = createResource(
|
||||
() => [session(), params.id] as const,
|
||||
async ([sess, id]) => {
|
||||
if (!sess.account) return; // No account, no relation
|
||||
const relations = await session().client.v1.accounts.relationships.fetch({
|
||||
id: [id],
|
||||
});
|
||||
return relations.length > 0 ? relations[0] : undefined;
|
||||
},
|
||||
);
|
||||
|
||||
const relationship = () =>
|
||||
catchError(relationshipUncaught, (reason) => {
|
||||
console.error(reason);
|
||||
});
|
||||
|
||||
const bannerImg = () => profile()?.header;
|
||||
const avatarImg = () => profile()?.avatar;
|
||||
const displayName = () =>
|
||||
resolveCustomEmoji(profile()?.displayName || "", profile()?.emojis ?? []);
|
||||
const fullUsername = () => (profile()?.acct ? `@${profile()!.acct!}` : ""); // TODO: full user name
|
||||
const description = () => profile()?.note;
|
||||
|
||||
const isTootListLoading = () =>
|
||||
recentTootChunk.loading ||
|
||||
(recentTootFilter().pinned && pinnedTootChunk.loading);
|
||||
|
||||
const sessionDisplayName = createMemo(() =>
|
||||
resolveCustomEmoji(
|
||||
session().account?.inf?.displayName || "",
|
||||
session().account?.inf?.emojis ?? [],
|
||||
),
|
||||
);
|
||||
|
||||
const useSessionDisplayName = (e: HTMLElement) => {
|
||||
createRenderEffect(() => (e.innerHTML = sessionDisplayName()));
|
||||
};
|
||||
|
||||
const toggleSubscribeHome = async (event: Event) => {
|
||||
const client = session().client;
|
||||
if (!session().account) return;
|
||||
const isSubscribed = relationship()?.following ?? false;
|
||||
mutateRelationship((x) => Object.assign({ following: !isSubscribed }, x));
|
||||
subscribeMenuState.onClose(event);
|
||||
|
||||
if (isSubscribed) {
|
||||
const nrel = await client.v1.accounts.$select(params.id).unfollow();
|
||||
mutateRelationship(nrel);
|
||||
} else {
|
||||
const nrel = await client.v1.accounts.$select(params.id).follow();
|
||||
mutateRelationship(nrel);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Scaffold
|
||||
topbar={
|
||||
<AppBar
|
||||
role="navigation"
|
||||
position="static"
|
||||
color={scrolledPastBanner() ? "primary" : "transparent"}
|
||||
elevation={scrolledPastBanner() ? undefined : 0}
|
||||
>
|
||||
<Toolbar
|
||||
variant="dense"
|
||||
sx={{
|
||||
display: "flex",
|
||||
color: scrolledPastBanner()
|
||||
? undefined
|
||||
: bannerSampledColors()?.text,
|
||||
paddingTop: "var(--safe-area-inset-top)",
|
||||
}}
|
||||
>
|
||||
<IconButton color="inherit" onClick={[pop, 1]} aria-label="Close">
|
||||
<Close />
|
||||
</IconButton>
|
||||
<Title
|
||||
class="Profile__page-title"
|
||||
style={{
|
||||
visibility: scrolledPastBanner() ? undefined : "hidden",
|
||||
}}
|
||||
ref={(e: HTMLElement) =>
|
||||
createRenderEffect(() => (e.innerHTML = displayName()))
|
||||
}
|
||||
></Title>
|
||||
|
||||
<IconButton
|
||||
id={menuButId}
|
||||
aria-controls={optMenuId}
|
||||
color="inherit"
|
||||
onClick={[setMenuOpen, true]}
|
||||
aria-label="Open Options for the Profile"
|
||||
>
|
||||
<MoreVert />
|
||||
</IconButton>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
}
|
||||
class="Profile"
|
||||
>
|
||||
<Menu
|
||||
id={optMenuId}
|
||||
open={menuOpen()}
|
||||
onClose={[setMenuOpen, false]}
|
||||
anchor={() =>
|
||||
document.getElementById(menuButId)!.getBoundingClientRect()
|
||||
}
|
||||
aria-label="Options for the Profile"
|
||||
>
|
||||
<Show when={session().account}>
|
||||
<MenuItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar src={session().account?.inf?.avatar} />
|
||||
</ListItemAvatar>
|
||||
<ListItemText secondary={"Default account"}>
|
||||
<span ref={useSessionDisplayName}></span>
|
||||
</ListItemText>
|
||||
{/* <ArrowRight /> // for future */}
|
||||
</MenuItem>
|
||||
</Show>
|
||||
<Show when={session().account && profile()}>
|
||||
<Show
|
||||
when={isCurrentSessionProfile()}
|
||||
fallback={
|
||||
<MenuItem
|
||||
onClick={(event) => {
|
||||
const { left, right, top } =
|
||||
event.currentTarget.getBoundingClientRect();
|
||||
openSubscribeMenu({
|
||||
left,
|
||||
right,
|
||||
top,
|
||||
e: 1,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<PlaylistAdd />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Subscribe...</ListItemText>
|
||||
</MenuItem>
|
||||
}
|
||||
>
|
||||
<MenuItem disabled>
|
||||
<ListItemIcon>
|
||||
<Edit />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Edit...</ListItemText>
|
||||
</MenuItem>
|
||||
</Show>
|
||||
<Divider />
|
||||
</Show>
|
||||
<MenuItem disabled>
|
||||
<ListItemIcon>
|
||||
<Group />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Followers</ListItemText>
|
||||
<ListItemSecondaryAction>
|
||||
<span aria-label="The number of the account follower">
|
||||
{profile()?.followersCount ?? ""}
|
||||
</span>
|
||||
</ListItemSecondaryAction>
|
||||
</MenuItem>
|
||||
<MenuItem disabled>
|
||||
<ListItemIcon>
|
||||
<Subject />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Following</ListItemText>
|
||||
<ListItemSecondaryAction>
|
||||
<span aria-label="The number the account following">
|
||||
{profile()?.followingCount ?? ""}
|
||||
</span>
|
||||
</ListItemSecondaryAction>
|
||||
</MenuItem>
|
||||
<MenuItem disabled>
|
||||
<ListItemIcon>
|
||||
<PersonOff />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Blocklist</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem disabled>
|
||||
<ListItemIcon>
|
||||
<Send />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Mention in...</ListItemText>
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<MenuItem
|
||||
component={"a"}
|
||||
href={profile()?.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ListItemIcon>
|
||||
<OpenInBrowser />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Open in browser...</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => share({ url: profile()?.url })}>
|
||||
<ListItemIcon>
|
||||
<Share />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Share...</ListItemText>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
<div
|
||||
style={{
|
||||
height: `${268 * (Math.min(560, windowSize.width) / 560)}px`,
|
||||
}}
|
||||
class="banner"
|
||||
role="presentation"
|
||||
>
|
||||
<img
|
||||
ref={(e) => obx.observe(e)}
|
||||
src={bannerImg()}
|
||||
crossOrigin="anonymous"
|
||||
alt={`Banner image for ${profile()?.displayName || "the user"}`}
|
||||
onLoad={(event) => {
|
||||
const ins = new FastAverageColor();
|
||||
const colors = ins.getColor(event.currentTarget);
|
||||
setBannerSampledColors({
|
||||
average: colors.hex,
|
||||
text: colors.isDark ? "white" : "black",
|
||||
});
|
||||
ins.destroy();
|
||||
}}
|
||||
></img>
|
||||
</div>
|
||||
|
||||
<Menu {...subscribeMenuState}>
|
||||
<MenuItem
|
||||
onClick={toggleSubscribeHome}
|
||||
aria-label={`${relationship()?.following ? "Unfollow" : "Follow"} on your home timeline`}
|
||||
>
|
||||
<ListItemAvatar>
|
||||
<Avatar src={session().account?.inf?.avatar}></Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
secondary={
|
||||
relationship()?.following
|
||||
? undefined
|
||||
: profile()?.locked
|
||||
? "A request will be sent"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span ref={useSessionDisplayName}></span>
|
||||
<span>'s Home</span>
|
||||
</ListItemText>
|
||||
|
||||
<Checkbox checked={relationship()?.following ?? false} />
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
<div
|
||||
class="intro"
|
||||
style={{
|
||||
"background-color": bannerSampledColors()?.average,
|
||||
color: bannerSampledColors()?.text,
|
||||
}}
|
||||
>
|
||||
<section class="acct-grp">
|
||||
<Avatar
|
||||
src={avatarImg()}
|
||||
alt={`${profile()?.displayName || "the user"}'s avatar`}
|
||||
sx={{
|
||||
marginTop: "calc(-16px - 72px / 2)",
|
||||
width: "72px",
|
||||
height: "72px",
|
||||
}}
|
||||
></Avatar>
|
||||
<div class="name-grp">
|
||||
<div class="display-name">
|
||||
<Show when={profile()?.bot}>
|
||||
<SmartToySharp class="acct-mark" aria-label="Bot" />
|
||||
</Show>
|
||||
<Show when={profile()?.locked}>
|
||||
<Lock class="acct-mark" aria-label="Locked" />
|
||||
</Show>
|
||||
<Body2
|
||||
component="span"
|
||||
ref={(e: HTMLElement) =>
|
||||
createRenderEffect(() => (e.innerHTML = displayName()))
|
||||
}
|
||||
aria-label="Display name"
|
||||
></Body2>
|
||||
</div>
|
||||
<span aria-label="Complete username" class="username">{fullUsername()}</span>
|
||||
</div>
|
||||
<div role="presentation">
|
||||
<Switch>
|
||||
<Match
|
||||
when={
|
||||
!session().account ||
|
||||
profileUncaught.loading ||
|
||||
profileUncaught.error
|
||||
}
|
||||
>
|
||||
{<></>}
|
||||
</Match>
|
||||
<Match when={isCurrentSessionProfile()}>
|
||||
<IconButton color="inherit">
|
||||
<Edit />
|
||||
</IconButton>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
onClick={(event) => {
|
||||
openSubscribeMenu(
|
||||
event.currentTarget.getBoundingClientRect(),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{relationship()?.following ? "Subscribed" : "Subscribe"}
|
||||
</Button>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
class="description"
|
||||
aria-label={`${profile()?.displayName || "the user"}'s description`}
|
||||
ref={(e) =>
|
||||
createRenderEffect(() => (e.innerHTML = description() || ""))
|
||||
}
|
||||
></section>
|
||||
|
||||
<table
|
||||
class="acct-fields"
|
||||
aria-label={`${profile()?.displayName || "the user"}'s fields`}
|
||||
>
|
||||
<tbody>
|
||||
<For each={profile()?.fields ?? []}>
|
||||
{(item, index) => {
|
||||
return (
|
||||
<tr data-field-index={index()}>
|
||||
<td>{item.name}</td>
|
||||
<td>
|
||||
<Show when={item.verifiedAt}>
|
||||
<Verified />
|
||||
</Show>
|
||||
</td>
|
||||
<td
|
||||
ref={(e) => {
|
||||
createRenderEffect(() => (e.innerHTML = item.value));
|
||||
}}
|
||||
></td>
|
||||
</tr>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="toot-list-toolbar">
|
||||
<TootFilterButton
|
||||
options={{
|
||||
pinned: "Pinneds",
|
||||
boost: "Boosts",
|
||||
reply: "Replies",
|
||||
original: "Originals",
|
||||
}}
|
||||
applied={recentTootFilter()}
|
||||
onApply={setRecentTootFilter}
|
||||
disabledKeys={["original"]}
|
||||
></TootFilterButton>
|
||||
</div>
|
||||
|
||||
<TimeSourceProvider value={time}>
|
||||
<Show when={recentTootFilter().pinned && pinnedToots.list.length > 0}>
|
||||
<TootList
|
||||
threads={pinnedToots.list}
|
||||
onUnknownThread={pinnedToots.getPath}
|
||||
onChangeToot={pinnedToots.set}
|
||||
/>
|
||||
<Divider />
|
||||
</Show>
|
||||
<TootList
|
||||
id={recentTootListId}
|
||||
threads={recentToots.list}
|
||||
onUnknownThread={recentToots.getPath}
|
||||
onChangeToot={recentToots.set}
|
||||
/>
|
||||
</TimeSourceProvider>
|
||||
|
||||
<Show when={!recentTootChunk()?.done}>
|
||||
<div
|
||||
style={{
|
||||
"text-align": "center",
|
||||
"padding-bottom": "var(--safe-area-inset-bottom)",
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
aria-label="Load More"
|
||||
aria-controls={recentTootListId}
|
||||
size="large"
|
||||
color="primary"
|
||||
onClick={[refetchRecentToots, "prev"]}
|
||||
disabled={isTootListLoading()}
|
||||
>
|
||||
<Show when={isTootListLoading()} fallback={<ExpandMore />}>
|
||||
<CircularProgress sx={{ width: "24px", height: "24px" }} />
|
||||
</Show>
|
||||
</IconButton>
|
||||
</div>
|
||||
</Show>
|
||||
</Scaffold>
|
||||
);
|
||||
};
|
||||
|
||||
export default Profile;
|
|
@ -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;
|
|
@ -1,34 +0,0 @@
|
|||
import { cleanupOutdatedCaches, precacheAndRoute } from "workbox-precaching";
|
||||
import { clientsClaim } from "workbox-core";
|
||||
import { dispatchCall, isJSONRPCCall, type Call } from "./workerrpc";
|
||||
|
||||
function isServiceWorker(
|
||||
self: WorkerGlobalScope,
|
||||
): self is ServiceWorkerGlobalScope {
|
||||
return !!(self as unknown as ServiceWorkerGlobalScope).registration;
|
||||
}
|
||||
|
||||
if (isServiceWorker(self)) {
|
||||
cleanupOutdatedCaches();
|
||||
precacheAndRoute(self.__WB_MANIFEST, {
|
||||
cleanURLs: false,
|
||||
});
|
||||
|
||||
// auto update
|
||||
self.skipWaiting();
|
||||
clientsClaim();
|
||||
} else {
|
||||
throw new TypeError("This entry point must be run in a service worker");
|
||||
}
|
||||
|
||||
export const Service = {
|
||||
ping() {},
|
||||
};
|
||||
|
||||
self.addEventListener("message", (event: MessageEvent<unknown>) => {
|
||||
const payload = event.data;
|
||||
if (typeof payload !== "object") return;
|
||||
if (isJSONRPCCall(payload as Record<string, unknown>)) {
|
||||
dispatchCall(Service, event as MessageEvent<Call<unknown>>);
|
||||
}
|
||||
});
|
|
@ -1,3 +0,0 @@
|
|||
export type Service = {
|
||||
ping(): void
|
||||
};
|
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "WebWorker"],
|
||||
},
|
||||
}
|
|
@ -1,214 +0,0 @@
|
|||
export type JSONRPC = {
|
||||
jsonrpc: "2.0";
|
||||
id?: string | number;
|
||||
};
|
||||
|
||||
export type Call<T = undefined> = JSONRPC & {
|
||||
method: string;
|
||||
params: T;
|
||||
};
|
||||
|
||||
export type RemoteError<E = undefined> = { data: E } & (
|
||||
| {
|
||||
code: number;
|
||||
message: string;
|
||||
}
|
||||
| {
|
||||
code: -32700;
|
||||
message: "Parse Error";
|
||||
}
|
||||
| {
|
||||
code: -32600;
|
||||
message: "Invalid Request";
|
||||
}
|
||||
| {
|
||||
code: -32601;
|
||||
message: "Method not found";
|
||||
}
|
||||
| {
|
||||
code: -32602;
|
||||
message: "Invalid params";
|
||||
}
|
||||
| {
|
||||
code: -32603;
|
||||
message: "Internal error";
|
||||
}
|
||||
);
|
||||
|
||||
export type Result<T, E> = JSONRPC & { id: string | number } & (
|
||||
| {
|
||||
result: T;
|
||||
error: undefined;
|
||||
}
|
||||
| { error: RemoteError<E>; result: undefined }
|
||||
);
|
||||
|
||||
export function isJSONRPCResult(
|
||||
object: Record<string, unknown>,
|
||||
): object is Result<unknown, unknown> {
|
||||
return object["jsonrpc"] === "2.0" && object["id"] && !object["method"];
|
||||
}
|
||||
|
||||
export function isJSONRPCCall(
|
||||
object: Record<string, unknown>,
|
||||
): object is Call<unknown> {
|
||||
return object["jsonrpc"] === "2.0" && !!object["method"];
|
||||
}
|
||||
|
||||
export class ResultDispatcher {
|
||||
private map: Map<
|
||||
number | string,
|
||||
((value: Result<unknown, unknown>) => void) | true // `true` = a call is generated, but the promise not created
|
||||
>;
|
||||
private nextId: number = Number.MIN_SAFE_INTEGER;
|
||||
|
||||
constructor() {
|
||||
this.map = new Map();
|
||||
}
|
||||
|
||||
private rollId() {
|
||||
let id = 0;
|
||||
while (this.map.get((id = this.nextId++))) {
|
||||
if (this.nextId >= Number.MAX_SAFE_INTEGER) {
|
||||
this.nextId = Number.MIN_SAFE_INTEGER;
|
||||
}
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
createCall<T>(
|
||||
method: string,
|
||||
params: T,
|
||||
): [Promise<Call<T>>, Promise<Result<unknown, unknown>>] {
|
||||
const id = this.rollId();
|
||||
const p = new Promise<Result<unknown, unknown>>((resolve) =>
|
||||
this.map.set(id, resolve),
|
||||
);
|
||||
this.map.set(id, true);
|
||||
const call: Call<T> = {
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
method,
|
||||
params,
|
||||
};
|
||||
|
||||
return [
|
||||
new Promise((resolve) => {
|
||||
const waitUntilTheIdSet = () => {
|
||||
// We must do this check to make sure the id is set before the call made.
|
||||
// or the dispatching may lost the callback
|
||||
if (this.map.get(id)) {
|
||||
resolve(call);
|
||||
} else {
|
||||
setTimeout(waitUntilTheIdSet, 0);
|
||||
}
|
||||
};
|
||||
|
||||
waitUntilTheIdSet();
|
||||
}),
|
||||
p,
|
||||
];
|
||||
}
|
||||
|
||||
dispatch(id: string | number, message: Result<unknown, unknown>) {
|
||||
{
|
||||
const callback = this.map.get(id);
|
||||
if (!callback) return;
|
||||
if (typeof callback !== "boolean") {
|
||||
callback(message);
|
||||
this.map.delete(id);
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
let retried = 0;
|
||||
|
||||
const checkAndDispatch = () => {
|
||||
const callback = this.map.get(id);
|
||||
if (typeof callback !== "boolean") {
|
||||
callback(message);
|
||||
this.map.delete(id);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
setTimeout(checkAndDispatch, 0);
|
||||
if (++retried > 3) {
|
||||
console.warn(
|
||||
`retried ${retried} time(s) but the callback is still disappeared, id is "${id}"`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// start the loop
|
||||
checkAndDispatch();
|
||||
});
|
||||
}
|
||||
|
||||
createTypedCall<
|
||||
S extends AnyService,
|
||||
E = unknown,
|
||||
K extends keyof S = keyof S,
|
||||
P extends Parameters<S[K]> = Parameters<S[K]>,
|
||||
R extends ReturnType<S[K]> = ReturnType<S[K]>,
|
||||
>(method: K, ...params: P): [Promise<Call<P>>, Promise<Result<R, E>>] {
|
||||
return this.createCall(method as string, params) as [
|
||||
Promise<Call<P>>,
|
||||
Promise<Result<R, E>>,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
type AnyService = Record<string, ((...args: unknown[]) => unknown) | undefined>
|
||||
|
||||
export async function dispatchCall<
|
||||
S extends AnyService,
|
||||
>(service: S, event: MessageEvent<Call<unknown>>) {
|
||||
try {
|
||||
const fn = service[event.data.method];
|
||||
if (!fn) {
|
||||
if (event.data.id)
|
||||
return event.source.postMessage({
|
||||
jsonrpc: "2.0",
|
||||
id: event.data.id,
|
||||
error: {
|
||||
code: -30601,
|
||||
message: "Method not found",
|
||||
},
|
||||
} as Result<void, void>);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await fn(...event.data.params as unknown[]);
|
||||
|
||||
if (!event.data.id) return;
|
||||
|
||||
event.source.postMessage({
|
||||
jsonrpc: "2.0",
|
||||
id: event.data.id,
|
||||
result: result,
|
||||
} as Result<unknown, void>);
|
||||
} catch (reason) {
|
||||
event.source.postMessage({
|
||||
jsonrpc: "2.0",
|
||||
id: event.data.id,
|
||||
error: {
|
||||
code: 0,
|
||||
message: String(reason),
|
||||
data: reason,
|
||||
},
|
||||
} as Result<unknown, unknown>);
|
||||
}
|
||||
} catch (reason) {
|
||||
if (event.data.id)
|
||||
event.source.postMessage({
|
||||
jsonrpc: "2.0",
|
||||
id: event.data.id,
|
||||
error: {
|
||||
code: -32603,
|
||||
message: "Internal error",
|
||||
data: reason,
|
||||
},
|
||||
} as Result<void, unknown>);
|
||||
}
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
import { createMemo, For, type Component, type JSX } from "solid-js";
|
||||
import Scaffold from "~material/Scaffold";
|
||||
import Scaffold from "../material/Scaffold";
|
||||
import {
|
||||
AppBar,
|
||||
IconButton,
|
||||
|
@ -19,17 +19,17 @@ import {
|
|||
autoMatchLangTag,
|
||||
createTranslator,
|
||||
SUPPORTED_LANGS,
|
||||
} from "~platform/i18n";
|
||||
import { Title } from "~material/typography";
|
||||
} from "../platform/i18n";
|
||||
import { Title } from "../material/typography";
|
||||
import type { Template } from "@solid-primitives/i18n";
|
||||
import { useStore } from "@nanostores/solid";
|
||||
import { $settings } from "./stores";
|
||||
import { useNavigator } from "~platform/StackedRouter";
|
||||
import { 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> & {
|
||||
|
@ -37,9 +37,9 @@ const ChooseLang: Component = () => {
|
|||
};
|
||||
}>,
|
||||
);
|
||||
const settings = useStore($settings);
|
||||
const settings = useStore($settings)
|
||||
|
||||
const code = () => settings().language;
|
||||
const code = () => settings().language
|
||||
|
||||
const unsupportedLangCodes = createMemo(() => {
|
||||
return iso639_1.getAllCodes().filter((x) => !["zh", "en"].includes(x));
|
||||
|
@ -48,8 +48,8 @@ const ChooseLang: Component = () => {
|
|||
const matchedLangCode = createMemo(() => autoMatchLangTag());
|
||||
|
||||
const onCodeChange = (code?: string) => {
|
||||
$settings.setKey("language", code);
|
||||
};
|
||||
$settings.setKey("language", code)
|
||||
}
|
||||
|
||||
return (
|
||||
<Scaffold
|
||||
|
@ -59,7 +59,7 @@ const ChooseLang: Component = () => {
|
|||
variant="dense"
|
||||
sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }}
|
||||
>
|
||||
<IconButton color="inherit" onClick={[pop, 1]} disableRipple>
|
||||
<IconButton color="inherit" onClick={[navigate, -1]} disableRipple>
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
<Title>{t("Choose Language")}</Title>
|
||||
|
@ -96,10 +96,7 @@ const ChooseLang: Component = () => {
|
|||
<ListItemText>{t(`lang.${c}`)}</ListItemText>
|
||||
<ListItemSecondaryAction>
|
||||
<Radio
|
||||
checked={
|
||||
code() === c ||
|
||||
(code() === undefined && matchedLangCode() == c)
|
||||
}
|
||||
checked={code() === c || (code() === undefined && matchedLangCode() == c)}
|
||||
/>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItemButton>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import type { Component } from "solid-js";
|
||||
import Scaffold from "~material/Scaffold";
|
||||
import Scaffold from "../material/Scaffold";
|
||||
import {
|
||||
AppBar,
|
||||
Divider,
|
||||
|
@ -12,15 +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";
|
||||
|
||||
const Motions: Component = () => {
|
||||
const {pop} = useNavigator();
|
||||
const navigate = useNavigate();
|
||||
const [t] = createTranslator(
|
||||
(code) =>
|
||||
import(`./i18n/${code}.json`) as Promise<{
|
||||
|
@ -36,7 +36,7 @@ const Motions: Component = () => {
|
|||
variant="dense"
|
||||
sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }}
|
||||
>
|
||||
<IconButton color="inherit" onClick={[pop, 1]} disableRipple>
|
||||
<IconButton color="inherit" onClick={[navigate, -1]} disableRipple>
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
<Title>{t("motions")}</Title>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { createMemo, For, type Component, type JSX } from "solid-js";
|
||||
import Scaffold from "~material/Scaffold";
|
||||
import Scaffold from "../material/Scaffold";
|
||||
import {
|
||||
AppBar,
|
||||
IconButton,
|
||||
|
@ -17,17 +17,17 @@ import {
|
|||
autoMatchRegion,
|
||||
createTranslator,
|
||||
SUPPORTED_REGIONS,
|
||||
} from "~platform/i18n";
|
||||
import { Title } from "~material/typography";
|
||||
} from "../platform/i18n";
|
||||
import { Title } from "../material/typography";
|
||||
import type { Template } from "@solid-primitives/i18n";
|
||||
import { useNavigate } from "@solidjs/router";
|
||||
import { $settings } from "./stores";
|
||||
import { useStore } from "@nanostores/solid";
|
||||
import { useNavigator } from "~platform/StackedRouter";
|
||||
|
||||
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> & {
|
||||
|
@ -54,10 +54,10 @@ const ChooseRegion: Component = () => {
|
|||
variant="dense"
|
||||
sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }}
|
||||
>
|
||||
<IconButton color="inherit" onClick={[pop, 1]} disableRipple>
|
||||
<IconButton color="inherit" onClick={[navigate, -1]} disableRipple>
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
<Title>{t("Choose Region")}</Title>
|
||||
<Title>{t("Choose Language")}</Title>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
}
|
||||
|
|
|
@ -1,5 +1,11 @@
|
|||
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,
|
||||
|
@ -11,7 +17,6 @@ import {
|
|||
ListItemSecondaryAction,
|
||||
ListItemText,
|
||||
ListSubheader,
|
||||
NativeSelect,
|
||||
Switch,
|
||||
Toolbar,
|
||||
} from "@suid/material";
|
||||
|
@ -23,157 +28,51 @@ import {
|
|||
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 { useSessions } from "../masto/clients.js";
|
||||
import { useNavigator } from "~platform/StackedRouter.jsx";
|
||||
|
||||
type Inset = {
|
||||
top?: number;
|
||||
right?: number;
|
||||
bottom?: number;
|
||||
left?: number;
|
||||
};
|
||||
|
||||
type SafeAreaInsets = {
|
||||
landscape: Inset;
|
||||
protrait: Inset;
|
||||
};
|
||||
|
||||
const safeAreaInsets: Record<string, SafeAreaInsets> = {
|
||||
iphone15: {
|
||||
protrait: {
|
||||
top: 59,
|
||||
bottom: 34,
|
||||
},
|
||||
landscape: {
|
||||
bottom: 21,
|
||||
left: 59,
|
||||
right: 59,
|
||||
},
|
||||
},
|
||||
iphone12: {
|
||||
protrait: {
|
||||
top: 47,
|
||||
bottom: 34,
|
||||
},
|
||||
landscape: {
|
||||
bottom: 21,
|
||||
left: 47,
|
||||
right: 47,
|
||||
},
|
||||
},
|
||||
iphone13mini: {
|
||||
protrait: {
|
||||
top: 50,
|
||||
bottom: 34,
|
||||
},
|
||||
landscape: {
|
||||
bottom: 21,
|
||||
left: 50,
|
||||
right: 50,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let screenOrientationCallback: (() => void) | undefined;
|
||||
|
||||
function removeSafeAreaEmulation(root: HTMLElement) {
|
||||
for (const name of ["top", "right", "bottom", "left"]) {
|
||||
root.style.removeProperty(`--safe-area-inset-${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
function applySafeAreaEmulation(root: HTMLElement, insets: Inset) {
|
||||
removeSafeAreaEmulation(root);
|
||||
for (const key of Object.keys(insets) as (keyof Inset)[]) {
|
||||
const value = insets[key];
|
||||
if (!value || value === 0) continue;
|
||||
root.style.setProperty(`--safe-area-inset-${key}`, `${value}px`);
|
||||
}
|
||||
}
|
||||
|
||||
function setupSafeAreaEmulation(name: string) {
|
||||
const insets = safeAreaInsets[name];
|
||||
const root = document.querySelector(":root")! as HTMLElement;
|
||||
|
||||
if (screenOrientationCallback) {
|
||||
window.screen.orientation.removeEventListener(
|
||||
"change",
|
||||
screenOrientationCallback,
|
||||
);
|
||||
screenOrientationCallback = undefined;
|
||||
}
|
||||
|
||||
removeSafeAreaEmulation(root);
|
||||
|
||||
if (insets) {
|
||||
screenOrientationCallback = () => {
|
||||
console.debug(
|
||||
`safe area emulation target: ${window.screen.orientation.type}`,
|
||||
);
|
||||
if (window.screen.orientation.type === "portrait-primary") {
|
||||
console.debug("safe area emulation: protrait");
|
||||
applySafeAreaEmulation(root, insets.protrait);
|
||||
} else if (window.screen.orientation.type === "landscape-primary") {
|
||||
console.debug("safe area emulation: landscape");
|
||||
applySafeAreaEmulation(root, insets.landscape);
|
||||
}
|
||||
};
|
||||
window.screen.orientation.addEventListener(
|
||||
"change",
|
||||
screenOrientationCallback,
|
||||
);
|
||||
screenOrientationCallback();
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.accept((mod) => {
|
||||
if (!mod) return;
|
||||
if (screenOrientationCallback) {
|
||||
mod["screenOrientationCallback"] = screenOrientationCallback;
|
||||
setTimeout(screenOrientationCallback, 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
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 } = 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);
|
||||
};
|
||||
|
||||
const subpage = children(() => props.children);
|
||||
|
||||
css`
|
||||
ul {
|
||||
padding: 0;
|
||||
|
@ -181,9 +80,6 @@ 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 (
|
||||
|
@ -194,7 +90,7 @@ const Settings: Component = () => {
|
|||
variant="dense"
|
||||
sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }}
|
||||
>
|
||||
<IconButton color="inherit" onClick={[pop, 1]} disableRipple>
|
||||
<IconButton color="inherit" onClick={[navigate, -1]} disableRipple>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
<Title>{t("Settings")}</Title>
|
||||
|
@ -202,6 +98,10 @@ const Settings: Component = () => {
|
|||
</AppBar>
|
||||
}
|
||||
>
|
||||
<BottomSheet open={!!subpage()} onClose={() => navigate(-1)}>
|
||||
{subpage()}
|
||||
</BottomSheet>
|
||||
|
||||
<List class="setting-list" use:solid-styled>
|
||||
<li>
|
||||
<ul>
|
||||
|
@ -219,9 +119,9 @@ const Settings: Component = () => {
|
|||
<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>
|
||||
{({ 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>
|
||||
|
@ -336,58 +236,7 @@ const Settings: Component = () => {
|
|||
</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"
|
||||
}
|
||||
>
|
||||
Safe Area Insets
|
||||
</ListItemText>
|
||||
</ListItem>
|
||||
<Divider />
|
||||
</li>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</List>
|
||||
</Scaffold>
|
||||
);
|
||||
|
|
|
@ -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}}",
|
||||
|
|
|
@ -12,7 +12,6 @@
|
|||
"updates.ready": "更新已准备好,下次开启会启动新版本",
|
||||
"updates.no": "已是最新版本",
|
||||
"version": "正在使用 v{{packageVersion}} ({{builtAt}}构建, {{buildMode}})",
|
||||
"version.code": "代码版本",
|
||||
"Language": "语言",
|
||||
"Region": "区域",
|
||||
"lang.auto": "(自动){{detected}}",
|
||||
|
|
|
@ -4,7 +4,7 @@ import {
|
|||
type Component,
|
||||
type JSX,
|
||||
} from "solid-js";
|
||||
import Scaffold from "~material/Scaffold";
|
||||
import Scaffold from "../material/Scaffold";
|
||||
import {
|
||||
AppBar,
|
||||
IconButton,
|
||||
|
@ -17,8 +17,8 @@ import {
|
|||
} from "@suid/material";
|
||||
import { Close as CloseIcon } from "@suid/icons-material";
|
||||
import iso639_1 from "iso-639-1";
|
||||
import { createTranslator } from "~platform/i18n";
|
||||
import { Title } from "~material/typography";
|
||||
import { createTranslator } from "../platform/i18n";
|
||||
import { Title } from "../material/typography";
|
||||
|
||||
type ChooseTootLangProps = {
|
||||
code: string;
|
||||
|
|
|
@ -2,8 +2,8 @@ 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 Img from "../material/Img";
|
||||
import { Body2 } from "../material/typography";
|
||||
import { appliedCustomEmoji } from "../masto/toot";
|
||||
import { TootPreviewCard } from "./RegularToot";
|
||||
|
||||
|
|
|
@ -3,10 +3,12 @@ import {
|
|||
Show,
|
||||
onMount,
|
||||
type ParentComponent,
|
||||
createRenderEffect,
|
||||
children,
|
||||
Suspense,
|
||||
} from "solid-js";
|
||||
import { useDocumentTitle } from "../utils";
|
||||
import Scaffold from "~material/Scaffold";
|
||||
import { type mastodon } from "masto";
|
||||
import Scaffold from "../material/Scaffold";
|
||||
import {
|
||||
AppBar,
|
||||
ListItemSecondaryAction,
|
||||
|
@ -16,16 +18,22 @@ import {
|
|||
Toolbar,
|
||||
} from "@suid/material";
|
||||
import { css } from "solid-styled";
|
||||
import { TimeSourceProvider, createTimeSource } from "~platform/timesrc";
|
||||
import { TimeSourceProvider, createTimeSource } from "../platform/timesrc";
|
||||
import ProfileMenuButton from "./ProfileMenuButton";
|
||||
import Tabs from "~material/Tabs";
|
||||
import Tab from "~material/Tab";
|
||||
import Tabs from "../material/Tabs";
|
||||
import Tab from "../material/Tab";
|
||||
import { makeEventListener } from "@solid-primitives/event-listener";
|
||||
import 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";
|
||||
|
||||
const Home: ParentComponent = (props) => {
|
||||
let panelList: HTMLDivElement;
|
||||
|
@ -34,18 +42,30 @@ const Home: ParentComponent = (props) => {
|
|||
|
||||
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 = () => {
|
||||
|
@ -82,17 +102,17 @@ const Home: ParentComponent = (props) => {
|
|||
}
|
||||
};
|
||||
|
||||
const requestRecalculateTabIndicator = () => {
|
||||
if (scrollEventLockReleased) {
|
||||
requestAnimationFrame(recalculateTabIndicator);
|
||||
}
|
||||
};
|
||||
|
||||
createRenderEffect(() => {
|
||||
makeEventListener(window, "resize", requestRecalculateTabIndicator);
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
makeEventListener(panelList, "scroll", () => {
|
||||
if (scrollEventLockReleased) {
|
||||
requestAnimationFrame(recalculateTabIndicator);
|
||||
}
|
||||
});
|
||||
makeEventListener(window, "resize", () => {
|
||||
if (scrollEventLockReleased) {
|
||||
requestAnimationFrame(recalculateTabIndicator);
|
||||
}
|
||||
});
|
||||
requestAnimationFrame(recalculateTabIndicator);
|
||||
});
|
||||
|
||||
|
@ -115,6 +135,30 @@ const Home: ParentComponent = (props) => {
|
|||
}
|
||||
};
|
||||
|
||||
const openFullScreenToot = (
|
||||
toot: mastodon.v1.Status,
|
||||
srcElement?: HTMLElement,
|
||||
reply?: boolean,
|
||||
) => {
|
||||
const p = profiles()[0];
|
||||
const inf = p.account.inf ?? profile();
|
||||
if (!inf) {
|
||||
console.warn("no account info?");
|
||||
return;
|
||||
}
|
||||
setHeroSrc((x) =>
|
||||
Object.assign({}, x, { [BOTTOM_SHEET_HERO]: srcElement }),
|
||||
);
|
||||
const acct = `${inf.username}@${p.account.site}`;
|
||||
setTootBottomSheetCache(acct, toot);
|
||||
navigate(`/${encodeURIComponent(acct)}/${toot.id}`, {
|
||||
state: reply
|
||||
? {
|
||||
tootReply: true,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
css`
|
||||
.tab-panel {
|
||||
|
@ -124,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;
|
||||
|
@ -137,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));
|
||||
|
@ -145,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%;
|
||||
}
|
||||
|
@ -165,7 +202,7 @@ const Home: ParentComponent = (props) => {
|
|||
class="responsive"
|
||||
sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }}
|
||||
>
|
||||
<Tabs>
|
||||
<Tabs onFocusChanged={setCurrentFocusOn} offset={panelOffset()}>
|
||||
<Tab focus={isTabFocus(0)} onClick={[onTabClick, 0]}>
|
||||
Home
|
||||
</Tab>
|
||||
|
@ -176,7 +213,7 @@ const Home: ParentComponent = (props) => {
|
|||
Public
|
||||
</Tab>
|
||||
</Tabs>
|
||||
<ProfileMenuButton profile={profiles()[0]}>
|
||||
<ProfileMenuButton profile={profile()}>
|
||||
<MenuItem
|
||||
onClick={(e) =>
|
||||
$settings.setKey(
|
||||
|
@ -197,23 +234,24 @@ const Home: ParentComponent = (props) => {
|
|||
>
|
||||
<TimeSourceProvider value={now}>
|
||||
<Show when={!!client()}>
|
||||
<div
|
||||
class="panel-list"
|
||||
ref={panelList!}
|
||||
onScroll={requestRecalculateTabIndicator}
|
||||
>
|
||||
<div class="panel-list" ref={panelList!}>
|
||||
<div class="tab-panel">
|
||||
<div>
|
||||
<TimelinePanel
|
||||
client={client()}
|
||||
name="home"
|
||||
prefetch={prefetching()}
|
||||
openFullScreenToot={openFullScreenToot}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-panel">
|
||||
<div>
|
||||
<TrendTimelinePanel client={client()} />
|
||||
<TrendTimelinePanel
|
||||
client={client()}
|
||||
prefetch={prefetching()}
|
||||
openFullScreenToot={openFullScreenToot}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-panel">
|
||||
|
@ -222,6 +260,7 @@ const Home: ParentComponent = (props) => {
|
|||
client={client()}
|
||||
name="public"
|
||||
prefetch={prefetching()}
|
||||
openFullScreenToot={openFullScreenToot}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -229,6 +268,13 @@ const Home: ParentComponent = (props) => {
|
|||
</div>
|
||||
</Show>
|
||||
</TimeSourceProvider>
|
||||
<Suspense>
|
||||
<HeroSourceProvider value={[heroSrc, setHeroSrc]}>
|
||||
<BottomSheet open={!!child()} onClose={() => navigate(-1)}>
|
||||
{child()}
|
||||
</BottomSheet>
|
||||
</HeroSourceProvider>
|
||||
</Suspense>
|
||||
</Scaffold>
|
||||
</>
|
||||
);
|
||||
|
|
149
src/timelines/MediaAttachmentGrid.tsx
Normal file
149
src/timelines/MediaAttachmentGrid.tsx
Normal 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;
|
|
@ -5,111 +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 { 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 [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>Bookmarks</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem disabled>
|
||||
<ListItemIcon>
|
||||
<LikeIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Likes</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem disabled>
|
||||
<ListItemIcon>
|
||||
<ListIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText>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>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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,16 +1,24 @@
|
|||
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;
|
||||
|
@ -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`
|
||||
|
|
|
@ -6,12 +6,18 @@ import {
|
|||
Show,
|
||||
createRenderEffect,
|
||||
createSignal,
|
||||
type Setter,
|
||||
createEffect,
|
||||
} from "solid-js";
|
||||
import tootStyle from "./toot.module.css";
|
||||
import { formatRelative } from "date-fns";
|
||||
import Img from "~material/Img.js";
|
||||
import { Body2 } from "~material/typography.js";
|
||||
import Img from "../material/Img.js";
|
||||
import {
|
||||
Body1,
|
||||
Body2,
|
||||
Caption,
|
||||
Subheading,
|
||||
Title,
|
||||
} from "../material/typography.js";
|
||||
import { css } from "solid-styled";
|
||||
import {
|
||||
BookmarkAddOutlined,
|
||||
|
@ -20,22 +26,86 @@ import {
|
|||
Star,
|
||||
StarOutline,
|
||||
Bookmark,
|
||||
Reply,
|
||||
Share,
|
||||
SmartToySharp,
|
||||
Lock,
|
||||
} from "@suid/icons-material";
|
||||
import { useTimeSource } from "~platform/timesrc.js";
|
||||
import { useTimeSource } from "../platform/timesrc.js";
|
||||
import { resolveCustomEmoji } from "../masto/toot.js";
|
||||
import { Divider } from "@suid/material";
|
||||
import cardStyle from "~material/cards.module.css";
|
||||
import Button from "~material/Button.js";
|
||||
import MediaAttachmentGrid from "./toots/MediaAttachmentGrid.jsx";
|
||||
import { useDateFnLocale } from "~platform/i18n";
|
||||
import { canShare, share } from "~platform/share";
|
||||
import { makeAcctText, useDefaultSession } from "../masto/clients";
|
||||
import TootContent from "./toots/TootContent";
|
||||
import BoostIcon from "./toots/BoostIcon";
|
||||
import PreviewCard from "./toots/PreviewCard";
|
||||
import { Divider, IconButton } from "@suid/material";
|
||||
import cardStyle from "../material/cards.module.css";
|
||||
import Button from "../material/Button.js";
|
||||
import MediaAttachmentGrid from "./MediaAttachmentGrid.js";
|
||||
import { FastAverageColor } from "fast-average-color";
|
||||
import Color from "colorjs.io";
|
||||
import { useDateFnLocale } from "../platform/i18n";
|
||||
import { canShare, share } from "../platform/share";
|
||||
|
||||
type TootContentViewProps = {
|
||||
source?: string;
|
||||
emojis?: mastodon.v1.CustomEmoji[];
|
||||
} & JSX.HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
const TootContentView: Component<TootContentViewProps> = (props) => {
|
||||
const [managed, rest] = splitProps(props, ["source", "emojis"]);
|
||||
return (
|
||||
<div
|
||||
ref={(ref) => {
|
||||
createRenderEffect(() => {
|
||||
ref.innerHTML = managed.source
|
||||
? managed.emojis
|
||||
? resolveCustomEmoji(managed.source, managed.emojis)
|
||||
: managed.source
|
||||
: "";
|
||||
});
|
||||
}}
|
||||
{...rest}
|
||||
></div>
|
||||
);
|
||||
};
|
||||
|
||||
const RetootIcon: Component<JSX.HTMLElementTags["i"]> = (props) => {
|
||||
const [managed, rest] = splitProps(props, ["class"]);
|
||||
css`
|
||||
.retoot-icon {
|
||||
padding: 0;
|
||||
display: inline-block;
|
||||
border-radius: 2px;
|
||||
|
||||
> :global(svg) {
|
||||
color: green;
|
||||
font-size: 1rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
`;
|
||||
return (
|
||||
<i class={["retoot-icon", managed.class].join(" ")} {...rest}>
|
||||
<Repeat />
|
||||
</i>
|
||||
);
|
||||
};
|
||||
|
||||
const ReplyIcon: Component<JSX.HTMLElementTags["i"]> = (props) => {
|
||||
const [managed, rest] = splitProps(props, ["class"]);
|
||||
css`
|
||||
.retoot-icon {
|
||||
padding: 0;
|
||||
display: inline-block;
|
||||
border-radius: 2px;
|
||||
|
||||
> :global(svg) {
|
||||
color: var(--tutu-color-primary);
|
||||
font-size: 1rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
`;
|
||||
return (
|
||||
<i class={["retoot-icon", managed.class].join(" ")} {...rest}>
|
||||
<Reply />
|
||||
</i>
|
||||
);
|
||||
};
|
||||
|
||||
type TootActionGroupProps<T extends mastodon.v1.Status> = {
|
||||
onRetoot?: (value: T) => void;
|
||||
|
@ -47,7 +117,7 @@ type TootActionGroupProps<T extends mastodon.v1.Status> = {
|
|||
) => void;
|
||||
};
|
||||
|
||||
type RegularTootProps = {
|
||||
type TootCardProps = {
|
||||
status: mastodon.v1.Status;
|
||||
actionable?: boolean;
|
||||
evaluated?: boolean;
|
||||
|
@ -139,41 +209,27 @@ function TootActionGroup<T extends mastodon.v1.Status>(
|
|||
);
|
||||
}
|
||||
|
||||
function TootAuthorGroup(
|
||||
props: {
|
||||
status: mastodon.v1.Status;
|
||||
now: Date;
|
||||
} & JSX.HTMLElementTags["div"],
|
||||
) {
|
||||
const [managed, rest] = splitProps(props, ["status", "now"]);
|
||||
const toot = () => managed.status;
|
||||
function TootAuthorGroup(props: { status: mastodon.v1.Status; now: Date }) {
|
||||
const toot = () => props.status;
|
||||
const dateFnLocale = useDateFnLocale();
|
||||
|
||||
return (
|
||||
<div class={tootStyle.tootAuthorGrp} {...rest}>
|
||||
<div class={tootStyle.tootAuthorGrp}>
|
||||
<Img src={toot().account.avatar} class={tootStyle.tootAvatar} />
|
||||
<div class={tootStyle.tootAuthorNameGrp}>
|
||||
<div class={tootStyle.tootAuthorNamePrimary}>
|
||||
<Show when={toot().account.bot}>
|
||||
<SmartToySharp class="acct-mark" aria-label="Bot" />
|
||||
</Show>
|
||||
<Show when={toot().account.locked}>
|
||||
<Lock class="acct-mark" aria-label="Locked" />
|
||||
</Show>
|
||||
<Body2
|
||||
component="span"
|
||||
ref={(e: { innerHTML: string }) => {
|
||||
createRenderEffect(() => {
|
||||
e.innerHTML = resolveCustomEmoji(
|
||||
toot().account.displayName,
|
||||
toot().account.emojis,
|
||||
);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Body2
|
||||
class={tootStyle.tootAuthorNamePrimary}
|
||||
ref={(e: { innerHTML: string }) => {
|
||||
createRenderEffect(() => {
|
||||
e.innerHTML = resolveCustomEmoji(
|
||||
toot().account.displayName,
|
||||
toot().account.emojis,
|
||||
);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<time datetime={toot().createdAt}>
|
||||
{formatRelative(toot().createdAt, managed.now, {
|
||||
{formatRelative(toot().createdAt, props.now, {
|
||||
locale: dateFnLocale(),
|
||||
})}
|
||||
</time>
|
||||
|
@ -185,57 +241,75 @@ function TootAuthorGroup(
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* find bottom-to-top the element with `data-action`.
|
||||
*/
|
||||
export function findElementActionable(
|
||||
element: HTMLElement,
|
||||
top: HTMLElement,
|
||||
): HTMLElement | undefined {
|
||||
let current = element;
|
||||
while (!current.dataset.action) {
|
||||
if (!current.parentElement || current.parentElement === top) {
|
||||
return undefined;
|
||||
export function TootPreviewCard(props: {
|
||||
src: mastodon.v1.PreviewCard;
|
||||
alwaysCompact?: boolean;
|
||||
}) {
|
||||
let root: HTMLAnchorElement;
|
||||
|
||||
createEffect(() => {
|
||||
if (props.alwaysCompact) {
|
||||
root.classList.add(tootStyle.compact);
|
||||
return;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
return current;
|
||||
if (!props.src.width) return;
|
||||
const width = root.getBoundingClientRect().width;
|
||||
if (width > props.src.width) {
|
||||
root.classList.add(tootStyle.compact);
|
||||
} else {
|
||||
root.classList.remove(tootStyle.compact);
|
||||
}
|
||||
});
|
||||
|
||||
const onImgLoad = (event: Event & { currentTarget: HTMLImageElement }) => {
|
||||
// TODO: better extraction algorithm
|
||||
// I'd like to use a pattern panel and match one in the panel from the extracted color
|
||||
const fac = new FastAverageColor();
|
||||
const result = fac.getColor(event.currentTarget);
|
||||
if (result.error) {
|
||||
console.error(result.error);
|
||||
fac.destroy();
|
||||
return;
|
||||
}
|
||||
root.style.setProperty("--tutu-color-surface", result.hex);
|
||||
const focusSurface = result.isDark
|
||||
? new Color(result.hex).darken(0.2)
|
||||
: new Color(result.hex).lighten(0.2);
|
||||
root.style.setProperty("--tutu-color-surface-d", focusSurface.toString());
|
||||
const textColor = result.isDark ? "white" : "black";
|
||||
const secondaryTextColor = new Color(textColor);
|
||||
secondaryTextColor.alpha = 0.75;
|
||||
root.style.setProperty("--tutu-color-on-surface", textColor);
|
||||
root.style.setProperty(
|
||||
"--tutu-color-secondary-text-on-surface",
|
||||
secondaryTextColor.toString(),
|
||||
);
|
||||
fac.destroy();
|
||||
};
|
||||
|
||||
return (
|
||||
<a
|
||||
ref={root!}
|
||||
class={tootStyle.previewCard}
|
||||
href={props.src.url}
|
||||
target="_blank"
|
||||
referrerPolicy="unsafe-url"
|
||||
>
|
||||
<Show when={props.src.image}>
|
||||
<img
|
||||
crossOrigin="anonymous"
|
||||
src={props.src.image!}
|
||||
onLoad={onImgLoad}
|
||||
loading="lazy"
|
||||
/>
|
||||
</Show>
|
||||
<Title component="h1">{props.src.title}</Title>
|
||||
<Body1 component="p">{props.src.description}</Body1>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function onToggleReveal(setValue: Setter<boolean>, event: Event) {
|
||||
event.stopPropagation();
|
||||
setValue((x) => !x);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for a toot.
|
||||
*
|
||||
* If the session involved is not the first session, you must wrap
|
||||
* this component under a `<DefaultSessionProvier />` with correct
|
||||
* session.
|
||||
*
|
||||
* **Handling Clicks**
|
||||
* There are multiple actions supported in the component. Some handlers
|
||||
* are passed in, some should be handled as the click event.
|
||||
*
|
||||
* For those handler directly passed in, see the props starts with "on".
|
||||
* We are moving to the new method below.
|
||||
*
|
||||
* The following actions are handled by the click event:
|
||||
* - `[data-action="acct"]`: open the profile page of a account
|
||||
* - `[data-acct-id]` is the account id for the client
|
||||
* - `[data-client]` is the client perferred
|
||||
* - `[href]` is the url of the account
|
||||
*
|
||||
* Handling the click event for this component, you should use
|
||||
* {@link findElementActionable} to find out if the click event has
|
||||
* additional intent. If the event's target is any from
|
||||
* the subtree of any "actionable" element, the function returns the element.
|
||||
*
|
||||
* You can extract the intent from the attributes of the "actionable" element.
|
||||
* The action type is the dataset's `action`.
|
||||
*/
|
||||
const RegularToot: Component<RegularTootProps> = (props) => {
|
||||
const RegularToot: Component<TootCardProps> = (props) => {
|
||||
let rootRef: HTMLElement;
|
||||
const [managed, managedActionGroup, rest] = splitProps(
|
||||
props,
|
||||
|
@ -245,8 +319,6 @@ const RegularToot: Component<RegularTootProps> = (props) => {
|
|||
const now = useTimeSource();
|
||||
const status = () => managed.status;
|
||||
const toot = () => status().reblog ?? status();
|
||||
const session = useDefaultSession();
|
||||
const [reveal, setReveal] = createSignal(false);
|
||||
|
||||
css`
|
||||
.reply-sep {
|
||||
|
@ -293,7 +365,7 @@ const RegularToot: Component<RegularTootProps> = (props) => {
|
|||
|
||||
return (
|
||||
<>
|
||||
<article
|
||||
<section
|
||||
classList={{
|
||||
[tootStyle.toot]: true,
|
||||
[tootStyle.expanded]: managed.evaluated,
|
||||
|
@ -308,49 +380,33 @@ const RegularToot: Component<RegularTootProps> = (props) => {
|
|||
>
|
||||
<Show when={!!status().reblog}>
|
||||
<div class={tootStyle.tootRetootGrp}>
|
||||
<BoostIcon />
|
||||
<Body2
|
||||
ref={(e: { innerHTML: string }) => {
|
||||
createRenderEffect(() => {
|
||||
e.innerHTML = resolveCustomEmoji(
|
||||
status().account.displayName,
|
||||
toot().emojis,
|
||||
);
|
||||
});
|
||||
}}
|
||||
></Body2>
|
||||
<span>boosts</span>
|
||||
<RetootIcon />
|
||||
<span>
|
||||
<Body2
|
||||
ref={(e: { innerHTML: string }) => {
|
||||
createRenderEffect(() => {
|
||||
e.innerHTML = resolveCustomEmoji(
|
||||
status().account.displayName,
|
||||
toot().emojis,
|
||||
);
|
||||
});
|
||||
}}
|
||||
></Body2>{" "}
|
||||
boosted
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
<TootAuthorGroup
|
||||
status={toot()}
|
||||
now={now()}
|
||||
data-action="acct"
|
||||
data-client={session() ? makeAcctText(session()!) : undefined}
|
||||
data-acct-id={toot().account.id}
|
||||
/>
|
||||
<TootContent
|
||||
<TootAuthorGroup status={toot()} now={now()} />
|
||||
<TootContentView
|
||||
source={toot().content}
|
||||
emojis={toot().emojis}
|
||||
mentions={toot().mentions}
|
||||
class={cardStyle.cardNoPad}
|
||||
sensitive={toot().sensitive}
|
||||
spoilerText={toot().spoilerText}
|
||||
reveal={reveal()}
|
||||
onToggleReveal={[onToggleReveal, setReveal]}
|
||||
class={tootStyle.tootContent}
|
||||
/>
|
||||
<Show
|
||||
when={
|
||||
toot().card && (!toot().sensitive || (toot().sensitive && reveal()))
|
||||
}
|
||||
>
|
||||
<PreviewCard src={toot().card!} />
|
||||
<Show when={toot().card}>
|
||||
<TootPreviewCard src={toot().card!} />
|
||||
</Show>
|
||||
<Show when={toot().mediaAttachments.length > 0}>
|
||||
<MediaAttachmentGrid
|
||||
attachments={toot().mediaAttachments}
|
||||
sensitive={toot().sensitive}
|
||||
/>
|
||||
<MediaAttachmentGrid attachments={toot().mediaAttachments} />
|
||||
</Show>
|
||||
<Show when={managed.actionable}>
|
||||
<Divider
|
||||
|
@ -359,7 +415,7 @@ const RegularToot: Component<RegularTootProps> = (props) => {
|
|||
/>
|
||||
<TootActionGroup value={toot()} {...managedActionGroup} />
|
||||
</Show>
|
||||
</article>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
92
src/timelines/Thread.tsx
Normal file
92
src/timelines/Thread.tsx
Normal file
|
@ -0,0 +1,92 @@
|
|||
import type { mastodon } from "masto";
|
||||
import {
|
||||
For,
|
||||
Show,
|
||||
createResource,
|
||||
createSignal,
|
||||
type Component,
|
||||
type Ref,
|
||||
} from "solid-js";
|
||||
import CompactToot from "./CompactToot";
|
||||
import { useTimeSource } from "../platform/timesrc";
|
||||
import RegularToot, { findRootToot } from "./RegularToot";
|
||||
import cardStyle from "../material/cards.module.css";
|
||||
import { css } from "solid-styled";
|
||||
|
||||
type TootActionTarget = {
|
||||
client: mastodon.rest.Client;
|
||||
status: mastodon.v1.Status;
|
||||
};
|
||||
|
||||
type TootActions = {
|
||||
onBoost(client: mastodon.rest.Client, status: mastodon.v1.Status): void;
|
||||
onBookmark(client: mastodon.rest.Client, status: mastodon.v1.Status): void;
|
||||
onReply(target: TootActionTarget, element: HTMLElement): void;
|
||||
};
|
||||
|
||||
type ThreadProps = {
|
||||
ref?: Ref<HTMLElement>;
|
||||
client: mastodon.rest.Client;
|
||||
toots: readonly mastodon.v1.Status[];
|
||||
isExpended: (status: mastodon.v1.Status) => boolean;
|
||||
|
||||
onItemClick(status: mastodon.v1.Status, event: MouseEvent): void;
|
||||
} & TootActions;
|
||||
|
||||
const Thread: Component<ThreadProps> = (props) => {
|
||||
const boost = (status: mastodon.v1.Status) => {
|
||||
props.onBoost(props.client, status);
|
||||
};
|
||||
|
||||
const bookmark = (status: mastodon.v1.Status) => {
|
||||
props.onBookmark(props.client, status);
|
||||
};
|
||||
|
||||
const reply = (
|
||||
status: mastodon.v1.Status,
|
||||
event: MouseEvent & { currentTarget: HTMLElement },
|
||||
) => {
|
||||
const element = findRootToot(event.currentTarget);
|
||||
props.onReply({ client: props.client, status }, element);
|
||||
};
|
||||
|
||||
css`
|
||||
.thread {
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
`
|
||||
return (
|
||||
<article ref={props.ref} class="thread">
|
||||
<For each={props.toots}>
|
||||
{(status, index) => {
|
||||
const useThread = props.toots.length > 1;
|
||||
const threadPosition = useThread
|
||||
? index() === 0
|
||||
? "top"
|
||||
: index() === props.toots.length - 1
|
||||
? "bottom"
|
||||
: "middle"
|
||||
: undefined;
|
||||
return (
|
||||
<RegularToot
|
||||
data-status-id={status.id}
|
||||
data-thread-sort={index()}
|
||||
status={status}
|
||||
thread={threadPosition}
|
||||
class={cardStyle.card}
|
||||
evaluated={props.isExpended(status)}
|
||||
actionable={props.isExpended(status)}
|
||||
onBookmark={(s) => bookmark(s)}
|
||||
onRetoot={(s) => boost(s)}
|
||||
onReply={reply}
|
||||
onClick={[props.onItemClick, status]}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
export default Thread;
|
|
@ -1,5 +1,6 @@
|
|||
import {
|
||||
Component,
|
||||
For,
|
||||
onCleanup,
|
||||
createSignal,
|
||||
Show,
|
||||
|
@ -11,30 +12,75 @@ import {
|
|||
import { type mastodon } from "masto";
|
||||
import { Button, LinearProgress } from "@suid/material";
|
||||
import { createTimeline } from "../masto/timelines";
|
||||
|
||||
import { vibrate } from "../platform/hardware";
|
||||
import PullDownToRefresh from "./PullDownToRefresh";
|
||||
import TootComposer from "./TootComposer";
|
||||
import TootList from "./TootList";
|
||||
import Thread from "./Thread.jsx";
|
||||
|
||||
const TimelinePanel: Component<{
|
||||
client: mastodon.rest.Client;
|
||||
name: "home" | "public";
|
||||
prefetch?: boolean;
|
||||
|
||||
openFullScreenToot: (
|
||||
toot: mastodon.v1.Status,
|
||||
srcElement?: HTMLElement,
|
||||
reply?: boolean,
|
||||
) => void;
|
||||
}> = (props) => {
|
||||
const [scrollLinked, setScrollLinked] = createSignal<HTMLElement>();
|
||||
|
||||
const [timeline, snapshot, { refetch: refetchTimeline }] = createTimeline(
|
||||
() => props.client.v1.timelines[props.name],
|
||||
() => ({ limit: 20 }),
|
||||
() => 20,
|
||||
);
|
||||
const [expandedThreadId, setExpandedThreadId] = createSignal<string>();
|
||||
const [typing, setTyping] = createSignal(false);
|
||||
|
||||
const tlEndObserver = new IntersectionObserver(() => {
|
||||
if (untrack(() => props.prefetch) && !snapshot.loading)
|
||||
refetchTimeline("prev");
|
||||
refetchTimeline("next");
|
||||
});
|
||||
|
||||
onCleanup(() => tlEndObserver.disconnect());
|
||||
|
||||
const onBookmark = async (
|
||||
client: mastodon.rest.Client,
|
||||
status: mastodon.v1.Status,
|
||||
) => {
|
||||
const result = await (status.bookmarked
|
||||
? client.v1.statuses.$select(status.id).unbookmark()
|
||||
: client.v1.statuses.$select(status.id).bookmark());
|
||||
timeline.set(result.id, result);
|
||||
};
|
||||
|
||||
const onBoost = async (
|
||||
client: mastodon.rest.Client,
|
||||
status: mastodon.v1.Status,
|
||||
) => {
|
||||
vibrate(50);
|
||||
const rootStatus = status.reblog ? status.reblog : status;
|
||||
const reblogged = rootStatus.reblogged;
|
||||
if (status.reblog) {
|
||||
status.reblog = { ...status.reblog, reblogged: !reblogged };
|
||||
timeline.set(status.id, status);
|
||||
} else {
|
||||
timeline.set(
|
||||
status.id,
|
||||
Object.assign(status, {
|
||||
reblogged: !reblogged,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const result = reblogged
|
||||
? await client.v1.statuses.$select(status.id).unreblog()
|
||||
: (await client.v1.statuses.$select(status.id).reblog()).reblog!;
|
||||
timeline.set(
|
||||
status.id,
|
||||
Object.assign(status.reblog ?? status, result.reblog),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallback={(err, reset) => {
|
||||
|
@ -58,16 +104,42 @@ const TimelinePanel: Component<{
|
|||
style={{
|
||||
"--scaffold-topbar-height": "0px",
|
||||
}}
|
||||
isTyping={typing()}
|
||||
onTypingChange={setTyping}
|
||||
client={props.client}
|
||||
onSent={() => refetchTimeline("prev")}
|
||||
/>
|
||||
</Show>
|
||||
<For each={timeline.list}>
|
||||
{(itemId, index) => {
|
||||
const path = timeline.getPath(itemId)!;
|
||||
const toots = path.reverse().map((x) => x.value);
|
||||
|
||||
<TootList
|
||||
threads={timeline.list}
|
||||
onUnknownThread={timeline.getPath}
|
||||
onChangeToot={timeline.set}
|
||||
></TootList>
|
||||
return (
|
||||
<Thread
|
||||
toots={toots}
|
||||
onBoost={onBoost}
|
||||
onBookmark={onBookmark}
|
||||
onReply={({ status }, element) =>
|
||||
props.openFullScreenToot(status, element, true)
|
||||
}
|
||||
client={props.client}
|
||||
isExpended={(status) => status.id === expandedThreadId()}
|
||||
onItemClick={(status, event) => {
|
||||
setTyping(false);
|
||||
if (status.id !== expandedThreadId()) {
|
||||
setExpandedThreadId((x) => (x ? undefined : status.id));
|
||||
} else {
|
||||
props.openFullScreenToot(
|
||||
status,
|
||||
event.currentTarget as HTMLElement,
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<div ref={(e) => tlEndObserver.observe(e)}></div>
|
||||
|
|
|
@ -1,17 +0,0 @@
|
|||
.TootBottomSheet {
|
||||
overflow: hidden;
|
||||
|
||||
.Scrollable {
|
||||
padding-bottom: var(--safe-area-inset-bottom, 0);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior-y: contain;
|
||||
height: calc(100% - var(--scaffold-topbar-height, 0px));
|
||||
}
|
||||
|
||||
.progress-line {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
|
@ -1,31 +1,29 @@
|
|||
import { useLocation, useParams } from "@solidjs/router";
|
||||
import { useLocation, useNavigate, useParams } from "@solidjs/router";
|
||||
import {
|
||||
catchError,
|
||||
createEffect,
|
||||
createRenderEffect,
|
||||
createResource,
|
||||
createSignal,
|
||||
For,
|
||||
Show,
|
||||
type Component,
|
||||
} from "solid-js";
|
||||
import Scaffold from "~material/Scaffold";
|
||||
import Scaffold from "../material/Scaffold";
|
||||
import { AppBar, CircularProgress, IconButton, Toolbar } from "@suid/material";
|
||||
import { Title } from "~material/typography";
|
||||
import { Close as CloseIcon } from "@suid/icons-material";
|
||||
import { useSessionForAcctStr } from "../masto/clients";
|
||||
import { Title } from "../material/typography";
|
||||
import {
|
||||
ArrowBack as BackIcon,
|
||||
Close as CloseIcon,
|
||||
} from "@suid/icons-material";
|
||||
import { createUnauthorizedClient, useSessions } from "../masto/clients";
|
||||
import { resolveCustomEmoji } from "../masto/toot";
|
||||
import RegularToot, { findElementActionable } from "./RegularToot";
|
||||
import RegularToot from "./RegularToot";
|
||||
import type { mastodon } from "masto";
|
||||
import cards from "~material/cards.module.css";
|
||||
import cards from "../material/cards.module.css";
|
||||
import { css } from "solid-styled";
|
||||
import { vibrate } from "~platform/hardware";
|
||||
import { createTimeSource, TimeSourceProvider } from "~platform/timesrc";
|
||||
import { vibrate } from "../platform/hardware";
|
||||
import { createTimeSource, TimeSourceProvider } from "../platform/timesrc";
|
||||
import TootComposer from "./TootComposer";
|
||||
import { useDocumentTitle } from "../utils";
|
||||
import { createTimelineControlsForArray } from "../masto/timelines";
|
||||
import TootList from "./TootList";
|
||||
import "./TootBottomSheet.css";
|
||||
import { useNavigator } from "~platform/StackedRouter";
|
||||
import BackButton from "~platform/BackButton";
|
||||
|
||||
let cachedEntry: [string, mastodon.v1.Status] | undefined;
|
||||
|
||||
|
@ -42,12 +40,32 @@ function getCache(acct: string, id: string) {
|
|||
const TootBottomSheet: Component = (props) => {
|
||||
const params = useParams<{ acct: string; id: string }>();
|
||||
const location = useLocation<{
|
||||
tootBottomSheetPushedCount?: number;
|
||||
tootReply?: boolean;
|
||||
}>();
|
||||
const { pop, push } = useNavigator();
|
||||
const navigate = useNavigate();
|
||||
const allSession = useSessions();
|
||||
const time = createTimeSource();
|
||||
const [isInTyping, setInTyping] = createSignal(false);
|
||||
const acctText = () => decodeURIComponent(params.acct);
|
||||
const session = useSessionForAcctStr(acctText);
|
||||
const session = () => {
|
||||
const [inputUsername, inputSite] = acctText().split("@", 2);
|
||||
const authedSession = allSession().find(
|
||||
(x) =>
|
||||
x.account.site === inputSite &&
|
||||
x.account.inf?.username === inputUsername,
|
||||
);
|
||||
return (
|
||||
authedSession ?? {
|
||||
client: createUnauthorizedClient(inputSite),
|
||||
account: undefined,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const pushedCount = () => {
|
||||
return location.state?.tootBottomSheetPushedCount || 0;
|
||||
};
|
||||
|
||||
const [remoteToot, { mutate: setRemoteToot }] = createResource(
|
||||
() => [session().client, params.id] as const,
|
||||
|
@ -56,10 +74,7 @@ const TootBottomSheet: Component = (props) => {
|
|||
},
|
||||
);
|
||||
|
||||
const toot = () =>
|
||||
catchError(remoteToot, (error) => {
|
||||
console.error(error);
|
||||
}) ?? getCache(acctText(), params.id);
|
||||
const toot = () => remoteToot() ?? getCache(acctText(), params.id);
|
||||
|
||||
createEffect((lastTootId?: string) => {
|
||||
const tootId = toot()?.id;
|
||||
|
@ -69,40 +84,30 @@ const TootBottomSheet: Component = (props) => {
|
|||
return tootId;
|
||||
});
|
||||
|
||||
const [tootContextErrorUncaught, { refetch: refetchContext }] =
|
||||
createResource(
|
||||
() => [session().client, params.id] as const,
|
||||
async ([client, id]) => {
|
||||
return await client.v1.statuses.$select(id).context.fetch();
|
||||
},
|
||||
);
|
||||
createEffect(() => {
|
||||
if (location.state?.tootReply) {
|
||||
setInTyping(true);
|
||||
}
|
||||
});
|
||||
|
||||
const tootContext = () =>
|
||||
catchError(tootContextErrorUncaught, (error) => {
|
||||
console.error(error);
|
||||
});
|
||||
const [tootContext, { refetch: refetchContext }] = createResource(
|
||||
() => [session().client, params.id] as const,
|
||||
async ([client, id]) => {
|
||||
return await client.v1.statuses.$select(id).context.fetch();
|
||||
},
|
||||
);
|
||||
|
||||
const ancestors = createTimelineControlsForArray(
|
||||
() => tootContext()?.ancestors,
|
||||
);
|
||||
const descendants = createTimelineControlsForArray(
|
||||
() => tootContext()?.descendants,
|
||||
);
|
||||
const ancestors = () => tootContext()?.ancestors ?? [];
|
||||
const descendants = () => tootContext()?.descendants ?? [];
|
||||
|
||||
createEffect(() => {
|
||||
if (ancestors.list.length > 0) {
|
||||
if (ancestors().length > 0) {
|
||||
document.querySelector(`#toot-${toot()!.id}`)?.scrollIntoView();
|
||||
}
|
||||
});
|
||||
|
||||
useDocumentTitle(() => {
|
||||
const t = toot()?.reblog ?? toot();
|
||||
const name = t?.account.displayName ?? "Someone";
|
||||
return `${name}'s toot`;
|
||||
});
|
||||
|
||||
const tootDisplayName = () => {
|
||||
const t = toot()?.reblog ?? toot();
|
||||
const t = toot();
|
||||
if (t) {
|
||||
return resolveCustomEmoji(t.account.displayName, t.account.emojis);
|
||||
}
|
||||
|
@ -157,45 +162,31 @@ const TootBottomSheet: Component = (props) => {
|
|||
setRemoteToot(result);
|
||||
};
|
||||
|
||||
const switchContext = (status: mastodon.v1.Status) => {
|
||||
if (isInTyping()) {
|
||||
setInTyping(false);
|
||||
return;
|
||||
}
|
||||
setCache(params.acct, status);
|
||||
navigate(`/${params.acct}/${status.id}`, {
|
||||
state: {
|
||||
tootBottomSheetPushedCount: pushedCount() + 1,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const defaultMentions = () => {
|
||||
const tootAcct = remoteToot()?.reblog?.account ?? remoteToot()?.account;
|
||||
if (!tootAcct) {
|
||||
return;
|
||||
}
|
||||
|
||||
const others = ancestors.list.map((x) => ancestors.get(x)!.value.account);
|
||||
const others = ancestors().map((x) => x.account);
|
||||
|
||||
const values = [tootAcct, ...others].map((x) => `@${x.acct}`);
|
||||
return Array.from(new Set(values).keys());
|
||||
};
|
||||
|
||||
const handleMainTootClick = (
|
||||
event: MouseEvent & { currentTarget: HTMLElement },
|
||||
) => {
|
||||
const actionableElement = findElementActionable(
|
||||
event.target as HTMLElement,
|
||||
event.currentTarget,
|
||||
);
|
||||
|
||||
if (actionableElement) {
|
||||
if (actionableElement.dataset.action === "acct") {
|
||||
event.stopPropagation();
|
||||
|
||||
const target = actionableElement as HTMLAnchorElement;
|
||||
|
||||
const acct = encodeURIComponent(
|
||||
target.dataset.client || `@${new URL(target.href).origin}`,
|
||||
);
|
||||
|
||||
push(`/${acct}/profile/${target.dataset.acctId}`);
|
||||
|
||||
return;
|
||||
} else {
|
||||
console.warn("unknown action", actionableElement.dataset.rel);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
css`
|
||||
.name :global(img) {
|
||||
max-height: 1em;
|
||||
|
@ -229,8 +220,14 @@ const TootBottomSheet: Component = (props) => {
|
|||
variant="dense"
|
||||
sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }}
|
||||
>
|
||||
<BackButton color="inherit" />
|
||||
<Title component="div" class="name" use:solid-styled>
|
||||
<IconButton color="inherit" onClick={[navigate, -1]} disableRipple>
|
||||
{pushedCount() > 0 ? <BackIcon /> : <CloseIcon />}
|
||||
</IconButton>
|
||||
<Title
|
||||
component="div"
|
||||
class="name"
|
||||
use:solid-styled
|
||||
>
|
||||
<span
|
||||
ref={(e: HTMLElement) =>
|
||||
createRenderEffect(
|
||||
|
@ -243,62 +240,77 @@ const TootBottomSheet: Component = (props) => {
|
|||
</Toolbar>
|
||||
</AppBar>
|
||||
}
|
||||
class="TootBottomSheet"
|
||||
>
|
||||
<div class="Scrollable">
|
||||
<TimeSourceProvider value={time}>
|
||||
<TootList
|
||||
threads={ancestors.list}
|
||||
onUnknownThread={ancestors.getPath}
|
||||
onChangeToot={ancestors.set}
|
||||
/>
|
||||
<TimeSourceProvider value={time}>
|
||||
<For each={ancestors()}>
|
||||
{(item) => (
|
||||
<RegularToot
|
||||
id={`toot-${item.id}`}
|
||||
class={cards.card}
|
||||
status={item}
|
||||
actionable={false}
|
||||
onClick={[switchContext, item]}
|
||||
></RegularToot>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<article>
|
||||
<Show when={toot()}>
|
||||
<RegularToot
|
||||
id={`toot-${toot()!.id}`}
|
||||
class={cards.card}
|
||||
style={{
|
||||
"scroll-margin-top":
|
||||
"calc(var(--scaffold-topbar-height) + 20px)",
|
||||
"cursor": "auto",
|
||||
"user-select": "auto",
|
||||
}}
|
||||
status={toot()!}
|
||||
actionable={!!actSession()}
|
||||
evaluated={true}
|
||||
onBookmark={onBookmark}
|
||||
onRetoot={onBoost}
|
||||
onFavourite={onFav}
|
||||
onClick={handleMainTootClick}
|
||||
></RegularToot>
|
||||
</Show>
|
||||
</article>
|
||||
|
||||
<Show when={session()!.account}>
|
||||
<TootComposer
|
||||
mentions={defaultMentions()}
|
||||
profile={session().account!}
|
||||
replyToDisplayName={toot()?.account?.displayName || ""}
|
||||
client={session().client}
|
||||
onSent={() => refetchContext()}
|
||||
inReplyToId={remoteToot()?.reblog?.id ?? remoteToot()?.id}
|
||||
/>
|
||||
<article>
|
||||
<Show when={toot()}>
|
||||
<RegularToot
|
||||
id={`toot-${toot()!.id}`}
|
||||
class={cards.card}
|
||||
style={{
|
||||
"scroll-margin-top":
|
||||
"calc(var(--scaffold-topbar-height) + 20px)",
|
||||
}}
|
||||
status={toot()!}
|
||||
actionable={!!actSession()}
|
||||
evaluated={true}
|
||||
onBookmark={onBookmark}
|
||||
onRetoot={onBoost}
|
||||
onFavourite={onFav}
|
||||
></RegularToot>
|
||||
</Show>
|
||||
</article>
|
||||
|
||||
<Show when={tootContextErrorUncaught.loading}>
|
||||
<div class="progress-line">
|
||||
<CircularProgress style="width: 1.5em; height: 1.5em;" />
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<TootList
|
||||
threads={descendants.list}
|
||||
onUnknownThread={descendants.getPath}
|
||||
onChangeToot={descendants.set}
|
||||
<Show when={session()!.account}>
|
||||
<TootComposer
|
||||
isTyping={isInTyping()}
|
||||
onTypingChange={setInTyping}
|
||||
mentions={defaultMentions()}
|
||||
profile={session().account!}
|
||||
replyToDisplayName={toot()?.account?.displayName || ""}
|
||||
client={session().client}
|
||||
onSent={() => refetchContext()}
|
||||
inReplyToId={remoteToot()?.reblog?.id ?? remoteToot()?.id}
|
||||
/>
|
||||
</TimeSourceProvider>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={tootContext.loading}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
"justify-content": "center",
|
||||
"margin-block": "12px",
|
||||
}}
|
||||
>
|
||||
<CircularProgress style="width: 1.5em; height: 1.5em;" />
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<For each={descendants()}>
|
||||
{(item) => (
|
||||
<RegularToot
|
||||
id={`toot-${item.id}`}
|
||||
class={cards.card}
|
||||
status={item}
|
||||
actionable={false}
|
||||
onClick={[switchContext, item]}
|
||||
></RegularToot>
|
||||
)}
|
||||
</For>
|
||||
</TimeSourceProvider>
|
||||
<div style={{ height: "var(--safe-area-inset-bottom, 0)" }}></div>
|
||||
</Scaffold>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,45 +0,0 @@
|
|||
.TootComposer {
|
||||
--card-gut: 8px;
|
||||
contain: content;
|
||||
|
||||
> .MuiToolbar-root {
|
||||
justify-content: space-between;
|
||||
|
||||
> :first-child {
|
||||
margin-left: -0.5em;
|
||||
}
|
||||
|
||||
> :last-child {
|
||||
margin-right: -0.5em;
|
||||
}
|
||||
}
|
||||
|
||||
.reply-input {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.options {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 16px;
|
||||
flex-flow: row wrap;
|
||||
padding-top: 16px;
|
||||
padding-bottom: 8px;
|
||||
margin-left: -0.5em;
|
||||
margin-right: -0.5em;
|
||||
|
||||
animation: TootComposerFadeIn 110ms var(--tutu-anim-curve-sharp) both;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes TootComposerFadeIn {
|
||||
0% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
11
src/timelines/TootComposer.module.css
Normal file
11
src/timelines/TootComposer.module.css
Normal file
|
@ -0,0 +1,11 @@
|
|||
|
||||
.composer {
|
||||
composes: card from "../material/cards.module.css";
|
||||
--card-gut: 8px;
|
||||
}
|
||||
|
||||
.replyInput {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
|
@ -1,15 +1,15 @@
|
|||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createRenderEffect,
|
||||
createSignal,
|
||||
createUniqueId,
|
||||
onMount,
|
||||
Show,
|
||||
type Accessor,
|
||||
type Component,
|
||||
type JSX,
|
||||
type Ref,
|
||||
} from "solid-js";
|
||||
import Scaffold from "~material/Scaffold";
|
||||
import Scaffold from "../material/Scaffold";
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
|
@ -23,9 +23,6 @@ import {
|
|||
Switch,
|
||||
Divider,
|
||||
CircularProgress,
|
||||
Toolbar,
|
||||
MenuItem,
|
||||
ListItemAvatar,
|
||||
} from "@suid/material";
|
||||
import {
|
||||
ArrowDropDown,
|
||||
|
@ -36,27 +33,20 @@ import {
|
|||
ListAlt as ListAltIcon,
|
||||
Visibility,
|
||||
Translate,
|
||||
Close,
|
||||
MoreVert,
|
||||
} from "@suid/icons-material";
|
||||
import type { Account } from "../accounts/stores";
|
||||
import "./TootComposer.css";
|
||||
import BottomSheet from "~material/BottomSheet";
|
||||
import { useLanguage } from "~platform/i18n";
|
||||
import tootComposers from "./TootComposer.module.css";
|
||||
import { makeEventListener } from "@solid-primitives/event-listener";
|
||||
import BottomSheet from "../material/BottomSheet";
|
||||
import { useLanguage } from "../platform/i18n";
|
||||
import iso639_1 from "iso-639-1";
|
||||
import ChooseTootLang from "./ChooseTootLang";
|
||||
import type { mastodon } from "masto";
|
||||
import cardStyles from "~material/cards.module.css";
|
||||
import Menu, { createManagedMenuState } from "~material/Menu";
|
||||
import { useDefaultSession } from "../masto/clients";
|
||||
import { resolveCustomEmoji } from "../masto/toot";
|
||||
import SizedTextarea from "~platform/SizedTextarea";
|
||||
|
||||
type TootVisibility = "public" | "unlisted" | "private" | "direct";
|
||||
|
||||
const TootVisibilityPickerDialog: Component<{
|
||||
open?: boolean;
|
||||
class?: string;
|
||||
onClose: () => void;
|
||||
visibility: TootVisibility;
|
||||
onVisibilityChange: (value: TootVisibility) => void;
|
||||
|
@ -86,19 +76,14 @@ const TootVisibilityPickerDialog: Component<{
|
|||
};
|
||||
|
||||
return (
|
||||
<BottomSheet
|
||||
open={props.open}
|
||||
onClose={props.onClose}
|
||||
bottomUp
|
||||
class={props.class}
|
||||
>
|
||||
<BottomSheet open={props.open} onClose={props.onClose} bottomUp>
|
||||
<Scaffold
|
||||
bottom={
|
||||
<div
|
||||
style={{
|
||||
"border-top": "1px solid #ddd",
|
||||
background: "var(--tutu-color-surface)",
|
||||
padding: "8px 16px calc(8px + var(--safe-area-inset-bottom, 0px))",
|
||||
padding: "8px 16px",
|
||||
width: "100%",
|
||||
"text-align": "end",
|
||||
}}
|
||||
|
@ -178,13 +163,12 @@ const TootVisibilityPickerDialog: Component<{
|
|||
|
||||
const TootLanguagePickerDialog: Component<{
|
||||
open?: boolean;
|
||||
class?: string;
|
||||
onClose: () => void;
|
||||
code: string;
|
||||
onCodeChange: (nval: string) => void;
|
||||
}> = (props) => {
|
||||
return (
|
||||
<BottomSheet open={props.open} onClose={props.onClose} class={props.class}>
|
||||
<BottomSheet open={props.open} onClose={props.onClose}>
|
||||
<Show when={props.open}>
|
||||
<ChooseTootLang
|
||||
code={props.code}
|
||||
|
@ -204,41 +188,33 @@ function randomChoose<T extends any[]>(
|
|||
return K[idx];
|
||||
}
|
||||
|
||||
function useRandomChoice<T>(choices: () => T[]): Accessor<T> {
|
||||
return createMemo(() => randomChoose(Math.random(), choices()));
|
||||
}
|
||||
|
||||
function cancelEvent(event: Event) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
const TootComposer: Component<{
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
style?: JSX.CSSProperties;
|
||||
profile?: Account;
|
||||
replyToDisplayName?: string;
|
||||
mentions?: readonly string[];
|
||||
isTyping?: boolean;
|
||||
onTypingChange: (value: boolean) => void;
|
||||
client?: mastodon.rest.Client;
|
||||
inReplyToId?: string;
|
||||
onSent?: (status: mastodon.v1.Status) => void;
|
||||
}> = (props) => {
|
||||
let inputRef: HTMLTextAreaElement;
|
||||
let sendKey: string | undefined;
|
||||
|
||||
const session = useDefaultSession();
|
||||
|
||||
const [active, setActive] = createSignal(false);
|
||||
const typing = () => props.isTyping;
|
||||
const setTyping = (v: boolean) => props.onTypingChange(v);
|
||||
const [sending, setSending] = createSignal(false);
|
||||
const [visibility, setVisibility] = createSignal<TootVisibility>("public");
|
||||
const [permPicker, setPermPicker] = createSignal(false);
|
||||
const [language, setLanguage] = createSignal("en");
|
||||
const [langPickerOpen, setLangPickerOpen] = createSignal(false);
|
||||
const appLanguage = useLanguage();
|
||||
const [openMenu, menuState] = createManagedMenuState();
|
||||
|
||||
const randomPlaceholder = useRandomChoice(() => [
|
||||
"What's happening?",
|
||||
"What do you think?",
|
||||
]);
|
||||
const randomPlaceholder = createMemo(() =>
|
||||
randomChoose(Math.random(), ["What's happening?", "What do your think?"]),
|
||||
);
|
||||
|
||||
createEffect(() => {
|
||||
const lang = appLanguage().split("-")[0];
|
||||
|
@ -246,11 +222,15 @@ const TootComposer: Component<{
|
|||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (active()) {
|
||||
if (typing()) {
|
||||
setTimeout(() => inputRef.focus(), 0);
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
makeEventListener(inputRef, "focus", () => setTyping(true));
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (inputRef.value !== "") return;
|
||||
if (props.mentions) {
|
||||
|
@ -260,7 +240,7 @@ const TootComposer: Component<{
|
|||
});
|
||||
|
||||
const containerStyle = () =>
|
||||
active() || permPicker()
|
||||
typing() || permPicker()
|
||||
? {
|
||||
position: "sticky" as const,
|
||||
top: "var(--scaffold-topbar-height, 0)",
|
||||
|
@ -283,15 +263,17 @@ const TootComposer: Component<{
|
|||
}
|
||||
};
|
||||
|
||||
const idempotencyKey = createMemo(() => window.crypto.randomUUID());
|
||||
const getOrGenSendKey = () => {
|
||||
if (sendKey === undefined) {
|
||||
sendKey = window.crypto.randomUUID();
|
||||
}
|
||||
return sendKey;
|
||||
};
|
||||
|
||||
const send = async () => {
|
||||
const client = session()?.client;
|
||||
if (!client) return;
|
||||
|
||||
setSending(true);
|
||||
try {
|
||||
const status = await client.v1.statuses.create(
|
||||
const status = await props.client!.v1.statuses.create(
|
||||
{
|
||||
status: inputRef.value,
|
||||
language: language(),
|
||||
|
@ -301,7 +283,7 @@ const TootComposer: Component<{
|
|||
{
|
||||
requestInit: {
|
||||
headers: {
|
||||
["Idempotency-Key"]: idempotencyKey(),
|
||||
["Idempotency-Key"]: getOrGenSendKey(),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -317,72 +299,27 @@ const TootComposer: Component<{
|
|||
return (
|
||||
<div
|
||||
ref={props.ref}
|
||||
class={/* @once */ `TootComposer ${cardStyles.card}`}
|
||||
class={tootComposers.composer}
|
||||
style={containerStyle()}
|
||||
on:touchend={
|
||||
cancelEvent
|
||||
/* on: is required to register the event handler on the exact element */
|
||||
}
|
||||
on:touchmove={cancelEvent}
|
||||
on:wheel={cancelEvent}
|
||||
onClick={(e) => inputRef.focus()}
|
||||
>
|
||||
<Show when={active()}>
|
||||
<Toolbar class={cardStyles.cardNoPad}>
|
||||
<IconButton
|
||||
onClick={[setActive, false]}
|
||||
aria-label="Close the composer"
|
||||
>
|
||||
<Close />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={(e) => openMenu(e.currentTarget.getBoundingClientRect())}
|
||||
>
|
||||
<MoreVert />
|
||||
</IconButton>
|
||||
</Toolbar>
|
||||
<div class={cardStyles.cardNoPad}>
|
||||
<Menu {...menuState}>
|
||||
<MenuItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar src={session()?.account.inf?.avatar}></Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText secondary={"Default account"}>
|
||||
<span
|
||||
ref={(e) => {
|
||||
createRenderEffect(() => {
|
||||
const inf = session()?.account.inf;
|
||||
return (e.innerHTML = resolveCustomEmoji(
|
||||
inf?.displayName || "",
|
||||
inf?.emojis ?? [],
|
||||
));
|
||||
});
|
||||
}}
|
||||
></span>
|
||||
</ListItemText>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="reply-input">
|
||||
<div class={tootComposers.replyInput}>
|
||||
<Show when={props.profile}>
|
||||
<Avatar
|
||||
src={props.profile!.inf?.avatar}
|
||||
sx={{ marginLeft: "-0.25em" }}
|
||||
/>
|
||||
</Show>
|
||||
<SizedTextarea
|
||||
<textarea
|
||||
ref={inputRef!}
|
||||
placeholder={
|
||||
props.replyToDisplayName
|
||||
? `Reply to ${props.replyToDisplayName}...`
|
||||
: randomPlaceholder()
|
||||
}
|
||||
onFocus={[setActive, true]}
|
||||
style={{ width: "100%", border: "none" }}
|
||||
disabled={sending()}
|
||||
autocomplete="off"
|
||||
></SizedTextarea>
|
||||
></textarea>
|
||||
<Show when={props.client}>
|
||||
<Show
|
||||
when={!sending()}
|
||||
|
@ -398,43 +335,36 @@ const TootComposer: Component<{
|
|||
</div>
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
sx={{ marginRight: "-0.5em" }}
|
||||
onClick={send}
|
||||
aria-label="Send"
|
||||
>
|
||||
<IconButton sx={{ marginRight: "-0.5em" }} onClick={send}>
|
||||
<Send />
|
||||
</IconButton>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={active()}>
|
||||
<div class="options">
|
||||
<Button
|
||||
startIcon={<Translate />}
|
||||
endIcon={<ArrowDropDown />}
|
||||
onClick={[setLangPickerOpen, true]}
|
||||
disabled={sending()}
|
||||
>
|
||||
<span style={{ "vertical-align": "bottom" }}>
|
||||
{iso639_1.getNativeName(language())}
|
||||
</span>
|
||||
<Show when={typing()}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
"justify-content": "flex-end",
|
||||
"margin-top": "8px",
|
||||
gap: "16px",
|
||||
"flex-flow": "row wrap",
|
||||
}}
|
||||
>
|
||||
<Button onClick={[setLangPickerOpen, true]} disabled={sending()}>
|
||||
<Translate sx={{ marginTop: "-0.25em", marginRight: "0.25em" }} />
|
||||
{iso639_1.getNativeName(language())}
|
||||
<ArrowDropDown sx={{ marginTop: "-0.25em" }} />
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<Visibility />}
|
||||
endIcon={<ArrowDropDown />}
|
||||
onClick={[setPermPicker, true]}
|
||||
disabled={sending()}
|
||||
>
|
||||
<span style={{ "vertical-align": "bottom" }}>
|
||||
{visibilityText()}
|
||||
</span>
|
||||
<Button onClick={[setPermPicker, true]} disabled={sending()}>
|
||||
<Visibility sx={{ marginTop: "-0.15em", marginRight: "0.25em" }} />
|
||||
{visibilityText()}
|
||||
<ArrowDropDown sx={{ marginTop: "-0.25em" }} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<TootVisibilityPickerDialog
|
||||
class={cardStyles.cardNoPad}
|
||||
open={permPicker()}
|
||||
onClose={() => setPermPicker(false)}
|
||||
visibility={visibility()}
|
||||
|
@ -442,7 +372,6 @@ const TootComposer: Component<{
|
|||
/>
|
||||
|
||||
<TootLanguagePickerDialog
|
||||
class={cardStyles.cardNoPad}
|
||||
open={langPickerOpen()}
|
||||
onClose={() => setLangPickerOpen(false)}
|
||||
code={language()}
|
||||
|
|
|
@ -1,288 +0,0 @@
|
|||
import {
|
||||
Component,
|
||||
createSignal,
|
||||
ErrorBoundary,
|
||||
type Ref,
|
||||
createSelector,
|
||||
Index,
|
||||
createMemo,
|
||||
} from "solid-js";
|
||||
import { type mastodon } from "masto";
|
||||
import { vibrate } from "~platform/hardware";
|
||||
import { useDefaultSession } from "../masto/clients";
|
||||
import { setCache as setTootBottomSheetCache } from "./TootBottomSheet";
|
||||
import RegularToot, {
|
||||
findElementActionable,
|
||||
findRootToot,
|
||||
} from "./RegularToot";
|
||||
import cardStyle from "~material/cards.module.css";
|
||||
import type { ThreadNode } from "../masto/timelines";
|
||||
import { useNavigator } from "~platform/StackedRouter";
|
||||
import { ANIM_CURVE_STD } from "~material/theme";
|
||||
|
||||
function durationOf(rect0: DOMRect, rect1: DOMRect) {
|
||||
const distancelt = Math.sqrt(
|
||||
Math.pow(Math.abs(rect0.top - rect1.top), 2) +
|
||||
Math.pow(Math.abs(rect0.left - rect1.left), 2),
|
||||
);
|
||||
const distancerb = Math.sqrt(
|
||||
Math.pow(Math.abs(rect0.bottom - rect1.bottom), 2) +
|
||||
Math.pow(Math.abs(rect0.right - rect1.right), 2),
|
||||
);
|
||||
const distance = distancelt + distancerb;
|
||||
const duration = distance / 1.6;
|
||||
return duration;
|
||||
}
|
||||
|
||||
function positionTootInThread(index: number, threadLength: number) {
|
||||
if (index === 0) {
|
||||
return "top";
|
||||
} else if (index === threadLength - 1) {
|
||||
return "bottom";
|
||||
}
|
||||
return "middle";
|
||||
}
|
||||
|
||||
const TootList: Component<{
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
id?: string;
|
||||
threads: readonly string[];
|
||||
onUnknownThread: (id: string) => ThreadNode[] | undefined;
|
||||
onChangeToot: (id: string, value: mastodon.v1.Status) => void;
|
||||
}> = (props) => {
|
||||
const session = useDefaultSession();
|
||||
const [expandedThreadId, setExpandedThreadId] = createSignal<string>();
|
||||
const { push } = useNavigator();
|
||||
|
||||
const onBookmark = async (status: mastodon.v1.Status) => {
|
||||
const client = session()?.client;
|
||||
if (!client) return;
|
||||
|
||||
const result = await (status.bookmarked
|
||||
? client.v1.statuses.$select(status.id).unbookmark()
|
||||
: client.v1.statuses.$select(status.id).bookmark());
|
||||
props.onChangeToot(result.id, result);
|
||||
};
|
||||
|
||||
const toggleBoost = async (status: mastodon.v1.Status) => {
|
||||
const client = session()?.client;
|
||||
if (!client) return;
|
||||
|
||||
vibrate(50);
|
||||
const rootStatus = status.reblog ? status.reblog : status;
|
||||
const reblogged = rootStatus.reblogged;
|
||||
if (status.reblog) {
|
||||
props.onChangeToot(status.id, {
|
||||
...status,
|
||||
reblog: { ...status.reblog, reblogged: !reblogged },
|
||||
});
|
||||
} else {
|
||||
props.onChangeToot(status.id, {
|
||||
...status,
|
||||
reblogged: !reblogged,
|
||||
});
|
||||
}
|
||||
|
||||
const result = reblogged
|
||||
? await client.v1.statuses.$select(status.id).unreblog()
|
||||
: (await client.v1.statuses.$select(status.id).reblog()).reblog!;
|
||||
|
||||
if (status.reblog) {
|
||||
props.onChangeToot(status.id, {
|
||||
...status,
|
||||
reblog: result,
|
||||
});
|
||||
} else {
|
||||
props.onChangeToot(status.id, result);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFavourite = async (status: mastodon.v1.Status) => {
|
||||
const client = session()?.client;
|
||||
if (!client) return;
|
||||
const ovalue = status.favourited;
|
||||
props.onChangeToot(status.id, { ...status, favourited: !ovalue });
|
||||
|
||||
const result = ovalue
|
||||
? await client.v1.statuses.$select(status.id).unfavourite()
|
||||
: await client.v1.statuses.$select(status.id).favourite();
|
||||
props.onChangeToot(status.id, result);
|
||||
};
|
||||
|
||||
const openFullScreenToot = (
|
||||
toot: mastodon.v1.Status,
|
||||
srcElement: HTMLElement,
|
||||
reply?: boolean,
|
||||
) => {
|
||||
const p = session()?.account;
|
||||
if (!p) return;
|
||||
const inf = p.inf;
|
||||
if (!inf) {
|
||||
console.warn("no account info?");
|
||||
return;
|
||||
}
|
||||
|
||||
const acct = `${inf.username}@${p.site}`;
|
||||
setTootBottomSheetCache(acct, toot);
|
||||
|
||||
push(`/${encodeURIComponent(acct)}/toot/${toot.id}`, {
|
||||
animateOpen(element) {
|
||||
const rect0 = srcElement.getBoundingClientRect(); // the start rect
|
||||
const rect1 = element.getBoundingClientRect(); // the end rect
|
||||
|
||||
const duration = durationOf(rect0, rect1);
|
||||
|
||||
const keyframes = {
|
||||
top: [`${rect0.top}px`, `${rect1.top}px`],
|
||||
bottom: [`${rect0.bottom}px`, `${rect1.bottom}px`],
|
||||
left: [`${rect0.left}px`, `${rect1.left}px`],
|
||||
right: [`${rect0.right}px`, `${rect1.right}px`],
|
||||
height: [`${rect0.height}px`, `${rect1.height}px`],
|
||||
margin: 0,
|
||||
};
|
||||
|
||||
srcElement.style.visibility = "hidden";
|
||||
|
||||
const animation = element.animate(keyframes, {
|
||||
duration,
|
||||
easing: ANIM_CURVE_STD,
|
||||
});
|
||||
return animation;
|
||||
},
|
||||
|
||||
animateClose(element) {
|
||||
const rect0 = element.getBoundingClientRect(); // the start rect
|
||||
const rect1 = srcElement.getBoundingClientRect(); // the end rect
|
||||
|
||||
const duration = durationOf(rect0, rect1);
|
||||
|
||||
const keyframes = {
|
||||
top: [`${rect0.top}px`, `${rect1.top}px`],
|
||||
bottom: [`${rect0.bottom}px`, `${rect1.bottom}px`],
|
||||
left: [`${rect0.left}px`, `${rect1.left}px`],
|
||||
right: [`${rect0.right}px`, `${rect1.right}px`],
|
||||
height: [`${rect0.height}px`, `${rect1.height}px`],
|
||||
margin: 0,
|
||||
};
|
||||
|
||||
srcElement.style.visibility = "";
|
||||
|
||||
const animation = element.animate(keyframes, {
|
||||
duration,
|
||||
easing: ANIM_CURVE_STD,
|
||||
});
|
||||
return animation;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onItemClick = (
|
||||
status: mastodon.v1.Status,
|
||||
event: MouseEvent & { target: EventTarget; currentTarget: HTMLElement },
|
||||
) => {
|
||||
if (!(event.target instanceof HTMLElement)) {
|
||||
throw new Error("target is not an element");
|
||||
}
|
||||
const actionableElement = findElementActionable(
|
||||
event.target,
|
||||
event.currentTarget,
|
||||
);
|
||||
|
||||
if (actionableElement && checkIsExpended(status)) {
|
||||
if (actionableElement.dataset.action === "acct") {
|
||||
event.stopPropagation();
|
||||
|
||||
const target = actionableElement as HTMLAnchorElement;
|
||||
|
||||
const acct = encodeURIComponent(
|
||||
target.dataset.client || `@${new URL(target.href).origin}`,
|
||||
);
|
||||
|
||||
push(`/${acct}/profile/${target.dataset.acctId}`);
|
||||
|
||||
return;
|
||||
} else {
|
||||
console.warn("unknown action", actionableElement.dataset.rel);
|
||||
}
|
||||
} else if (
|
||||
event.target.parentElement &&
|
||||
event.target.parentElement.tagName === "A"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// else if (!actionableElement || !checkIsExpended(status) || <rel is not one of known action>)
|
||||
if (status.id !== expandedThreadId()) {
|
||||
setExpandedThreadId((x) => (x ? undefined : status.id));
|
||||
} else {
|
||||
openFullScreenToot(status, event.currentTarget as HTMLElement);
|
||||
}
|
||||
};
|
||||
|
||||
const checkIsExpendedId = createSelector(expandedThreadId);
|
||||
|
||||
const checkIsExpended = (status: mastodon.v1.Status) =>
|
||||
checkIsExpendedId(status.id);
|
||||
|
||||
const reply = (
|
||||
status: mastodon.v1.Status,
|
||||
event: { currentTarget: HTMLElement },
|
||||
) => {
|
||||
const element = findRootToot(event.currentTarget);
|
||||
openFullScreenToot(status, element, true);
|
||||
};
|
||||
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallback={(err, reset) => {
|
||||
console.error(err);
|
||||
return <p>Oops: {String(err)}</p>;
|
||||
}}
|
||||
>
|
||||
<div ref={props.ref} id={props.id} class="toot-list">
|
||||
<Index each={props.threads}>
|
||||
{(threadId, threadIdx) => {
|
||||
const thread = createMemo(() =>
|
||||
props.onUnknownThread(threadId())?.reverse(),
|
||||
);
|
||||
|
||||
const threadLength = () => thread()?.length ?? 0;
|
||||
|
||||
return (
|
||||
<Index each={thread()}>
|
||||
{(threadNode, index) => {
|
||||
const status = () => threadNode().value;
|
||||
|
||||
return (
|
||||
<RegularToot
|
||||
data-status-id={status().id}
|
||||
data-thread={threadIdx}
|
||||
data-thread-len={threadLength()}
|
||||
data-thread-sort={index}
|
||||
status={status()}
|
||||
thread={
|
||||
threadLength() > 1
|
||||
? positionTootInThread(index, threadLength())
|
||||
: undefined
|
||||
}
|
||||
class={cardStyle.card}
|
||||
evaluated={checkIsExpended(status())}
|
||||
actionable={checkIsExpended(status())}
|
||||
onBookmark={onBookmark}
|
||||
onRetoot={toggleBoost}
|
||||
onFavourite={toggleFavourite}
|
||||
onReply={reply}
|
||||
onClick={[onItemClick, status()]}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</Index>
|
||||
);
|
||||
}}
|
||||
</Index>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
export default TootList;
|
102
src/timelines/TootThread.tsx
Normal file
102
src/timelines/TootThread.tsx
Normal file
|
@ -0,0 +1,102 @@
|
|||
import type { mastodon } from "masto";
|
||||
import { Show, createResource, createSignal, type Component, type Ref } from "solid-js";
|
||||
import CompactToot from "./CompactToot";
|
||||
import { useTimeSource } from "../platform/timesrc";
|
||||
import RegularToot from "./RegularToot";
|
||||
import cardStyle from "../material/cards.module.css";
|
||||
import { css } from "solid-styled";
|
||||
|
||||
type TootThreadProps = {
|
||||
ref?: Ref<HTMLElement>,
|
||||
status: mastodon.v1.Status;
|
||||
client: mastodon.rest.Client;
|
||||
expanded?: 0 | 1 | 2;
|
||||
|
||||
onBoost?(client: mastodon.rest.Client, status: mastodon.v1.Status): void;
|
||||
onBookmark?(client: mastodon.rest.Client, status: mastodon.v1.Status): void;
|
||||
onReply?(client: mastodon.rest.Client, status: mastodon.v1.Status): void
|
||||
onExpandChange?(level: 0 | 1 | 2): void;
|
||||
};
|
||||
|
||||
const TootThread: Component<TootThreadProps> = (props) => {
|
||||
const status = () => props.status;
|
||||
const now = useTimeSource();
|
||||
const expanded = () => props.expanded ?? 0;
|
||||
|
||||
const [inReplyTo] = createResource(
|
||||
() => [props.client, status().inReplyToId || null] as const,
|
||||
async ([client, replyToId]) => {
|
||||
if (!(client && replyToId)) return null;
|
||||
return await client.v1.statuses.$select(replyToId).fetch();
|
||||
},
|
||||
);
|
||||
|
||||
const boost = (status: mastodon.v1.Status) => {
|
||||
props.onBoost?.(props.client, status);
|
||||
};
|
||||
|
||||
const bookmark = (status: mastodon.v1.Status) => {
|
||||
props.onBookmark?.(props.client, status);
|
||||
};
|
||||
|
||||
const reply = (status: mastodon.v1.Status) => {
|
||||
props.onReply?.(props.client, status)
|
||||
}
|
||||
|
||||
css`
|
||||
article {
|
||||
transition:
|
||||
margin 90ms var(--tutu-anim-curve-sharp),
|
||||
var(--tutu-transition-shadow);
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.thread-line {
|
||||
position: relative;
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 36px;
|
||||
top: 16px;
|
||||
bottom: 0;
|
||||
background-color: var(--tutu-color-secondary);
|
||||
width: 2px;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.expanded {
|
||||
margin-block: 20px;
|
||||
box-shadow: var(--tutu-shadow-e9);
|
||||
}
|
||||
`;
|
||||
|
||||
const nextExpandLevel = [1, 2, 2] as const;
|
||||
|
||||
return (
|
||||
<article
|
||||
ref={props.ref}
|
||||
classList={{ "thread-line": !!inReplyTo(), expanded: expanded() > 0 }}
|
||||
onClick={() => props.onExpandChange?.(nextExpandLevel[expanded()])}
|
||||
>
|
||||
<Show when={inReplyTo()}>
|
||||
<CompactToot
|
||||
status={inReplyTo()!}
|
||||
now={now()}
|
||||
class={[cardStyle.card, cardStyle.manualMargin].join(" ")}
|
||||
/>
|
||||
</Show>
|
||||
<RegularToot
|
||||
status={status()}
|
||||
class={cardStyle.card}
|
||||
actionable={expanded() > 0}
|
||||
onBookmark={(s) => bookmark(s)}
|
||||
onRetoot={(s) => boost(s)}
|
||||
onReply={s => reply(s)}
|
||||
/>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
export default TootThread;
|
|
@ -1,24 +1,100 @@
|
|||
import {
|
||||
Component,
|
||||
For,
|
||||
onCleanup,
|
||||
createSignal,
|
||||
untrack,
|
||||
Match,
|
||||
Switch as JsSwitch,
|
||||
ErrorBoundary,
|
||||
createSelector,
|
||||
} from "solid-js";
|
||||
import { type mastodon } from "masto";
|
||||
import { Button } from "@suid/material";
|
||||
import { createTimelineSnapshot } from "../masto/timelines.js";
|
||||
import { vibrate } from "../platform/hardware.js";
|
||||
import PullDownToRefresh from "./PullDownToRefresh.jsx";
|
||||
import TootList from "./TootList.jsx";
|
||||
import Thread from "./Thread.jsx";
|
||||
|
||||
const TrendTimelinePanel: Component<{
|
||||
client: mastodon.rest.Client;
|
||||
prefetch?: boolean;
|
||||
|
||||
openFullScreenToot: (
|
||||
toot: mastodon.v1.Status,
|
||||
srcElement?: HTMLElement,
|
||||
reply?: boolean,
|
||||
) => void;
|
||||
}> = (props) => {
|
||||
const [scrollLinked, setScrollLinked] = createSignal<HTMLElement>();
|
||||
const [tl, snapshot, { refetch: refetchTimeline }] = createTimelineSnapshot(
|
||||
const [
|
||||
timeline,
|
||||
snapshot,
|
||||
{ refetch: refetchTimeline, mutate: mutateTimeline },
|
||||
] = createTimelineSnapshot(
|
||||
() => props.client.v1.trends.statuses,
|
||||
() => ({ limit: 120 }),
|
||||
() => 120,
|
||||
);
|
||||
const [expandedId, setExpandedId] = createSignal<string>();
|
||||
|
||||
const tlEndObserver = new IntersectionObserver(() => {
|
||||
if (untrack(() => props.prefetch) && !snapshot.loading)
|
||||
refetchTimeline();
|
||||
});
|
||||
|
||||
onCleanup(() => tlEndObserver.disconnect());
|
||||
|
||||
const isExpandedId = createSelector(expandedId);
|
||||
|
||||
const isExpanded = (st: mastodon.v1.Status) => isExpandedId(st.id);
|
||||
|
||||
const onBookmark = async (
|
||||
index: number,
|
||||
client: mastodon.rest.Client,
|
||||
status: mastodon.v1.Status,
|
||||
) => {
|
||||
const result = await (status.bookmarked
|
||||
? client.v1.statuses.$select(status.id).unbookmark()
|
||||
: client.v1.statuses.$select(status.id).bookmark());
|
||||
mutateTimeline((o) => {
|
||||
o![index] = [result];
|
||||
return o;
|
||||
});
|
||||
};
|
||||
|
||||
const onBoost = async (
|
||||
index: number,
|
||||
client: mastodon.rest.Client,
|
||||
status: mastodon.v1.Status,
|
||||
) => {
|
||||
const reblogged = status.reblog
|
||||
? status.reblog.reblogged
|
||||
: status.reblogged;
|
||||
vibrate(50);
|
||||
mutateTimeline(index, (th) => {
|
||||
const x = th[0];
|
||||
if (x.reblog) {
|
||||
x.reblog = { ...x.reblog, reblogged: !reblogged };
|
||||
return [Object.assign({}, x)];
|
||||
} else {
|
||||
return [
|
||||
Object.assign({}, x, {
|
||||
reblogged: !reblogged,
|
||||
}),
|
||||
];
|
||||
}
|
||||
});
|
||||
const result = reblogged
|
||||
? await client.v1.statuses.$select(status.id).unreblog()
|
||||
: (await client.v1.statuses.$select(status.id).reblog()).reblog!;
|
||||
mutateTimeline(index, (th) => {
|
||||
Object.assign(th[0].reblog ?? th[0], {
|
||||
reblogged: result.reblogged,
|
||||
reblogsCount: result.reblogsCount,
|
||||
});
|
||||
return th;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ErrorBoundary
|
||||
|
@ -29,7 +105,7 @@ const TrendTimelinePanel: Component<{
|
|||
<PullDownToRefresh
|
||||
linkedElement={scrollLinked()}
|
||||
loading={snapshot.loading}
|
||||
onRefresh={() => refetchTimeline("next")}
|
||||
onRefresh={() => refetchTimeline({ direction: "new" })}
|
||||
/>
|
||||
<div
|
||||
ref={(e) =>
|
||||
|
@ -38,12 +114,34 @@ const TrendTimelinePanel: Component<{
|
|||
}, 0)
|
||||
}
|
||||
>
|
||||
<TootList
|
||||
threads={tl.list}
|
||||
onUnknownThread={tl.getPath}
|
||||
onChangeToot={tl.set}
|
||||
/>
|
||||
<For each={timeline}>
|
||||
{(item, index) => {
|
||||
let element: HTMLElement | undefined;
|
||||
return (
|
||||
<Thread
|
||||
ref={element}
|
||||
toots={item}
|
||||
onBoost={(...args) => onBoost(index(), ...args)}
|
||||
onBookmark={(...args) => onBookmark(index(), ...args)}
|
||||
onReply={(client, status) =>
|
||||
props.openFullScreenToot(status, element, true)
|
||||
}
|
||||
client={props.client}
|
||||
isExpended={isExpanded}
|
||||
onItemClick={(x) => {
|
||||
if (x.id !== expandedId()) {
|
||||
setExpandedId((o) => (o ? undefined : x.id));
|
||||
} else {
|
||||
props.openFullScreenToot(x, element);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<div ref={(e) => tlEndObserver.observe(e)}></div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
|
@ -51,7 +149,7 @@ const TrendTimelinePanel: Component<{
|
|||
"align-items": "center",
|
||||
"justify-content": "center",
|
||||
"flex-flow": "column",
|
||||
gap: "20px",
|
||||
gap: "20px"
|
||||
}}
|
||||
>
|
||||
<JsSwitch>
|
||||
|
@ -64,6 +162,16 @@ const TrendTimelinePanel: Component<{
|
|||
>
|
||||
Retry
|
||||
</Button>
|
||||
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={[refetchTimeline, undefined]}
|
||||
disabled={snapshot.loading}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Match>
|
||||
</JsSwitch>
|
||||
</div>
|
||||
|
|
|
@ -4,15 +4,12 @@
|
|||
--toot-avatar-size: 40px;
|
||||
margin-block: 0;
|
||||
position: relative;
|
||||
contain: content;
|
||||
cursor: pointer;
|
||||
|
||||
&.toot {
|
||||
/* fix composition ordering: I think the css module processor should aware the overriding and behaves, but no */
|
||||
transition:
|
||||
margin-top 60ms var(--tutu-anim-curve-sharp),
|
||||
margin-bottom 60ms var(--tutu-anim-curve-sharp),
|
||||
height 60ms var(--tutu-anim-curve-sharp),
|
||||
margin-block 125ms var(--tutu-anim-curve-std),
|
||||
height 225ms var(--tutu-anim-curve-std),
|
||||
var(--tutu-transition-shadow);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
@ -41,7 +38,6 @@
|
|||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
contain: layout style;
|
||||
|
||||
> :not(:first-child) {
|
||||
flex-grow: 1;
|
||||
|
@ -51,7 +47,10 @@
|
|||
.tootAuthorNameGrp {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
color: var(--tutu-color-secondary-text-on-surface);
|
||||
|
||||
>* {
|
||||
color: var(--tutu-color-secondary-text-on-surface);
|
||||
}
|
||||
|
||||
> :last-child {
|
||||
grid-column: 1 /3;
|
||||
|
@ -69,14 +68,7 @@
|
|||
}
|
||||
|
||||
.tootAuthorNamePrimary {
|
||||
color: var(--tutu-color-on-surface);
|
||||
|
||||
> :global(.acct-mark) {
|
||||
font-size: 1.2em;
|
||||
color: var(--tutu-color-secondary-text-on-surface);
|
||||
vertical-align: sub;
|
||||
margin-right: 0.25em;
|
||||
}
|
||||
color: revert;
|
||||
}
|
||||
|
||||
.tootAvatar {
|
||||
|
@ -89,6 +81,97 @@
|
|||
background-color: var(--tutu-color-surface-d);
|
||||
}
|
||||
|
||||
.tootContent {
|
||||
composes: cardNoPad from "../material/cards.module.css";
|
||||
margin-left: calc(var(--card-pad, 0) + var(--toot-avatar-size, 0) + 8px);
|
||||
margin-right: var(--card-pad, 0);
|
||||
line-height: 1.5;
|
||||
|
||||
& a {
|
||||
color: var(--tutu-color-primary-d);
|
||||
}
|
||||
|
||||
& :global(a[target="_blank"]) {
|
||||
> :global(.invisible) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
> :global(.ellipsis) {
|
||||
&::after {
|
||||
display: inline;
|
||||
content: "...";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.previewCard {
|
||||
composes: cardGutSkip from "../material/cards.module.css";
|
||||
display: block;
|
||||
border: 1px solid #eeeeee;
|
||||
background-color: var(--tutu-color-surface);
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-top: 1em;
|
||||
margin-bottom: 1.5em;
|
||||
color: var(--tutu-color-secondary-text-on-surface);
|
||||
transition: color 220ms var(--tutu-anim-curve-std), background-color 220ms var(--tutu-anim-curve-std);
|
||||
padding-bottom: 8px;
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
|
||||
>img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background-color: var(--tutu-color-surface-d);
|
||||
color: var(--tutu-color-on-surface);
|
||||
|
||||
>h1 {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
>h1 {
|
||||
color: var(--tutu-color-on-surface);
|
||||
max-height: calc(4 * var(--title-line-height) * var(--title-size));
|
||||
}
|
||||
|
||||
>p {
|
||||
max-height: calc(8 * var(--body-line-height) * var(--body-size));
|
||||
}
|
||||
|
||||
>h1,
|
||||
>p {
|
||||
margin-left: 16px;
|
||||
margin-right: 16px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&.compact {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(10%, 30%) 1fr;
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
padding-top: 8px;
|
||||
|
||||
>img:first-child {
|
||||
grid-row: 1 / 3;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
>h1,
|
||||
>p {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.toot.compact {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
|
@ -124,19 +207,48 @@
|
|||
}
|
||||
}
|
||||
|
||||
.tootRetootGrp {
|
||||
display: flex;
|
||||
gap: 0.25em;
|
||||
margin-bottom: 8px;
|
||||
align-items: center;
|
||||
.compactTootContent {
|
||||
composes: tootContent;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
> :first-child {
|
||||
margin-right: 0.25em;
|
||||
.tootRetootGrp {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.tootAttachmentGrp {
|
||||
composes: cardNoPad from "../material/cards.module.css";
|
||||
margin-top: 1em;
|
||||
margin-left: calc(var(--card-pad, 0) + var(--toot-avatar-size, 0) + 8px);
|
||||
margin-right: var(--card-pad, 0);
|
||||
gap: 4px;
|
||||
|
||||
> :where(img, video) {
|
||||
max-height: 35vh;
|
||||
min-height: 40px;
|
||||
min-width: 40px;
|
||||
object-fit: contain;
|
||||
max-width: 100%;
|
||||
background-color: var(--tutu-color-surface-d);
|
||||
border-radius: 2px;
|
||||
border: 1px solid transparent;
|
||||
transition: border-color 220ms var(--tutu-anim-curve-std),
|
||||
box-shadow 220ms var(--tutu-anim-curve-std);
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
border: 1px solid var(--tutu-color-surface-dd);
|
||||
box-shadow: var(--tutu-shadow-e1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tootBottomActionGrp {
|
||||
composes: cardGutSkip from "~material/cards.module.css";
|
||||
composes: cardGutSkip from "../material/cards.module.css";
|
||||
padding-block: calc((var(--card-gut) - 10px) / 2);
|
||||
|
||||
animation: 225ms var(--tutu-anim-curve-std) tootBottomExpanding;
|
||||
|
|
|
@ -1,13 +0,0 @@
|
|||
.BoostIcon {
|
||||
display: inline-flex;
|
||||
border-radius: 2px;
|
||||
background-color: green;
|
||||
padding: 0.125em;
|
||||
align-items: center;
|
||||
|
||||
> svg {
|
||||
color: white;
|
||||
font-size: 1em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
import {
|
||||
splitProps,
|
||||
type Component,
|
||||
type JSX,
|
||||
} from "solid-js";
|
||||
|
||||
import {
|
||||
Repeat,
|
||||
} from "@suid/icons-material";
|
||||
import "./BoostIcon.css";
|
||||
|
||||
|
||||
const BoostIcon: Component<JSX.HTMLElementTags["i"]> = (props) => {
|
||||
const [managed, rest] = splitProps(props, ["class"]);
|
||||
return (
|
||||
<i class={["BoostIcon", managed.class].join(" ")} {...rest}>
|
||||
<Repeat />
|
||||
</i>
|
||||
);
|
||||
};
|
||||
|
||||
export default BoostIcon;
|
|
@ -1,48 +0,0 @@
|
|||
.MediaAttachmentGrid {
|
||||
/* Note: MeidaAttachmentGrid has hard-coded layout calcalation */
|
||||
margin-top: 1em;
|
||||
margin-left: var(--card-pad, 0);
|
||||
margin-right: var(--card-pad, 0);
|
||||
contain: layout style;
|
||||
gap: 4px;
|
||||
|
||||
> * {
|
||||
max-height: 35vh;
|
||||
min-height: 40px;
|
||||
min-width: 40px;
|
||||
max-width: 100%;
|
||||
contain: strict;
|
||||
content-visibility: auto;
|
||||
background-color: var(--media-color-accent, var(--tutu-color-surface-d));
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--tutu-color-surface-d);
|
||||
transition: outline-width 60ms var(--tutu-anim-curve-std), border-color 60ms var(--tutu-anim-curve-std);
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
outline: 8px solid var(--media-color-accent, var(--tutu-color-surface-d));
|
||||
border-color: var(--media-color-accent, var(--tutu-color-surface-d));
|
||||
z-index: calc(var(--tutu-zidx-nav) - 1);
|
||||
}
|
||||
}
|
||||
|
||||
> * > * {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
> * > :where(img, video) {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
> * >.sensitive-placeholder {
|
||||
display: inline-flex;
|
||||
display: inline flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
:where(.thread-top, .thread-mid)>.MediaAttachmentGrid {
|
||||
margin-left: calc(var(--card-pad, 0) + var(--toot-avatar-size, 0) + 8px);
|
||||
}
|
|
@ -1,244 +0,0 @@
|
|||
import type { mastodon } from "masto";
|
||||
import {
|
||||
type Component,
|
||||
Index,
|
||||
Match,
|
||||
Switch,
|
||||
createMemo,
|
||||
createRenderEffect,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
untrack,
|
||||
} from "solid-js";
|
||||
import MediaViewer from "../MediaViewer";
|
||||
import { render } from "solid-js/web";
|
||||
import {
|
||||
createElementSize,
|
||||
useWindowSize,
|
||||
} from "@solid-primitives/resize-observer";
|
||||
import { useStore } from "@nanostores/solid";
|
||||
import { $settings } from "../../settings/stores";
|
||||
import { averageColorHex } from "~platform/blurhash";
|
||||
import "./MediaAttachmentGrid.css";
|
||||
import cardStyle from "~material/cards.module.css";
|
||||
import { Preview } from "@suid/icons-material";
|
||||
import { IconButton } from "@suid/material";
|
||||
import Masonry from "~platform/Masonry";
|
||||
|
||||
type ElementSize = { width: number; height: number };
|
||||
|
||||
function constraintedSize(
|
||||
{ width: owidth, height: oheight }: Readonly<ElementSize>, // originalSize
|
||||
{ width: mwidth, height: mheight }: Readonly<Partial<ElementSize>>, // modifier
|
||||
{ width: maxWidth, height: maxHeight }: Readonly<ElementSize>, // maxSize
|
||||
) {
|
||||
const ySize = owidth + (mwidth ?? 0);
|
||||
const yScale = ySize > maxWidth ? ySize / maxWidth : 1;
|
||||
const xSize = oheight + (mheight ?? 0);
|
||||
const xScale = xSize > maxHeight ? xSize / maxHeight : 1;
|
||||
|
||||
const maxScale = Math.max(yScale, xScale);
|
||||
const scaledWidth = owidth / maxScale;
|
||||
const scaledHeight = oheight / maxScale;
|
||||
|
||||
return {
|
||||
width: scaledWidth,
|
||||
height: scaledHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function isolateCallback(event: Event) {
|
||||
if (event.target !== event.currentTarget) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
const MediaAttachmentGrid: Component<{
|
||||
attachments: mastodon.v1.MediaAttachment[];
|
||||
sensitive?: boolean;
|
||||
}> = (props) => {
|
||||
const [rootRef, setRootRef] = createSignal<HTMLElement>();
|
||||
const [viewerIndex, setViewerIndex] = createSignal<number>();
|
||||
const viewerOpened = () => typeof viewerIndex() !== "undefined";
|
||||
const settings = useStore($settings);
|
||||
const windowSize = useWindowSize();
|
||||
const [reveal, setReveal] = createSignal([] as number[]);
|
||||
|
||||
createRenderEffect(() => {
|
||||
const vidx = viewerIndex();
|
||||
if (typeof vidx === "undefined") return;
|
||||
const container = document.createElement("div");
|
||||
container.setAttribute("role", "presentation");
|
||||
document.body.appendChild(container);
|
||||
const dispose = render(() => {
|
||||
onCleanup(() => {
|
||||
document.body.removeChild(container);
|
||||
});
|
||||
|
||||
return (
|
||||
<MediaViewer
|
||||
show={viewerOpened()}
|
||||
index={viewerIndex() || 0}
|
||||
onIndexUpdated={setViewerIndex}
|
||||
media={props.attachments}
|
||||
onClose={() => setViewerIndex()}
|
||||
/>
|
||||
);
|
||||
}, container);
|
||||
|
||||
onCleanup(dispose);
|
||||
});
|
||||
|
||||
const openViewerFor = (index: number) => {
|
||||
setViewerIndex(index);
|
||||
};
|
||||
|
||||
const columnCount = () => {
|
||||
if (props.attachments.length === 1) {
|
||||
return 1;
|
||||
} else if (props.attachments.length % 2 === 0) {
|
||||
return 2;
|
||||
} else {
|
||||
return 3;
|
||||
}
|
||||
};
|
||||
|
||||
const rawElementSize = createElementSize(rootRef);
|
||||
|
||||
const elementWidth = () => rawElementSize.width;
|
||||
|
||||
const itemMaxSize = createMemo(() => {
|
||||
const ewidth = elementWidth();
|
||||
const width = ewidth
|
||||
? (ewidth - (columnCount() - 1) * 4) / columnCount()
|
||||
: 1;
|
||||
|
||||
return {
|
||||
height: windowSize.height * 0.35,
|
||||
width,
|
||||
};
|
||||
});
|
||||
|
||||
const itemStyle = (item: mastodon.v1.MediaAttachment) => {
|
||||
const { width, height } = constraintedSize(
|
||||
item.meta?.small || { width: 1, height: 1 },
|
||||
{ width: 2, height: 2 },
|
||||
itemMaxSize(),
|
||||
);
|
||||
|
||||
const accentColor =
|
||||
item.meta?.colors?.accent ??
|
||||
(item.blurhash ? averageColorHex(item.blurhash) : undefined);
|
||||
|
||||
return Object.assign(
|
||||
{
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
"contain-intrinsic-size": `${width}px ${height}px`,
|
||||
},
|
||||
accentColor ? { "--media-color-accent": accentColor } : {},
|
||||
);
|
||||
};
|
||||
|
||||
const isReveal = (idx: number) => {
|
||||
return reveal().includes(idx);
|
||||
};
|
||||
|
||||
const addReveal = (idx: number) => {
|
||||
if (!untrack(() => isReveal(idx))) {
|
||||
setReveal((x) => [...x, idx]);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Masonry
|
||||
component="section"
|
||||
ref={setRootRef}
|
||||
class={`MediaAttachmentGrid ${cardStyle.cardNoPad}`}
|
||||
classList={{
|
||||
sensitive: props.sensitive,
|
||||
}}
|
||||
onClick={isolateCallback}
|
||||
>
|
||||
<Index each={props.attachments}>
|
||||
{(item, index) => {
|
||||
const itemType = () => item().type;
|
||||
|
||||
const style = createMemo(() => itemStyle(item()));
|
||||
return (
|
||||
<div style={style()} role="presentation">
|
||||
<Switch>
|
||||
<Match when={props.sensitive && !isReveal(index)}>
|
||||
<div
|
||||
class="sensitive-placeholder"
|
||||
data-sort={index}
|
||||
data-media-type={item().type}
|
||||
>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
size="large"
|
||||
onClick={[addReveal, index]}
|
||||
aria-label="Reveal this media"
|
||||
>
|
||||
<Preview />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={itemType() === "image"}>
|
||||
<img
|
||||
src={item().previewUrl}
|
||||
width={item().meta?.small?.width}
|
||||
height={item().meta?.small?.height}
|
||||
alt={item().description || undefined}
|
||||
onClick={[openViewerFor, index]}
|
||||
loading="lazy"
|
||||
data-sort={index}
|
||||
data-media-type={item().type}
|
||||
></img>
|
||||
</Match>
|
||||
<Match when={itemType() === "video"}>
|
||||
<video
|
||||
src={item().url || undefined}
|
||||
autoplay={!props.sensitive && settings().autoPlayVideos}
|
||||
playsinline={settings().autoPlayVideos ? true : undefined}
|
||||
controls
|
||||
poster={item().previewUrl}
|
||||
width={item().meta?.small?.width}
|
||||
height={item().meta?.small?.height}
|
||||
data-sort={index}
|
||||
data-media-type={item().type}
|
||||
preload="metadata"
|
||||
/>
|
||||
</Match>
|
||||
<Match when={itemType() === "gifv"}>
|
||||
<video
|
||||
src={item().url || undefined}
|
||||
autoplay={!props.sensitive && settings().autoPlayGIFs}
|
||||
controls
|
||||
playsinline /* or safari on iOS will play in full-screen */
|
||||
loop
|
||||
poster={item().previewUrl}
|
||||
width={item().meta?.small?.width}
|
||||
height={item().meta?.small?.height}
|
||||
data-sort={index}
|
||||
data-media-type={item().type}
|
||||
preload="metadata"
|
||||
/>
|
||||
</Match>
|
||||
<Match when={itemType() === "audio"}>
|
||||
<audio
|
||||
src={item().url || undefined}
|
||||
controls
|
||||
data-sort={index}
|
||||
data-media-type={item().type}
|
||||
></audio>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Index>
|
||||
</Masonry>
|
||||
);
|
||||
};
|
||||
|
||||
export default MediaAttachmentGrid;
|
|
@ -1,71 +0,0 @@
|
|||
.PreviewCard {
|
||||
display: block;
|
||||
border: 1px solid #eeeeee;
|
||||
background-color: var(--tutu-color-surface-d);
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-top: 1em;
|
||||
margin-bottom: 1.5em;
|
||||
color: var(--tutu-color-secondary-text-on-surface);
|
||||
transition: color 220ms var(--tutu-anim-curve-std), background-color 220ms var(--tutu-anim-curve-std);
|
||||
padding-bottom: 8px;
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
contain: layout style;
|
||||
|
||||
>img {
|
||||
background-color: var(--tutu-color-surface);
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
|
||||
&.loaded {
|
||||
background-color: #eeeeee;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
color: var(--tutu-color-on-surface);
|
||||
|
||||
>h1 {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
>h1 {
|
||||
color: var(--tutu-color-on-surface);
|
||||
max-height: calc(4 * var(--title-line-height) * var(--title-size));
|
||||
}
|
||||
|
||||
>p {
|
||||
max-height: calc(8 * var(--body-line-height) * var(--body-size));
|
||||
}
|
||||
|
||||
>h1,
|
||||
>p {
|
||||
margin-left: 16px;
|
||||
margin-right: 16px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&.compact {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(10%, 30%) 1fr;
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
padding-top: 8px;
|
||||
|
||||
>img:first-child {
|
||||
grid-row: 1 / 3;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
>h1,
|
||||
>p {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,107 +0,0 @@
|
|||
import Color from "colorjs.io";
|
||||
import type { mastodon } from "masto";
|
||||
import { createEffect, createMemo, Show } from "solid-js";
|
||||
import { Title, Body1 } from "~material/typography";
|
||||
import { averageColorHex } from "~platform/blurhash";
|
||||
import "./PreviewCard.css";
|
||||
|
||||
function onResetImg(event: Event & { currentTarget: HTMLImageElement }) {
|
||||
event.currentTarget.classList.remove("loaded");
|
||||
}
|
||||
|
||||
function onImgLoaded(event: Event & { currentTarget: HTMLImageElement }) {
|
||||
event.currentTarget.classList.add("loaded");
|
||||
}
|
||||
|
||||
export function PreviewCard(props: {
|
||||
src: mastodon.v1.PreviewCard;
|
||||
alwaysCompact?: boolean;
|
||||
}) {
|
||||
let root: HTMLAnchorElement;
|
||||
|
||||
createEffect(() => {
|
||||
if (props.alwaysCompact) {
|
||||
root.classList.add("compact");
|
||||
return;
|
||||
}
|
||||
if (!props.src.width) return;
|
||||
const width = root.getBoundingClientRect().width;
|
||||
if (width > props.src.width) {
|
||||
root.classList.add("compact");
|
||||
} else {
|
||||
root.classList.remove("compact");
|
||||
}
|
||||
});
|
||||
|
||||
const imgAverageColor = createMemo(() => {
|
||||
if (!props.src.image) return;
|
||||
return new Color(averageColorHex(props.src.blurhash));
|
||||
});
|
||||
|
||||
const prefersWhiteText = createMemo(() => {
|
||||
const oc = imgAverageColor();
|
||||
if (!oc) return;
|
||||
const colorWhite = new Color("white");
|
||||
|
||||
return colorWhite.luminance / oc.luminance > 3.5;
|
||||
});
|
||||
|
||||
const focusSurfaceColor = createMemo(() => {
|
||||
const oc = imgAverageColor();
|
||||
if (!oc) return;
|
||||
if (prefersWhiteText()) {
|
||||
return new Color(oc).darken(0.2);
|
||||
} else {
|
||||
return new Color(oc).lighten(0.2);
|
||||
}
|
||||
});
|
||||
|
||||
const textColorName = createMemo(() => {
|
||||
const useWhiteText = prefersWhiteText();
|
||||
if (typeof useWhiteText === "undefined") {
|
||||
return;
|
||||
}
|
||||
return useWhiteText ? "white" : "black";
|
||||
});
|
||||
|
||||
const secondaryTextColor = createMemo(() => {
|
||||
const tcn = textColorName();
|
||||
if (!tcn) return;
|
||||
const tc = new Color(tcn);
|
||||
tc.alpha = 0.75;
|
||||
return tc;
|
||||
});
|
||||
|
||||
return (
|
||||
<a
|
||||
ref={root!}
|
||||
class={"PreviewCard"}
|
||||
href={props.src.url}
|
||||
target="_blank"
|
||||
referrerPolicy="unsafe-url"
|
||||
style={{
|
||||
"--tutu-color-surface": imgAverageColor()?.toString(),
|
||||
"--tutu-color-surface-d": focusSurfaceColor()?.toString(),
|
||||
"--tutu-color-on-surface": textColorName(),
|
||||
"--tutu-color-secondary-text-on-surface":
|
||||
secondaryTextColor()?.toString(),
|
||||
}}
|
||||
>
|
||||
<Show when={props.src.image}>
|
||||
<img
|
||||
onLoadStart={onResetImg}
|
||||
onLoad={onImgLoaded}
|
||||
crossOrigin="anonymous"
|
||||
src={props.src.image!}
|
||||
width={props.src.width || undefined}
|
||||
height={props.src.height || undefined}
|
||||
loading="lazy"
|
||||
/>
|
||||
</Show>
|
||||
<Title component="h1">{props.src.title}</Title>
|
||||
<Body1 component="p">{props.src.description}</Body1>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export default PreviewCard;
|
|
@ -1,36 +0,0 @@
|
|||
.TootContent {
|
||||
margin-left: var(--card-pad, 0);
|
||||
margin-right: var(--card-pad, 0);
|
||||
line-height: 1.5;
|
||||
|
||||
> .content {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
& * {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
& a {
|
||||
color: var(--tutu-color-primary-d);
|
||||
}
|
||||
|
||||
& a[target="_blank"] {
|
||||
word-break: break-all;
|
||||
|
||||
>.invisible {
|
||||
display: none;
|
||||
}
|
||||
|
||||
>.ellipsis {
|
||||
&::after {
|
||||
display: inline;
|
||||
content: "...";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:where(.thread-top, .thread-mid) > .TootContent {
|
||||
margin-left: calc(var(--card-pad, 0) + var(--toot-avatar-size, 0) + 8px);
|
||||
}
|
|
@ -1,115 +0,0 @@
|
|||
import type { mastodon } from "masto";
|
||||
import {
|
||||
splitProps,
|
||||
type Component,
|
||||
type JSX,
|
||||
createRenderEffect,
|
||||
createMemo,
|
||||
Show,
|
||||
} from "solid-js";
|
||||
import { resolveCustomEmoji } from "../../masto/toot.js";
|
||||
import { makeAcctText, useDefaultSession } from "../../masto/clients.js";
|
||||
import "./TootContent.css";
|
||||
import { Button } from "@suid/material";
|
||||
import { createTranslator } from "~platform/i18n.js";
|
||||
|
||||
function preventDefault(event: Event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
export type TootContentProps = JSX.HTMLAttributes<HTMLDivElement> & {
|
||||
source?: string;
|
||||
emojis?: mastodon.v1.CustomEmoji[];
|
||||
mentions: mastodon.v1.StatusMention[];
|
||||
sensitive?: boolean;
|
||||
spoilerText?: string;
|
||||
reveal?: boolean;
|
||||
onToggleReveal?: JSX.EventHandlerUnion<HTMLElement, Event>;
|
||||
};
|
||||
|
||||
const TootContent: Component<TootContentProps> = (oprops) => {
|
||||
const [t] = createTranslator(
|
||||
(code) =>
|
||||
import(`./i18n/${code}.json`) as Promise<{
|
||||
default: {
|
||||
cw: string;
|
||||
};
|
||||
}>,
|
||||
);
|
||||
|
||||
const session = useDefaultSession();
|
||||
const [props, rest] = splitProps(oprops, [
|
||||
"source",
|
||||
"emojis",
|
||||
"mentions",
|
||||
"class",
|
||||
"sensitive",
|
||||
"spoilerText",
|
||||
"reveal",
|
||||
"onToggleReveal",
|
||||
]);
|
||||
|
||||
const clientFinder = createMemo(() =>
|
||||
session() ? makeAcctText(session()!) : undefined,
|
||||
);
|
||||
|
||||
const shouldRevealContent = () => {
|
||||
return !props.sensitive || (props.sensitive && props.reveal);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(ref) => {
|
||||
createRenderEffect(() => {
|
||||
const finder = clientFinder();
|
||||
for (const mention of props.mentions) {
|
||||
const elements = ref.querySelectorAll<HTMLAnchorElement>(
|
||||
`a[href='${mention.url}']`,
|
||||
);
|
||||
for (const e of elements) {
|
||||
e.onclick = preventDefault;
|
||||
e.dataset.action = "acct";
|
||||
e.dataset.client = finder;
|
||||
e.dataset.acctId = mention.id.toString();
|
||||
}
|
||||
}
|
||||
});
|
||||
}}
|
||||
class={`TootContent ${props.class || ""}`}
|
||||
{...rest}
|
||||
>
|
||||
<Show when={props.sensitive}>
|
||||
<div>
|
||||
<span
|
||||
ref={(ref) => {
|
||||
createRenderEffect(() => {
|
||||
ref.innerHTML = props.spoilerText
|
||||
? props.emojis
|
||||
? resolveCustomEmoji(props.spoilerText, props.emojis)
|
||||
: props.spoilerText
|
||||
: "";
|
||||
});
|
||||
}}
|
||||
></span>
|
||||
<Button onClick={props.onToggleReveal}>{t("cw")}</Button>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={shouldRevealContent()}>
|
||||
<div
|
||||
class="content"
|
||||
ref={(ref) =>
|
||||
createRenderEffect(() => {
|
||||
ref.innerHTML = props.source
|
||||
? props.emojis
|
||||
? resolveCustomEmoji(props.source, props.emojis)
|
||||
: props.source
|
||||
: "";
|
||||
})
|
||||
}
|
||||
></div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TootContent;
|
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"cw": "\"Content Warning\""
|
||||
}
|
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"cw": "“内容警告”"
|
||||
}
|
|
@ -1,23 +1,18 @@
|
|||
import {
|
||||
createRenderEffect,
|
||||
onCleanup,
|
||||
type Accessor,
|
||||
} from "solid-js";
|
||||
import { createRenderEffect, createSignal, onCleanup } from "solid-js";
|
||||
|
||||
export function useDocumentTitle(newTitle?: string | Accessor<string>) {
|
||||
export function useDocumentTitle(newTitle?: string) {
|
||||
const capturedTitle = document.title;
|
||||
const [title, setTitle] = createSignal(newTitle ?? capturedTitle);
|
||||
|
||||
createRenderEffect(() => {
|
||||
if (newTitle)
|
||||
document.title = typeof newTitle === "string" ? newTitle : newTitle();
|
||||
document.title = title();
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
document.title = capturedTitle;
|
||||
});
|
||||
|
||||
return (x: ((x: string) => string) | string) =>
|
||||
(document.title = typeof x === "string" ? x : x(document.title));
|
||||
return setTitle;
|
||||
}
|
||||
|
||||
export function mergeClass(c1: string | undefined, c2: string | undefined) {
|
||||
|
|
100
tools/certs/localhost.direct.crt
Normal file
100
tools/certs/localhost.direct.crt
Normal file
|
@ -0,0 +1,100 @@
|
|||
-----BEGIN CERTIFICATE-----
|
||||
MIIGaDCCBVCgAwIBAgIMcgWlFk4ihQWQO96fMA0GCSqGSIb3DQEBCwUAMFUxCzAJ
|
||||
BgNVBAYTAkJFMRkwFwYDVQQKExBHbG9iYWxTaWduIG52LXNhMSswKQYDVQQDEyJH
|
||||
bG9iYWxTaWduIEdDQyBSNiBBbHBoYVNTTCBDQSAyMDIzMB4XDTI0MDQxNzE5MTkz
|
||||
OVoXDTI1MDUxOTE5MTkzOFowHTEbMBkGA1UEAwwSKi5sb2NhbGhvc3QuZGlyZWN0
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAl7j/nKHNPbO+9oCQyKOV
|
||||
sbSe3lJLSUup2Tr/nCBgPUdBJE4ZrrhgLYz49qU9d/tXQG2thywa3bcVMq6Vv7Wl
|
||||
pzPEJzsGNgAp1e8Z++aN8VoUb46BlsvAOAUEOYcfk3SVfM85orEhBVYswUfunptM
|
||||
LW75zAO+kLgbgzpAVk6vtgWWEXNMVVdA6hOitNWKbR6s5Qh8wGJ+YmhYMfn+lcxX
|
||||
e9e8gmPFZ6EegGSu1ZFP9KlSq8X6udSYSOZPccjdLcjbznx4opbRfgfT09O5IZw2
|
||||
SSoHvRotDxY/BiPaubmQnhz/xrMoXyJm6TDibYnfPvVQD5946+euP3gS4IfA6C5O
|
||||
VwIDAQABo4IDbjCCA2owDgYDVR0PAQH/BAQDAgWgMAwGA1UdEwEB/wQCMAAwgZkG
|
||||
CCsGAQUFBwEBBIGMMIGJMEkGCCsGAQUFBzAChj1odHRwOi8vc2VjdXJlLmdsb2Jh
|
||||
bHNpZ24uY29tL2NhY2VydC9nc2djY3I2YWxwaGFzc2xjYTIwMjMuY3J0MDwGCCsG
|
||||
AQUFBzABhjBodHRwOi8vb2NzcC5nbG9iYWxzaWduLmNvbS9nc2djY3I2YWxwaGFz
|
||||
c2xjYTIwMjMwVwYDVR0gBFAwTjAIBgZngQwBAgEwQgYKKwYBBAGgMgoBAzA0MDIG
|
||||
CCsGAQUFBwIBFiZodHRwczovL3d3dy5nbG9iYWxzaWduLmNvbS9yZXBvc2l0b3J5
|
||||
LzBEBgNVHR8EPTA7MDmgN6A1hjNodHRwOi8vY3JsLmdsb2JhbHNpZ24uY29tL2dz
|
||||
Z2NjcjZhbHBoYXNzbGNhMjAyMy5jcmwwLwYDVR0RBCgwJoISKi5sb2NhbGhvc3Qu
|
||||
ZGlyZWN0ghBsb2NhbGhvc3QuZGlyZWN0MB0GA1UdJQQWMBQGCCsGAQUFBwMBBggr
|
||||
BgEFBQcDAjAfBgNVHSMEGDAWgBS9BbfzipM8c8t5+g+FEqF3lhiRdDAdBgNVHQ4E
|
||||
FgQUKI953RG67hpYzTKIgOFfjOhCFHUwggF9BgorBgEEAdZ5AgQCBIIBbQSCAWkB
|
||||
ZwB2AKLjCuRF772tm3447Udnd1PXgluElNcrXhssxLlQpEfnAAABju2AJRgAAAQD
|
||||
AEcwRQIhAM6LOxsJZpalFLVL5gxcPVg/esjcs77aMy55RbEsugIcAiA+eXsLDS0l
|
||||
L2LAVio6ccRujBXv4AVX8+UMEjxTCJOK8gB1ABNK3xq1mEIJeAxv70x6kaQWtyNJ
|
||||
zlhXat+u2qfCq+AiAAABju2AJUoAAAQDAEYwRAIgafNBrzSDplrC/23Al8N62TGN
|
||||
df6/I3sFbRdK1WjBrCsCIEXSOPPQrhEEoMZN8ZGRzkY7znL0zWuJsDA2IDj7+mUQ
|
||||
AHYATnWjJ1yaEMM4W2zU3z9S6x3w4I4bjWnAsfpksWKaOd8AAAGO7YAmKAAABAMA
|
||||
RzBFAiEA72BBCCM0QbJ1iN6jr9xBf51RNDjI6vV3me/v2m0CjvACICazjNaoB080
|
||||
cqeQVF9ROzyHkaYUkb7vpeDd+EZeMhhWMA0GCSqGSIb3DQEBCwUAA4IBAQBBImJh
|
||||
WM2CJEEALTrfPO4qiTig0jr9GoIhW0Vy31qiIfOchv8yNBTCc01Zd4LKqnpNId7K
|
||||
a3TmMEyt/kf5PUSVkoVhBlk2wOdbtNvzxmc1VgUteBcng99GQNs4TJ6kOTuz9T0P
|
||||
ycvgB48A7cjLtQ/bQSYWvJkn46VgYAIofBUrX7Bc4gLCs/XobADO5iLm9vvmvhlM
|
||||
TigYA6vG4jgSOHnNOyAgus3FVupFA7Xsyo3lxo8BKD2/DkeJykc505i+s3xF6Tn0
|
||||
sv7t7GQAukAu/AUiPIvRYYXzFBebx14/nuCjwRvhYt5O/At2dzt+ctNmyfpD/NAa
|
||||
1cuNyikOi8Y/8hUQ
|
||||
-----END CERTIFICATE-----
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIFjDCCA3SgAwIBAgIQfx8skC6D0OO2+zvuR4tegDANBgkqhkiG9w0BAQsFADBM
|
||||
MSAwHgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSNjETMBEGA1UEChMKR2xv
|
||||
YmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjAeFw0yMzA3MTkwMzQzMjVaFw0y
|
||||
NjA3MTkwMDAwMDBaMFUxCzAJBgNVBAYTAkJFMRkwFwYDVQQKExBHbG9iYWxTaWdu
|
||||
IG52LXNhMSswKQYDVQQDEyJHbG9iYWxTaWduIEdDQyBSNiBBbHBoYVNTTCBDQSAy
|
||||
MDIzMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA00Jvk5ADppO0rgDn
|
||||
j1M14XIb032Aas409JJFAb8cUjipFOth7ySLdaWLe3s63oSs5x3eWwzTpX4BFkzZ
|
||||
bxT1eoJSHfT2M0wZ5QOPcCIjsr+YB8TAvV2yJSyq+emRrN/FtgCSTaWXSJ5jipW8
|
||||
SJ/VAuXPMzuAP2yYpuPcjjQ5GyrssDXgu+FhtYxqyFP7BSvx9jQhh5QV5zhLycua
|
||||
n8n+J0Uw09WRQK6JGQ5HzDZQinkNel+fZZNRG1gE9Qeh+tHBplrkalB1g85qJkPO
|
||||
J7SoEvKsmDkajggk/sSq7NPyzFaa/VBGZiRRG+FkxCBniGD5618PQ4trcwHyMojS
|
||||
FObOHQIDAQABo4IBXzCCAVswDgYDVR0PAQH/BAQDAgGGMB0GA1UdJQQWMBQGCCsG
|
||||
AQUFBwMBBggrBgEFBQcDAjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBS9
|
||||
BbfzipM8c8t5+g+FEqF3lhiRdDAfBgNVHSMEGDAWgBSubAWjkxPioufi1xzWx/B/
|
||||
yGdToDB7BggrBgEFBQcBAQRvMG0wLgYIKwYBBQUHMAGGImh0dHA6Ly9vY3NwMi5n
|
||||
bG9iYWxzaWduLmNvbS9yb290cjYwOwYIKwYBBQUHMAKGL2h0dHA6Ly9zZWN1cmUu
|
||||
Z2xvYmFsc2lnbi5jb20vY2FjZXJ0L3Jvb3QtcjYuY3J0MDYGA1UdHwQvMC0wK6Ap
|
||||
oCeGJWh0dHA6Ly9jcmwuZ2xvYmFsc2lnbi5jb20vcm9vdC1yNi5jcmwwIQYDVR0g
|
||||
BBowGDAIBgZngQwBAgEwDAYKKwYBBAGgMgoBAzANBgkqhkiG9w0BAQsFAAOCAgEA
|
||||
fMkkMo5g4mn1ft4d4xR2kHzYpDukhC1XYPwfSZN3A9nEBadjdKZMH7iuS1vF8uSc
|
||||
g26/30DRPen2fFRsr662ECyUCR4OfeiiGNdoQvcesM9Xpew3HLQP4qHg+s774hNL
|
||||
vGRD4aKSKwFqLMrcqCw6tEAfX99tFWsD4jzbC6k8tjSLzEl0fTUlfkJaWpvLVkpg
|
||||
9et8tD8d51bymCg5J6J6wcXpmsSGnksBobac1+nXmgB7jQC9edU8Z41FFo87BV3k
|
||||
CtrWWsdkQavObMsXUPl/AO8y/jOuAWz0wyvPnKom+o6W4vKDY6/6XPypNdebOJ6m
|
||||
jyaILp0quoQvhjx87BzENh5s57AIOyIGpS0sDEChVDPzLEfRsH2FJ8/W5woF0nvs
|
||||
BTqfYSCqblQbHeDDtCj7Mlf8JfqaMuqcbE4rMSyfeHyCdZQwnc/r9ujnth691AJh
|
||||
xyYeCM04metJIe7cB6d4dFm+Pd5ervY4x32r0uQ1Q0spy1VjNqUJjussYuXNyMmF
|
||||
HSuLQQ6PrePmH5lcSMQpYKzPoD/RiNVD/PK0O3vuO5vh3o7oKb1FfzoanDsFFTrw
|
||||
0aLOdRW/tmLPWVNVlAb8ad+B80YJsL4HXYnQG8wYAFb8LhwSDyT9v+C1C1lcIHE7
|
||||
nE0AAp9JSHxDYsma9pi4g0Phg3BgOm2euTRzw7R0SzU=
|
||||
-----END CERTIFICATE-----
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIFUTCCBDmgAwIBAgIQdR4/VknnTLv2nQAmtnyqjDANBgkqhkiG9w0BAQwFADBX
|
||||
MQswCQYDVQQGEwJCRTEZMBcGA1UEChMQR2xvYmFsU2lnbiBudi1zYTEQMA4GA1UE
|
||||
CxMHUm9vdCBDQTEbMBkGA1UEAxMSR2xvYmFsU2lnbiBSb290IENBMB4XDTE5MDYx
|
||||
OTAwMDAwMFoXDTI4MDEyODEyMDAwMFowTDEgMB4GA1UECxMXR2xvYmFsU2lnbiBS
|
||||
b290IENBIC0gUjYxEzARBgNVBAoTCkdsb2JhbFNpZ24xEzARBgNVBAMTCkdsb2Jh
|
||||
bFNpZ24wggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCVB+hzymb57BTK
|
||||
ezz3DQjxtEULLIK0SMbrWzyug7hBkjMUpG9/6SrMxrCIa8W2idHGsv8UzlEUIexK
|
||||
3RtaxtaH7k06FQbtZGYLkoDKRN5zlE7zp4l/T3hjCMgSUG1CZi9NuXkoTVIaihqA
|
||||
txmBDn7EirxkTCEcQ2jXPTyKxbJm1ZCatzEGxb7ibTIGph75ueuqo7i/voJjUNDw
|
||||
GInf5A959eqiHyrScC5757yTu21T4kh8jBAHOP9msndhfuDqjDyqtKT285VKEgdt
|
||||
/Yyyic/QoGF3yFh0sNQjOvddOsqi250J3l1ELZDxgc1Xkvp+vFAEYzTfa5MYvms2
|
||||
sjnkrCQ2t/DvthwTV5O23rL44oW3c6K4NapF8uCdNqFvVIrxclZuLojFUUJEFZTu
|
||||
o8U4lptOTloLR/MGNkl3MLxxN+Wm7CEIdfzmYRY/d9XZkZeECmzUAk10wBTt/Tn7
|
||||
g/JeFKEEsAvp/u6P4W4LsgizYWYJarEGOmWWWcDwNf3J2iiNGhGHcIEKqJp1HZ46
|
||||
hgUAntuA1iX53AWeJ1lMdjlb6vmlodiDD9H/3zAR+YXPM0j1ym1kFCx6WE/TSwhJ
|
||||
xZVkGmMOeT31s4zKWK2cQkV5bg6HGVxUsWW2v4yb3BPpDW+4LtxnbsmLEbWEFIoA
|
||||
GXCDeZGXkdQaJ783HjIH2BRjPChMrwIDAQABo4IBIjCCAR4wDgYDVR0PAQH/BAQD
|
||||
AgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFK5sBaOTE+Ki5+LXHNbH8H/I
|
||||
Z1OgMB8GA1UdIwQYMBaAFGB7ZhpFDZfKiVAvfQTNNKj//P1LMD0GCCsGAQUFBwEB
|
||||
BDEwLzAtBggrBgEFBQcwAYYhaHR0cDovL29jc3AuZ2xvYmFsc2lnbi5jb20vcm9v
|
||||
dHIxMDMGA1UdHwQsMCowKKAmoCSGImh0dHA6Ly9jcmwuZ2xvYmFsc2lnbi5jb20v
|
||||
cm9vdC5jcmwwRwYDVR0gBEAwPjA8BgRVHSAAMDQwMgYIKwYBBQUHAgEWJmh0dHBz
|
||||
Oi8vd3d3Lmdsb2JhbHNpZ24uY29tL3JlcG9zaXRvcnkvMA0GCSqGSIb3DQEBDAUA
|
||||
A4IBAQDHrE3fEsZgYRw59I03e5wt03B45il4hAHmquLc33pbkGZn6r3GgoKVzvwC
|
||||
aBgtl6Jp93gZD8G5UjAFLj840jWDhOP7KSX6Q7rGjOsWNFFDJJLDUKQeJpB1PTRu
|
||||
HqVI15zxiCl/VCP7mbTW7X/pILaFOPO+T0Qj+TUOU37WOjk6wdeyyOFiDhKSwH2Y
|
||||
VE4YlAo0R10Jo3uNnSCFBgPw7gy1xt1+ajCbnzZYpQNXFy/0Lp9h3JOClE7TGvli
|
||||
FUazCjxvhHm5YWqulA51wFT2K9LRiiEWw3UJAgTTmxASitVHHLb3erkETk6SCwGv
|
||||
OG1eD0qLwuSeARZmhw3xFOCvMHeQ
|
||||
-----END CERTIFICATE-----
|
28
tools/certs/localhost.direct.key
Normal file
28
tools/certs/localhost.direct.key
Normal file
|
@ -0,0 +1,28 @@
|
|||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCXuP+coc09s772
|
||||
gJDIo5WxtJ7eUktJS6nZOv+cIGA9R0EkThmuuGAtjPj2pT13+1dAba2HLBrdtxUy
|
||||
rpW/taWnM8QnOwY2ACnV7xn75o3xWhRvjoGWy8A4BQQ5hx+TdJV8zzmisSEFVizB
|
||||
R+6em0wtbvnMA76QuBuDOkBWTq+2BZYRc0xVV0DqE6K01YptHqzlCHzAYn5iaFgx
|
||||
+f6VzFd717yCY8VnoR6AZK7VkU/0qVKrxfq51JhI5k9xyN0tyNvOfHiiltF+B9PT
|
||||
07khnDZJKge9Gi0PFj8GI9q5uZCeHP/GsyhfImbpMOJtid8+9VAPn3jr564/eBLg
|
||||
h8DoLk5XAgMBAAECggEAEFqIcsGd9cCiHL/O21GGmRj25s/H/aaCMEADvThgJzq+
|
||||
8sLYUdTdyQsg6rT04zHcPb2UrrU6UBuj1UqsKXXS3SrfQbtC+B8cY0raaiR3uEQV
|
||||
X9DkdvSPS3p+8hR2etZeJo3PkJG3FXQsbsjqF351v4/urObaa47sqEBnHuZsWhol
|
||||
jMEfztg5NWHz1r02UuUCPGy/uIn5MBQVcawn1LZuEepUc+El+fdwlFIC/TQj2Zuh
|
||||
EZUjMTFzaPDKhoXUvNlIi9YqchRsyB72IDJf/XxAKIp1QXqMcIH4c4sgwB7rbLiD
|
||||
G9xu7B68OTAhUdF0SJnuz3MhLlc0fqD0xRJoC8M5gQKBgQDBtHrPfqw61dDf3ghr
|
||||
EaCLDknPtmgtcH1tED3e6YfxEaQOnP/O03eigpakMBGbxpcGsSnkAD8KkBbWE9Wp
|
||||
FXo4xI4D4e4XZ0r7UciChXamxOWwwv5yUbkB+hie1WZSl5pVqc4ZS7p/1D4mDD8Y
|
||||
fnbXInF+mOtSSNZJ9RL3hTKLuQKBgQDIhChy+3yibuQz4hHwrSEr7yKMYFAR9TSl
|
||||
7AqjQ/Vw1uhb6dOD8nrcG9zaQhZsprKGpNuyw0Xy0U0skBfnv6nEMbKBNdP3YyQe
|
||||
/T3xUu7Z2kdRnaF1Vn/+g6bECXJs3FRIioBbA9XFKfRibxuWjPPpXZk5w3YF73nx
|
||||
SkpF1DlSjwKBgQCB0MNxZbJlJ8B5F6NKpiCSsLu00ckVksrsGbNtPdLWM31gMcWa
|
||||
Rcxqg9wTIwfZ/whd+sNZQvT8zj4PsHFDhNpJSyjl3zciRh5ROakIGAvBjjlk8fl2
|
||||
geBcO9DeOaP+fA15lXhDKaZOXt5bv19VugNJAJNRRYiHt7qtC+pvKbwLOQKBgCP+
|
||||
NRSOuAygQy5dAkNlkHLGdjkkgLr4fP7bo/0ykbgzm3oEOweQWyVvivFSs5vFQH6S
|
||||
0S0BiGjR0TySkPf0m5CwKw6ujuH1VeKKKrhK3r0URYEM/pKFeGxDTYga+gM4eZib
|
||||
4/Zydcjygv+4WgdoPdBCEOMhhuoB1q3NXA+0zKVZAoGBAIHR7Udk8rzwuno5IGc8
|
||||
AgdMhkDojqQaDJdJlAAmmX1IoqJOpyGdws+uUtQ/YnnNqQn2eTTlPxnl5ldtBYmR
|
||||
iT8XWLi4jIsY0jh6fLCaZwcu4RB0Rrw1N6nsZQFr/PG61ZPa8RJBfAGRE3QWc3WN
|
||||
L/Q1JdU0auJcvQn78yA/gLVp
|
||||
-----END PRIVATE KEY-----
|
|
@ -12,9 +12,5 @@
|
|||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
"paths": {
|
||||
"~platform/*": ["./src/platform/*"],
|
||||
"~material/*": ["./src/material/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
185
vite.config.ts
185
vite.config.ts
|
@ -1,159 +1,44 @@
|
|||
import { defineConfig, loadEnv } from "vite";
|
||||
import { defineConfig } from "vite";
|
||||
import solid from "vite-plugin-solid";
|
||||
import solidStyled from "vite-plugin-solid-styled";
|
||||
import suid from "@suid/vite-plugin";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
import version from "vite-plugin-package-version";
|
||||
import manifest from "./manifest.config";
|
||||
import { GetManualChunk } from "rollup";
|
||||
import devtools from "solid-devtools/vite";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
/**
|
||||
* Put all strings (/i18n/{key}.<json|js|ts>) into separated chunks based on the key.
|
||||
*/
|
||||
const chunkStrs: GetManualChunk = (id, { getModuleInfo }) => {
|
||||
const match = /.*\/i18n\/(.*)\.[jt]s.*$/.exec(id);
|
||||
if (match) {
|
||||
const key = match[1];
|
||||
|
||||
const dependentEntryPoints = [];
|
||||
|
||||
// we use a Set here so we handle each module at most once. This
|
||||
// prevents infinite loops in case of circular dependencies
|
||||
const idsToHandle = new Set(getModuleInfo(id)!.dynamicImporters);
|
||||
|
||||
for (const moduleId of idsToHandle) {
|
||||
const { isEntry, dynamicImporters, importers } = getModuleInfo(moduleId)!;
|
||||
if (isEntry || dynamicImporters.length > 0)
|
||||
dependentEntryPoints.push(moduleId);
|
||||
|
||||
// The Set iterator is intelligent enough to iterate over
|
||||
// elements that are added during iteration
|
||||
for (const importerId of importers) idsToHandle.add(importerId);
|
||||
}
|
||||
|
||||
// If there is a unique entry, we put it into a chunk based on the
|
||||
// entry name
|
||||
if (dependentEntryPoints.length === 1) {
|
||||
return `${key}.${
|
||||
dependentEntryPoints[0].split("/").slice(-1)[0].split(".")[0]
|
||||
}.strings`;
|
||||
}
|
||||
// For multiple entries, we put it into a "shared" chunk
|
||||
if (dependentEntryPoints.length > 1) {
|
||||
return `${key}.shared.strings`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const manualChunks: GetManualChunk = (id, meta) => {
|
||||
return chunkStrs(id, meta);
|
||||
};
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const devConf = loadEnv(mode, import.meta.dirname, "DEV");
|
||||
|
||||
const serverHttpCertBase = devConf["DEV_SERVER_HTTP_CERT_BASE"];
|
||||
const serverHttpCertPassword = devConf["DEV_SERVER_HTTP_CERT_PASS"];
|
||||
|
||||
const serverHttpCertKey = serverHttpCertBase
|
||||
? `${serverHttpCertBase}.key`
|
||||
: undefined;
|
||||
const serverHttpCertCrt = serverHttpCertBase
|
||||
? `${serverHttpCertBase}.crt`
|
||||
: undefined;
|
||||
|
||||
const isTestBuild = ["development", "staging"].includes(mode);
|
||||
|
||||
return {
|
||||
plugins: [
|
||||
devtools({
|
||||
autoname: true,
|
||||
locator: {
|
||||
targetIDE:
|
||||
(devConf["DEV_LOCATOR_EDITOR"] as
|
||||
| "vscode"
|
||||
| "atom"
|
||||
| "webstorm"
|
||||
| "vscode-insiders"
|
||||
| "") || undefined,
|
||||
componentLocation: true,
|
||||
jsxLocation: true,
|
||||
},
|
||||
}),
|
||||
suid(),
|
||||
solid(),
|
||||
solidStyled({
|
||||
filter: {
|
||||
include: "src/**/*.{tsx,jsx}",
|
||||
exclude: "node_modules/**/*.{ts,js,tsx,jsx}",
|
||||
},
|
||||
}),
|
||||
VitePWA({
|
||||
strategies: "injectManifest",
|
||||
registerType: "autoUpdate",
|
||||
devOptions: {
|
||||
enabled: true,
|
||||
},
|
||||
srcDir: "src/serviceworker",
|
||||
filename: "main.ts",
|
||||
manifest: manifest,
|
||||
pwaAssets: {
|
||||
config: true,
|
||||
},
|
||||
injectManifest: {
|
||||
globPatterns: ["**/*.{js,wasm,css,html,svg,png,ico}"],
|
||||
},
|
||||
}),
|
||||
version(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
/* We don't allow directly acessing the source root,
|
||||
because this encourage cross referencing between different
|
||||
module and loose the isolation. (Cross referencing is still
|
||||
possible, we don't stop it in any technical way.)
|
||||
|
||||
If the module is so important and is being referencing
|
||||
everywhere in the app. Consider promoting it to the top
|
||||
dir.
|
||||
|
||||
see docs/devnotes.md#module-isolation for details.
|
||||
*/
|
||||
"~platform": resolve(__dirname, "src/platform"),
|
||||
"~material": resolve(__dirname, "src/material"),
|
||||
export default defineConfig(({ mode }) => ({
|
||||
plugins: [
|
||||
suid(),
|
||||
solid(),
|
||||
solidStyled({
|
||||
filter: {
|
||||
include: "src/**/*.{tsx,jsx}",
|
||||
exclude: "node_modules/**/*.{ts,js,tsx,jsx}",
|
||||
},
|
||||
},
|
||||
server: {
|
||||
https: serverHttpCertBase
|
||||
? {
|
||||
// This config controls https for the *dev server*.
|
||||
// See docs/dev-https.md for setting up https
|
||||
key: serverHttpCertKey,
|
||||
cert: serverHttpCertCrt,
|
||||
passphrase: serverHttpCertPassword,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
esbuild: {
|
||||
pure: isTestBuild ? undefined : ["console.debug", "console.trace"],
|
||||
drop: isTestBuild ? undefined : ["debugger"],
|
||||
},
|
||||
define: {
|
||||
"import.meta.env.BUILT_AT": `"${new Date().toISOString()}"`,
|
||||
},
|
||||
css: {
|
||||
devSourcemap: true,
|
||||
},
|
||||
build: {
|
||||
target: ["firefox98", "safari15.4", "ios15.4", "chrome84", "edge87"],
|
||||
sourcemap: true,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks,
|
||||
},
|
||||
}),
|
||||
VitePWA({
|
||||
registerType: "autoUpdate",
|
||||
devOptions: {
|
||||
enabled: mode === "staging",
|
||||
},
|
||||
}),
|
||||
version(),
|
||||
],
|
||||
server: {
|
||||
https: {
|
||||
// localhost.direct: https://github.com/Upinel/localhost.direct
|
||||
key: "tools/certs/localhost.direct.key",
|
||||
cert: "tools/certs/localhost.direct.crt",
|
||||
passphrase: "localhost",
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
define: {
|
||||
"import.meta.env.BUILT_AT": `"${new Date().toISOString()}"`,
|
||||
},
|
||||
css: {
|
||||
devSourcemap: true,
|
||||
},
|
||||
build: {
|
||||
target: ["firefox98", "safari15.4", "ios15.4", "chrome84", "edge87"],
|
||||
sourcemap: true,
|
||||
},
|
||||
}));
|
||||
|
|
Loading…
Reference in a new issue