Merge pull request #3524 from glitch-soc/glitch-soc/merge-upstream

Merge upstream changes up to 52aa8a44bbd0c9a6673cbd9b7c088020be938799
This commit is contained in:
Claire 2026-06-03 23:05:36 +02:00 committed by GitHub
commit 1c9b6625e2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 1679 additions and 1205 deletions

View File

@ -2,6 +2,145 @@
All notable changes to this project will be documented in this file.
## [4.6.0] - UNRELEASED
### Added
- **Add collections** (#37992, #37005, #37049, #37020, #37053, #37110, #37117, #37122, #37154, #37157, #37176, #37192, #37222, #37225, #37254, #37277, #37298, #37322, #37434, #37468, #37514, #37512, #37549, #37556, #37560, #37580, #37591, #37552, #37618, #37643, #37658, #37731, #37678, #37741, #37762, #37790, #37805, #37823, #37837, #37842, #37850, #37848, #37812, #37950, #37898, #37916, #37920, #37927, #37928, #37961, #37967, #37974, #37989, #37986, #38004, #38026, #38027, #38030, #38038, #38065, #38081, #38082, #38096, #38106, #38113, #38124, #38133, #38144, #38153, #38166, #38167, #38169, #38170, #38177, #38193, #38213, #38251, #38255, #38256, #38282, #38298, #38292, #38307, #38306, #38316, #38115, #38329, #38334, #38337, #38351, #38368, #38370, #38356, #38383, #38386, #38385, #38394, #38393, #38399, #38402, #38409, #38414, #38413, #38424, #38425, #38450, #38508, #38528, #38534, #38536, #38540, #38543, #38491, #38586, #38611, #38588, #38612, #38628, #38626, #38630, #38633, #38629, #38638, #38645, #38644, #38636, #38660, #38657, #38688, #38690, #38672, #38698, #38697, #38708, #38712, #38713, #38709, #38719, #38728, #38730, #38732, #38739, #38749, #38751, #38750, #38767, #38769, #38783, #38785, #38959, #38786, #38794, #38776, #38817, #38792, #38822, #38827, #38831, #38830, #38844, #38843, #38852, #38850, #38847, #38865, #38897, #38900, #38919, #38933, #38934, #38935, #38942, #38941, #38954, #38961, #38957, #38962, #38991, #39009, #39062, #39029, #39069, #39020, #39073, #39082, #39096, #39080, #39182, #39143, #39127, #37929, #38029, #39194, #39198, #39210, #39211, #39202, #39214, #39215, #39220, #39234, #39260, and #39251 by @ChaosExAnima, @ClearlyClaire, @Gargron, @arte7, @diondiondion, @mjankowski, @oneiros, and @shleeable)
- Create collections with up to 25 accounts each, then share them with others. You can read more about this feature [on our blog](https://blog.joinmastodon.org/2026/04/designing-collections/). This is based on FEP-7aa9 (Featured Collections) to be interoperable with the wider Fediverse. All the new API methods [are documented here](https://docs.joinmastodon.org/client/collections/).
- **Add email subscriptions** (#38163, #38507, #38502, #38487, #38527, #38582, #38741, #38907, and #39162 by @ClearlyClaire and @Gargron)
- Admins can allow specific roles to enable email subscriptions on their profile, allowing anonymous visitors to subscribe to their posts via email.
- **Add new overview landing page setting** (#39074, #39170, #39163, and #39138 by @Gargron, @diondiondion, and @zunda)
- Admins can choose a new frontpage for anonymous visitors, which combines the about page and most recent posts from local profiles.
- **Add ability to require 2FA for specific roles** (including Everybody) (#37701, #37846, and #38906 by @ClearlyClaire and @mjankowski)
- Add export for custom filters (#39085 by @arte7)
- Add ability to search email blocks by domain in admin UI (#38923 by @arte7)
- Add new endpoints for profile editing in REST API (#37912, #37934, #37932, #38221, and #38339 by @ClearlyClaire)
- Add `GET /api/v1/profile` and `PATCH /api/v1/profile` to replace the existing `update_credentials` endpoint. See [the documentation](https://www.notion.so/joinmastodon/Mastodon-v4-6-0-beta1-changelog-3656208ac91b8088a745d15a9e81f727) for more information.
- Add `missing_attribution` boolean to preview cards in REST API (#38043 by @ClearlyClaire)
- Documentation: https://docs.joinmastodon.org/entities/PreviewCard/#missing_attribution
- Add `exclude_direct` flag to `/api/v1/accounts/:id/statuses` to exclude direct messages (#37763 by @ClearlyClaire)
- Add `max_note_length` and `max_display_name_length` attributes to `configuration.accounts` in `Instance` entity (#37991 by @ClearlyClaire)
- Add profile field limits to instance entity in REST API (#37535 by @mkljczk)
- This adds attributes `configuration.accounts.max_profile_fields`, `configuration.accounts.profile_field_name_limit` and `configuration.accounts.profile_field_value_limit` to the [`Instance` entity](https://docs.joinmastodon.org/entities/Instance).
- Add `unresolved` flag to `/api/v1/admin/reports` to query both resolved and unresolved reports (#38323 by @mkljczk)
- Add fallback attributes to notifications for new and infrequent notifications in REST API (#38832 and #38860 by @ClearlyClaire)
- This adds a [`supported_types`](https://docs.joinmastodon.org/methods/notifications/#query-parameters-1) parameter to `GET /api/v1/notifications`, `GET /api/v1/notifications/:id`, `GET /api/v2/notifications`, and `GET /api/v2/notifications/:group_key` along with a new `fallback` attribute for notifications and notification groups.
- Add support for posts in vertical languages in web UI (#37204, #38205, and #38797 by @shimon1024)
- Add `PageUp` and `PageDown` hotkeys for list navigation (#39252 by @diondiondion)
- Add `g`+`e` keyboard shortcut to access the trending page in web UI (#38014 by @antoinecellerier)
- Add `Cmd`/`Ctrl`+`Enter` for form submissions in more text areas in web UI (#37821 by @diondiondion)
- Add support for quoting by dragging a link into the compose form in web UI (#36859 and #36896 by @ClearlyClaire and @tribela)
- Add `text-autospace` to posts to improve rendering of mixed script posts in web UI (#37694 by @ahxxm)
- Add `nan-TW` to supported locales (#37650, #34923, #37822, and #37721 by @ClearlyClaire and @Yoxem)
- Add support for `hosts` resolver in request socket DNS lookup (#38699, #38866, and #39030 by @ClearlyClaire and @mjankowski)
- Add support for FEP-2c59 (Webfinger Backlink) (#38239, #38538, and #38639 by @ClearlyClaire and @shleeable)
- Add support for FEP-3b86 (Activity Intents) (#38120 and #38130 by @ClearlyClaire and @Gargron)
- Add support for alt text for profile pictures and headers (#37634, #37641, and #38000 by @ClearlyClaire and @Doxterpepper)
- Add support for multiple keypairs for remote accounts (#38279, #38407, #38419, #38511, #38516, #38515, #38555 and #39235 by @ClearlyClaire)
- Add support for the “require approval” feature for email domain blocks to `tootctl email_domain_blocks` (#34579 and #38107 by @ClearlyClaire and @e-nomem)
- Add `--keep-interacted` flag to `tootctl media remove` to preserve cached media on cleanup (#36200 by @northerner)
- Add systemd service file for prometheus exporter (#35130 by @ThisIsMissEm)
### Changed
- **Change design of profiles in web UI** (#37472, #37490, #37479, #37513, #37527, #37550, #37538, #37632, #37627, #37593, #37638, #37626, #37645, #37653, #37683, #37707, #37682, #37742, #37747, #37760, #37761, #37831, #37766, #37811, #37813, #37825, #37854, #37851, #37876, #37885, #37892, #37890, #37907, #37922, #37952, #37958, #37996, #37990, #37994, #38005, #38012, #38040, #38052, #38066, #38083, #38147, #38148, #38152, #38168, #38156, #38175, #38191, #38189, #38235, #38283, #38310, #38309, #38315, #38314, #38365, #38366, #38363, #38346, #38382, #38384, #38400, #38404, #38417, #38426, #38440, #38442, #38443, #38445, #38446, #38451, #38456, #38509, #38510, #38512, #38513, #38517, #38529, #38531, #38535, #38532, #38544, #38549, #38575, #38579, #38580, #38581, #38585, #38584, #38604, #38605, #38606, #38607, #38622, #38616, #38625, #38632, #38640, #38663, #38667, #38646, #38691, #38692, #38766, #38791, #38687, #38826, #38828, #38863, #38845, #38870, #38872, #38932, #38945, #38963, #38964, #39055, #39042, #38893, #39079, #39084, #39160, #39070, and #39217 by @ChaosExAnima, @ClearlyClaire, @Coro365, @diondiondion, and @shleeable)
- The profile screen has been entirely redesigned, has new features, and allows you to update your own profile directly without going into the preferences panel. You can read more about it [on our blog](https://blog.joinmastodon.org/2026/03/a-redesign-for-profiles/).
- **Change how #Wrapstodon reports are generated and displayed** (#37033, #37045, #37093, #37055, #37096, #37047, #37103, #37104, #37106, #37109, #37121, #37138, #37134, #37177, #37182, #37169, #37186, #37187, #37188, #37189, #37190, #37193, #37198, #37201, #37203, #37205, #37206, #37207, #37209, #37202, #37216, #37219, #37224, #37226, #37229, #37249, #37251, #37256, #37261, #37269, #37270, #37273, and #37289 by @ChaosExAnima, @ClearlyClaire, @channyeintun, and @diondiondion)
- This finishes up work started in 2024 by completely revamping how Wrapstodon reports are generated and displayed, reducing the amount of data collected and generating reports when active users ask for them.
- Instead of requiring manual generation from a server administrator, this is now offered between the 10th of December and the end of each year if enabled in the server settings.
- The design of the Wrapstodon report has also been fully reworked to be more delightful and easier to share!
- The relevant API endpoints are documented at https://docs.joinmastodon.org/methods/annual_reports/
- Change pending user notification email to link directly to the pending account (#39206 by @vmstan)
- Changed emoji processing in web UI to make it less resource intensive and more robust (#39077, #39008, #39088, #38892, #38885, #38965, #38854, #38825, #38784, #38541, #37442, #37300, #37306, #37271, #37255, #37284, #37272, #37178, #37084, #37080, #37418, #39167, and #39126 by @ChaosExAnima, @ClearlyClaire, @diondiondion, and @gomasy)
- Change composer textarea to have a limited height to prevent column scrolling (#39268 by @diondiondion)
- Change mentions of “Mastodon gGmbH” to “Mastodon GmbH” (#39261 by @renchap)
- Change the limited profile message to be less misleading (#39231 by @mortie)
- Change images/videos in posts in web UI to not have unlimited height (#36966, #37035, #37136, and #37032 by @diondiondion)
- Change search field and tabs to stick to the top on the search results page in web UI (#38968 by @diondiondion)
- Change “anyone can quote” label to “quotes allowed” in web UI (#37427 by @vmstan)
- Change navigation by `j`/`k` hotkeys to anchor navigated item to top of viewport in web UI (#38036 by @diondiondion)
- Change hotkeys to focus columns to not reset scroll, add hotkey `0` to scroll to top in web UI (#37052 by @diondiondion)
- Change media modal swipe animation in web UI (#36916, #37034, #37323, and #37464 by @ChaosExAnima and @heathdutton)
- Change “Hide”/“Show all” eye icon in thread view in web UI (#22301 by @tribela)
- Change order of onboarding steps (follow people, then fill out profile) in web UI (#38121 by @Gargron)
- Change “Why do you want to join” field on the sign-up page to have a label (#38936 by @diondiondion)
- Change date of birth field on the sign-up page to use locale-specific fields order (#36039 and #36895 by @mjankowski)
- Change how invalid-but-not-expired invites are shown in admin UI (#38736 by @ClearlyClaire)
- Change wording and ordering of media display settings (#38731 by @mjankowski)
- Change wording of server account recommendation setting description (#36771 by @mjankowski)
- Change wording and ordering of account migration warnings (#20387 by @jsoref)
- Change wording of “Automatic post deletion” settings (#37286 by @mjankowski)
- Change wording of language filter settings to clarify they do not impact home/lists (#38490 by @mjankowski)
- Change invitations to only bypass sign-up approval setting when the issuer of the invitation has the `invite_bypass_approval` permission (#38278 by @ClearlyClaire)
- This splits the “Invite Users” permission into a new “Invite Users without review” permission.
- Existing roles will be updated to have the new permission if they have the old one, but default permissions will not include the new `invite_bypass_approval` permission.
- Change followers synchronization mechanism on followers-only posts to be skipped for accounts with 25k followers or more (#37302 by @ClearlyClaire)
- Change “dark”, “light” and “high contrast” themes to be separate “Color scheme” and “Contrast” settings handled by a single theme (#37095, #37120, #37288, #37459, #37470, #37477, #37519, #37520, #37523, #37524, #37526, #37612, #37824, #37807, #37810, #37819, #37906, and #38261 by @ClearlyClaire, @diondiondion, and @mjankowski)
- Existing settings should be migrated automatically from user settings, and using browser defaults otherwise.
- This also allows third-party theme authors to make use of the same browser defaults and user settings. Learn more about this in [our new Theming docs](https://docs.joinmastodon.org/dev/frontend/theming/).
- Change default theme to use CSS theme tokens (#36861, #36936, #37019, #37054, #37056, #37081, #37105, #37268, #37841, #37843, #38387, #38459, and #38621 by @diondiondion)
- A [guide to using the new tokens](https://docs.joinmastodon.org/dev/frontend/design-tokens/) can be found in our docs.
- Change location blocks in default `nginx.conf` (#19644 and #37866 by @BedrockDigger and @Izorkin)
- Change `proxy_read_timeout` to 120 seconds in default `nginx.conf` (#30599 by @shleeable)
- Change JSON-LD collection handling (#34595 and #37806 by @ClearlyClaire and @sneakers-the-rat)
### Removed
- Remove support for EOL Node version 20 (#38926 by @mjankowski)
- Remove support for Ruby 3.2 (#37476 by @mjankowski)
- Remove support for `ENABLE_SIDEKIQ_UNIQUE_JOBS_UI` (#38340 by @ClearlyClaire)
- Remove support for ImageMagick (#37488 by @mjankowski)
### Fixed
- Fix accessibility issues in web UI (#37250, #38006, #38033, #38188, #38230, #38252, #38257, #38285, #38293, #38362, #38387, #38459, #38796, #38801, #39098, #39111, #39120, #39129, #39133, #39134, #39144, #39145, #39149, #39164, #39165, #39169, and #39181 by @ChaosExAnima and @diondiondion)
- Fix processing some link previews where text is language-tagged (#39190 by @zunda)
- Fix error when “New trends” email is sent at the same time trends are recomputed (#39122 by @arte7)
- Fix hover card opening even when not preceded by mouse movement in web UI (#39166 by @diondiondion)
- Fix [ominous](https://mastodon.social/@mcc/116404362104299129) "Moments remaining" timestamp in web UI (#38488 and #38689 by @ChaosExAnima and @MitarashiDango)
- Fix filters not being applied to search results in web UI (#36346 by @ClearlyClaire)
- Fix error when visiting non-public hashtag timelines (#36961 by @diondiondion)
- Fix duplicate favourite/boost counters in some languages (#36844 by @ChaosExAnima)
- Fix unblocking domain from blocked domains column not updating the list in web UI (#38882 by @tribela)
- Fix profile dropdown menu sometimes ending with a separator in web UI (#38481 by @mkljczk)
- Fix short numbers rounding up instead of truncating in web UI (#38114 by @serranodfm)
- Fix directory showing load more button when no more profiles exist in web UI (#37465 by @heathdutton)
- Fix focus restoration after closing some modals in web UI (#37424 by @MegaManSec)
- Fix video modals being pushed down on mobile in web UI (#37421 by @ChaosExAnima)
- Fix outer page margins when viewport width equals content width in web UI (#36733 by @diondiondion)
- Fix announcement margin when in advanced web UI (#36714 by @ChaosExAnima)
- Fix navigation overflow issue in advanced web UI (#39178 by @diondiondion)
- Fix stale merging stale account from cached instance API response in web UI (#37666 by @ChaosExAnima)
- Fix HTML `lang` attribute being stripped out of remote posts (#39114 by @artemist)
- Fix remote posts with large media descriptions being rejected (#39135 by @ClearlyClaire)
- Fix some occurrence of PostgreSQL log pollution when processing new hashtags (#35792 by @oelison)
- Fix replica database not being used when `REPLICA_DB_HOST` is used but neither `REPLICA_DB_NAME` nor `REPLICA_DATABASE_URL` (#37240 by @smiba)
- Fix remote media attachment thumbnails not being stored in the `cache/` directory (#36911 by @shugo)
- Fix race condition when processing posts twice with the same idempotency key (#37879 by @ClearlyClaire)
- Fix various missing translation strings (#37671, #37838, #37078, #37371, and #37827 by @ClearlyClaire, @mjankowski, and @valtlai)
- Fix last post time for remote accounts not being accurately tracked (#37619 by @ClearlyClaire)
- Fix filtering of mentions from filtered-on-their-origin-server accounts (#37583 by @ClearlyClaire)
- Fix irrelevant remote accounts being passed through to local fan-out worker (#37589 by @ClearlyClaire)
- Fix required field markers being displayed on fields that cannot be empty anyway in settings (#37291 by @diondiondion)
- Fix thumbnails for links from The Guardian (and possibly other CDNs that check URL hashes) not showing up (#36139 by @phocks)
- Fix `mastodon-async-refresh` response header not being exposed through CORS (#38914 by @mkljczk)
- Fix FASP availability being incorrectly updated (#38818 by @oneiros)
- Fix use of deprecated `vsync` FFmpeg option, using `fps_mode` instead (FFmpeg >= 5.1 now required) (#38198 by @mjankowski)
- Fix unnecessary downcasing of some words in admin UI (#37364 by @ClearlyClaire)
- Fix delivery worker counting unsalvageable HTTP errors as successes (#37235 by @shleeable)
- Fix streaming heartbeat comment not being its own event (#37389 by @ClearlyClaire)
- Fix posts with edited out media attachments being returned in `GET /api/v1/accounts/:id/statuses?only_media=true` (#37363 by @ClearlyClaire)
- Fix wrong media attachment URLs being returned from `DELETE /api/v1/statuses/:id` (#35880 by @dbarabashh)
- Fix hashtag matching by replacing negative look-behind with positive look-behind (#37684 and #38212 by @ClearlyClaire)
- Fix discovery of ActivityPub representation from HTML tags in presence of a non-ActivityPub alternate Link header (#37439 by @shleeable)
- Fix Webfinger endpoint not handling new ActivityPub ID scheme (#38391 by @ClearlyClaire)
- Fix error when admin-selected theme does not exist by falling back to `default` theme (#38703 by @shleeable)
- Fix wrong endonyms for Divehi and Latvian in languages list (#36254 and #36876 by @cuu508 and @shimon1024)
- Fix `Accept` headers when fetching ActivityPub resources not including JSON-LD profile (#30354 by @TheOneric)
- Fix wrong hover indicators on unclickable items in admin UI (#38782 by @diondiondion)
- Fix streaming server using deprecated `url.parse` instead of WHATWG URL API (#36973 by @Exagone313)
## [4.5.11] - 2026-06-03
### Security

View File

@ -43,6 +43,7 @@ module ContextHelper
interaction_policies: {
'gts' => 'https://gotosocial.org/ns#',
'interactionPolicy' => { '@id' => 'gts:interactionPolicy', '@type' => '@id' },
'canFeature' => { '@id' => 'https://w3id.org/fep/7aa9#canFeature', '@type' => '@id' },
'canQuote' => { '@id' => 'gts:canQuote', '@type' => '@id' },
'automaticApproval' => { '@id' => 'gts:automaticApproval', '@type' => '@id' },
'manualApproval' => { '@id' => 'gts:manualApproval', '@type' => '@id' },
@ -53,6 +54,22 @@ module ContextHelper
'interactingObject' => { '@id' => 'gts:interactingObject', '@type' => '@id' },
'interactionTarget' => { '@id' => 'gts:interactionTarget', '@type' => '@id' },
},
feature_requests: { 'FeatureRequest' => 'https://w3id.org/fep/7aa9#FeatureRequest' },
featured_collections: {
'FeaturedCollection' => 'https://w3id.org/fep/7aa9#FeaturedCollection',
'FeaturedItem' => 'https://w3id.org/fep/7aa9#FeaturedItem',
'FeatureRequest' => 'https://w3id.org/fep/7aa9#FeatureRequest',
'FeatureAuthorization' => 'https://w3id.org/fep/7aa9#FeatureAuthorization',
'topic' => { '@id' => 'https://w3id.org/fep/7aa9#topic', '@type' => '@id' },
'featuredObject' => { '@id' => 'https://w3id.org/fep/7aa9#featuredObject', '@type' => '@id' },
'featureAuthorization' => { '@id' => 'https://w3id.org/fep/7aa9#featureAuthorization', '@type' => '@id' },
},
feature_authorizations: {
'gts' => 'https://gotosocial.org/ns#',
'FeatureAuthorization' => 'https://w3id.org/fep/7aa9#FeatureAuthorization',
'interactingObject' => { '@id' => 'gts:interactingObject', '@type' => '@id' },
'interactionTarget' => { '@id' => 'gts:interactionTarget', '@type' => '@id' },
},
}.freeze
def full_context

View File

@ -110,8 +110,8 @@ const hotkeyMatcherMap = {
mention: just('m'),
open: any('enter', 'o'),
openProfile: just('p'),
moveDown: just('j'),
moveUp: just('k'),
moveDown: any('j', 'pagedown'),
moveUp: any('k', 'pageup'),
moveToTop: just('0'),
toggleHidden: just('x'),
toggleSensitive: just('h'),
@ -148,9 +148,15 @@ const hotkeyMatcherMap = {
type HotkeyName = keyof typeof hotkeyMatcherMap;
export type HandlerMap = Partial<
Record<HotkeyName, (event: KeyboardEvent) => void>
>;
type HandlerFunction =
// When a handler returns a boolean, it should indicate whether the
// hotkey was handled (i.e. it resulted in an action).
// If `false` is returned, `preventDefault` and `stopPropagation`
// will not be called on the keyboard event, restoring the key's
// native behaviour.
((event: KeyboardEvent) => boolean) | ((event: KeyboardEvent) => void);
export type HandlerMap = Partial<Record<HotkeyName, HandlerFunction>>;
export function useHotkeys<T extends HTMLElement>(handlers: HandlerMap) {
const ref = useRef<T>(null);
@ -185,7 +191,7 @@ export function useHotkeys<T extends HTMLElement>(handlers: HandlerMap) {
const matchCandidates: {
// A candidate will be have an undefined handler if it's matched,
// but handled in a parent component rather than this one.
handler: ((event: KeyboardEvent) => void) | undefined;
handler: HandlerFunction | undefined;
priority: number;
}[] = [];
@ -210,9 +216,11 @@ export function useHotkeys<T extends HTMLElement>(handlers: HandlerMap) {
const bestMatchingHandler = matchCandidates.at(0)?.handler;
if (bestMatchingHandler) {
bestMatchingHandler(event);
event.stopPropagation();
event.preventDefault();
const wasHandled = bestMatchingHandler(event);
if (wasHandled !== false) {
event.stopPropagation();
event.preventDefault();
}
}
// Add last keypress to buffer

View File

@ -6,6 +6,7 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { createSelector } from '@reduxjs/toolkit';
import type { ApiRuleJSON } from '@/flavours/glitch/api_types/instance';
import type { SelectItem } from '@/flavours/glitch/components/dropdown_selector';
import { Select } from '@/flavours/glitch/components/form_fields';
import type { RootState } from '@/flavours/glitch/store';
@ -104,13 +105,13 @@ export const RulesSection: FC<RulesSectionProps> = ({ isLoading = false }) => {
defaultMessage='Language'
/>
</label>
<Select onChange={handleLocaleChange} id='language-select'>
<Select
onChange={handleLocaleChange}
id='language-select'
value={selectedLocale}
>
{localeOptions.map((option) => (
<option
key={option.value}
value={option.value}
selected={option.value === selectedLocale}
>
<option key={option.value} value={option.value}>
{option.text}
</option>
))}
@ -121,15 +122,10 @@ export const RulesSection: FC<RulesSectionProps> = ({ isLoading = false }) => {
);
};
const selectRules = (state: RootState) => {
const rules = state.server.server.item?.rules;
if (!rules) {
return [];
}
return rules;
};
const selectRules = createSelector(
[(state: RootState) => state.server.server.item],
(item) => item?.rules ?? [],
);
const rulesSelector = createSelector(
[selectRules, (_state, locale: string) => locale],
@ -142,18 +138,19 @@ const rulesSelector = createSelector(
return rule;
}
const translatedRule: ApiRuleJSON = { ...rule };
const partialLocale = locale.split('-')[0];
if (partialLocale && translations[partialLocale]) {
rule.text = translations[partialLocale].text;
rule.hint = translations[partialLocale].hint;
translatedRule.text = translations[partialLocale].text;
translatedRule.hint = translations[partialLocale].hint;
}
if (translations[locale]) {
rule.text = translations[locale].text;
rule.hint = translations[locale].hint;
translatedRule.text = translations[locale].text;
translatedRule.hint = translations[locale].hint;
}
return rule;
return translatedRule;
});
},
);

View File

@ -72,7 +72,7 @@ class KeyboardShortcuts extends ImmutablePureComponent {
<td><FormattedMessage id='keyboard_shortcuts.bookmark' defaultMessage='to bookmark' /></td>
</tr>
<tr>
<td><kbd>enter</kbd>, <kbd>o</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.keys.enter' defaultMessage='Enter' tagName='kbd' />, <kbd>o</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.enter' defaultMessage='to open status' /></td>
</tr>
<tr>
@ -92,11 +92,11 @@ class KeyboardShortcuts extends ImmutablePureComponent {
<td><FormattedMessage id='keyboard_shortcuts.toggle_sensitivity' defaultMessage='to show/hide media' /></td>
</tr>
<tr>
<td><kbd>k</kbd></td>
<td><kbd>k</kbd>, <FormattedMessage id='keyboard_shortcuts.keys.page_up' defaultMessage='Page Up' tagName='kbd' /></td>
<td><FormattedMessage id='keyboard_shortcuts.up' defaultMessage='to move up in the list' /></td>
</tr>
<tr>
<td><kbd>j</kbd></td>
<td><kbd>j</kbd>, <FormattedMessage id='keyboard_shortcuts.keys.page_down' defaultMessage='Page Down' tagName='kbd' /></td>
<td><FormattedMessage id='keyboard_shortcuts.down' defaultMessage='to move down in the list' /></td>
</tr>
<tr>
@ -116,15 +116,15 @@ class KeyboardShortcuts extends ImmutablePureComponent {
<td><FormattedMessage id='keyboard_shortcuts.compose' defaultMessage='to focus the compose textarea' /></td>
</tr>
<tr>
<td><kbd>alt</kbd>+<kbd>n</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.keys.alt' defaultMessage='Alt' tagName='kbd' />+<kbd>n</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.toot' defaultMessage='to start a brand new post' /></td>
</tr>
<tr>
<td><kbd>alt</kbd>+<kbd>x</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.keys.alt' defaultMessage='Alt' tagName='kbd' />+<kbd>x</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.spoilers' defaultMessage='to show/hide CW field' /></td>
</tr>
<tr>
<td><kbd>backspace</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.keys.backspace' defaultMessage='Backspace' tagName='kbd' /></td>
<td><FormattedMessage id='keyboard_shortcuts.back' defaultMessage='to navigate back' /></td>
</tr>
<tr>
@ -136,7 +136,7 @@ class KeyboardShortcuts extends ImmutablePureComponent {
<td><FormattedMessage id='keyboard_shortcuts.secondary_toot' defaultMessage='to send toot using secondary privacy setting' /></td>
</tr>
<tr>
<td><kbd>esc</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.keys.esc' defaultMessage='Esc' tagName='kbd' /></td>
<td><FormattedMessage id='keyboard_shortcuts.unfocus' defaultMessage='to un-focus compose textarea/search' /></td>
</tr>
<tr>

View File

@ -554,7 +554,8 @@ class UI extends PureComponent {
if (currentItemIndex === -1) {
focusColumn(1);
} else {
focusItemSibling(currentItemIndex, -1);
const wasHandled = focusItemSibling(currentItemIndex, -1);
return wasHandled;
}
};
@ -563,7 +564,8 @@ class UI extends PureComponent {
if (currentItemIndex === -1) {
focusColumn(1);
} else {
focusItemSibling(currentItemIndex, 1);
const wasHandled = focusItemSibling(currentItemIndex, 1);
return wasHandled;
}
};

View File

@ -159,13 +159,12 @@ export function focusItemSibling(index: number, direction: 1 | -1) {
);
if (!siblingItem) {
return;
return false;
}
// If sibling element is empty, we skip it
if (siblingItem.matches(':empty')) {
focusItemSibling(index + direction, direction);
return;
return focusItemSibling(index + direction, direction);
}
// Check if the sibling is a post or a 'follow suggestions' widget
@ -184,5 +183,8 @@ export function focusItemSibling(index: number, direction: 1 | -1) {
});
targetElement.focus();
return true;
} else {
return false;
}
}

View File

@ -10,7 +10,7 @@ import { me, reduceMotion } from 'flavours/glitch/initial_state';
import ready from 'flavours/glitch/ready';
import { store } from 'flavours/glitch/store';
import { isProduction, isDevelopment } from './utils/environment';
import { isDevelopment, isProduction } from './utils/environment';
function main() {
perf.start('main()');
@ -41,29 +41,30 @@ function main() {
);
store.dispatch(setupBrowserNotifications());
if (isProduction() && me && 'serviceWorker' in navigator) {
const { Workbox } = await import('workbox-window');
const wb = new Workbox(
isDevelopment() ? '/packs-dev/dev-sw.js?dev-sw' : '/sw.js',
{ type: 'module', scope: '/' },
);
let registration;
try {
registration = await wb.register();
} catch (err) {
console.error(err);
if (
me &&
'serviceWorker' in navigator &&
(isDevelopment() || isProduction()) // Disallow testing environment
) {
let swPath = '/sw.js';
if (isDevelopment()) {
const { default: swDevUrl } =
await import('@/flavours/glitch/service_worker/sw?url');
swPath = swDevUrl;
}
if (
registration &&
'Notification' in window &&
Notification.permission === 'granted'
) {
const registerPushNotifications =
await import('flavours/glitch/actions/push_notifications');
await navigator.serviceWorker.register(swPath, {
scope: '/',
type: 'module',
});
store.dispatch(registerPushNotifications.register());
if (isProduction()) {
if ('Notification' in window && Notification.permission === 'granted') {
const registerPushNotifications =
await import('flavours/glitch/actions/push_notifications');
store.dispatch(registerPushNotifications.register());
}
}
}

View File

@ -0,0 +1,390 @@
import { DAY } from '../utils/time';
import { expireCachedItems, handleFetch } from './caching';
const now = 1_700_000_000_000;
describe('expireCachedItems', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(now);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
test('deletes expired entries and requests without a cached response', async () => {
const cache = new MockCache();
const missingRequest = new Request('https://example.com/missing');
cache.set('/fresh', now - DAY);
cache.set('/expired', now - DAY * 31);
cache.store.set(missingRequest.url, { request: missingRequest });
vi.stubGlobal('caches', {
open: vi.fn().mockResolvedValue(cache),
});
await expireCachedItems({ name: 'images', ttl: DAY * 30, max: 5 });
expect(Array.from(cache.store.keys())).toEqual([
'https://example.com/fresh',
]);
});
test('trims the oldest valid entries when the cache exceeds max size', async () => {
const cache = new MockCache();
cache.set('/oldest', now - DAY * 3);
cache.set('/older', now - DAY * 2);
cache.set('/newest', now - DAY);
vi.stubGlobal('caches', {
open: vi.fn().mockResolvedValue(cache),
});
await expireCachedItems({ name: 'images', ttl: DAY * 30, max: 2 });
expect(Array.from(cache.store.keys())).toEqual([
'https://example.com/older',
'https://example.com/newest',
]);
});
test('keeps entries without a timestamp header over timestamped entries', async () => {
const cache = new MockCache();
const untimestampedRequest = new Request('https://example.com/no-header');
cache.store.set(untimestampedRequest.url, {
request: untimestampedRequest,
response: createResponse(),
});
cache.set('/older', now - DAY * 2);
cache.set('/newer', now - DAY);
vi.stubGlobal('caches', {
open: vi.fn().mockResolvedValue(cache),
});
await expireCachedItems({ name: 'images', ttl: DAY * 30, max: 2 });
expect(Array.from(cache.store.keys())).toEqual([
'https://example.com/no-header',
'https://example.com/newer',
]);
});
});
describe('handleFetch', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(now);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
test('serves cached images without hitting the network while the TTL is valid', async () => {
const imageCache = new MockCache();
const request = createRequest('/test.png', 'image');
const cachedResponse = createResponse(now - DAY);
imageCache.store.set(request.url, { request, response: cachedResponse });
vi.stubGlobal('caches', {
open: vi.fn().mockResolvedValue(imageCache),
});
const fetch = vi.fn();
vi.stubGlobal('fetch', fetch);
const { event, respondWith } = createFetchEvent(request);
handleFetch(event);
await expect(respondWith()).resolves.toBe(cachedResponse);
expect(fetch).not.toHaveBeenCalled();
});
test('fetches stale cached images from the network and stores a refreshed response', async () => {
const imageCache = new MockCache();
const request = createRequest('/stale.png', 'image');
const networkResponse = new Response('fresh', {
headers: { 'content-type': 'image/png' },
status: 200,
statusText: 'OK',
});
const putSpy = vi.spyOn(imageCache, 'put');
imageCache.store.set(request.url, {
request,
response: createResponse(now - DAY * 8),
});
vi.stubGlobal('caches', {
open: vi.fn().mockResolvedValue(imageCache),
});
const fetch = vi.fn().mockResolvedValue(networkResponse);
vi.stubGlobal('fetch', fetch);
const { event, respondWith } = createFetchEvent(request);
handleFetch(event);
await expect(respondWith()).resolves.toBe(networkResponse);
expect(fetch).toHaveBeenCalledWith(request);
expect(putSpy).toHaveBeenCalledWith(request, expect.any(Response));
expect(
imageCache.store.get(request.url)?.response?.headers.get('x-timestamp'),
).toBe(now.toString());
});
test('does not cache opaque image responses with status zero', async () => {
const imageCache = new MockCache();
const request = createRequest('/opaque.png', 'image');
const opaqueResponse = Response.error();
const putSpy = vi.spyOn(imageCache, 'put');
vi.stubGlobal('caches', {
open: vi.fn().mockResolvedValue(imageCache),
});
const fetch = vi.fn().mockResolvedValue(opaqueResponse);
vi.stubGlobal('fetch', fetch);
const { event, respondWith } = createFetchEvent(request);
handleFetch(event);
await expect(respondWith()).resolves.toBe(opaqueResponse);
expect(fetch).toHaveBeenCalledWith(request);
expect(putSpy).not.toHaveBeenCalled();
expect(imageCache.store.size).toBe(0);
});
test.each([
['/intl/en.js', '', 'mastodon-locales'],
['/fonts/mastodon.woff2', 'font', 'mastodon-fonts'],
])(
'routes %s requests through %s',
async (pathname, destination, cacheName) => {
const cache = new MockCache();
const request = createRequest(pathname, destination);
const networkResponse = new Response('asset', { status: 200 });
const open = vi.fn().mockImplementation((name: string) => {
expect(name).toBe(cacheName);
return Promise.resolve(cache);
});
vi.stubGlobal('caches', { open });
const fetch = vi.fn().mockResolvedValue(networkResponse);
vi.stubGlobal('fetch', fetch);
const { event, respondWith } = createFetchEvent(request);
handleFetch(event);
await expect(respondWith()).resolves.toBe(networkResponse);
expect(fetch).toHaveBeenCalledWith(request);
expect(open).toHaveBeenCalledWith(cacheName);
},
);
test('clears the root cache after a successful logout request', async () => {
const webCache = new MockCache();
const deleteSpy = vi.spyOn(webCache, 'delete');
vi.stubGlobal('caches', {
open: vi.fn().mockImplementation((name: string) => {
expect(name).toBe('mastodon-web');
return Promise.resolve(webCache);
}),
});
const fetch = vi
.fn()
.mockResolvedValue(new Response(null, { status: 200 }));
vi.stubGlobal('fetch', fetch);
const { event, respondWith } = createFetchEvent(
createRequest('/auth/sign_out'),
);
handleFetch(event);
await expect(respondWith()).resolves.toBeInstanceOf(Response);
expect(deleteSpy).toHaveBeenCalledWith('/');
});
test('does not clear the root cache after a failed logout request', async () => {
const webCache = new MockCache();
const deleteSpy = vi.spyOn(webCache, 'delete');
vi.stubGlobal('caches', {
open: vi.fn().mockResolvedValue(webCache),
});
const fetch = vi
.fn()
.mockResolvedValue(
new Response(null, { status: 500, statusText: 'Error' }),
);
vi.stubGlobal('fetch', fetch);
const { event, respondWith } = createFetchEvent(
createRequest('/auth/sign_out'),
);
handleFetch(event);
await expect(respondWith()).resolves.toBeInstanceOf(Response);
expect(deleteSpy).not.toHaveBeenCalled();
});
test('clears the root cache for opaqueredirect logout responses', async () => {
const webCache = new MockCache();
const deleteSpy = vi.spyOn(webCache, 'delete');
const opaqueRedirectResponse = {
ok: false,
type: 'opaqueredirect',
} as Response;
vi.stubGlobal('caches', {
open: vi.fn().mockResolvedValue(webCache),
});
const fetch = vi.fn().mockResolvedValue(opaqueRedirectResponse);
vi.stubGlobal('fetch', fetch);
const { event, respondWith } = createFetchEvent(
createRequest('/auth/sign_out'),
);
handleFetch(event);
await expect(respondWith()).resolves.toBe(opaqueRedirectResponse);
expect(deleteSpy).toHaveBeenCalledWith('/');
});
test('ignores requests that are not handled by the service worker cache', () => {
const { event, respondWith, respondWithMock } = createFetchEvent(
createRequest('/api/v1/timelines/home'),
);
handleFetch(event);
expect(respondWithMock).not.toHaveBeenCalled();
expect(respondWith()).toBeUndefined();
});
});
interface CacheEntry {
request: Request;
response?: Response;
}
class MockCache implements Cache {
public readonly store = new Map<string, CacheEntry>();
private normalizeRequest(request: RequestInfo | URL) {
if (request instanceof Request) {
return request.url;
}
return request.toString();
}
add(): Promise<void> {
return Promise.reject(new Error('Not implemented'));
}
addAll(): Promise<void> {
return Promise.reject(new Error('Not implemented'));
}
delete(request: RequestInfo | URL): Promise<boolean> {
return Promise.resolve(this.store.delete(this.normalizeRequest(request)));
}
keys(): Promise<readonly Request[]> {
return Promise.resolve(
Array.from(this.store.values(), ({ request }) => request),
);
}
match(request: RequestInfo | URL): Promise<Response | undefined> {
return Promise.resolve(
this.store.get(this.normalizeRequest(request))?.response,
);
}
matchAll(): Promise<readonly Response[]> {
return Promise.reject(new Error('Not implemented'));
}
put(request: RequestInfo | URL, response: Response): Promise<void> {
const normalizedRequest =
request instanceof Request ? request : new Request(request);
this.store.set(this.normalizeRequest(normalizedRequest), {
request: normalizedRequest,
response,
});
return Promise.resolve();
}
set(pathname: string, timestamp?: number) {
const request = new Request(`https://example.com${pathname}`);
this.store.set(request.url, {
request,
response: createResponse(timestamp),
});
return request;
}
}
function createResponse(timestamp?: number) {
const headers = new Headers();
if (timestamp !== undefined) {
headers.set('x-timestamp', timestamp.toString());
}
return new Response('body', { headers });
}
function createRequest(pathname: string, destination = '') {
const request = new Request(`https://example.com${pathname}`);
Object.defineProperty(request, 'destination', {
value: destination,
configurable: true,
});
return request;
}
function createFetchEvent(request: Request) {
let responsePromise: Promise<Response> | undefined;
const respondWith = vi.fn((response: Response | Promise<Response>) => {
responsePromise = Promise.resolve(response);
});
return {
event: {
request,
respondWith,
} as unknown as FetchEvent,
respondWith: () => responsePromise,
respondWithMock: respondWith,
};
}

View File

@ -0,0 +1,143 @@
/// <reference lib="WebWorker" />
/// <reference types="vite/client" />
import { DAY } from '../utils/time';
const CACHE_NAME_PREFIX = 'mastodon-';
const CACHE_HEADER_TTL = 'x-timestamp';
export async function cacheRoot() {
const cache = await openWebCache();
const response = await fetch('/', {
credentials: 'include',
redirect: 'manual',
});
await cache.put('/', response);
}
export function handleFetch(event: FetchEvent) {
const url = new URL(event.request.url);
if (url.pathname === '/auth/sign_out') {
event.respondWith(handleLogout(event));
} else if (/intl\/.*\.js$/.test(url.pathname)) {
event.respondWith(cacheFirst({ event, name: 'locales' }));
} else if (event.request.destination === 'font') {
event.respondWith(cacheFirst({ event, name: 'fonts' }));
} else if (event.request.destination === 'image') {
event.respondWith(cacheFirst({ event, name: 'images', ttl: DAY * 7 }));
}
}
async function cacheFirst({
event,
name,
ttl = DAY * 30,
max = 5,
}: {
event: FetchEvent;
name: string;
ttl?: number;
max?: number;
}) {
const cache = await caches.open(`${CACHE_NAME_PREFIX}${name}`);
const request = event.request;
const cachedResponse = await cache.match(request);
// Start expiring cache items while the process continues.
void expireCachedItems({ name, ttl, max });
if (cachedResponse) {
// If we have a cached response, check the TTL header.
const ttlHeader = Number.parseInt(
cachedResponse.headers.get(CACHE_HEADER_TTL) ?? '0',
);
if (!ttlHeader || ttlHeader + ttl > Date.now()) {
return cachedResponse;
}
}
const networkResponse = await fetch(request);
// For opaque responses, the status will be zero so we can't clone them.
if (networkResponse.status !== 0) {
// Clone request with a custom header to store timestamp.
const cloneHeaders = new Headers(networkResponse.headers);
cloneHeaders.set(CACHE_HEADER_TTL, Date.now().toString());
const cloneResponse = new Response(networkResponse.clone().body, {
headers: cloneHeaders,
status: networkResponse.status,
statusText: networkResponse.statusText,
});
await cache.put(request, cloneResponse);
}
return networkResponse;
}
export async function expireCachedItems({
name,
ttl = DAY * 30,
max = 5,
}: {
name: string;
ttl?: number;
max?: number;
}) {
const cache = await caches.open(`${CACHE_NAME_PREFIX}${name}`);
const keys = await cache.keys();
const now = Date.now();
const validKeys: { key: Request; timestamp: number }[] = [];
for (const key of keys) {
const cachedResponse = await cache.match(key);
if (!cachedResponse) {
await cache.delete(key);
continue;
}
const timestamp = Number.parseInt(
cachedResponse.headers.get(CACHE_HEADER_TTL) ?? '0',
);
if (!timestamp || timestamp + ttl > now) {
validKeys.push({ key, timestamp: timestamp || Number.POSITIVE_INFINITY });
continue;
}
await cache.delete(key);
}
if (validKeys.length <= max) {
return;
}
const sortedValidKeys = validKeys.toSorted(
({ timestamp: a }, { timestamp: b }) => a - b,
);
await Promise.all(
sortedValidKeys
.slice(0, sortedValidKeys.length - max)
.map(({ key }) => cache.delete(key)),
);
}
function openWebCache() {
return caches.open(`${CACHE_NAME_PREFIX}web`);
}
async function handleLogout(event: FetchEvent) {
const response = await fetch(event.request);
if (response.ok || response.type === 'opaqueredirect') {
const cache = await openWebCache();
await cache.delete('/');
}
return response;
}

View File

@ -0,0 +1,22 @@
/// <reference lib="WebWorker" />
/// <reference types="vite/client" />
import { cacheRoot, handleFetch } from './caching';
import { handleNotificationClick, handlePush } from './web_push_notifications';
declare const self: ServiceWorkerGlobalScope;
// Cause a new version of a registered Service Worker to replace an existing one
// that is already installed, and replace the currently active worker on open pages.
self.addEventListener('install', (event) => {
event.waitUntil(cacheRoot());
});
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim());
});
self.addEventListener('fetch', handleFetch);
self.addEventListener('push', handlePush);
self.addEventListener('notificationclick', handleNotificationClick);

View File

@ -0,0 +1,208 @@
import { IntlMessageFormat } from 'intl-messageformat';
import { unescape } from 'lodash';
// see config/vite/plugins/sw-locales
// it needs to be updated when new locale keys are used in this file
import locales from "virtual:mastodon-sw-locales";
const MAX_NOTIFICATIONS = 5;
const GROUP_TAG = 'tag';
const notify = options =>
self.registration.getNotifications().then(notifications => {
if (notifications.length >= MAX_NOTIFICATIONS) { // Reached the maximum number of notifications, proceed with grouping
const group = {
title: formatMessage('notifications.group', options.data.preferred_locale, { count: notifications.length + 1 }),
body: notifications.sort((n1, n2) => n1.timestamp < n2.timestamp).map(notification => notification.title).join('\n'),
badge: '/badge.png',
icon: '/android-chrome-192x192.png',
tag: GROUP_TAG,
data: {
url: (new URL('/notifications', self.location)).href,
count: notifications.length + 1,
preferred_locale: options.data.preferred_locale,
},
};
notifications.forEach(notification => notification.close());
return self.registration.showNotification(group.title, group);
} else if (notifications.length === 1 && notifications[0].tag === GROUP_TAG) { // Already grouped, proceed with appending the notification to the group
const group = cloneNotification(notifications[0]);
group.title = formatMessage('notifications.group', options.data.preferred_locale, { count: group.data.count + 1 });
group.body = `${options.title}\n${group.body}`;
group.data = { ...group.data, count: group.data.count + 1 };
return self.registration.showNotification(group.title, group);
}
return self.registration.showNotification(options.title, options);
});
const fetchFromApi = (path, method, accessToken) => {
const url = (new URL(path, self.location)).href;
return fetch(url, {
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
method: method,
credentials: 'include',
}).then(res => {
if (res.ok) {
return res;
} else {
throw new Error(res.status);
}
}).then(res => res.json());
};
const cloneNotification = notification => {
const clone = {};
let k;
// Object.assign() does not work with notifications
for(k in notification) {
clone[k] = notification[k];
}
return clone;
};
const formatMessage = (messageId, locale, values = {}) =>
(new IntlMessageFormat(locales[locale][messageId], locale)).format(values);
const htmlToPlainText = html =>
unescape(html.replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n').replace(/<[^>]*>/g, ''));
export const handlePush = (event) => {
const { access_token, notification_id, preferred_locale, title, body, icon } = event.data.json();
// Placeholder until more information can be loaded
event.waitUntil(
fetchFromApi(`/api/v1/notifications/${notification_id}`, 'get', access_token).then(notification => {
const options = {};
options.title = formatMessage(`notification.${notification.type}`, preferred_locale, { name: notification.account.display_name.length > 0 ? notification.account.display_name : notification.account.username });
options.body = notification.status && htmlToPlainText(notification.status.content);
options.icon = notification.account.avatar_static;
options.timestamp = notification.created_at && new Date(notification.created_at);
options.tag = notification.id;
options.badge = '/badge.png';
options.image = notification.status && notification.status.media_attachments.length > 0 && notification.status.media_attachments[0].preview_url || undefined;
options.data = { access_token, preferred_locale, id: notification.status ? notification.status.id : notification.account.id };
if (notification.status) {
options.data.url = `/@${notification.status.account.acct}/${notification.status.id}`;
} else {
options.data.url = `/@${notification.account.acct}`;
}
if (notification.status && notification.status.spoiler_text || notification.status.sensitive) {
options.data.hiddenBody = htmlToPlainText(notification.status.content);
options.data.hiddenImage = notification.status.media_attachments.length > 0 && notification.status.media_attachments[0].preview_url;
if (notification.status.spoiler_text) {
options.body = notification.status.spoiler_text;
}
options.image = undefined;
options.actions = [actionExpand(preferred_locale)];
} else if (['mention', 'status'].includes(notification.type)) {
options.actions = [actionReblog(preferred_locale), actionFavourite(preferred_locale)];
}
return notify(options);
}).catch(() => {
return notify({
title,
body,
icon,
tag: notification_id,
timestamp: new Date(),
badge: '/badge.png',
data: { access_token, preferred_locale, url: '/notifications' },
});
}),
);
};
const actionExpand = preferred_locale => ({
action: 'expand',
icon: '/web-push-icon_expand.png',
title: formatMessage('status.show_more', preferred_locale),
});
const actionReblog = preferred_locale => ({
action: 'reblog',
icon: '/web-push-icon_reblog.png',
title: formatMessage('status.reblog', preferred_locale),
});
const actionFavourite = preferred_locale => ({
action: 'favourite',
icon: '/web-push-icon_favourite.png',
title: formatMessage('status.favourite', preferred_locale),
});
const findBestClient = clients => {
const focusedClient = clients.find(client => client.focused);
const visibleClient = clients.find(client => client.visibilityState === 'visible');
return focusedClient || visibleClient || clients[0];
};
const expandNotification = notification => {
const newNotification = cloneNotification(notification);
newNotification.body = newNotification.data.hiddenBody;
newNotification.image = newNotification.data.hiddenImage;
newNotification.actions = [actionReblog(notification.data.preferred_locale), actionFavourite(notification.data.preferred_locale)];
return self.registration.showNotification(newNotification.title, newNotification);
};
const removeActionFromNotification = (notification, action) => {
const newNotification = cloneNotification(notification);
newNotification.actions = newNotification.actions.filter(item => item.action !== action);
return self.registration.showNotification(newNotification.title, newNotification);
};
const openUrl = url =>
self.clients.matchAll({ type: 'window' }).then(clientList => {
if (clientList.length !== 0 && 'navigate' in clientList[0]) { // Chrome 42-48 does not support navigate
const client = findBestClient(clientList);
return client.navigate(url).then(client => client.focus());
}
return self.clients.openWindow(url);
});
export const handleNotificationClick = (event) => {
const reactToNotificationClick = new Promise((resolve, reject) => {
if (event.action) {
if (event.action === 'expand') {
resolve(expandNotification(event.notification));
} else if (event.action === 'reblog') {
const { data } = event.notification;
resolve(fetchFromApi(`/api/v1/statuses/${data.id}/reblog`, 'post', data.access_token).then(() => removeActionFromNotification(event.notification, 'reblog')));
} else if (event.action === 'favourite') {
const { data } = event.notification;
resolve(fetchFromApi(`/api/v1/statuses/${data.id}/favourite`, 'post', data.access_token).then(() => removeActionFromNotification(event.notification, 'favourite')));
} else {
reject(`Unknown action: ${event.action}`);
}
} else {
event.notification.close();
resolve(openUrl(event.notification.data.url));
}
});
event.waitUntil(reactToNotificationClick);
};

View File

@ -614,6 +614,7 @@ body > [data-popper-placement] {
display: block;
box-sizing: border-box;
width: 100%;
max-height: 300px;
margin: 0;
color: var(--color-text-primary);
background: transparent;

View File

@ -109,8 +109,8 @@ const hotkeyMatcherMap = {
mention: just('m'),
open: any('enter', 'o'),
openProfile: just('p'),
moveDown: just('j'),
moveUp: just('k'),
moveDown: any('j', 'pagedown'),
moveUp: any('k', 'pageup'),
moveToTop: just('0'),
toggleHidden: just('x'),
toggleSensitive: just('h'),
@ -147,9 +147,15 @@ const hotkeyMatcherMap = {
type HotkeyName = keyof typeof hotkeyMatcherMap;
export type HandlerMap = Partial<
Record<HotkeyName, (event: KeyboardEvent) => void>
>;
type HandlerFunction =
// When a handler returns a boolean, it should indicate whether the
// hotkey was handled (i.e. it resulted in an action).
// If `false` is returned, `preventDefault` and `stopPropagation`
// will not be called on the keyboard event, restoring the key's
// native behaviour.
((event: KeyboardEvent) => boolean) | ((event: KeyboardEvent) => void);
export type HandlerMap = Partial<Record<HotkeyName, HandlerFunction>>;
export function useHotkeys<T extends HTMLElement>(handlers: HandlerMap) {
const ref = useRef<T>(null);
@ -184,7 +190,7 @@ export function useHotkeys<T extends HTMLElement>(handlers: HandlerMap) {
const matchCandidates: {
// A candidate will be have an undefined handler if it's matched,
// but handled in a parent component rather than this one.
handler: ((event: KeyboardEvent) => void) | undefined;
handler: HandlerFunction | undefined;
priority: number;
}[] = [];
@ -209,9 +215,11 @@ export function useHotkeys<T extends HTMLElement>(handlers: HandlerMap) {
const bestMatchingHandler = matchCandidates.at(0)?.handler;
if (bestMatchingHandler) {
bestMatchingHandler(event);
event.stopPropagation();
event.preventDefault();
const wasHandled = bestMatchingHandler(event);
if (wasHandled !== false) {
event.stopPropagation();
event.preventDefault();
}
}
// Add last keypress to buffer

View File

@ -6,6 +6,7 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { createSelector } from '@reduxjs/toolkit';
import type { ApiRuleJSON } from '@/mastodon/api_types/instance';
import type { SelectItem } from '@/mastodon/components/dropdown_selector';
import { Select } from '@/mastodon/components/form_fields';
import type { RootState } from '@/mastodon/store';
@ -104,13 +105,13 @@ export const RulesSection: FC<RulesSectionProps> = ({ isLoading = false }) => {
defaultMessage='Language'
/>
</label>
<Select onChange={handleLocaleChange} id='language-select'>
<Select
onChange={handleLocaleChange}
id='language-select'
value={selectedLocale}
>
{localeOptions.map((option) => (
<option
key={option.value}
value={option.value}
selected={option.value === selectedLocale}
>
<option key={option.value} value={option.value}>
{option.text}
</option>
))}
@ -121,15 +122,10 @@ export const RulesSection: FC<RulesSectionProps> = ({ isLoading = false }) => {
);
};
const selectRules = (state: RootState) => {
const rules = state.server.server.item?.rules;
if (!rules) {
return [];
}
return rules;
};
const selectRules = createSelector(
[(state: RootState) => state.server.server.item],
(item) => item?.rules ?? [],
);
const rulesSelector = createSelector(
[selectRules, (_state, locale: string) => locale],
@ -142,18 +138,19 @@ const rulesSelector = createSelector(
return rule;
}
const translatedRule: ApiRuleJSON = { ...rule };
const partialLocale = locale.split('-')[0];
if (partialLocale && translations[partialLocale]) {
rule.text = translations[partialLocale].text;
rule.hint = translations[partialLocale].hint;
translatedRule.text = translations[partialLocale].text;
translatedRule.hint = translations[partialLocale].hint;
}
if (translations[locale]) {
rule.text = translations[locale].text;
rule.hint = translations[locale].hint;
translatedRule.text = translations[locale].text;
translatedRule.hint = translations[locale].hint;
}
return rule;
return translatedRule;
});
},
);

View File

@ -68,7 +68,7 @@ class KeyboardShortcuts extends ImmutablePureComponent {
<td><FormattedMessage id='keyboard_shortcuts.quote' defaultMessage='Quote post' /></td>
</tr>
<tr>
<td><kbd>enter</kbd>, <kbd>o</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.keys.enter' defaultMessage='Enter' tagName='kbd' />, <kbd>o</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.enter' defaultMessage='to open status' /></td>
</tr>
<tr>
@ -88,11 +88,11 @@ class KeyboardShortcuts extends ImmutablePureComponent {
<td><FormattedMessage id='keyboard_shortcuts.toggle_sensitivity' defaultMessage='to show/hide media' /></td>
</tr>
<tr>
<td><kbd>k</kbd></td>
<td><kbd>k</kbd>, <FormattedMessage id='keyboard_shortcuts.keys.page_up' defaultMessage='Page Up' tagName='kbd' /></td>
<td><FormattedMessage id='keyboard_shortcuts.up' defaultMessage='to move up in the list' /></td>
</tr>
<tr>
<td><kbd>j</kbd></td>
<td><kbd>j</kbd>, <FormattedMessage id='keyboard_shortcuts.keys.page_down' defaultMessage='Page Down' tagName='kbd' /></td>
<td><FormattedMessage id='keyboard_shortcuts.down' defaultMessage='to move down in the list' /></td>
</tr>
<tr>
@ -112,15 +112,15 @@ class KeyboardShortcuts extends ImmutablePureComponent {
<td><FormattedMessage id='keyboard_shortcuts.compose' defaultMessage='to focus the compose textarea' /></td>
</tr>
<tr>
<td><kbd>alt</kbd>+<kbd>n</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.keys.alt' defaultMessage='Alt' tagName='kbd' />+<kbd>n</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.toot' defaultMessage='to start a brand new post' /></td>
</tr>
<tr>
<td><kbd>alt</kbd>+<kbd>x</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.keys.alt' defaultMessage='Alt' tagName='kbd' />+<kbd>x</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.spoilers' defaultMessage='to show/hide CW field' /></td>
</tr>
<tr>
<td><kbd>backspace</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.keys.backspace' defaultMessage='Backspace' tagName='kbd' /></td>
<td><FormattedMessage id='keyboard_shortcuts.back' defaultMessage='to navigate back' /></td>
</tr>
<tr>
@ -128,7 +128,7 @@ class KeyboardShortcuts extends ImmutablePureComponent {
<td><FormattedMessage id='keyboard_shortcuts.search' defaultMessage='to focus search' /></td>
</tr>
<tr>
<td><kbd>esc</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.keys.esc' defaultMessage='Esc' tagName='kbd' /></td>
<td><FormattedMessage id='keyboard_shortcuts.unfocus' defaultMessage='to un-focus compose textarea/search' /></td>
</tr>
<tr>

View File

@ -508,7 +508,8 @@ class UI extends PureComponent {
if (currentItemIndex === -1) {
focusColumn(1);
} else {
focusItemSibling(currentItemIndex, -1);
const wasHandled = focusItemSibling(currentItemIndex, -1);
return wasHandled;
}
};
@ -517,7 +518,8 @@ class UI extends PureComponent {
if (currentItemIndex === -1) {
focusColumn(1);
} else {
focusItemSibling(currentItemIndex, 1);
const wasHandled = focusItemSibling(currentItemIndex, 1);
return wasHandled;
}
};

View File

@ -159,13 +159,12 @@ export function focusItemSibling(index: number, direction: 1 | -1) {
);
if (!siblingItem) {
return;
return false;
}
// If sibling element is empty, we skip it
if (siblingItem.matches(':empty')) {
focusItemSibling(index + direction, direction);
return;
return focusItemSibling(index + direction, direction);
}
// Check if the sibling is a post or a 'follow suggestions' widget
@ -184,5 +183,8 @@ export function focusItemSibling(index: number, direction: 1 | -1) {
});
targetElement.focus();
return true;
} else {
return false;
}
}

View File

@ -819,6 +819,12 @@
"keyboard_shortcuts.heading": "Keyboard shortcuts",
"keyboard_shortcuts.home": "Open home timeline",
"keyboard_shortcuts.hotkey": "Hotkey",
"keyboard_shortcuts.keys.alt": "Alt",
"keyboard_shortcuts.keys.backspace": "Backspace",
"keyboard_shortcuts.keys.enter": "Enter",
"keyboard_shortcuts.keys.esc": "Esc",
"keyboard_shortcuts.keys.page_down": "Page Down",
"keyboard_shortcuts.keys.page_up": "Page Up",
"keyboard_shortcuts.legend": "Display this legend",
"keyboard_shortcuts.load_more": "Focus \"Load more\" button",
"keyboard_shortcuts.local": "Open local timeline",

View File

@ -10,7 +10,7 @@ import { me, reduceMotion } from 'mastodon/initial_state';
import ready from 'mastodon/ready';
import { store } from 'mastodon/store';
import { isProduction, isDevelopment } from './utils/environment';
import { isDevelopment, isProduction } from './utils/environment';
function main() {
perf.start('main()');
@ -41,29 +41,30 @@ function main() {
);
store.dispatch(setupBrowserNotifications());
if (isProduction() && me && 'serviceWorker' in navigator) {
const { Workbox } = await import('workbox-window');
const wb = new Workbox(
isDevelopment() ? '/packs-dev/dev-sw.js?dev-sw' : '/sw.js',
{ type: 'module', scope: '/' },
);
let registration;
try {
registration = await wb.register();
} catch (err) {
console.error(err);
if (
me &&
'serviceWorker' in navigator &&
(isDevelopment() || isProduction()) // Disallow testing environment
) {
let swPath = '/sw.js';
if (isDevelopment()) {
const { default: swDevUrl } =
await import('@/mastodon/service_worker/sw?url');
swPath = swDevUrl;
}
if (
registration &&
'Notification' in window &&
Notification.permission === 'granted'
) {
const registerPushNotifications =
await import('mastodon/actions/push_notifications');
await navigator.serviceWorker.register(swPath, {
scope: '/',
type: 'module',
});
store.dispatch(registerPushNotifications.register());
if (isProduction()) {
if ('Notification' in window && Notification.permission === 'granted') {
const registerPushNotifications =
await import('mastodon/actions/push_notifications');
store.dispatch(registerPushNotifications.register());
}
}
}

View File

@ -0,0 +1,390 @@
import { DAY } from '../utils/time';
import { expireCachedItems, handleFetch } from './caching';
const now = 1_700_000_000_000;
describe('expireCachedItems', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(now);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
test('deletes expired entries and requests without a cached response', async () => {
const cache = new MockCache();
const missingRequest = new Request('https://example.com/missing');
cache.set('/fresh', now - DAY);
cache.set('/expired', now - DAY * 31);
cache.store.set(missingRequest.url, { request: missingRequest });
vi.stubGlobal('caches', {
open: vi.fn().mockResolvedValue(cache),
});
await expireCachedItems({ name: 'images', ttl: DAY * 30, max: 5 });
expect(Array.from(cache.store.keys())).toEqual([
'https://example.com/fresh',
]);
});
test('trims the oldest valid entries when the cache exceeds max size', async () => {
const cache = new MockCache();
cache.set('/oldest', now - DAY * 3);
cache.set('/older', now - DAY * 2);
cache.set('/newest', now - DAY);
vi.stubGlobal('caches', {
open: vi.fn().mockResolvedValue(cache),
});
await expireCachedItems({ name: 'images', ttl: DAY * 30, max: 2 });
expect(Array.from(cache.store.keys())).toEqual([
'https://example.com/older',
'https://example.com/newest',
]);
});
test('keeps entries without a timestamp header over timestamped entries', async () => {
const cache = new MockCache();
const untimestampedRequest = new Request('https://example.com/no-header');
cache.store.set(untimestampedRequest.url, {
request: untimestampedRequest,
response: createResponse(),
});
cache.set('/older', now - DAY * 2);
cache.set('/newer', now - DAY);
vi.stubGlobal('caches', {
open: vi.fn().mockResolvedValue(cache),
});
await expireCachedItems({ name: 'images', ttl: DAY * 30, max: 2 });
expect(Array.from(cache.store.keys())).toEqual([
'https://example.com/no-header',
'https://example.com/newer',
]);
});
});
describe('handleFetch', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(now);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
test('serves cached images without hitting the network while the TTL is valid', async () => {
const imageCache = new MockCache();
const request = createRequest('/test.png', 'image');
const cachedResponse = createResponse(now - DAY);
imageCache.store.set(request.url, { request, response: cachedResponse });
vi.stubGlobal('caches', {
open: vi.fn().mockResolvedValue(imageCache),
});
const fetch = vi.fn();
vi.stubGlobal('fetch', fetch);
const { event, respondWith } = createFetchEvent(request);
handleFetch(event);
await expect(respondWith()).resolves.toBe(cachedResponse);
expect(fetch).not.toHaveBeenCalled();
});
test('fetches stale cached images from the network and stores a refreshed response', async () => {
const imageCache = new MockCache();
const request = createRequest('/stale.png', 'image');
const networkResponse = new Response('fresh', {
headers: { 'content-type': 'image/png' },
status: 200,
statusText: 'OK',
});
const putSpy = vi.spyOn(imageCache, 'put');
imageCache.store.set(request.url, {
request,
response: createResponse(now - DAY * 8),
});
vi.stubGlobal('caches', {
open: vi.fn().mockResolvedValue(imageCache),
});
const fetch = vi.fn().mockResolvedValue(networkResponse);
vi.stubGlobal('fetch', fetch);
const { event, respondWith } = createFetchEvent(request);
handleFetch(event);
await expect(respondWith()).resolves.toBe(networkResponse);
expect(fetch).toHaveBeenCalledWith(request);
expect(putSpy).toHaveBeenCalledWith(request, expect.any(Response));
expect(
imageCache.store.get(request.url)?.response?.headers.get('x-timestamp'),
).toBe(now.toString());
});
test('does not cache opaque image responses with status zero', async () => {
const imageCache = new MockCache();
const request = createRequest('/opaque.png', 'image');
const opaqueResponse = Response.error();
const putSpy = vi.spyOn(imageCache, 'put');
vi.stubGlobal('caches', {
open: vi.fn().mockResolvedValue(imageCache),
});
const fetch = vi.fn().mockResolvedValue(opaqueResponse);
vi.stubGlobal('fetch', fetch);
const { event, respondWith } = createFetchEvent(request);
handleFetch(event);
await expect(respondWith()).resolves.toBe(opaqueResponse);
expect(fetch).toHaveBeenCalledWith(request);
expect(putSpy).not.toHaveBeenCalled();
expect(imageCache.store.size).toBe(0);
});
test.each([
['/intl/en.js', '', 'mastodon-locales'],
['/fonts/mastodon.woff2', 'font', 'mastodon-fonts'],
])(
'routes %s requests through %s',
async (pathname, destination, cacheName) => {
const cache = new MockCache();
const request = createRequest(pathname, destination);
const networkResponse = new Response('asset', { status: 200 });
const open = vi.fn().mockImplementation((name: string) => {
expect(name).toBe(cacheName);
return Promise.resolve(cache);
});
vi.stubGlobal('caches', { open });
const fetch = vi.fn().mockResolvedValue(networkResponse);
vi.stubGlobal('fetch', fetch);
const { event, respondWith } = createFetchEvent(request);
handleFetch(event);
await expect(respondWith()).resolves.toBe(networkResponse);
expect(fetch).toHaveBeenCalledWith(request);
expect(open).toHaveBeenCalledWith(cacheName);
},
);
test('clears the root cache after a successful logout request', async () => {
const webCache = new MockCache();
const deleteSpy = vi.spyOn(webCache, 'delete');
vi.stubGlobal('caches', {
open: vi.fn().mockImplementation((name: string) => {
expect(name).toBe('mastodon-web');
return Promise.resolve(webCache);
}),
});
const fetch = vi
.fn()
.mockResolvedValue(new Response(null, { status: 200 }));
vi.stubGlobal('fetch', fetch);
const { event, respondWith } = createFetchEvent(
createRequest('/auth/sign_out'),
);
handleFetch(event);
await expect(respondWith()).resolves.toBeInstanceOf(Response);
expect(deleteSpy).toHaveBeenCalledWith('/');
});
test('does not clear the root cache after a failed logout request', async () => {
const webCache = new MockCache();
const deleteSpy = vi.spyOn(webCache, 'delete');
vi.stubGlobal('caches', {
open: vi.fn().mockResolvedValue(webCache),
});
const fetch = vi
.fn()
.mockResolvedValue(
new Response(null, { status: 500, statusText: 'Error' }),
);
vi.stubGlobal('fetch', fetch);
const { event, respondWith } = createFetchEvent(
createRequest('/auth/sign_out'),
);
handleFetch(event);
await expect(respondWith()).resolves.toBeInstanceOf(Response);
expect(deleteSpy).not.toHaveBeenCalled();
});
test('clears the root cache for opaqueredirect logout responses', async () => {
const webCache = new MockCache();
const deleteSpy = vi.spyOn(webCache, 'delete');
const opaqueRedirectResponse = {
ok: false,
type: 'opaqueredirect',
} as Response;
vi.stubGlobal('caches', {
open: vi.fn().mockResolvedValue(webCache),
});
const fetch = vi.fn().mockResolvedValue(opaqueRedirectResponse);
vi.stubGlobal('fetch', fetch);
const { event, respondWith } = createFetchEvent(
createRequest('/auth/sign_out'),
);
handleFetch(event);
await expect(respondWith()).resolves.toBe(opaqueRedirectResponse);
expect(deleteSpy).toHaveBeenCalledWith('/');
});
test('ignores requests that are not handled by the service worker cache', () => {
const { event, respondWith, respondWithMock } = createFetchEvent(
createRequest('/api/v1/timelines/home'),
);
handleFetch(event);
expect(respondWithMock).not.toHaveBeenCalled();
expect(respondWith()).toBeUndefined();
});
});
interface CacheEntry {
request: Request;
response?: Response;
}
class MockCache implements Cache {
public readonly store = new Map<string, CacheEntry>();
private normalizeRequest(request: RequestInfo | URL) {
if (request instanceof Request) {
return request.url;
}
return request.toString();
}
add(): Promise<void> {
return Promise.reject(new Error('Not implemented'));
}
addAll(): Promise<void> {
return Promise.reject(new Error('Not implemented'));
}
delete(request: RequestInfo | URL): Promise<boolean> {
return Promise.resolve(this.store.delete(this.normalizeRequest(request)));
}
keys(): Promise<readonly Request[]> {
return Promise.resolve(
Array.from(this.store.values(), ({ request }) => request),
);
}
match(request: RequestInfo | URL): Promise<Response | undefined> {
return Promise.resolve(
this.store.get(this.normalizeRequest(request))?.response,
);
}
matchAll(): Promise<readonly Response[]> {
return Promise.reject(new Error('Not implemented'));
}
put(request: RequestInfo | URL, response: Response): Promise<void> {
const normalizedRequest =
request instanceof Request ? request : new Request(request);
this.store.set(this.normalizeRequest(normalizedRequest), {
request: normalizedRequest,
response,
});
return Promise.resolve();
}
set(pathname: string, timestamp?: number) {
const request = new Request(`https://example.com${pathname}`);
this.store.set(request.url, {
request,
response: createResponse(timestamp),
});
return request;
}
}
function createResponse(timestamp?: number) {
const headers = new Headers();
if (timestamp !== undefined) {
headers.set('x-timestamp', timestamp.toString());
}
return new Response('body', { headers });
}
function createRequest(pathname: string, destination = '') {
const request = new Request(`https://example.com${pathname}`);
Object.defineProperty(request, 'destination', {
value: destination,
configurable: true,
});
return request;
}
function createFetchEvent(request: Request) {
let responsePromise: Promise<Response> | undefined;
const respondWith = vi.fn((response: Response | Promise<Response>) => {
responsePromise = Promise.resolve(response);
});
return {
event: {
request,
respondWith,
} as unknown as FetchEvent,
respondWith: () => responsePromise,
respondWithMock: respondWith,
};
}

View File

@ -0,0 +1,143 @@
/// <reference lib="WebWorker" />
/// <reference types="vite/client" />
import { DAY } from '../utils/time';
const CACHE_NAME_PREFIX = 'mastodon-';
const CACHE_HEADER_TTL = 'x-timestamp';
export async function cacheRoot() {
const cache = await openWebCache();
const response = await fetch('/', {
credentials: 'include',
redirect: 'manual',
});
await cache.put('/', response);
}
export function handleFetch(event: FetchEvent) {
const url = new URL(event.request.url);
if (url.pathname === '/auth/sign_out') {
event.respondWith(handleLogout(event));
} else if (/intl\/.*\.js$/.test(url.pathname)) {
event.respondWith(cacheFirst({ event, name: 'locales' }));
} else if (event.request.destination === 'font') {
event.respondWith(cacheFirst({ event, name: 'fonts' }));
} else if (event.request.destination === 'image') {
event.respondWith(cacheFirst({ event, name: 'images', ttl: DAY * 7 }));
}
}
async function cacheFirst({
event,
name,
ttl = DAY * 30,
max = 5,
}: {
event: FetchEvent;
name: string;
ttl?: number;
max?: number;
}) {
const cache = await caches.open(`${CACHE_NAME_PREFIX}${name}`);
const request = event.request;
const cachedResponse = await cache.match(request);
// Start expiring cache items while the process continues.
void expireCachedItems({ name, ttl, max });
if (cachedResponse) {
// If we have a cached response, check the TTL header.
const ttlHeader = Number.parseInt(
cachedResponse.headers.get(CACHE_HEADER_TTL) ?? '0',
);
if (!ttlHeader || ttlHeader + ttl > Date.now()) {
return cachedResponse;
}
}
const networkResponse = await fetch(request);
// For opaque responses, the status will be zero so we can't clone them.
if (networkResponse.status !== 0) {
// Clone request with a custom header to store timestamp.
const cloneHeaders = new Headers(networkResponse.headers);
cloneHeaders.set(CACHE_HEADER_TTL, Date.now().toString());
const cloneResponse = new Response(networkResponse.clone().body, {
headers: cloneHeaders,
status: networkResponse.status,
statusText: networkResponse.statusText,
});
await cache.put(request, cloneResponse);
}
return networkResponse;
}
export async function expireCachedItems({
name,
ttl = DAY * 30,
max = 5,
}: {
name: string;
ttl?: number;
max?: number;
}) {
const cache = await caches.open(`${CACHE_NAME_PREFIX}${name}`);
const keys = await cache.keys();
const now = Date.now();
const validKeys: { key: Request; timestamp: number }[] = [];
for (const key of keys) {
const cachedResponse = await cache.match(key);
if (!cachedResponse) {
await cache.delete(key);
continue;
}
const timestamp = Number.parseInt(
cachedResponse.headers.get(CACHE_HEADER_TTL) ?? '0',
);
if (!timestamp || timestamp + ttl > now) {
validKeys.push({ key, timestamp: timestamp || Number.POSITIVE_INFINITY });
continue;
}
await cache.delete(key);
}
if (validKeys.length <= max) {
return;
}
const sortedValidKeys = validKeys.toSorted(
({ timestamp: a }, { timestamp: b }) => a - b,
);
await Promise.all(
sortedValidKeys
.slice(0, sortedValidKeys.length - max)
.map(({ key }) => cache.delete(key)),
);
}
function openWebCache() {
return caches.open(`${CACHE_NAME_PREFIX}web`);
}
async function handleLogout(event: FetchEvent) {
const response = await fetch(event.request);
if (response.ok || response.type === 'opaqueredirect') {
const cache = await openWebCache();
await cache.delete('/');
}
return response;
}

View File

@ -1,88 +0,0 @@
import { ExpirationPlugin } from 'workbox-expiration';
import { registerRoute } from 'workbox-routing';
import { CacheFirst } from 'workbox-strategies';
import { handleNotificationClick, handlePush } from './web_push_notifications';
const CACHE_NAME_PREFIX = 'mastodon-';
function openWebCache() {
return caches.open(`${CACHE_NAME_PREFIX}web`);
}
function fetchRoot() {
return fetch('/', { credentials: 'include', redirect: 'manual' });
}
registerRoute(
/intl\/.*\.js$/,
new CacheFirst({
cacheName: `${CACHE_NAME_PREFIX}locales`,
plugins: [
new ExpirationPlugin({
maxAgeSeconds: 30 * 24 * 60 * 60, // 1 month
maxEntries: 5,
}),
],
}),
);
registerRoute(
({ request }) => request.destination === 'font',
new CacheFirst({
cacheName: `${CACHE_NAME_PREFIX}fonts`,
plugins: [
new ExpirationPlugin({
maxAgeSeconds: 30 * 24 * 60 * 60, // 1 month
maxEntries: 5,
}),
],
}),
);
registerRoute(
({ request }) => request.destination === 'image',
new CacheFirst({
cacheName: `m${CACHE_NAME_PREFIX}media`,
plugins: [
new ExpirationPlugin({
maxAgeSeconds: 7 * 24 * 60 * 60, // 1 week
maxEntries: 256,
}),
],
}),
);
// Cause a new version of a registered Service Worker to replace an existing one
// that is already installed, and replace the currently active worker on open pages.
self.addEventListener('install', function(event) {
event.waitUntil(Promise.all([openWebCache(), fetchRoot()]).then(([cache, root]) => cache.put('/', root)));
});
self.addEventListener('activate', function(event) {
event.waitUntil(self.clients.claim());
});
self.addEventListener('fetch', function(event) {
const url = new URL(event.request.url);
if (url.pathname === '/auth/sign_out') {
const asyncResponse = fetch(event.request);
const asyncCache = openWebCache();
event.respondWith(asyncResponse.then(response => {
if (response.ok || response.type === 'opaqueredirect') {
return Promise.all([
asyncCache.then(cache => cache.delete('/')),
indexedDB.deleteDatabase('mastodon'),
]).then(() => response);
}
return response;
}));
}
});
self.addEventListener('push', handlePush);
self.addEventListener('notificationclick', handleNotificationClick);

View File

@ -0,0 +1,22 @@
/// <reference lib="WebWorker" />
/// <reference types="vite/client" />
import { cacheRoot, handleFetch } from './caching';
import { handleNotificationClick, handlePush } from './web_push_notifications';
declare const self: ServiceWorkerGlobalScope;
// Cause a new version of a registered Service Worker to replace an existing one
// that is already installed, and replace the currently active worker on open pages.
self.addEventListener('install', (event) => {
event.waitUntil(cacheRoot());
});
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim());
});
self.addEventListener('fetch', handleFetch);
self.addEventListener('push', handlePush);
self.addEventListener('notificationclick', handleNotificationClick);

View File

@ -614,6 +614,7 @@ body > [data-popper-placement] {
display: block;
box-sizing: border-box;
width: 100%;
max-height: 300px;
margin: 0;
color: var(--color-text-primary);
background: transparent;

View File

@ -3,6 +3,8 @@
class ActivityPub::FeatureAuthorizationSerializer < ActivityPub::Serializer
include RoutingHelper
context_extensions :feature_authorizations
attributes :id, :type, :interacting_object, :interaction_target
def id

View File

@ -1,6 +1,8 @@
# frozen_string_literal: true
class ActivityPub::FeatureRequestSerializer < ActivityPub::Serializer
context_extensions :feature_requests
attributes :id, :type, :instrument
attribute :virtual_object, key: :object

View File

@ -1,6 +1,8 @@
# frozen_string_literal: true
class ActivityPub::FeaturedCollectionSerializer < ActivityPub::Serializer
context_extensions :discoverable, :featured_collections, :hashtag, :sensitive
attributes :id, :type, :total_items, :name, :attributed_to, :url,
:sensitive, :discoverable, :published, :updated

View File

@ -3,8 +3,9 @@
class ActivityPub::FeaturedItemSerializer < ActivityPub::Serializer
include RoutingHelper
attributes :id, :type, :featured_object, :featured_object_type,
:feature_authorization, :published
context_extensions :featured_collections
attributes :id, :type, :featured_object, :feature_authorization, :published
def id
ActivityPub::TagManager.instance.uri_for(object)
@ -18,10 +19,6 @@ class ActivityPub::FeaturedItemSerializer < ActivityPub::Serializer
ActivityPub::TagManager.instance.uri_for(object.account)
end
def featured_object_type
object.account.actor_type || 'Person'
end
def feature_authorization
if object.account.local?
ap_account_feature_authorization_url(object.account_id, object)

View File

@ -17,7 +17,7 @@ module Mastodon
end
def default_prerelease
'alpha.9'
'beta.1'
end
def prerelease

View File

@ -117,19 +117,15 @@
"stacktrace-js": "^2.0.2",
"stringz": "^2.1.0",
"substring-trie": "^1.0.2",
"terser": "^5.48.0",
"tesseract.js": "^7.0.0",
"tiny-queue": "^0.2.1",
"twitter-text": "3.1.0",
"use-debounce": "^10.0.0",
"vite": "^8.0.0",
"vite-plugin-manifest-sri": "^0.2.0",
"vite-plugin-pwa": "^1.2.0",
"vite-plugin-svgr": "^5.0.0",
"wicg-inert": "^3.1.2",
"workbox-expiration": "^7.3.0",
"workbox-routing": "^7.3.0",
"workbox-strategies": "^7.3.0",
"workbox-window": "^7.3.0"
"wicg-inert": "^3.1.2"
},
"devDependencies": {
"@eslint/js": "^9.39.2",

View File

@ -37,7 +37,6 @@ RSpec.describe ActivityPub::FeaturedCollectionSerializer do
'id' => ActivityPub::TagManager.instance.uri_for(collection_items.first),
'type' => 'FeaturedItem',
'featuredObject' => ActivityPub::TagManager.instance.uri_for(collection_items.first.account),
'featuredObjectType' => 'Person',
'featureAuthorization' => ap_account_feature_authorization_url(collection_items.first.account_id, collection_items.first),
'published' => match_api_datetime_format,
},
@ -45,7 +44,6 @@ RSpec.describe ActivityPub::FeaturedCollectionSerializer do
'id' => ActivityPub::TagManager.instance.uri_for(collection_items.last),
'type' => 'FeaturedItem',
'featuredObject' => ActivityPub::TagManager.instance.uri_for(collection_items.last.account),
'featuredObjectType' => 'Person',
'featureAuthorization' => ap_account_feature_authorization_url(collection_items.last.account_id, collection_items.last),
'published' => match_api_datetime_format,
},

View File

@ -15,7 +15,6 @@ RSpec.describe ActivityPub::FeaturedItemSerializer do
'type' => 'FeaturedItem',
'id' => ActivityPub::TagManager.instance.uri_for(collection_item),
'featuredObject' => ActivityPub::TagManager.instance.uri_for(collection_item.account),
'featuredObjectType' => 'Person',
'featureAuthorization' => ap_account_feature_authorization_url(collection_item.account_id, collection_item),
'published' => '2026-04-16T01:00:00Z',
})
@ -32,7 +31,6 @@ RSpec.describe ActivityPub::FeaturedItemSerializer do
'type' => 'FeaturedItem',
'id' => ActivityPub::TagManager.instance.uri_for(collection_item),
'featuredObject' => ActivityPub::TagManager.instance.uri_for(collection_item.account),
'featuredObjectType' => 'Person',
'featureAuthorization' => 'https://example.com/auth/1',
})
end

View File

@ -5,12 +5,14 @@ require 'rails_helper'
RSpec.describe 'Auth Registration' do
context 'when there are server rules' do
let!(:rule) { Fabricate :rule, text: 'You must be seven meters tall' }
let!(:rule_translation) { Fabricate :rule_translation, rule:, hint: 'Rule translation hint', text: rule.text }
it 'shows rules page before proceeding with sign up' do
visit new_user_registration_path
expect(page)
.to have_title(I18n.t('auth.register'))
.and have_text(rule.text)
.and have_text(rule_translation.hint)
end
end

View File

@ -17,7 +17,6 @@ import {
UserConfig,
} from 'vite';
import manifestSRI from 'vite-plugin-manifest-sri';
import { VitePWA } from 'vite-plugin-pwa';
import svgr from 'vite-plugin-svgr';
import { MastodonAssetsManifest } from './config/vite/plugin-assets-manifest';
@ -155,6 +154,14 @@ export const config: UserConfigFnPromise = async ({ mode, command }) => {
}
return '[name]-[hash].js';
},
entryFileNames({ name }) {
// If this is the service worker, don't add the hash to the name.
if (name === 'sw') {
return '[name].js';
}
// Otherwise, use the same value as chunkFileNames.
return '[name]-[hash].js';
},
},
},
},
@ -188,29 +195,6 @@ export const config: UserConfigFnPromise = async ({ mode, command }) => {
manifestSRI({
manifestPaths: ['.vite/manifest.json'],
}),
VitePWA({
srcDir: path.resolve(jsRoot, 'mastodon/service_worker'),
// We need to use injectManifest because we use our own service worker
strategies: 'injectManifest',
manifest: false,
injectRegister: false,
injectManifest: {
// Do not inject a manifest, we don't use precache
injectionPoint: undefined,
buildPlugins: {
vite: [
// Provide a virtual import with only the locales used in the ServiceWorker
MastodonServiceWorkerLocales(),
],
},
},
// Force the output location, because we have a symlink in `public/sw.js`
outDir: path.resolve(__dirname, 'public/packs'),
devOptions: {
enabled: true,
type: 'module',
},
}),
svgr(),
// Old library types need to be converted
optimizeLodashImports() as PluginOption,
@ -224,7 +208,9 @@ export const config: UserConfigFnPromise = async ({ mode, command }) => {
};
async function findEntrypoints() {
const entrypoints: Record<string, string> = {};
const entrypoints: Record<string, string> = {
sw: path.resolve(jsRoot, 'mastodon/service_worker/sw.ts'),
};
// First, JS entrypoints
const jsEntrypointsDir = path.resolve(jsRoot, 'entrypoints');

979
yarn.lock

File diff suppressed because it is too large Load Diff