Compare commits

...

92 commits

Author SHA1 Message Date
Natsu Kagami 71dcf865c7
Don't statically pin the size of the carousel 2024-04-02 23:14:47 +02:00
Natsu Kagami fc45850396
Link to GtS settings when we know we are on GtS 2024-04-02 23:14:47 +02:00
Natsu Kagami 8a6fa4fe0f
Don't login automatically, allow multiple accounts to be added 2024-04-02 23:14:47 +02:00
Natsu Kagami 98574817d8
Make main column bigger 2024-04-02 23:14:47 +02:00
Natsu Kagami 741549d401
Add a bit more touch 2024-04-02 23:14:47 +02:00
Natsu Kagami f7b2770866
Force display instance
Because I don't like this decision from Phanpy
2024-04-02 23:14:47 +02:00
Natsu Kagami a541113c7e
Automatically put people into DTTHDon login 2024-04-02 23:14:47 +02:00
Natsu Kagami ded6e374d3
Add some DTTH notice 2024-04-02 23:14:47 +02:00
Natsu Kagami 67af5e09c1
Incorporate commit hash 2024-04-02 23:14:46 +02:00
Natsu Kagami 8ed17cdc0b
Get flakes to work 2024-04-02 23:14:42 +02:00
Lim Chee Aun 671d2c9bb1 Less wider submenu 2024-03-28 18:22:29 +08:00
Lim Chee Aun 49fa48bd28 Test if this fixes submenu not opening 2024-03-28 18:22:03 +08:00
Lim Chee Aun 32fb406629 Better shift, but not dynamic 2024-03-28 12:18:25 +08:00
Lim Chee Aun 6950698935 Color space works differently in different browsers 2024-03-28 12:13:38 +08:00
Lim Chee Aun fd9d8059bc Handle info with menu dropdown for profile page 2024-03-28 00:25:10 +08:00
Lim Chee Aun 3b975e899b Try use smaller dimension for fine pointers 2024-03-28 00:23:31 +08:00
Lim Chee Aun b1950046d4 Better alignment for poll radios/checkboxes 2024-03-27 22:08:56 +08:00
Lim Chee Aun d2af509eaf Hacky way to show on-screen keyboard
Doesn't work some of the time.
2024-03-27 21:22:47 +08:00
Lim Chee Aun 311160983f Experiment hide some visibility icons 2024-03-27 19:09:01 +08:00
Lim Chee Aun 9d7d5df7f2 Fix sudden Chrome CSS bug with text-shadow affecting underlines 2024-03-27 16:17:09 +08:00
Lim Chee Aun 927430853a Fix CW-ed images from QPs not cloaked 2024-03-27 16:03:15 +08:00
Lim Chee Aun 1692637e22 Possibly fix weird race conditions
No idea how this happen at all
2024-03-27 14:58:32 +08:00
Lim Chee Aun 2bc24cc495 Pass in postID for Boosted/Liked sheet here too 2024-03-27 10:19:01 +08:00
Lim Chee Aun 66e58c74ef Shazam the filtered notifications 2024-03-27 10:18:34 +08:00
Lim Chee Aun e3591514a1 Use acct instead of username 2024-03-27 10:18:12 +08:00
Lim Chee Aun 4abb1aeaed Fix poll got false value 2024-03-27 09:46:37 +08:00
Lim Chee Aun 7cac17a043 Need Loader fallbacks 2024-03-27 08:09:24 +08:00
Lim Chee Aun 7049166b40 Finally facing the consequences of hacky code
By fixing it with more hacky code
2024-03-26 23:45:22 +08:00
Lim Chee Aun 0a695410d9 Cloak the buttons in filtered notifications 2024-03-26 23:44:18 +08:00
Lim Chee Aun d671178c02 Update copies for severed relationships
Ref: https://github.com/mastodon/mastodon/pull/29731
2024-03-26 19:47:03 +08:00
Lim Chee Aun 67a05450cf Test this lazy shazam 2024-03-26 16:35:02 +08:00
Lim Chee Aun 438b520970 Fix sudden weird underline bug 2024-03-26 13:49:14 +08:00
Lim Chee Aun c8c96f08ac Another attempt to conditional load Intl.Segmenter polyfill 2024-03-25 19:31:25 +08:00
Lim Chee Aun c9bbca9e11 Might as well go further into custom emoji reactions
But still MVP-ish. Misskey emoji shortcodes ain't going to work tho'
2024-03-25 17:58:56 +08:00
Lim Chee Aun 39800e771c Add Mangane 2024-03-25 12:06:03 +08:00
Lim Chee Aun b1c81f7d71 Preliminary support for emoji reaction notifications
Note: pleroma:emoji_reaction is not tested.
2024-03-25 12:05:49 +08:00
Lim Chee Aun 53e9aac14f Show chevron to hint dropdown 2024-03-25 10:26:37 +08:00
Lim Chee Aun cc268019a0 Upgrade dependencies 2024-03-25 10:13:42 +08:00
Lim Chee Aun 9d16c6c12a Fix policy change not working for push notifications
1. Turns out `policy` needs to be inside `data` hash
2. namedItem(policy) → namedItem('policy')

Super embarrassed that these bugs exist for 7 months since push notifications release.
2024-03-25 09:20:51 +08:00
Lim Chee Aun 27a7bc7627 Edit profile now includes extra fields 2024-03-24 23:39:45 +08:00
Lim Chee Aun 1a2914362f Very, very simple Edit Profile sheet. 2024-03-24 20:49:02 +08:00
Lim Chee Aun 9c8aff6d32 Show post preview inside Boosted/Liked by modal
And show the menu in more places
2024-03-24 17:24:47 +08:00
Lim Chee Aun 6816a4b64a Port the tooltip stuff to other link cards 2024-03-24 16:53:33 +08:00
Lim Chee Aun 13f5621488 Fix char counter not showing properly on Firefox 2024-03-24 16:37:58 +08:00
Lim Chee Aun fd59a39021 Preliminary support for severed relationships notifications
Reference: https://github.com/mastodon/mastodon/pull/27511

This is done purely based on the above codebase without real testing.
2024-03-24 14:13:58 +08:00
Lim Chee Aun c19096ab1b Try no split CSS 2024-03-24 10:13:51 +08:00
Lim Chee Aun 0fbc566454 Fix this somehow-partially implemented dot shortcut 2024-03-24 00:21:41 +08:00
Lim Chee Aun f6a9f7807e Allow Lists to be in Shortcuts (except columns)
…and all various Lists-related improvements
2024-03-23 23:52:05 +08:00
Lim Chee Aun 8378d6fc1d Upgrade dependencies 2024-03-23 15:00:13 +08:00
Lim Chee Aun 5ccf8b6842 Show published dates for cards 2024-03-23 12:26:50 +08:00
Lim Chee Aun d6b65d0413 Better red color for danger menus 2024-03-23 12:26:22 +08:00
Lim Chee Aun 8eb67f469c Add Enable/Disable notifications/boosts for accounts 2024-03-23 12:26:01 +08:00
Lim Chee Aun 717633e422 Filters, finally. 2024-03-23 01:07:24 +08:00
Lim Chee Aun f6c2097a89 Fix beyond to date range formatting 2024-03-22 09:33:32 +08:00
Lim Chee Aun 5695b3ca1e Fix alignment issues with the checkboxes 2024-03-21 08:59:07 +08:00
Lim Chee Aun 15c113ecb1 Reduce brightness
iOS seems to HDR-ify it and it's so annoyingly brighter
2024-03-20 14:30:07 +08:00
Lim Chee Aun 4a75d6f172 Fix flex issues 2024-03-20 11:18:56 +08:00
Lim Chee Aun 8f43099840 More conditional menu dividers
Srsly need better way to render these dividers
2024-03-20 11:04:38 +08:00
Lim Chee Aun a2743f9940 This got prettier-ed 2024-03-20 11:04:38 +08:00
Lim Chee Aun 4c2210c68b MVP-ish filtered notifications UI 2024-03-20 11:04:38 +08:00
Lim Chee Aun da909e4084 Fix wrong filtered counts due to grouped boosts 2024-03-20 11:04:38 +08:00
Lim Chee Aun 552ad249e5 Clean up the usernames 2024-03-20 11:04:38 +08:00
Chee Aun 9a5704ee95
Merge pull request #464 from snail-coupe/phanpy-crmbl-uk
Update README.md - adding another instance
2024-03-18 09:02:03 +08:00
snail-coupe c7f68c8971
Update README.md - adding another instance 2024-03-17 21:31:26 +00:00
Lim Chee Aun e8219e458d Try this font settings out.
Depends on system font's capabilities, so may not work.
2024-03-16 20:02:20 +08:00
Lim Chee Aun 6157ee105c Fix "hide"-filtered post bug again 2024-03-16 18:45:59 +08:00
Lim Chee Aun 4718ef36b0 Need one more detail: site version 2024-03-16 17:49:41 +08:00
Lim Chee Aun 2723ef4593 Attempt to fix wrong boosts count 2024-03-16 13:36:23 +08:00
Chee Aun d1965a84b5
Merge pull request #461 from Vinnl/ellipsis-tooltip
Add tooltip for truncated preview text
2024-03-16 13:33:28 +08:00
Lim Chee Aun c7762cc56f Upgrade dependencies 2024-03-16 10:12:34 +08:00
Vincent cf05568e0c
Add tooltip for truncated preview text
Expose the full content of preview text that might get truncated in
their tooltips.
2024-03-15 18:06:56 +01:00
Lim Chee Aun 69c47489e3 Fix some at-mentions not handled 2024-03-15 18:20:45 +08:00
Lim Chee Aun 861ad83423 More keyboard shortcuts for Catch-up 2024-03-15 18:06:52 +08:00
Lim Chee Aun cd3ed64e48 Show relative time if boosting/quoting old post 2024-03-15 16:02:33 +08:00
Lim Chee Aun 2e28c147b9 Scope the keyboard shortcuts in Catch-up 2024-03-15 09:05:05 +08:00
Lim Chee Aun fef033b282 Show relative time if replying to old post
Ref: https://blog.joinmastodon.org/2023/11/improving-the-quality-of-conversations-on-mastodon/
2024-03-13 13:30:58 +08:00
Lim Chee Aun 3dbbba0be2 Fix captioning turned on even when showCaption = false 2024-03-12 08:14:07 +08:00
Lim Chee Aun 0b8cbbef51 Consider the safe areas 2024-03-11 19:04:08 +08:00
Lim Chee Aun f72ec0aba5 Scroll up too if changing author 2024-03-11 12:21:15 +08:00
Lim Chee Aun d63e6c87c4 Potential perf improvements for canvas 2024-03-10 23:25:07 +08:00
Lim Chee Aun f5ea96a093 Merge dup boosts in Catch-up 2024-03-10 23:24:17 +08:00
Lim Chee Aun 0e1be5dbdc MVP-ish initial implementation of Quote
The menuExtras is hacky, I know.
2024-03-09 21:29:44 +08:00
Lim Chee Aun 4843970e1b Custom context menu if link has hash 2024-03-09 17:01:50 +08:00
Lim Chee Aun a0367f4860 Basic j/k/o/enter shortcuts for Notifications page 2024-03-08 16:25:23 +08:00
Lim Chee Aun 687a08b2a4 Forgot to add 'k' lol
Might as well add 'h' and 'l', & fix the selected author focusing issue
2024-03-08 14:53:38 +08:00
Lim Chee Aun ac07479edd Fix wrong account shown for multiple same-username links 2024-03-08 14:52:31 +08:00
Lim Chee Aun 306a96eec3 Need uppercase C,else it'll be true instead of false
🤦‍♂️🤦‍♂️🤦‍♂️🤦‍♂️🤦‍♂️🤦‍♂️🤦‍♂️🤦‍♂️🤦‍♂️🤦‍♂️🤦‍♂️🤦‍♂️🤦‍♂️🤦‍♂️🤦‍♂️🤦‍♂️🤦‍♂️🤦‍♂️🤦‍♂️🤦‍♂️🤦‍♂️
2024-03-07 16:33:56 +08:00
Lim Chee Aun 061d769901 Test fix race-condition for new notifications 2024-03-07 16:06:08 +08:00
Lim Chee Aun cf1c10b338 Show text from poll too 2024-03-07 12:34:38 +08:00
Lim Chee Aun 7f6ef4ff96 Better copy for embed post 2024-03-07 09:05:52 +08:00
Lim Chee Aun ce190cbc50 Lock icon for locked profiles 2024-03-07 09:05:40 +08:00
Lim Chee Aun e7e4f15234 Need extra check on domain 2024-03-06 22:01:13 +08:00
68 changed files with 4158 additions and 825 deletions

View file

@ -10,6 +10,7 @@ assignees: ''
**Describe the bug**
A clear and concise description of what the bug is.
- Which site: [e.g. dev.phanpy.social OR phanpy.social]
- Which site version: [On Phanpy, go to Settings -> About]
- Which instance: [e.g. mastodon.social]
**To Reproduce**

6
.gitignore vendored
View file

@ -26,4 +26,8 @@ dist-ssr
# Custom
.env.dev
phanpy-dist.zip
phanpy-dist.tar.gz
phanpy-dist.tar.gz
# Nix
.direnv
result

View file

@ -43,7 +43,7 @@ Everything is designed and engineered following my taste and vision. This is a p
- **Status actions (reply, boost, favourite, bookmark, etc) are hidden by default**.<br>They only appear in individual status page. This is to reduce clutter and distraction. It may result in lower engagement, but we're not chasing numbers here.
- **Boost is represented with the rocket icon**.<br>The green double arrow icon (retweet for Twitter) doesn't look right for the term "boost". Green rocket looks weird, so I use purple.
- **Short usernames (`@username`) are displayed in timelines, instead of the full account username (`@username@instance`)**.<br>Despite the [guideline](https://docs.joinmastodon.org/api/guidelines/#username) mentioned that "Decentralization must be transparent to the user", I don't think we should shove it to the face every single time. There are also some [screen-reader-related accessibility concerns](https://twitter.com/lifeofablindgrl/status/1595864647554502656) with the full username, though this web app is unfortunately not accessible yet.
- ~~**Short usernames (`@username`) are displayed in timelines, instead of the full account username (`@username@instance`)**.<br>Despite the [guideline](https://docs.joinmastodon.org/api/guidelines/#username) mentioned that "Decentralization must be transparent to the user", I don't think we should shove it to the face every single time. There are also some [screen-reader-related accessibility concerns](https://twitter.com/lifeofablindgrl/status/1595864647554502656) with the full username, though this web app is unfortunately not accessible yet.~~ DTTHDon fork displays the full username by default.
- **No autoplay for video/GIF/whatever in timeline**.<br>The timeline is already a huge mess with lots of people, brands, news and media trying to grab your attention. Let's not make it worse. (Current exception now would be animated emojis.)
- **Hash-based URLs**.<br>This web app is not meant to be a full-fledged replacement to Mastodon's existing front-end. There's no SEO, database, serverless or any long-running servers. I could be wrong one day.
@ -200,6 +200,7 @@ These are self-hosted by other wonderful folks.
- [phanpy.bauxite.tech](https://phanpy.bauxite.tech) by [@b4ux1t3@hachyderm.io](https://hachyderm.io/@b4ux1t3)
- [phanpy.hear-me.social](https://phanpy.hear-me.social) by [@admin@hear-me.social](https://hear-me.social/@admin)
- [phanpy.fulda.social](https://phanpy.fulda.social) by [@Ganneff@fulda.social](https://fulda.social/@Ganneff)
- [phanpy.crmbl.uk](https://phanpy.crmbl.uk) by [@snail@crmbl.uk](https://mstdn.crmbl.uk/@snail)
> Note: Add yours by creating a pull request.
@ -250,6 +251,7 @@ And here I am. Building a Mastodon web client.
- [Statuzer](https://statuzer.com/)
- [Tusked](https://tusked.app/)
- [Mastodon Glitch Edition (standalone frontend)](https://iceshrimp.dev/iceshrimp/masto-fe-standalone)
- [Mangane](https://github.com/BDX-town/Mangane)
- [More...](https://github.com/hueyy/awesome-mastodon/#clients)
## 💁‍♂️ Notice to all other social media client developers

61
flake.lock Normal file
View file

@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1694529238,
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1711703276,
"narHash": "sha256-iMUFArF0WCatKK6RzfUJknjem0H9m4KgorO/p3Dopkk=",
"owner": "nixOS",
"repo": "nixpkgs",
"rev": "d8fe5e6c92d0d190646fb9f1056741a229980089",
"type": "github"
},
"original": {
"owner": "nixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

42
flake.nix Normal file
View file

@ -0,0 +1,42 @@
{
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:
let
pkgs = import nixpkgs { inherit system; };
lib = pkgs.lib;
in
rec {
packages.default = pkgs.buildNpmPackage {
pname = "dtth-phanpy";
version = "0.1.0";
nativeBuildInputs = with pkgs; [ git ];
ESBUILD_BINARY_PATH = lib.getExe pkgs.esbuild;
src = lib.cleanSource ./.;
npmFlags = [ "--legacy-peer-deps" ];
npmDepsHash = "sha256-98FVCq5T3hkcEJ2uUMsUHVlNjm4X6IHrAtDp4OhMtoI=";
# npmDepsHash = lib.fakeHash;
# DTTH-specific env variables
PHANPY_CLIENT_NAME = "DTTH Phanpy";
PHANPY_CLIENT_ID = "ch.dtth.phanpy";
PHANPY_WEBSITE = "https://social.dtth.ch";
installPhase = ''
runHook preInstall
mkdir -p $out/lib
cp -r dist $out/lib/phanpy
runHook postInstall
'';
};
devShells.default = pkgs.mkShell {
inputsFrom = [ packages.default ];
buildInputs = with pkgs; [ nodejs ];
};
});
}

929
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -27,11 +27,11 @@
"idb-keyval": "~6.2.1",
"just-debounce-it": "~3.2.0",
"lz-string": "~1.5.0",
"masto": "~6.6.4",
"masto": "~6.7.0",
"moize": "~6.1.6",
"p-retry": "~6.2.0",
"p-throttle": "~6.1.0",
"preact": "~10.19.6",
"preact": "~10.20.1",
"react-hotkeys-hook": "~4.5.0",
"react-intersection-observer": "~9.8.1",
"react-quick-pinch-zoom": "~5.1.0",
@ -46,16 +46,16 @@
"valtio": "1.13.2"
},
"devDependencies": {
"@preact/preset-vite": "~2.8.1",
"@preact/preset-vite": "~2.8.2",
"@trivago/prettier-plugin-sort-imports": "~4.3.0",
"postcss": "~8.4.35",
"postcss": "~8.4.38",
"postcss-dark-theme-class": "~1.2.1",
"postcss-preset-env": "~9.4.0",
"postcss-preset-env": "~9.5.2",
"twitter-text": "~3.1.0",
"vite": "~5.1.5",
"vite": "~5.2.6",
"vite-plugin-generate-file": "~0.1.1",
"vite-plugin-html-config": "~1.0.11",
"vite-plugin-pwa": "~0.19.2",
"vite-plugin-pwa": "~0.19.7",
"vite-plugin-remove-console": "~2.2.0",
"workbox-cacheable-response": "~7.0.0",
"workbox-expiration": "~7.0.0",

View file

@ -34,6 +34,8 @@ a.mention span {
text-decoration-color: inherit;
text-decoration-thickness: 2px;
text-underline-offset: 2px;
font-variant-numeric: slashed-zero;
font-feature-settings: 'ss01';
}
/* a.mention:has(span).hashtag {
color: var(--link-light-color);
@ -293,7 +295,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
video,
img,
audio {
min-height: 88px; /* for extreme dimensions */
min-height: var(--pointer-min-dimension); /* for extreme dimensions */
}
}
}
@ -1788,6 +1790,7 @@ body > .szh-menu-container {
animation-duration: 0.3s;
animation-timing-function: ease-in-out;
width: auto;
min-width: min(12em, 90vw);
}
.szh-menu .footer {
margin: 8px 0 -8px;
@ -1922,11 +1925,11 @@ body > .szh-menu-container {
.szh-menu__item:not(.szh-menu__item--disabled):not(
.szh-menu__item--hover
).danger {
color: var(--red-color);
color: var(--red-text-color);
}
.szh-menu
.szh-menu__item.danger:not(.szh-menu__item--disabled).szh-menu__item--hover {
background-color: var(--red-color);
background-color: var(--red-text-color);
}
.szh-menu
.szh-menu__item:not(.szh-menu__item--disabled):not(
@ -2023,71 +2026,86 @@ body > .szh-menu-container {
text-shadow: none;
}
/* DONUT METER */
/* CHAR COUNTER */
meter.donut {
appearance: none;
}
meter.donut::-webkit-meter-inner-element,
meter.donut::-webkit-meter-bar,
meter.donut::-webkit-meter-optimum-value,
meter.donut::-webkit-meter-suboptimum-value,
meter.donut::-webkit-meter-even-less-good-value {
display: none;
}
meter.donut::-moz-meter-bar {
background: transparent;
}
meter.donut {
position: relative;
.char-counter {
--dimension: 24px;
--border-width: 2px;
--middle-circle-radius: calc(var(--dimension) / 2 - var(--border-width));
width: var(--dimension);
height: var(--dimension);
border-radius: 50%;
--fill: calc(var(--percentage) * 1%);
--color: var(--link-color);
--middle-circle: radial-gradient(
circle at 50% 50%,
var(--bg-color) var(--middle-circle-radius),
transparent var(--middle-circle-radius)
);
background-image: var(--middle-circle),
conic-gradient(var(--color) var(--fill), var(--outline-color) 0);
transform: scale(0.7);
transition: transform 0.2s ease-in-out;
}
meter.donut.warning {
--color: var(--orange-color);
transform: scale(1);
}
meter.donut.danger {
--color: var(--red-color);
transform: scale(1);
}
meter.donut.explode {
background-image: none;
transform: scale(1);
}
meter.donut:is(.warning, .danger, .explode):after {
content: attr(data-left);
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 12px;
color: var(--text-insignificant-color);
}
meter.donut:is(.danger, .explode):after {
color: var(--red-color);
}
meter.donut[hidden] {
min-width: var(--dimension);
min-height: var(--dimension);
position: relative;
display: inline-block;
visibility: hidden;
&[hidden] {
visibility: hidden;
}
* {
pointer-events: none;
}
meter {
appearance: none;
position: relative;
--border-width: 2px;
--middle-circle-radius: calc(var(--dimension) / 2 - var(--border-width));
width: var(--dimension);
height: var(--dimension);
border-radius: 50%;
--fill: calc(var(--percentage) * 1%);
--color: var(--link-color);
--middle-circle: radial-gradient(
circle at 50% 50%,
var(--bg-color) var(--middle-circle-radius),
transparent var(--middle-circle-radius)
);
background-image: var(--middle-circle),
conic-gradient(var(--color) var(--fill), var(--outline-color) 0);
transform: scale(0.7);
transition: transform 0.2s ease-in-out;
&::-webkit-meter-inner-element,
&::-webkit-meter-bar,
&::-webkit-meter-optimum-value,
&::-webkit-meter-suboptimum-value,
&::-webkit-meter-even-less-good-value {
display: none;
}
&::-moz-meter-bar {
background: transparent;
}
&.warning {
--color: var(--orange-color);
transform: scale(1);
}
&.danger {
--color: var(--red-color);
transform: scale(1);
}
&.explode {
background-image: none;
transform: scale(1);
}
&:is(.warning, .danger, .explode) + .counter {
opacity: 1;
color: var(--text-insignificant-color);
}
&:is(.danger, .explode) + .counter {
opacity: 1;
color: var(--red-color);
}
}
.counter {
line-height: 1;
opacity: 0;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 12px;
}
}
/* SHINY PILL */
@ -2285,10 +2303,10 @@ ul.link-list li a .icon {
filter: none !important;
}
.nav-menu-button .avatar {
transition: box-shadow 0.3s ease-out;
box-shadow: 0 0 0 2px var(--bg-color), 0 0 0 4px var(--link-light-color) !important;
}
.nav-menu-button:is(:hover, :focus, .active) .avatar {
box-shadow: 0 0 0 2px var(--bg-color), 0 0 0 4px var(--link-light-color);
box-shadow: 0 0 0 2px var(--bg-color), 0 0 0 4px var(--link-color) !important;
}
.nav-menu-button.with-avatar .icon {
position: absolute;
@ -2672,6 +2690,10 @@ ul.link-list li a .icon {
box-shadow: 0px 1px var(--bg-blur-color);
transition: transform 0.4s var(--timing-function);
--back-transition: transform 0.4s ease-out;
&:is(:empty, :has(> a:empty)) {
display: none;
}
}
.timeline:not(.flat) > li > a {
border-radius: inherit;
@ -2722,8 +2744,8 @@ ul.link-list li a .icon {
padding: 32px;
} */
li.timeline-item-carousel {
width: 95vw;
max-width: calc(320px * 3.3);
/* width: 95vw;
max-width: calc(320px * 3.3); */
transform: translateX(calc(-50% + var(--main-width) / 2));
}
}

View file

@ -27,6 +27,7 @@ import AccountStatuses from './pages/account-statuses';
import Bookmarks from './pages/bookmarks';
// import Catchup from './pages/catchup';
import Favourites from './pages/favourites';
import Filters from './pages/filters';
import FollowedHashtags from './pages/followed-hashtags';
import Following from './pages/following';
import Hashtag from './pages/hashtag';
@ -463,7 +464,8 @@ function SecondaryRoutes({ isLoggedIn }) {
<Route index element={<Lists />} />
<Route path=":id" element={<List />} />
</Route>
<Route path="/ft" element={<FollowedHashtags />} />
<Route path="/fh" element={<FollowedHashtags />} />
<Route path="/ft" element={<Filters />} />
<Route
path="/catchup"
element={

View file

@ -15,7 +15,8 @@ body.cloak,
.account-block,
.catchup-filters .filter-author *,
.post-peek-html *,
.post-peek-content > * {
.post-peek-content > *,
.request-notifications-account * {
text-decoration-thickness: 1.1em;
text-decoration-line: line-through;
/* text-rendering: optimizeSpeed; */
@ -51,7 +52,8 @@ body.cloak,
.cloak {
.media-container figcaption,
.media-container figcaption > *,
.catchup-filters .filter-author * {
.catchup-filters .filter-author *,
.request-notifications-account * {
color: var(--text-color) !important;
}
}

View file

@ -78,6 +78,7 @@ export const ICONS = {
refresh: () => import('@iconify-icons/mingcute/refresh-2-line'),
emoji2: () => import('@iconify-icons/mingcute/emoji-2-line'),
filter: () => import('@iconify-icons/mingcute/filter-2-line'),
filters: () => import('@iconify-icons/mingcute/filter-line'),
chart: () => import('@iconify-icons/mingcute/chart-line-line'),
react: () => import('@iconify-icons/mingcute/react-line'),
layout4: () => import('@iconify-icons/mingcute/layout-4-line'),
@ -103,4 +104,8 @@ export const ICONS = {
'arrows-right': () => import('@iconify-icons/mingcute/arrows-right-line'),
code: () => import('@iconify-icons/mingcute/code-line'),
copy: () => import('@iconify-icons/mingcute/copy-2-line'),
quote: () => import('@iconify-icons/mingcute/quote-left-line'),
settings: () => import('@iconify-icons/mingcute/settings-6-line'),
'heart-break': () => import('@iconify-icons/mingcute/heart-crack-line'),
'user-setting': () => import('@iconify-icons/mingcute/user-setting-line'),
};

View file

@ -33,7 +33,7 @@ function AccountBlock({
<span>
<b></b>
<br />
<span class="account-block-acct">@</span>
<span class="account-block-acct"></span>
</span>
</div>
);
@ -62,6 +62,7 @@ function AccountBlock({
group,
followersCount,
createdAt,
locked,
} = account;
let [_, acct1, acct2] = acct.match(/([^@]+)(@.+)/i) || [, acct];
if (accountInstance) {
@ -86,7 +87,7 @@ function AccountBlock({
class="account-block"
href={url}
target={external ? '_blank' : null}
title={`@${acct}`}
title={acct2 ? acct : `@${acct}`}
onClick={(e) => {
if (external) return;
e.preventDefault();
@ -120,9 +121,16 @@ function AccountBlock({
</>
)}{' '}
<span class="account-block-acct">
@{acct1}
{acct2 ? '' : '@'}
{acct1}
<wbr />
{acct2}
{locked && (
<>
{' '}
<Icon icon="lock" size="s" alt="Locked" />
</>
)}
</span>
{showActivity && (
<>

View file

@ -781,3 +781,108 @@
}
}
}
#edit-profile-container {
p {
margin-block: 8px;
}
label {
input,
textarea {
display: block;
width: 100%;
}
textarea {
resize: vertical;
min-height: 5em;
max-height: 50vh;
}
}
table {
width: 100%;
th {
text-align: left;
color: var(--text-insignificant-color);
font-weight: normal;
font-size: 0.8em;
text-transform: uppercase;
}
tbody tr td:first-child {
width: 40%;
}
input {
width: 100%;
}
}
footer {
display: flex;
justify-content: space-between;
padding: 8px 0;
* {
vertical-align: middle;
}
}
}
.handle-info {
.handle-handle {
display: inline-block;
margin-block: 5px;
b {
font-weight: 600;
padding: 2px 4px;
border-radius: 4px;
display: inline-block;
box-shadow: 0 0 0 5px var(--bg-blur-color);
&.handle-username {
color: var(--orange-fg-color);
background-color: var(--orange-bg-color);
}
&.handle-server {
color: var(--purple-fg-color);
background-color: var(--purple-bg-color);
}
}
}
.handle-at {
display: inline-block;
margin-inline: -3px;
position: relative;
z-index: 1;
}
.handle-legend {
margin-top: 0.25em;
}
.handle-legend-icon {
overflow: hidden;
display: inline-block;
width: 14px;
height: 14px;
border: 4px solid transparent;
border-radius: 8px;
background-clip: padding-box;
&.username {
background-color: var(--orange-fg-color);
border-color: var(--orange-bg-color);
}
&.server {
background-color: var(--purple-fg-color);
border-color: var(--purple-bg-color);
}
}
}

View file

@ -14,6 +14,7 @@ import { api } from '../utils/api';
import enhanceContent from '../utils/enhance-content';
import getHTMLText from '../utils/getHTMLText';
import handleContentLinks from '../utils/handle-content-links';
import { getLists } from '../utils/lists';
import niceDateTime from '../utils/nice-date-time';
import pmem from '../utils/pmem';
import shortenNumber from '../utils/shorten-number';
@ -340,6 +341,17 @@ function AccountInfo({
[standalone, id, statusesCount],
);
const onProfileUpdate = useCallback(
(newAccount) => {
if (newAccount.id === id) {
console.log('Updated account info', newAccount);
setInfo(newAccount);
states.accounts[`${newAccount.id}@${instance}`] = newAccount;
}
},
[id, instance],
);
return (
<div
tabIndex="-1"
@ -453,12 +465,15 @@ function AccountInfo({
e.target.classList.add('loaded');
try {
// Get color from four corners of image
const canvas = document.createElement('canvas');
const canvas = window.OffscreenCanvas
? new OffscreenCanvas(1, 1)
: document.createElement('canvas');
const ctx = canvas.getContext('2d', {
willReadFrequently: true,
});
canvas.width = e.target.width;
canvas.height = e.target.height;
ctx.imageSmoothingEnabled = false;
ctx.drawImage(e.target, 0, 0);
// const colors = [
// ctx.getImageData(0, 0, 1, 1).data,
@ -526,13 +541,55 @@ function AccountInfo({
/>
)}
<header>
<AccountBlock
account={info}
instance={instance}
avatarSize="xxxl"
external={standalone}
internal={!standalone}
/>
{standalone ? (
<Menu2
shift={
window.matchMedia('(min-width: calc(40em))').matches
? 114
: 64
}
menuButton={
<div>
<AccountBlock
account={info}
instance={instance}
avatarSize="xxxl"
onClick={() => {}}
/>
</div>
}
>
<div class="szh-menu__header">
<AccountHandleInfo acct={acct} instance={instance} />
</div>
<MenuItem
onClick={() => {
const handle = `@${acct}`;
try {
navigator.clipboard.writeText(handle);
showToast('Handle copied');
} catch (e) {
console.error(e);
showToast('Unable to copy handle');
}
}}
>
<Icon icon="link" />
<span>Copy handle</span>
</MenuItem>
<MenuItem href={url} target="_blank">
<Icon icon="external" />
<span>Go to original profile page</span>
</MenuItem>
</Menu2>
) : (
<AccountBlock
account={info}
instance={instance}
avatarSize="xxxl"
internal
/>
)}
</header>
<div class="faux-header-bg" aria-hidden="true" />
<main>
@ -789,8 +846,10 @@ function AccountInfo({
<RelatedActions
info={info}
instance={instance}
standalone={standalone}
authenticated={authenticated}
onRelationshipChange={onRelationshipChange}
onProfileUpdate={onProfileUpdate}
/>
</footer>
</>
@ -805,8 +864,10 @@ const FAMILIAR_FOLLOWERS_LIMIT = 3;
function RelatedActions({
info,
instance,
standalone,
authenticated,
onRelationshipChange = () => {},
onProfileUpdate = () => {},
}) {
if (!info) return null;
const {
@ -917,6 +978,7 @@ function RelatedActions({
const [showTranslatedBio, setShowTranslatedBio] = useState(false);
const [showAddRemoveLists, setShowAddRemoveLists] = useState(false);
const [showPrivateNoteModal, setShowPrivateNoteModal] = useState(false);
const [showEditProfile, setShowEditProfile] = useState(false);
const [lists, setLists] = useState([]);
return (
@ -1026,6 +1088,70 @@ function RelatedActions({
{privateNote ? 'Edit private note' : 'Add private note'}
</span>
</MenuItem>
{following && !!relationship && (
<>
<MenuItem
onClick={() => {
setRelationshipUIState('loading');
(async () => {
try {
const rel = await currentMasto.v1.accounts
.$select(accountID.current)
.follow({
notify: !notifying,
});
if (rel) setRelationship(rel);
setRelationshipUIState('default');
showToast(
rel.notifying
? `Notifications enabled for @${username}'s posts.`
: ` Notifications disabled for @${username}'s posts.`,
);
} catch (e) {
alert(e);
setRelationshipUIState('error');
}
})();
}}
>
<Icon icon="notification" />
<span>
{notifying
? 'Disable notifications'
: 'Enable notifications'}
</span>
</MenuItem>
<MenuItem
onClick={() => {
setRelationshipUIState('loading');
(async () => {
try {
const rel = await currentMasto.v1.accounts
.$select(accountID.current)
.follow({
reblogs: !showingReblogs,
});
if (rel) setRelationship(rel);
setRelationshipUIState('default');
showToast(
rel.showingReblogs
? `Boosts from @${username} disabled.`
: `Boosts from @${username} enabled.`,
);
} catch (e) {
alert(e);
setRelationshipUIState('error');
}
})();
}}
>
<Icon icon="rocket" />
<span>
{showingReblogs ? 'Disable boosts' : 'Enable boosts'}
</span>
</MenuItem>
</>
)}
{/* Add/remove from lists is only possible if following the account */}
{following && (
<MenuItem
@ -1273,6 +1399,19 @@ function RelatedActions({
</MenuItem>
</>
)}
{currentAuthenticated && isSelf && standalone && (
<>
<MenuDivider />
<MenuItem
onClick={() => {
setShowEditProfile(true);
}}
>
<Icon icon="pencil" />
<span>Edit profile</span>
</MenuItem>
</>
)}
{import.meta.env.DEV && currentAuthenticated && isSelf && (
<>
<MenuDivider />
@ -1414,6 +1553,22 @@ function RelatedActions({
/>
</Modal>
)}
{!!showEditProfile && (
<Modal
onClose={() => {
setShowEditProfile(false);
}}
>
<EditProfileSheet
onClose={({ state, account } = {}) => {
setShowEditProfile(false);
if (state === 'success' && account) {
onProfileUpdate(account);
}
}}
/>
</Modal>
)}
</>
);
}
@ -1491,13 +1646,12 @@ function AddRemoveListsSheet({ accountID, onClose }) {
setUIState('loading');
(async () => {
try {
const lists = await masto.v1.lists.list();
lists.sort((a, b) => a.title.localeCompare(b.title));
const lists = await getLists();
setLists(lists);
const listsContainingAccount = await masto.v1.accounts
.$select(accountID)
.lists.list();
console.log({ lists, listsContainingAccount });
setLists(lists);
setListsContainingAccount(listsContainingAccount);
setUIState('default');
} catch (e) {
@ -1702,4 +1856,213 @@ function PrivateNoteSheet({
);
}
function EditProfileSheet({ onClose = () => {} }) {
const { masto } = api();
const [uiState, setUIState] = useState('loading');
const [account, setAccount] = useState(null);
useEffect(() => {
(async () => {
try {
const acc = await masto.v1.accounts.verifyCredentials();
setAccount(acc);
setUIState('default');
} catch (e) {
console.error(e);
setUIState('error');
}
})();
}, []);
console.log('EditProfileSheet', account);
const { displayName, source } = account || {};
const { note, fields } = source || {};
const fieldsAttributesRef = useRef(null);
return (
<div class="sheet" id="edit-profile-container">
{!!onClose && (
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
</button>
)}
<header>
<b>Edit profile</b>
</header>
<main>
{uiState === 'loading' ? (
<p class="ui-state">
<Loader abrupt />
</p>
) : (
<form
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.target);
const displayName = formData.get('display_name');
const note = formData.get('note');
const fieldsAttributesFields =
fieldsAttributesRef.current.querySelectorAll(
'input[name^="fields_attributes"]',
);
const fieldsAttributes = [];
fieldsAttributesFields.forEach((field) => {
const name = field.name;
const [_, index, key] =
name.match(/fields_attributes\[(\d+)\]\[(.+)\]/) || [];
const value = field.value ? field.value.trim() : '';
if (index && key && value) {
if (!fieldsAttributes[index]) fieldsAttributes[index] = {};
fieldsAttributes[index][key] = value;
}
});
// Fill in the blanks
fieldsAttributes.forEach((field) => {
if (field.name && !field.value) {
field.value = '';
}
});
(async () => {
try {
const newAccount = await masto.v1.accounts.updateCredentials({
displayName,
note,
fieldsAttributes,
});
console.log('updated account', newAccount);
onClose?.({
state: 'success',
account: newAccount,
});
} catch (e) {
console.error(e);
alert(e?.message || 'Unable to update profile.');
}
})();
}}
>
<p>
<label>
Name{' '}
<input
type="text"
name="display_name"
defaultValue={displayName}
maxLength={30}
disabled={uiState === 'loading'}
/>
</label>
</p>
<p>
<label>
Bio
<textarea
defaultValue={note}
name="note"
maxLength={500}
rows="5"
disabled={uiState === 'loading'}
/>
</label>
</p>
{/* Table for fields; name and values are in fields, min 4 rows */}
<p>Extra fields</p>
<table ref={fieldsAttributesRef}>
<thead>
<tr>
<th>Label</th>
<th>Content</th>
</tr>
</thead>
<tbody>
{Array.from({ length: Math.max(4, fields.length) }).map(
(_, i) => {
const { name = '', value = '' } = fields[i] || {};
return (
<FieldsAttributesRow
key={i}
name={name}
value={value}
index={i}
disabled={uiState === 'loading'}
/>
);
},
)}
</tbody>
</table>
<footer>
<button
type="button"
class="light"
disabled={uiState === 'loading'}
onClick={() => {
onClose?.();
}}
>
Cancel
</button>
<button type="submit" disabled={uiState === 'loading'}>
Save
</button>
</footer>
</form>
)}
</main>
</div>
);
}
function FieldsAttributesRow({ name, value, disabled, index: i }) {
const [hasValue, setHasValue] = useState(!!value);
return (
<tr>
<td>
<input
type="text"
name={`fields_attributes[${i}][name]`}
defaultValue={name}
disabled={disabled}
maxLength={255}
required={hasValue}
/>
</td>
<td>
<input
type="text"
name={`fields_attributes[${i}][value]`}
defaultValue={value}
disabled={disabled}
maxLength={255}
onChange={(e) => setHasValue(!!e.currentTarget.value)}
/>
</td>
</tr>
);
}
function AccountHandleInfo({ acct, instance }) {
// acct = username or username@server
let [username, server] = acct.split('@');
if (!server) server = instance;
return (
<div class="handle-info">
<span class="handle-handle">
<b class="handle-username">{username}</b>
<span class="handle-at">@</span>
<b class="handle-server">{server}</b>
</span>
<div class="handle-legend">
<span class="ib">
<span class="handle-legend-icon username" /> username
</span>{' '}
<span class="ib">
<span class="handle-legend-icon server" /> server domain name
</span>
</div>
</div>
);
}
export default AccountInfo;

View file

@ -21,6 +21,7 @@ const canvas = window.OffscreenCanvas
const ctx = canvas.getContext('2d', {
willReadFrequently: true,
});
ctx.imageSmoothingEnabled = false;
function Avatar({ url, size, alt = '', squircle, ...props }) {
size = SIZES[size] || size || SIZES.m;

View file

@ -39,6 +39,8 @@ function Columns() {
if (!Component) return null;
// Don't show Search column with no query, for now
if (type === 'search' && !params.query) return null;
// Don't show List column with no list, for now
if (type === 'list' && !params.id) return null;
return (
<Component key={type + JSON.stringify(params)} {...params} columnMode />
);

View file

@ -1,6 +1,7 @@
import { useHotkeys } from 'react-hotkeys-hook';
import openCompose from '../utils/open-compose';
import openOSK from '../utils/open-osk';
import states from '../utils/states';
import Icon from './icon';
@ -14,6 +15,7 @@ export default function ComposeButton() {
states.showCompose = true;
}
} else {
openOSK();
states.showCompose = true;
}
}

View file

@ -95,6 +95,10 @@
0 1px 10px var(--bg-color), 0 1px 10px var(--bg-color),
0 1px 10px var(--bg-color);
z-index: 2;
strong {
color: var(--red-color);
}
}
#_compose-container .status-preview-legend.reply-to {
color: var(--reply-to-color);

View file

@ -174,6 +174,8 @@ function highlightText(text, { maxCharacters = Infinity }) {
); // Emoji shortcodes
}
const rtf = new Intl.RelativeTimeFormat();
function Compose({
onClose,
replyToStatus,
@ -235,6 +237,12 @@ function Compose({
};
const focusTextarea = () => {
setTimeout(() => {
if (!textareaRef.current) return;
// status starts with newline, focus on first position
if (draftStatus?.status?.startsWith?.('\n')) {
textareaRef.current.selectionStart = 0;
textareaRef.current.selectionEnd = 0;
}
console.debug('FOCUS textarea');
textareaRef.current?.focus();
}, 300);
@ -291,7 +299,7 @@ function Compose({
setVisibility(visibility);
setLanguage(language || presf.postingDefaultLanguage || DEFAULT_LANG);
setSensitive(sensitive);
setPoll(composablePoll);
if (composablePoll) setPoll(composablePoll);
setMediaAttachments(mediaAttachments);
setUIState('default');
} catch (e) {
@ -631,6 +639,16 @@ function Compose({
return [topLanguages, restLanguages];
}, [language]);
const replyToStatusMonthsAgo = useMemo(
() =>
!!replyToStatus?.createdAt &&
Math.floor(
(Date.now() - new Date(replyToStatus.createdAt)) /
(1000 * 60 * 60 * 24 * 30),
),
[replyToStatus],
);
return (
<div id="compose-container-outer">
<div id="compose-container" class={standalone ? 'standalone' : ''}>
@ -780,6 +798,16 @@ function Compose({
Replying to @
{replyToStatus.account.acct || replyToStatus.account.username}
&rsquo;s post
{replyToStatusMonthsAgo >= 3 && (
<>
{' '}
(
<strong>
{rtf.format(-replyToStatusMonthsAgo, 'month')}
</strong>
)
</>
)}
</div>
</div>
)}
@ -1634,27 +1662,31 @@ function CharCountMeter({ maxCharacters = 500, hidden }) {
const charCount = snapStates.composerCharacterCount;
const leftChars = maxCharacters - charCount;
if (hidden) {
return <meter class="donut" hidden />;
return <span class="char-counter" hidden />;
}
return (
<meter
class={`donut ${
leftChars <= -10
? 'explode'
: leftChars <= 0
? 'danger'
: leftChars <= 20
? 'warning'
: ''
}`}
value={charCount}
max={maxCharacters}
data-left={leftChars}
<span
class="char-counter"
title={`${leftChars}/${maxCharacters}`}
style={{
'--percentage': (charCount / maxCharacters) * 100,
}}
/>
>
<meter
class={`${
leftChars <= -10
? 'explode'
: leftChars <= 0
? 'danger'
: leftChars <= 20
? 'warning'
: ''
}`}
value={charCount}
max={maxCharacters}
/>
<span class="counter">{leftChars}</span>
</span>
);
}

View file

@ -0,0 +1,17 @@
export default function CustomEmoji({ staticUrl, alt, url }) {
return (
<picture>
<source srcset={staticUrl} media="(prefers-reduced-motion: reduce)" />
<img
key={alt}
src={url}
alt={alt}
class="shortcode-emoji emoji"
width="16"
height="16"
loading="lazy"
decoding="async"
/>
</picture>
);
}

View file

@ -1,5 +1,7 @@
import { memo } from 'preact/compat';
import CustomEmoji from './custom-emoji';
function EmojiText({ text, emojis }) {
if (!text) return '';
if (!emojis?.length) return text;
@ -12,21 +14,7 @@ function EmojiText({ text, emojis }) {
const emoji = emojis.find((e) => e.shortcode === word);
if (emoji) {
const { url, staticUrl } = emoji;
return (
<picture>
<source srcset={staticUrl} media="(prefers-reduced-motion: reduce)" />
<img
key={word}
src={url}
alt={word}
class="shortcode-emoji emoji"
width="16"
height="16"
loading="lazy"
decoding="async"
/>
</picture>
);
return <CustomEmoji staticUrl={staticUrl} alt={word} url={url} />;
}
return word;
});

View file

@ -1,4 +1,24 @@
#generic-accounts-container {
.post-preview {
--max-height: 120px;
max-height: var(--max-height);
overflow: hidden;
margin-block: 8px;
border: 1px solid var(--outline-color);
border-radius: 8px;
pointer-events: none;
.status {
font-size: calc(var(--text-size) * 0.9);
mask-image: linear-gradient(
to bottom,
black calc(var(--max-height) / 2),
transparent calc(var(--max-height) - 8px)
);
filter: saturate(0.5);
}
}
.accounts-list {
--list-gap: 16px;
list-style: none;

View file

@ -12,10 +12,12 @@ import useLocationChange from '../utils/useLocationChange';
import AccountBlock from './account-block';
import Icon from './icon';
import Loader from './loader';
import Status from './status';
export default function GenericAccounts({
instance,
excludeRelationshipAttrs = [],
postID,
onClose = () => {},
}) {
const { masto, instance: currentInstance } = api();
@ -129,6 +131,8 @@ export default function GenericAccounts({
}
}, [snapStates.reloadGenericAccounts.counter]);
const post = states.statuses[postID];
return (
<div id="generic-accounts-container" class="sheet" tabindex="-1">
<button type="button" class="sheet-close" onClick={onClose}>
@ -138,6 +142,11 @@ export default function GenericAccounts({
<h2>{heading || 'Accounts'}</h2>
</header>
<main>
{post && (
<div class="post-preview">
<Status status={post} size="s" readOnly />
</div>
)}
{accounts.length > 0 ? (
<>
<ul class="accounts-list">

View file

@ -0,0 +1,27 @@
import { shouldPolyfill } from '@formatjs/intl-segmenter/should-polyfill';
import { Suspense } from 'preact/compat';
import { useEffect, useState } from 'preact/hooks';
import Loader from './loader';
const supportsIntlSegmenter = !shouldPolyfill();
export default function IntlSegmenterSuspense({ children }) {
if (supportsIntlSegmenter) {
return <Suspense fallback={<Loader />}>{children}</Suspense>;
}
const [polyfillLoaded, setPolyfillLoaded] = useState(false);
useEffect(() => {
(async () => {
await import('@formatjs/intl-segmenter/polyfill-force');
setPolyfillLoaded(true);
})();
}, []);
return polyfillLoaded ? (
<Suspense fallback={<Loader />}>{children}</Suspense>
) : (
<Loader />
);
}

View file

@ -0,0 +1,46 @@
/*
Rendered but hidden. Only show when visible
*/
import { useLayoutEffect, useRef, useState } from 'preact/hooks';
import { useInView } from 'react-intersection-observer';
export default function LazyShazam({ children }) {
const containerRef = useRef();
const [visible, setVisible] = useState(false);
const [visibleStart, setVisibleStart] = useState(false);
const { ref } = useInView({
root: null,
trackVisibility: true,
delay: 1000,
onChange: (inView) => {
if (inView) {
setVisible(true);
}
},
triggerOnce: true,
skip: visibleStart || visible,
});
useLayoutEffect(() => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
if (rect.bottom > 0) {
setVisibleStart(true);
}
}, []);
if (visibleStart) return children;
return (
<div
ref={containerRef}
class="shazam-container no-animation"
hidden={!visible}
>
<div ref={ref} class="shazam-container-inner">
{children}
</div>
</div>
);
}

View file

@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'preact/hooks';
import { api } from '../utils/api';
import { addListStore, deleteListStore, updateListStore } from '../utils/lists';
import supports from '../utils/supports';
import Icon from './icon';
@ -75,6 +76,14 @@ function ListAddEdit({ list, onClose }) {
state: 'success',
list: listResult,
});
setTimeout(() => {
if (editMode) {
updateListStore(listResult);
} else {
addListStore(listResult);
}
}, 1);
} catch (e) {
console.error(e);
setUIState('error');
@ -146,6 +155,9 @@ function ListAddEdit({ list, onClose }) {
onClose?.({
state: 'deleted',
});
setTimeout(() => {
deleteListStore(list.id);
}, 1);
} catch (e) {
console.error(e);
setUIState('error');

View file

@ -1,4 +1,4 @@
import { Menu, MenuItem, SubMenu } from '@szhsin/react-menu';
import { MenuItem, SubMenu } from '@szhsin/react-menu';
import { cloneElement } from 'preact';
import { useRef } from 'preact/hooks';
@ -10,6 +10,7 @@ function MenuConfirm({
confirmLabel,
menuItemClassName,
menuFooter,
menuExtras,
...props
}) {
const { children, onClick, ...restProps } = props;
@ -53,6 +54,7 @@ function MenuConfirm({
<MenuItem className={menuItemClassName} onClick={onClick}>
{confirmLabel}
</MenuItem>
{menuExtras}
{menuFooter}
</Parent>
);

View file

@ -1,3 +1,4 @@
import { lazy } from 'preact/compat';
import { useLocation, useNavigate } from 'react-router-dom';
import { subscribe, useSnapshot } from 'valtio';
@ -8,16 +9,19 @@ import showToast from '../utils/show-toast';
import states from '../utils/states';
import AccountSheet from './account-sheet';
import Compose from './compose';
// import Compose from './compose';
import Drafts from './drafts';
import EmbedModal from './embed-modal';
import GenericAccounts from './generic-accounts';
import IntlSegmenterSuspense from './intl-segmenter-suspense';
import MediaAltModal from './media-alt-modal';
import MediaModal from './media-modal';
import Modal from './modal';
import ReportModal from './report-modal';
import ShortcutsSettings from './shortcuts-settings';
const Compose = lazy(() => import('./compose'));
subscribe(states, (changes) => {
for (const [action, path, value, prevValue] of changes) {
// When closing modal, focus on deck
@ -36,49 +40,51 @@ export default function Modals() {
<>
{!!snapStates.showCompose && (
<Modal class="solid">
<Compose
replyToStatus={
typeof snapStates.showCompose !== 'boolean'
? snapStates.showCompose.replyToStatus
: window.__COMPOSE__?.replyToStatus || null
}
editStatus={
states.showCompose?.editStatus ||
window.__COMPOSE__?.editStatus ||
null
}
draftStatus={
states.showCompose?.draftStatus ||
window.__COMPOSE__?.draftStatus ||
null
}
onClose={(results) => {
const { newStatus, instance, type } = results || {};
states.showCompose = false;
window.__COMPOSE__ = null;
if (newStatus) {
states.reloadStatusPage++;
showToast({
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) => {
toast.hideToast();
states.prevLocation = location;
navigate(
instance
? `/${instance}/s/${newStatus.id}`
: `/s/${newStatus.id}`,
);
},
});
<IntlSegmenterSuspense>
<Compose
replyToStatus={
typeof snapStates.showCompose !== 'boolean'
? snapStates.showCompose.replyToStatus
: window.__COMPOSE__?.replyToStatus || null
}
}}
/>
editStatus={
states.showCompose?.editStatus ||
window.__COMPOSE__?.editStatus ||
null
}
draftStatus={
states.showCompose?.draftStatus ||
window.__COMPOSE__?.draftStatus ||
null
}
onClose={(results) => {
const { newStatus, instance, type } = results || {};
states.showCompose = false;
window.__COMPOSE__ = null;
if (newStatus) {
states.reloadStatusPage++;
showToast({
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) => {
toast.hideToast();
states.prevLocation = location;
navigate(
instance
? `/${instance}/s/${newStatus.id}`
: `/s/${newStatus.id}`,
);
},
});
}
}}
/>
</IntlSegmenterSuspense>
</Modal>
)}
{!!snapStates.showSettings && (
@ -179,6 +185,7 @@ export default function Modals() {
excludeRelationshipAttrs={
snapStates.showGenericAccounts.excludeRelationshipAttrs
}
postID={snapStates.showGenericAccounts.postID}
onClose={() => (states.showGenericAccounts = false)}
/>
</Modal>

View file

@ -8,6 +8,11 @@
font-weight: 500;
unicode-bidi: isolate;
}
i {
font-variant-numeric: slashed-zero;
font-feature-settings: 'ss01';
}
}
.name-text.show-acct {
display: inline-block;
@ -21,6 +26,9 @@ a.name-text.short:is(:hover, :focus) i {
font-style: normal;
opacity: 0.75;
}
.name-text i.instance {
opacity: 0.35;
}
.name-text .avatar {
vertical-align: middle;

View file

@ -50,7 +50,11 @@ function NameText({
class={`name-text ${showAcct ? 'show-acct' : ''} ${short ? 'short' : ''}`}
href={url}
target={external ? '_blank' : null}
title={`${displayName ? `${displayName} ` : ''}@${acct}`}
title={
displayName
? `${displayName} (${acct2 ? '' : '@'}${acct})`
: `${acct2 ? '' : '@'}${acct}`
}
onClick={(e) => {
if (external) return;
e.preventDefault();
@ -72,12 +76,13 @@ function NameText({
<b>
<EmojiText text={displayName} emojis={emojis} />
</b>
{!showAcct && username && (
{!showAcct && username ? (
<>
{' '}
<i>@{username}</i>
</>
)}
) : ' '}
<i class="instance">{acct2}</i>
</>
) : short ? (
<i>{username}</i>
@ -88,8 +93,9 @@ function NameText({
<>
<br />
<i>
@{acct1}
<span class="ib">{acct2}</span>
{acct2 ? '' : '@'}
{acct1}
{!!acct2 && <span class="ib">{acct2}</span>}
</i>
</>
)}

View file

@ -88,3 +88,7 @@
.sparkle-icon {
animation: sparkle-icon 0.3s ease-in-out infinite alternate;
}
.nav-submenu {
max-width: 14em;
}

View file

@ -5,13 +5,15 @@ import {
MenuDivider,
MenuItem,
SubMenu,
FocusableItem,
} from '@szhsin/react-menu';
import { memo } from 'preact/compat';
import { useEffect, useRef, useState } from 'preact/hooks';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { useLongPress } from 'use-long-press';
import { useSnapshot } from 'valtio';
import { api } from '../utils/api';
import { getLists } from '../utils/lists';
import safeBoundingBoxPadding from '../utils/safe-bounding-box-padding';
import states from '../utils/states';
import store from '../utils/store';
@ -19,21 +21,19 @@ import store from '../utils/store';
import Avatar from './avatar';
import Icon from './icon';
import MenuLink from './menu-link';
import { accountsIsDtth, gtsDtthSettings } from '../utils/dtth';
function NavMenu(props) {
const snapStates = useSnapshot(states);
const { masto, instance, authenticated } = api();
const [currentAccount, setCurrentAccount] = useState();
const [moreThanOneAccount, setMoreThanOneAccount] = useState(false);
useEffect(() => {
const [currentAccount, moreThanOneAccount] = useMemo(() => {
const accounts = store.local.getJSON('accounts') || [];
const acc = accounts.find(
(account) => account.info.id === store.session.get('currentAccount'),
);
if (acc) setCurrentAccount(acc);
setMoreThanOneAccount(accounts.length > 1);
const acc =
accounts.find(
(account) => account.info.id === store.session.get('currentAccount'),
) || accounts[0];
return [acc, accounts.length > 1];
}, []);
// Home = Following
@ -89,6 +89,13 @@ function NavMenu(props) {
return results;
}
const [lists, setLists] = useState([]);
useEffect(() => {
if (menuState === 'open') {
getLists().then(setLists);
}
}, [menuState === 'open']);
const buttonClickTS = useRef();
return (
<>
@ -97,7 +104,7 @@ function NavMenu(props) {
type="button"
class={`button plain nav-menu-button ${
moreThanOneAccount ? 'with-avatar' : ''
} ${open ? 'active' : ''}`}
} ${menuState === 'open' ? 'active' : ''}`}
style={{ position: 'relative' }}
onClick={() => {
buttonClickTS.current = Date.now();
@ -143,7 +150,7 @@ function NavMenu(props) {
}}
{...props}
overflow="auto"
viewScroll="close"
// viewScroll="close"
position="anchor"
align="center"
boundingBoxPadding={boundingBoxPadding}
@ -203,13 +210,48 @@ function NavMenu(props) {
<Icon icon="user" size="l" /> <span>Profile</span>
</MenuLink>
)}
<MenuLink to="/l">
<Icon icon="list" size="l" /> <span>Lists</span>
</MenuLink>
{currentAccount && accountsIsDtth(currentAccount) &&
<FocusableItem title="Takes you to DTTHDon settings">
<a href={gtsDtthSettings} target='_blank'><Icon icon="user-setting" size="l" /> <span>User Settings&hellip;</span></a>
</FocusableItem>}
{lists?.length > 0 ? (
<SubMenu
menuClassName="nav-submenu"
overflow="auto"
gap={-8}
label={
<>
<Icon icon="list" size="l" />
<span class="menu-grow">Lists</span>
<Icon icon="chevron-right" />
</>
}
>
<MenuLink to="/l">
<span>All Lists</span>
</MenuLink>
{lists?.length > 0 && (
<>
<MenuDivider />
{lists.map((list) => (
<MenuLink key={list.id} to={`/l/${list.id}`}>
<span>{list.title}</span>
</MenuLink>
))}
</>
)}
</SubMenu>
) : (
<MenuLink to="/l">
<Icon icon="list" size="l" />
<span>Lists</span>
</MenuLink>
)}
<MenuLink to="/b">
<Icon icon="bookmark" size="l" /> <span>Bookmarks</span>
</MenuLink>
<SubMenu
menuClassName="nav-submenu"
overflow="auto"
gap={-8}
label={
@ -223,11 +265,15 @@ function NavMenu(props) {
<MenuLink to="/f">
<Icon icon="heart" size="l" /> <span>Likes</span>
</MenuLink>
<MenuLink to="/ft">
<MenuLink to="/fh">
<Icon icon="hashtag" size="l" />{' '}
<span>Followed Hashtags</span>
</MenuLink>
<MenuDivider />
<MenuLink to="/ft">
<Icon icon="filters" size="l" />
Filters
</MenuLink>
<MenuItem
onClick={() => {
states.showGenericAccounts = {

View file

@ -2,11 +2,12 @@ import { Fragment } from 'preact';
import { memo } from 'preact/compat';
import shortenNumber from '../utils/shorten-number';
import states from '../utils/states';
import states, { statusKey } from '../utils/states';
import store from '../utils/store';
import useTruncated from '../utils/useTruncated';
import Avatar from './avatar';
import CustomEmoji from './custom-emoji';
import FollowRequestButtons from './follow-request-buttons';
import Icon from './icon';
import Link from './link';
@ -25,6 +26,9 @@ const NOTIFICATION_ICONS = {
update: 'pencil',
'admin.signup': 'account-edit',
'admin.report': 'account-warning',
severed_relationships: 'heart-break',
emoji_reaction: 'emoji2',
'pleroma:emoji_reaction': 'emoji2',
};
/*
@ -42,6 +46,24 @@ admin.sign_up = Someone signed up (optionally sent to admins)
admin.report = A new report has been filed
*/
function emojiText(emoji, emoji_url) {
let url;
let staticUrl;
if (typeof emoji_url === 'string') {
url = emoji_url;
} else {
url = emoji_url?.url;
staticUrl = emoji_url?.staticUrl;
}
return url ? (
<>
reacted to your post with{' '}
<CustomEmoji url={url} staticUrl={staticUrl} alt={emoji} />
</>
) : (
`reacted to your post with ${emoji}.`
);
}
const contentText = {
mention: 'mentioned you in their post.',
status: 'published a post.',
@ -63,6 +85,35 @@ const contentText = {
'favourite+reblog_reply': 'boosted & liked your reply.',
'admin.sign_up': 'signed up.',
'admin.report': (targetAccount) => <>reported {targetAccount}</>,
severed_relationships: (name) => (
<>
Lost connections with <i>{name}</i>.
</>
),
emoji_reaction: emojiText,
'pleroma:emoji_reaction': emojiText,
};
// account_suspension, domain_block, user_domain_block
const SEVERED_RELATIONSHIPS_TEXT = {
account_suspension: ({ from, targetName }) => (
<>
An admin from <i>{from}</i> has suspended <i>{targetName}</i>, which means
you can no longer receive updates from them or interact with them.
</>
),
domain_block: ({ from, targetName, followersCount, followingCount }) => (
<>
An admin from <i>{from}</i> has blocked <i>{targetName}</i>. Affected
followers: {followersCount}, followings: {followingCount}.
</>
),
user_domain_block: ({ targetName, followersCount, followingCount }) => (
<>
You have blocked <i>{targetName}</i>. Removed followers: {followersCount},
followings: {followingCount}.
</>
),
};
const AVATARS_LIMIT = 50;
@ -73,7 +124,8 @@ function Notification({
isStatic,
disableContextMenu,
}) {
const { id, status, account, report, _accounts, _statuses } = notification;
const { id, status, account, report, event, _accounts, _statuses } =
notification;
let { type } = notification;
// status = Attached when type of the notification is favourite, reblog, status, mention, poll, or update
@ -128,13 +180,30 @@ function Notification({
if (typeof text === 'function') {
const count = _statuses?.length || _accounts?.length;
if (count) {
text = text(count);
} else if (type === 'admin.report') {
if (type === 'admin.report') {
const targetAccount = report?.targetAccount;
if (targetAccount) {
text = text(<NameText account={targetAccount} showAvatar />);
}
} else if (type === 'severed_relationships') {
const targetName = event?.targetName;
if (targetName) {
text = text(targetName);
}
} else if (
(type === 'emoji_reaction' || type === 'pleroma:emoji_reaction') &&
notification.emoji
) {
const emojiURL =
notification.emoji_url || // This is string
status?.emojis?.find?.(
(emoji) =>
emoji?.shortcode ===
notification.emoji.replace(/^:/, '').replace(/:$/, ''),
); // Emoji object instead of string
text = text(notification.emoji, emojiURL);
} else if (count) {
text = text(count);
}
}
@ -159,6 +228,7 @@ function Notification({
accounts: _accounts,
showReactions: type === 'favourite+reblog',
excludeRelationshipAttrs: type === 'follow' ? ['followedBy'] : [],
postID: statusKey(actualStatusID, instance),
};
};
@ -203,9 +273,11 @@ function Notification({
</b>{' '}
</>
) : (
<>
<NameText account={account} showAvatar />{' '}
</>
account && (
<>
<NameText account={account} showAvatar />{' '}
</>
)
)}
</>
)}
@ -224,6 +296,23 @@ function Notification({
{type === 'follow_request' && (
<FollowRequestButtons accountID={account.id} />
)}
{type === 'severed_relationships' && (
<div>
{SEVERED_RELATIONSHIPS_TEXT[event.type]({
from: instance,
...event,
})}
<br />
<a
href={`https://${instance}/severed_relationships`}
target="_blank"
rel="noopener noreferrer"
>
Learn more <Icon icon="external" size="s" />
</a>
.
</div>
)}
</>
)}
{_accounts?.length > 1 && (

View file

@ -26,6 +26,8 @@
background-color: var(--bg-blur-color);
backdrop-filter: blur(16px);
padding: 16px;
padding: calc(var(--sai-top, 0) + 16px) calc(var(--sai-right, 0) + 16px)
16px calc(var(--sai-left, 0) + 16px);
display: flex;
gap: 8px;
justify-content: space-between;
@ -41,6 +43,8 @@
main {
padding: 0 16px 16px;
padding: 0 calc(var(--sai-right, 0) + 16px)
calc(var(--sai-bottom, 0) + 16px) calc(var(--sai-left, 0) + 16px);
/* display: flex;
flex-direction: column;
gap: 16px; */

View file

@ -232,8 +232,8 @@ function ReportModal({ account, post, onClose }) {
disabled={uiState === 'loading'}
/>
</section>
<section>
{domain !== currentDomain && (
{!!domain && domain !== currentDomain && (
<section>
<p>
<label>
<input
@ -247,8 +247,8 @@ function ReportModal({ account, post, onClose }) {
</span>
</label>
</p>
)}
</section>
</section>
)}
<footer>
<button type="submit" disabled={uiState === 'loading'}>
Send Report

View file

@ -73,7 +73,7 @@ const SearchForm = forwardRef((props, ref) => {
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
spellCheck="false"
onSearch={(e) => {
if (!e.target.value) {
setSearchParams({});

View file

@ -14,6 +14,7 @@ import tabMenuBarUrl from '../assets/tab-menu-bar.svg';
import { api } from '../utils/api';
import { fetchFollowedTags } from '../utils/followed-tags';
import { getLists, getListTitle } from '../utils/lists';
import pmem from '../utils/pmem';
import showToast from '../utils/show-toast';
import states from '../utils/states';
@ -43,7 +44,7 @@ const TYPES = [
const TYPE_TEXT = {
following: 'Home / Following',
notifications: 'Notifications',
list: 'List',
list: 'Lists',
public: 'Public (Local / Federated)',
search: 'Search',
'account-statuses': 'Account',
@ -58,6 +59,7 @@ const TYPE_PARAMS = {
{
text: 'List ID',
name: 'id',
notRequired: true,
},
],
public: [
@ -122,10 +124,6 @@ const TYPE_PARAMS = {
},
],
};
const fetchListTitle = pmem(async ({ id }) => {
const list = await api().masto.v1.lists.$select(id).fetch();
return list.title;
});
const fetchAccountTitle = pmem(async ({ id }) => {
const account = await api().masto.v1.accounts.$select(id).fetch();
return account.username || account.acct || account.displayName;
@ -150,10 +148,11 @@ export const SHORTCUTS_META = {
icon: 'notification',
},
list: {
id: 'list',
title: fetchListTitle,
path: ({ id }) => `/l/${id}`,
id: ({ id }) => (id ? 'list' : 'lists'),
title: ({ id }) => (id ? getListTitle(id) : 'Lists'),
path: ({ id }) => (id ? `/l/${id}` : '/l'),
icon: 'list',
excludeViewMode: ({ id }) => (!id ? ['multi-column'] : []),
},
public: {
id: 'public',
@ -496,18 +495,8 @@ function ShortcutsSettings({ onClose }) {
);
}
const FETCH_MAX_AGE = 1000 * 60; // 1 minute
const fetchLists = pmem(
() => {
const { masto } = api();
return masto.v1.lists.list();
},
{
maxAge: FETCH_MAX_AGE,
},
);
const FORM_NOTES = {
list: `Specific list is optional. For multi-column mode, list is required, else the column will not be shown.`,
search: `For multi-column mode, search term is required, else the column will not be shown.`,
hashtag: 'Multiple hashtags are supported. Space-separated.',
};
@ -532,8 +521,7 @@ function ShortcutForm({
if (currentType !== 'list') return;
try {
setUIState('loading');
const lists = await fetchLists();
lists.sort((a, b) => a.title.localeCompare(b.title));
const lists = await getLists();
setLists(lists);
setUIState('default');
} catch (e) {
@ -644,6 +632,7 @@ function ShortcutForm({
disabled={disabled || uiState === 'loading'}
defaultValue={editMode ? shortcut.id : undefined}
>
<option value=""></option>
{lists.map((list) => (
<option value={list.id}>{list.title}</option>
))}
@ -671,7 +660,7 @@ function ShortcutForm({
}
autocorrect="off"
autocapitalize="off"
spellcheck={false}
spellCheck={false}
pattern={pattern}
/>
{currentType === 'hashtag' &&

View file

@ -1,14 +1,15 @@
import './shortcuts.css';
import { Menu, MenuItem } from '@szhsin/react-menu';
import { MenuDivider, SubMenu } from '@szhsin/react-menu';
import { memo } from 'preact/compat';
import { useMemo, useRef } from 'preact/hooks';
import { useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
import { useNavigate } from 'react-router-dom';
import { useSnapshot } from 'valtio';
import { SHORTCUTS_META } from '../components/shortcuts-settings';
import { api } from '../utils/api';
import { getLists } from '../utils/lists';
import states from '../utils/states';
import AsyncText from './AsyncText';
@ -34,47 +35,48 @@ function Shortcuts() {
const menuRef = useRef();
const formattedShortcuts = useMemo(
() =>
shortcuts
.map((pin, i) => {
const { type, ...data } = pin;
if (!SHORTCUTS_META[type]) return null;
let { id, path, title, subtitle, icon } = SHORTCUTS_META[type];
const hasLists = useRef(false);
const formattedShortcuts = shortcuts
.map((pin, i) => {
const { type, ...data } = pin;
if (!SHORTCUTS_META[type]) return null;
let { id, path, title, subtitle, icon } = SHORTCUTS_META[type];
if (typeof id === 'function') {
id = id(data, i);
}
if (typeof path === 'function') {
path = path(
{
...data,
instance: data.instance || instance,
},
i,
);
}
if (typeof title === 'function') {
title = title(data, i);
}
if (typeof subtitle === 'function') {
subtitle = subtitle(data, i);
}
if (typeof icon === 'function') {
icon = icon(data, i);
}
if (typeof id === 'function') {
id = id(data, i);
}
if (typeof path === 'function') {
path = path(
{
...data,
instance: data.instance || instance,
},
i,
);
}
if (typeof title === 'function') {
title = title(data, i);
}
if (typeof subtitle === 'function') {
subtitle = subtitle(data, i);
}
if (typeof icon === 'function') {
icon = icon(data, i);
}
return {
id,
path,
title,
subtitle,
icon,
};
})
.filter(Boolean),
[shortcuts],
);
if (id === 'lists') {
hasLists.current = true;
}
return {
id,
path,
title,
subtitle,
icon,
};
})
.filter(Boolean);
const navigate = useNavigate();
useHotkeys(['1', '2', '3', '4', '5', '6', '7', '8', '9'], (e, handler) => {
@ -88,6 +90,8 @@ function Shortcuts() {
}
});
const [lists, setLists] = useState([]);
return (
<div id="shortcuts">
{snapStates.settings.shortcutsViewMode === 'tab-menu-bar' ? (
@ -147,6 +151,11 @@ function Shortcuts() {
menuClassName="glass-menu shortcuts-menu"
gap={8}
position="anchor"
onMenuChange={(e) => {
if (e.open && hasLists.current) {
getLists().then(setLists);
}
}}
menuButton={
<button
type="button"
@ -171,6 +180,35 @@ function Shortcuts() {
}
>
{formattedShortcuts.map(({ id, path, title, subtitle, icon }, i) => {
if (id === 'lists') {
return (
<SubMenu
menuClassName="glass-menu"
overflow="auto"
gap={-8}
label={
<>
<Icon icon={icon} size="l" />
<span class="menu-grow">
<AsyncText>{title}</AsyncText>
</span>
<Icon icon="chevron-right" />
</>
}
>
<MenuLink to="/l">
<span>All Lists</span>
</MenuLink>
<MenuDivider />
{lists?.map((list) => (
<MenuLink key={list.id} to={`/l/${list.id}`}>
<span>{list.title}</span>
</MenuLink>
))}
</SubMenu>
);
}
return (
<MenuLink
to={path}

View file

@ -105,7 +105,7 @@
padding: 2px;
vertical-align: top;
text-transform: uppercase;
text-shadow: 0 1px var(--bg-color);
/* text-shadow: 0 1px var(--bg-color); */
&:hover {
color: var(--text-color);
@ -623,26 +623,26 @@
.spoiler-media-button
),
~ .card .meta-container {
/* filter: blur(5px) invert(0.5);
image-rendering: crisp-edges;
image-rendering: pixelated; */
opacity: 0.2;
text-decoration-thickness: 1.5em;
text-decoration-line: line-through;
/* text-rendering: optimizeSpeed; */
pointer-events: none;
user-select: none;
/* contain: layout; */
/* transform: scale(0.97);
transition: transform 0.1s ease-in-out; */
* {
text-decoration-color: inherit;
text-decoration-thickness: 1.5em;
text-decoration-line: line-through;
/* text-rendering: optimizeSpeed; */
}
}
~ *:not(
.media-container,
.card,
.media-figure-multiple,
.spoiler-media-button
),
~ .card .meta-container {
img {
filter: invert(0.5);
background-color: black;
@ -908,7 +908,7 @@
grid-auto-rows: 1fr;
gap: 2px;
/* height: 160px; */
min-height: 88px;
min-height: var(--pointer-min-dimension);
height: auto;
max-height: max(160px, 33vh);
}
@ -1037,9 +1037,9 @@
.status .media-container.media-eq1 .media {
display: inline-block;
max-width: 100% !important;
min-width: 88px;
min-width: var(--pointer-min-dimension);
/* width: auto; */
min-height: 88px;
min-height: var(--pointer-min-dimension);
/* --maxAspectHeight: max(160px, 33vh);
--aspectWidth: calc(--width / --height * var(--maxAspectHeight)); */
width: min(var(--aspectWidth), var(--width), 100%);
@ -1300,7 +1300,7 @@ body:has(#modal-container .carousel) .status .media img:hover {
:is(.status, .media-post) .media-audio {
width: 100%;
height: 100%;
min-height: 88px;
min-height: var(--pointer-min-dimension);
background-image: radial-gradient(
circle at center center,
transparent,
@ -1585,16 +1585,16 @@ a:focus-visible .card img {
}
.card .meta.domain {
opacity: 1;
color: var(--link-color);
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
display: block;
color: var(--text-insignificant-color);
.domain {
color: var(--link-color);
}
}
.card:visited .meta.domain {
.card:visited .meta .domain {
color: var(--link-visited-color);
}
.card .meta.domain * {
.card .meta .domain * {
vertical-align: middle;
}
a.card {
@ -1696,6 +1696,7 @@ a.card:is(:hover, :focus):visited {
.poll-label input:is([type='radio'], [type='checkbox']) {
flex-shrink: 0;
margin: 3px;
min-height: 1em;
}
.poll-option-votes {
flex-shrink: 0;
@ -1719,6 +1720,7 @@ a.card:is(:hover, :focus):visited {
}
.poll-option-title {
text-shadow: 0 1px var(--bg-color);
line-height: 1.2;
}
.poll-option-title .icon {
vertical-align: middle;
@ -1753,6 +1755,13 @@ a.card:is(:hover, :focus):visited {
margin-left: calc(-50px - 16px);
}
/* EMOJI REACTIONS */
.status.large .emoji-reactions {
cursor: default;
margin-left: calc(-50px - 16px);
}
/* ACTIONS */
.status .actions {

View file

@ -24,8 +24,9 @@ import { useHotkeys } from 'react-hotkeys-hook';
import { useLongPress } from 'use-long-press';
import { useSnapshot } from 'valtio';
import AccountBlock from '../components/account-block';
import CustomEmoji from '../components/custom-emoji';
import EmojiText from '../components/emoji-text';
import LazyShazam from '../components/lazy-shazam';
import Loader from '../components/loader';
import Menu2 from '../components/menu2';
import MenuConfirm from '../components/menu-confirm';
@ -85,6 +86,8 @@ const isIOS =
window.ontouchstart !== undefined &&
/iPad|iPhone|iPod/.test(navigator.userAgent);
const rtf = new Intl.RelativeTimeFormat();
const REACTIONS_LIMIT = 80;
function getPollText(poll) {
@ -239,6 +242,8 @@ function Status({
_deleted,
_pinned,
// _filtered,
// Non-Mastodon
emojiReactions,
} = status;
const currentAccount = useMemo(() => {
@ -508,6 +513,13 @@ function Status({
(attachment) => !attachment.description?.trim?.(),
);
}, [mediaAttachments]);
const statusMonthsAgo = useMemo(() => {
return Math.floor(
(new Date() - createdAtDate) / (1000 * 60 * 60 * 24 * 30),
);
}, [createdAtDate]);
const boostStatus = async () => {
if (!sameInstance || !authenticated) {
alert(unauthInteractionErrorMessage);
@ -715,25 +727,6 @@ function Status({
const isPinnable = ['public', 'unlisted', 'private'].includes(visibility);
const StatusMenuItems = (
<>
{isSizeLarge && (
<>
<MenuItem
onClick={() => {
states.showGenericAccounts = {
heading: 'Boosted/Liked by…',
fetchAccounts: fetchBoostedLikedByAccounts,
instance,
showReactions: true,
};
}}
>
<Icon icon="react" />
<span>
Boosted/Liked by<span class="more-insignificant"></span>
</span>
</MenuItem>
</>
)}
{!isSizeLarge && sameInstance && (
<>
<div class="menu-control-group-horizontal status-menu">
@ -748,17 +741,41 @@ function Status({
confirmLabel={
<>
<Icon icon="rocket" />
<span>{reblogged ? 'Unboost?' : 'Boost to everyone?'}</span>
<span>{reblogged ? 'Unboost' : 'Boost'}</span>
</>
}
className={`menu-reblog ${reblogged ? 'checked' : ''}`}
menuExtras={
<MenuItem
onClick={() => {
states.showCompose = {
draftStatus: {
status: `\n${url}`,
},
};
}}
>
<Icon icon="quote" />
<span>Quote</span>
</MenuItem>
}
menuFooter={
mediaNoDesc &&
!reblogged && (
mediaNoDesc && !reblogged ? (
<div class="footer">
<Icon icon="alert" />
Some media have no descriptions.
</div>
) : (
statusMonthsAgo >= 3 && (
<div class="footer">
<Icon icon="info" />
<span>
Old post (
<strong>{rtf.format(-statusMonthsAgo, 'month')}</strong>
)
</span>
</div>
)
)
}
disabled={!canBoost}
@ -807,6 +824,29 @@ function Status({
</div>
</>
)}
{!isSizeLarge && sameInstance && (isSizeLarge || showActionsBar) && (
<MenuDivider />
)}
{(isSizeLarge || showActionsBar) && (
<>
<MenuItem
onClick={() => {
states.showGenericAccounts = {
heading: 'Boosted/Liked by…',
fetchAccounts: fetchBoostedLikedByAccounts,
instance,
showReactions: true,
postID: sKey,
};
}}
>
<Icon icon="react" />
<span>
Boosted/Liked by<span class="more-insignificant"></span>
</span>
</MenuItem>
</>
)}
{(enableTranslate || !language || differentLanguage) && <MenuDivider />}
{enableTranslate ? (
<div class={supportsTTS ? 'menu-horizontal' : ''}>
@ -858,13 +898,12 @@ function Status({
</div>
)
)}
{!isSizeLarge ||
((enableTranslate || !language || differentLanguage) && (
<MenuDivider />
))}
{((!isSizeLarge && sameInstance) ||
enableTranslate ||
!language ||
differentLanguage) && <MenuDivider />}
{!isSizeLarge && (
<>
<MenuDivider />
<MenuLink
to={instance ? `/${instance}/s/${id}` : `/s/${id}`}
onClick={(e) => {
@ -947,7 +986,7 @@ function Status({
}}
>
<Icon icon="code" />
<span>Embed</span>
<span>Embed post</span>
</MenuItem>
)}
{(isSelf || mentionSelf) && <MenuDivider />}
@ -1100,7 +1139,12 @@ function Status({
const { clientX, clientY } = e.touches?.[0] || e;
// link detection copied from onContextMenu because here it works
const link = e.target.closest('a');
if (link && statusRef.current.contains(link)) return;
if (
link &&
statusRef.current.contains(link) &&
!link.getAttribute('href').startsWith('#')
)
return;
e.preventDefault();
setContextMenuProps({
anchorPoint: {
@ -1346,7 +1390,12 @@ function Status({
if (e.metaKey) return;
// console.log('context menu', e);
const link = e.target.closest('a');
if (link && statusRef.current.contains(link)) return;
if (
link &&
statusRef.current.contains(link) &&
!link.getAttribute('href').startsWith('#')
)
return;
// If there's selected text, don't show custom context menu
const selection = window.getSelection?.();
@ -1544,11 +1593,14 @@ function Status({
}`}
/>
) : (
<Icon
icon={visibilityIconsMap[visibility]}
alt={visibilityText[visibility]}
size="s"
/>
visibility !== 'public' &&
visibility !== 'direct' && (
<Icon
icon={visibilityIconsMap[visibility]}
alt={visibilityText[visibility]}
size="s"
/>
)
)}{' '}
<RelativeTime datetime={createdAtDate} format="micro" />
{!previewMode && !readOnly && (
@ -1599,11 +1651,15 @@ function Status({
// {StatusMenuItems}
// </Menu>
<span class="time">
<Icon
icon={visibilityIconsMap[visibility]}
alt={visibilityText[visibility]}
size="s"
/>{' '}
{visibility !== 'public' && visibility !== 'direct' && (
<>
<Icon
icon={visibilityIconsMap[visibility]}
alt={visibilityText[visibility]}
size="s"
/>{' '}
</>
)}
<RelativeTime datetime={createdAtDate} format="micro" />
</span>
))}
@ -1798,7 +1854,9 @@ function Status({
media={media}
autoAnimate={isSizeLarge}
showCaption={mediaAttachments.length === 1}
allowLongerCaption={!content}
allowLongerCaption={
!content && mediaAttachments.length === 1
}
lang={language}
altIndex={
showMultipleMediaCaptions &&
@ -1881,6 +1939,46 @@ function Status({
</>
)}
</div>
{!!emojiReactions?.length && (
<div class="emoji-reactions">
{emojiReactions.map((emojiReaction) => {
const { name, count, me } = emojiReaction;
const isShortCode = /^:.+?:$/.test(name);
if (isShortCode) {
const emoji = emojis.find(
(e) =>
e.shortcode ===
name.replace(/^:/, '').replace(/:$/, ''),
);
if (emoji) {
return (
<span
class={`emoji-reaction tag ${
me ? '' : 'insignificant'
}`}
>
<CustomEmoji
alt={name}
url={emoji.url}
staticUrl={emoji.staticUrl}
/>
{count}
</span>
);
}
}
return (
<span
class={`emoji-reaction tag ${
me ? '' : 'insignificant'
}`}
>
{name} {count}
</span>
);
})}
</div>
)}
<div class={`actions ${_deleted ? 'disabled' : ''}`}>
<div class="action has-count">
<StatusButton
@ -1910,11 +2008,23 @@ function Status({
confirmLabel={
<>
<Icon icon="rocket" />
<span>
{reblogged ? 'Unboost?' : 'Boost to everyone?'}
</span>
<span>{reblogged ? 'Unboost' : 'Boost'}</span>
</>
}
menuExtras={
<MenuItem
onClick={() => {
states.showCompose = {
draftStatus: {
status: `\n${url}`,
},
};
}}
>
<Icon icon="quote" />
<span>Quote</span>
</MenuItem>
}
menuFooter={
mediaNoDesc &&
!reblogged && (
@ -2131,10 +2241,13 @@ function Card({ card, selfReferential, instance }) {
const w = 44;
const h = 44;
const blurhashPixels = decodeBlurHash(blurhash, w, h);
const canvas = document.createElement('canvas');
const canvas = window.OffscreenCanvas
? new OffscreenCanvas(1, 1)
: document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = false;
const imageData = ctx.createImageData(w, h);
imageData.data.set(blurhashPixels);
ctx.putImageData(imageData, 0, 0);
@ -2169,13 +2282,19 @@ function Card({ card, selfReferential, instance }) {
/>
</div>
<div class="meta-container">
<p class="meta domain" dir="auto">
{domain}
<p class="meta domain">
<span class="domain">{domain}</span>{' '}
{!!publishedAt && <>&middot; </>}
{!!publishedAt && (
<>
<RelativeTime datetime={publishedAt} format="micro" />
</>
)}
</p>
<p class="title" dir="auto">
<p class="title" dir="auto" title={title}>
{title}
</p>
<p class="meta" dir="auto">
<p class="meta" dir="auto" title={description}>
{description ||
(!!publishedAt && (
<RelativeTime datetime={publishedAt} format="micro" />
@ -2242,10 +2361,22 @@ function Card({ card, selfReferential, instance }) {
>
<div class="meta-container">
<p class="meta domain">
<Icon icon="link" size="s" /> <span>{domain}</span>
<span class="domain">
<Icon icon="link" size="s" /> <span>{domain}</span>
</span>{' '}
{!!publishedAt && <>&middot; </>}
{!!publishedAt && (
<>
<RelativeTime datetime={publishedAt} format="micro" />
</>
)}
</p>
<p class="title" title={title}>
{title}
</p>
<p class="meta" title={description || providerName || authorName}>
{description || providerName || authorName}
</p>
<p class="title">{title}</p>
<p class="meta">{description || providerName || authorName}</p>
</div>
</a>
);
@ -3005,20 +3136,22 @@ const QuoteStatuses = memo(({ id, instance, level = 0 }) => {
return uniqueQuotes.map((q) => {
return (
<Link
key={q.instance + q.id}
to={`${q.instance ? `/${q.instance}` : ''}/s/${q.id}`}
class="status-card-link"
data-read-more="Read more →"
>
<Status
statusID={q.id}
instance={q.instance}
size="s"
quoted={level + 1}
enableCommentHint
/>
</Link>
<LazyShazam>
<Link
key={q.instance + q.id}
to={`${q.instance ? `/${q.instance}` : ''}/s/${q.id}`}
class="status-card-link"
data-read-more="Read more →"
>
<Status
statusID={q.id}
instance={q.instance}
size="s"
quoted={level + 1}
enableCommentHint
/>
</Link>
</LazyShazam>
);
});
});

View file

@ -209,17 +209,13 @@ function Timeline({
const showNewPostsIndicator =
items.length > 0 && uiState !== 'loading' && showNew;
const handleLoadNewPosts = useCallback(() => {
loadItems(true);
if (showNewPostsIndicator) loadItems(true);
scrollableRef.current?.scrollTo({
top: 0,
behavior: 'smooth',
});
}, [loadItems]);
const dotRef = useHotkeys('.', () => {
if (showNewPostsIndicator) {
handleLoadNewPosts();
}
});
}, [loadItems, showNewPostsIndicator]);
const dotRef = useHotkeys('.', handleLoadNewPosts);
// const {
// scrollDirection,
@ -365,6 +361,7 @@ function Timeline({
jRef.current = node;
kRef.current = node;
oRef.current = node;
dotRef.current = node;
}}
tabIndex="-1"
>
@ -535,15 +532,15 @@ const TimelineItem = memo(
const url = instance
? `/${instance}/s/${actualStatusID}`
: `/s/${actualStatusID}`;
let title = '';
if (type === 'boosts') {
title = `${items.length} Boosts`;
} else if (type === 'pinned') {
title = 'Pinned posts';
}
const isCarousel = type === 'boosts' || type === 'pinned';
if (items) {
const fItems = filteredItems(items, filterContext);
let title = '';
if (type === 'boosts') {
title = `${fItems.length} Boosts`;
} else if (type === 'pinned') {
title = 'Pinned posts';
}
const isCarousel = type === 'boosts' || type === 'pinned';
if (isCarousel) {
// Here, we don't hide filtered posts, but we sort them last
fItems.sort((a, b) => {

View file

@ -3,11 +3,15 @@ import './index.css';
import './app.css';
import { render } from 'preact';
import { lazy } from 'preact/compat';
import { useEffect, useState } from 'preact/hooks';
import Compose from './components/compose';
import IntlSegmenterSuspense from './components/intl-segmenter-suspense';
// import Compose from './components/compose';
import useTitle from './utils/useTitle';
const Compose = lazy(() => import('./components/compose'));
if (window.opener) {
console = window.opener.console;
}
@ -57,23 +61,25 @@ function App() {
console.debug('OPEN COMPOSE');
return (
<Compose
editStatus={editStatus}
replyToStatus={replyToStatus}
draftStatus={draftStatus}
standalone
hasOpener={window.opener}
onClose={(results) => {
const { newStatus, fn = () => {} } = results || {};
try {
if (newStatus) {
window.opener.__STATES__.reloadStatusPage++;
}
fn();
setUIState('closed');
} catch (e) {}
}}
/>
<IntlSegmenterSuspense>
<Compose
editStatus={editStatus}
replyToStatus={replyToStatus}
draftStatus={draftStatus}
standalone
hasOpener={window.opener}
onClose={(results) => {
const { newStatus, fn = () => {} } = results || {};
try {
if (newStatus) {
window.opener.__STATES__.reloadStatusPage++;
}
fn();
setUIState('closed');
} catch (e) {}
}}
/>
</IntlSegmenterSuspense>
);
}

View file

@ -1,4 +1,5 @@
{
"@mastodon/edit-media-attributes": ">=4.1",
"@mastodon/list-exclusive": ">=4.2"
"@mastodon/list-exclusive": ">=4.2",
"@mastodon/filtered-notifications": "~4.3 || >=4.3"
}

View file

@ -8,7 +8,7 @@
--sai-left: env(safe-area-inset-left);
--text-size: 16px;
--main-width: 40em;
--main-width: max(60dvw, 40em);
text-size-adjust: none;
--hairline-width: 1px;
--monospace-font: ui-monospace, 'SFMono-Regular', Consolas, 'Liberation Mono',
@ -16,6 +16,12 @@
--blue-color: royalblue;
--purple-color: blueviolet;
--purple-fg-color: color-mix(
in srgb-linear,
var(--purple-color) 60%,
var(--text-color) 40%
);
--purple-bg-color: color-mix(in srgb, var(--purple-color) 10%, transparent);
--green-color: darkgreen;
--orange-color: darkorange;
--orange-light-bg-color: color-mix(
@ -23,7 +29,18 @@
var(--orange-color) 20%,
transparent
);
--orange-fg-color: color-mix(
in srgb-linear,
var(--orange-color) 60%,
var(--text-color) 40%
);
--orange-bg-color: color-mix(in srgb, var(--orange-color) 10%, transparent);
--red-color: orangered;
--red-text-color: color-mix(
in srgb-linear,
var(--red-color) 60%,
var(--text-color) 40%
);
--red-bg-color: color-mix(in lch, var(--red-color) 40%, transparent);
--bg-color: #fff;
--bg-faded-color: #f0f2f5;
@ -91,6 +108,14 @@
--timing-function: cubic-bezier(0.3, 0.5, 0, 1);
--spring-timing-funtion: cubic-bezier(0.175, 0.885, 0.32, 1.275);
--pointer-min-dimension: 88px;
}
@media (pointer: fine) {
:root {
--pointer-min-dimension: 44px;
}
}
@media (min-resolution: 2dppx) {
@ -227,7 +252,7 @@ button[hidden] {
}
:is(button, .button):not(:disabled, .disabled):is(:hover, :focus) {
cursor: pointer;
filter: brightness(1.2);
filter: brightness(1.05);
}
:is(button, .button):not(:disabled, .disabled):active {
filter: brightness(0.8);

View file

@ -4,7 +4,7 @@ import './cloak-mode.css';
// Polyfill needed for Firefox < 122
// https://bugzilla.mozilla.org/show_bug.cgi?id=1423593
import '@formatjs/intl-segmenter/polyfill';
// import '@formatjs/intl-segmenter/polyfill';
import { render } from 'preact';
import { HashRouter } from 'react-router-dom';

View file

@ -206,8 +206,12 @@ function AccountStatuses() {
const [featuredTags, setFeaturedTags] = useState([]);
useTitle(
account?.acct
? `${account?.displayName ? account.displayName + ' ' : ''}@${
account.acct
? `${
account?.displayName
? `${account.displayName} (${/@/.test(account.acct) ? '' : '@'}${
account.acct
})`
: `${/@/.test(account.acct) ? '' : '@'}${account.acct}`
}${
!excludeReplies
? ' (+ Replies)'

View file

@ -525,10 +525,13 @@
background-color: var(--bg-faded-color);
box-shadow: 0 8px 16px -8px var(--drop-shadow-color),
inset 0 1px var(--bg-color);
outline: 1px solid var(--outline-color);
text-shadow: 0 1px var(--bg-color);
}
&:hover:not(:focus-visible) {
outline: 1px solid var(--outline-color);
}
&:active {
filter: brightness(0.95);
box-shadow: none;
@ -626,10 +629,24 @@
gap: 4px;
align-items: center;
flex-shrink: 0;
min-height: 24px;
.icon {
> .avatar {
outline: 1px solid var(--bg-blur-color);
}
> .avatar ~ .avatar {
margin-left: -8px;
}
> .icon {
color: var(--reblog-color);
}
> .name-text {
opacity: 0.75;
filter: grayscale(0.75);
}
}
.post-author {
@ -796,6 +813,10 @@
text-decoration: none;
text-decoration-color: transparent;
color: var(--link-text-color);
span {
text-decoration: none;
}
}
}
@ -1077,6 +1098,20 @@
dd {
margin-block-end: 1em;
margin-inline: 1em;
+ dd {
margin-block-start: -0.9em;
}
}
}
kbd {
border-radius: 4px;
display: inline-block;
padding: 0.2em 0.3em;
margin: 1px 0;
line-height: 1;
border: 1px solid var(--outline-color);
background-color: var(--bg-faded-color);
}
}

View file

@ -32,12 +32,12 @@ import { oklab2rgb, rgb2oklab } from '../utils/color-utils';
import db from '../utils/db';
import emojifyText from '../utils/emojify-text';
import { isFiltered } from '../utils/filters';
import getHTMLText from '../utils/getHTMLText';
import htmlContentLength from '../utils/html-content-length';
import niceDateTime from '../utils/nice-date-time';
import shortenNumber from '../utils/shorten-number';
import showToast from '../utils/show-toast';
import states, { statusKey } from '../utils/states';
import statusPeek from '../utils/status-peek';
import store from '../utils/store';
import { getCurrentAccountNS } from '../utils/store-utils';
import { assignFollowedTags } from '../utils/timeline-utils';
@ -429,9 +429,28 @@ function Catchup() {
return postFilterMatches;
});
// Deduplicate boosts
const boostedPosts = {};
filteredPosts.forEach((post) => {
if (post.reblog) {
if (boostedPosts[post.reblog.id]) {
if (boostedPosts[post.reblog.id].__BOOSTERS) {
boostedPosts[post.reblog.id].__BOOSTERS.add(post.account);
} else {
boostedPosts[post.reblog.id].__BOOSTERS = new Set([post.account]);
}
post.__HIDDEN = true;
} else {
boostedPosts[post.reblog.id] = post;
}
}
});
if (selectedAuthor && authorCountsMap.has(selectedAuthor)) {
filteredPosts = filteredPosts.filter(
(post) => post.account.id === selectedAuthor,
(post) =>
post.account.id === selectedAuthor ||
[...(post.__BOOSTERS || [])].find((a) => a.id === selectedAuthor),
);
}
@ -459,39 +478,41 @@ function Catchup() {
authorCountsList.forEach((authorID, index) => {
authorIndices[authorID] = index;
});
return filteredPosts.sort((a, b) => {
if (groupBy === 'account') {
const aAccountID = a.account.id;
const bAccountID = b.account.id;
const aIndex = authorIndices[aAccountID];
const bIndex = authorIndices[bAccountID];
const order = aIndex - bIndex;
if (order !== 0) {
return order;
return filteredPosts
.filter((post) => !post.__HIDDEN)
.sort((a, b) => {
if (groupBy === 'account') {
const aAccountID = a.account.id;
const bAccountID = b.account.id;
const aIndex = authorIndices[aAccountID];
const bIndex = authorIndices[bAccountID];
const order = aIndex - bIndex;
if (order !== 0) {
return order;
}
}
}
if (sortBy !== 'createdAt') {
a = a.reblog || a;
b = b.reblog || b;
if (sortBy !== 'density' && a[sortBy] === b[sortBy]) {
return a.createdAt > b.createdAt ? 1 : -1;
if (sortBy !== 'createdAt') {
a = a.reblog || a;
b = b.reblog || b;
if (sortBy !== 'density' && a[sortBy] === b[sortBy]) {
return a.createdAt > b.createdAt ? 1 : -1;
}
}
if (sortBy === 'density') {
const aDensity = postDensity(a);
const bDensity = postDensity(b);
if (sortOrder === 'asc') {
return aDensity > bDensity ? 1 : -1;
} else {
return bDensity > aDensity ? 1 : -1;
}
}
}
if (sortBy === 'density') {
const aDensity = postDensity(a);
const bDensity = postDensity(b);
if (sortOrder === 'asc') {
return aDensity > bDensity ? 1 : -1;
return a[sortBy] > b[sortBy] ? 1 : -1;
} else {
return bDensity > aDensity ? 1 : -1;
return b[sortBy] > a[sortBy] ? 1 : -1;
}
}
if (sortOrder === 'asc') {
return a[sortBy] > b[sortBy] ? 1 : -1;
} else {
return b[sortBy] > a[sortBy] ? 1 : -1;
}
});
});
}, [filteredPosts, sortBy, sortOrder, groupBy, authorCountsList]);
const prevGroup = useRef(null);
@ -589,41 +610,46 @@ function Catchup() {
authors,
]);
const prevSelectedAuthorMissing = useRef(false);
useEffect(() => {
// console.log({
// prevSelectedAuthorMissing,
// selectedAuthor,
// authors,
// });
let timer;
if (selectedAuthor) {
if (authors[selectedAuthor]) {
if (prevSelectedAuthorMissing.current) {
timer = setTimeout(() => {
authorsListParent.current
.querySelector(`[data-author="${selectedAuthor}"]`)
?.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'center',
});
}, 500);
prevSelectedAuthorMissing.current = false;
// Check if author is visible and within the scrollable area viewport
const authorElement = authorsListParent.current.querySelector(
`[data-author="${selectedAuthor}"]`,
);
const scrollableRect =
authorsListParent.current?.getBoundingClientRect();
const authorRect = authorElement?.getBoundingClientRect();
console.log({
sLeft: scrollableRect.left,
sRight: scrollableRect.right,
aLeft: authorRect.left,
aRight: authorRect.right,
});
if (
authorRect.left < scrollableRect.left ||
authorRect.right > scrollableRect.right
) {
authorElement.scrollIntoView({
block: 'nearest',
inline: 'center',
behavior: 'smooth',
});
} else if (authorRect.top < 0) {
authorElement.scrollIntoView({
block: 'nearest',
inline: 'nearest',
behavior: 'smooth',
});
}
} else {
prevSelectedAuthorMissing.current = true;
}
}
return () => {
clearTimeout(timer);
};
}, [selectedAuthor, authors]);
const [showHelp, setShowHelp] = useState(false);
const itemsSelector = '.catchup-list > li > a';
useHotkeys(
const jRef = useHotkeys(
'j',
() => {
const activeItem = document.activeElement.closest(itemsSelector);
@ -663,12 +689,121 @@ function Catchup() {
},
{
preventDefault: true,
ignoreModifiers: true,
},
);
const kRef = useHotkeys(
'k',
() => {
const activeItem = document.activeElement.closest(itemsSelector);
const activeItemRect = activeItem?.getBoundingClientRect();
const allItems = Array.from(
scrollableRef.current.querySelectorAll(itemsSelector),
);
if (
activeItem &&
activeItemRect.top < scrollableRef.current.clientHeight &&
activeItemRect.bottom > 0
) {
const activeItemIndex = allItems.indexOf(activeItem);
let prevItem = allItems[activeItemIndex - 1];
if (prevItem) {
prevItem.focus();
prevItem.scrollIntoView({
block: 'center',
inline: 'center',
behavior: 'smooth',
});
}
} else {
const topmostItem = allItems.find((item) => {
const itemRect = item.getBoundingClientRect();
return itemRect.top >= 44 && itemRect.left >= 0;
});
if (topmostItem) {
topmostItem.focus();
topmostItem.scrollIntoView({
block: 'nearest',
inline: 'center',
behavior: 'smooth',
});
}
}
},
{
preventDefault: true,
ignoreModifiers: true,
},
);
const hlRef = useHotkeys(
'h, l',
(_, handler) => {
// Go next/prev selectedAuthor in authorCountsList list
const key = handler.keys[0];
if (selectedAuthor) {
const index = authorCountsList.indexOf(selectedAuthor);
if (key === 'h') {
if (index > 0 && index < authorCountsList.length) {
setSelectedAuthor(authorCountsList[index - 1]);
scrollableRef.current?.focus();
}
} else if (key === 'l') {
if (index < authorCountsList.length - 1 && index >= 0) {
setSelectedAuthor(authorCountsList[index + 1]);
scrollableRef.current?.focus();
}
}
} else if (key === 'l') {
setSelectedAuthor(authorCountsList[0]);
scrollableRef.current?.focus();
}
},
{
preventDefault: true,
ignoreModifiers: true,
enableOnFormTags: ['input'],
},
);
const escRef = useHotkeys(
'esc',
() => {
setSelectedAuthor(null);
scrollableRef.current?.focus();
},
{
preventDefault: true,
ignoreModifiers: true,
enableOnFormTags: ['input'],
},
);
const dotRef = useHotkeys(
'.',
() => {
scrollableRef.current?.scrollTo({
top: 0,
behavior: 'smooth',
});
},
{
preventDefault: true,
ignoreModifiers: true,
enableOnFormTags: ['input'],
},
);
return (
<div
ref={scrollableRef}
ref={(node) => {
scrollableRef.current = node;
jRef.current = node;
kRef.current = node;
hlRef.current = node;
escRef.current = node;
}}
id="catchup-page"
class="deck-container"
tabIndex="-1"
@ -821,10 +956,12 @@ function Catchup() {
<Link to={`/catchup?id=${pc.id}`}>
<Icon icon="history2" />{' '}
<span>
{formatRange(
new Date(pc.startAt),
new Date(pc.endAt),
)}
{pc.startAt
? dtf.formatRange(
new Date(pc.startAt),
new Date(pc.endAt),
)
: `… – ${dtf.format(new Date(pc.endAt))}`}
</span>
</Link>{' '}
<span>
@ -876,7 +1013,7 @@ function Catchup() {
{posts.length > 0 && (
<p>
<b class="ib">
{formatRange(
{dtf.formatRange(
new Date(posts[0].createdAt),
new Date(posts[posts.length - 1].createdAt),
)}
@ -997,7 +1134,12 @@ function Catchup() {
)}
</div>
{!!title && (
<h1 class="title" lang={language} dir="auto">
<h1
class="title"
lang={language}
dir="auto"
title={title}
>
{title}
</h1>
)}
@ -1007,6 +1149,7 @@ function Catchup() {
class="description"
lang={language}
dir="auto"
title={description}
>
{description}
</p>
@ -1120,7 +1263,7 @@ function Catchup() {
authors[author].avatarStatic || authors[author].avatar
}
size="xxl"
alt={`${authors[author].displayName} (@${authors[author].username})`}
alt={`${authors[author].displayName} (@${authors[author].acct})`}
/>{' '}
<span class="count">{authorCounts[author]}</span>
<span class="username">{authors[author].username}</span>
@ -1330,6 +1473,25 @@ function Catchup() {
Posts are grouped by authors, sorted by posts count per
author.
</dd>
<dt>Keyboard shortcuts</dt>
<dd>
<kbd>j</kbd>: Next post
</dd>
<dd>
<kbd>k</kbd>: Previous post
</dd>
<dd>
<kbd>l</kbd>: Next author
</dd>
<dd>
<kbd>h</kbd>: Previous author
</dd>
<dd>
<kbd>Enter</kbd>: Open post details
</dd>
<dd>
<kbd>.</kbd>: Scroll to top
</dd>
</dl>
</main>
</div>
@ -1351,6 +1513,7 @@ const PostLine = memo(
_followedTags: isFollowedTags,
_filtered: filterInfo,
visibility,
__BOOSTERS,
} = post;
const isReplyTo = inReplyToId && inReplyToAccountId !== account.id;
const isFiltered = !!filterInfo;
@ -1384,7 +1547,12 @@ const PostLine = memo(
<Avatar
url={account.avatarStatic || account.avatar}
squircle={account.bot}
/>{' '}
/>
{__BOOSTERS?.size > 0
? [...__BOOSTERS].map((b) => (
<Avatar url={b.avatarStatic || b.avatar} squircle={b.bot} />
))
: ''}{' '}
<Icon icon="rocket" />{' '}
{/* <Avatar
url={reblog.account.avatarStatic || reblog.account.avatar}
@ -1484,7 +1652,7 @@ function PostPeek({ post, filterInfo }) {
const isThread =
(inReplyToId && inReplyToAccountId === account.id) || !!_thread;
const showMedia = !spoilerText && !sensitive;
const postText = content ? getHTMLText(content) : '';
const postText = content ? statusPeek(post) : '';
return (
<div class="post-peek" title={!spoilerText ? postText : ''}>
@ -1518,19 +1686,27 @@ function PostPeek({ post, filterInfo }) {
<span class="post-peek-tag post-peek-thread">Thread</span>{' '}
</>
)}
{content ? (
{!!content && (
<div
dangerouslySetInnerHTML={{
__html: emojifyText(content, emojis),
}}
/>
) : mediaAttachments?.length === 1 &&
mediaAttachments[0].description ? (
<>
<span class="post-peek-tag post-peek-alt">ALT</span>{' '}
<div>{mediaAttachments[0].description}</div>
</>
) : null}
)}
{!!poll?.options?.length &&
poll.options.map((o) => (
<div>
{poll.multiple ? '▪️' : '•'} {o.title}
</div>
))}
{!content &&
mediaAttachments?.length === 1 &&
mediaAttachments[0].description && (
<>
<span class="post-peek-tag post-peek-alt">ALT</span>{' '}
<div>{mediaAttachments[0].description}</div>
</>
)}
</div>
)}
</span>
@ -1668,9 +1844,6 @@ const dtf = new Intl.DateTimeFormat(locale, {
hour: 'numeric',
minute: 'numeric',
});
function formatRange(startDate, endDate) {
return dtf.formatRange(startDate, endDate);
}
function binByTime(data, key, numBins) {
// Extract dates from data objects

149
src/pages/filters.css Normal file
View file

@ -0,0 +1,149 @@
#filters-page {
.filters-list {
list-style: none;
padding: 0;
margin: 0;
li {
padding: 8px 16px;
border-bottom: var(--hairline-width) solid var(--outline-color);
display: flex;
align-items: center;
justify-content: space-between;
}
h2 {
font-weight: 500;
margin: 0;
padding: 0;
font-size: 1em;
}
}
}
#filters-add-edit-modal {
.filter-form-row {
margin-bottom: 16px;
+ .filter-form-row {
margin-top: 16px;
border-top: 1px solid var(--outline-color);
padding-top: 16px;
}
}
main {
padding-top: 10px;
line-height: 1.5;
p {
margin-block: 1em;
}
}
label {
display: flex;
align-items: center;
gap: 4px;
}
.filter-form-keywords {
margin: 0 -16px 16px;
}
.filter-form-cols {
display: flex;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
.filter-form-col {
flex-basis: 160px;
flex-grow: 1;
> *:first-child {
margin-top: 0;
}
> *:last-child {
margin-bottom: 0;
}
}
}
.filter-keywords {
--gap: 16px;
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: var(--gap);
padding: var(--gap);
overflow-y: auto;
min-height: 80px;
max-height: 25vh;
background-color: var(--bg-faded-blur-color);
counter-reset: index;
scroll-behavior: smooth;
li {
counter-increment: index;
display: flex;
gap: 4px;
align-items: center;
flex-wrap: wrap;
&:not(:only-child):before {
content: counter(index);
font-size: 10px;
color: var(--text-insignificant-color);
align-self: flex-start;
}
input[type='text'] {
flex-basis: 160px;
flex-grow: 100;
}
.filter-keyword-actions {
display: flex;
gap: 8px;
flex-grow: 1;
align-items: center;
justify-content: space-between;
label {
font-size: 0.8em;
line-height: 1;
}
}
}
}
.filter-keywords-footer {
padding: 8px 16px 0;
display: flex;
justify-content: space-between;
}
input[type='text'] {
display: block;
width: 100%;
}
.filter-form-footer {
display: flex;
gap: 16px;
justify-content: space-between;
align-items: center;
> span {
display: flex;
align-items: center;
}
button[type='submit'] {
padding-inline: 24px;
}
}
}

581
src/pages/filters.jsx Normal file
View file

@ -0,0 +1,581 @@
import './filters.css';
import { useEffect, useReducer, useRef, useState } from 'preact/hooks';
import Icon from '../components/icon';
import Link from '../components/link';
import Loader from '../components/loader';
import MenuConfirm from '../components/menu-confirm';
import Modal from '../components/modal';
import NavMenu from '../components/nav-menu';
import RelativeTime from '../components/relative-time';
import { api } from '../utils/api';
import useInterval from '../utils/useInterval';
import useTitle from '../utils/useTitle';
const FILTER_CONTEXT = ['home', 'public', 'notifications', 'thread', 'account'];
const FILTER_CONTEXT_UNIMPLEMENTED = ['notifications', 'thread', 'account'];
const FILTER_CONTEXT_LABELS = {
home: 'Home and lists',
notifications: 'Notifications',
public: 'Public timelines',
thread: 'Conversations',
account: 'Profiles',
};
const EXPIRY_DURATIONS = [
0, // forever
30 * 60, // 30 minutes
60 * 60, // 1 hour
6 * 60 * 60, // 6 hours
12 * 60 * 60, // 12 hours
60 * 60 * 24, // 24 hours
60 * 60 * 24 * 7, // 7 days
60 * 60 * 24 * 30, // 30 days
];
const EXPIRY_DURATIONS_LABELS = {
0: 'Never',
1800: '30 minutes',
3600: '1 hour',
21600: '6 hours',
43200: '12 hours',
86_400: '24 hours',
604_800: '7 days',
2_592_000: '30 days',
};
function Filters() {
const { masto } = api();
useTitle(`Filters`, `/ft`);
const [uiState, setUIState] = useState('default');
const [showFiltersAddEditModal, setShowFiltersAddEditModal] = useState(false);
const [reloadCount, reload] = useReducer((c) => c + 1, 0);
const [filters, setFilters] = useState([]);
useEffect(() => {
setUIState('loading');
(async () => {
try {
const filters = await masto.v2.filters.list();
filters.sort((a, b) => a.title.localeCompare(b.title));
filters.forEach((filter) => {
if (filter.keywords?.length) {
filter.keywords.sort((a, b) => a.id - b.id);
}
});
console.log(filters);
setFilters(filters);
setUIState('default');
} catch (e) {
console.error(e);
setUIState('error');
}
})();
}, [reloadCount]);
return (
<div id="filters-page" class="deck-container" tabIndex="-1">
<div class="timeline-deck deck">
<header>
<div class="header-grid">
<div class="header-side">
<NavMenu />
<Link to="/" class="button plain">
<Icon icon="home" size="l" />
</Link>
</div>
<h1>Filters</h1>
<div class="header-side">
<button
type="button"
class="plain"
onClick={() => {
setShowFiltersAddEditModal(true);
}}
>
<Icon icon="plus" size="l" alt="New filter" />
</button>
</div>
</div>
</header>
<main>
{filters.length > 0 ? (
<>
<ul class="filters-list">
{filters.map((filter) => {
const { id, title, expiresAt, keywords } = filter;
return (
<li key={id}>
<div>
<h2>{title}</h2>
{keywords?.length > 0 && (
<div>
{keywords.map((k) => (
<>
<span class="tag collapsed insignificant">
{k.wholeWord ? `${k.keyword}` : k.keyword}
</span>{' '}
</>
))}
</div>
)}
<small class="insignificant">
<ExpiryStatus expiresAt={expiresAt} />
</small>
</div>
<button
type="button"
class="plain"
onClick={() => {
setShowFiltersAddEditModal({
filter,
});
}}
>
<Icon icon="pencil" size="l" alt="Edit filter" />
</button>
</li>
);
})}
</ul>
{filters.length > 1 && (
<footer class="ui-state">
<small class="insignificant">
{filters.length} filter
{filters.length === 1 ? '' : 's'}
</small>
</footer>
)}
</>
) : uiState === 'loading' ? (
<p class="ui-state">
<Loader />
</p>
) : uiState === 'error' ? (
<p class="ui-state">Unable to load filters.</p>
) : (
<p class="ui-state">No filters yet.</p>
)}
</main>
</div>
{!!showFiltersAddEditModal && (
<Modal
title="Add filter"
onClose={() => {
setShowFiltersAddEditModal(false);
}}
>
<FiltersAddEdit
filter={showFiltersAddEditModal?.filter}
onClose={(result) => {
if (result.state === 'success') {
reload();
}
setShowFiltersAddEditModal(false);
}}
/>
</Modal>
)}
</div>
);
}
let _id = 1;
const incID = () => _id++;
function FiltersAddEdit({ filter, onClose }) {
const { masto } = api();
const [uiState, setUIState] = useState('default');
const editMode = !!filter;
const { context, expiresAt, id, keywords, title, filterAction } =
filter || {};
const hasExpiry = !!expiresAt;
const expiresAtDate = hasExpiry && new Date(expiresAt);
const [editKeywords, setEditKeywords] = useState(keywords || []);
const keywordsRef = useRef();
// Hacky way of handling removed keywords for both existing and new ones
const [removedKeywordIDs, setRemovedKeywordIDs] = useState([]);
const [removedKeyword_IDs, setRemovedKeyword_IDs] = useState([]);
const filteredEditKeywords = editKeywords.filter(
(k) =>
!removedKeywordIDs.includes(k.id) && !removedKeyword_IDs.includes(k._id),
);
return (
<div class="sheet" id="filters-add-edit-modal">
{!!onClose && (
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
</button>
)}
<header>
<h2>{editMode ? 'Edit filter' : 'New filter'}</h2>
</header>
<main>
<form
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.target);
const title = formData.get('title');
const keywordIDs = formData.getAll('keyword_attributes[][id]');
const keywordKeywords = formData.getAll(
'keyword_attributes[][keyword]',
);
// const keywordWholeWords = formData.getAll(
// 'keyword_attributes[][whole_word]',
// );
// Not using getAll because it skips the empty checkboxes
const keywordWholeWords = [
...keywordsRef.current.querySelectorAll(
'input[name="keyword_attributes[][whole_word]"]',
),
].map((i) => i.checked);
const keywordsAttributes = keywordKeywords.map((k, i) => ({
id: keywordIDs[i] || undefined,
keyword: k,
wholeWord: keywordWholeWords[i],
}));
// if (editMode && keywords?.length) {
// // Find which one got deleted and add to keywordsAttributes
// keywords.forEach((k) => {
// if (!keywordsAttributes.find((ka) => ka.id === k.id)) {
// keywordsAttributes.push({
// ...k,
// _destroy: true,
// });
// }
// });
// }
if (editMode && removedKeywordIDs?.length) {
removedKeywordIDs.forEach((id) => {
keywordsAttributes.push({
id,
_destroy: true,
});
});
}
const context = formData.getAll('context');
let expiresIn = formData.get('expires_in');
const filterAction = formData.get('filter_action');
console.log({
title,
keywordIDs,
keywords: keywordKeywords,
wholeWords: keywordWholeWords,
keywordsAttributes,
context,
expiresIn,
filterAction,
});
// Required fields
if (!title || !context?.length) {
return;
}
setUIState('loading');
(async () => {
try {
let filterResult;
if (editMode) {
if (expiresIn === '' || expiresIn === null) {
// No value
// Preserve existing expiry if not specified
// Seconds from now to expiresAtDate
// Other clients don't do this
expiresIn = Math.floor((expiresAtDate - new Date()) / 1000);
} else if (expiresIn === '0' || expiresIn === 0) {
// 0 = Never
expiresIn = null;
} else {
expiresIn = +expiresIn;
}
filterResult = await masto.v2.filters.$select(id).update({
title,
context,
expiresIn,
keywordsAttributes,
filterAction,
});
} else {
expiresIn = +expiresIn || null;
filterResult = await masto.v2.filters.create({
title,
context,
expiresIn,
keywordsAttributes,
filterAction,
});
}
console.log({ filterResult });
setUIState('default');
onClose?.({
state: 'success',
filter: filterResult,
});
} catch (error) {
console.error(error);
setUIState('error');
alert(
editMode
? 'Unable to edit filter'
: 'Unable to create filter',
);
}
})();
}}
>
<div class="filter-form-row">
<label>
<b>Title</b>
<input
type="text"
name="title"
defaultValue={title}
disabled={uiState === 'loading'}
dir="auto"
required
/>
</label>
</div>
<div class="filter-form-keywords" ref={keywordsRef}>
{filteredEditKeywords.length ? (
<ul class="filter-keywords">
{filteredEditKeywords.map((k) => {
const { id, keyword, wholeWord, _id } = k;
return (
<li key={`${id}-${_id}`}>
<input
type="hidden"
name="keyword_attributes[][id]"
value={id}
/>
<input
name="keyword_attributes[][keyword]"
type="text"
defaultValue={keyword}
disabled={uiState === 'loading'}
required
/>
<div class="filter-keyword-actions">
<label>
<input
name="keyword_attributes[][whole_word]"
type="checkbox"
value={id} // Hacky way to map checkbox boolean to the keyword id
defaultChecked={wholeWord}
disabled={uiState === 'loading'}
/>{' '}
Whole word
</label>
<button
type="button"
class="light danger small"
disabled={uiState === 'loading'}
onClick={() => {
if (id) {
removedKeywordIDs.push(id);
setRemovedKeywordIDs([...removedKeywordIDs]);
} else if (_id) {
removedKeyword_IDs.push(_id);
setRemovedKeyword_IDs([...removedKeyword_IDs]);
}
}}
>
<Icon icon="x" />
</button>
</div>
</li>
);
})}
</ul>
) : (
<div class="filter-keywords">
<div class="insignificant">No keywords. Add one.</div>
</div>
)}
<footer class="filter-keywords-footer">
<button
type="button"
class="light"
onClick={() => {
setEditKeywords([
...editKeywords,
{
_id: incID(),
keyword: '',
wholeWord: true,
},
]);
setTimeout(() => {
// Focus last input
const fields =
keywordsRef.current.querySelectorAll(
'input[type="text"]',
);
fields[fields.length - 1]?.focus?.();
}, 10);
}}
>
Add keyword
</button>{' '}
{filteredEditKeywords?.length > 1 && (
<small class="insignificant">
{filteredEditKeywords.length} keyword
{filteredEditKeywords.length === 1 ? '' : 's'}
</small>
)}
</footer>
</div>
<div class="filter-form-cols">
<div class="filter-form-col">
<div>
<b>Filter from</b>
</div>
{FILTER_CONTEXT.map((ctx) => (
<div>
<label
class={
FILTER_CONTEXT_UNIMPLEMENTED.includes(ctx)
? 'insignificant'
: ''
}
>
<input
type="checkbox"
name="context"
value={ctx}
defaultChecked={!!context ? context.includes(ctx) : true}
disabled={uiState === 'loading'}
/>{' '}
{FILTER_CONTEXT_LABELS[ctx]}
{FILTER_CONTEXT_UNIMPLEMENTED.includes(ctx) ? '*' : ''}
</label>{' '}
</div>
))}
<p>
<small class="insignificant">* Not implemented yet</small>
</p>
</div>
<div class="filter-form-col">
{editMode && (
<>
Status:{' '}
<b>
<ExpiryStatus expiresAt={expiresAt} showNeverExpires />
</b>
</>
)}
<div>
<label for="filters-expires_in">
{editMode ? 'Change expiry' : 'Expiry'}
</label>
<select
id="filters-expires_in"
name="expires_in"
disabled={uiState === 'loading'}
defaultValue={editMode ? undefined : 0}
>
{editMode && <option></option>}
{EXPIRY_DURATIONS.map((v) => (
<option value={v}>{EXPIRY_DURATIONS_LABELS[v]}</option>
))}
</select>
</div>
<p>
Filtered post will be
<br />
<label class="ib">
<input
type="radio"
name="filter_action"
value="warn"
defaultChecked={filterAction === 'warn' || !editMode}
disabled={uiState === 'loading'}
/>{' '}
minimized
</label>{' '}
<label class="ib">
<input
type="radio"
name="filter_action"
value="hide"
defaultChecked={filterAction === 'hide'}
disabled={uiState === 'loading'}
/>{' '}
hidden
</label>
</p>
</div>
</div>
<footer class="filter-form-footer">
<span>
<button type="submit" disabled={uiState === 'loading'}>
{editMode ? 'Save' : 'Create'}
</button>{' '}
<Loader abrupt hidden={uiState !== 'loading'} />
</span>
{editMode && (
<MenuConfirm
disabled={uiState === 'loading'}
align="end"
menuItemClassName="danger"
confirmLabel="Delete this filter?"
onClick={() => {
setUIState('loading');
(async () => {
try {
await masto.v2.filters.$select(id).remove();
setUIState('default');
onClose?.({
state: 'success',
});
} catch (e) {
console.error(e);
setUIState('error');
alert('Unable to delete filter.');
}
})();
}}
>
<button
type="button"
class="light danger"
onClick={() => {}}
disabled={uiState === 'loading'}
>
Delete
</button>
</MenuConfirm>
)}
</footer>
</form>
</main>
</div>
);
}
function ExpiryStatus({ expiresAt, showNeverExpires }) {
const hasExpiry = !!expiresAt;
const expiresAtDate = hasExpiry && new Date(expiresAt);
const expired = hasExpiry && expiresAtDate <= new Date();
// If less than a minute left, re-render interval every second, else every minute
const [_, rerender] = useReducer((c) => c + 1, 0);
useInterval(rerender, expired || 30_000);
return expired ? (
'Expired'
) : hasExpiry ? (
<>
Expiring <RelativeTime datetime={expiresAtDate} />
</>
) : (
showNeverExpires && 'Never expires'
);
}
export default Filters;

View file

@ -10,7 +10,7 @@ import useTitle from '../utils/useTitle';
function FollowedHashtags() {
const { masto, instance } = api();
useTitle(`Followed Hashtags`, `/ft`);
useTitle(`Followed Hashtags`, `/fh`);
const [uiState, setUIState] = useState('default');
const [followedHashtags, setFollowedHashtags] = useState([]);

View file

@ -285,7 +285,7 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
required
autocorrect="off"
autocapitalize="off"
spellcheck={false}
spellCheck={false}
// no spaces, no hashtags
pattern="[^#][^\s#]+[^#]"
disabled={reachLimit}

View file

@ -1,6 +1,6 @@
import './lists.css';
import { Menu, MenuItem } from '@szhsin/react-menu';
import { Menu, MenuDivider, MenuItem } from '@szhsin/react-menu';
import { useEffect, useRef, useState } from 'preact/hooks';
import { InView } from 'react-intersection-observer';
import { useNavigate, useParams } from 'react-router-dom';
@ -12,10 +12,12 @@ import Link from '../components/link';
import ListAddEdit from '../components/list-add-edit';
import Menu2 from '../components/menu2';
import MenuConfirm from '../components/menu-confirm';
import MenuLink from '../components/menu-link';
import Modal from '../components/modal';
import Timeline from '../components/timeline';
import { api } from '../utils/api';
import { filteredItems } from '../utils/filters';
import { getList, getLists } from '../utils/lists';
import states, { saveStatus } from '../utils/states';
import useTitle from '../utils/useTitle';
@ -71,13 +73,18 @@ function List(props) {
}
}
const [lists, setLists] = useState([]);
useEffect(() => {
getLists().then(setLists);
}, []);
const [list, setList] = useState({ title: 'List' });
// const [title, setTitle] = useState(`List`);
useTitle(list.title, `/l/:id`);
useEffect(() => {
(async () => {
try {
const list = await masto.v1.lists.$select(id).fetch();
const list = await getList(id);
setList(list);
// setTitle(list.title);
} catch (e) {
@ -107,9 +114,32 @@ function List(props) {
showReplyParent
// refresh={reloadCount}
headerStart={
<Link to="/l" class="button plain">
<Icon icon="list" size="l" />
</Link>
// <Link to="/l" class="button plain">
// <Icon icon="list" size="l" />
// </Link>
<Menu2
overflow="auto"
menuButton={
<button type="button" class="plain">
<Icon icon="list" size="l" alt="Lists" />
<Icon icon="chevron-down" size="s" />
</button>
}
>
<MenuLink to="/l">
<span>All Lists</span>
</MenuLink>
{lists?.length > 0 && (
<>
<MenuDivider />
{lists.map((list) => (
<MenuLink key={list.id} to={`/l/${list.id}`}>
<span>{list.title}</span>
</MenuLink>
))}
</>
)}
</Menu2>
}
headerEnd={
<Menu2

View file

@ -8,11 +8,10 @@ import ListAddEdit from '../components/list-add-edit';
import Loader from '../components/loader';
import Modal from '../components/modal';
import NavMenu from '../components/nav-menu';
import { api } from '../utils/api';
import { fetchLists } from '../utils/lists';
import useTitle from '../utils/useTitle';
function Lists() {
const { masto } = api();
useTitle(`Lists`, `/l`);
const [uiState, setUIState] = useState('default');
@ -22,8 +21,7 @@ function Lists() {
setUIState('loading');
(async () => {
try {
const lists = await masto.v1.lists.list();
lists.sort((a, b) => a.title.localeCompare(b.title));
const lists = await fetchLists();
console.log(lists);
setLists(lists);
setUIState('default');

View file

@ -11,6 +11,7 @@ import instancesListURL from '../data/instances.json?url';
import { getAuthorizationURL, registerApplication } from '../utils/auth';
import store from '../utils/store';
import useTitle from '../utils/useTitle';
import { gtsDtth } from '../utils/dtth';
const { PHANPY_DEFAULT_INSTANCE: DEFAULT_INSTANCE } = import.meta.env;
@ -23,7 +24,7 @@ function Login() {
const instance = searchParams.get('instance');
const submit = searchParams.get('submit');
const [instanceText, setInstanceText] = useState(
instance || cachedInstanceURL?.toLowerCase() || '',
instance || cachedInstanceURL?.toLowerCase() || gtsDtth,
);
const [instancesList, setInstancesList] = useState([]);
@ -160,7 +161,7 @@ function Login() {
autocorrect="off"
autocapitalize="off"
autocomplete="off"
spellcheck={false}
spellCheck={false}
placeholder="instance domain"
onInput={(e) => {
setInstanceText(e.target.value);

View file

@ -143,6 +143,7 @@
border-color: var(--reply-to-color);
box-shadow: 0 0 0 3px var(--reply-to-faded-color);
}
.notification:focus-visible .status-link,
.notification .status-link:is(:hover, :focus) {
background-color: var(--bg-blur-color);
filter: saturate(1);
@ -420,3 +421,145 @@
color: var(--text-color);
background-color: var(--link-faded-color);
}
/* FILTERED NOTIFICATIONS */
.filtered-notifications {
padding-block-end: 16px;
summary {
padding: 8px 16px;
cursor: pointer;
font-weight: 600;
user-select: none;
margin: 16px 0 0;
color: var(--text-insignificant-color);
&::marker,
&::-webkit-details-marker {
color: var(--text-insignificant-color);
}
}
details[open] summary {
color: var(--text-color);
}
summary + ul {
}
ul {
list-style: none;
padding: 0;
margin: 0;
max-height: 50vh;
max-height: 50dvh;
overflow: auto;
border-top: 1px solid var(--outline-color);
border-bottom: 1px solid var(--outline-color);
background-color: var(--bg-faded-color);
@media (min-width: 40em) {
background-color: var(--bg-color);
border-radius: 16px;
border-width: 0;
}
li {
display: flex;
padding: 16px;
row-gap: 8px;
column-gap: 16px;
border-bottom: 1px solid var(--outline-color);
}
li:last-child {
border-bottom: none;
}
.request-notifcations {
min-width: 0;
flex-grow: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
.last-post {
max-width: 100%;
> .status-link {
border-radius: 8px;
overflow: hidden;
--max-height: 160px;
max-height: var(--max-height);
border: 1px solid var(--outline-color);
&:is(:hover, :focus-visible) {
border-color: var(--outline-hover-color);
}
.status {
mask-image: linear-gradient(
to bottom,
black calc(var(--max-height) / 2),
transparent calc(var(--max-height) - 8px)
);
font-size: calc(var(--text-size) * 0.9);
.content-container {
pointer-events: none;
filter: saturate(0.5);
}
}
}
}
.request-notifications-account {
display: flex;
align-items: center;
gap: 4px;
}
}
.notification-request-buttons {
grid-area: buttons;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
button {
max-width: 30vw;
}
.notification-request-states {
min-height: 32px;
text-align: center;
vertical-align: middle;
.icon {
margin-inline: 8px;
&.notification-accepted {
color: var(--green-color);
}
&.notification-dismissed {
color: var(--red-color);
}
}
}
}
}
}
#notifications-settings {
label {
display: flex;
gap: 8px;
align-items: center;
input[type='checkbox'] {
flex-shrink: 0;
}
}
}

View file

@ -3,6 +3,7 @@ import './notifications.css';
import { Fragment } from 'preact';
import { memo } from 'preact/compat';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
import { InView } from 'react-intersection-observer';
import { useSearchParams } from 'react-router-dom';
import { useSnapshot } from 'valtio';
@ -13,8 +14,10 @@ import FollowRequestButtons from '../components/follow-request-buttons';
import Icon from '../components/icon';
import Link from '../components/link';
import Loader from '../components/loader';
import Modal from '../components/modal';
import NavMenu from '../components/nav-menu';
import Notification from '../components/notification';
import Status from '../components/status';
import { api } from '../utils/api';
import enhanceContent from '../utils/enhance-content';
import groupNotifications from '../utils/group-notifications';
@ -22,8 +25,10 @@ import handleContentLinks from '../utils/handle-content-links';
import niceDateTime from '../utils/nice-date-time';
import { getRegistration } from '../utils/push-notifications';
import shortenNumber from '../utils/shorten-number';
import showToast from '../utils/show-toast';
import states, { saveStatus } from '../utils/states';
import { getCurrentInstance } from '../utils/store-utils';
import supports from '../utils/supports';
import usePageVisibility from '../utils/usePageVisibility';
import useScroll from '../utils/useScroll';
import useTitle from '../utils/useTitle';
@ -31,6 +36,12 @@ import useTitle from '../utils/useTitle';
const LIMIT = 30; // 30 is the maximum limit :(
const emptySearchParams = new URLSearchParams();
const scrollIntoViewOptions = {
block: 'center',
inline: 'center',
behavior: 'smooth',
};
function Notifications({ columnMode }) {
useTitle('Notifications', '/notifications');
const { masto, instance } = api();
@ -129,6 +140,28 @@ function Notifications({ columnMode }) {
}
}
const supportsFilteredNotifications = supports(
'@mastodon/filtered-notifications',
);
const [showNotificationsSettings, setShowNotificationsSettings] =
useState(false);
const [notificationsPolicy, setNotificationsPolicy] = useState({});
function fetchNotificationsPolicy() {
return masto.v1.notifications.policy.fetch().catch(() => {});
}
function loadNotificationsPolicy() {
fetchNotificationsPolicy()
.then((policy) => {
console.log('✨ Notifications policy', policy);
setNotificationsPolicy(policy);
})
.catch(() => {});
}
const [notificationsRequests, setNotificationsRequests] = useState(null);
function fetchNotificationsRequest() {
return masto.v1.notifications.requests.list();
}
const loadNotifications = (firstLoad) => {
setShowNew(false);
setUIState('loading');
@ -154,6 +187,10 @@ function Notifications({ columnMode }) {
setFollowRequests(requests);
})
.catch(() => {});
if (supportsFilteredNotifications) {
loadNotificationsPolicy();
}
}
const { done } = await fetchNotificationsPromise;
@ -161,6 +198,7 @@ function Notifications({ columnMode }) {
setUIState('default');
} catch (e) {
console.error(e);
setUIState('error');
}
})();
@ -221,6 +259,9 @@ function Notifications({ columnMode }) {
lastHiddenTime.current = Date.now();
}
unsub = subscribeKey(states, 'notificationsShowNew', (v) => {
if (uiState === 'loading') {
return;
}
if (v) {
loadUpdates();
}
@ -270,11 +311,84 @@ function Notifications({ columnMode }) {
// }
// }, [uiState]);
const itemsSelector = '.notification';
const jRef = useHotkeys('j', () => {
const activeItem = document.activeElement.closest(itemsSelector);
const activeItemRect = activeItem?.getBoundingClientRect();
const allItems = Array.from(
scrollableRef.current.querySelectorAll(itemsSelector),
);
if (
activeItem &&
activeItemRect.top < scrollableRef.current.clientHeight &&
activeItemRect.bottom > 0
) {
const activeItemIndex = allItems.indexOf(activeItem);
let nextItem = allItems[activeItemIndex + 1];
if (nextItem) {
nextItem.focus();
nextItem.scrollIntoView(scrollIntoViewOptions);
}
} else {
const topmostItem = allItems.find((item) => {
const itemRect = item.getBoundingClientRect();
return itemRect.top >= 44 && itemRect.left >= 0;
});
if (topmostItem) {
topmostItem.focus();
topmostItem.scrollIntoView(scrollIntoViewOptions);
}
}
});
const kRef = useHotkeys('k', () => {
// focus on previous status after active item
const activeItem = document.activeElement.closest(itemsSelector);
const activeItemRect = activeItem?.getBoundingClientRect();
const allItems = Array.from(
scrollableRef.current.querySelectorAll(itemsSelector),
);
if (
activeItem &&
activeItemRect.top < scrollableRef.current.clientHeight &&
activeItemRect.bottom > 0
) {
const activeItemIndex = allItems.indexOf(activeItem);
let prevItem = allItems[activeItemIndex - 1];
if (prevItem) {
prevItem.focus();
prevItem.scrollIntoView(scrollIntoViewOptions);
}
} else {
const topmostItem = allItems.find((item) => {
const itemRect = item.getBoundingClientRect();
return itemRect.top >= 44 && itemRect.left >= 0;
});
if (topmostItem) {
topmostItem.focus();
topmostItem.scrollIntoView(scrollIntoViewOptions);
}
}
});
const oRef = useHotkeys(['enter', 'o'], () => {
const activeItem = document.activeElement.closest(itemsSelector);
const statusLink = activeItem?.querySelector('.status-link');
if (statusLink) {
statusLink.click();
}
});
return (
<div
id="notifications-page"
class="deck-container"
ref={scrollableRef}
ref={(node) => {
scrollableRef.current = node;
jRef.current = node;
kRef.current = node;
oRef.current = node;
}}
tabIndex="-1"
>
<div class={`timeline-deck deck ${onlyMentions ? 'only-mentions' : ''}`}>
@ -301,7 +415,17 @@ function Notifications({ columnMode }) {
</div>
<h1>Notifications</h1>
<div class="header-side">
{/* <Loader hidden={uiState !== 'loading'} /> */}
{supportsFilteredNotifications && (
<button
type="button"
class="button plain"
onClick={() => {
setShowNotificationsSettings(true);
}}
>
<Icon icon="settings" size="l" alt="Notifications settings" />
</button>
)}
</div>
</div>
{showNew && uiState !== 'loading' && (
@ -406,6 +530,76 @@ function Notifications({ columnMode }) {
)}
</div>
)}
{supportsFilteredNotifications &&
notificationsPolicy?.summary?.pendingRequestsCount > 0 && (
<div class="shazam-container">
<div class="shazam-container-inner">
<div class="filtered-notifications">
<details
onToggle={async (e) => {
const { open } = e.target;
if (open) {
const requests = await fetchNotificationsRequest();
setNotificationsRequests(requests);
console.log({ open, requests });
}
}}
>
<summary>
Filtered notifications from{' '}
{notificationsPolicy.summary.pendingRequestsCount} people
</summary>
{!notificationsRequests ? (
<p class="ui-state">
<Loader abrupt />
</p>
) : (
notificationsRequests?.length > 0 && (
<ul>
{notificationsRequests.map((request) => (
<li key={request.id}>
<div class="request-notifcations">
{!request.lastStatus?.id && (
<AccountBlock
useAvatarStatic
showStats
account={request.account}
/>
)}
{request.lastStatus?.id && (
<div class="last-post">
<Link
class="status-link"
to={`/${instance}/s/${request.lastStatus.id}`}
>
<Status
status={request.lastStatus}
size="s"
readOnly
/>
</Link>
</div>
)}
<NotificationRequestModalButton
request={request}
/>
</div>
<NotificationRequestButtons
request={request}
onChange={() => {
loadNotifications(true);
}}
/>
</li>
))}
</ul>
)
)}
</details>
</div>
</div>
</div>
)}
<div id="mentions-option">
<label>
<input
@ -514,6 +708,109 @@ function Notifications({ columnMode }) {
</InView>
)}
</div>
{supportsFilteredNotifications && showNotificationsSettings && (
<Modal
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowNotificationsSettings(false);
}
}}
>
<div class="sheet" id="notifications-settings" tabIndex="-1">
<button
type="button"
class="sheet-close"
onClick={() => setShowNotificationsSettings(false)}
>
<Icon icon="x" />
</button>
<header>
<h2>Notifications settings</h2>
</header>
<main>
<form
onSubmit={(e) => {
e.preventDefault();
const {
filterNotFollowing,
filterNotFollowers,
filterNewAccounts,
filterPrivateMentions,
} = e.target;
const allFilters = {
filterNotFollowing: filterNotFollowing.checked,
filterNotFollowers: filterNotFollowers.checked,
filterNewAccounts: filterNewAccounts.checked,
filterPrivateMentions: filterPrivateMentions.checked,
};
setNotificationsPolicy({
...notificationsPolicy,
...allFilters,
});
setShowNotificationsSettings(false);
(async () => {
try {
await masto.v1.notifications.policy.update(allFilters);
showToast('Notifications settings updated');
} catch (e) {
console.error(e);
}
})();
}}
>
<p>Filter out notifications from people:</p>
<p>
<label>
<input
type="checkbox"
switch
defaultChecked={notificationsPolicy.filterNotFollowing}
name="filterNotFollowing"
/>{' '}
You don't follow
</label>
</p>
<p>
<label>
<input
type="checkbox"
switch
defaultChecked={notificationsPolicy.filterNotFollowers}
name="filterNotFollowers"
/>{' '}
Who don't follow you
</label>
</p>
<p>
<label>
<input
type="checkbox"
switch
defaultChecked={notificationsPolicy.filterNewAccounts}
name="filterNewAccounts"
/>{' '}
With a new account
</label>
</p>
<p>
<label>
<input
type="checkbox"
switch
defaultChecked={notificationsPolicy.filterPrivateMentions}
name="filterPrivateMentions"
/>{' '}
Who unsolicitedly private mention you
</label>
</p>
<p>
<button type="submit">Save</button>
</p>
</form>
</main>
</div>
</Modal>
)}
</div>
);
}
@ -596,4 +893,186 @@ function AnnouncementBlock({ announcement }) {
);
}
function fetchNotficationsByAccount(accountID) {
const { masto } = api();
return masto.v1.notifications.list({
accountID,
});
}
function NotificationRequestModalButton({ request }) {
const { instance } = api();
const [uiState, setUIState] = useState('loading');
const { account, lastStatus } = request;
const [showModal, setShowModal] = useState(false);
const [notifications, setNotifications] = useState([]);
function onClose() {
setShowModal(false);
}
useEffect(() => {
if (!request?.account?.id) return;
if (!showModal) return;
setUIState('loading');
(async () => {
const notifs = await fetchNotficationsByAccount(request.account.id);
setNotifications(notifs || []);
setUIState('default');
})();
}, [showModal, request?.account?.id]);
return (
<>
<button
type="button"
class="plain4 request-notifications-account"
onClick={() => {
setShowModal(true);
}}
>
<Icon icon="notification" class="more-insignificant" />{' '}
<small>View notifications from @{account.username}</small>{' '}
<Icon icon="chevron-down" />
</button>
{showModal && (
<Modal
onClick={(e) => {
if (e.target === e.currentTarget) {
onClose();
}
}}
>
<div class="sheet" tabIndex="-1">
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
</button>
<header>
<b>Notifications from @{account.username}</b>
</header>
<main>
{uiState === 'loading' ? (
<p class="ui-state">
<Loader abrupt />
</p>
) : (
notifications.map((notification) => (
<div
class="notification-peek"
onClick={(e) => {
const { target } = e;
// If button or links
if (
e.target.tagName === 'BUTTON' ||
e.target.tagName === 'A'
) {
onClose();
}
}}
>
<Notification
instance={instance}
notification={notification}
isStatic
/>
</div>
))
)}
</main>
</div>
</Modal>
)}
</>
);
}
function NotificationRequestButtons({ request, onChange }) {
const { masto } = api();
const [uiState, setUIState] = useState('default');
const [requestState, setRequestState] = useState(null); // accept, dismiss
const hasRequestState = requestState !== null;
return (
<p class="notification-request-buttons">
<button
type="button"
disabled={uiState === 'loading' || hasRequestState}
onClick={() => {
setUIState('loading');
(async () => {
try {
await masto.v1.notifications.requests
.$select(request.id)
.accept();
setRequestState('accept');
setUIState('default');
onChange({
request,
state: 'accept',
});
showToast(
`Notifications from @${request.account.username} will not be filtered from now on.`,
);
} catch (error) {
setUIState('error');
console.error(error);
showToast(`Unable to accept notification request`);
}
})();
}}
>
Allow
</button>{' '}
<button
type="button"
disabled={uiState === 'loading' || hasRequestState}
class="light danger"
onClick={() => {
setUIState('loading');
(async () => {
try {
await masto.v1.notifications.requests
.$select(request.id)
.dismiss();
setRequestState('dismiss');
setUIState('default');
onChange({
request,
state: 'dismiss',
});
showToast(
`Notifications from @${request.account.username} will not show up in Filtered notifications from now on.`,
);
} catch (error) {
setUIState('error');
console.error(error);
showToast(`Unable to dismiss notification request`);
}
})();
}}
>
Dismiss
</button>
<span class="notification-request-states">
{uiState === 'loading' ? (
<Loader abrupt />
) : requestState === 'accept' ? (
<Icon
icon="check-circle"
alt="Accepted"
class="notification-accepted"
/>
) : (
requestState === 'dismiss' && (
<Icon
icon="x-circle"
alt="Dismissed"
class="notification-dismissed"
/>
)
)}
</span>
</p>
);
}
export default memo(Notifications);

View file

@ -571,14 +571,18 @@ function Settings({ onClose }) {
}}
>
@phanpy
</a>
</a> (
<a href="https://git.dtth.ch/nki/phanpy" target="_blank">
DTTH Fork
</a>
)
<br />
<a
href="https://github.com/cheeaun/phanpy"
target="_blank"
rel="noopener noreferrer"
>
Built
Original
</a>{' '}
by{' '}
<a
@ -633,10 +637,10 @@ function Settings({ onClose }) {
type="text"
class="version-string"
readOnly
size="18" // Manually calculated here
size={10 /* build time */ + (1+8) /* commit hash */ + '-dtth'.length}
value={`${__BUILD_TIME__.slice(0, 10).replace(/-/g, '.')}${
__COMMIT_HASH__ ? `.${__COMMIT_HASH__}` : ''
}`}
__COMMIT_HASH__ ? `.${__COMMIT_HASH__.slice(0, 8)}` : ''
}-dtth`}
onClick={(e) => {
e.target.select();
// Copy to clipboard
@ -653,7 +657,7 @@ function Settings({ onClose }) {
<span class="ib insignificant">
(
<a
href={`https://github.com/cheeaun/phanpy/commit/${__COMMIT_HASH__}`}
href={`https://git.dtth.ch/nki/phanpy/commit/${__COMMIT_HASH__}`}
target="_blank"
rel="noopener noreferrer"
>
@ -690,9 +694,10 @@ function PushNotificationsSection({ onClose }) {
) {
setAllowNotifications(true);
const { alerts, policy } = backendSubscription;
console.log('backendSubscription', backendSubscription);
previousPolicyRef.current = policy;
const { elements } = pushFormRef.current;
const policyEl = elements.namedItem(policy);
const policyEl = elements.namedItem('policy');
if (policyEl) policyEl.value = policy;
// alerts is {}, iterate it
Object.keys(alerts).forEach((alert) => {
@ -721,65 +726,68 @@ function PushNotificationsSection({ onClose }) {
<form
ref={pushFormRef}
onChange={() => {
const values = Object.fromEntries(new FormData(pushFormRef.current));
const allowNotifications = !!values['policy-allow'];
const params = {
policy: values.policy,
data: {
alerts: {
mention: !!values.mention,
favourite: !!values.favourite,
reblog: !!values.reblog,
follow: !!values.follow,
follow_request: !!values.followRequest,
poll: !!values.poll,
update: !!values.update,
status: !!values.status,
setTimeout(() => {
const values = Object.fromEntries(new FormData(pushFormRef.current));
const allowNotifications = !!values['policy-allow'];
const params = {
data: {
policy: values.policy,
alerts: {
mention: !!values.mention,
favourite: !!values.favourite,
reblog: !!values.reblog,
follow: !!values.follow,
follow_request: !!values.followRequest,
poll: !!values.poll,
update: !!values.update,
status: !!values.status,
},
},
},
};
};
let alertsCount = 0;
// Remove false values from data.alerts
// API defaults to false anyway
Object.keys(params.data.alerts).forEach((key) => {
if (!params.data.alerts[key]) {
delete params.data.alerts[key];
} else {
alertsCount++;
}
});
const policyChanged = previousPolicyRef.current !== params.policy;
let alertsCount = 0;
// Remove false values from data.alerts
// API defaults to false anyway
Object.keys(params.data.alerts).forEach((key) => {
if (!params.data.alerts[key]) {
delete params.data.alerts[key];
} else {
alertsCount++;
}
});
const policyChanged =
previousPolicyRef.current !== params.data.policy;
console.log('PN Form', {
values,
allowNotifications: allowNotifications,
params,
});
console.log('PN Form', {
values,
allowNotifications: allowNotifications,
params,
});
if (allowNotifications && alertsCount > 0) {
if (policyChanged) {
console.debug('Policy changed.');
removeSubscription()
.then(() => {
updateSubscription(params);
})
.catch((err) => {
if (allowNotifications && alertsCount > 0) {
if (policyChanged) {
console.debug('Policy changed.');
removeSubscription()
.then(() => {
updateSubscription(params);
})
.catch((err) => {
console.warn(err);
alert('Failed to update subscription. Please try again.');
});
} else {
updateSubscription(params).catch((err) => {
console.warn(err);
alert('Failed to update subscription. Please try again.');
});
}
} else {
updateSubscription(params).catch((err) => {
removeSubscription().catch((err) => {
console.warn(err);
alert('Failed to update subscription. Please try again.');
alert('Failed to remove subscription. Please try again.');
});
}
} else {
removeSubscription().catch((err) => {
console.warn(err);
alert('Failed to remove subscription. Please try again.');
});
}
}, 100);
}}
>
<h3>Push Notifications (beta)</h3>

View file

@ -217,13 +217,23 @@ function Trending({ columnMode, ...props }) {
)}
</div>
{!!title && (
<h1 class="title" lang={language} dir="auto">
<h1
class="title"
lang={language}
dir="auto"
title={title}
>
{title}
</h1>
)}
</header>
{!!description && (
<p class="description" lang={language} dir="auto">
<p
class="description"
lang={language}
dir="auto"
title={description}
>
{description}
</p>
)}

View file

@ -104,6 +104,7 @@ function Welcome() {
</a>
.
</p>
<p class="desc">A minimalistic opinionated DTTHDon web client.</p>
</div>
<div id="why-container">
<div class="sections">
@ -162,6 +163,38 @@ function Welcome() {
</section>
</div>
</div>
<footer>
<hr />
<p>
<a href="https://git.dtth.ch/nki/phanpy" target="_blank">
DTTHDon Fork
</a>
</p>
<p>
<a href="https://github.com/cheeaun/phanpy" target="_blank">
Original built
</a>{' '}
by{' '}
<a
href="https://mastodon.social/@cheeaun"
target="_blank"
onClick={(e) => {
e.preventDefault();
states.showAccount = 'cheeaun@mastodon.social';
}}
>
@cheeaun
</a>
.{' '}
<a
href="https://github.com/cheeaun/phanpy/blob/main/PRIVACY.MD"
target="_blank"
>
Privacy Policy
</a>
.
</p>
</footer>
</main>
);
}

13
src/utils/dtth.js Normal file
View file

@ -0,0 +1,13 @@
export function accountsIsDtth(account) {
return (
account.info &&
typeof account.info.url === 'string' &&
account.info.url.startsWith(gtsDtth)
);
}
/** URL to DTTHDon */
export const gtsDtth = 'https://gts.dtth.ch';
/** URL to DTTHDon settings */
export const gtsDtthSettings = 'https://gts.dtth.ch/settings';

View file

@ -63,11 +63,11 @@ function groupNotifications(notifications) {
mappedNotification.id += `-${id}`;
}
} else {
account._types = [type];
if (account) account._types = [type];
let n = (notificationsMap[key] = {
...notification,
type: virtualType,
_accounts: [account],
_accounts: account ? [account] : [],
});
cleanNotifications[j++] = n;
}

View file

@ -16,7 +16,9 @@ function handleContentLinks(opts) {
const textBeforeLinkIsAt = prevText?.endsWith('@');
const textStartsWithAt = target.innerText.startsWith('@');
if (
(target.classList.contains('u-url') && textStartsWithAt) ||
((target.classList.contains('u-url') ||
target.classList.contains('mention')) &&
textStartsWithAt) ||
(textBeforeLinkIsAt && !textStartsWithAt)
) {
const targetText = (
@ -24,12 +26,14 @@ function handleContentLinks(opts) {
).innerText.trim();
const username = targetText.replace(/^@/, '');
const url = target.getAttribute('href');
const mention = mentions.find(
(mention) =>
mention.username === username ||
mention.acct === username ||
mention.url === url,
);
// Only fallback to acct/username check if url doesn't match
const mention =
mentions.find((mention) => mention.url === url) ||
mentions.find(
(mention) =>
mention.acct === username || mention.username === username,
);
console.warn('MENTION', mention, url);
if (mention) {
e.preventDefault();
e.stopPropagation();

114
src/utils/lists.js Normal file
View file

@ -0,0 +1,114 @@
import { api } from './api';
import pmem from './pmem';
import store from './store';
const FETCH_MAX_AGE = 1000 * 60; // 1 minute
const MAX_AGE = 24 * 60 * 60 * 1000; // 1 day
export const fetchLists = pmem(
async () => {
const { masto } = api();
const lists = await masto.v1.lists.list();
lists.sort((a, b) => a.title.localeCompare(b.title));
if (lists.length) {
setTimeout(() => {
// Save to local storage, with saved timestamp
store.account.set('lists', {
lists,
updatedAt: Date.now(),
});
}, 1);
}
return lists;
},
{
maxAge: FETCH_MAX_AGE,
},
);
export async function getLists() {
try {
const { lists, updatedAt } = store.account.get('lists') || {};
if (!lists?.length) return await fetchLists();
if (Date.now() - updatedAt > MAX_AGE) {
// Stale-while-revalidate
fetchLists();
return lists;
}
return lists;
} catch (e) {
return [];
}
}
export const fetchList = pmem(
(id) => {
const { masto } = api();
return masto.v1.lists.$select(id).fetch();
},
{
maxAge: FETCH_MAX_AGE,
},
);
export async function getList(id) {
const { lists } = store.account.get('lists') || {};
console.log({ lists });
if (lists?.length) {
const theList = lists.find((l) => l.id === id);
if (theList) return theList;
}
try {
return fetchList(id);
} catch (e) {
return null;
}
}
export async function getListTitle(id) {
const list = await getList(id);
return list?.title || '';
}
export function addListStore(list) {
const { lists } = store.account.get('lists') || {};
if (lists?.length) {
lists.push(list);
lists.sort((a, b) => a.title.localeCompare(b.title));
store.account.set('lists', {
lists,
updatedAt: Date.now(),
});
}
}
export function updateListStore(list) {
const { lists } = store.account.get('lists') || {};
if (lists?.length) {
const index = lists.findIndex((l) => l.id === list.id);
if (index !== -1) {
lists[index] = list;
lists.sort((a, b) => a.title.localeCompare(b.title));
store.account.set('lists', {
lists,
updatedAt: Date.now(),
});
}
}
}
export function deleteListStore(listID) {
const { lists } = store.account.get('lists') || {};
if (lists?.length) {
const index = lists.findIndex((l) => l.id === listID);
if (index !== -1) {
lists.splice(index, 1);
store.account.set('lists', {
lists,
updatedAt: Date.now(),
});
}
}
}

16
src/utils/open-osk.jsx Normal file
View file

@ -0,0 +1,16 @@
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); // https://stackoverflow.com/a/23522755
export default function openOSK() {
if (isSafari) {
const fauxEl = document.createElement('input');
fauxEl.style.position = 'absolute';
fauxEl.style.top = '0';
fauxEl.style.left = '0';
fauxEl.style.opacity = '0';
document.body.appendChild(fauxEl);
fauxEl.focus();
setTimeout(() => {
document.body.removeChild(fauxEl);
}, 500);
}
}

View file

@ -9,8 +9,10 @@ function statusPeek(status) {
text += getHTMLText(content);
}
text = text.trim();
if (poll) {
text += ' 📊';
if (poll?.options?.length) {
text += `\n\n📊:\n${poll.options
.map((o) => `${poll.multiple ? '▪️' : '•'} ${o.title}`)
.join('\n')}`;
}
if (mediaAttachments?.length) {
text +=

View file

@ -2,6 +2,7 @@ import store from './store';
export function getAccount(id) {
const accounts = store.local.getJSON('accounts') || [];
if (!id) return accounts[0];
return accounts.find((a) => a.info.id === id) || accounts[0];
}

View file

@ -24,8 +24,12 @@ try {
} catch (error) {
// If error, means git is not installed or not a git repo (could be downloaded instead of git cloned)
// Fallback to random hash which should be different on every build run 🤞
commitHash = uid();
fakeCommitHash = true;
if (process.env.PHANPY_COMMIT_HASH) {
commitHash = process.env.PHANPY_COMMIT_HASH;
} else {
commitHash = uid();
fakeCommitHash = true;
}
}
const rollbarCode = fs.readFileSync(
@ -110,6 +114,7 @@ export default defineConfig({
],
build: {
sourcemap: true,
cssCodeSplit: false,
rollupOptions: {
treeshake: false,
input: {
@ -117,9 +122,9 @@ export default defineConfig({
compose: resolve(__dirname, 'compose/index.html'),
},
output: {
manualChunks: {
'intl-segmenter-polyfill': ['@formatjs/intl-segmenter/polyfill'],
},
// manualChunks: {
// 'intl-segmenter-polyfill': ['@formatjs/intl-segmenter/polyfill'],
// },
chunkFileNames: (chunkInfo) => {
const { facadeModuleId } = chunkInfo;
if (facadeModuleId && facadeModuleId.includes('icon')) {