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
with:
ref: production
- run: git tag -a "'{date +%Y.%m.%d}.{git rev-parse --short HEAD}'" $(git rev-parse HEAD)
- run: git push
- run: git tag "`date +%Y.%m.%d`.`git rev-parse --short HEAD`" $(git rev-parse HEAD)
- 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
.env.dev
src/data/instances-full.json
phanpy-dist.zip
# Nix
.direnv

View file

@ -8,6 +8,7 @@
"index.css$",
".css$",
"<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
- [masto.js](https://github.com/neet/masto.js/) - Mastodon API client
- [Iconify](https://iconify.design/) - Icon library
- [MingCute icons](https://www.mingcute.com/)
- Vanilla CSS - *Yes, I'm old school.*
Some of these may change in the future. The front-end world is ever-changing.

View file

@ -5,11 +5,11 @@
"systems": "systems"
},
"locked": {
"lastModified": 1689068808,
"narHash": "sha256-6ixXo3wt24N/melDWjq70UuHQLxGV8jZvooRanIHXw0=",
"lastModified": 1694529238,
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "919d646de7be200f3bf08cb76ae1f09402b6f9b4",
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
"type": "github"
},
"original": {
@ -20,16 +20,16 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1689413807,
"narHash": "sha256-exuzOvOhGAEKWQKwDuZAL4N8a1I837hH5eocaTcIbLc=",
"lastModified": 1697059129,
"narHash": "sha256-9NJcFF9CEYPvHJ5ckE8kvINvI84SZZ87PvqMbH6pro0=",
"owner": "nixOS",
"repo": "nixpkgs",
"rev": "46ed466081b9cad1125b11f11a2af5cc40b942c7",
"rev": "5e4c2ada4fcd54b99d56d7bd62f384511a7e2593",
"type": "github"
},
"original": {
"owner": "nixOS",
"ref": "nixpkgs-unstable",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"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;
outputs = { self, nixpkgs, flake-utils, ... }: flake-utils.lib.eachDefaultSystem (system:
@ -7,7 +7,7 @@
pkgs = import nixpkgs { inherit system; };
lib = pkgs.lib;
in
{
rec {
packages.default = pkgs.buildNpmPackage {
pname = "dtth-phanpy";
version = "0.1.0";
@ -16,7 +16,7 @@
src = lib.cleanSource ./.;
npmDepsHash = "sha256-tqR3YQ++nJmwDNKIm7uFLhJ5HlAqfeEmJVyynHx3Hzw=";
npmDepsHash = "sha256-LpvZfIzIdgxXg4upcDKm7jbK7CjrRvg//HULO4GDTdU=";
# npmDepsHash = lib.fakeHash;
# DTTH-specific env variables
@ -33,6 +33,7 @@
};
devShells.default = pkgs.mkShell {
inputsFrom = [ packages.default ];
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": {
"@formatjs/intl-localematcher": "~0.4.2",
"@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",
"@szhsin/react-menu": "~4.0.3",
"@uidotdev/usehooks": "~2.2.0",
"dayjs": "~1.11.9",
"@szhsin/react-menu": "~4.1.0",
"@uidotdev/usehooks": "~2.4.0",
"dayjs": "~1.11.10",
"dayjs-twitter": "~0.5.0",
"fast-blurhash": "~1.1.2",
"fast-deep-equal": "~3.1.3",
"idb-keyval": "~6.2.1",
"just-debounce-it": "~3.2.0",
"lz-string": "~1.5.0",
"masto": "~5.11.4",
"mem": "~9.0.2",
"p-retry": "~6.0.0",
"masto": "~6.3.1",
"moize": "~6.1.6",
"p-retry": "~6.1.0",
"p-throttle": "~5.1.0",
"preact": "~10.17.1",
"preact": "~10.18.1",
"react-hotkeys-hook": "~4.4.1",
"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",
"string-length": "5.0.1",
"swiped-events": "~1.1.7",
@ -42,13 +42,13 @@
"valtio": "1.9.0"
},
"devDependencies": {
"@preact/preset-vite": "~2.5.0",
"@preact/preset-vite": "~2.6.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-preset-env": "~9.1.3",
"postcss-preset-env": "~9.2.0",
"twitter-text": "~3.1.0",
"vite": "~4.4.9",
"vite": "~4.4.11",
"vite-plugin-generate-file": "~0.0.4",
"vite-plugin-html-config": "~1.0.11",
"vite-plugin-pwa": "~0.16.5",

View file

@ -161,33 +161,23 @@ self.addEventListener('notificationclick', (event) => {
console.log('NOTIFICATION CLICK payload', payload);
const { badge, body, data, dir, icon, lang, tag, timestamp, title } = payload;
const { access_token, notification_type } = data;
const actions = new Promise((resolve) => {
event.notification.close();
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',
includeUncontrolled: true,
})
.then((clients) => {
});
console.log('NOTIFICATION CLICK clients 1', clients);
if (clients.length && 'navigate' in clients[0]) {
console.log('NOTIFICATION CLICK clients 2', clients);
const bestClient =
clients.find(
(client) =>
client.focused || client.visibilityState === 'visible',
(client) => client.focused || client.visibilityState === 'visible',
) || clients[0];
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) {
console.log('NOTIFICATION CLICK postMessage', bestClient);
bestClient.postMessage?.({
@ -198,15 +188,13 @@ self.addEventListener('notificationclick', (event) => {
bestClient.focus();
} else {
console.log('NOTIFICATION CLICK openWindow', url);
self.clients.openWindow(url);
await self.clients.openWindow(url);
}
// }
} else {
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);
}
:is(.carousel-top-controls, .carousel-controls) {
/* mix-blend-mode: luminosity; */
position: absolute;
left: 0;
left: env(safe-area-inset-left, 0);
@ -1119,10 +1120,9 @@ button.carousel-dot {
button.carousel-dot {
background-color: transparent;
}
.carousel-controls :is(.button, button).carousel-button {
:is(.button, button).carousel-button {
background-color: var(--bg-blur-color);
}
.carousel-controls
:is(.button, button).carousel-button:is(:hover, :focus):not(:active) {
background-color: var(--bg-color);
}
@ -1140,15 +1140,19 @@ button.carousel-dot {
border: 1px solid var(--outline-color);
box-shadow: 0 4px 32px var(--drop-shadow-color);
/* backdrop-filter: blur(12px) invert(0.25); */
transition: background-color 0.2s ease-out;
&:hover {
background-color: var(--bg-color);
}
}
button.carousel-dot {
backdrop-filter: none !important;
border: none;
box-shadow: none;
}
button.carousel-dot[disabled] {
/* button.carousel-dot[disabled] {
pointer-events: none;
}
} */
button.carousel-dot .icon {
transition: all 0.2s;
transform: scale(0.5);
@ -1331,12 +1335,16 @@ body:has(.media-modal-container + .status-deck) .media-post-link {
position: relative;
}
.sheet-max {
width: 90vw;
width: 90dvw;
max-width: none;
height: 90vh;
height: 90dvh;
}
@media (min-width: 40em) {
.sheet {
width: 90vw;
width: 90dvw;
}
}
.sheet .sheet-close {
position: absolute;
border-radius: 0;
@ -1423,6 +1431,10 @@ body:has(.media-modal-container + .status-deck) .media-post-link {
display: inline-block;
margin: 4px;
align-self: center;
&.clickable {
cursor: pointer;
}
}
.tag .icon {
vertical-align: middle;
@ -1742,7 +1754,7 @@ meter.donut[hidden] {
font-weight: 500;
text-shadow: 0 1px 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),
0 6px 16px -3px var(--drop-shadow-color);
}
@ -1750,8 +1762,7 @@ meter.donut[hidden] {
color: var(--text-color);
border-color: var(--link-color);
filter: none !important;
box-shadow: 0 0 0 1px var(--link-text-color),
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 var(--drop-shadow-color);
}

View file

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

View file

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

View file

@ -220,6 +220,7 @@
}
.account-container .actions {
margin-block: 8px;
display: flex;
gap: 8px;
justify-content: space-between;
@ -342,23 +343,82 @@
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%;
color: var(--text-insignificant-color);
background-color: var(--bg-faded-color);
padding: 8px 12px;
--size: 8px;
--original-color: var(--link-color);
&:is(:hover, :focus-within) {
background-color: var(--link-bg-hover-color);
}
}
.posting-stats-bar {
--gap: 0.5px;
--gap-color: var(--outline-color);
height: var(--size);
border-radius: var(--size);
height: var(--posting-stats-size);
border-radius: var(--posting-stats-size);
overflow: hidden;
margin: 8px 0;
box-shadow: inset 0 0 0 1px var(--outline-color),
@ -388,9 +448,9 @@
.posting-stats-legend-item {
display: inline-block;
width: var(--size);
height: var(--size);
border-radius: var(--size);
width: var(--posting-stats-size);
height: var(--posting-stats-size);
border-radius: var(--posting-stats-size);
background-color: var(--text-insignificant-color);
vertical-align: middle;
margin: 0 4px 2px;

View file

@ -1,14 +1,21 @@
import './account-info.css';
import { Menu, MenuDivider, MenuItem, SubMenu } from '@szhsin/react-menu';
import { useEffect, useMemo, useReducer, useRef, useState } from 'preact/hooks';
import { proxy, useSnapshot } from 'valtio';
import {
useCallback,
useEffect,
useMemo,
useReducer,
useRef,
useState,
} from 'preact/hooks';
import { api } from '../utils/api';
import enhanceContent from '../utils/enhance-content';
import getHTMLText from '../utils/getHTMLText';
import handleContentLinks from '../utils/handle-content-links';
import niceDateTime from '../utils/nice-date-time';
import pmem from '../utils/pmem';
import shortenNumber from '../utils/shorten-number';
import showToast from '../utils/show-toast';
import states, { hideAllModals } from '../utils/states';
@ -49,8 +56,64 @@ const MUTE_DURATIONS_LABELS = {
const LIMIT = 80;
const accountInfoStates = proxy({
familiarFollowers: [],
const ACCOUNT_INFO_MAX_AGE = 1000 * 60 * 10; // 10 mins
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({
@ -63,10 +126,10 @@ function AccountInfo({
const { masto } = api({
instance,
});
const { masto: currentMasto } = api();
const [uiState, setUIState] = useState('default');
const isString = typeof account === 'string';
const [info, setInfo] = useState(isString ? null : account);
const snapAccountInfoStates = useSnapshot(accountInfoStates);
const isSelf = useMemo(
() => account.id === store.session.get('currentAccount'),
@ -121,6 +184,7 @@ function AccountInfo({
username,
memorial,
moved,
roles,
} = info || {};
let headerIsAvatar = false;
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 followersIterator = useRef();
const familiarFollowersCache = useRef([]);
async function fetchFollowers(firstLoad) {
if (firstLoad || !followersIterator.current) {
followersIterator.current = masto.v1.accounts.listFollowers(id, {
followersIterator.current = masto.v1.accounts.$select(id).followers.list({
limit: LIMIT,
});
}
@ -153,9 +223,9 @@ function AccountInfo({
// On first load, fetch familiar followers, merge to top of results' `value`
// Remove dups on every fetch
if (firstLoad) {
const familiarFollowers = await masto.v1.accounts.fetchFamiliarFollowers(
id,
);
const familiarFollowers = await masto.v1.accounts
.familiarFollowers(id)
.fetch();
familiarFollowersCache.current = familiarFollowers[0].accounts;
newValue = [
...familiarFollowersCache.current,
@ -184,7 +254,7 @@ function AccountInfo({
const followingIterator = useRef();
async function fetchFollowing(firstLoad) {
if (firstLoad || !followingIterator.current) {
followingIterator.current = masto.v1.accounts.listFollowing(id, {
followingIterator.current = masto.v1.accounts.$select(id).following.list({
limit: LIMIT,
});
}
@ -195,6 +265,51 @@ function AccountInfo({
const LinkOrDiv = standalone ? 'div' : Link;
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 (
<div
class={`account-container ${uiState === 'loading' ? 'skeleton' : ''}`}
@ -229,7 +344,7 @@ function AccountInfo({
<p> </p>
<p> </p>
</div>
<p class="stats">
<div class="stats">
<div>
<span></span> Followers
</div>
@ -240,7 +355,7 @@ function AccountInfo({
<span></span> Posts
</div>
<div>Joined </div>
</p>
</div>
</main>
</>
) : (
@ -381,8 +496,20 @@ function AccountInfo({
<Icon icon="group" /> Group
</span>
)}
{roles?.map((role) => (
<span class="tag">
{role.name}
{!!accountInstance && (
<>
{' '}
<span class="more-insignificant">{accountInstance}</span>
</>
)}
</span>
))}
<div
class="note"
dir="auto"
onClick={handleContentLinks({
instance,
})}
@ -399,6 +526,7 @@ function AccountInfo({
verifiedAt ? 'profile-verified' : ''
}`}
key={name + i}
dir="auto"
>
<b>
<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-inner stats-avatars-bunch">
{(snapAccountInfoStates.familiarFollowers || []).map(
(follower) => (
{familiarFollowers.map((follower) => (
<Avatar
url={follower.avatarStatic}
size="s"
alt={`${follower.displayName} @${follower.acct}`}
squircle={follower?.bot}
/>
),
)}
))}
</span>
</span>
)}
@ -494,11 +620,112 @@ function AccountInfo({
)}
</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
info={info}
instance={instance}
authenticated={authenticated}
standalone={standalone}
onRelationshipChange={onRelationshipChange}
/>
</main>
</>
@ -510,7 +737,12 @@ function AccountInfo({
const FAMILIAR_FOLLOWERS_LIMIT = 3;
function RelatedActions({ info, instance, authenticated, standalone }) {
function RelatedActions({
info,
instance,
authenticated,
onRelationshipChange = () => {},
}) {
if (!info) return null;
const {
masto: currentMasto,
@ -521,7 +753,6 @@ function RelatedActions({ info, instance, authenticated, standalone }) {
const [relationshipUIState, setRelationshipUIState] = useState('default');
const [relationship, setRelationship] = useState(null);
const [postingStats, setPostingStats] = useState();
const { id, acct, url, username, locked, lastStatusAt, note, fields, moved } =
info;
@ -555,7 +786,7 @@ function RelatedActions({ info, instance, authenticated, standalone }) {
// Grab this account from my logged-in instance
const acctHasInstance = info.acct.includes('@');
try {
const results = await currentMasto.v2.search({
const results = await currentMasto.v2.search.fetch({
q: acctHasInstance ? info.acct : `${info.username}@${instance}`,
type: 'accounts',
limit: 1,
@ -584,12 +815,12 @@ function RelatedActions({ info, instance, authenticated, standalone }) {
if (moved) return;
setRelationshipUIState('loading');
accountInfoStates.familiarFollowers = [];
setPostingStats(null);
const fetchRelationships = currentMasto.v1.accounts.fetchRelationships([
currentID,
]);
const fetchRelationships = currentMasto.v1.accounts.relationships.fetch(
{
id: [currentID],
},
);
try {
const relationships = await fetchRelationships;
@ -599,63 +830,7 @@ function RelatedActions({ info, instance, authenticated, standalone }) {
if (relationships.length) {
const relationship = relationships[0];
setRelationship(relationship);
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);
}
}
onRelationshipChange({ relationship, currentID });
}
} catch (e) {
console.error(e);
@ -677,75 +852,9 @@ function RelatedActions({ info, instance, authenticated, standalone }) {
const [showTranslatedBio, setShowTranslatedBio] = useState(false);
const [showAddRemoveLists, setShowAddRemoveLists] = useState(false);
const hasPostingStats = postingStats?.total >= 3;
const accountLink = instance ? `/${instance}/a/${id}` : `/a/${id}`;
return (
<>
{hasPostingStats && (
<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">
<div class="actions">
<span>
{followedBy ? (
<span class="tag">Following you</span>
@ -880,14 +989,15 @@ function RelatedActions({ info, instance, authenticated, standalone }) {
setRelationshipUIState('loading');
(async () => {
try {
const newRelationship =
await currentMasto.v1.accounts.unmute(
currentInfo?.id || id,
);
const newRelationship = await currentMasto.v1.accounts
.$select(currentInfo?.id || id)
.unmute();
console.log('unmuting', newRelationship);
setRelationship(newRelationship);
setRelationshipUIState('default');
showToast(`Unmuted @${username}`);
states.reloadGenericAccounts.id = 'mute';
states.reloadGenericAccounts.counter++;
} catch (e) {
console.error(e);
setRelationshipUIState('error');
@ -927,18 +1037,19 @@ function RelatedActions({ info, instance, authenticated, standalone }) {
(async () => {
try {
const newRelationship =
await currentMasto.v1.accounts.mute(
currentInfo?.id || id,
{
await currentMasto.v1.accounts
.$select(currentInfo?.id || id)
.mute({
duration,
},
);
});
console.log('muting', newRelationship);
setRelationship(newRelationship);
setRelationshipUIState('default');
showToast(
`Muted @${username} for ${MUTE_DURATIONS_LABELS[duration]}`,
);
states.reloadGenericAccounts.id = 'mute';
states.reloadGenericAccounts.counter++;
} catch (e) {
console.error(e);
setRelationshipUIState('error');
@ -971,24 +1082,24 @@ function RelatedActions({ info, instance, authenticated, standalone }) {
(async () => {
try {
if (blocking) {
const newRelationship =
await currentMasto.v1.accounts.unblock(
currentInfo?.id || id,
);
const newRelationship = await currentMasto.v1.accounts
.$select(currentInfo?.id || id)
.unblock();
console.log('unblocking', newRelationship);
setRelationship(newRelationship);
setRelationshipUIState('default');
showToast(`Unblocked @${username}`);
} else {
const newRelationship =
await currentMasto.v1.accounts.block(
currentInfo?.id || id,
);
const newRelationship = await currentMasto.v1.accounts
.$select(currentInfo?.id || id)
.block();
console.log('blocking', newRelationship);
setRelationship(newRelationship);
setRelationshipUIState('default');
showToast(`Blocked @${username}`);
}
states.reloadGenericAccounts.id = 'block';
states.reloadGenericAccounts.counter++;
} catch (e) {
console.error(e);
setRelationshipUIState('error');
@ -1050,14 +1161,14 @@ function RelatedActions({ info, instance, authenticated, standalone }) {
// );
// if (yes) {
newRelationship = await currentMasto.v1.accounts.unfollow(
accountID.current,
);
newRelationship = await currentMasto.v1.accounts
.$select(accountID.current)
.unfollow();
// }
} else {
newRelationship = await currentMasto.v1.accounts.follow(
accountID.current,
);
newRelationship = await currentMasto.v1.accounts
.$select(accountID.current)
.follow();
}
if (newRelationship) setRelationship(newRelationship);
@ -1096,7 +1207,7 @@ function RelatedActions({ info, instance, authenticated, standalone }) {
</MenuConfirm>
)}
</span>
</p>
</div>
{!!showTranslatedBio && (
<Modal
class="light"
@ -1202,9 +1313,9 @@ function AddRemoveListsSheet({ accountID, onClose }) {
(async () => {
try {
const lists = await masto.v1.lists.list();
const listsContainingAccount = await masto.v1.accounts.listLists(
accountID,
);
const listsContainingAccount = await masto.v1.accounts
.$select(accountID)
.lists.list();
console.log({ lists, listsContainingAccount });
setLists(lists);
setListsContainingAccount(listsContainingAccount);
@ -1246,11 +1357,15 @@ function AddRemoveListsSheet({ accountID, onClose }) {
(async () => {
try {
if (inList) {
await masto.v1.lists.removeAccount(list.id, {
await masto.v1.lists
.$select(list.id)
.accounts.remove({
accountIds: [accountID],
});
} else {
await masto.v1.lists.addAccount(list.id, {
await masto.v1.lists
.$select(list.id)
.accounts.create({
accountIds: [accountID],
});
}

View file

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

View file

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

View file

@ -487,7 +487,28 @@
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 {
padding-top: 8px;
display: flex;
@ -495,10 +516,6 @@
flex: 1;
gap: 8px;
}
#media-sheet textarea {
width: 100%;
height: 10em;
}
#media-sheet .media-preview {
border: 2px solid var(--outline-color);
border-radius: 8px;
@ -515,6 +532,7 @@
linear-gradient(-45deg, transparent 75%, var(--img-bg-color) 75%);
background-size: 20px 20px;
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
flex: 0.8;
}
#media-sheet .media-preview > * {
width: 100%;
@ -534,11 +552,11 @@
#media-sheet .media-preview > * {
max-height: none;
}
#media-sheet textarea {
/* #media-sheet textarea {
flex: 1;
min-height: 100%;
height: auto;
}
} */
}
#custom-emojis-sheet {

View file

@ -185,7 +185,7 @@ function Compose({
: visibility,
);
setLanguage(language || prefs.postingDefaultLanguage || DEFAULT_LANG);
setSensitive(sensitive);
setSensitive(sensitive && !!spoilerText);
} else if (editStatus) {
const { visibility, language, sensitive, poll, mediaAttachments } =
editStatus;
@ -197,9 +197,9 @@ function Compose({
setUIState('loading');
(async () => {
try {
const statusSource = await masto.v1.statuses.fetchSource(
editStatus.id,
);
const statusSource = await masto.v1.statuses
.$select(editStatus.id)
.source.fetch();
console.log({ statusSource });
const { text, spoilerText } = statusSource;
textareaRef.current.value = text;
@ -749,9 +749,7 @@ function Compose({
file,
description,
});
return masto.v2.mediaAttachments
.create(params)
.then((res) => {
return masto.v2.media.create(params).then((res) => {
if (res.id) {
attachment.id = res.id;
}
@ -784,6 +782,8 @@ function Compose({
/* 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?
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 = {
status,
@ -818,10 +818,9 @@ function Compose({
let newStatus;
if (editStatus) {
newStatus = await masto.v1.statuses.update(
editStatus.id,
params,
);
newStatus = await masto.v1.statuses
.$select(editStatus.id)
.update(params);
saveStatus(newStatus, instance, {
skipThreading: true,
});
@ -839,6 +838,8 @@ function Compose({
// Close
onClose({
// type: post, reply, edit
type: editStatus ? 'edit' : replyToStatus ? 'reply' : 'post',
newStatus,
instance,
});
@ -933,13 +934,13 @@ function Compose({
performSearch={(params) => {
const { type, q, limit } = params;
if (type === 'accounts') {
return masto.v1.accounts.search({
return masto.v1.accounts.search.list({
q,
limit,
resolve: false,
});
}
return masto.v2.search(params);
return masto.v2.search.fetch(params);
}}
/>
{mediaAttachments?.length > 0 && (
@ -1475,7 +1476,11 @@ function MediaAttachment({
onRemove = () => {},
}) {
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 });
const [description, setDescription] = useState(attachment.description);
const suffixType = type.split('/')[0];
@ -1542,6 +1547,7 @@ function MediaAttachment({
<div class="media-attachment">
<div
class="media-preview"
tabIndex="0"
onClick={() => {
setShowModal(true);
}}
@ -1568,6 +1574,7 @@ function MediaAttachment({
</div>
{showModal && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowModal(false);
@ -1605,7 +1612,20 @@ function MediaAttachment({
<audio src={url} controls />
) : null}
</div>
<div class="media-form">
{descTextarea}
<footer>
<button
type="button"
class="light block"
onClick={() => {
setShowModal(false);
}}
>
Done
</button>
</footer>
</div>
</main>
</div>
</Modal>

View file

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

View file

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

View file

@ -21,6 +21,7 @@ export default function GenericAccounts({ onClose = () => {} }) {
}
const {
id,
heading,
fetchAccounts,
accounts: staticAccounts,
@ -60,6 +61,14 @@ export default function GenericAccounts({ onClose = () => {} }) {
}
}, [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 (
<div id="generic-accounts-container" class="sheet" tabindex="-1">
<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-warning': () => import('@iconify-icons/mingcute/user-warning-line'),
keyboard: () => import('@iconify-icons/mingcute/keyboard-line'),
cloud: () => import('@iconify-icons/mingcute/cloud-line'),
};
function Icon({
@ -126,7 +127,7 @@ function Icon({
}, [iconBlock]);
return (
<div
<span
class={`icon ${className}`}
title={title || alt}
style={{
@ -151,7 +152,7 @@ function Icon({
}}
/>
)}
</div>
</span>
);
}

View file

@ -56,7 +56,7 @@ function ListAddEdit({ list, onClose }) {
let listResult;
if (editMode) {
listResult = await masto.v1.lists.update(list.id, {
listResult = await masto.v1.lists.$select(list.id).update({
title,
replies_policy: repliesPolicy,
exclusive,
@ -141,7 +141,7 @@ function ListAddEdit({ list, onClose }) {
(async () => {
try {
await masto.v1.lists.remove(list.id);
await masto.v1.lists.$select(list.id).remove();
setUIState('default');
onClose?.({
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 { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
@ -6,14 +6,15 @@ import { useHotkeys } from 'react-hotkeys-hook';
import Icon from './icon';
import Link from './link';
import Media from './media';
import MediaAltModal from './media-alt-modal';
import MenuLink from './menu-link';
import Modal from './modal';
import TranslationBlock from './translation-block';
function MediaModal({
mediaAttachments,
statusID,
instance,
lang,
index = 0,
onClose = () => {},
}) {
@ -138,14 +139,19 @@ function MediaModal({
class="media-alt"
hidden={!showControls}
onClick={() => {
setShowMediaAlt(media.description);
setShowMediaAlt({
alt: media.description,
lang,
});
}}
>
<Icon icon="info" />
<span class="media-alt-desc">{media.description}</span>
<span class="alt-badge">ALT</span>
<span class="media-alt-desc" lang={lang} dir="auto">
{media.description}
</span>
</button>
)}
<Media media={media} showOriginal />
<Media media={media} showOriginal lang={lang} />
</div>
);
})}
@ -279,7 +285,8 @@ function MediaModal({
}}
>
<MediaAltModal
alt={showMediaAlt}
alt={showMediaAlt.alt || showMediaAlt}
lang={showMediaAlt?.lang}
onClose={() => setShowMediaAlt(false)}
/>
</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;

View file

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

View file

@ -11,6 +11,7 @@ import AccountSheet from './account-sheet';
import Compose from './compose';
import Drafts from './drafts';
import GenericAccounts from './generic-accounts';
import MediaAltModal from './media-alt-modal';
import MediaModal from './media-modal';
import Modal from './modal';
import ShortcutsSettings from './shortcuts-settings';
@ -50,13 +51,17 @@ export default function Modals() {
null
}
onClose={(results) => {
const { newStatus, instance } = results || {};
const { newStatus, instance, type } = results || {};
states.showCompose = false;
window.__COMPOSE__ = null;
if (newStatus) {
states.reloadStatusPage++;
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,
duration: 10_000, // 10 seconds
onClick: (toast) => {
@ -174,6 +179,24 @@ export default function Modals() {
/>
</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 shortenedDisplayName = trimmedDisplayName
.replace(/(\:(\w|\+|\-)+\:)(?=|[\!\.\?]|$)/g, '') // Remove shortcodes, regex from https://regex101.com/r/iE9uV0/1
.replace(/\s+/g, '') // E.g. "My name" === "myname"
.replace(/[^a-z0-9]/gi, ''); // Remove non-alphanumeric characters
.replace(/\s+/g, ''); // E.g. "My name" === "myname"
const shortenedAlphaNumericDisplayName = shortenedDisplayName.replace(
/[^a-z0-9]/gi,
'',
); // Remove non-alphanumeric characters
if (
!short &&
(trimmedUsername === trimmedDisplayName ||
trimmedUsername === shortenedDisplayName)
trimmedUsername === shortenedDisplayName ||
trimmedUsername === shortenedAlphaNumericDisplayName ||
trimmedUsername.localeCompare?.(shortenedDisplayName, 'en', {
sensitivity: 'base',
}) === 0)
) {
username = null;
}

View file

@ -17,7 +17,7 @@ import { accountsIsDtth, gtsDtthSettings } from '../utils/dtth';
function NavMenu(props) {
const snapStates = useSnapshot(states);
const { instance, authenticated } = api();
const { masto, instance, authenticated } = api();
const [currentAccount, setCurrentAccount] = useState();
const [moreThanOneAccount, setMoreThanOneAccount] = useState(false);
@ -61,6 +61,28 @@ function NavMenu(props) {
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 (
<>
<button
@ -209,6 +231,29 @@ function NavMenu(props) {
>
<Icon icon="group" size="l" /> <span>Accounts&hellip;</span>
</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
onClick={() => {
states.showKeyboardShortcutsHelp = true;

View file

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

View file

@ -58,14 +58,14 @@ const contentText = {
'favourite+reblog+account': (count) =>
`boosted & favourited ${count} of your posts.`,
'favourite+reblog_reply': 'boosted & favourited your reply.',
'admin.signup': 'signed up.',
'admin.report': 'reported a post.',
'admin.sign_up': 'signed up.',
'admin.report': (targetAccount) => <>reported {targetAccount}</>,
};
const AVATARS_LIMIT = 50;
function Notification({ notification, instance, reload, isStatic }) {
const { id, status, account, _accounts, _statuses } = notification;
const { id, status, account, report, _accounts, _statuses } = notification;
let { type } = notification;
// 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') {
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) {

View file

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

View file

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

View file

@ -82,6 +82,8 @@
list-style: none;
display: flex;
justify-content: center;
min-width: 20vw;
flex-basis: 20vw;
}
#shortcuts .tab-bar li a {
-webkit-tap-highlight-color: transparent;
@ -95,7 +97,13 @@
padding: 8px;
text-decoration: none;
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 {
transform: scale(0.95);
@ -171,6 +179,8 @@ shortcuts .tab-bar[hidden] {
}
#shortcuts .tab-bar li {
flex-grow: 0;
min-width: auto;
flex-basis: auto;
}
#shortcuts .tab-bar li a {
padding: 0 16px;

View file

@ -166,8 +166,11 @@
.status.large .status-card :is(.content, .poll, .media-container) {
max-height: 80vh !important;
}
.status-card :is(.content.truncated, .poll, .media-container.truncated) {
.status-card :is(.content, .poll, .media-container) {
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);
}
.status.small
@ -299,7 +302,7 @@
overflow: hidden;
/* text-overflow: ellipsis; */
}
.status > .container > .meta .name-text {
.status > .container > .meta .meta-name {
mask-image: linear-gradient(to left, transparent, black 16px);
flex-grow: 1;
}
@ -334,7 +337,7 @@
.status > .container > .meta a.time:after {
content: '';
position: absolute;
inset: -16px;
inset: -16px -16px -8px;
}
.status > .container > .meta .reply-to {
opacity: 0.5;
@ -457,7 +460,7 @@
.status
.content-container.has-spoiler:not(.show-spoiler)
.spoiler
~ *:not(.media-container, .card),
~ *:not(.media-container, .card, .media-figure-multiple),
.status
.content-container.has-spoiler:not(.show-spoiler)
.spoiler
@ -466,7 +469,7 @@
.status
.content-container.has-spoiler:not(.show-spoiler)
.spoiler
~ .media-container
~ :is(.media-container, .media-figure-multiple)
figcaption {
filter: blur(5px) invert(0.5);
image-rendering: crisp-edges;
@ -480,7 +483,7 @@
.status
.content-container.has-spoiler:not(.show-spoiler)
.spoiler
~ .media-container
~ :is(.media-container, .media-figure-multiple)
.media
> *,
.status
@ -544,7 +547,7 @@
max-height: 40vh;
max-height: 40dvh;
}
.timeline-deck .status .content.truncated {
.timeline-deck .status:not(.truncated .status) .content.truncated {
mask-image: linear-gradient(
to top,
transparent,
@ -552,7 +555,7 @@
black 1.5em
);
}
.timeline-deck .status .content.truncated:after {
.timeline-deck .status:not(.truncated .status) .content.truncated:after {
content: attr(data-read-more);
line-height: 1;
display: inline-block;
@ -708,21 +711,21 @@
figure {
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
/* align-items: flex-end; */
column-gap: 4px;
figcaption {
margin: -2px 0 0;
padding: 0 4px;
align-self: flex-end;
padding: 4px;
font-size: 90%;
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;
cursor: pointer;
white-space: pre-line;
flex-basis: 15em;
flex-grow: 1;
}
}
@ -833,7 +836,7 @@
.status .media:is(:hover, :focus) {
border-color: var(--outline-hover-color);
}
.status .media:active {
.status .media:active:not(:has(button:active)) {
filter: brightness(0.8);
transform: scale(0.99);
}
@ -845,6 +848,22 @@
}
.status .media {
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),
a:focus-visible .status .media img {
@ -874,9 +893,9 @@ body:has(#modal-container .carousel) .status .media img:hover {
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
color: var(--video-fg-color);
background-color: var(--video-bg-color);
box-shadow: inset 0 0 0 2px var(--video-outline-color);
color: var(--media-fg-color);
background-color: var(--media-bg-color);
box-shadow: inset 0 0 0 2px var(--media-outline-color);
display: flex;
place-content: center;
place-items: center;
@ -893,9 +912,9 @@ body:has(#modal-container .carousel) .status .media img:hover {
position: absolute;
bottom: 8px;
right: 8px;
color: var(--video-fg-color);
background-color: var(--video-bg-color);
border: var(--hairline-width) solid var(--video-outline-color);
color: var(--media-fg-color);
background-color: var(--media-bg-color);
border: var(--hairline-width) solid var(--media-outline-color);
border-radius: 4px;
padding: 0 4px;
}
@ -910,9 +929,9 @@ body:has(#modal-container .carousel) .status .media img:hover {
position: absolute;
bottom: 8px;
right: 8px;
color: var(--bg-faded-color);
background-color: var(--text-insignificant-color);
backdrop-filter: blur(6px) saturate(3) invert(0.2);
color: var(--media-fg-color);
background-color: var(--media-bg-color);
border: var(--hairline-width) solid var(--media-outline-color);
border-radius: 4px;
padding: 0 4px;
}
@ -979,6 +998,62 @@ body:has(#modal-container .carousel) .status .media img:hover {
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 {
position: relative;
}
@ -1003,6 +1078,12 @@ body:has(#modal-container .carousel) .status .media img:hover {
font-size: 90%;
z-index: 1;
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 {
overflow: hidden;
@ -1638,3 +1719,37 @@ a.card:is(:hover, :focus):visited {
#reactions-container .reactions-block .reblog-icon {
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,
} from '@szhsin/react-menu';
import { decodeBlurHash } from 'fast-blurhash';
import mem from 'mem';
import pThrottle from 'p-throttle';
import { memo } from 'preact/compat';
import {
@ -22,7 +21,6 @@ import {
import { useHotkeys } from 'react-hotkeys-hook';
import { InView } from 'react-intersection-observer';
import { useLongPress } from 'use-long-press';
import useResizeObserver from 'use-resize-observer';
import { useSnapshot } from 'valtio';
import { snapshot } from 'valtio/vanilla';
@ -43,6 +41,7 @@ import htmlContentLength from '../utils/html-content-length';
import isMastodonLinkMaybe from '../utils/isMastodonLinkMaybe';
import localeMatch from '../utils/locale-match';
import niceDateTime from '../utils/nice-date-time';
import pmem from '../utils/pmem';
import safeBoundingBoxPadding from '../utils/safe-bounding-box-padding';
import shortenNumber from '../utils/shorten-number';
import showToast from '../utils/show-toast';
@ -56,6 +55,7 @@ import Avatar from './avatar';
import Icon from './icon';
import Link from './link';
import Media from './media';
import { isMediaCaptionLong } from './media';
import MenuLink from './menu-link';
import RelativeTime from './relative-time';
import TranslationBlock from './translation-block';
@ -67,13 +67,9 @@ const throttle = pThrottle({
});
function fetchAccount(id, masto) {
try {
return masto.v1.accounts.fetch(id);
} catch (e) {
return Promise.reject(e);
return masto.v1.accounts.$select(id).fetch();
}
}
const memFetchAccount = mem(fetchAccount);
const memFetchAccount = pmem(fetchAccount);
const visibilityText = {
public: 'Public',
@ -390,11 +386,11 @@ function Status({
reblogsCount: reblogsCount + (reblogged ? -1 : 1),
};
if (reblogged) {
const newStatus = await masto.v1.statuses.unreblog(id);
const newStatus = await masto.v1.statuses.$select(id).unreblog();
saveStatus(newStatus, instance);
return true;
} else {
const newStatus = await masto.v1.statuses.reblog(id);
const newStatus = await masto.v1.statuses.$select(id).reblog();
saveStatus(newStatus, instance);
return true;
}
@ -418,11 +414,11 @@ function Status({
reblogsCount: reblogsCount + (reblogged ? -1 : 1),
};
if (reblogged) {
const newStatus = await masto.v1.statuses.unreblog(id);
const newStatus = await masto.v1.statuses.$select(id).unreblog();
saveStatus(newStatus, instance);
return true;
} else {
const newStatus = await masto.v1.statuses.reblog(id);
const newStatus = await masto.v1.statuses.$select(id).reblog();
saveStatus(newStatus, instance);
return true;
}
@ -446,10 +442,10 @@ function Status({
favouritesCount: favouritesCount + (favourited ? -1 : 1),
};
if (favourited) {
const newStatus = await masto.v1.statuses.unfavourite(id);
const newStatus = await masto.v1.statuses.$select(id).unfavourite();
saveStatus(newStatus, instance);
} else {
const newStatus = await masto.v1.statuses.favourite(id);
const newStatus = await masto.v1.statuses.$select(id).favourite();
saveStatus(newStatus, instance);
}
} catch (e) {
@ -470,10 +466,10 @@ function Status({
bookmarked: !bookmarked,
};
if (bookmarked) {
const newStatus = await masto.v1.statuses.unbookmark(id);
const newStatus = await masto.v1.statuses.$select(id).unbookmark();
saveStatus(newStatus, instance);
} else {
const newStatus = await masto.v1.statuses.bookmark(id);
const newStatus = await masto.v1.statuses.$select(id).bookmark();
saveStatus(newStatus, instance);
}
} catch (e) {
@ -484,7 +480,7 @@ function Status({
};
const differentLanguage =
language &&
!!language &&
language !== targetLanguage &&
!localeMatch([language], [targetLanguage]) &&
!contentTranslationHideLanguages.find(
@ -708,9 +704,9 @@ function Status({
<MenuItem
onClick={async () => {
try {
const newStatus = await masto.v1.statuses[
muted ? 'unmute' : 'mute'
](id);
const newStatus = await masto.v1.statuses
.$select(id)
[muted ? 'unmute' : 'mute']();
saveStatus(newStatus, instance);
showToast(muted ? 'Conversation unmuted' : 'Conversation muted');
} catch (e) {
@ -763,7 +759,7 @@ function Status({
// if (yes) {
(async () => {
try {
await masto.v1.statuses.remove(id);
await masto.v1.statuses.$select(id).remove();
const cachedStatus = getStatus(id, instance);
cachedStatus._deleted = true;
showToast('Deleted');
@ -790,8 +786,17 @@ function Status({
x: 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(
(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;
// link detection copied from onContextMenu because here it works
const link = e.target.closest('a');
@ -802,12 +807,13 @@ function Status({
y: clientY,
});
setIsContextMenuOpen(true);
},
}
: null,
{
threshold: 600,
captureEvent: true,
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 (
<article
ref={(node) => {
@ -968,13 +1040,14 @@ function Status({
)}
<div class="container">
<div class="meta">
{/* <span> */}
<span class="meta-name">
<NameText
account={status.account}
instance={instance}
showAvatar={size === 's'}
showAcct={isSizeLarge}
/>
</span>
{/* {inReplyToAccount && !withinContext && size !== 's' && (
<>
{' '}
@ -1191,7 +1264,8 @@ function Status({
}}
refresh={() => {
return masto.v1.polls
.fetch(poll.id)
.$select(poll.id)
.fetch()
.then((pollResponse) => {
states.statuses[sKey].poll = pollResponse;
})
@ -1199,7 +1273,8 @@ function Status({
}}
votePoll={(choices) => {
return masto.v1.polls
.vote(poll.id, {
.$select(poll.id)
.votes.create({
choices,
})
.then((pollResponse) => {
@ -1255,19 +1330,27 @@ function Status({
</button>
)}
{!!mediaAttachments.length && (
<MultipleMediaFigure
lang={language}
enabled={showMultipleMediaCaptions}
captionChildren={captionChildren}
>
<div
ref={mediaContainerRef}
class={`media-container media-eq${mediaAttachments.length} ${
mediaAttachments.length > 2 ? 'media-gt2' : ''
} ${mediaAttachments.length > 4 ? 'media-gt4' : ''}`}
>
{mediaAttachments
.slice(0, isSizeLarge ? undefined : 4)
.map((media, i) => (
{displayedMediaAttachments.map((media, i) => (
<Media
key={media.id}
media={media}
autoAnimate={isSizeLarge}
showCaption={mediaAttachments.length === 1}
lang={language}
altIndex={
showMultipleMediaCaptions && !!media.description && i + 1
}
to={`/${instance}/s/${id}?${
withinContext ? 'media' : 'media-only'
}=${i + 1}`}
@ -1281,6 +1364,7 @@ function Status({
/>
))}
</div>
</MultipleMediaFigure>
)}
{!!card &&
card?.url !== status.url &&
@ -1448,7 +1532,7 @@ function Status({
statusID={showEdited}
instance={instance}
fetchStatusHistory={() => {
return masto.v1.statuses.listHistory(showEdited);
return masto.v1.statuses.$select(showEdited).history.list();
}}
onClose={() => {
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 }) {
const snapStates = useSnapshot(states);
const {
@ -1485,14 +1582,18 @@ function Card({ card, instance }) {
description,
html,
providerName,
providerUrl,
authorName,
authorUrl,
width,
height,
image,
imageDescription,
url,
type,
embedUrl,
language,
publishedAt,
} = card;
/* type
@ -1518,7 +1619,7 @@ function Card({ card, instance }) {
// NOTE: This is for quote post
// (async () => {
// const { masto } = api({ instance });
// const status = await masto.v1.statuses.fetch(id);
// const status = await masto.v1.statuses.$select(id).fetch();
// saveStatus(status, instance);
// setCardStatusID(id);
// })();
@ -1565,7 +1666,7 @@ function Card({ card, instance }) {
width={width}
height={height}
loading="lazy"
alt=""
alt={imageDescription || ''}
onError={(e) => {
try {
e.target.style.display = 'none';
@ -1738,15 +1839,16 @@ function ReactionsModal({ statusID, instance, onClose }) {
(async () => {
try {
if (firstLoad) {
reblogIterator.current = masto.v1.statuses.listRebloggedBy(statusID, {
reblogIterator.current = masto.v1.statuses
.$select(statusID)
.rebloggedBy.list({
limit: REACTIONS_LIMIT,
});
favouriteIterator.current = masto.v1.statuses.listFavouritedBy(
statusID,
{
favouriteIterator.current = masto.v1.statuses
.$select(statusID)
.favouritedBy.list({
limit: REACTIONS_LIMIT,
},
);
});
}
const [{ value: reblogResults }, { value: favouriteResults }] =
await Promise.allSettled([
@ -1976,7 +2078,10 @@ function _unfurlMastodonLink(instance, url) {
if (statusMatch) {
const id = statusMatch[3];
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) {
return {
status,
@ -1989,8 +2094,8 @@ function _unfurlMastodonLink(instance, url) {
}
const { masto } = api({ instance });
const mastoSearchFetch = masto.v2
.search({
const mastoSearchFetch = masto.v2.search
.fetch({
q: url,
type: 'statuses',
resolve: true,
@ -2060,11 +2165,7 @@ function nicePostURL(url) {
);
}
const unfurlMastodonLink = throttle(
mem(_unfurlMastodonLink, {
cacheKey: (instance, url) => `${instance}:${url}`,
}),
);
const unfurlMastodonLink = throttle(_unfurlMastodonLink);
function FilteredStatus({ status, filterInfo, instance, containerProps = {} }) {
const {
@ -2087,7 +2188,7 @@ function FilteredStatus({ status, filterInfo, instance, containerProps = {} }) {
threshold: 600,
captureEvent: true,
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 Link from './link';
import Loader from './loader';
import NavMenu from './nav-menu';
import Status from './status';
const scrollIntoViewOptions = {
block: 'nearest',
inline: 'center',
behavior: 'smooth',
};
function Timeline({
title,
titleComponent,
@ -112,7 +117,7 @@ function Timeline({
}
if (nextItem) {
nextItem.focus();
nextItem.scrollIntoViewIfNeeded?.();
nextItem.scrollIntoView(scrollIntoViewOptions);
}
} else {
// If active status is not in viewport, get the topmost status-link in viewport
@ -122,7 +127,7 @@ function Timeline({
});
if (topmostItem) {
topmostItem.focus();
topmostItem.scrollIntoViewIfNeeded?.();
topmostItem.scrollIntoView(scrollIntoViewOptions);
}
}
});
@ -151,7 +156,7 @@ function Timeline({
}
if (prevItem) {
prevItem.focus();
prevItem.scrollIntoViewIfNeeded?.();
prevItem.scrollIntoView(scrollIntoViewOptions);
}
} else {
// If active status is not in viewport, get the topmost status-link in viewport
@ -161,7 +166,7 @@ function Timeline({
});
if (topmostItem) {
topmostItem.focus();
topmostItem.scrollIntoViewIfNeeded?.();
topmostItem.scrollIntoView(scrollIntoViewOptions);
}
}
});
@ -413,7 +418,7 @@ function Timeline({
const isMiddle = i > 0 && i < items.length - 1;
const isSpoiler = item.sensitive && !!item.spoilerText;
const showCompact =
(isSpoiler && i > 0) ||
(!_differentAuthor && isSpoiler && i > 0) ||
(manyItems &&
isMiddle &&
(type === 'thread' ||

View file

@ -7,6 +7,7 @@ import { useEffect, useRef, useState } from 'preact/hooks';
import sourceLanguages from '../data/lingva-source-languages';
import getTranslateTargetLanguage from '../utils/get-translate-target-language';
import localeCode2Text from '../utils/localeCode2Text';
import pmem from '../utils/pmem';
import Icon from './icon';
import Loader from './loader';
@ -25,7 +26,7 @@ const LINGVA_INSTANCES = [
];
let currentLingvaInstance = 0;
function lingvaTranslate(text, source, target) {
function _lingvaTranslate(text, source, target) {
console.log('TRANSLATE', text, source, target);
const fetchCall = () => {
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,
// });
}
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({
forceTranslate,

View file

@ -47,7 +47,7 @@
--reply-to-color: var(--orange-color);
--reply-to-text-color: #b36200;
--favourite-color: var(--red-color);
--reply-to-faded-color: #ffa60030;
--reply-to-faded-color: #ffa60020;
--outline-color: rgba(128, 128, 128, 0.2);
--outline-hover-color: rgba(128, 128, 128, 0.7);
--divider-color: rgba(0, 0, 0, 0.1);
@ -64,9 +64,9 @@
--close-button-hover-color: rgba(0, 0, 0, 1);
/* Video colors won't change based on color scheme */
--video-fg-color: #f0f2f5;
--video-bg-color: #242526;
--video-outline-color: color-mix(in lch, var(--video-fg-color), transparent);
--media-fg-color: #f0f2f5;
--media-bg-color: #242526;
--media-outline-color: color-mix(in lch, var(--media-fg-color), transparent);
--timing-function: cubic-bezier(0.3, 0.5, 0, 1);
}
@ -92,9 +92,14 @@
--link-light-color: #6494ed99;
--link-faded-color: #6494ed88;
--link-bg-hover-color: #34353799;
--link-visited-color: color-mix(
in lch,
mediumslateblue 70%,
var(--text-color) 30%
);
--reblog-faded-color: #b190f141;
--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);
--bg-blur-color: #24252699;
--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 { useParams, useSearchParams } from 'react-router-dom';
import { useSnapshot } from 'valtio';
@ -31,7 +31,8 @@ function AccountStatuses() {
const results = [];
if (firstLoad) {
const { value: pinnedStatuses } = await masto.v1.accounts
.listStatuses(id, {
.$select(id)
.statuses.list({
pinned: true,
})
.next();
@ -53,7 +54,9 @@ function AccountStatuses() {
}
}
if (firstLoad || !accountStatusesIterator.current) {
accountStatusesIterator.current = masto.v1.accounts.listStatuses(id, {
accountStatusesIterator.current = masto.v1.accounts
.$select(id)
.statuses.list({
limit: LIMIT,
exclude_replies: excludeReplies,
exclude_reblogs: excludeBoosts,
@ -86,14 +89,16 @@ function AccountStatuses() {
useEffect(() => {
(async () => {
try {
const acc = await masto.v1.accounts.fetch(id);
const acc = await masto.v1.accounts.$select(id).fetch();
console.log(acc);
setAccount(acc);
} catch (e) {
console.error(e);
}
try {
const featuredTags = await masto.v1.accounts.listFeaturedTags(id);
const featuredTags = await masto.v1.accounts
.$select(id)
.featuredTags.list(id);
console.log({ featuredTags });
setFeaturedTags(featuredTags);
} catch (e) {
@ -113,7 +118,7 @@ function AccountStatuses() {
<AccountInfo
instance={instance}
account={cachedAccount || id}
fetchAccount={() => masto.v1.accounts.fetch(id)}
fetchAccount={() => masto.v1.accounts.$select(id).fetch()}
authenticated={authenticated}
standalone
/>

View file

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

View file

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

View file

@ -1,6 +1,5 @@
import {
FocusableItem,
Menu,
MenuDivider,
MenuGroup,
MenuItem,
@ -47,7 +46,7 @@ function Hashtags({ columnMode, ...props }) {
const maxID = useRef(undefined);
async function fetchHashtags(firstLoad) {
// if (firstLoad || !hashtagsIterator.current) {
// hashtagsIterator.current = masto.v1.timelines.listHashtag(hashtag, {
// hashtagsIterator.current = masto.v1.timelines.tag.$select(hashtag).list({
// limit: LIMIT,
// any: hashtags.slice(1),
// });
@ -55,8 +54,9 @@ function Hashtags({ columnMode, ...props }) {
// const results = await hashtagsIterator.current.next();
// NOTE: Temporary fix for listHashtag not persisting `any` in subsequent calls.
const results = await masto.v1.timelines
.listHashtag(hashtag, {
const results = await masto.v1.timelines.tag
.$select(hashtag)
.list({
limit: LIMIT,
any: hashtags.slice(1),
maxId: firstLoad ? undefined : maxID.current,
@ -82,8 +82,9 @@ function Hashtags({ columnMode, ...props }) {
async function checkForUpdates() {
try {
const results = await masto.v1.timelines
.listHashtag(hashtag, {
const results = await masto.v1.timelines.tag
.$select(hashtag)
.list({
limit: 1,
any: hashtags.slice(1),
since_id: latestItem.current,
@ -105,7 +106,7 @@ function Hashtags({ columnMode, ...props }) {
useEffect(() => {
(async () => {
try {
const info = await masto.v1.tags.fetch(hashtag);
const info = await masto.v1.tags.$select(hashtag).fetch();
console.log(info);
setInfo(info);
} catch (e) {
@ -164,7 +165,8 @@ function Hashtags({ columnMode, ...props }) {
// return;
// }
masto.v1.tags
.unfollow(hashtag)
.$select(hashtag)
.unfollow()
.then(() => {
setInfo({ ...info, following: false });
showToast(`Unfollowed #${hashtag}`);
@ -178,7 +180,8 @@ function Hashtags({ columnMode, ...props }) {
});
} else {
masto.v1.tags
.follow(hashtag)
.$select(hashtag)
.follow()
.then(() => {
setInfo({ ...info, following: true });
showToast(`Followed #${hashtag}`);
@ -258,11 +261,14 @@ function Hashtags({ columnMode, ...props }) {
onClick={(e) => {
hashtags.splice(i, 1);
hashtags.sort();
navigate(
instance
// navigate(
// instance
// ? `/${instance}/t/${hashtags.join('+')}`
// : `/t/${hashtags.join('+')}`,
// );
location.hash = instance
? `/${instance}/t/${hashtags.join('+')}`
: `/t/${hashtags.join('+')}`,
);
: `/t/${hashtags.join('+')}`;
}}
>
<Icon icon="x" alt="Remove hashtag" class="danger-icon" />
@ -317,7 +323,8 @@ function Hashtags({ columnMode, ...props }) {
}
if (newInstance) {
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 db from '../utils/db';
import groupNotifications from '../utils/group-notifications';
import openCompose from '../utils/open-compose';
import states, { saveStatus } from '../utils/states';
import { getCurrentAccountNS } from '../utils/store-utils';
@ -49,24 +48,6 @@ function Home() {
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();
async function fetchList(firstLoad) {
if (firstLoad || !listIterator.current) {
listIterator.current = masto.v1.timelines.listList(id, {
listIterator.current = masto.v1.timelines.list.$select(id).list({
limit: LIMIT,
});
}
@ -56,7 +56,7 @@ function List(props) {
async function checkForUpdates() {
try {
const results = await masto.v1.timelines.listList(id, {
const results = await masto.v1.timelines.list.$select(id).list({
limit: 1,
since_id: latestItem.current,
});
@ -77,7 +77,7 @@ function List(props) {
useEffect(() => {
(async () => {
try {
const list = await masto.v1.lists.fetch(id);
const list = await masto.v1.lists.$select(id).fetch();
setList(list);
// setTitle(list.title);
} catch (e) {
@ -200,7 +200,9 @@ function ListManageMembers({ listID, onClose }) {
(async () => {
try {
if (firstLoad || !membersIterator.current) {
membersIterator.current = masto.v1.lists.listAccounts(listID, {
membersIterator.current = masto.v1.lists
.$select(listID)
.accounts.list({
limit: MEMBERS_LIMIT,
});
}
@ -274,7 +276,7 @@ function RemoveAddButton({ account, listID }) {
setUIState('loading');
(async () => {
try {
await masto.v1.lists.addAccount(listID, {
await masto.v1.lists.$select(listID).accounts.create({
accountIds: [account.id],
});
setUIState('default');
@ -290,7 +292,7 @@ function RemoveAddButton({ account, listID }) {
(async () => {
try {
await masto.v1.lists.removeAccount(listID, {
await masto.v1.lists.$select(listID).accounts.remove({
accountIds: [account.id],
});
setUIState('default');

View file

@ -92,11 +92,16 @@ function Notifications({ columnMode }) {
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???
return masto.v1.followRequests.list({
try {
return await masto.v1.followRequests.list({
limit: 80,
});
} catch (e) {
// Silently fail
return [];
}
}
const loadFollowRequests = () => {
@ -112,8 +117,13 @@ function Notifications({ columnMode }) {
})();
};
function fetchAnnouncements() {
return masto.v1.announcements.list();
async function fetchAnnouncements() {
try {
return await masto.v1.announcements.list();
} catch (e) {
// Silently fail
return [];
}
}
const loadNotifications = (firstLoad) => {
@ -379,7 +389,10 @@ function Notifications({ columnMode }) {
)}
{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') {
return null;
}
@ -392,7 +405,8 @@ function Notifications({ columnMode }) {
// if notificationDay is yesterday, show "Yesterday"
// if notificationDay is before yesterday, show date
const heading =
notificationDay.toDateString() === yesterdayDate.toDateString()
notificationDay.toDateString() ===
yesterdayDate.toDateString()
? 'Yesterday'
: niceDateTime(currentDay, {
hideTime: true,

View file

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

View file

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

View file

@ -135,3 +135,8 @@
padding-inline: 16px;
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 logo from '../assets/logo.svg';
import Icon from '../components/icon';
import Link from '../components/link';
import RelativeTime from '../components/relative-time';
@ -34,7 +35,7 @@ function Settings({ onClose }) {
const currentTextSize = store.local.get('textSize') || DEFAULT_TEXT_SIZE;
const [prefs, setPrefs] = useState(store.account.get('preferences') || {});
const { masto, authenticated } = api();
const { masto, authenticated, instance } = api();
// 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.
// useEffect(() => {
@ -178,7 +179,8 @@ function Settings({ onClose }) {
<li>
<div>
<label for="posting-privacy-field">
Default visibility
Default visibility{' '}
<Icon icon="cloud" alt="Synced" class="synced-icon" />
</label>
</div>
<div>
@ -217,6 +219,19 @@ function Settings({ onClose }) {
</li>
</ul>
</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>
@ -339,6 +354,7 @@ function Settings({ onClose }) {
<a
href="https://github.com/thedaviddelta/lingva-translate"
target="_blank"
rel="noopener noreferrer"
>
Lingva Translate
</a>
@ -435,6 +451,7 @@ function Settings({ onClose }) {
<a
href="https://hachyderm.io/@phanpy"
// target="_blank"
rel="noopener noreferrer"
onClick={(e) => {
e.preventDefault();
states.showAccount = 'phanpy@hachyderm.io';
@ -458,6 +475,7 @@ function Settings({ onClose }) {
<a
href="https://mastodon.social/@cheeaun"
// target="_blank"
rel="noopener noreferrer"
onClick={(e) => {
e.preventDefault();
states.showAccount = 'cheeaun@mastodon.social';
@ -468,9 +486,26 @@ function Settings({ onClose }) {
</div>
</div>
<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
href="https://github.com/cheeaun/phanpy/blob/main/PRIVACY.MD"
target="_blank"
rel="noopener noreferrer"
>
Privacy Policy
</a>

View file

@ -14,7 +14,7 @@ import {
} from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
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 { useSnapshot } from 'valtio';
@ -54,6 +54,12 @@ function resetScrollPosition(id) {
delete scrollPositions[id];
}
const scrollIntoViewOptions = {
block: 'nearest',
inline: 'center',
behavior: 'smooth',
};
function StatusPage(params) {
const { id } = params;
const { masto, instance } = api({ instance: params.instance });
@ -94,7 +100,7 @@ function StatusPage(params) {
if (!heroStatus && showMedia) {
(async () => {
try {
const status = await masto.v1.statuses.fetch(id);
const status = await masto.v1.statuses.$select(id).fetch();
saveStatus(status, instance);
setHeroStatus(status);
} catch (err) {
@ -135,6 +141,7 @@ function StatusPage(params) {
mediaAttachments={mediaAttachments}
statusID={mediaStatusID || id}
instance={instance}
lang={heroStatus?.language}
index={mediaIndex - 1}
onClose={handleMediaClose}
/>
@ -228,12 +235,15 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
(async () => {
const heroFetch = () =>
pRetry(() => masto.v1.statuses.fetch(id), {
pRetry(() => masto.v1.statuses.$select(id).fetch(), {
retries: 4,
});
const contextFetch = pRetry(() => masto.v1.statuses.fetchContext(id), {
const contextFetch = pRetry(
() => masto.v1.statuses.$select(id).context.fetch(),
{
retries: 8,
});
},
);
const hasStatus = !!snapStates.statuses[sKey];
let heroStatus = snapStates.statuses[sKey];
@ -554,7 +564,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
let nextStatus = allStatusLinks[activeStatusIndex + 1];
if (nextStatus) {
nextStatus.focus();
nextStatus.scrollIntoViewIfNeeded?.();
nextStatus.scrollIntoView(scrollIntoViewOptions);
}
} else {
// 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) {
topmostStatusLink.focus();
topmostStatusLink.scrollIntoViewIfNeeded?.();
topmostStatusLink.scrollIntoView(scrollIntoViewOptions);
}
}
});
@ -588,7 +598,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
let prevStatus = allStatusLinks[activeStatusIndex - 1];
if (prevStatus) {
prevStatus.focus();
prevStatus.scrollIntoViewIfNeeded?.();
prevStatus.scrollIntoView(scrollIntoViewOptions);
}
} else {
// 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) {
topmostStatusLink.focus();
topmostStatusLink.scrollIntoViewIfNeeded?.();
topmostStatusLink.scrollIntoView(scrollIntoViewOptions);
}
}
});
@ -939,7 +949,8 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
setUIState('loading');
(async () => {
try {
const results = await currentMasto.v2.search({
const results =
await currentMasto.v2.search.fetch({
q: heroStatus.url,
type: 'statuses',
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 { useNavigate, useParams } from 'react-router-dom';
import { useSnapshot } from 'valtio';
@ -30,13 +30,13 @@ function Trending({ columnMode, ...props }) {
const trendIterator = useRef();
async function fetchTrend(firstLoad) {
if (firstLoad || !trendIterator.current) {
trendIterator.current = masto.v1.trends.listStatuses({
trendIterator.current = masto.v1.trends.statuses.list({
limit: LIMIT,
});
// Get hashtags
try {
const iterator = masto.v1.trends.listTags();
const iterator = masto.v1.trends.tags.list();
const { value: tags } = await iterator.next();
console.log(tags);
setHashtags(tags);
@ -64,8 +64,8 @@ function Trending({ columnMode, ...props }) {
async function checkForUpdates() {
try {
const results = await masto.v1.trends
.listStatuses({
const results = await masto.v1.trends.statuses
.list({
limit: 1,
// NOT SUPPORTED
// 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 logoText from '../assets/logo-text.svg';
import logo from '../assets/logo.svg';
import Link from '../components/link';
import states from '../utils/states';
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 {
@ -37,14 +37,17 @@ export function initClient({ instance, accessToken }) {
}
const url = instance ? `https://${instance}` : `https://${DEFAULT_INSTANCE}`;
const client = createClient({
const masto = createRestAPIClient({
url,
accessToken, // Can be null
disableVersionCheck: true, // Allow non-Mastodon instances
timeout: 30_000, // Unfortunatly this is global instead of per-request
});
client.__instance__ = instance;
const client = {
masto,
instance,
accessToken,
};
apis[instance] = client;
if (!accountApis[instance]) accountApis[instance] = {};
if (accessToken) accountApis[instance][accessToken] = client;
@ -55,7 +58,8 @@ export function initClient({ instance, accessToken }) {
// Get the instance information
// The config is needed for composing
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
let info;
try {
@ -63,7 +67,7 @@ export async function initInstance(client, instance) {
} catch (e) {}
if (!info) {
try {
info = await masto.v1.instances.fetch();
info = await masto.v1.instance.fetch();
} catch (e) {}
}
if (!info) return;
@ -91,17 +95,28 @@ export async function initInstance(client, instance) {
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
// 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);
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
export async function initAccount(client, instance, accessToken, vapidKey) {
const masto = client;
const { masto } = client;
const mastoAccount = await masto.v1.accounts.verifyCredentials();
console.log('CURRENTACCOUNT SET', mastoAccount.id);
store.session.set('currentAccount', mastoAccount.id);
saveAccount({
@ -115,7 +130,7 @@ export async function initAccount(client, instance, accessToken, vapidKey) {
// Get preferences
export async function initPreferences(client) {
try {
const masto = client;
const { masto } = client;
const preferences = await masto.v1.preferences.fetch();
store.account.set('preferences', preferences);
} 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 && accessToken) {
return {
masto:
const client =
accountApis[instance]?.[accessToken] ||
initClient({ instance, accessToken }),
initClient({ instance, accessToken });
const { masto, streaming } = client;
return {
masto,
streaming,
client,
authenticated: true,
instance,
};
@ -149,8 +168,12 @@ export function api({ instance, accessToken, accountID, account } = {}) {
for (const instance in accountApis) {
if (accountApis[instance][accessToken]) {
console.log('X 2', accountApis, instance, accessToken);
const client = accountApis[instance][accessToken];
const { masto, streaming } = client;
return {
masto: accountApis[instance][accessToken],
masto,
streaming,
client,
authenticated: true,
instance,
};
@ -160,13 +183,17 @@ export function api({ instance, accessToken, accountID, account } = {}) {
if (account) {
const accessToken = account.accessToken;
const instance = account.instanceURL.toLowerCase().trim();
const client = initClient({ instance, accessToken });
const { masto, streaming } = client;
return {
masto: initClient({ instance, accessToken }),
masto,
streaming,
client,
authenticated: true,
instance,
};
} 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) {
const accessToken = account.accessToken;
const instance = account.instanceURL.toLowerCase().trim();
return {
masto:
const client =
accountApis[instance]?.[accessToken] ||
initClient({ instance, accessToken }),
initClient({ instance, accessToken });
const { masto, streaming } = client;
return {
masto,
streaming,
client,
authenticated: true,
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 (instance) {
const masto = apis[instance] || initClient({ instance });
const client = apis[instance] || initClient({ instance });
const { masto, streaming, accessToken } = client;
return {
masto,
authenticated: !!masto.config.props.accessToken,
streaming,
client,
authenticated: !!accessToken,
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 (currentAccountApi) {
return {
masto: currentAccountApi,
masto: currentAccountApi.masto,
streaming: currentAccountApi.streaming,
client: currentAccountApi,
authenticated: true,
instance: currentAccountApi.__instance__,
instance: currentAccountApi.instance,
};
}
const currentAccount = getCurrentAccount();
@ -215,15 +251,22 @@ export function api({ instance, accessToken, accountID, account } = {}) {
accountApis[instance]?.[accessToken] ||
initClient({ instance, accessToken });
return {
masto: currentAccountApi,
masto: currentAccountApi.masto,
streaming: currentAccountApi.streaming,
client: currentAccountApi,
authenticated: true,
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 {
masto: apis[DEFAULT_INSTANCE] || initClient({ instance: DEFAULT_INSTANCE }),
masto,
streaming,
client,
authenticated: false,
instance: DEFAULT_INSTANCE,
};

View file

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

View file

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

View file

@ -2,7 +2,7 @@ export default function isMastodonLinkMaybe(url) {
const { pathname } = new URL(url);
return (
/^\/.*\/\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
/^\/(notice|objects)\/[a-z0-9-]+$/i.test(pathname) // Pleroma
);

View file

@ -1,5 +1,6 @@
import { match } from '@formatjs/intl-localematcher';
import mem from 'mem';
import mem from './mem';
function _localeMatch(...args) {
// Wrap in try/catch because localeMatcher throws on invalid locales
@ -10,8 +11,6 @@ function _localeMatch(...args) {
return defaultLocale || false;
}
}
const localeMatch = mem(_localeMatch, {
cacheKey: (args) => args.join(),
});
const localeMatch = mem(_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) {
const { masto } = api();
return masto.v1.webPushSubscriptions.create(subscription);
return masto.v1.push.subscription.create(subscription);
}
function fetchBackendPushSubscription() {
const { masto } = api();
return masto.v1.webPushSubscriptions.fetch();
return masto.v1.push.subscription.fetch();
}
function updateBackendPushSubscription(subscription) {
const { masto } = api();
return masto.v1.webPushSubscriptions.update(subscription);
return masto.v1.push.subscription.update(subscription);
}
function removeBackendPushSubscription() {
const { masto } = api();
return masto.v1.webPushSubscriptions.remove();
return masto.v1.push.subscription.remove();
}
// Front-end

View file

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

View file

@ -11,6 +11,11 @@ export function getAccountByAccessToken(accessToken) {
}
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 account = getAccount(currentAccount);
return account;

View file

@ -1,17 +1,25 @@
import { useRef } from 'preact/hooks';
import { useThrottledCallback } from 'use-debounce';
import useResizeObserver from 'use-resize-observer';
export default function useTruncated({ className = 'truncated' } = {}) {
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({
ref,
box: 'border-box',
onResize: ({ height }) => {
if (ref.current) {
const { scrollHeight } = ref.current;
ref.current.classList.toggle(className, scrollHeight > height);
}
},
onResize,
});
return ref;
}