Compare commits

...

91 commits

Author SHA1 Message Date
Natsu Kagami 96f1a9d9f2
Update flake 2023-10-15 14:36:19 +02:00
Natsu Kagami 88901aa070
Merge remote-tracking branch 'upstream/main' 2023-10-15 14:23:40 +02:00
Lim Chee Aun 32b72f9297 Prevent time link from overlapping too much 2023-10-15 19:52:33 +08:00
Lim Chee Aun 57dead7960 Slight contrast bump for shiny pills 2023-10-15 19:52:17 +08:00
Lim Chee Aun 9786752a4f Group similar captions
Some folks really just copy/paste same desc for multiple media's
2023-10-15 18:28:04 +08:00
Lim Chee Aun ed8c9e994b Upgrade preact/preset-vite 2023-10-15 16:25:18 +08:00
Lim Chee Aun 8cf30773ce Close notification early
Not sure if this would make a difference but possibly fix some bugs
2023-10-15 16:25:04 +08:00
Lim Chee Aun 6540dd5642 Only set CW if there's spoiler text
Some posts have sensitive media but no spoiler text
2023-10-15 11:24:44 +08:00
Lim Chee Aun c80c8b3294 Need id as dependency too
- inner functions are not reading the updated id
- probably need to rewrite this as this code looks prone to errors
2023-10-15 10:50:33 +08:00
Lim Chee Aun e1ae89b00e Contextually highlight related caption when hovering over image
For multiple-media figures
2023-10-15 09:00:35 +08:00
Lim Chee Aun f9299ac15c Try generate more legit 'Release' 2023-10-15 08:45:11 +08:00
Lim Chee Aun df9eeeb0b3 Don't have to memoize unfurl
It already has caching
2023-10-15 01:42:24 +08:00
Lim Chee Aun 32bf258bbf Test memoize enhanceContent 2023-10-15 01:19:21 +08:00
Lim Chee Aun f56a44ac97 Complete transition from mem to moize 2023-10-14 20:33:40 +08:00
Lim Chee Aun 0a7f158b70 Memoize translated results
First step in migrating to moize
2023-10-14 20:10:34 +08:00
Lim Chee Aun ab1b34d4d2 Fix handling of admin.report notification
This is untested, may break.
2023-10-14 17:59:18 +08:00
Lim Chee Aun f2f7b7fe1f Fix admin.sign_up typo 2023-10-14 17:58:46 +08:00
Lim Chee Aun 7264f543bd Change p to div here too 2023-10-13 23:39:59 +08:00
Lim Chee Aun 66e4ba4991 Upgrade dependencies 2023-10-13 17:23:42 +08:00
Lim Chee Aun f6864f96bd Change p to div 2023-10-13 15:46:43 +08:00
Lim Chee Aun f67d4fd916 Fix id may not be available yet 2023-10-13 15:46:31 +08:00
Lim Chee Aun cd403fe605 Fix error with zero posts 2023-10-13 15:31:04 +08:00
Lim Chee Aun 5481aa12be Cache account info fetches for 10mins 2023-10-13 15:27:24 +08:00
Lim Chee Aun 806ad2c6a2 Fix media re-rendering due to url object keep being recreated 2023-10-12 23:19:48 +08:00
Lim Chee Aun d1b8d737cc Enable on-demand posting stats
- Slight refactor
- Make sure stats also work when switching instances
- Make sure zero stats fallback
2023-10-12 23:11:20 +08:00
Lim Chee Aun a095a30500 Breaking news: upgrade to masto v6
Expecting bugs!

Also include some fixes for states init.
2023-10-12 12:48:09 +08:00
Lim Chee Aun 5de7eec2ca Only show hover styles for tab bar when has hover
The hover delays the tap a little
2023-10-11 19:13:02 +08:00
Lim Chee Aun b8767f3618 Fix load wrong account's stuff when adding new account
Some account-based calls were called before states are initialized
2023-10-11 19:07:36 +08:00
Lim Chee Aun 68759e64d1 Silence errors for follow requests & announcements 2023-10-09 21:53:58 +08:00
Lim Chee Aun 78a6f13380 Fix leaked follow requests from Notifications popover to page 2023-10-09 19:46:07 +08:00
Lim Chee Aun a697fb04df Disable follow request buttons once has relationship 2023-10-09 19:44:54 +08:00
Lim Chee Aun 39f7d4e00d Fix familiar followers leaked to other profiles
Mistake for using global state when it should be per-profile
2023-10-07 17:13:55 +08:00
Lim Chee Aun 12d0e6aed8 Fix media caption and index not synced 2023-10-07 09:41:38 +08:00
Lim Chee Aun 769a5cb099 Change caption display logic for multiple media
- Show all of them or none of them
- If there's at least one caption < 140 chars, show all of them
- Fix potential bug when there are > 4 media
2023-10-06 23:57:12 +08:00
Lim Chee Aun d6d10d091e Slight adjustments to tab bar styles 2023-10-06 18:13:10 +08:00
Lim Chee Aun 5c6e9756d0 Upgrade dependencies 2023-10-06 18:12:37 +08:00
Lim Chee Aun eace6c4d9b Slight adjustments to media alt edit sheet 2023-10-05 18:07:36 +08:00
Lim Chee Aun 4723358d2d Fix borked image when restore from draft 2023-10-05 18:01:18 +08:00
Lim Chee Aun aad855cafc Try to use the additional new props for card
Only use imageDescription for now
2023-10-05 08:54:59 +08:00
Lim Chee Aun 643b6bce07 Try to use the additional new props for card
Only use imageDescription for now
2023-10-04 22:40:34 +08:00
Lim Chee Aun 5faf911b17 Replace scrollIntoViewIfNeeded with scrollIntoView
Because non-standard and not supported on Firefox
2023-10-04 21:24:48 +08:00
Lim Chee Aun ddd1ec5819 Compare accents and diacritics too 2023-10-04 21:23:21 +08:00
Lim Chee Aun 8cd3e38f22 Move this up, Intl stuff seems to run slow sometimes 2023-10-04 10:19:28 +08:00
Lim Chee Aun be964f933c Better throttle instead of debounce 2023-10-04 10:05:21 +08:00
Lim Chee Aun d429ef9161 Don't compact spoiler post if from different author 2023-10-04 08:31:40 +08:00
Lim Chee Aun 9885c8f388 Better contrast for visited links in dark mode 2023-10-04 00:09:32 +08:00
Lim Chee Aun 8be2c738df Make figcaption self align to bottom
This is in case the image height is smaller than the figcaption.
Could be possible for text in other languages.
Flexbox is so cool.
2023-10-03 22:15:15 +08:00
Lim Chee Aun faa7ffc310 Slight adjustments to carousel top buttons 2023-10-03 22:10:32 +08:00
Lim Chee Aun 4ac2e4aa7b Possibly fix rendering issue in Vanadium 2023-10-03 20:38:55 +08:00
Lim Chee Aun 60d55d45c2 Maybe need —tags 2023-10-03 19:45:42 +08:00
Lim Chee Aun 4436c337dd Cleanup 2023-10-03 15:07:47 +08:00
Lim Chee Aun c335655896 Link to Mingcute icons 2023-10-03 15:07:19 +08:00
Lim Chee Aun 48f1527cc6 Robustify useTruncated
Also attempt to fix weird scrollHeight bug again
2023-10-03 13:03:03 +08:00
Lim Chee Aun fcbf99f121 Got to dir=auto all the things 2023-10-03 10:29:28 +08:00
Lim Chee Aun 028b30a334 Possibly fix this tagging thing 2023-10-02 23:22:14 +08:00
Lim Chee Aun 5793476223 Change icons for muted/blocked users
It's not consistent with the icons on the menu for muting/blocking.
There's no "user" in these icons but at least more recognizable. The text should give sufficient context despite less contextual icons.
2023-10-02 21:20:47 +08:00
Lim Chee Aun 715357c8c9 Show synced icon & link to instance for more settings
Context: some users were confused why some settings are not on Phanpy when it can be set on their own instance's web UI
2023-10-02 21:13:56 +08:00
Lim Chee Aun 56365ebc39 Fix duplicate alt badges 2023-10-02 20:55:15 +08:00
Lim Chee Aun a1a78370cc Remove 'Media {i}:'
It'll look weird when description is not English
2023-10-02 19:57:19 +08:00
Lim Chee Aun 7e993704cc More conditions for show/hide captions
- Remove unused code
- Refactor and memoize the long/short calculation too
2023-10-02 18:58:42 +08:00
Lim Chee Aun f05267b216 MVP implementation of listing muted/blocked users 2023-10-02 17:51:36 +08:00
Lim Chee Aun 634e81e9d0 Show roles in account info 2023-10-02 16:55:13 +08:00
Lim Chee Aun 52c63690a3 More noopener noreferrer 2023-10-02 15:58:59 +08:00
Lim Chee Aun 348efe0069 Experiment figcaption for *multiple* media's 2023-10-02 12:21:26 +08:00
Lim Chee Aun 9f6236762d Place captions to right side of media when there's enough space 2023-10-02 09:30:35 +08:00
Lim Chee Aun 8a4ab1bdb9 Rewrite to be slightly more readable
Also, try to fix openWindow not working for Safari PWA
2023-10-01 23:20:48 +08:00
Lim Chee Aun a32a264159 Upgrade preact 2023-10-01 17:53:04 +08:00
Lim Chee Aun a364488895 Test only use longpress for iOS 2023-10-01 17:14:32 +08:00
Lim Chee Aun d05f0a4f23 Remove unused import 2023-10-01 17:14:18 +08:00
Lim Chee Aun 49fdcf7837 Show Translate button when different lang inside alt modal 2023-10-01 14:39:44 +08:00
Lim Chee Aun baa2605d27 Fix navigate not working 2023-10-01 14:38:28 +08:00
Lim Chee Aun 359fd92ae0 Little adjustments, show more captions 2023-10-01 13:18:31 +08:00
Lim Chee Aun 6a16b25722 Show tooltips for the tiny buttons on poll UI 2023-09-30 23:23:52 +08:00
Lim Chee Aun 4dd706ff96 Pass lang into media description
- Assume status lang applies to media description
- Allow RTL for media description
2023-09-30 23:23:34 +08:00
Lim Chee Aun 30f6d50a68 Let's further reduce cancelOnMovement 2023-09-30 00:26:51 +08:00
Lim Chee Aun 3042dea886 Allow GIFs play on focus/blur too 2023-09-29 21:02:29 +08:00
Lim Chee Aun ac14e61b6d Upgrade deps, fix warnings 2023-09-29 21:02:09 +08:00
Lim Chee Aun 27b0813e49 Fix flickering text bug
Font size changes when truncated class is added/removed, thus making it flickering
2023-09-29 09:38:14 +08:00
Lim Chee Aun 99d7525436 Fix name text becomes too easily clickable 2023-09-29 08:58:31 +08:00
Lim Chee Aun f9cb9502b1 Extract alt badge styles out from tag
- Differentiate clickable version vs non-clickable version
- Also differentiate alt badge vs the other "tags" on media
2023-09-28 23:48:01 +08:00
Lim Chee Aun 01c90150a8 Allow show more figcaption 2023-09-28 19:46:44 +08:00
Lim Chee Aun c1da6b8767 Remove previous experimental code 2023-09-28 18:08:36 +08:00
Lim Chee Aun dc06508aa5 Replace Info icon with ALT badge
This will be the "icon" as most users are already used to it
2023-09-28 16:25:13 +08:00
Lim Chee Aun 8c4a88b333 Fade out yellow more 2023-09-28 16:08:24 +08:00
Lim Chee Aun 8a10ffd477 Have to use media-fg/bg for alt badges 2023-09-28 15:59:10 +08:00
Lim Chee Aun b6c59d4ee1 Use luminosity for aesthetics 2023-09-28 15:48:55 +08:00
Lim Chee Aun 13cf7b3f92 It's time for global media alt modal 2023-09-28 15:48:32 +08:00
Lim Chee Aun fd1b45900d Different copy for toast when replying or editing 2023-09-28 15:45:38 +08:00
Lim Chee Aun 0f5edef199 Miss one here 2023-09-28 11:22:05 +08:00
Lim Chee Aun 4dfc0d0b41 Don't show 'Read more' if parent is already truncated 2023-09-28 11:21:40 +08:00
Lim Chee Aun b7416bc17d Handle Takahe links 2023-09-28 11:19:24 +08:00
65 changed files with 2761 additions and 2188 deletions

View file

@ -12,5 +12,5 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
ref: production ref: production
- run: git tag -a "'{date +%Y.%m.%d}.{git rev-parse --short HEAD}'" $(git rev-parse HEAD) - run: git tag "`date +%Y.%m.%d`.`git rev-parse --short HEAD`" $(git rev-parse HEAD)
- run: git push - run: git push --tags

25
.github/workflows/tagrelease.yml vendored Normal file
View file

@ -0,0 +1,25 @@
name: Create Release on every tag push in `production`
on:
push:
branches:
- production
tags:
- '*'
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: production
- uses: actions/setup-node@v3
with:
node-version: 18
- run: npm ci && npm run build
- run: cd dist && zip -r ../phanpy-dist.zip . && cd ..
- uses: softprops/action-gh-release@v1
with:
generate_release_notes: true
files: phanpy-dist.zip

1
.gitignore vendored
View file

@ -26,6 +26,7 @@ dist-ssr
# Custom # Custom
.env.dev .env.dev
src/data/instances-full.json src/data/instances-full.json
phanpy-dist.zip
# Nix # Nix
.direnv .direnv

View file

@ -8,6 +8,7 @@
"index.css$", "index.css$",
".css$", ".css$",
"<THIRD_PARTY_MODULES>", "<THIRD_PARTY_MODULES>",
"/assets/",
"^../", "^../",
"^[./]" "^[./]"
], ],

View file

@ -121,6 +121,7 @@ Try search for "how to self-host static sites" as there are many ways to do it.
- [React Router](https://reactrouter.com/) - Routing - [React Router](https://reactrouter.com/) - Routing
- [masto.js](https://github.com/neet/masto.js/) - Mastodon API client - [masto.js](https://github.com/neet/masto.js/) - Mastodon API client
- [Iconify](https://iconify.design/) - Icon library - [Iconify](https://iconify.design/) - Icon library
- [MingCute icons](https://www.mingcute.com/)
- Vanilla CSS - *Yes, I'm old school.* - Vanilla CSS - *Yes, I'm old school.*
Some of these may change in the future. The front-end world is ever-changing. Some of these may change in the future. The front-end world is ever-changing.

View file

@ -5,11 +5,11 @@
"systems": "systems" "systems": "systems"
}, },
"locked": { "locked": {
"lastModified": 1689068808, "lastModified": 1694529238,
"narHash": "sha256-6ixXo3wt24N/melDWjq70UuHQLxGV8jZvooRanIHXw0=", "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
"owner": "numtide", "owner": "numtide",
"repo": "flake-utils", "repo": "flake-utils",
"rev": "919d646de7be200f3bf08cb76ae1f09402b6f9b4", "rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -20,16 +20,16 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1689413807, "lastModified": 1697059129,
"narHash": "sha256-exuzOvOhGAEKWQKwDuZAL4N8a1I837hH5eocaTcIbLc=", "narHash": "sha256-9NJcFF9CEYPvHJ5ckE8kvINvI84SZZ87PvqMbH6pro0=",
"owner": "nixOS", "owner": "nixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "46ed466081b9cad1125b11f11a2af5cc40b942c7", "rev": "5e4c2ada4fcd54b99d56d7bd62f384511a7e2593",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "nixOS", "owner": "nixOS",
"ref": "nixpkgs-unstable", "ref": "nixos-unstable",
"repo": "nixpkgs", "repo": "nixpkgs",
"type": "github" "type": "github"
} }

View file

@ -1,5 +1,5 @@
{ {
inputs.nixpkgs.url = github:nixOS/nixpkgs/nixpkgs-unstable; inputs.nixpkgs.url = github:nixOS/nixpkgs/nixos-unstable;
inputs.flake-utils.url = github:numtide/flake-utils; inputs.flake-utils.url = github:numtide/flake-utils;
outputs = { self, nixpkgs, flake-utils, ... }: flake-utils.lib.eachDefaultSystem (system: outputs = { self, nixpkgs, flake-utils, ... }: flake-utils.lib.eachDefaultSystem (system:
@ -7,7 +7,7 @@
pkgs = import nixpkgs { inherit system; }; pkgs = import nixpkgs { inherit system; };
lib = pkgs.lib; lib = pkgs.lib;
in in
{ rec {
packages.default = pkgs.buildNpmPackage { packages.default = pkgs.buildNpmPackage {
pname = "dtth-phanpy"; pname = "dtth-phanpy";
version = "0.1.0"; version = "0.1.0";
@ -16,7 +16,7 @@
src = lib.cleanSource ./.; src = lib.cleanSource ./.;
npmDepsHash = "sha256-tqR3YQ++nJmwDNKIm7uFLhJ5HlAqfeEmJVyynHx3Hzw="; npmDepsHash = "sha256-LpvZfIzIdgxXg4upcDKm7jbK7CjrRvg//HULO4GDTdU=";
# npmDepsHash = lib.fakeHash; # npmDepsHash = lib.fakeHash;
# DTTH-specific env variables # DTTH-specific env variables
@ -33,6 +33,7 @@
}; };
devShells.default = pkgs.mkShell { devShells.default = pkgs.mkShell {
inputsFrom = [ packages.default ];
buildInputs = with pkgs; [ nodejs ]; buildInputs = with pkgs; [ nodejs ];
}; };
}); });

2326
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -12,25 +12,25 @@
"dependencies": { "dependencies": {
"@formatjs/intl-localematcher": "~0.4.2", "@formatjs/intl-localematcher": "~0.4.2",
"@github/text-expander-element": "~2.5.0", "@github/text-expander-element": "~2.5.0",
"@iconify-icons/mingcute": "~1.2.7", "@iconify-icons/mingcute": "~1.2.8",
"@justinribeiro/lite-youtube": "~1.5.0", "@justinribeiro/lite-youtube": "~1.5.0",
"@szhsin/react-menu": "~4.0.3", "@szhsin/react-menu": "~4.1.0",
"@uidotdev/usehooks": "~2.2.0", "@uidotdev/usehooks": "~2.4.0",
"dayjs": "~1.11.9", "dayjs": "~1.11.10",
"dayjs-twitter": "~0.5.0", "dayjs-twitter": "~0.5.0",
"fast-blurhash": "~1.1.2", "fast-blurhash": "~1.1.2",
"fast-deep-equal": "~3.1.3", "fast-deep-equal": "~3.1.3",
"idb-keyval": "~6.2.1", "idb-keyval": "~6.2.1",
"just-debounce-it": "~3.2.0", "just-debounce-it": "~3.2.0",
"lz-string": "~1.5.0", "lz-string": "~1.5.0",
"masto": "~5.11.4", "masto": "~6.3.1",
"mem": "~9.0.2", "moize": "~6.1.6",
"p-retry": "~6.0.0", "p-retry": "~6.1.0",
"p-throttle": "~5.1.0", "p-throttle": "~5.1.0",
"preact": "~10.17.1", "preact": "~10.18.1",
"react-hotkeys-hook": "~4.4.1", "react-hotkeys-hook": "~4.4.1",
"react-intersection-observer": "~9.5.2", "react-intersection-observer": "~9.5.2",
"react-quick-pinch-zoom": "~4.9.0", "react-quick-pinch-zoom": "~5.0.0",
"react-router-dom": "6.6.2", "react-router-dom": "6.6.2",
"string-length": "5.0.1", "string-length": "5.0.1",
"swiped-events": "~1.1.7", "swiped-events": "~1.1.7",
@ -42,13 +42,13 @@
"valtio": "1.9.0" "valtio": "1.9.0"
}, },
"devDependencies": { "devDependencies": {
"@preact/preset-vite": "~2.5.0", "@preact/preset-vite": "~2.6.0",
"@trivago/prettier-plugin-sort-imports": "~4.2.0", "@trivago/prettier-plugin-sort-imports": "~4.2.0",
"postcss": "~8.4.29", "postcss": "~8.4.31",
"postcss-dark-theme-class": "~1.0.0", "postcss-dark-theme-class": "~1.0.0",
"postcss-preset-env": "~9.1.3", "postcss-preset-env": "~9.2.0",
"twitter-text": "~3.1.0", "twitter-text": "~3.1.0",
"vite": "~4.4.9", "vite": "~4.4.11",
"vite-plugin-generate-file": "~0.0.4", "vite-plugin-generate-file": "~0.0.4",
"vite-plugin-html-config": "~1.0.11", "vite-plugin-html-config": "~1.0.11",
"vite-plugin-pwa": "~0.16.5", "vite-plugin-pwa": "~0.16.5",

View file

@ -161,33 +161,23 @@ self.addEventListener('notificationclick', (event) => {
console.log('NOTIFICATION CLICK payload', payload); console.log('NOTIFICATION CLICK payload', payload);
const { badge, body, data, dir, icon, lang, tag, timestamp, title } = payload; const { badge, body, data, dir, icon, lang, tag, timestamp, title } = payload;
const { access_token, notification_type } = data; const { access_token, notification_type } = data;
const actions = new Promise((resolve) => {
event.notification.close();
const url = `/#/notifications?id=${tag}&access_token=${btoa(access_token)}`; const url = `/#/notifications?id=${tag}&access_token=${btoa(access_token)}`;
self.clients
.matchAll({ event.notification.close();
event.waitUntil(
(async () => {
const clients = await self.clients.matchAll({
type: 'window', type: 'window',
includeUncontrolled: true, includeUncontrolled: true,
}) });
.then((clients) => {
console.log('NOTIFICATION CLICK clients 1', clients); console.log('NOTIFICATION CLICK clients 1', clients);
if (clients.length && 'navigate' in clients[0]) { if (clients.length && 'navigate' in clients[0]) {
console.log('NOTIFICATION CLICK clients 2', clients); console.log('NOTIFICATION CLICK clients 2', clients);
const bestClient = const bestClient =
clients.find( clients.find(
(client) => (client) => client.focused || client.visibilityState === 'visible',
client.focused || client.visibilityState === 'visible',
) || clients[0]; ) || clients[0];
console.log('NOTIFICATION CLICK navigate', url); console.log('NOTIFICATION CLICK navigate', url);
// Check if URL is root / or /notifications
// const clientURL = new URL(bestClient.url);
// if (
// /^#\/?$/.test(clientURL.hash) ||
// /^#\/notifications/i.test(clientURL.hash)
// ) {
// bestClient.navigate(url).then((client) => client?.focus());
// } else {
// User might be on a different page (e.g. composing a post), so don't navigate anywhere else
if (bestClient) { if (bestClient) {
console.log('NOTIFICATION CLICK postMessage', bestClient); console.log('NOTIFICATION CLICK postMessage', bestClient);
bestClient.postMessage?.({ bestClient.postMessage?.({
@ -198,15 +188,13 @@ self.addEventListener('notificationclick', (event) => {
bestClient.focus(); bestClient.focus();
} else { } else {
console.log('NOTIFICATION CLICK openWindow', url); console.log('NOTIFICATION CLICK openWindow', url);
self.clients.openWindow(url); await self.clients.openWindow(url);
} }
// } // }
} else { } else {
console.log('NOTIFICATION CLICK openWindow', url); console.log('NOTIFICATION CLICK openWindow', url);
self.clients.openWindow(url); await self.clients.openWindow(url);
} }
resolve(); })(),
}); );
});
event.waitUntil(actions);
}); });

View file

@ -1087,6 +1087,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
top: env(safe-area-inset-top, 0); top: env(safe-area-inset-top, 0);
} }
:is(.carousel-top-controls, .carousel-controls) { :is(.carousel-top-controls, .carousel-controls) {
/* mix-blend-mode: luminosity; */
position: absolute; position: absolute;
left: 0; left: 0;
left: env(safe-area-inset-left, 0); left: env(safe-area-inset-left, 0);
@ -1119,10 +1120,9 @@ button.carousel-dot {
button.carousel-dot { button.carousel-dot {
background-color: transparent; background-color: transparent;
} }
.carousel-controls :is(.button, button).carousel-button { :is(.button, button).carousel-button {
background-color: var(--bg-blur-color); background-color: var(--bg-blur-color);
} }
.carousel-controls
:is(.button, button).carousel-button:is(:hover, :focus):not(:active) { :is(.button, button).carousel-button:is(:hover, :focus):not(:active) {
background-color: var(--bg-color); background-color: var(--bg-color);
} }
@ -1140,15 +1140,19 @@ button.carousel-dot {
border: 1px solid var(--outline-color); border: 1px solid var(--outline-color);
box-shadow: 0 4px 32px var(--drop-shadow-color); box-shadow: 0 4px 32px var(--drop-shadow-color);
/* backdrop-filter: blur(12px) invert(0.25); */ /* backdrop-filter: blur(12px) invert(0.25); */
transition: background-color 0.2s ease-out;
&:hover {
background-color: var(--bg-color);
}
} }
button.carousel-dot { button.carousel-dot {
backdrop-filter: none !important; backdrop-filter: none !important;
border: none; border: none;
box-shadow: none; box-shadow: none;
} }
button.carousel-dot[disabled] { /* button.carousel-dot[disabled] {
pointer-events: none; pointer-events: none;
} } */
button.carousel-dot .icon { button.carousel-dot .icon {
transition: all 0.2s; transition: all 0.2s;
transform: scale(0.5); transform: scale(0.5);
@ -1331,12 +1335,16 @@ body:has(.media-modal-container + .status-deck) .media-post-link {
position: relative; position: relative;
} }
.sheet-max { .sheet-max {
width: 90vw;
width: 90dvw;
max-width: none; max-width: none;
height: 90vh; height: 90vh;
height: 90dvh; height: 90dvh;
} }
@media (min-width: 40em) {
.sheet {
width: 90vw;
width: 90dvw;
}
}
.sheet .sheet-close { .sheet .sheet-close {
position: absolute; position: absolute;
border-radius: 0; border-radius: 0;
@ -1423,6 +1431,10 @@ body:has(.media-modal-container + .status-deck) .media-post-link {
display: inline-block; display: inline-block;
margin: 4px; margin: 4px;
align-self: center; align-self: center;
&.clickable {
cursor: pointer;
}
} }
.tag .icon { .tag .icon {
vertical-align: middle; vertical-align: middle;
@ -1742,7 +1754,7 @@ meter.donut[hidden] {
font-weight: 500; font-weight: 500;
text-shadow: 0 1px var(--bg-color); text-shadow: 0 1px var(--bg-color);
background-color: var(--bg-color); background-color: var(--bg-color);
border: 1px solid var(--outline-color); border: 2px solid var(--link-faded-color);
box-shadow: 0 3px 16px var(--drop-shadow-color), box-shadow: 0 3px 16px var(--drop-shadow-color),
0 6px 16px -3px var(--drop-shadow-color); 0 6px 16px -3px var(--drop-shadow-color);
} }
@ -1750,8 +1762,7 @@ meter.donut[hidden] {
color: var(--text-color); color: var(--text-color);
border-color: var(--link-color); border-color: var(--link-color);
filter: none !important; filter: none !important;
box-shadow: 0 0 0 1px var(--link-text-color), box-shadow: 0 3px 16px var(--drop-shadow-color),
0 3px 16px var(--drop-shadow-color),
0 6px 16px -3px var(--drop-shadow-color), 0 6px 16px -3px var(--drop-shadow-color),
0 6px 16px var(--drop-shadow-color); 0 6px 16px var(--drop-shadow-color);
} }

View file

@ -114,28 +114,31 @@ function App() {
code, code,
}); });
const masto = initClient({ instance: instanceURL, accessToken }); const client = initClient({ instance: instanceURL, accessToken });
await Promise.allSettled([ await Promise.allSettled([
initInstance(masto, instanceURL), initInstance(client, instanceURL),
initAccount(masto, instanceURL, accessToken, vapidKey), initAccount(client, instanceURL, accessToken, vapidKey),
]); ]);
initStates(); initStates();
initPreferences(masto); initPreferences(client);
setIsLoggedIn(true); setIsLoggedIn(true);
setUIState('default'); setUIState('default');
})(); })();
} else { } else {
window.__IGNORE_GET_ACCOUNT_ERROR__ = true;
const account = getCurrentAccount(); const account = getCurrentAccount();
if (account) { if (account) {
store.session.set('currentAccount', account.info.id); store.session.set('currentAccount', account.info.id);
const { masto, instance } = api({ account }); const { client } = api({ account });
console.log('masto', masto); const { instance } = client;
initPreferences(masto); // console.log('masto', masto);
initStates();
initPreferences(client);
setUIState('loading'); setUIState('loading');
(async () => { (async () => {
try { try {
await initInstance(masto, instance); await initInstance(client, instance);
} catch (e) { } catch (e) {
} finally { } finally {
setIsLoggedIn(true); setIsLoggedIn(true);
@ -251,9 +254,9 @@ function App() {
<Shortcuts /> <Shortcuts />
)} )}
<Modals /> <Modals />
<NotificationService /> {isLoggedIn && <NotificationService />}
<BackgroundService isLoggedIn={isLoggedIn} /> <BackgroundService isLoggedIn={isLoggedIn} />
<SearchCommand onClose={focusDeck} /> {uiState !== 'loading' && <SearchCommand onClose={focusDeck} />}
<KeyboardShortcutsHelp /> <KeyboardShortcutsHelp />
</> </>
); );

View file

@ -3,7 +3,6 @@ import './account-block.css';
// import { useNavigate } from 'react-router-dom'; // import { useNavigate } from 'react-router-dom';
import enhanceContent from '../utils/enhance-content'; import enhanceContent from '../utils/enhance-content';
import niceDateTime from '../utils/nice-date-time'; import niceDateTime from '../utils/nice-date-time';
import shortenNumber from '../utils/shorten-number';
import states from '../utils/states'; import states from '../utils/states';
import Avatar from './avatar'; import Avatar from './avatar';

View file

@ -220,6 +220,7 @@
} }
.account-container .actions { .account-container .actions {
margin-block: 8px;
display: flex; display: flex;
gap: 8px; gap: 8px;
justify-content: space-between; justify-content: space-between;
@ -342,23 +343,82 @@
opacity: 1; opacity: 1;
} }
} }
.account-container .posting-stats { .account-container .posting-stats-button {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
color: inherit;
background-color: var(--bg-faded-color);
padding: 8px 12px;
font-size: 90%;
color: var(--text-insignificant-color);
line-height: 1;
vertical-align: text-top;
border-radius: 4px;
&:is(:hover, :focus-within) {
color: var(--text-color);
background-color: var(--link-bg-hover-color);
filter: none !important;
}
.loader-container {
margin: 0;
opacity: 0.5;
transform: scale(0.75);
}
}
@keyframes wobble {
0% {
transform: rotate(-4deg);
}
100% {
transform: rotate(4deg);
}
}
@keyframes loading-spin {
0% {
transform: rotate(0deg) scale(0.75);
}
100% {
transform: rotate(360deg) scale(0.75);
}
}
.posting-stats-icon {
display: inline-block;
width: 24px;
height: 8px;
filter: opacity(0.75);
animation: wobble 2s linear both infinite alternate !important;
&.loading {
animation: loading-spin 0.35s linear both infinite !important;
}
}
.account-container {
--posting-stats-size: 8px;
--original-color: var(--link-color);
.posting-stats {
font-size: 90%; font-size: 90%;
color: var(--text-insignificant-color); color: var(--text-insignificant-color);
background-color: var(--bg-faded-color); background-color: var(--bg-faded-color);
padding: 8px 12px; padding: 8px 12px;
--size: 8px;
--original-color: var(--link-color);
&:is(:hover, :focus-within) { &:is(:hover, :focus-within) {
background-color: var(--link-bg-hover-color); background-color: var(--link-bg-hover-color);
} }
}
.posting-stats-bar { .posting-stats-bar {
--gap: 0.5px; --gap: 0.5px;
--gap-color: var(--outline-color); --gap-color: var(--outline-color);
height: var(--size); height: var(--posting-stats-size);
border-radius: var(--size); border-radius: var(--posting-stats-size);
overflow: hidden; overflow: hidden;
margin: 8px 0; margin: 8px 0;
box-shadow: inset 0 0 0 1px var(--outline-color), box-shadow: inset 0 0 0 1px var(--outline-color),
@ -388,9 +448,9 @@
.posting-stats-legend-item { .posting-stats-legend-item {
display: inline-block; display: inline-block;
width: var(--size); width: var(--posting-stats-size);
height: var(--size); height: var(--posting-stats-size);
border-radius: var(--size); border-radius: var(--posting-stats-size);
background-color: var(--text-insignificant-color); background-color: var(--text-insignificant-color);
vertical-align: middle; vertical-align: middle;
margin: 0 4px 2px; margin: 0 4px 2px;

View file

@ -1,14 +1,21 @@
import './account-info.css'; import './account-info.css';
import { Menu, MenuDivider, MenuItem, SubMenu } from '@szhsin/react-menu'; import { Menu, MenuDivider, MenuItem, SubMenu } from '@szhsin/react-menu';
import { useEffect, useMemo, useReducer, useRef, useState } from 'preact/hooks'; import {
import { proxy, useSnapshot } from 'valtio'; useCallback,
useEffect,
useMemo,
useReducer,
useRef,
useState,
} from 'preact/hooks';
import { api } from '../utils/api'; import { api } from '../utils/api';
import enhanceContent from '../utils/enhance-content'; import enhanceContent from '../utils/enhance-content';
import getHTMLText from '../utils/getHTMLText'; import getHTMLText from '../utils/getHTMLText';
import handleContentLinks from '../utils/handle-content-links'; import handleContentLinks from '../utils/handle-content-links';
import niceDateTime from '../utils/nice-date-time'; import niceDateTime from '../utils/nice-date-time';
import pmem from '../utils/pmem';
import shortenNumber from '../utils/shorten-number'; import shortenNumber from '../utils/shorten-number';
import showToast from '../utils/show-toast'; import showToast from '../utils/show-toast';
import states, { hideAllModals } from '../utils/states'; import states, { hideAllModals } from '../utils/states';
@ -49,8 +56,64 @@ const MUTE_DURATIONS_LABELS = {
const LIMIT = 80; const LIMIT = 80;
const accountInfoStates = proxy({ const ACCOUNT_INFO_MAX_AGE = 1000 * 60 * 10; // 10 mins
familiarFollowers: [],
function fetchFamiliarFollowers(currentID, masto) {
return masto.v1.accounts.familiarFollowers.fetch({
id: [currentID],
});
}
const memFetchFamiliarFollowers = pmem(fetchFamiliarFollowers, {
maxAge: ACCOUNT_INFO_MAX_AGE,
});
async function fetchPostingStats(accountID, masto) {
const fetchStatuses = masto.v1.accounts
.$select(accountID)
.statuses.list({
limit: 20,
})
.next();
const { value: statuses } = await fetchStatuses;
console.log('fetched statuses', statuses);
const stats = {
total: statuses.length,
originals: 0,
replies: 0,
boosts: 0,
};
// Categories statuses by type
// - Original posts (not replies to others)
// - Threads (self-replies + 1st original post)
// - Boosts (reblogs)
// - Replies (not-self replies)
statuses.forEach((status) => {
if (status.reblog) {
stats.boosts++;
} else if (
!!status.inReplyToId &&
status.inReplyToAccountId !== status.account.id // Not self-reply
) {
stats.replies++;
} else {
stats.originals++;
}
});
// Count days since last post
if (statuses.length) {
stats.daysSinceLastPost = Math.ceil(
(Date.now() - new Date(statuses[statuses.length - 1].createdAt)) /
86400000,
);
}
console.log('posting stats', stats);
return stats;
}
const memFetchPostingStats = pmem(fetchPostingStats, {
maxAge: ACCOUNT_INFO_MAX_AGE,
}); });
function AccountInfo({ function AccountInfo({
@ -63,10 +126,10 @@ function AccountInfo({
const { masto } = api({ const { masto } = api({
instance, instance,
}); });
const { masto: currentMasto } = api();
const [uiState, setUIState] = useState('default'); const [uiState, setUIState] = useState('default');
const isString = typeof account === 'string'; const isString = typeof account === 'string';
const [info, setInfo] = useState(isString ? null : account); const [info, setInfo] = useState(isString ? null : account);
const snapAccountInfoStates = useSnapshot(accountInfoStates);
const isSelf = useMemo( const isSelf = useMemo(
() => account.id === store.session.get('currentAccount'), () => account.id === store.session.get('currentAccount'),
@ -121,6 +184,7 @@ function AccountInfo({
username, username,
memorial, memorial,
moved, moved,
roles,
} = info || {}; } = info || {};
let headerIsAvatar = false; let headerIsAvatar = false;
let { header, headerStatic } = info || {}; let { header, headerStatic } = info || {};
@ -134,13 +198,19 @@ function AccountInfo({
} }
} }
const accountInstance = useMemo(() => {
if (!url) return null;
const domain = new URL(url).hostname;
return domain;
}, [url]);
const [headerCornerColors, setHeaderCornerColors] = useState([]); const [headerCornerColors, setHeaderCornerColors] = useState([]);
const followersIterator = useRef(); const followersIterator = useRef();
const familiarFollowersCache = useRef([]); const familiarFollowersCache = useRef([]);
async function fetchFollowers(firstLoad) { async function fetchFollowers(firstLoad) {
if (firstLoad || !followersIterator.current) { if (firstLoad || !followersIterator.current) {
followersIterator.current = masto.v1.accounts.listFollowers(id, { followersIterator.current = masto.v1.accounts.$select(id).followers.list({
limit: LIMIT, limit: LIMIT,
}); });
} }
@ -153,9 +223,9 @@ function AccountInfo({
// On first load, fetch familiar followers, merge to top of results' `value` // On first load, fetch familiar followers, merge to top of results' `value`
// Remove dups on every fetch // Remove dups on every fetch
if (firstLoad) { if (firstLoad) {
const familiarFollowers = await masto.v1.accounts.fetchFamiliarFollowers( const familiarFollowers = await masto.v1.accounts
id, .familiarFollowers(id)
); .fetch();
familiarFollowersCache.current = familiarFollowers[0].accounts; familiarFollowersCache.current = familiarFollowers[0].accounts;
newValue = [ newValue = [
...familiarFollowersCache.current, ...familiarFollowersCache.current,
@ -184,7 +254,7 @@ function AccountInfo({
const followingIterator = useRef(); const followingIterator = useRef();
async function fetchFollowing(firstLoad) { async function fetchFollowing(firstLoad) {
if (firstLoad || !followingIterator.current) { if (firstLoad || !followingIterator.current) {
followingIterator.current = masto.v1.accounts.listFollowing(id, { followingIterator.current = masto.v1.accounts.$select(id).following.list({
limit: LIMIT, limit: LIMIT,
}); });
} }
@ -195,6 +265,51 @@ function AccountInfo({
const LinkOrDiv = standalone ? 'div' : Link; const LinkOrDiv = standalone ? 'div' : Link;
const accountLink = instance ? `/${instance}/a/${id}` : `/a/${id}`; const accountLink = instance ? `/${instance}/a/${id}` : `/a/${id}`;
const [familiarFollowers, setFamiliarFollowers] = useState([]);
const [postingStats, setPostingStats] = useState();
const [postingStatsUIState, setPostingStatsUIState] = useState('default');
const hasPostingStats = !!postingStats?.total;
const renderFamiliarFollowers = async (currentID) => {
try {
const followers = await memFetchFamiliarFollowers(
currentID,
currentMasto,
);
console.log('fetched familiar followers', followers);
setFamiliarFollowers(
followers[0].accounts.slice(0, FAMILIAR_FOLLOWERS_LIMIT),
);
} catch (e) {
console.error(e);
}
};
const renderPostingStats = async () => {
if (!id) return;
setPostingStatsUIState('loading');
try {
const stats = await memFetchPostingStats(id, masto);
setPostingStats(stats);
setPostingStatsUIState('default');
} catch (e) {
console.error(e);
setPostingStatsUIState('error');
}
};
const onRelationshipChange = useCallback(
({ relationship, currentID }) => {
if (!relationship.following) {
renderFamiliarFollowers(currentID);
if (!standalone) {
renderPostingStats();
}
}
},
[standalone, id],
);
return ( return (
<div <div
class={`account-container ${uiState === 'loading' ? 'skeleton' : ''}`} class={`account-container ${uiState === 'loading' ? 'skeleton' : ''}`}
@ -229,7 +344,7 @@ function AccountInfo({
<p> </p> <p> </p>
<p> </p> <p> </p>
</div> </div>
<p class="stats"> <div class="stats">
<div> <div>
<span></span> Followers <span></span> Followers
</div> </div>
@ -240,7 +355,7 @@ function AccountInfo({
<span></span> Posts <span></span> Posts
</div> </div>
<div>Joined </div> <div>Joined </div>
</p> </div>
</main> </main>
</> </>
) : ( ) : (
@ -381,8 +496,20 @@ function AccountInfo({
<Icon icon="group" /> Group <Icon icon="group" /> Group
</span> </span>
)} )}
{roles?.map((role) => (
<span class="tag">
{role.name}
{!!accountInstance && (
<>
{' '}
<span class="more-insignificant">{accountInstance}</span>
</>
)}
</span>
))}
<div <div
class="note" class="note"
dir="auto"
onClick={handleContentLinks({ onClick={handleContentLinks({
instance, instance,
})} })}
@ -399,6 +526,7 @@ function AccountInfo({
verifiedAt ? 'profile-verified' : '' verifiedAt ? 'profile-verified' : ''
}`} }`}
key={name + i} key={name + i}
dir="auto"
> >
<b> <b>
<EmojiText text={name} emojis={emojis} />{' '} <EmojiText text={name} emojis={emojis} />{' '}
@ -427,19 +555,17 @@ function AccountInfo({
}; };
}} }}
> >
{!!snapAccountInfoStates.familiarFollowers.length && ( {!!familiarFollowers.length && (
<span class="shazam-container-horizontal"> <span class="shazam-container-horizontal">
<span class="shazam-container-inner stats-avatars-bunch"> <span class="shazam-container-inner stats-avatars-bunch">
{(snapAccountInfoStates.familiarFollowers || []).map( {familiarFollowers.map((follower) => (
(follower) => (
<Avatar <Avatar
url={follower.avatarStatic} url={follower.avatarStatic}
size="s" size="s"
alt={`${follower.displayName} @${follower.acct}`} alt={`${follower.displayName} @${follower.acct}`}
squircle={follower?.bot} squircle={follower?.bot}
/> />
), ))}
)}
</span> </span>
</span> </span>
)} )}
@ -494,11 +620,112 @@ function AccountInfo({
)} )}
</div> </div>
</div> </div>
{!!postingStats && (
<LinkOrDiv
to={accountLink}
class="account-metadata-box"
onClick={() => {
states.showAccount = false;
}}
>
<div class="shazam-container">
<div class="shazam-container-inner">
{hasPostingStats ? (
<div
class="posting-stats"
title={`${Math.round(
(postingStats.originals / postingStats.total) * 100,
)}% original posts, ${Math.round(
(postingStats.replies / postingStats.total) * 100,
)}% replies, ${Math.round(
(postingStats.boosts / postingStats.total) * 100,
)}% boosts`}
>
<div>
{postingStats.daysSinceLastPost < 365
? `Last ${postingStats.total} posts in the past
${postingStats.daysSinceLastPost} day${
postingStats.daysSinceLastPost > 1 ? 's' : ''
}`
: `
Last ${postingStats.total} posts in the past year(s)
`}
</div>
<div
class="posting-stats-bar"
style={{
// [originals | replies | boosts]
'--originals-percentage': `${
(postingStats.originals / postingStats.total) *
100
}%`,
'--replies-percentage': `${
((postingStats.originals +
postingStats.replies) /
postingStats.total) *
100
}%`,
}}
/>
<div class="posting-stats-legends">
<span class="ib">
<span class="posting-stats-legend-item posting-stats-legend-item-originals" />{' '}
Original
</span>{' '}
<span class="ib">
<span class="posting-stats-legend-item posting-stats-legend-item-replies" />{' '}
Replies
</span>{' '}
<span class="ib">
<span class="posting-stats-legend-item posting-stats-legend-item-boosts" />{' '}
Boosts
</span>
</div>
</div>
) : (
<div class="posting-stats">Post stats unavailable.</div>
)}
</div>
</div>
</LinkOrDiv>
)}
<div class="account-metadata-box">
<div
class="shazam-container no-animation"
hidden={!!postingStats}
>
<div class="shazam-container-inner">
<button
type="button"
class="posting-stats-button"
disabled={postingStatsUIState === 'loading'}
onClick={() => {
renderPostingStats();
}}
>
<div
class={`posting-stats-bar posting-stats-icon ${
postingStatsUIState === 'loading' ? 'loading' : ''
}`}
style={{
'--originals-percentage': '33%',
'--replies-percentage': '66%',
}}
/>
View post stats{' '}
{/* <Loader
abrupt
hidden={postingStatsUIState !== 'loading'}
/> */}
</button>
</div>
</div>
</div>
<RelatedActions <RelatedActions
info={info} info={info}
instance={instance} instance={instance}
authenticated={authenticated} authenticated={authenticated}
standalone={standalone} onRelationshipChange={onRelationshipChange}
/> />
</main> </main>
</> </>
@ -510,7 +737,12 @@ function AccountInfo({
const FAMILIAR_FOLLOWERS_LIMIT = 3; const FAMILIAR_FOLLOWERS_LIMIT = 3;
function RelatedActions({ info, instance, authenticated, standalone }) { function RelatedActions({
info,
instance,
authenticated,
onRelationshipChange = () => {},
}) {
if (!info) return null; if (!info) return null;
const { const {
masto: currentMasto, masto: currentMasto,
@ -521,7 +753,6 @@ function RelatedActions({ info, instance, authenticated, standalone }) {
const [relationshipUIState, setRelationshipUIState] = useState('default'); const [relationshipUIState, setRelationshipUIState] = useState('default');
const [relationship, setRelationship] = useState(null); const [relationship, setRelationship] = useState(null);
const [postingStats, setPostingStats] = useState();
const { id, acct, url, username, locked, lastStatusAt, note, fields, moved } = const { id, acct, url, username, locked, lastStatusAt, note, fields, moved } =
info; info;
@ -555,7 +786,7 @@ function RelatedActions({ info, instance, authenticated, standalone }) {
// Grab this account from my logged-in instance // Grab this account from my logged-in instance
const acctHasInstance = info.acct.includes('@'); const acctHasInstance = info.acct.includes('@');
try { try {
const results = await currentMasto.v2.search({ const results = await currentMasto.v2.search.fetch({
q: acctHasInstance ? info.acct : `${info.username}@${instance}`, q: acctHasInstance ? info.acct : `${info.username}@${instance}`,
type: 'accounts', type: 'accounts',
limit: 1, limit: 1,
@ -584,12 +815,12 @@ function RelatedActions({ info, instance, authenticated, standalone }) {
if (moved) return; if (moved) return;
setRelationshipUIState('loading'); setRelationshipUIState('loading');
accountInfoStates.familiarFollowers = [];
setPostingStats(null);
const fetchRelationships = currentMasto.v1.accounts.fetchRelationships([ const fetchRelationships = currentMasto.v1.accounts.relationships.fetch(
currentID, {
]); id: [currentID],
},
);
try { try {
const relationships = await fetchRelationships; const relationships = await fetchRelationships;
@ -599,63 +830,7 @@ function RelatedActions({ info, instance, authenticated, standalone }) {
if (relationships.length) { if (relationships.length) {
const relationship = relationships[0]; const relationship = relationships[0];
setRelationship(relationship); setRelationship(relationship);
onRelationshipChange({ relationship, currentID });
if (!relationship.following) {
try {
const fetchFamiliarFollowers =
currentMasto.v1.accounts.fetchFamiliarFollowers(currentID);
const fetchStatuses = currentMasto.v1.accounts
.listStatuses(currentID, {
limit: 20,
})
.next();
const followers = await fetchFamiliarFollowers;
console.log('fetched familiar followers', followers);
accountInfoStates.familiarFollowers =
followers[0].accounts.slice(0, FAMILIAR_FOLLOWERS_LIMIT);
if (!standalone) {
const { value: statuses } = await fetchStatuses;
console.log('fetched statuses', statuses);
const stats = {
total: statuses.length,
originals: 0,
replies: 0,
boosts: 0,
};
// Categories statuses by type
// - Original posts (not replies to others)
// - Threads (self-replies + 1st original post)
// - Boosts (reblogs)
// - Replies (not-self replies)
statuses.forEach((status) => {
if (status.reblog) {
stats.boosts++;
} else if (
status.inReplyToAccountId !== currentID &&
!!status.inReplyToId
) {
stats.replies++;
} else {
stats.originals++;
}
});
// Count days since last post
stats.daysSinceLastPost = Math.ceil(
(Date.now() -
new Date(statuses[statuses.length - 1].createdAt)) /
86400000,
);
console.log('posting stats', stats);
setPostingStats(stats);
}
} catch (e) {
console.error(e);
}
}
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@ -677,75 +852,9 @@ function RelatedActions({ info, instance, authenticated, standalone }) {
const [showTranslatedBio, setShowTranslatedBio] = useState(false); const [showTranslatedBio, setShowTranslatedBio] = useState(false);
const [showAddRemoveLists, setShowAddRemoveLists] = useState(false); const [showAddRemoveLists, setShowAddRemoveLists] = useState(false);
const hasPostingStats = postingStats?.total >= 3;
const accountLink = instance ? `/${instance}/a/${id}` : `/a/${id}`;
return ( return (
<> <>
{hasPostingStats && ( <div class="actions">
<Link
to={accountLink}
class="account-metadata-box"
onClick={() => {
states.showAccount = false;
}}
>
<div class="shazam-container">
<div class="shazam-container-inner">
<div
class="posting-stats"
title={`${Math.round(
(postingStats.originals / postingStats.total) * 100,
)}% original posts, ${Math.round(
(postingStats.replies / postingStats.total) * 100,
)}% replies, ${Math.round(
(postingStats.boosts / postingStats.total) * 100,
)}% boosts`}
>
<div>
{postingStats.daysSinceLastPost < 365
? `Last ${postingStats.total} posts in the past
${postingStats.daysSinceLastPost} day${
postingStats.daysSinceLastPost > 1 ? 's' : ''
}`
: `
Last ${postingStats.total} posts in the past year(s)
`}
</div>
<div
class="posting-stats-bar"
style={{
// [originals | replies | boosts]
'--originals-percentage': `${
(postingStats.originals / postingStats.total) * 100
}%`,
'--replies-percentage': `${
((postingStats.originals + postingStats.replies) /
postingStats.total) *
100
}%`,
}}
/>
<div class="posting-stats-legends">
<span class="ib">
<span class="posting-stats-legend-item posting-stats-legend-item-originals" />{' '}
Original
</span>{' '}
<span class="ib">
<span class="posting-stats-legend-item posting-stats-legend-item-replies" />{' '}
Replies
</span>{' '}
<span class="ib">
<span class="posting-stats-legend-item posting-stats-legend-item-boosts" />{' '}
Boosts
</span>
</div>
</div>
</div>
</div>
</Link>
)}
<p class="actions">
<span> <span>
{followedBy ? ( {followedBy ? (
<span class="tag">Following you</span> <span class="tag">Following you</span>
@ -880,14 +989,15 @@ function RelatedActions({ info, instance, authenticated, standalone }) {
setRelationshipUIState('loading'); setRelationshipUIState('loading');
(async () => { (async () => {
try { try {
const newRelationship = const newRelationship = await currentMasto.v1.accounts
await currentMasto.v1.accounts.unmute( .$select(currentInfo?.id || id)
currentInfo?.id || id, .unmute();
);
console.log('unmuting', newRelationship); console.log('unmuting', newRelationship);
setRelationship(newRelationship); setRelationship(newRelationship);
setRelationshipUIState('default'); setRelationshipUIState('default');
showToast(`Unmuted @${username}`); showToast(`Unmuted @${username}`);
states.reloadGenericAccounts.id = 'mute';
states.reloadGenericAccounts.counter++;
} catch (e) { } catch (e) {
console.error(e); console.error(e);
setRelationshipUIState('error'); setRelationshipUIState('error');
@ -927,18 +1037,19 @@ function RelatedActions({ info, instance, authenticated, standalone }) {
(async () => { (async () => {
try { try {
const newRelationship = const newRelationship =
await currentMasto.v1.accounts.mute( await currentMasto.v1.accounts
currentInfo?.id || id, .$select(currentInfo?.id || id)
{ .mute({
duration, duration,
}, });
);
console.log('muting', newRelationship); console.log('muting', newRelationship);
setRelationship(newRelationship); setRelationship(newRelationship);
setRelationshipUIState('default'); setRelationshipUIState('default');
showToast( showToast(
`Muted @${username} for ${MUTE_DURATIONS_LABELS[duration]}`, `Muted @${username} for ${MUTE_DURATIONS_LABELS[duration]}`,
); );
states.reloadGenericAccounts.id = 'mute';
states.reloadGenericAccounts.counter++;
} catch (e) { } catch (e) {
console.error(e); console.error(e);
setRelationshipUIState('error'); setRelationshipUIState('error');
@ -971,24 +1082,24 @@ function RelatedActions({ info, instance, authenticated, standalone }) {
(async () => { (async () => {
try { try {
if (blocking) { if (blocking) {
const newRelationship = const newRelationship = await currentMasto.v1.accounts
await currentMasto.v1.accounts.unblock( .$select(currentInfo?.id || id)
currentInfo?.id || id, .unblock();
);
console.log('unblocking', newRelationship); console.log('unblocking', newRelationship);
setRelationship(newRelationship); setRelationship(newRelationship);
setRelationshipUIState('default'); setRelationshipUIState('default');
showToast(`Unblocked @${username}`); showToast(`Unblocked @${username}`);
} else { } else {
const newRelationship = const newRelationship = await currentMasto.v1.accounts
await currentMasto.v1.accounts.block( .$select(currentInfo?.id || id)
currentInfo?.id || id, .block();
);
console.log('blocking', newRelationship); console.log('blocking', newRelationship);
setRelationship(newRelationship); setRelationship(newRelationship);
setRelationshipUIState('default'); setRelationshipUIState('default');
showToast(`Blocked @${username}`); showToast(`Blocked @${username}`);
} }
states.reloadGenericAccounts.id = 'block';
states.reloadGenericAccounts.counter++;
} catch (e) { } catch (e) {
console.error(e); console.error(e);
setRelationshipUIState('error'); setRelationshipUIState('error');
@ -1050,14 +1161,14 @@ function RelatedActions({ info, instance, authenticated, standalone }) {
// ); // );
// if (yes) { // if (yes) {
newRelationship = await currentMasto.v1.accounts.unfollow( newRelationship = await currentMasto.v1.accounts
accountID.current, .$select(accountID.current)
); .unfollow();
// } // }
} else { } else {
newRelationship = await currentMasto.v1.accounts.follow( newRelationship = await currentMasto.v1.accounts
accountID.current, .$select(accountID.current)
); .follow();
} }
if (newRelationship) setRelationship(newRelationship); if (newRelationship) setRelationship(newRelationship);
@ -1096,7 +1207,7 @@ function RelatedActions({ info, instance, authenticated, standalone }) {
</MenuConfirm> </MenuConfirm>
)} )}
</span> </span>
</p> </div>
{!!showTranslatedBio && ( {!!showTranslatedBio && (
<Modal <Modal
class="light" class="light"
@ -1202,9 +1313,9 @@ function AddRemoveListsSheet({ accountID, onClose }) {
(async () => { (async () => {
try { try {
const lists = await masto.v1.lists.list(); const lists = await masto.v1.lists.list();
const listsContainingAccount = await masto.v1.accounts.listLists( const listsContainingAccount = await masto.v1.accounts
accountID, .$select(accountID)
); .lists.list();
console.log({ lists, listsContainingAccount }); console.log({ lists, listsContainingAccount });
setLists(lists); setLists(lists);
setListsContainingAccount(listsContainingAccount); setListsContainingAccount(listsContainingAccount);
@ -1246,11 +1357,15 @@ function AddRemoveListsSheet({ accountID, onClose }) {
(async () => { (async () => {
try { try {
if (inList) { if (inList) {
await masto.v1.lists.removeAccount(list.id, { await masto.v1.lists
.$select(list.id)
.accounts.remove({
accountIds: [accountID], accountIds: [accountID],
}); });
} else { } else {
await masto.v1.lists.addAccount(list.id, { await masto.v1.lists
.$select(list.id)
.accounts.create({
accountIds: [accountID], accountIds: [accountID],
}); });
} }

View file

@ -46,7 +46,7 @@ function AccountSheet({ account, instance: propInstance, onClose }) {
}); });
return info; return info;
} catch (e) { } catch (e) {
const result = await masto.v2.search({ const result = await masto.v2.search.fetch({
q: account, q: account,
type: 'accounts', type: 'accounts',
limit: 1, limit: 1,
@ -57,7 +57,7 @@ function AccountSheet({ account, instance: propInstance, onClose }) {
} else if (/https?:\/\/[^/]+\/@/.test(account)) { } else if (/https?:\/\/[^/]+\/@/.test(account)) {
const accountURL = new URL(account); const accountURL = new URL(account);
const acct = accountURL.pathname.replace(/^\//, ''); const acct = accountURL.pathname.replace(/^\//, '');
const result = await masto.v2.search({ const result = await masto.v2.search.fetch({
q: acct, q: acct,
type: 'accounts', type: 'accounts',
limit: 1, limit: 1,

View file

@ -11,10 +11,10 @@ export default memo(function BackgroundService({ isLoggedIn }) {
// - WebSocket to receive notifications when page is visible // - WebSocket to receive notifications when page is visible
const [visible, setVisible] = useState(true); const [visible, setVisible] = useState(true);
usePageVisibility(setVisible); usePageVisibility(setVisible);
const notificationStream = useRef();
useEffect(() => { useEffect(() => {
let sub;
if (isLoggedIn && visible) { if (isLoggedIn && visible) {
const { masto, instance } = api(); const { masto, streaming, instance } = api();
(async () => { (async () => {
// 1. Get the latest notification // 1. Get the latest notification
if (states.notificationsLast) { if (states.notificationsLast) {
@ -42,34 +42,26 @@ export default memo(function BackgroundService({ isLoggedIn }) {
} }
// 2. Start streaming // 2. Start streaming
notificationStream.current = await masto.ws.stream( if (streaming) {
'/api/v1/streaming', sub = streaming.user.notification.subscribe();
{ console.log('🎏 Streaming notification', sub);
stream: 'user:notification', for await (const entry of sub) {
}, if (!sub) break;
); console.log('🔔🔔 Notification entry', entry);
console.log('🎏 Streaming notification', notificationStream.current); if (entry.event === 'notification') {
console.log('🔔🔔 Notification', entry);
notificationStream.current.on('notification', (notification) => { saveStatus(entry.payload, instance, {
console.log('🔔🔔 Notification', notification);
if (notification.status) {
saveStatus(notification.status, instance, {
skipThreading: true, skipThreading: true,
}); });
} }
states.notificationsShowNew = true; states.notificationsShowNew = true;
}); }
}
notificationStream.current.ws.onclose = () => {
console.log('🔔🔔 Notification stream closed');
};
})(); })();
} }
return () => { return () => {
if (notificationStream.current) { sub?.unsubscribe?.();
notificationStream.current.ws.close(); sub = null;
notificationStream.current = null;
}
}; };
}, [visible, isLoggedIn]); }, [visible, isLoggedIn]);

View file

@ -487,7 +487,28 @@
padding-inline: 24px; padding-inline: 24px;
} }
} }
#media-sheet {
.media-form {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
min-height: 50vh;
textarea {
flex-grow: 1;
resize: none;
width: 100%;
/* height: 10em; */
}
footer {
display: flex;
justify-content: space-between;
align-items: center;
}
}
}
#media-sheet main { #media-sheet main {
padding-top: 8px; padding-top: 8px;
display: flex; display: flex;
@ -495,10 +516,6 @@
flex: 1; flex: 1;
gap: 8px; gap: 8px;
} }
#media-sheet textarea {
width: 100%;
height: 10em;
}
#media-sheet .media-preview { #media-sheet .media-preview {
border: 2px solid var(--outline-color); border: 2px solid var(--outline-color);
border-radius: 8px; border-radius: 8px;
@ -515,6 +532,7 @@
linear-gradient(-45deg, transparent 75%, var(--img-bg-color) 75%); linear-gradient(-45deg, transparent 75%, var(--img-bg-color) 75%);
background-size: 20px 20px; background-size: 20px 20px;
background-position: 0 0, 0 10px, 10px -10px, -10px 0px; background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
flex: 0.8;
} }
#media-sheet .media-preview > * { #media-sheet .media-preview > * {
width: 100%; width: 100%;
@ -534,11 +552,11 @@
#media-sheet .media-preview > * { #media-sheet .media-preview > * {
max-height: none; max-height: none;
} }
#media-sheet textarea { /* #media-sheet textarea {
flex: 1; flex: 1;
min-height: 100%; min-height: 100%;
height: auto; height: auto;
} } */
} }
#custom-emojis-sheet { #custom-emojis-sheet {

View file

@ -185,7 +185,7 @@ function Compose({
: visibility, : visibility,
); );
setLanguage(language || prefs.postingDefaultLanguage || DEFAULT_LANG); setLanguage(language || prefs.postingDefaultLanguage || DEFAULT_LANG);
setSensitive(sensitive); setSensitive(sensitive && !!spoilerText);
} else if (editStatus) { } else if (editStatus) {
const { visibility, language, sensitive, poll, mediaAttachments } = const { visibility, language, sensitive, poll, mediaAttachments } =
editStatus; editStatus;
@ -197,9 +197,9 @@ function Compose({
setUIState('loading'); setUIState('loading');
(async () => { (async () => {
try { try {
const statusSource = await masto.v1.statuses.fetchSource( const statusSource = await masto.v1.statuses
editStatus.id, .$select(editStatus.id)
); .source.fetch();
console.log({ statusSource }); console.log({ statusSource });
const { text, spoilerText } = statusSource; const { text, spoilerText } = statusSource;
textareaRef.current.value = text; textareaRef.current.value = text;
@ -749,9 +749,7 @@ function Compose({
file, file,
description, description,
}); });
return masto.v2.mediaAttachments return masto.v2.media.create(params).then((res) => {
.create(params)
.then((res) => {
if (res.id) { if (res.id) {
attachment.id = res.id; attachment.id = res.id;
} }
@ -784,6 +782,8 @@ function Compose({
/* NOTE: /* NOTE:
Using snakecase here because masto.js's `isObject` returns false for `params`, ONLY happens when opening in pop-out window. This is maybe due to `window.masto` variable being passed from the parent window. The check that failed is `x.constructor === Object`, so maybe the `Object` in new window is different than parent window's? Using snakecase here because masto.js's `isObject` returns false for `params`, ONLY happens when opening in pop-out window. This is maybe due to `window.masto` variable being passed from the parent window. The check that failed is `x.constructor === Object`, so maybe the `Object` in new window is different than parent window's?
Code: https://github.com/neet/masto.js/blob/dd0d649067b6a2b6e60fbb0a96597c373a255b00/src/serializers/is-object.ts#L2 Code: https://github.com/neet/masto.js/blob/dd0d649067b6a2b6e60fbb0a96597c373a255b00/src/serializers/is-object.ts#L2
// TODO: Note above is no longer true in Masto.js v6. Revisit this.
*/ */
let params = { let params = {
status, status,
@ -818,10 +818,9 @@ function Compose({
let newStatus; let newStatus;
if (editStatus) { if (editStatus) {
newStatus = await masto.v1.statuses.update( newStatus = await masto.v1.statuses
editStatus.id, .$select(editStatus.id)
params, .update(params);
);
saveStatus(newStatus, instance, { saveStatus(newStatus, instance, {
skipThreading: true, skipThreading: true,
}); });
@ -839,6 +838,8 @@ function Compose({
// Close // Close
onClose({ onClose({
// type: post, reply, edit
type: editStatus ? 'edit' : replyToStatus ? 'reply' : 'post',
newStatus, newStatus,
instance, instance,
}); });
@ -933,13 +934,13 @@ function Compose({
performSearch={(params) => { performSearch={(params) => {
const { type, q, limit } = params; const { type, q, limit } = params;
if (type === 'accounts') { if (type === 'accounts') {
return masto.v1.accounts.search({ return masto.v1.accounts.search.list({
q, q,
limit, limit,
resolve: false, resolve: false,
}); });
} }
return masto.v2.search(params); return masto.v2.search.fetch(params);
}} }}
/> />
{mediaAttachments?.length > 0 && ( {mediaAttachments?.length > 0 && (
@ -1475,7 +1476,11 @@ function MediaAttachment({
onRemove = () => {}, onRemove = () => {},
}) { }) {
const supportsEdit = supports('@mastodon/edit-media-attributes'); const supportsEdit = supports('@mastodon/edit-media-attributes');
const { url, type, id } = attachment; const { type, id, file } = attachment;
const url = useMemo(
() => (file ? URL.createObjectURL(file) : attachment.url),
[file, attachment.url],
);
console.log({ attachment }); console.log({ attachment });
const [description, setDescription] = useState(attachment.description); const [description, setDescription] = useState(attachment.description);
const suffixType = type.split('/')[0]; const suffixType = type.split('/')[0];
@ -1542,6 +1547,7 @@ function MediaAttachment({
<div class="media-attachment"> <div class="media-attachment">
<div <div
class="media-preview" class="media-preview"
tabIndex="0"
onClick={() => { onClick={() => {
setShowModal(true); setShowModal(true);
}} }}
@ -1568,6 +1574,7 @@ function MediaAttachment({
</div> </div>
{showModal && ( {showModal && (
<Modal <Modal
class="light"
onClick={(e) => { onClick={(e) => {
if (e.target === e.currentTarget) { if (e.target === e.currentTarget) {
setShowModal(false); setShowModal(false);
@ -1605,7 +1612,20 @@ function MediaAttachment({
<audio src={url} controls /> <audio src={url} controls />
) : null} ) : null}
</div> </div>
<div class="media-form">
{descTextarea} {descTextarea}
<footer>
<button
type="button"
class="light block"
onClick={() => {
setShowModal(false);
}}
>
Done
</button>
</footer>
</div>
</main> </main>
</div> </div>
</Modal> </Modal>

View file

@ -128,9 +128,9 @@ function Drafts({ onClose }) {
if (replyTo) { if (replyTo) {
setUIState('loading'); setUIState('loading');
try { try {
replyToStatus = await masto.v1.statuses.fetch( replyToStatus = await masto.v1.statuses
replyTo.id, .$select(replyTo.id)
); .fetch();
} catch (e) { } catch (e) {
console.error(e); console.error(e);
alert('Error fetching reply-to status!'); alert('Error fetching reply-to status!');

View file

@ -17,13 +17,15 @@ function FollowRequestButtons({ accountID, onChange }) {
<p class="follow-request-buttons"> <p class="follow-request-buttons">
<button <button
type="button" type="button"
disabled={uiState === 'loading'} disabled={uiState === 'loading' || hasRelationship}
onClick={() => { onClick={() => {
setUIState('loading'); setUIState('loading');
setRequestState('accept'); setRequestState('accept');
(async () => { (async () => {
try { try {
const rel = await masto.v1.followRequests.authorize(accountID); const rel = await masto.v1.followRequests
.$select(accountID)
.authorize();
if (!rel?.followedBy) { if (!rel?.followedBy) {
throw new Error('Follow request not accepted'); throw new Error('Follow request not accepted');
} }
@ -40,14 +42,16 @@ function FollowRequestButtons({ accountID, onChange }) {
</button>{' '} </button>{' '}
<button <button
type="button" type="button"
disabled={uiState === 'loading'} disabled={uiState === 'loading' || hasRelationship}
class="light danger" class="light danger"
onClick={() => { onClick={() => {
setUIState('loading'); setUIState('loading');
setRequestState('reject'); setRequestState('reject');
(async () => { (async () => {
try { try {
const rel = await masto.v1.followRequests.reject(accountID); const rel = await masto.v1.followRequests
.$select(accountID)
.reject();
if (rel?.followedBy) { if (rel?.followedBy) {
throw new Error('Follow request not rejected'); throw new Error('Follow request not rejected');
} }

View file

@ -21,6 +21,7 @@ export default function GenericAccounts({ onClose = () => {} }) {
} }
const { const {
id,
heading, heading,
fetchAccounts, fetchAccounts,
accounts: staticAccounts, accounts: staticAccounts,
@ -60,6 +61,14 @@ export default function GenericAccounts({ onClose = () => {} }) {
} }
}, [staticAccounts, fetchAccounts]); }, [staticAccounts, fetchAccounts]);
useEffect(() => {
// reloadGenericAccounts contains value like {id: 'mute', counter: 1}
// We only need to reload if the id matches
if (snapStates.reloadGenericAccounts?.id === id) {
loadAccounts(true);
}
}, [snapStates.reloadGenericAccounts.counter]);
return ( return (
<div id="generic-accounts-container" class="sheet" tabindex="-1"> <div id="generic-accounts-container" class="sheet" tabindex="-1">
<button type="button" class="sheet-close" onClick={onClose}> <button type="button" class="sheet-close" onClick={onClose}>

View file

@ -100,6 +100,7 @@ export const ICONS = {
'account-edit': () => import('@iconify-icons/mingcute/user-edit-line'), 'account-edit': () => import('@iconify-icons/mingcute/user-edit-line'),
'account-warning': () => import('@iconify-icons/mingcute/user-warning-line'), 'account-warning': () => import('@iconify-icons/mingcute/user-warning-line'),
keyboard: () => import('@iconify-icons/mingcute/keyboard-line'), keyboard: () => import('@iconify-icons/mingcute/keyboard-line'),
cloud: () => import('@iconify-icons/mingcute/cloud-line'),
}; };
function Icon({ function Icon({
@ -126,7 +127,7 @@ function Icon({
}, [iconBlock]); }, [iconBlock]);
return ( return (
<div <span
class={`icon ${className}`} class={`icon ${className}`}
title={title || alt} title={title || alt}
style={{ style={{
@ -151,7 +152,7 @@ function Icon({
}} }}
/> />
)} )}
</div> </span>
); );
} }

View file

@ -56,7 +56,7 @@ function ListAddEdit({ list, onClose }) {
let listResult; let listResult;
if (editMode) { if (editMode) {
listResult = await masto.v1.lists.update(list.id, { listResult = await masto.v1.lists.$select(list.id).update({
title, title,
replies_policy: repliesPolicy, replies_policy: repliesPolicy,
exclusive, exclusive,
@ -141,7 +141,7 @@ function ListAddEdit({ list, onClose }) {
(async () => { (async () => {
try { try {
await masto.v1.lists.remove(list.id); await masto.v1.lists.$select(list.id).remove();
setUIState('default'); setUIState('default');
onClose?.({ onClose?.({
state: 'deleted', state: 'deleted',

View file

@ -0,0 +1,74 @@
import { Menu, MenuItem } from '@szhsin/react-menu';
import { useState } from 'preact/hooks';
import { useSnapshot } from 'valtio';
import getTranslateTargetLanguage from '../utils/get-translate-target-language';
import localeMatch from '../utils/locale-match';
import states from '../utils/states';
import Icon from './icon';
import TranslationBlock from './translation-block';
export default function MediaAltModal({ alt, lang, onClose }) {
const snapStates = useSnapshot(states);
const [forceTranslate, setForceTranslate] = useState(false);
const targetLanguage = getTranslateTargetLanguage(true);
const contentTranslationHideLanguages =
snapStates.settings.contentTranslationHideLanguages || [];
const differentLanguage =
!!lang &&
lang !== targetLanguage &&
!localeMatch([lang], [targetLanguage]) &&
!contentTranslationHideLanguages.find(
(l) => lang === l || localeMatch([lang], [l]),
);
return (
<div class="sheet">
{!!onClose && (
<button type="button" class="sheet-close outer" onClick={onClose}>
<Icon icon="x" />
</button>
)}
<header class="header-grid">
<h2>Media description</h2>
<div class="header-side">
<Menu
align="end"
menuButton={
<button type="button" class="plain4">
<Icon icon="more" alt="More" size="xl" />
</button>
}
>
<MenuItem
disabled={forceTranslate}
onClick={() => {
setForceTranslate(true);
}}
>
<Icon icon="translate" />
<span>Translate</span>
</MenuItem>
</Menu>
</div>
</header>
<main lang={lang} dir="auto">
<p
style={{
whiteSpace: 'pre-wrap',
}}
>
{alt}
</p>
{(differentLanguage || forceTranslate) && (
<TranslationBlock
forceTranslate={forceTranslate}
sourceLanguage={lang}
text={alt}
/>
)}
</main>
</div>
);
}

View file

@ -1,4 +1,4 @@
import { Menu, MenuItem } from '@szhsin/react-menu'; import { Menu } from '@szhsin/react-menu';
import { getBlurHashAverageColor } from 'fast-blurhash'; import { getBlurHashAverageColor } from 'fast-blurhash';
import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
@ -6,14 +6,15 @@ import { useHotkeys } from 'react-hotkeys-hook';
import Icon from './icon'; import Icon from './icon';
import Link from './link'; import Link from './link';
import Media from './media'; import Media from './media';
import MediaAltModal from './media-alt-modal';
import MenuLink from './menu-link'; import MenuLink from './menu-link';
import Modal from './modal'; import Modal from './modal';
import TranslationBlock from './translation-block';
function MediaModal({ function MediaModal({
mediaAttachments, mediaAttachments,
statusID, statusID,
instance, instance,
lang,
index = 0, index = 0,
onClose = () => {}, onClose = () => {},
}) { }) {
@ -138,14 +139,19 @@ function MediaModal({
class="media-alt" class="media-alt"
hidden={!showControls} hidden={!showControls}
onClick={() => { onClick={() => {
setShowMediaAlt(media.description); setShowMediaAlt({
alt: media.description,
lang,
});
}} }}
> >
<Icon icon="info" /> <span class="alt-badge">ALT</span>
<span class="media-alt-desc">{media.description}</span> <span class="media-alt-desc" lang={lang} dir="auto">
{media.description}
</span>
</button> </button>
)} )}
<Media media={media} showOriginal /> <Media media={media} showOriginal lang={lang} />
</div> </div>
); );
})} })}
@ -279,7 +285,8 @@ function MediaModal({
}} }}
> >
<MediaAltModal <MediaAltModal
alt={showMediaAlt} alt={showMediaAlt.alt || showMediaAlt}
lang={showMediaAlt?.lang}
onClose={() => setShowMediaAlt(false)} onClose={() => setShowMediaAlt(false)}
/> />
</Modal> </Modal>
@ -288,52 +295,4 @@ function MediaModal({
); );
} }
function MediaAltModal({ alt, onClose }) {
const [forceTranslate, setForceTranslate] = useState(false);
return (
<div class="sheet">
{!!onClose && (
<button type="button" class="sheet-close outer" onClick={onClose}>
<Icon icon="x" />
</button>
)}
<header class="header-grid">
<h2>Media description</h2>
<div class="header-side">
<Menu
align="end"
menuButton={
<button type="button" class="plain4">
<Icon icon="more" alt="More" size="xl" />
</button>
}
>
<MenuItem
disabled={forceTranslate}
onClick={() => {
setForceTranslate(true);
}}
>
<Icon icon="translate" />
<span>Translate</span>
</MenuItem>
</Menu>
</div>
</header>
<main>
<p
style={{
whiteSpace: 'pre-wrap',
}}
>
{alt}
</p>
{forceTranslate && (
<TranslationBlock forceTranslate={forceTranslate} text={alt} />
)}
</main>
</div>
);
}
export default MediaModal; export default MediaModal;

View file

@ -9,6 +9,9 @@ import {
} from 'preact/hooks'; } from 'preact/hooks';
import QuickPinchZoom, { make3dTransformValue } from 'react-quick-pinch-zoom'; import QuickPinchZoom, { make3dTransformValue } from 'react-quick-pinch-zoom';
import mem from '../utils/mem';
import states from '../utils/states';
import Icon from './icon'; import Icon from './icon';
import Link from './link'; import Link from './link';
import { formatDuration } from './status'; import { formatDuration } from './status';
@ -25,7 +28,49 @@ video = Video clip
audio = Audio track audio = Audio track
*/ */
function Media({ media, to, showOriginal, autoAnimate, onClick = () => {} }) { const dataAltLabel = 'ALT';
const AltBadge = (props) => {
const { alt, lang, index, ...rest } = props;
if (!alt || !alt.trim()) return null;
return (
<button
type="button"
class="alt-badge clickable"
{...rest}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
states.showMediaAlt = {
alt,
lang,
};
}}
title="Media description"
>
{dataAltLabel}
{!!index && <sup>{index}</sup>}
</button>
);
};
const MEDIA_CAPTION_LIMIT = 140;
export const isMediaCaptionLong = mem((caption) =>
caption?.length
? caption.length > MEDIA_CAPTION_LIMIT ||
/[\n\r].*[\n\r]/.test(caption.trim())
: false,
);
function Media({
media,
to,
lang,
showOriginal,
autoAnimate,
showCaption,
altIndex,
onClick = () => {},
}) {
const { const {
blurhash, blurhash,
description, description,
@ -134,6 +179,35 @@ function Media({ media, to, showOriginal, autoAnimate, onClick = () => {} }) {
aspectRatio: `${width} / ${height}`, aspectRatio: `${width} / ${height}`,
}; };
const longDesc = isMediaCaptionLong(description);
const showInlineDesc =
!!showCaption && !showOriginal && !!description && !longDesc;
const Figure = !showInlineDesc
? Fragment
: (props) => {
const { children, ...restProps } = props;
return (
<figure {...restProps}>
{children}
<figcaption
class="media-caption"
lang={lang}
dir="auto"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
states.showMediaAlt = {
alt: description,
lang,
};
}}
>
{description}
</figcaption>
</figure>
);
};
if (isImage) { if (isImage) {
// Note: type: unknown might not have width/height // Note: type: unknown might not have width/height
quickPinchZoomProps.containerProps.style.display = 'inherit'; quickPinchZoomProps.containerProps.style.display = 'inherit';
@ -152,11 +226,13 @@ function Media({ media, to, showOriginal, autoAnimate, onClick = () => {} }) {
}, [mediaURL]); }, [mediaURL]);
return ( return (
<Figure>
<Parent <Parent
ref={parentRef} ref={parentRef}
class={`media media-image`} class={`media media-image`}
onClick={onClick} onClick={onClick}
data-orientation={orientation} data-orientation={orientation}
data-has-alt={!showInlineDesc}
style={ style={
showOriginal showOriginal
? { ? {
@ -193,9 +269,10 @@ function Media({ media, to, showOriginal, autoAnimate, onClick = () => {} }) {
/> />
</QuickPinchZoom> </QuickPinchZoom>
) : ( ) : (
<>
<img <img
src={mediaURL} src={mediaURL}
alt={description} alt={showInlineDesc ? '' : description}
width={width} width={width}
height={height} height={height}
data-orientation={orientation} data-orientation={orientation}
@ -223,8 +300,13 @@ function Media({ media, to, showOriginal, autoAnimate, onClick = () => {} }) {
} }
}} }}
/> />
{!showInlineDesc && (
<AltBadge alt={description} lang={lang} index={altIndex} />
)}
</>
)} )}
</Parent> </Parent>
</Figure>
); );
} else if (type === 'gifv' || type === 'video' || isVideoMaybe) { } else if (type === 'gifv' || type === 'video' || isVideoMaybe) {
const shortDuration = original.duration < 31; const shortDuration = original.duration < 31;
@ -252,11 +334,8 @@ function Media({ media, to, showOriginal, autoAnimate, onClick = () => {} }) {
></video> ></video>
`; `;
const showInlineDesc = !showOriginal && !isGIF && !!description;
const Container = showInlineDesc ? 'figure' : Fragment;
return ( return (
<Container> <Figure>
<Parent <Parent
class={`media media-${isGIF ? 'gif' : 'video'} ${ class={`media media-${isGIF ? 'gif' : 'video'} ${
autoGIFAnimate ? 'media-contain' : '' autoGIFAnimate ? 'media-contain' : ''
@ -264,6 +343,7 @@ function Media({ media, to, showOriginal, autoAnimate, onClick = () => {} }) {
data-orientation={orientation} data-orientation={orientation}
data-formatted-duration={formattedDuration} data-formatted-duration={formattedDuration}
data-label={isGIF && !showOriginal && !autoGIFAnimate ? 'GIF' : ''} data-label={isGIF && !showOriginal && !autoGIFAnimate ? 'GIF' : ''}
data-has-alt={!showInlineDesc}
// style={{ // style={{
// backgroundColor: // backgroundColor:
// rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`, // rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
@ -291,6 +371,20 @@ function Media({ media, to, showOriginal, autoAnimate, onClick = () => {} }) {
} catch (e) {} } catch (e) {}
} }
}} }}
onFocus={() => {
if (hoverAnimate) {
try {
videoRef.current.play();
} catch (e) {}
}
}}
onBlur={() => {
if (hoverAnimate) {
try {
videoRef.current.pause();
} catch (e) {}
}
}}
> >
{showOriginal || autoGIFAnimate ? ( {showOriginal || autoGIFAnimate ? (
isGIF && showOriginal ? ( isGIF && showOriginal ? (
@ -339,24 +433,20 @@ function Media({ media, to, showOriginal, autoAnimate, onClick = () => {} }) {
</div> </div>
</> </>
)} )}
</Parent> {!showOriginal && !showInlineDesc && (
{showInlineDesc && ( <AltBadge alt={description} lang={lang} index={altIndex} />
<figcaption
onClick={() => {
location.hash = to;
}}
>
{description}
</figcaption>
)} )}
</Container> </Parent>
</Figure>
); );
} else if (type === 'audio') { } else if (type === 'audio') {
const formattedDuration = formatDuration(original.duration); const formattedDuration = formatDuration(original.duration);
return ( return (
<Figure>
<Parent <Parent
class="media media-audio" class="media media-audio"
data-formatted-duration={formattedDuration} data-formatted-duration={formattedDuration}
data-has-alt={!showInlineDesc}
onClick={onClick} onClick={onClick}
style={!showOriginal && mediaStyles} style={!showOriginal && mediaStyles}
> >
@ -365,7 +455,7 @@ function Media({ media, to, showOriginal, autoAnimate, onClick = () => {} }) {
) : previewUrl ? ( ) : previewUrl ? (
<img <img
src={previewUrl} src={previewUrl}
alt={description} alt={showInlineDesc ? '' : description}
width={width} width={width}
height={height} height={height}
data-orientation={orientation} data-orientation={orientation}
@ -373,11 +463,17 @@ function Media({ media, to, showOriginal, autoAnimate, onClick = () => {} }) {
/> />
) : null} ) : null}
{!showOriginal && ( {!showOriginal && (
<>
<div class="media-play"> <div class="media-play">
<Icon icon="play" size="xl" /> <Icon icon="play" size="xl" />
</div> </div>
{!showInlineDesc && (
<AltBadge alt={description} lang={lang} index={altIndex} />
)}
</>
)} )}
</Parent> </Parent>
</Figure>
); );
} }
} }

View file

@ -11,6 +11,7 @@ import AccountSheet from './account-sheet';
import Compose from './compose'; import Compose from './compose';
import Drafts from './drafts'; import Drafts from './drafts';
import GenericAccounts from './generic-accounts'; import GenericAccounts from './generic-accounts';
import MediaAltModal from './media-alt-modal';
import MediaModal from './media-modal'; import MediaModal from './media-modal';
import Modal from './modal'; import Modal from './modal';
import ShortcutsSettings from './shortcuts-settings'; import ShortcutsSettings from './shortcuts-settings';
@ -50,13 +51,17 @@ export default function Modals() {
null null
} }
onClose={(results) => { onClose={(results) => {
const { newStatus, instance } = results || {}; const { newStatus, instance, type } = results || {};
states.showCompose = false; states.showCompose = false;
window.__COMPOSE__ = null; window.__COMPOSE__ = null;
if (newStatus) { if (newStatus) {
states.reloadStatusPage++; states.reloadStatusPage++;
showToast({ showToast({
text: 'Post published. Check it out.', text: {
post: 'Post published. Check it out.',
reply: 'Reply posted. Check it out.',
edit: 'Post updated. Check it out.',
}[type || 'post'],
delay: 1000, delay: 1000,
duration: 10_000, // 10 seconds duration: 10_000, // 10 seconds
onClick: (toast) => { onClick: (toast) => {
@ -174,6 +179,24 @@ export default function Modals() {
/> />
</Modal> </Modal>
)} )}
{!!snapStates.showMediaAlt && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
states.showMediaAlt = false;
}
}}
>
<MediaAltModal
alt={snapStates.showMediaAlt.alt || snapStates.showMediaAlt}
lang={snapStates.showMediaAlt?.lang}
onClose={() => {
states.showMediaAlt = false;
}}
/>
</Modal>
)}
</> </>
); );
} }

View file

@ -25,13 +25,20 @@ function NameText({
const trimmedDisplayName = (displayName || '').toLowerCase().trim(); const trimmedDisplayName = (displayName || '').toLowerCase().trim();
const shortenedDisplayName = trimmedDisplayName const shortenedDisplayName = trimmedDisplayName
.replace(/(\:(\w|\+|\-)+\:)(?=|[\!\.\?]|$)/g, '') // Remove shortcodes, regex from https://regex101.com/r/iE9uV0/1 .replace(/(\:(\w|\+|\-)+\:)(?=|[\!\.\?]|$)/g, '') // Remove shortcodes, regex from https://regex101.com/r/iE9uV0/1
.replace(/\s+/g, '') // E.g. "My name" === "myname" .replace(/\s+/g, ''); // E.g. "My name" === "myname"
.replace(/[^a-z0-9]/gi, ''); // Remove non-alphanumeric characters const shortenedAlphaNumericDisplayName = shortenedDisplayName.replace(
/[^a-z0-9]/gi,
'',
); // Remove non-alphanumeric characters
if ( if (
!short && !short &&
(trimmedUsername === trimmedDisplayName || (trimmedUsername === trimmedDisplayName ||
trimmedUsername === shortenedDisplayName) trimmedUsername === shortenedDisplayName ||
trimmedUsername === shortenedAlphaNumericDisplayName ||
trimmedUsername.localeCompare?.(shortenedDisplayName, 'en', {
sensitivity: 'base',
}) === 0)
) { ) {
username = null; username = null;
} }

View file

@ -17,7 +17,7 @@ import { accountsIsDtth, gtsDtthSettings } from '../utils/dtth';
function NavMenu(props) { function NavMenu(props) {
const snapStates = useSnapshot(states); const snapStates = useSnapshot(states);
const { instance, authenticated } = api(); const { masto, instance, authenticated } = api();
const [currentAccount, setCurrentAccount] = useState(); const [currentAccount, setCurrentAccount] = useState();
const [moreThanOneAccount, setMoreThanOneAccount] = useState(false); const [moreThanOneAccount, setMoreThanOneAccount] = useState(false);
@ -61,6 +61,28 @@ function NavMenu(props) {
0, 0,
]); ]);
const mutesIterator = useRef();
async function fetchMutes(firstLoad) {
if (firstLoad || !mutesIterator.current) {
mutesIterator.current = masto.v1.mutes.list({
limit: 80,
});
}
const results = await mutesIterator.current.next();
return results;
}
const blocksIterator = useRef();
async function fetchBlocks(firstLoad) {
if (firstLoad || !blocksIterator.current) {
blocksIterator.current = masto.v1.blocks.list({
limit: 80,
});
}
const results = await blocksIterator.current.next();
return results;
}
return ( return (
<> <>
<button <button
@ -209,6 +231,29 @@ function NavMenu(props) {
> >
<Icon icon="group" size="l" /> <span>Accounts&hellip;</span> <Icon icon="group" size="l" /> <span>Accounts&hellip;</span>
</MenuItem> </MenuItem>
<MenuItem
onClick={() => {
states.showGenericAccounts = {
id: 'mute',
heading: 'Muted users',
fetchAccounts: fetchMutes,
};
}}
>
<Icon icon="mute" size="l" /> Muted users&hellip;
</MenuItem>
<MenuItem
onClick={() => {
states.showGenericAccounts = {
id: 'block',
heading: 'Blocked users',
fetchAccounts: fetchBlocks,
};
}}
>
<Icon icon="block" size="l" />
Blocked users&hellip;
</MenuItem>
<MenuItem <MenuItem
onClick={() => { onClick={() => {
states.showKeyboardShortcutsHelp = true; states.showKeyboardShortcutsHelp = true;

View file

@ -38,7 +38,7 @@ export default memo(function NotificationService() {
? getAccountByAccessToken(accessToken) ? getAccountByAccessToken(accessToken)
: getCurrentAccount(); : getCurrentAccount();
(async () => { (async () => {
const notification = await masto.v1.notifications.fetch(id); const notification = await masto.v1.notifications.$select(id).fetch();
if (notification && account) { if (notification && account) {
console.log('🛎️ Notification', { id, notification, account }); console.log('🛎️ Notification', { id, notification, account });
const accountInstance = account.instanceURL; const accountInstance = account.instanceURL;

View file

@ -58,14 +58,14 @@ const contentText = {
'favourite+reblog+account': (count) => 'favourite+reblog+account': (count) =>
`boosted & favourited ${count} of your posts.`, `boosted & favourited ${count} of your posts.`,
'favourite+reblog_reply': 'boosted & favourited your reply.', 'favourite+reblog_reply': 'boosted & favourited your reply.',
'admin.signup': 'signed up.', 'admin.sign_up': 'signed up.',
'admin.report': 'reported a post.', 'admin.report': (targetAccount) => <>reported {targetAccount}</>,
}; };
const AVATARS_LIMIT = 50; const AVATARS_LIMIT = 50;
function Notification({ notification, instance, reload, isStatic }) { function Notification({ notification, instance, reload, isStatic }) {
const { id, status, account, _accounts, _statuses } = notification; const { id, status, account, report, _accounts, _statuses } = notification;
let { type } = notification; let { type } = notification;
// status = Attached when type of the notification is favourite, reblog, status, mention, poll, or update // status = Attached when type of the notification is favourite, reblog, status, mention, poll, or update
@ -119,7 +119,15 @@ function Notification({ notification, instance, reload, isStatic }) {
} }
if (typeof text === 'function') { if (typeof text === 'function') {
text = text(_statuses?.length || _accounts?.length); const count = _statuses?.length || _accounts?.length;
if (count) {
text = text(count);
} else if (type === 'admin.report') {
const targetAccount = report?.targetAccount;
if (targetAccount) {
text = text(<NameText account={targetAccount} showAvatar />);
}
}
} }
if (type === 'mention' && !status) { if (type === 'mention' && !status) {

View file

@ -199,6 +199,7 @@ export default function Poll({
setUIState('default'); setUIState('default');
})(); })();
}} }}
title="Refresh"
> >
<Icon icon="refresh" alt="Refresh" /> <Icon icon="refresh" alt="Refresh" />
</button> </button>
@ -212,6 +213,7 @@ export default function Poll({
e.preventDefault(); e.preventDefault();
setShowResults(!showResults); setShowResults(!showResults);
}} }}
title={showResults ? 'Hide results' : 'Show results'}
> >
<Icon <Icon
icon={showResults ? 'eye-open' : 'eye-close'} icon={showResults ? 'eye-open' : 'eye-close'}

View file

@ -4,14 +4,15 @@ import {
compressToEncodedURIComponent, compressToEncodedURIComponent,
decompressFromEncodedURIComponent, decompressFromEncodedURIComponent,
} from 'lz-string'; } from 'lz-string';
import mem from 'mem';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
import floatingButtonUrl from '../assets/floating-button.svg'; import floatingButtonUrl from '../assets/floating-button.svg';
import multiColumnUrl from '../assets/multi-column.svg'; import multiColumnUrl from '../assets/multi-column.svg';
import tabMenuBarUrl from '../assets/tab-menu-bar.svg'; import tabMenuBarUrl from '../assets/tab-menu-bar.svg';
import { api } from '../utils/api'; import { api } from '../utils/api';
import pmem from '../utils/pmem';
import showToast from '../utils/show-toast'; import showToast from '../utils/show-toast';
import states from '../utils/states'; import states from '../utils/states';
@ -132,15 +133,10 @@ export const SHORTCUTS_META = {
}, },
list: { list: {
id: 'list', id: 'list',
title: mem( title: pmem(async ({ id }) => {
async ({ id }) => { const list = await api().masto.v1.lists.$select(id).fetch();
const list = await api().masto.v1.lists.fetch(id);
return list.title; return list.title;
}, }),
{
cacheKey: ([{ id }]) => id,
},
),
path: ({ id }) => `/l/${id}`, path: ({ id }) => `/l/${id}`,
icon: 'list', icon: 'list',
}, },
@ -166,15 +162,10 @@ export const SHORTCUTS_META = {
}, },
'account-statuses': { 'account-statuses': {
id: 'account-statuses', id: 'account-statuses',
title: mem( title: pmem(async ({ id }) => {
async ({ id }) => { const account = await api().masto.v1.accounts.$select(id).fetch();
const account = await api().masto.v1.accounts.fetch(id);
return account.username || account.acct || account.displayName; return account.username || account.acct || account.displayName;
}, }),
{
cacheKey: ([{ id }]) => id,
},
),
path: ({ id }) => `/a/${id}`, path: ({ id }) => `/a/${id}`,
icon: 'user', icon: 'user',
}, },

View file

@ -82,6 +82,8 @@
list-style: none; list-style: none;
display: flex; display: flex;
justify-content: center; justify-content: center;
min-width: 20vw;
flex-basis: 20vw;
} }
#shortcuts .tab-bar li a { #shortcuts .tab-bar li a {
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
@ -95,7 +97,13 @@
padding: 8px; padding: 8px;
text-decoration: none; text-decoration: none;
text-shadow: 0 var(--hairline-width) var(--bg-color); text-shadow: 0 var(--hairline-width) var(--bg-color);
width: 20vw; width: 100%;
@media (hover: hover) {
&:is(:hover, :focus) {
color: var(--text-color);
}
}
} }
#shortcuts .tab-bar li a:active { #shortcuts .tab-bar li a:active {
transform: scale(0.95); transform: scale(0.95);
@ -171,6 +179,8 @@ shortcuts .tab-bar[hidden] {
} }
#shortcuts .tab-bar li { #shortcuts .tab-bar li {
flex-grow: 0; flex-grow: 0;
min-width: auto;
flex-basis: auto;
} }
#shortcuts .tab-bar li a { #shortcuts .tab-bar li a {
padding: 0 16px; padding: 0 16px;

View file

@ -166,8 +166,11 @@
.status.large .status-card :is(.content, .poll, .media-container) { .status.large .status-card :is(.content, .poll, .media-container) {
max-height: 80vh !important; max-height: 80vh !important;
} }
.status-card :is(.content.truncated, .poll, .media-container.truncated) { .status-card :is(.content, .poll, .media-container) {
font-size: inherit !important; font-size: inherit !important;
}
.status-card :is(.content.truncated, .poll, .media-container.truncated) {
/* font-size: inherit !important; */
mask-image: linear-gradient(to bottom, #000 80px, transparent); mask-image: linear-gradient(to bottom, #000 80px, transparent);
} }
.status.small .status.small
@ -299,7 +302,7 @@
overflow: hidden; overflow: hidden;
/* text-overflow: ellipsis; */ /* text-overflow: ellipsis; */
} }
.status > .container > .meta .name-text { .status > .container > .meta .meta-name {
mask-image: linear-gradient(to left, transparent, black 16px); mask-image: linear-gradient(to left, transparent, black 16px);
flex-grow: 1; flex-grow: 1;
} }
@ -334,7 +337,7 @@
.status > .container > .meta a.time:after { .status > .container > .meta a.time:after {
content: ''; content: '';
position: absolute; position: absolute;
inset: -16px; inset: -16px -16px -8px;
} }
.status > .container > .meta .reply-to { .status > .container > .meta .reply-to {
opacity: 0.5; opacity: 0.5;
@ -457,7 +460,7 @@
.status .status
.content-container.has-spoiler:not(.show-spoiler) .content-container.has-spoiler:not(.show-spoiler)
.spoiler .spoiler
~ *:not(.media-container, .card), ~ *:not(.media-container, .card, .media-figure-multiple),
.status .status
.content-container.has-spoiler:not(.show-spoiler) .content-container.has-spoiler:not(.show-spoiler)
.spoiler .spoiler
@ -466,7 +469,7 @@
.status .status
.content-container.has-spoiler:not(.show-spoiler) .content-container.has-spoiler:not(.show-spoiler)
.spoiler .spoiler
~ .media-container ~ :is(.media-container, .media-figure-multiple)
figcaption { figcaption {
filter: blur(5px) invert(0.5); filter: blur(5px) invert(0.5);
image-rendering: crisp-edges; image-rendering: crisp-edges;
@ -480,7 +483,7 @@
.status .status
.content-container.has-spoiler:not(.show-spoiler) .content-container.has-spoiler:not(.show-spoiler)
.spoiler .spoiler
~ .media-container ~ :is(.media-container, .media-figure-multiple)
.media .media
> *, > *,
.status .status
@ -544,7 +547,7 @@
max-height: 40vh; max-height: 40vh;
max-height: 40dvh; max-height: 40dvh;
} }
.timeline-deck .status .content.truncated { .timeline-deck .status:not(.truncated .status) .content.truncated {
mask-image: linear-gradient( mask-image: linear-gradient(
to top, to top,
transparent, transparent,
@ -552,7 +555,7 @@
black 1.5em black 1.5em
); );
} }
.timeline-deck .status .content.truncated:after { .timeline-deck .status:not(.truncated .status) .content.truncated:after {
content: attr(data-read-more); content: attr(data-read-more);
line-height: 1; line-height: 1;
display: inline-block; display: inline-block;
@ -708,21 +711,21 @@
figure { figure {
margin: 0; margin: 0;
padding: 0; padding: 0;
display: flex;
flex-wrap: wrap;
/* align-items: flex-end; */
column-gap: 4px;
figcaption { figcaption {
margin: -2px 0 0; align-self: flex-end;
padding: 0 4px; padding: 4px;
font-size: 90%; font-size: 90%;
color: var(--text-insignificant-color); color: var(--text-insignificant-color);
overflow: hidden;
white-space: normal;
display: -webkit-box;
display: box;
-webkit-box-orient: vertical;
box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
line-height: 1.2; line-height: 1.2;
cursor: pointer;
white-space: pre-line;
flex-basis: 15em;
flex-grow: 1;
} }
} }
@ -833,7 +836,7 @@
.status .media:is(:hover, :focus) { .status .media:is(:hover, :focus) {
border-color: var(--outline-hover-color); border-color: var(--outline-hover-color);
} }
.status .media:active { .status .media:active:not(:has(button:active)) {
filter: brightness(0.8); filter: brightness(0.8);
transform: scale(0.99); transform: scale(0.99);
} }
@ -845,6 +848,22 @@
} }
.status .media { .status .media {
cursor: pointer; cursor: pointer;
&[data-has-alt] {
position: relative;
.alt-badge {
position: absolute;
bottom: 8px;
left: 8px;
&:before {
content: '';
position: absolute;
inset: -12px;
}
}
}
} }
.status .media img:is(:hover, :focus), .status .media img:is(:hover, :focus),
a:focus-visible .status .media img { a:focus-visible .status .media img {
@ -874,9 +893,9 @@ body:has(#modal-container .carousel) .status .media img:hover {
left: 50%; left: 50%;
top: 50%; top: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
color: var(--video-fg-color); color: var(--media-fg-color);
background-color: var(--video-bg-color); background-color: var(--media-bg-color);
box-shadow: inset 0 0 0 2px var(--video-outline-color); box-shadow: inset 0 0 0 2px var(--media-outline-color);
display: flex; display: flex;
place-content: center; place-content: center;
place-items: center; place-items: center;
@ -893,9 +912,9 @@ body:has(#modal-container .carousel) .status .media img:hover {
position: absolute; position: absolute;
bottom: 8px; bottom: 8px;
right: 8px; right: 8px;
color: var(--video-fg-color); color: var(--media-fg-color);
background-color: var(--video-bg-color); background-color: var(--media-bg-color);
border: var(--hairline-width) solid var(--video-outline-color); border: var(--hairline-width) solid var(--media-outline-color);
border-radius: 4px; border-radius: 4px;
padding: 0 4px; padding: 0 4px;
} }
@ -910,9 +929,9 @@ body:has(#modal-container .carousel) .status .media img:hover {
position: absolute; position: absolute;
bottom: 8px; bottom: 8px;
right: 8px; right: 8px;
color: var(--bg-faded-color); color: var(--media-fg-color);
background-color: var(--text-insignificant-color); background-color: var(--media-bg-color);
backdrop-filter: blur(6px) saturate(3) invert(0.2); border: var(--hairline-width) solid var(--media-outline-color);
border-radius: 4px; border-radius: 4px;
padding: 0 4px; padding: 0 4px;
} }
@ -979,6 +998,62 @@ body:has(#modal-container .carousel) .status .media img:hover {
white-space: normal; white-space: normal;
} }
.media-figure-multiple {
margin: 0;
padding: 0;
figcaption {
padding: 4px;
font-size: 90%;
color: var(--text-insignificant-color);
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
& > * {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: inline-flex;
gap: 4px;
&:hover {
color: var(--text-color);
cursor: pointer;
}
&:only-child {
white-space: pre-line;
overflow: auto;
text-overflow: unset;
}
}
sup {
opacity: 0.75;
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
}
/* Only 4, for now. Would be better if this is a for loop */
&:has(.media[data-has-alt]:nth-child(1):is(:hover, :focus))
figcaption
> div[data-caption-index~='1'],
&:has(.media[data-has-alt]:nth-child(2):is(:hover, :focus))
figcaption
> div[data-caption-index~='2'],
&:has(.media[data-has-alt]:nth-child(3):is(:hover, :focus))
figcaption
> div[data-caption-index~='3'],
&:has(.media[data-has-alt]:nth-child(4):is(:hover, :focus))
figcaption
> div[data-caption-index~='4'] {
color: var(--text-color);
}
}
.carousel-item { .carousel-item {
position: relative; position: relative;
} }
@ -1003,6 +1078,12 @@ body:has(#modal-container .carousel) .status .media img:hover {
font-size: 90%; font-size: 90%;
z-index: 1; z-index: 1;
text-shadow: 0 var(--hairline-width) var(--bg-color); text-shadow: 0 var(--hairline-width) var(--bg-color);
mix-blend-mode: luminosity;
white-space: pre-line;
&:is(:hover, :focus) {
mix-blend-mode: normal;
}
} }
.carousel-item button.media-alt .media-alt-desc { .carousel-item button.media-alt .media-alt-desc {
overflow: hidden; overflow: hidden;
@ -1638,3 +1719,37 @@ a.card:is(:hover, :focus):visited {
#reactions-container .reactions-block .reblog-icon { #reactions-container .reactions-block .reblog-icon {
color: var(--reblog-color); color: var(--reblog-color);
} }
/* ALT BADGE */
.alt-badge {
font-size: 12px;
font-weight: bold;
color: var(--media-fg-color);
background-color: var(--media-bg-color);
border: var(--hairline-width) solid var(--media-outline-color);
mix-blend-mode: luminosity;
border-radius: 4px;
padding: 4px;
opacity: 0.65;
sup {
vertical-align: super;
font-weight: normal;
line-height: 0;
padding-left: 2px;
}
&.clickable {
opacity: 0.75;
border-width: 2px;
&:is(:hover, :focus):not(:active) {
transition: 0.15s ease-out;
transition-property: transform, opacity, mix-blend-mode;
transform: scale(1.15);
opacity: 0.9;
mix-blend-mode: normal;
}
}
}

View file

@ -9,7 +9,6 @@ import {
MenuItem, MenuItem,
} from '@szhsin/react-menu'; } from '@szhsin/react-menu';
import { decodeBlurHash } from 'fast-blurhash'; import { decodeBlurHash } from 'fast-blurhash';
import mem from 'mem';
import pThrottle from 'p-throttle'; import pThrottle from 'p-throttle';
import { memo } from 'preact/compat'; import { memo } from 'preact/compat';
import { import {
@ -22,7 +21,6 @@ import {
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { InView } from 'react-intersection-observer'; import { InView } from 'react-intersection-observer';
import { useLongPress } from 'use-long-press'; import { useLongPress } from 'use-long-press';
import useResizeObserver from 'use-resize-observer';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
import { snapshot } from 'valtio/vanilla'; import { snapshot } from 'valtio/vanilla';
@ -43,6 +41,7 @@ import htmlContentLength from '../utils/html-content-length';
import isMastodonLinkMaybe from '../utils/isMastodonLinkMaybe'; import isMastodonLinkMaybe from '../utils/isMastodonLinkMaybe';
import localeMatch from '../utils/locale-match'; import localeMatch from '../utils/locale-match';
import niceDateTime from '../utils/nice-date-time'; import niceDateTime from '../utils/nice-date-time';
import pmem from '../utils/pmem';
import safeBoundingBoxPadding from '../utils/safe-bounding-box-padding'; import safeBoundingBoxPadding from '../utils/safe-bounding-box-padding';
import shortenNumber from '../utils/shorten-number'; import shortenNumber from '../utils/shorten-number';
import showToast from '../utils/show-toast'; import showToast from '../utils/show-toast';
@ -56,6 +55,7 @@ import Avatar from './avatar';
import Icon from './icon'; import Icon from './icon';
import Link from './link'; import Link from './link';
import Media from './media'; import Media from './media';
import { isMediaCaptionLong } from './media';
import MenuLink from './menu-link'; import MenuLink from './menu-link';
import RelativeTime from './relative-time'; import RelativeTime from './relative-time';
import TranslationBlock from './translation-block'; import TranslationBlock from './translation-block';
@ -67,13 +67,9 @@ const throttle = pThrottle({
}); });
function fetchAccount(id, masto) { function fetchAccount(id, masto) {
try { return masto.v1.accounts.$select(id).fetch();
return masto.v1.accounts.fetch(id);
} catch (e) {
return Promise.reject(e);
} }
} const memFetchAccount = pmem(fetchAccount);
const memFetchAccount = mem(fetchAccount);
const visibilityText = { const visibilityText = {
public: 'Public', public: 'Public',
@ -390,11 +386,11 @@ function Status({
reblogsCount: reblogsCount + (reblogged ? -1 : 1), reblogsCount: reblogsCount + (reblogged ? -1 : 1),
}; };
if (reblogged) { if (reblogged) {
const newStatus = await masto.v1.statuses.unreblog(id); const newStatus = await masto.v1.statuses.$select(id).unreblog();
saveStatus(newStatus, instance); saveStatus(newStatus, instance);
return true; return true;
} else { } else {
const newStatus = await masto.v1.statuses.reblog(id); const newStatus = await masto.v1.statuses.$select(id).reblog();
saveStatus(newStatus, instance); saveStatus(newStatus, instance);
return true; return true;
} }
@ -418,11 +414,11 @@ function Status({
reblogsCount: reblogsCount + (reblogged ? -1 : 1), reblogsCount: reblogsCount + (reblogged ? -1 : 1),
}; };
if (reblogged) { if (reblogged) {
const newStatus = await masto.v1.statuses.unreblog(id); const newStatus = await masto.v1.statuses.$select(id).unreblog();
saveStatus(newStatus, instance); saveStatus(newStatus, instance);
return true; return true;
} else { } else {
const newStatus = await masto.v1.statuses.reblog(id); const newStatus = await masto.v1.statuses.$select(id).reblog();
saveStatus(newStatus, instance); saveStatus(newStatus, instance);
return true; return true;
} }
@ -446,10 +442,10 @@ function Status({
favouritesCount: favouritesCount + (favourited ? -1 : 1), favouritesCount: favouritesCount + (favourited ? -1 : 1),
}; };
if (favourited) { if (favourited) {
const newStatus = await masto.v1.statuses.unfavourite(id); const newStatus = await masto.v1.statuses.$select(id).unfavourite();
saveStatus(newStatus, instance); saveStatus(newStatus, instance);
} else { } else {
const newStatus = await masto.v1.statuses.favourite(id); const newStatus = await masto.v1.statuses.$select(id).favourite();
saveStatus(newStatus, instance); saveStatus(newStatus, instance);
} }
} catch (e) { } catch (e) {
@ -470,10 +466,10 @@ function Status({
bookmarked: !bookmarked, bookmarked: !bookmarked,
}; };
if (bookmarked) { if (bookmarked) {
const newStatus = await masto.v1.statuses.unbookmark(id); const newStatus = await masto.v1.statuses.$select(id).unbookmark();
saveStatus(newStatus, instance); saveStatus(newStatus, instance);
} else { } else {
const newStatus = await masto.v1.statuses.bookmark(id); const newStatus = await masto.v1.statuses.$select(id).bookmark();
saveStatus(newStatus, instance); saveStatus(newStatus, instance);
} }
} catch (e) { } catch (e) {
@ -484,7 +480,7 @@ function Status({
}; };
const differentLanguage = const differentLanguage =
language && !!language &&
language !== targetLanguage && language !== targetLanguage &&
!localeMatch([language], [targetLanguage]) && !localeMatch([language], [targetLanguage]) &&
!contentTranslationHideLanguages.find( !contentTranslationHideLanguages.find(
@ -708,9 +704,9 @@ function Status({
<MenuItem <MenuItem
onClick={async () => { onClick={async () => {
try { try {
const newStatus = await masto.v1.statuses[ const newStatus = await masto.v1.statuses
muted ? 'unmute' : 'mute' .$select(id)
](id); [muted ? 'unmute' : 'mute']();
saveStatus(newStatus, instance); saveStatus(newStatus, instance);
showToast(muted ? 'Conversation unmuted' : 'Conversation muted'); showToast(muted ? 'Conversation unmuted' : 'Conversation muted');
} catch (e) { } catch (e) {
@ -763,7 +759,7 @@ function Status({
// if (yes) { // if (yes) {
(async () => { (async () => {
try { try {
await masto.v1.statuses.remove(id); await masto.v1.statuses.$select(id).remove();
const cachedStatus = getStatus(id, instance); const cachedStatus = getStatus(id, instance);
cachedStatus._deleted = true; cachedStatus._deleted = true;
showToast('Deleted'); showToast('Deleted');
@ -790,8 +786,17 @@ function Status({
x: 0, x: 0,
y: 0, y: 0,
}); });
const isIOS =
window.ontouchstart !== undefined &&
/iPad|iPhone|iPod/.test(navigator.userAgent);
// Only iOS/iPadOS browsers don't support contextmenu
// Some comments report iPadOS might support contextmenu if a mouse is connected
const bindLongPressContext = useLongPress( const bindLongPressContext = useLongPress(
(e) => { isIOS
? (e) => {
if (e.pointerType === 'mouse') return;
// There's 'pen' too, but not sure if contextmenu event would trigger from a pen
const { clientX, clientY } = e.touches?.[0] || e; const { clientX, clientY } = e.touches?.[0] || e;
// link detection copied from onContextMenu because here it works // link detection copied from onContextMenu because here it works
const link = e.target.closest('a'); const link = e.target.closest('a');
@ -802,12 +807,13 @@ function Status({
y: clientY, y: clientY,
}); });
setIsContextMenuOpen(true); setIsContextMenuOpen(true);
}, }
: null,
{ {
threshold: 600, threshold: 600,
captureEvent: true, captureEvent: true,
detect: 'touch', detect: 'touch',
cancelOnMovement: 4, // true allows movement of up to 25 pixels cancelOnMovement: 2, // true allows movement of up to 25 pixels
}, },
); );
@ -862,6 +868,72 @@ function Status({
}, },
); );
const displayedMediaAttachments = mediaAttachments.slice(
0,
isSizeLarge ? undefined : 4,
);
const showMultipleMediaCaptions =
mediaAttachments.length > 1 &&
displayedMediaAttachments.some(
(media) => !!media.description && !isMediaCaptionLong(media.description),
);
const captionChildren = useMemo(() => {
if (!showMultipleMediaCaptions) return null;
const attachments = [];
displayedMediaAttachments.forEach((media, i) => {
if (!media.description) return;
const index = attachments.findIndex(
(attachment) => attachment.media.description === media.description,
);
if (index === -1) {
attachments.push({
media,
indices: [i],
});
} else {
attachments[index].indices.push(i);
}
});
return attachments.map(({ media, indices }) => (
<div
key={media.id}
data-caption-index={indices.map((i) => i + 1).join(' ')}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
states.showMediaAlt = {
alt: media.description,
lang: language,
};
}}
title={media.description}
>
<sup>{indices.map((i) => i + 1).join(' ')}</sup> {media.description}
</div>
));
// return displayedMediaAttachments.map(
// (media, i) =>
// !!media.description && (
// <div
// key={media.id}
// data-caption-index={i + 1}
// onClick={(e) => {
// e.preventDefault();
// e.stopPropagation();
// states.showMediaAlt = {
// alt: media.description,
// lang: language,
// };
// }}
// title={media.description}
// >
// <sup>{i + 1}</sup> {media.description}
// </div>
// ),
// );
}, [showMultipleMediaCaptions, displayedMediaAttachments, language]);
return ( return (
<article <article
ref={(node) => { ref={(node) => {
@ -968,13 +1040,14 @@ function Status({
)} )}
<div class="container"> <div class="container">
<div class="meta"> <div class="meta">
{/* <span> */} <span class="meta-name">
<NameText <NameText
account={status.account} account={status.account}
instance={instance} instance={instance}
showAvatar={size === 's'} showAvatar={size === 's'}
showAcct={isSizeLarge} showAcct={isSizeLarge}
/> />
</span>
{/* {inReplyToAccount && !withinContext && size !== 's' && ( {/* {inReplyToAccount && !withinContext && size !== 's' && (
<> <>
{' '} {' '}
@ -1191,7 +1264,8 @@ function Status({
}} }}
refresh={() => { refresh={() => {
return masto.v1.polls return masto.v1.polls
.fetch(poll.id) .$select(poll.id)
.fetch()
.then((pollResponse) => { .then((pollResponse) => {
states.statuses[sKey].poll = pollResponse; states.statuses[sKey].poll = pollResponse;
}) })
@ -1199,7 +1273,8 @@ function Status({
}} }}
votePoll={(choices) => { votePoll={(choices) => {
return masto.v1.polls return masto.v1.polls
.vote(poll.id, { .$select(poll.id)
.votes.create({
choices, choices,
}) })
.then((pollResponse) => { .then((pollResponse) => {
@ -1255,19 +1330,27 @@ function Status({
</button> </button>
)} )}
{!!mediaAttachments.length && ( {!!mediaAttachments.length && (
<MultipleMediaFigure
lang={language}
enabled={showMultipleMediaCaptions}
captionChildren={captionChildren}
>
<div <div
ref={mediaContainerRef} ref={mediaContainerRef}
class={`media-container media-eq${mediaAttachments.length} ${ class={`media-container media-eq${mediaAttachments.length} ${
mediaAttachments.length > 2 ? 'media-gt2' : '' mediaAttachments.length > 2 ? 'media-gt2' : ''
} ${mediaAttachments.length > 4 ? 'media-gt4' : ''}`} } ${mediaAttachments.length > 4 ? 'media-gt4' : ''}`}
> >
{mediaAttachments {displayedMediaAttachments.map((media, i) => (
.slice(0, isSizeLarge ? undefined : 4)
.map((media, i) => (
<Media <Media
key={media.id} key={media.id}
media={media} media={media}
autoAnimate={isSizeLarge} autoAnimate={isSizeLarge}
showCaption={mediaAttachments.length === 1}
lang={language}
altIndex={
showMultipleMediaCaptions && !!media.description && i + 1
}
to={`/${instance}/s/${id}?${ to={`/${instance}/s/${id}?${
withinContext ? 'media' : 'media-only' withinContext ? 'media' : 'media-only'
}=${i + 1}`} }=${i + 1}`}
@ -1281,6 +1364,7 @@ function Status({
/> />
))} ))}
</div> </div>
</MultipleMediaFigure>
)} )}
{!!card && {!!card &&
card?.url !== status.url && card?.url !== status.url &&
@ -1448,7 +1532,7 @@ function Status({
statusID={showEdited} statusID={showEdited}
instance={instance} instance={instance}
fetchStatusHistory={() => { fetchStatusHistory={() => {
return masto.v1.statuses.listHistory(showEdited); return masto.v1.statuses.$select(showEdited).history.list();
}} }}
onClose={() => { onClose={() => {
setShowEdited(false); setShowEdited(false);
@ -1477,6 +1561,19 @@ function Status({
); );
} }
function MultipleMediaFigure(props) {
const { enabled, children, lang, captionChildren } = props;
if (!enabled || !captionChildren) return children;
return (
<figure class="media-figure-multiple">
{children}
<figcaption lang={lang} dir="auto">
{captionChildren}
</figcaption>
</figure>
);
}
function Card({ card, instance }) { function Card({ card, instance }) {
const snapStates = useSnapshot(states); const snapStates = useSnapshot(states);
const { const {
@ -1485,14 +1582,18 @@ function Card({ card, instance }) {
description, description,
html, html,
providerName, providerName,
providerUrl,
authorName, authorName,
authorUrl,
width, width,
height, height,
image, image,
imageDescription,
url, url,
type, type,
embedUrl, embedUrl,
language, language,
publishedAt,
} = card; } = card;
/* type /* type
@ -1518,7 +1619,7 @@ function Card({ card, instance }) {
// NOTE: This is for quote post // NOTE: This is for quote post
// (async () => { // (async () => {
// const { masto } = api({ instance }); // const { masto } = api({ instance });
// const status = await masto.v1.statuses.fetch(id); // const status = await masto.v1.statuses.$select(id).fetch();
// saveStatus(status, instance); // saveStatus(status, instance);
// setCardStatusID(id); // setCardStatusID(id);
// })(); // })();
@ -1565,7 +1666,7 @@ function Card({ card, instance }) {
width={width} width={width}
height={height} height={height}
loading="lazy" loading="lazy"
alt="" alt={imageDescription || ''}
onError={(e) => { onError={(e) => {
try { try {
e.target.style.display = 'none'; e.target.style.display = 'none';
@ -1738,15 +1839,16 @@ function ReactionsModal({ statusID, instance, onClose }) {
(async () => { (async () => {
try { try {
if (firstLoad) { if (firstLoad) {
reblogIterator.current = masto.v1.statuses.listRebloggedBy(statusID, { reblogIterator.current = masto.v1.statuses
.$select(statusID)
.rebloggedBy.list({
limit: REACTIONS_LIMIT, limit: REACTIONS_LIMIT,
}); });
favouriteIterator.current = masto.v1.statuses.listFavouritedBy( favouriteIterator.current = masto.v1.statuses
statusID, .$select(statusID)
{ .favouritedBy.list({
limit: REACTIONS_LIMIT, limit: REACTIONS_LIMIT,
}, });
);
} }
const [{ value: reblogResults }, { value: favouriteResults }] = const [{ value: reblogResults }, { value: favouriteResults }] =
await Promise.allSettled([ await Promise.allSettled([
@ -1976,7 +2078,10 @@ function _unfurlMastodonLink(instance, url) {
if (statusMatch) { if (statusMatch) {
const id = statusMatch[3]; const id = statusMatch[3];
const { masto } = api({ instance: domain }); const { masto } = api({ instance: domain });
remoteInstanceFetch = masto.v1.statuses.fetch(id).then((status) => { remoteInstanceFetch = masto.v1.statuses
.$select(id)
.fetch()
.then((status) => {
if (status?.id) { if (status?.id) {
return { return {
status, status,
@ -1989,8 +2094,8 @@ function _unfurlMastodonLink(instance, url) {
} }
const { masto } = api({ instance }); const { masto } = api({ instance });
const mastoSearchFetch = masto.v2 const mastoSearchFetch = masto.v2.search
.search({ .fetch({
q: url, q: url,
type: 'statuses', type: 'statuses',
resolve: true, resolve: true,
@ -2060,11 +2165,7 @@ function nicePostURL(url) {
); );
} }
const unfurlMastodonLink = throttle( const unfurlMastodonLink = throttle(_unfurlMastodonLink);
mem(_unfurlMastodonLink, {
cacheKey: (instance, url) => `${instance}:${url}`,
}),
);
function FilteredStatus({ status, filterInfo, instance, containerProps = {} }) { function FilteredStatus({ status, filterInfo, instance, containerProps = {} }) {
const { const {
@ -2087,7 +2188,7 @@ function FilteredStatus({ status, filterInfo, instance, containerProps = {} }) {
threshold: 600, threshold: 600,
captureEvent: true, captureEvent: true,
detect: 'touch', detect: 'touch',
cancelOnMovement: 4, // true allows movement of up to 25 pixels cancelOnMovement: 2, // true allows movement of up to 25 pixels
}, },
); );

View file

@ -14,10 +14,15 @@ import useScroll from '../utils/useScroll';
import Icon from './icon'; import Icon from './icon';
import Link from './link'; import Link from './link';
import Loader from './loader';
import NavMenu from './nav-menu'; import NavMenu from './nav-menu';
import Status from './status'; import Status from './status';
const scrollIntoViewOptions = {
block: 'nearest',
inline: 'center',
behavior: 'smooth',
};
function Timeline({ function Timeline({
title, title,
titleComponent, titleComponent,
@ -112,7 +117,7 @@ function Timeline({
} }
if (nextItem) { if (nextItem) {
nextItem.focus(); nextItem.focus();
nextItem.scrollIntoViewIfNeeded?.(); nextItem.scrollIntoView(scrollIntoViewOptions);
} }
} else { } else {
// If active status is not in viewport, get the topmost status-link in viewport // If active status is not in viewport, get the topmost status-link in viewport
@ -122,7 +127,7 @@ function Timeline({
}); });
if (topmostItem) { if (topmostItem) {
topmostItem.focus(); topmostItem.focus();
topmostItem.scrollIntoViewIfNeeded?.(); topmostItem.scrollIntoView(scrollIntoViewOptions);
} }
} }
}); });
@ -151,7 +156,7 @@ function Timeline({
} }
if (prevItem) { if (prevItem) {
prevItem.focus(); prevItem.focus();
prevItem.scrollIntoViewIfNeeded?.(); prevItem.scrollIntoView(scrollIntoViewOptions);
} }
} else { } else {
// If active status is not in viewport, get the topmost status-link in viewport // If active status is not in viewport, get the topmost status-link in viewport
@ -161,7 +166,7 @@ function Timeline({
}); });
if (topmostItem) { if (topmostItem) {
topmostItem.focus(); topmostItem.focus();
topmostItem.scrollIntoViewIfNeeded?.(); topmostItem.scrollIntoView(scrollIntoViewOptions);
} }
} }
}); });
@ -413,7 +418,7 @@ function Timeline({
const isMiddle = i > 0 && i < items.length - 1; const isMiddle = i > 0 && i < items.length - 1;
const isSpoiler = item.sensitive && !!item.spoilerText; const isSpoiler = item.sensitive && !!item.spoilerText;
const showCompact = const showCompact =
(isSpoiler && i > 0) || (!_differentAuthor && isSpoiler && i > 0) ||
(manyItems && (manyItems &&
isMiddle && isMiddle &&
(type === 'thread' || (type === 'thread' ||

View file

@ -7,6 +7,7 @@ import { useEffect, useRef, useState } from 'preact/hooks';
import sourceLanguages from '../data/lingva-source-languages'; import sourceLanguages from '../data/lingva-source-languages';
import getTranslateTargetLanguage from '../utils/get-translate-target-language'; import getTranslateTargetLanguage from '../utils/get-translate-target-language';
import localeCode2Text from '../utils/localeCode2Text'; import localeCode2Text from '../utils/localeCode2Text';
import pmem from '../utils/pmem';
import Icon from './icon'; import Icon from './icon';
import Loader from './loader'; import Loader from './loader';
@ -25,7 +26,7 @@ const LINGVA_INSTANCES = [
]; ];
let currentLingvaInstance = 0; let currentLingvaInstance = 0;
function lingvaTranslate(text, source, target) { function _lingvaTranslate(text, source, target) {
console.log('TRANSLATE', text, source, target); console.log('TRANSLATE', text, source, target);
const fetchCall = () => { const fetchCall = () => {
let instance = LINGVA_INSTANCES[currentLingvaInstance]; let instance = LINGVA_INSTANCES[currentLingvaInstance];
@ -55,11 +56,18 @@ function lingvaTranslate(text, source, target) {
); );
}, },
}); });
// return masto.v1.statuses.translate(id, { // return masto.v1.statuses.$select(id).translate({
// lang: DEFAULT_LANG, // lang: DEFAULT_LANG,
// }); // });
} }
const throttledLingvaTranslate = throttle(lingvaTranslate); const TRANSLATED_MAX_AGE = 1000 * 60 * 60; // 1 hour
const lingvaTranslate = pmem(_lingvaTranslate, {
maxAge: TRANSLATED_MAX_AGE,
});
const throttledLingvaTranslate = pmem(throttle(lingvaTranslate), {
// I know, this is double-layered memoization
maxAge: TRANSLATED_MAX_AGE,
});
function TranslationBlock({ function TranslationBlock({
forceTranslate, forceTranslate,

View file

@ -47,7 +47,7 @@
--reply-to-color: var(--orange-color); --reply-to-color: var(--orange-color);
--reply-to-text-color: #b36200; --reply-to-text-color: #b36200;
--favourite-color: var(--red-color); --favourite-color: var(--red-color);
--reply-to-faded-color: #ffa60030; --reply-to-faded-color: #ffa60020;
--outline-color: rgba(128, 128, 128, 0.2); --outline-color: rgba(128, 128, 128, 0.2);
--outline-hover-color: rgba(128, 128, 128, 0.7); --outline-hover-color: rgba(128, 128, 128, 0.7);
--divider-color: rgba(0, 0, 0, 0.1); --divider-color: rgba(0, 0, 0, 0.1);
@ -64,9 +64,9 @@
--close-button-hover-color: rgba(0, 0, 0, 1); --close-button-hover-color: rgba(0, 0, 0, 1);
/* Video colors won't change based on color scheme */ /* Video colors won't change based on color scheme */
--video-fg-color: #f0f2f5; --media-fg-color: #f0f2f5;
--video-bg-color: #242526; --media-bg-color: #242526;
--video-outline-color: color-mix(in lch, var(--video-fg-color), transparent); --media-outline-color: color-mix(in lch, var(--media-fg-color), transparent);
--timing-function: cubic-bezier(0.3, 0.5, 0, 1); --timing-function: cubic-bezier(0.3, 0.5, 0, 1);
} }
@ -92,9 +92,14 @@
--link-light-color: #6494ed99; --link-light-color: #6494ed99;
--link-faded-color: #6494ed88; --link-faded-color: #6494ed88;
--link-bg-hover-color: #34353799; --link-bg-hover-color: #34353799;
--link-visited-color: color-mix(
in lch,
mediumslateblue 70%,
var(--text-color) 30%
);
--reblog-faded-color: #b190f141; --reblog-faded-color: #b190f141;
--reply-to-text-color: var(--reply-to-color); --reply-to-text-color: var(--reply-to-color);
--reply-to-faded-color: #ffa60027; --reply-to-faded-color: #ffa60017;
--divider-color: rgba(255, 255, 255, 0.1); --divider-color: rgba(255, 255, 255, 0.1);
--bg-blur-color: #24252699; --bg-blur-color: #24252699;
--backdrop-color: rgba(0, 0, 0, 0.5); --backdrop-color: rgba(0, 0, 0, 0.5);

View file

@ -1,4 +1,4 @@
import { Menu, MenuItem } from '@szhsin/react-menu'; import { MenuItem } from '@szhsin/react-menu';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { useParams, useSearchParams } from 'react-router-dom'; import { useParams, useSearchParams } from 'react-router-dom';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
@ -31,7 +31,8 @@ function AccountStatuses() {
const results = []; const results = [];
if (firstLoad) { if (firstLoad) {
const { value: pinnedStatuses } = await masto.v1.accounts const { value: pinnedStatuses } = await masto.v1.accounts
.listStatuses(id, { .$select(id)
.statuses.list({
pinned: true, pinned: true,
}) })
.next(); .next();
@ -53,7 +54,9 @@ function AccountStatuses() {
} }
} }
if (firstLoad || !accountStatusesIterator.current) { if (firstLoad || !accountStatusesIterator.current) {
accountStatusesIterator.current = masto.v1.accounts.listStatuses(id, { accountStatusesIterator.current = masto.v1.accounts
.$select(id)
.statuses.list({
limit: LIMIT, limit: LIMIT,
exclude_replies: excludeReplies, exclude_replies: excludeReplies,
exclude_reblogs: excludeBoosts, exclude_reblogs: excludeBoosts,
@ -86,14 +89,16 @@ function AccountStatuses() {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
try { try {
const acc = await masto.v1.accounts.fetch(id); const acc = await masto.v1.accounts.$select(id).fetch();
console.log(acc); console.log(acc);
setAccount(acc); setAccount(acc);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }
try { try {
const featuredTags = await masto.v1.accounts.listFeaturedTags(id); const featuredTags = await masto.v1.accounts
.$select(id)
.featuredTags.list(id);
console.log({ featuredTags }); console.log({ featuredTags });
setFeaturedTags(featuredTags); setFeaturedTags(featuredTags);
} catch (e) { } catch (e) {
@ -113,7 +118,7 @@ function AccountStatuses() {
<AccountInfo <AccountInfo
instance={instance} instance={instance}
account={cachedAccount || id} account={cachedAccount || id}
fetchAccount={() => masto.v1.accounts.fetch(id)} fetchAccount={() => masto.v1.accounts.$select(id).fetch()}
authenticated={authenticated} authenticated={authenticated}
standalone standalone
/> />

View file

@ -52,9 +52,9 @@ function Accounts({ onClose }) {
onDblClick={async () => { onDblClick={async () => {
if (isCurrent) { if (isCurrent) {
try { try {
const info = await masto.v1.accounts.fetch( const info = await masto.v1.accounts
account.info.id, .$select(account.info.id)
); .fetch();
console.log('fetched account info', info); console.log('fetched account info', info);
account.info = info; account.info = info;
store.local.setJSON('accounts', accounts); store.local.setJSON('accounts', accounts);

View file

@ -13,7 +13,7 @@ const LIMIT = 20;
function Following({ title, path, id, ...props }) { function Following({ title, path, id, ...props }) {
useTitle(title || 'Following', path || '/following'); useTitle(title || 'Following', path || '/following');
const { masto, instance } = api(); const { masto, streaming, instance } = api();
const snapStates = useSnapshot(states); const snapStates = useSnapshot(states);
const homeIterator = useRef(); const homeIterator = useRef();
const latestItem = useRef(); const latestItem = useRef();
@ -22,7 +22,7 @@ function Following({ title, path, id, ...props }) {
async function fetchHome(firstLoad) { async function fetchHome(firstLoad) {
if (firstLoad || !homeIterator.current) { if (firstLoad || !homeIterator.current) {
homeIterator.current = masto.v1.timelines.listHome({ limit: LIMIT }); homeIterator.current = masto.v1.timelines.home.list({ limit: LIMIT });
} }
const results = await homeIterator.current.next(); const results = await homeIterator.current.next();
let { value } = results; let { value } = results;
@ -53,8 +53,8 @@ function Following({ title, path, id, ...props }) {
async function checkForUpdates() { async function checkForUpdates() {
try { try {
const results = await masto.v1.timelines const results = await masto.v1.timelines.home
.listHome({ .list({
limit: 5, limit: 5,
since_id: latestItem.current, since_id: latestItem.current,
}) })
@ -75,52 +75,33 @@ function Following({ title, path, id, ...props }) {
} }
} }
const ws = useRef(); useEffect(() => {
const streamUser = async () => { let sub;
console.log('🎏 Start streaming user', ws.current); (async () => {
if ( if (streaming) {
ws.current && sub = streaming.user.subscribe();
(ws.current.readyState === WebSocket.CONNECTING || console.log('🎏 Streaming user', sub);
ws.current.readyState === WebSocket.OPEN) for await (const entry of sub) {
) { if (!sub) break;
console.log('🎏 Streaming user already open'); if (entry.event === 'status.update') {
return; const status = entry.payload;
}
const stream = await masto.v1.stream.streamUser();
ws.current = stream.ws;
ws.current.__id = Math.random();
console.log('🎏 Streaming user', ws.current);
stream.on('status.update', (status) => {
console.log(`🔄 Status ${status.id} updated`); console.log(`🔄 Status ${status.id} updated`);
saveStatus(status, instance); saveStatus(status, instance);
}); } else if (entry.event === 'delete') {
const statusID = entry.payload;
stream.on('delete', (statusID) => {
console.log(`❌ Status ${statusID} deleted`); console.log(`❌ Status ${statusID} deleted`);
// delete states.statuses[statusID]; // delete states.statuses[statusID];
const s = getStatus(statusID, instance); const s = getStatus(statusID, instance);
if (s) s._deleted = true; if (s) s._deleted = true;
}); }
}
stream.ws.onclose = () => { }
console.log('🎏 Streaming user closed');
};
return stream;
};
useEffect(() => {
let stream;
(async () => {
stream = await streamUser();
})(); })();
return () => { return () => {
if (stream) { sub?.unsubscribe?.();
stream.ws.close(); sub = null;
ws.current = null;
}
}; };
}, []); }, [streaming]);
return ( return (
<Timeline <Timeline

View file

@ -1,6 +1,5 @@
import { import {
FocusableItem, FocusableItem,
Menu,
MenuDivider, MenuDivider,
MenuGroup, MenuGroup,
MenuItem, MenuItem,
@ -47,7 +46,7 @@ function Hashtags({ columnMode, ...props }) {
const maxID = useRef(undefined); const maxID = useRef(undefined);
async function fetchHashtags(firstLoad) { async function fetchHashtags(firstLoad) {
// if (firstLoad || !hashtagsIterator.current) { // if (firstLoad || !hashtagsIterator.current) {
// hashtagsIterator.current = masto.v1.timelines.listHashtag(hashtag, { // hashtagsIterator.current = masto.v1.timelines.tag.$select(hashtag).list({
// limit: LIMIT, // limit: LIMIT,
// any: hashtags.slice(1), // any: hashtags.slice(1),
// }); // });
@ -55,8 +54,9 @@ function Hashtags({ columnMode, ...props }) {
// const results = await hashtagsIterator.current.next(); // const results = await hashtagsIterator.current.next();
// NOTE: Temporary fix for listHashtag not persisting `any` in subsequent calls. // NOTE: Temporary fix for listHashtag not persisting `any` in subsequent calls.
const results = await masto.v1.timelines const results = await masto.v1.timelines.tag
.listHashtag(hashtag, { .$select(hashtag)
.list({
limit: LIMIT, limit: LIMIT,
any: hashtags.slice(1), any: hashtags.slice(1),
maxId: firstLoad ? undefined : maxID.current, maxId: firstLoad ? undefined : maxID.current,
@ -82,8 +82,9 @@ function Hashtags({ columnMode, ...props }) {
async function checkForUpdates() { async function checkForUpdates() {
try { try {
const results = await masto.v1.timelines const results = await masto.v1.timelines.tag
.listHashtag(hashtag, { .$select(hashtag)
.list({
limit: 1, limit: 1,
any: hashtags.slice(1), any: hashtags.slice(1),
since_id: latestItem.current, since_id: latestItem.current,
@ -105,7 +106,7 @@ function Hashtags({ columnMode, ...props }) {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
try { try {
const info = await masto.v1.tags.fetch(hashtag); const info = await masto.v1.tags.$select(hashtag).fetch();
console.log(info); console.log(info);
setInfo(info); setInfo(info);
} catch (e) { } catch (e) {
@ -164,7 +165,8 @@ function Hashtags({ columnMode, ...props }) {
// return; // return;
// } // }
masto.v1.tags masto.v1.tags
.unfollow(hashtag) .$select(hashtag)
.unfollow()
.then(() => { .then(() => {
setInfo({ ...info, following: false }); setInfo({ ...info, following: false });
showToast(`Unfollowed #${hashtag}`); showToast(`Unfollowed #${hashtag}`);
@ -178,7 +180,8 @@ function Hashtags({ columnMode, ...props }) {
}); });
} else { } else {
masto.v1.tags masto.v1.tags
.follow(hashtag) .$select(hashtag)
.follow()
.then(() => { .then(() => {
setInfo({ ...info, following: true }); setInfo({ ...info, following: true });
showToast(`Followed #${hashtag}`); showToast(`Followed #${hashtag}`);
@ -258,11 +261,14 @@ function Hashtags({ columnMode, ...props }) {
onClick={(e) => { onClick={(e) => {
hashtags.splice(i, 1); hashtags.splice(i, 1);
hashtags.sort(); hashtags.sort();
navigate( // navigate(
instance // instance
// ? `/${instance}/t/${hashtags.join('+')}`
// : `/t/${hashtags.join('+')}`,
// );
location.hash = instance
? `/${instance}/t/${hashtags.join('+')}` ? `/${instance}/t/${hashtags.join('+')}`
: `/t/${hashtags.join('+')}`, : `/t/${hashtags.join('+')}`;
);
}} }}
> >
<Icon icon="x" alt="Remove hashtag" class="danger-icon" /> <Icon icon="x" alt="Remove hashtag" class="danger-icon" />
@ -317,7 +323,8 @@ function Hashtags({ columnMode, ...props }) {
} }
if (newInstance) { if (newInstance) {
newInstance = newInstance.toLowerCase().trim(); newInstance = newInstance.toLowerCase().trim();
navigate(`/${newInstance}/t/${hashtags.join('+')}`); // navigate(`/${newInstance}/t/${hashtags.join('+')}`);
location.hash = `/${newInstance}/t/${hashtags.join('+')}`;
} }
}} }}
> >

View file

@ -13,7 +13,6 @@ import Notification from '../components/notification';
import { api } from '../utils/api'; import { api } from '../utils/api';
import db from '../utils/db'; import db from '../utils/db';
import groupNotifications from '../utils/group-notifications'; import groupNotifications from '../utils/group-notifications';
import openCompose from '../utils/open-compose';
import states, { saveStatus } from '../utils/states'; import states, { saveStatus } from '../utils/states';
import { getCurrentAccountNS } from '../utils/store-utils'; import { getCurrentAccountNS } from '../utils/store-utils';
@ -49,24 +48,6 @@ function Home() {
headerEnd={<NotificationsLink />} headerEnd={<NotificationsLink />}
/> />
)} )}
{/* <button
// hidden={scrollDirection === 'end' && !nearReachStart}
type="button"
id="compose-button"
onClick={(e) => {
if (e.shiftKey) {
const newWin = openCompose();
if (!newWin) {
alert('Looks like your browser is blocking popups.');
states.showCompose = true;
}
} else {
states.showCompose = true;
}
}}
>
<Icon icon="quill" size="xl" alt="Compose" />
</button> */}
</> </>
); );
} }

View file

@ -32,7 +32,7 @@ function List(props) {
const listIterator = useRef(); const listIterator = useRef();
async function fetchList(firstLoad) { async function fetchList(firstLoad) {
if (firstLoad || !listIterator.current) { if (firstLoad || !listIterator.current) {
listIterator.current = masto.v1.timelines.listList(id, { listIterator.current = masto.v1.timelines.list.$select(id).list({
limit: LIMIT, limit: LIMIT,
}); });
} }
@ -56,7 +56,7 @@ function List(props) {
async function checkForUpdates() { async function checkForUpdates() {
try { try {
const results = await masto.v1.timelines.listList(id, { const results = await masto.v1.timelines.list.$select(id).list({
limit: 1, limit: 1,
since_id: latestItem.current, since_id: latestItem.current,
}); });
@ -77,7 +77,7 @@ function List(props) {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
try { try {
const list = await masto.v1.lists.fetch(id); const list = await masto.v1.lists.$select(id).fetch();
setList(list); setList(list);
// setTitle(list.title); // setTitle(list.title);
} catch (e) { } catch (e) {
@ -200,7 +200,9 @@ function ListManageMembers({ listID, onClose }) {
(async () => { (async () => {
try { try {
if (firstLoad || !membersIterator.current) { if (firstLoad || !membersIterator.current) {
membersIterator.current = masto.v1.lists.listAccounts(listID, { membersIterator.current = masto.v1.lists
.$select(listID)
.accounts.list({
limit: MEMBERS_LIMIT, limit: MEMBERS_LIMIT,
}); });
} }
@ -274,7 +276,7 @@ function RemoveAddButton({ account, listID }) {
setUIState('loading'); setUIState('loading');
(async () => { (async () => {
try { try {
await masto.v1.lists.addAccount(listID, { await masto.v1.lists.$select(listID).accounts.create({
accountIds: [account.id], accountIds: [account.id],
}); });
setUIState('default'); setUIState('default');
@ -290,7 +292,7 @@ function RemoveAddButton({ account, listID }) {
(async () => { (async () => {
try { try {
await masto.v1.lists.removeAccount(listID, { await masto.v1.lists.$select(listID).accounts.remove({
accountIds: [account.id], accountIds: [account.id],
}); });
setUIState('default'); setUIState('default');

View file

@ -92,11 +92,16 @@ function Notifications({ columnMode }) {
return allNotifications; return allNotifications;
} }
function fetchFollowRequests() { async function fetchFollowRequests() {
// Note: no pagination here yet because this better be on a separate page. Should be rare use-case??? // Note: no pagination here yet because this better be on a separate page. Should be rare use-case???
return masto.v1.followRequests.list({ try {
return await masto.v1.followRequests.list({
limit: 80, limit: 80,
}); });
} catch (e) {
// Silently fail
return [];
}
} }
const loadFollowRequests = () => { const loadFollowRequests = () => {
@ -112,8 +117,13 @@ function Notifications({ columnMode }) {
})(); })();
}; };
function fetchAnnouncements() { async function fetchAnnouncements() {
return masto.v1.announcements.list(); try {
return await masto.v1.announcements.list();
} catch (e) {
// Silently fail
return [];
}
} }
const loadNotifications = (firstLoad) => { const loadNotifications = (firstLoad) => {
@ -379,7 +389,10 @@ function Notifications({ columnMode }) {
)} )}
{snapStates.notifications.length ? ( {snapStates.notifications.length ? (
<> <>
{snapStates.notifications.map((notification) => { {snapStates.notifications
// This is leaked from Notifications popover
.filter((n) => n.type !== 'follow_request')
.map((notification) => {
if (onlyMentions && notification.type !== 'mention') { if (onlyMentions && notification.type !== 'mention') {
return null; return null;
} }
@ -392,7 +405,8 @@ function Notifications({ columnMode }) {
// if notificationDay is yesterday, show "Yesterday" // if notificationDay is yesterday, show "Yesterday"
// if notificationDay is before yesterday, show date // if notificationDay is before yesterday, show date
const heading = const heading =
notificationDay.toDateString() === yesterdayDate.toDateString() notificationDay.toDateString() ===
yesterdayDate.toDateString()
? 'Yesterday' ? 'Yesterday'
: niceDateTime(currentDay, { : niceDateTime(currentDay, {
hideTime: true, hideTime: true,

View file

@ -29,7 +29,7 @@ function Public({ local, columnMode, ...props }) {
const publicIterator = useRef(); const publicIterator = useRef();
async function fetchPublic(firstLoad) { async function fetchPublic(firstLoad) {
if (firstLoad || !publicIterator.current) { if (firstLoad || !publicIterator.current) {
publicIterator.current = masto.v1.timelines.listPublic({ publicIterator.current = masto.v1.timelines.public.list({
limit: LIMIT, limit: LIMIT,
local: isLocal, local: isLocal,
}); });
@ -54,8 +54,8 @@ function Public({ local, columnMode, ...props }) {
async function checkForUpdates() { async function checkForUpdates() {
try { try {
const results = await masto.v1.timelines const results = await masto.v1.timelines.public
.listPublic({ .list({
limit: 1, limit: 1,
local: isLocal, local: isLocal,
since_id: latestItem.current, since_id: latestItem.current,

View file

@ -90,7 +90,7 @@ function Search(props) {
if (authenticated) params.offset = offsetRef.current; if (authenticated) params.offset = offsetRef.current;
} }
try { try {
const results = await masto.v2.search(params); const results = await masto.v2.search.fetch(params);
console.log(results); console.log(results);
if (type) { if (type) {
if (firstLoad) { if (firstLoad) {

View file

@ -135,3 +135,8 @@
padding-inline: 16px; padding-inline: 16px;
color: var(--text-insignificant-color); color: var(--text-insignificant-color);
} }
#settings-container .synced-icon {
color: var(--link-color);
vertical-align: middle;
}

View file

@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from 'preact/hooks';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
import logo from '../assets/logo.svg'; import logo from '../assets/logo.svg';
import Icon from '../components/icon'; import Icon from '../components/icon';
import Link from '../components/link'; import Link from '../components/link';
import RelativeTime from '../components/relative-time'; import RelativeTime from '../components/relative-time';
@ -34,7 +35,7 @@ function Settings({ onClose }) {
const currentTextSize = store.local.get('textSize') || DEFAULT_TEXT_SIZE; const currentTextSize = store.local.get('textSize') || DEFAULT_TEXT_SIZE;
const [prefs, setPrefs] = useState(store.account.get('preferences') || {}); const [prefs, setPrefs] = useState(store.account.get('preferences') || {});
const { masto, authenticated } = api(); const { masto, authenticated, instance } = api();
// Get preferences every time Settings is opened // Get preferences every time Settings is opened
// NOTE: Disabled for now because I don't expect this to change often. Also for some reason, the /api/v1/preferences endpoint is cached for a while and return old prefs if refresh immediately after changing them. // NOTE: Disabled for now because I don't expect this to change often. Also for some reason, the /api/v1/preferences endpoint is cached for a while and return old prefs if refresh immediately after changing them.
// useEffect(() => { // useEffect(() => {
@ -178,7 +179,8 @@ function Settings({ onClose }) {
<li> <li>
<div> <div>
<label for="posting-privacy-field"> <label for="posting-privacy-field">
Default visibility Default visibility{' '}
<Icon icon="cloud" alt="Synced" class="synced-icon" />
</label> </label>
</div> </div>
<div> <div>
@ -217,6 +219,19 @@ function Settings({ onClose }) {
</li> </li>
</ul> </ul>
</section> </section>
<p class="section-postnote">
<Icon icon="cloud" alt="Synced" class="synced-icon" />{' '}
<small>
Synced to your instance server's settings.{' '}
<a
href={`https://${instance}/`}
target="_blank"
rel="noopener noreferrer"
>
Go to your instance ({instance}) for more settings.
</a>
</small>
</p>
</> </>
)} )}
<h3>Experiments</h3> <h3>Experiments</h3>
@ -339,6 +354,7 @@ function Settings({ onClose }) {
<a <a
href="https://github.com/thedaviddelta/lingva-translate" href="https://github.com/thedaviddelta/lingva-translate"
target="_blank" target="_blank"
rel="noopener noreferrer"
> >
Lingva Translate Lingva Translate
</a> </a>
@ -435,6 +451,7 @@ function Settings({ onClose }) {
<a <a
href="https://hachyderm.io/@phanpy" href="https://hachyderm.io/@phanpy"
// target="_blank" // target="_blank"
rel="noopener noreferrer"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
states.showAccount = 'phanpy@hachyderm.io'; states.showAccount = 'phanpy@hachyderm.io';
@ -458,6 +475,7 @@ function Settings({ onClose }) {
<a <a
href="https://mastodon.social/@cheeaun" href="https://mastodon.social/@cheeaun"
// target="_blank" // target="_blank"
rel="noopener noreferrer"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
states.showAccount = 'cheeaun@mastodon.social'; states.showAccount = 'cheeaun@mastodon.social';
@ -468,9 +486,26 @@ function Settings({ onClose }) {
</div> </div>
</div> </div>
<p> <p>
<a
href="https://github.com/sponsors/cheeaun"
target="_blank"
rel="noopener noreferrer"
>
Sponsor
</a>{' '}
&middot;{' '}
<a
href="https://www.buymeacoffee.com/cheeaun"
target="_blank"
rel="noopener noreferrer"
>
Donate
</a>{' '}
&middot;{' '}
<a <a
href="https://github.com/cheeaun/phanpy/blob/main/PRIVACY.MD" href="https://github.com/cheeaun/phanpy/blob/main/PRIVACY.MD"
target="_blank" target="_blank"
rel="noopener noreferrer"
> >
Privacy Policy Privacy Policy
</a> </a>

View file

@ -14,7 +14,7 @@ import {
} from 'preact/hooks'; } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { InView } from 'react-intersection-observer'; import { InView } from 'react-intersection-observer';
import { matchPath, useParams, useSearchParams } from 'react-router-dom'; import { matchPath, useSearchParams } from 'react-router-dom';
import { useDebouncedCallback } from 'use-debounce'; import { useDebouncedCallback } from 'use-debounce';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
@ -54,6 +54,12 @@ function resetScrollPosition(id) {
delete scrollPositions[id]; delete scrollPositions[id];
} }
const scrollIntoViewOptions = {
block: 'nearest',
inline: 'center',
behavior: 'smooth',
};
function StatusPage(params) { function StatusPage(params) {
const { id } = params; const { id } = params;
const { masto, instance } = api({ instance: params.instance }); const { masto, instance } = api({ instance: params.instance });
@ -94,7 +100,7 @@ function StatusPage(params) {
if (!heroStatus && showMedia) { if (!heroStatus && showMedia) {
(async () => { (async () => {
try { try {
const status = await masto.v1.statuses.fetch(id); const status = await masto.v1.statuses.$select(id).fetch();
saveStatus(status, instance); saveStatus(status, instance);
setHeroStatus(status); setHeroStatus(status);
} catch (err) { } catch (err) {
@ -135,6 +141,7 @@ function StatusPage(params) {
mediaAttachments={mediaAttachments} mediaAttachments={mediaAttachments}
statusID={mediaStatusID || id} statusID={mediaStatusID || id}
instance={instance} instance={instance}
lang={heroStatus?.language}
index={mediaIndex - 1} index={mediaIndex - 1}
onClose={handleMediaClose} onClose={handleMediaClose}
/> />
@ -228,12 +235,15 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
(async () => { (async () => {
const heroFetch = () => const heroFetch = () =>
pRetry(() => masto.v1.statuses.fetch(id), { pRetry(() => masto.v1.statuses.$select(id).fetch(), {
retries: 4, retries: 4,
}); });
const contextFetch = pRetry(() => masto.v1.statuses.fetchContext(id), { const contextFetch = pRetry(
() => masto.v1.statuses.$select(id).context.fetch(),
{
retries: 8, retries: 8,
}); },
);
const hasStatus = !!snapStates.statuses[sKey]; const hasStatus = !!snapStates.statuses[sKey];
let heroStatus = snapStates.statuses[sKey]; let heroStatus = snapStates.statuses[sKey];
@ -554,7 +564,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
let nextStatus = allStatusLinks[activeStatusIndex + 1]; let nextStatus = allStatusLinks[activeStatusIndex + 1];
if (nextStatus) { if (nextStatus) {
nextStatus.focus(); nextStatus.focus();
nextStatus.scrollIntoViewIfNeeded?.(); nextStatus.scrollIntoView(scrollIntoViewOptions);
} }
} else { } else {
// If active status is not in viewport, get the topmost status-link in viewport // If active status is not in viewport, get the topmost status-link in viewport
@ -564,7 +574,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
}); });
if (topmostStatusLink) { if (topmostStatusLink) {
topmostStatusLink.focus(); topmostStatusLink.focus();
topmostStatusLink.scrollIntoViewIfNeeded?.(); topmostStatusLink.scrollIntoView(scrollIntoViewOptions);
} }
} }
}); });
@ -588,7 +598,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
let prevStatus = allStatusLinks[activeStatusIndex - 1]; let prevStatus = allStatusLinks[activeStatusIndex - 1];
if (prevStatus) { if (prevStatus) {
prevStatus.focus(); prevStatus.focus();
prevStatus.scrollIntoViewIfNeeded?.(); prevStatus.scrollIntoView(scrollIntoViewOptions);
} }
} else { } else {
// If active status is not in viewport, get the topmost status-link in viewport // If active status is not in viewport, get the topmost status-link in viewport
@ -598,7 +608,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
}); });
if (topmostStatusLink) { if (topmostStatusLink) {
topmostStatusLink.focus(); topmostStatusLink.focus();
topmostStatusLink.scrollIntoViewIfNeeded?.(); topmostStatusLink.scrollIntoView(scrollIntoViewOptions);
} }
} }
}); });
@ -939,7 +949,8 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
setUIState('loading'); setUIState('loading');
(async () => { (async () => {
try { try {
const results = await currentMasto.v2.search({ const results =
await currentMasto.v2.search.fetch({
q: heroStatus.url, q: heroStatus.url,
type: 'statuses', type: 'statuses',
resolve: true, resolve: true,

View file

@ -1,4 +1,4 @@
import { Menu, MenuItem } from '@szhsin/react-menu'; import { MenuItem } from '@szhsin/react-menu';
import { useMemo, useRef, useState } from 'preact/hooks'; import { useMemo, useRef, useState } from 'preact/hooks';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
@ -30,13 +30,13 @@ function Trending({ columnMode, ...props }) {
const trendIterator = useRef(); const trendIterator = useRef();
async function fetchTrend(firstLoad) { async function fetchTrend(firstLoad) {
if (firstLoad || !trendIterator.current) { if (firstLoad || !trendIterator.current) {
trendIterator.current = masto.v1.trends.listStatuses({ trendIterator.current = masto.v1.trends.statuses.list({
limit: LIMIT, limit: LIMIT,
}); });
// Get hashtags // Get hashtags
try { try {
const iterator = masto.v1.trends.listTags(); const iterator = masto.v1.trends.tags.list();
const { value: tags } = await iterator.next(); const { value: tags } = await iterator.next();
console.log(tags); console.log(tags);
setHashtags(tags); setHashtags(tags);
@ -64,8 +64,8 @@ function Trending({ columnMode, ...props }) {
async function checkForUpdates() { async function checkForUpdates() {
try { try {
const results = await masto.v1.trends const results = await masto.v1.trends.statuses
.listStatuses({ .list({
limit: 1, limit: 1,
// NOT SUPPORTED // NOT SUPPORTED
// since_id: latestItem.current, // since_id: latestItem.current,

View file

@ -7,6 +7,7 @@ import multiHashtagTimelineUrl from '../assets/features/multi-hashtag-timeline.j
import nestedCommentsThreadUrl from '../assets/features/nested-comments-thread.jpg'; import nestedCommentsThreadUrl from '../assets/features/nested-comments-thread.jpg';
import logoText from '../assets/logo-text.svg'; import logoText from '../assets/logo-text.svg';
import logo from '../assets/logo.svg'; import logo from '../assets/logo.svg';
import Link from '../components/link'; import Link from '../components/link';
import states from '../utils/states'; import states from '../utils/states';
import useTitle from '../utils/useTitle'; import useTitle from '../utils/useTitle';

View file

@ -1,4 +1,4 @@
import { createClient } from 'masto'; import { createRestAPIClient, createStreamingAPIClient } from 'masto';
import store from './store'; import store from './store';
import { import {
@ -37,14 +37,17 @@ export function initClient({ instance, accessToken }) {
} }
const url = instance ? `https://${instance}` : `https://${DEFAULT_INSTANCE}`; const url = instance ? `https://${instance}` : `https://${DEFAULT_INSTANCE}`;
const client = createClient({ const masto = createRestAPIClient({
url, url,
accessToken, // Can be null accessToken, // Can be null
disableVersionCheck: true, // Allow non-Mastodon instances
timeout: 30_000, // Unfortunatly this is global instead of per-request timeout: 30_000, // Unfortunatly this is global instead of per-request
}); });
client.__instance__ = instance;
const client = {
masto,
instance,
accessToken,
};
apis[instance] = client; apis[instance] = client;
if (!accountApis[instance]) accountApis[instance] = {}; if (!accountApis[instance]) accountApis[instance] = {};
if (accessToken) accountApis[instance][accessToken] = client; if (accessToken) accountApis[instance][accessToken] = client;
@ -55,7 +58,8 @@ export function initClient({ instance, accessToken }) {
// Get the instance information // Get the instance information
// The config is needed for composing // The config is needed for composing
export async function initInstance(client, instance) { export async function initInstance(client, instance) {
const masto = client; console.log('INIT INSTANCE', client, instance);
const { masto, accessToken } = client;
// Request v2, fallback to v1 if fail // Request v2, fallback to v1 if fail
let info; let info;
try { try {
@ -63,7 +67,7 @@ export async function initInstance(client, instance) {
} catch (e) {} } catch (e) {}
if (!info) { if (!info) {
try { try {
info = await masto.v1.instances.fetch(); info = await masto.v1.instance.fetch();
} catch (e) {} } catch (e) {}
} }
if (!info) return; if (!info) return;
@ -91,17 +95,28 @@ export async function initInstance(client, instance) {
store.local.setJSON('instances', instances); store.local.setJSON('instances', instances);
// This is a weird place to put this but here's updating the masto instance with the streaming API URL set in the configuration // This is a weird place to put this but here's updating the masto instance with the streaming API URL set in the configuration
// Reason: Streaming WebSocket URL may change, unlike the standard API REST URLs // Reason: Streaming WebSocket URL may change, unlike the standard API REST URLs
if (streamingApi || streaming) { const supportsWebSocket = 'WebSocket' in window;
if (supportsWebSocket && (streamingApi || streaming)) {
console.log('🎏 Streaming API URL:', streaming || streamingApi); console.log('🎏 Streaming API URL:', streaming || streamingApi);
masto.config.props.streamingApiUrl = streaming || streamingApi; // masto.config.props.streamingApiUrl = streaming || streamingApi;
// Legacy masto.ws
const streamClient = createStreamingAPIClient({
streamingApiUrl: streaming || streamingApi,
accessToken,
implementation: WebSocket,
});
client.streaming = streamClient;
// masto.ws = streamClient;
console.log('🎏 Streaming API client:', client);
} }
} }
// Get the account information and store it // Get the account information and store it
export async function initAccount(client, instance, accessToken, vapidKey) { export async function initAccount(client, instance, accessToken, vapidKey) {
const masto = client; const { masto } = client;
const mastoAccount = await masto.v1.accounts.verifyCredentials(); const mastoAccount = await masto.v1.accounts.verifyCredentials();
console.log('CURRENTACCOUNT SET', mastoAccount.id);
store.session.set('currentAccount', mastoAccount.id); store.session.set('currentAccount', mastoAccount.id);
saveAccount({ saveAccount({
@ -115,7 +130,7 @@ export async function initAccount(client, instance, accessToken, vapidKey) {
// Get preferences // Get preferences
export async function initPreferences(client) { export async function initPreferences(client) {
try { try {
const masto = client; const { masto } = client;
const preferences = await masto.v1.preferences.fetch(); const preferences = await masto.v1.preferences.fetch();
store.account.set('preferences', preferences); store.account.set('preferences', preferences);
} catch (e) { } catch (e) {
@ -134,10 +149,14 @@ export function api({ instance, accessToken, accountID, account } = {}) {
// If instance and accessToken are provided, get the masto instance for that account // If instance and accessToken are provided, get the masto instance for that account
if (instance && accessToken) { if (instance && accessToken) {
return { const client =
masto:
accountApis[instance]?.[accessToken] || accountApis[instance]?.[accessToken] ||
initClient({ instance, accessToken }), initClient({ instance, accessToken });
const { masto, streaming } = client;
return {
masto,
streaming,
client,
authenticated: true, authenticated: true,
instance, instance,
}; };
@ -149,8 +168,12 @@ export function api({ instance, accessToken, accountID, account } = {}) {
for (const instance in accountApis) { for (const instance in accountApis) {
if (accountApis[instance][accessToken]) { if (accountApis[instance][accessToken]) {
console.log('X 2', accountApis, instance, accessToken); console.log('X 2', accountApis, instance, accessToken);
const client = accountApis[instance][accessToken];
const { masto, streaming } = client;
return { return {
masto: accountApis[instance][accessToken], masto,
streaming,
client,
authenticated: true, authenticated: true,
instance, instance,
}; };
@ -160,13 +183,17 @@ export function api({ instance, accessToken, accountID, account } = {}) {
if (account) { if (account) {
const accessToken = account.accessToken; const accessToken = account.accessToken;
const instance = account.instanceURL.toLowerCase().trim(); const instance = account.instanceURL.toLowerCase().trim();
const client = initClient({ instance, accessToken });
const { masto, streaming } = client;
return { return {
masto: initClient({ instance, accessToken }), masto,
streaming,
client,
authenticated: true, authenticated: true,
instance, instance,
}; };
} else { } else {
throw new Error(`Access token ${accessToken} not found`); throw new Error(`Access token not found`);
} }
} }
} }
@ -178,10 +205,14 @@ export function api({ instance, accessToken, accountID, account } = {}) {
if (account) { if (account) {
const accessToken = account.accessToken; const accessToken = account.accessToken;
const instance = account.instanceURL.toLowerCase().trim(); const instance = account.instanceURL.toLowerCase().trim();
return { const client =
masto:
accountApis[instance]?.[accessToken] || accountApis[instance]?.[accessToken] ||
initClient({ instance, accessToken }), initClient({ instance, accessToken });
const { masto, streaming } = client;
return {
masto,
streaming,
client,
authenticated: true, authenticated: true,
instance, instance,
}; };
@ -192,10 +223,13 @@ export function api({ instance, accessToken, accountID, account } = {}) {
// If only instance is provided, get the masto instance for that instance // If only instance is provided, get the masto instance for that instance
if (instance) { if (instance) {
const masto = apis[instance] || initClient({ instance }); const client = apis[instance] || initClient({ instance });
const { masto, streaming, accessToken } = client;
return { return {
masto, masto,
authenticated: !!masto.config.props.accessToken, streaming,
client,
authenticated: !!accessToken,
instance, instance,
}; };
} }
@ -203,9 +237,11 @@ export function api({ instance, accessToken, accountID, account } = {}) {
// If no instance is provided, get the masto instance for the current account // If no instance is provided, get the masto instance for the current account
if (currentAccountApi) { if (currentAccountApi) {
return { return {
masto: currentAccountApi, masto: currentAccountApi.masto,
streaming: currentAccountApi.streaming,
client: currentAccountApi,
authenticated: true, authenticated: true,
instance: currentAccountApi.__instance__, instance: currentAccountApi.instance,
}; };
} }
const currentAccount = getCurrentAccount(); const currentAccount = getCurrentAccount();
@ -215,15 +251,22 @@ export function api({ instance, accessToken, accountID, account } = {}) {
accountApis[instance]?.[accessToken] || accountApis[instance]?.[accessToken] ||
initClient({ instance, accessToken }); initClient({ instance, accessToken });
return { return {
masto: currentAccountApi, masto: currentAccountApi.masto,
streaming: currentAccountApi.streaming,
client: currentAccountApi,
authenticated: true, authenticated: true,
instance, instance,
}; };
} }
// If no instance is provided and no account is logged in, get the masto instance for DEFAULT_INSTANCE // If no instance is provided and no account is logged in, get the masto instance for DEFAULT_INSTANCE
const client =
apis[DEFAULT_INSTANCE] || initClient({ instance: DEFAULT_INSTANCE });
const { masto, streaming } = client;
return { return {
masto: apis[DEFAULT_INSTANCE] || initClient({ instance: DEFAULT_INSTANCE }), masto,
streaming,
client,
authenticated: false, authenticated: false,
instance: DEFAULT_INSTANCE, instance: DEFAULT_INSTANCE,
}; };

View file

@ -1,9 +1,10 @@
import emojifyText from './emojify-text'; import emojifyText from './emojify-text';
import mem from './mem';
const fauxDiv = document.createElement('div'); const fauxDiv = document.createElement('div');
const whitelistLinkClasses = ['u-url', 'mention', 'hashtag']; const whitelistLinkClasses = ['u-url', 'mention', 'hashtag'];
function enhanceContent(content, opts = {}) { function _enhanceContent(content, opts = {}) {
const { emojis, postEnhanceDOM = () => {} } = opts; const { emojis, postEnhanceDOM = () => {} } = opts;
let enhancedContent = content; let enhancedContent = content;
const dom = document.createElement('div'); const dom = document.createElement('div');
@ -250,6 +251,7 @@ function enhanceContent(content, opts = {}) {
return enhancedContent; return enhancedContent;
} }
const enhanceContent = mem(_enhanceContent);
const defaultRejectFilter = [ const defaultRejectFilter = [
// Document metadata // Document metadata

View file

@ -3,6 +3,11 @@ import translationTargetLanguages from '../data/lingva-target-languages';
import localeMatch from './locale-match'; import localeMatch from './locale-match';
import states from './states'; import states from './states';
const locales = [
new Intl.DateTimeFormat().resolvedOptions().locale,
...navigator.languages,
];
function getTranslateTargetLanguage(fromSettings = false) { function getTranslateTargetLanguage(fromSettings = false) {
if (fromSettings) { if (fromSettings) {
const { contentTranslationTargetLanguage } = states.settings; const { contentTranslationTargetLanguage } = states.settings;
@ -11,10 +16,7 @@ function getTranslateTargetLanguage(fromSettings = false) {
} }
} }
return localeMatch( return localeMatch(
[ locales,
new Intl.DateTimeFormat().resolvedOptions().locale,
...navigator.languages,
],
translationTargetLanguages.map((l) => l.code.replace('_', '-')), // The underscore will fail Intl.Locale inside `match` translationTargetLanguages.map((l) => l.code.replace('_', '-')), // The underscore will fail Intl.Locale inside `match`
'en', 'en',
); );

View file

@ -2,7 +2,7 @@ export default function isMastodonLinkMaybe(url) {
const { pathname } = new URL(url); const { pathname } = new URL(url);
return ( return (
/^\/.*\/\d+$/i.test(pathname) || /^\/.*\/\d+$/i.test(pathname) ||
/^\/@[^/]+\/statuses\/\w+$/i.test(pathname) || // GoToSocial /^\/@[^/]+\/(statuses|posts)\/\w+\/?$/i.test(pathname) || // GoToSocial, Takahe
/^\/notes\/[a-z0-9]+$/i.test(pathname) || // Misskey, Calckey /^\/notes\/[a-z0-9]+$/i.test(pathname) || // Misskey, Calckey
/^\/(notice|objects)\/[a-z0-9-]+$/i.test(pathname) // Pleroma /^\/(notice|objects)\/[a-z0-9-]+$/i.test(pathname) // Pleroma
); );

View file

@ -1,5 +1,6 @@
import { match } from '@formatjs/intl-localematcher'; import { match } from '@formatjs/intl-localematcher';
import mem from 'mem';
import mem from './mem';
function _localeMatch(...args) { function _localeMatch(...args) {
// Wrap in try/catch because localeMatcher throws on invalid locales // Wrap in try/catch because localeMatcher throws on invalid locales
@ -10,8 +11,6 @@ function _localeMatch(...args) {
return defaultLocale || false; return defaultLocale || false;
} }
} }
const localeMatch = mem(_localeMatch, { const localeMatch = mem(_localeMatch);
cacheKey: (args) => args.join(),
});
export default localeMatch; export default localeMatch;

5
src/utils/mem.js Normal file
View file

@ -0,0 +1,5 @@
import moize from 'moize';
export default function mem(fn, opts = {}) {
return moize(fn, { ...opts, maxSize: 100 });
}

5
src/utils/pmem.js Normal file
View file

@ -0,0 +1,5 @@
import mem from './mem';
export default function pmem(fn, opts = {}) {
return mem(fn, { isPromise: true, ...opts });
}

View file

@ -34,22 +34,22 @@ import { getCurrentAccount } from './store-utils';
function createBackendPushSubscription(subscription) { function createBackendPushSubscription(subscription) {
const { masto } = api(); const { masto } = api();
return masto.v1.webPushSubscriptions.create(subscription); return masto.v1.push.subscription.create(subscription);
} }
function fetchBackendPushSubscription() { function fetchBackendPushSubscription() {
const { masto } = api(); const { masto } = api();
return masto.v1.webPushSubscriptions.fetch(); return masto.v1.push.subscription.fetch();
} }
function updateBackendPushSubscription(subscription) { function updateBackendPushSubscription(subscription) {
const { masto } = api(); const { masto } = api();
return masto.v1.webPushSubscriptions.update(subscription); return masto.v1.push.subscription.update(subscription);
} }
function removeBackendPushSubscription() { function removeBackendPushSubscription() {
const { masto } = api(); const { masto } = api();
return masto.v1.webPushSubscriptions.remove(); return masto.v1.push.subscription.remove();
} }
// Front-end // Front-end

View file

@ -1,8 +1,8 @@
import mem from 'mem';
import { proxy, subscribe } from 'valtio'; import { proxy, subscribe } from 'valtio';
import { subscribeKey } from 'valtio/utils'; import { subscribeKey } from 'valtio/utils';
import { api } from './api'; import { api } from './api';
import pmem from './pmem';
import store from './store'; import store from './store';
const states = proxy({ const states = proxy({
@ -18,12 +18,16 @@ const states = proxy({
homeLast: null, // Last item in 'home' list homeLast: null, // Last item in 'home' list
homeLastFetchTime: null, homeLastFetchTime: null,
notifications: [], notifications: [],
notificationsLast: store.account.get('notificationsLast') || null, // Last read notification notificationsLast: null, // Last read notification
notificationsNew: [], notificationsNew: [],
notificationsShowNew: false, notificationsShowNew: false,
notificationsLastFetchTime: null, notificationsLastFetchTime: null,
accounts: {}, accounts: {},
reloadStatusPage: 0, reloadStatusPage: 0,
reloadGenericAccounts: {
id: null,
counter: 0,
},
spoilers: {}, spoilers: {},
scrollPositions: {}, scrollPositions: {},
unfurledLinks: {}, unfurledLinks: {},
@ -39,24 +43,21 @@ const states = proxy({
showMediaModal: false, showMediaModal: false,
showShortcutsSettings: false, showShortcutsSettings: false,
showKeyboardShortcutsHelp: false, showKeyboardShortcutsHelp: false,
showGenericAccounts: false,
showMediaAlt: false,
// Shortcuts // Shortcuts
shortcuts: store.account.get('shortcuts') ?? [], shortcuts: [],
// Settings // Settings
settings: { settings: {
autoRefresh: store.account.get('settings-autoRefresh') ?? false, autoRefresh: false,
shortcutsViewMode: store.account.get('settings-shortcutsViewMode') ?? null, shortcutsViewMode: null,
shortcutsColumnsMode: shortcutsColumnsMode: false,
store.account.get('settings-shortcutsColumnsMode') ?? false, boostsCarousel: true,
boostsCarousel: store.account.get('settings-boostsCarousel') ?? true, contentTranslation: true,
contentTranslation: contentTranslationTargetLanguage: null,
store.account.get('settings-contentTranslation') ?? true, contentTranslationHideLanguages: [],
contentTranslationTargetLanguage: contentTranslationAutoInline: false,
store.account.get('settings-contentTranslationTargetLanguage') || null, cloakMode: false,
contentTranslationHideLanguages:
store.account.get('settings-contentTranslationHideLanguages') || [],
contentTranslationAutoInline:
store.account.get('settings-contentTranslationAutoInline') ?? false,
cloakMode: store.account.get('settings-cloakMode') ?? false,
}, },
}); });
@ -140,6 +141,7 @@ export function hideAllModals() {
states.showShortcutsSettings = false; states.showShortcutsSettings = false;
states.showKeyboardShortcutsHelp = false; states.showKeyboardShortcutsHelp = false;
states.showGenericAccounts = false; states.showGenericAccounts = false;
states.showMediaAlt = false;
} }
export function statusKey(id, instance) { export function statusKey(id, instance) {
@ -205,7 +207,7 @@ export function threadifyStatus(status, propInstance) {
if (!prevStatus) { if (!prevStatus) {
if (fetchIndex++ > 3) throw 'Too many fetches for thread'; // Some people revive old threads if (fetchIndex++ > 3) throw 'Too many fetches for thread'; // Some people revive old threads
await new Promise((r) => setTimeout(r, 500 * fetchIndex)); // Be nice to rate limits await new Promise((r) => setTimeout(r, 500 * fetchIndex)); // Be nice to rate limits
// prevStatus = await masto.v1.statuses.fetch(inReplyToId); // prevStatus = await masto.v1.statuses.$.select(inReplyToId).fetch();
prevStatus = await fetchStatus(inReplyToId, masto); prevStatus = await fetchStatus(inReplyToId, masto);
saveStatus(prevStatus, instance, { skipThreading: true }); saveStatus(prevStatus, instance, { skipThreading: true });
} }
@ -227,6 +229,6 @@ export function threadifyStatus(status, propInstance) {
}); });
} }
const fetchStatus = mem((statusID, masto) => { const fetchStatus = pmem((statusID, masto) => {
return masto.v1.statuses.fetch(statusID); return masto.v1.statuses.$select(statusID).fetch();
}); });

View file

@ -11,6 +11,11 @@ export function getAccountByAccessToken(accessToken) {
} }
export function getCurrentAccount() { export function getCurrentAccount() {
if (!window.__IGNORE_GET_ACCOUNT_ERROR__) {
// Track down getCurrentAccount() calls before account-based states are initialized
console.error('getCurrentAccount() called before states are initialized');
if (import.meta.env.DEV) console.trace();
}
const currentAccount = store.session.get('currentAccount'); const currentAccount = store.session.get('currentAccount');
const account = getAccount(currentAccount); const account = getAccount(currentAccount);
return account; return account;

View file

@ -1,17 +1,25 @@
import { useRef } from 'preact/hooks'; import { useRef } from 'preact/hooks';
import { useThrottledCallback } from 'use-debounce';
import useResizeObserver from 'use-resize-observer'; import useResizeObserver from 'use-resize-observer';
export default function useTruncated({ className = 'truncated' } = {}) { export default function useTruncated({ className = 'truncated' } = {}) {
const ref = useRef(); const ref = useRef();
const onResize = useThrottledCallback(({ height }) => {
if (ref.current) {
const { scrollHeight } = ref.current;
let truncated = scrollHeight > height;
if (truncated) {
const { height: _height, maxHeight } = getComputedStyle(ref.current);
const computedHeight = parseInt(maxHeight || _height, 10);
truncated = scrollHeight > computedHeight;
}
ref.current.classList.toggle(className, truncated);
}
}, 300);
useResizeObserver({ useResizeObserver({
ref, ref,
box: 'border-box', box: 'border-box',
onResize: ({ height }) => { onResize,
if (ref.current) {
const { scrollHeight } = ref.current;
ref.current.classList.toggle(className, scrollHeight > height);
}
},
}); });
return ref; return ref;
} }