From d801cf8e59edaea24cf70f1f62aa4e1ff8d1dcbd Mon Sep 17 00:00:00 2001 From: diondiondion Date: Thu, 25 Sep 2025 14:26:50 +0200 Subject: [PATCH 01/16] Replace `react-router-scroll-4` with inlined implementation (#36253) --- app/javascript/mastodon/components/router.tsx | 5 +- .../mastodon/components/scrollable_list.jsx | 2 +- .../mastodon/containers/mastodon.jsx | 9 +- .../mastodon/containers/scroll_container.js | 18 --- .../default_should_update_scroll.tsx | 25 ++++ .../containers/scroll_container/index.tsx | 62 ++++++++ .../scroll_container/scroll_context.tsx | 141 ++++++++++++++++++ .../scroll_container/state_storage.ts | 46 ++++++ .../mastodon/features/directory/index.tsx | 3 +- .../mastodon/features/status/index.jsx | 6 +- package.json | 2 +- yarn.lock | 55 ++----- 12 files changed, 302 insertions(+), 72 deletions(-) delete mode 100644 app/javascript/mastodon/containers/scroll_container.js create mode 100644 app/javascript/mastodon/containers/scroll_container/default_should_update_scroll.tsx create mode 100644 app/javascript/mastodon/containers/scroll_container/index.tsx create mode 100644 app/javascript/mastodon/containers/scroll_container/scroll_context.tsx create mode 100644 app/javascript/mastodon/containers/scroll_container/state_storage.ts diff --git a/app/javascript/mastodon/components/router.tsx b/app/javascript/mastodon/components/router.tsx index 815b4b59ab..1dc1d45083 100644 --- a/app/javascript/mastodon/components/router.tsx +++ b/app/javascript/mastodon/components/router.tsx @@ -1,6 +1,7 @@ import type { PropsWithChildren } from 'react'; import type React from 'react'; +import type { useLocation } from 'react-router'; import { Router as OriginalRouter, useHistory } from 'react-router'; import type { @@ -18,7 +19,9 @@ interface MastodonLocationState { mastodonModalKey?: string; } -type LocationState = MastodonLocationState | null | undefined; +export type LocationState = MastodonLocationState | null | undefined; + +export type MastodonLocation = ReturnType>; type HistoryPath = Path | LocationDescriptor; diff --git a/app/javascript/mastodon/components/scrollable_list.jsx b/app/javascript/mastodon/components/scrollable_list.jsx index 22ec18afa9..47b6235c9e 100644 --- a/app/javascript/mastodon/components/scrollable_list.jsx +++ b/app/javascript/mastodon/components/scrollable_list.jsx @@ -10,7 +10,7 @@ import { connect } from 'react-redux'; import { supportsPassiveEvents } from 'detect-passive-events'; import { throttle } from 'lodash'; -import ScrollContainer from 'mastodon/containers/scroll_container'; +import { ScrollContainer } from 'mastodon/containers/scroll_container'; import IntersectionObserverArticleContainer from '../containers/intersection_observer_article_container'; import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../features/ui/util/fullscreen'; diff --git a/app/javascript/mastodon/containers/mastodon.jsx b/app/javascript/mastodon/containers/mastodon.jsx index 8dcda3b0a9..086a7681c4 100644 --- a/app/javascript/mastodon/containers/mastodon.jsx +++ b/app/javascript/mastodon/containers/mastodon.jsx @@ -5,7 +5,6 @@ import { Route } from 'react-router-dom'; import { Provider as ReduxProvider } from 'react-redux'; -import { ScrollContext } from 'react-router-scroll-4'; import { fetchCustomEmojis } from 'mastodon/actions/custom_emojis'; import { hydrateStore } from 'mastodon/actions/store'; @@ -20,6 +19,8 @@ import { store } from 'mastodon/store'; import { isProduction } from 'mastodon/utils/environment'; import { BodyScrollLock } from 'mastodon/features/ui/components/body_scroll_lock'; +import { ScrollContext } from './scroll_container/scroll_context'; + const title = isProduction() ? siteTitle : `${siteTitle} (Dev)`; const hydrateAction = hydrateStore(initialState); @@ -45,10 +46,6 @@ export default class Mastodon extends PureComponent { } } - shouldUpdateScroll (prevRouterProps, { location }) { - return !(location.state?.mastodonModalKey && location.state?.mastodonModalKey !== prevRouterProps?.location?.state?.mastodonModalKey); - } - render () { return ( @@ -56,7 +53,7 @@ export default class Mastodon extends PureComponent { - + diff --git a/app/javascript/mastodon/containers/scroll_container.js b/app/javascript/mastodon/containers/scroll_container.js deleted file mode 100644 index d21ff63687..0000000000 --- a/app/javascript/mastodon/containers/scroll_container.js +++ /dev/null @@ -1,18 +0,0 @@ -import { ScrollContainer as OriginalScrollContainer } from 'react-router-scroll-4'; - -// ScrollContainer is used to automatically scroll to the top when pushing a -// new history state and remembering the scroll position when going back. -// There are a few things we need to do differently, though. -const defaultShouldUpdateScroll = (prevRouterProps, { location }) => { - // If the change is caused by opening a modal, do not scroll to top - return !(location.state?.mastodonModalKey && location.state?.mastodonModalKey !== prevRouterProps?.location?.state?.mastodonModalKey); -}; - -export default -class ScrollContainer extends OriginalScrollContainer { - - static defaultProps = { - shouldUpdateScroll: defaultShouldUpdateScroll, - }; - -} diff --git a/app/javascript/mastodon/containers/scroll_container/default_should_update_scroll.tsx b/app/javascript/mastodon/containers/scroll_container/default_should_update_scroll.tsx new file mode 100644 index 0000000000..b8726a1a75 --- /dev/null +++ b/app/javascript/mastodon/containers/scroll_container/default_should_update_scroll.tsx @@ -0,0 +1,25 @@ +import type { MastodonLocation } from 'mastodon/components/router'; + +export type ShouldUpdateScrollFn = ( + prevLocationContext: MastodonLocation | null, + locationContext: MastodonLocation, +) => boolean; + +/** + * ScrollBehavior will automatically scroll to the top on navigations + * or restore saved scroll positions, but on some location changes we + * need to prevent this. + */ + +export const defaultShouldUpdateScroll: ShouldUpdateScrollFn = ( + prevLocation, + location, +) => { + // If the change is caused by opening a modal, do not scroll to top + const shouldUpdateScroll = !( + location.state?.mastodonModalKey && + location.state.mastodonModalKey !== prevLocation?.state?.mastodonModalKey + ); + + return shouldUpdateScroll; +}; diff --git a/app/javascript/mastodon/containers/scroll_container/index.tsx b/app/javascript/mastodon/containers/scroll_container/index.tsx new file mode 100644 index 0000000000..e7d2726715 --- /dev/null +++ b/app/javascript/mastodon/containers/scroll_container/index.tsx @@ -0,0 +1,62 @@ +import React, { useContext, useEffect, useRef } from 'react'; + +import { defaultShouldUpdateScroll } from './default_should_update_scroll'; +import type { ShouldUpdateScrollFn } from './default_should_update_scroll'; +import { ScrollBehaviorContext } from './scroll_context'; + +interface ScrollContainerProps { + /** + * This key must be static for the element & not change + * while the component is mounted. + */ + scrollKey: string; + shouldUpdateScroll?: ShouldUpdateScrollFn; + children: React.ReactElement; +} + +/** + * `ScrollContainer` is used to manage the scroll position of elements on the page + * that can be scrolled independently of the page body. + * This component is a port of the unmaintained https://github.com/ytase/react-router-scroll/ + */ + +export const ScrollContainer: React.FC = ({ + children, + scrollKey, + shouldUpdateScroll = defaultShouldUpdateScroll, +}) => { + const scrollBehaviorContext = useContext(ScrollBehaviorContext); + + const containerRef = useRef(); + + /** + * Register/unregister scrollable element with ScrollBehavior + */ + useEffect(() => { + if (!scrollBehaviorContext || !containerRef.current) { + return; + } + + scrollBehaviorContext.registerElement( + scrollKey, + containerRef.current, + (prevLocation, location) => { + // Hack to allow accessing scrollBehavior._stateStorage + return shouldUpdateScroll.call( + scrollBehaviorContext.scrollBehavior, + prevLocation, + location, + ); + }, + ); + + return () => { + scrollBehaviorContext.unregisterElement(scrollKey); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return React.Children.only( + React.cloneElement(children, { ref: containerRef }), + ); +}; diff --git a/app/javascript/mastodon/containers/scroll_container/scroll_context.tsx b/app/javascript/mastodon/containers/scroll_container/scroll_context.tsx new file mode 100644 index 0000000000..a7eb780800 --- /dev/null +++ b/app/javascript/mastodon/containers/scroll_container/scroll_context.tsx @@ -0,0 +1,141 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; + +import { useLocation, useHistory } from 'react-router-dom'; + +import type { LocationBase } from 'scroll-behavior'; +import ScrollBehavior from 'scroll-behavior'; + +import type { + LocationState, + MastodonLocation, +} from 'mastodon/components/router'; +import { usePrevious } from 'mastodon/hooks/usePrevious'; + +import { defaultShouldUpdateScroll } from './default_should_update_scroll'; +import type { ShouldUpdateScrollFn } from './default_should_update_scroll'; +import { SessionStorage } from './state_storage'; + +type ScrollBehaviorInstance = InstanceType< + typeof ScrollBehavior +>; + +export interface ScrollBehaviorContextType { + registerElement: ( + key: string, + element: HTMLElement, + shouldUpdateScroll: ( + prevLocationContext: MastodonLocation | null, + locationContext: MastodonLocation, + ) => boolean, + ) => void; + unregisterElement: (key: string) => void; + scrollBehavior?: ScrollBehaviorInstance; +} + +export const ScrollBehaviorContext = + React.createContext(null); + +interface ScrollContextProps { + shouldUpdateScroll?: ShouldUpdateScrollFn; + children: React.ReactElement; +} + +/** + * A top-level wrapper that provides the app with an instance of the + * ScrollBehavior object. scroll-behavior is a library for managing the + * scroll position of a single-page app in the same way the browser would + * normally do for a multi-page app. This means it'll scroll back to top + * when navigating to a new page, and will restore the scroll position + * when navigating e.g. using `history.back`. + * The library keeps a record of scroll positions in session storage. + * + * This component is a port of the unmaintained https://github.com/ytase/react-router-scroll/ + */ + +export const ScrollContext: React.FC = ({ + children, + shouldUpdateScroll = defaultShouldUpdateScroll, +}) => { + const location = useLocation(); + const history = useHistory(); + + /** + * Keep the current location in a mutable ref so that ScrollBehavior's + * `getCurrentLocation` can access it without having to recreate the + * whole ScrollBehavior object + */ + const currentLocationRef = useRef(location); + useEffect(() => { + currentLocationRef.current = location; + }, [location]); + + /** + * Initialise ScrollBehavior object once – using state rather + * than a ref to simplify the types and ensure it's defined immediately. + */ + const [scrollBehavior] = useState( + (): ScrollBehaviorInstance => + new ScrollBehavior({ + addNavigationListener: history.listen.bind(history), + stateStorage: new SessionStorage(), + getCurrentLocation: () => + currentLocationRef.current as unknown as LocationBase, + shouldUpdateScroll: ( + prevLocationContext: MastodonLocation | null, + locationContext: MastodonLocation, + ) => + // Hack to allow accessing scrollBehavior._stateStorage + shouldUpdateScroll.call( + scrollBehavior, + prevLocationContext, + locationContext, + ), + }), + ); + + /** + * Handle scroll update when location changes + */ + const prevLocation = usePrevious(location) ?? null; + useEffect(() => { + scrollBehavior.updateScroll(prevLocation, location); + }, [location, prevLocation, scrollBehavior]); + + /** + * Stop Scrollbehavior on unmount + */ + useEffect(() => { + return () => { + scrollBehavior.stop(); + }; + }, [scrollBehavior]); + + /** + * Provide the app with a way to register separately scrollable + * elements to also be tracked by ScrollBehavior. (By default + * ScrollBehavior only handles scrolling on the main document body.) + */ + const contextValue = useMemo( + () => ({ + registerElement: (key, element, shouldUpdateScroll) => { + scrollBehavior.registerElement( + key, + element, + shouldUpdateScroll, + location, + ); + }, + unregisterElement: (key) => { + scrollBehavior.unregisterElement(key); + }, + scrollBehavior, + }), + [location, scrollBehavior], + ); + + return ( + + {React.Children.only(children)} + + ); +}; diff --git a/app/javascript/mastodon/containers/scroll_container/state_storage.ts b/app/javascript/mastodon/containers/scroll_container/state_storage.ts new file mode 100644 index 0000000000..fe8a208aae --- /dev/null +++ b/app/javascript/mastodon/containers/scroll_container/state_storage.ts @@ -0,0 +1,46 @@ +import type { LocationBase, ScrollPosition } from 'scroll-behavior'; + +const STATE_KEY_PREFIX = '@@scroll|'; + +interface LocationBaseWithKey extends LocationBase { + key?: string; +} + +/** + * This module is part of our port of https://github.com/ytase/react-router-scroll/ + * and handles storing scroll positions in SessionStorage. + * Stored positions (`[x, y]`) are keyed by the location key and an optional + * `scrollKey` that's used for to track separately scrollable elements other + * than the document body. + */ + +export class SessionStorage { + read( + location: LocationBaseWithKey, + key: string | null, + ): ScrollPosition | null { + const stateKey = this.getStateKey(location, key); + + try { + const value = sessionStorage.getItem(stateKey); + return value ? (JSON.parse(value) as ScrollPosition) : null; + } catch { + return null; + } + } + + save(location: LocationBaseWithKey, key: string | null, value: unknown) { + const stateKey = this.getStateKey(location, key); + const storedValue = JSON.stringify(value); + + try { + sessionStorage.setItem(stateKey, storedValue); + } catch {} + } + + getStateKey(location: LocationBaseWithKey, key: string | null) { + const locationKey = location.key; + const stateKeyBase = `${STATE_KEY_PREFIX}${locationKey}`; + return key == null ? stateKeyBase : `${stateKeyBase}|${key}`; + } +} diff --git a/app/javascript/mastodon/features/directory/index.tsx b/app/javascript/mastodon/features/directory/index.tsx index a29febcd1a..0fe140b4eb 100644 --- a/app/javascript/mastodon/features/directory/index.tsx +++ b/app/javascript/mastodon/features/directory/index.tsx @@ -21,7 +21,7 @@ import { ColumnHeader } from 'mastodon/components/column_header'; import { LoadMore } from 'mastodon/components/load_more'; import { LoadingIndicator } from 'mastodon/components/loading_indicator'; import { RadioButton } from 'mastodon/components/radio_button'; -import ScrollContainer from 'mastodon/containers/scroll_container'; +import { ScrollContainer } from 'mastodon/containers/scroll_container'; import { useSearchParam } from 'mastodon/hooks/useSearchParam'; import { useAppDispatch, useAppSelector } from 'mastodon/store'; @@ -206,7 +206,6 @@ export const Directory: React.FC<{ /> {multiColumn && !pinned ? ( - // @ts-expect-error ScrollContainer is not properly typed yet {scrollableArea} diff --git a/app/javascript/mastodon/features/status/index.jsx b/app/javascript/mastodon/features/status/index.jsx index 404faf609e..2ceff2577f 100644 --- a/app/javascript/mastodon/features/status/index.jsx +++ b/app/javascript/mastodon/features/status/index.jsx @@ -16,7 +16,7 @@ import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?reac import { Hotkeys } from 'mastodon/components/hotkeys'; import { Icon } from 'mastodon/components/icon'; import { LoadingIndicator } from 'mastodon/components/loading_indicator'; -import ScrollContainer from 'mastodon/containers/scroll_container'; +import { ScrollContainer } from 'mastodon/containers/scroll_container'; import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error'; import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; import { WithRouterPropTypes } from 'mastodon/utils/react_router'; @@ -526,9 +526,9 @@ class Status extends ImmutablePureComponent { this.setState({ fullscreen: isFullscreen() }); }; - shouldUpdateScroll = (prevRouterProps, { location }) => { + shouldUpdateScroll = (prevLocation, location) => { // Do not change scroll when opening a modal - if (location.state?.mastodonModalKey !== prevRouterProps?.location?.state?.mastodonModalKey) { + if (location.state?.mastodonModalKey !== prevLocation?.state?.mastodonModalKey) { return false; } diff --git a/package.json b/package.json index 2d0fa230cd..0fd14de656 100644 --- a/package.json +++ b/package.json @@ -99,7 +99,6 @@ "react-redux-loading-bar": "^5.0.8", "react-router": "^5.3.4", "react-router-dom": "^5.3.4", - "react-router-scroll-4": "^1.0.0-beta.1", "react-select": "^5.7.3", "react-sparklines": "^1.7.0", "react-swipeable-views": "^0.14.0", @@ -111,6 +110,7 @@ "rollup-plugin-gzip": "^4.1.1", "rollup-plugin-visualizer": "^6.0.3", "sass": "^1.62.1", + "scroll-behavior": "^0.11.0", "stacktrace-js": "^2.0.2", "stringz": "^2.1.0", "substring-trie": "^1.0.2", diff --git a/yarn.lock b/yarn.lock index 1ca3bec11e..beca808c93 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2836,7 +2836,6 @@ __metadata: react-redux-loading-bar: "npm:^5.0.8" react-router: "npm:^5.3.4" react-router-dom: "npm:^5.3.4" - react-router-scroll-4: "npm:^1.0.0-beta.1" react-select: "npm:^5.7.3" react-sparklines: "npm:^1.7.0" react-swipeable-views: "npm:^0.14.0" @@ -2849,6 +2848,7 @@ __metadata: rollup-plugin-gzip: "npm:^4.1.1" rollup-plugin-visualizer: "npm:^6.0.3" sass: "npm:^1.62.1" + scroll-behavior: "npm:^0.11.0" stacktrace-js: "npm:^2.0.2" storybook: "npm:^9.1.1" stringz: "npm:^2.1.0" @@ -6478,16 +6478,7 @@ __metadata: languageName: node linkType: hard -"dom-helpers@npm:^3.4.0": - version: 3.4.0 - resolution: "dom-helpers@npm:3.4.0" - dependencies: - "@babel/runtime": "npm:^7.1.2" - checksum: 10c0/1d2d3e4eadac2c4f4c8c7470a737ab32b7ec28237c4d094ea967ec3184168fd12452196fcc424a5d7860b6176117301aeaecba39467bf1a6e8492a8e5c9639d1 - languageName: node - linkType: hard - -"dom-helpers@npm:^5.0.1, dom-helpers@npm:^5.2.0": +"dom-helpers@npm:^5.0.1, dom-helpers@npm:^5.1.4, dom-helpers@npm:^5.2.0": version: 5.2.1 resolution: "dom-helpers@npm:5.2.1" dependencies: @@ -10036,6 +10027,13 @@ __metadata: languageName: node linkType: hard +"page-lifecycle@npm:^0.1.2": + version: 0.1.2 + resolution: "page-lifecycle@npm:0.1.2" + checksum: 10c0/509dbbc2ad2000dffcf591f66ab13d80fb1dba9337d85c76269173f7a5c3959b5a876e3bfb1e4494f6b932c1dc02a0b5824ebd452ab1a7204d4abdf498cb27c5 + languageName: node + linkType: hard + "parent-module@npm:^1.0.0": version: 1.0.1 resolution: "parent-module@npm:1.0.1" @@ -11277,21 +11275,6 @@ __metadata: languageName: node linkType: hard -"react-router-scroll-4@npm:^1.0.0-beta.1": - version: 1.0.0-beta.2 - resolution: "react-router-scroll-4@npm:1.0.0-beta.2" - dependencies: - scroll-behavior: "npm:^0.9.1" - warning: "npm:^3.0.0" - peerDependencies: - prop-types: ^15.6.0 - react: ^15.0.0 || ^16.0.0 - react-dom: ^15.0.0 || ^16.0.0 - react-router-dom: ^4.0 - checksum: 10c0/ad195b7359fd3146530cf299ec437f0a619c577b2cacfb2c76a156d3cd9d5d3e97af56e17c300c37ca8c485041e93124fe63f0c86db6aea468caf838281e62cb - languageName: node - linkType: hard - "react-router@npm:5.3.4, react-router@npm:^5.3.4": version: 5.3.4 resolution: "react-router@npm:5.3.4" @@ -12051,13 +12034,14 @@ __metadata: languageName: node linkType: hard -"scroll-behavior@npm:^0.9.1": - version: 0.9.12 - resolution: "scroll-behavior@npm:0.9.12" +"scroll-behavior@npm:^0.11.0": + version: 0.11.0 + resolution: "scroll-behavior@npm:0.11.0" dependencies: - dom-helpers: "npm:^3.4.0" + dom-helpers: "npm:^5.1.4" invariant: "npm:^2.2.4" - checksum: 10c0/4f438c48b93a1dcc2ab51a18670fac6f5ce41885291d8aa13251b4a187be9d0c6dd518ee974eb52ac9bbe227b9811c2615ecca73192a1a190b78dfdadb9c2cf2 + page-lifecycle: "npm:^0.1.2" + checksum: 10c0/c54010c9fdd9fc360fd7887ecf64f16972f9557ac679723709612cd54fc4778c7433ab46a9637933179ef31471f78e2591fb35351dc0e15537fecf1c8c89d32c languageName: node linkType: hard @@ -14013,15 +13997,6 @@ __metadata: languageName: node linkType: hard -"warning@npm:^3.0.0": - version: 3.0.0 - resolution: "warning@npm:3.0.0" - dependencies: - loose-envify: "npm:^1.0.0" - checksum: 10c0/6a2a56ab3139d3927193d926a027e74e1449fa47cc692feea95f8a81a4bb5b7f10c312def94cce03f3b58cb26ba3247858e75d17d596451d2c483a62e8204705 - languageName: node - linkType: hard - "warning@npm:^4.0.1, warning@npm:^4.0.3": version: 4.0.3 resolution: "warning@npm:4.0.3" From 11bd51564898854c652451c9a28d8f73afc6a293 Mon Sep 17 00:00:00 2001 From: diondiondion Date: Thu, 25 Sep 2025 18:14:49 +0200 Subject: [PATCH 02/16] Allow accessing ref of ScrollContainer's child (#36265) --- .../mastodon/components/scrollable_list.jsx | 2 +- .../containers/scroll_container/index.tsx | 16 +++++++++++++++- .../mastodon/features/status/index.jsx | 2 +- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/app/javascript/mastodon/components/scrollable_list.jsx b/app/javascript/mastodon/components/scrollable_list.jsx index 47b6235c9e..38c3cd991b 100644 --- a/app/javascript/mastodon/components/scrollable_list.jsx +++ b/app/javascript/mastodon/components/scrollable_list.jsx @@ -399,7 +399,7 @@ class ScrollableList extends PureComponent { if (trackScroll) { return ( - + {scrollableArea} ); diff --git a/app/javascript/mastodon/containers/scroll_container/index.tsx b/app/javascript/mastodon/containers/scroll_container/index.tsx index e7d2726715..0d0ab364dc 100644 --- a/app/javascript/mastodon/containers/scroll_container/index.tsx +++ b/app/javascript/mastodon/containers/scroll_container/index.tsx @@ -1,4 +1,9 @@ -import React, { useContext, useEffect, useRef } from 'react'; +import React, { + useContext, + useEffect, + useImperativeHandle, + useRef, +} from 'react'; import { defaultShouldUpdateScroll } from './default_should_update_scroll'; import type { ShouldUpdateScrollFn } from './default_should_update_scroll'; @@ -11,6 +16,7 @@ interface ScrollContainerProps { */ scrollKey: string; shouldUpdateScroll?: ShouldUpdateScrollFn; + childRef?: React.ForwardedRef; children: React.ReactElement; } @@ -23,12 +29,20 @@ interface ScrollContainerProps { export const ScrollContainer: React.FC = ({ children, scrollKey, + childRef, shouldUpdateScroll = defaultShouldUpdateScroll, }) => { const scrollBehaviorContext = useContext(ScrollBehaviorContext); const containerRef = useRef(); + /** + * If a childRef is passed, sync it with the containerRef. This + * is necessary because in this component's return statement, + * we're overwriting the immediate child component's ref prop. + */ + useImperativeHandle(childRef, () => containerRef.current, []); + /** * Register/unregister scrollable element with ScrollBehavior */ diff --git a/app/javascript/mastodon/features/status/index.jsx b/app/javascript/mastodon/features/status/index.jsx index 2ceff2577f..7c38af3277 100644 --- a/app/javascript/mastodon/features/status/index.jsx +++ b/app/javascript/mastodon/features/status/index.jsx @@ -602,7 +602,7 @@ class Status extends ImmutablePureComponent { )} /> - +
{ancestors} From f61d8cb02a22180d0502581c370e6d0e80c5b4bf Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Fri, 26 Sep 2025 04:21:03 -0400 Subject: [PATCH 03/16] Hold usable value lists in admin settings form (#36268) --- app/models/form/admin_settings.rb | 8 +++++--- app/views/admin/settings/about/show.html.haml | 4 ++-- app/views/admin/settings/registrations/show.html.haml | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb index 5f23e683b9..a19a6308fa 100644 --- a/app/models/form/admin_settings.rb +++ b/app/models/form/admin_settings.rb @@ -82,15 +82,17 @@ class Form::AdminSettings }.freeze DESCRIPTION_LIMIT = 200 + DOMAIN_BLOCK_AUDIENCES = %w(disabled users all).freeze + REGISTRATION_MODES = %w(open approved none).freeze attr_accessor(*KEYS) - validates :registrations_mode, inclusion: { in: %w(open approved none) }, if: -> { defined?(@registrations_mode) } + validates :registrations_mode, inclusion: { in: REGISTRATION_MODES }, if: -> { defined?(@registrations_mode) } validates :site_contact_email, :site_contact_username, presence: true, if: -> { defined?(@site_contact_username) || defined?(@site_contact_email) } validates :site_contact_username, existing_username: true, if: -> { defined?(@site_contact_username) } validates :bootstrap_timeline_accounts, existing_username: { multiple: true }, if: -> { defined?(@bootstrap_timeline_accounts) } - validates :show_domain_blocks, inclusion: { in: %w(disabled users all) }, if: -> { defined?(@show_domain_blocks) } - validates :show_domain_blocks_rationale, inclusion: { in: %w(disabled users all) }, if: -> { defined?(@show_domain_blocks_rationale) } + validates :show_domain_blocks, inclusion: { in: DOMAIN_BLOCK_AUDIENCES }, if: -> { defined?(@show_domain_blocks) } + validates :show_domain_blocks_rationale, inclusion: { in: DOMAIN_BLOCK_AUDIENCES }, if: -> { defined?(@show_domain_blocks_rationale) } validates :media_cache_retention_period, :content_cache_retention_period, :backups_retention_period, numericality: { only_integer: true }, allow_blank: true, if: -> { defined?(@media_cache_retention_period) || defined?(@content_cache_retention_period) || defined?(@backups_retention_period) } validates :min_age, numericality: { only_integer: true }, allow_blank: true, if: -> { defined?(@min_age) } validates :site_short_description, length: { maximum: DESCRIPTION_LIMIT }, if: -> { defined?(@site_short_description) } diff --git a/app/views/admin/settings/about/show.html.haml b/app/views/admin/settings/about/show.html.haml index 1eb47a0b54..adc8f1ff04 100644 --- a/app/views/admin/settings/about/show.html.haml +++ b/app/views/admin/settings/about/show.html.haml @@ -24,7 +24,7 @@ .fields-row__column.fields-row__column-6.fields-group = f.input :show_domain_blocks, collection_wrapper_tag: 'ul', - collection: %i(disabled users all), + collection: f.object.class::DOMAIN_BLOCK_AUDIENCES, include_blank: false, item_wrapper_tag: 'li', label_method: ->(value) { t("admin.settings.domain_blocks.#{value}") }, @@ -32,7 +32,7 @@ .fields-row__column.fields-row__column-6.fields-group = f.input :show_domain_blocks_rationale, collection_wrapper_tag: 'ul', - collection: %i(disabled users all), + collection: f.object.class::DOMAIN_BLOCK_AUDIENCES, include_blank: false, item_wrapper_tag: 'li', label_method: ->(value) { t("admin.settings.domain_blocks.#{value}") }, diff --git a/app/views/admin/settings/registrations/show.html.haml b/app/views/admin/settings/registrations/show.html.haml index cb5a3eb6ba..7303eca662 100644 --- a/app/views/admin/settings/registrations/show.html.haml +++ b/app/views/admin/settings/registrations/show.html.haml @@ -18,7 +18,7 @@ .fields-row .fields-row__column.fields-row__column-6.fields-group = f.input :registrations_mode, - collection: %w(open approved none), + collection: f.object.class::REGISTRATION_MODES, include_blank: false, label_method: ->(mode) { I18n.t("admin.settings.registrations_mode.modes.#{mode}") }, warning_hint: I18n.t('admin.settings.registrations_mode.warning_hint'), From c2d426a565c72d616284a61a0f6f2eca68336fae Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 26 Sep 2025 10:21:06 +0200 Subject: [PATCH 04/16] chore(deps): update dependency rubocop to v1.81.0 (#36269) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Gemfile.lock | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index b4d58cbb69..db436c01be 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -345,7 +345,7 @@ GEM azure-blob (~> 0.5.2) hashie (~> 5.0) jmespath (1.6.2) - json (2.13.2) + json (2.15.0) json-canonicalization (1.0.0) json-jwt (1.16.7) activesupport (>= 4.2) @@ -626,7 +626,7 @@ GEM net-smtp premailer (~> 1.7, >= 1.7.9) prettyprint (0.2.0) - prism (1.4.0) + prism (1.5.1) prometheus_exporter (2.3.0) webrick propshaft (1.3.1) @@ -722,7 +722,7 @@ GEM redis (4.8.1) redis-client (0.26.0) connection_pool - regexp_parser (2.11.2) + regexp_parser (2.11.3) reline (0.6.2) io-console (~> 0.5) request_store (1.7.0) @@ -766,7 +766,7 @@ GEM rspec-mocks (~> 3.0) sidekiq (>= 5, < 9) rspec-support (3.13.4) - rubocop (1.80.2) + rubocop (1.81.0) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -774,10 +774,10 @@ GEM parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.46.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.46.0) + rubocop-ast (1.47.1) parser (>= 3.3.7.2) prism (~> 1.4) rubocop-capybara (2.22.1) @@ -901,9 +901,9 @@ GEM unf (0.1.4) unf_ext unf_ext (0.0.9.1) - unicode-display_width (3.1.5) - unicode-emoji (~> 4.0, >= 4.0.4) - unicode-emoji (4.0.4) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.1.0) uri (1.0.3) useragent (0.16.11) validate_url (1.0.15) From 7431c505668d8face7415cc0d09be76e770ec821 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 26 Sep 2025 10:42:28 +0200 Subject: [PATCH 05/16] New Crowdin Translations (automated) (#36270) Co-authored-by: GitHub Actions --- app/javascript/mastodon/locales/be.json | 9 +++++++++ app/javascript/mastodon/locales/cs.json | 7 +++++++ app/javascript/mastodon/locales/de.json | 10 +++++----- app/javascript/mastodon/locales/et.json | 7 +++++++ app/javascript/mastodon/locales/fo.json | 7 +++++++ app/javascript/mastodon/locales/ga.json | 7 +++++++ app/javascript/mastodon/locales/nl.json | 7 +++++++ 7 files changed, 49 insertions(+), 5 deletions(-) diff --git a/app/javascript/mastodon/locales/be.json b/app/javascript/mastodon/locales/be.json index c61938faf5..6086fbf77f 100644 --- a/app/javascript/mastodon/locales/be.json +++ b/app/javascript/mastodon/locales/be.json @@ -864,6 +864,14 @@ "status.cancel_reblog_private": "Прыбраць", "status.cannot_quote": "Вы не маеце дазвол цытаваць гэты допіс", "status.cannot_reblog": "Гэты допіс нельга пашырыць", + "status.contains_quote": "Утрымлівае цытату", + "status.context.loading": "Загружаюцца іншыя адказы", + "status.context.loading_error": "Немагчыма загрузіць новыя адказы", + "status.context.loading_more": "Загружаюцца іншыя адказы", + "status.context.loading_success": "Усе адказы загружаныя", + "status.context.more_replies_found": "Знойдзеныя іншыя адказы", + "status.context.retry": "Паспрабаваць зноў", + "status.context.show": "Паказаць", "status.continued_thread": "Працяг ланцужка", "status.copy": "Скапіраваць спасылку на допіс", "status.delete": "Выдаліць", @@ -901,6 +909,7 @@ "status.quote_error.revoked": "Аўтар выдаліў допіс", "status.quote_followers_only": "Толькі падпісчыкі могуць цытаваць гэты допіс", "status.quote_manual_review": "Аўтар зробіць агляд уручную", + "status.quote_noun": "Цытаваць", "status.quote_policy_change": "Змяніць, хто можа цытаваць", "status.quote_post_author": "Цытаваў допіс @{name}", "status.quote_private": "Прыватныя допісы нельга цытаваць", diff --git a/app/javascript/mastodon/locales/cs.json b/app/javascript/mastodon/locales/cs.json index 3b4138cbc8..d972d4705b 100644 --- a/app/javascript/mastodon/locales/cs.json +++ b/app/javascript/mastodon/locales/cs.json @@ -865,6 +865,13 @@ "status.cannot_quote": "Nemáte oprávnění citovat tento příspěvek", "status.cannot_reblog": "Tento příspěvek nemůže být boostnutý", "status.contains_quote": "Obsahuje citaci", + "status.context.loading": "Načítání dalších odpovědí", + "status.context.loading_error": "Nelze načíst nové odpovědi", + "status.context.loading_more": "Načítání dalších odpovědí", + "status.context.loading_success": "Všechny odpovědi načteny", + "status.context.more_replies_found": "Nalezeny další odpovědi", + "status.context.retry": "Zkusit znovu", + "status.context.show": "Zobrazit", "status.continued_thread": "Pokračuje ve vlákně", "status.copy": "Zkopírovat odkaz na příspěvek", "status.delete": "Smazat", diff --git a/app/javascript/mastodon/locales/de.json b/app/javascript/mastodon/locales/de.json index 910ed0c19a..e756b6bab6 100644 --- a/app/javascript/mastodon/locales/de.json +++ b/app/javascript/mastodon/locales/de.json @@ -865,12 +865,12 @@ "status.cannot_quote": "Dir ist es nicht gestattet, diesen Beitrag zu zitieren", "status.cannot_reblog": "Dieser Beitrag kann nicht geteilt werden", "status.contains_quote": "Enthält Zitat", - "status.context.loading": "Weitere Antworten werden geladen", - "status.context.loading_error": "Neue Antworten konnten nicht geladen werden", - "status.context.loading_more": "Weitere Antworten werden geladen", - "status.context.loading_success": "Alle Antworten geladen", + "status.context.loading": "Weitere Antworten laden", + "status.context.loading_error": "Weitere Antworten konnten nicht geladen werden", + "status.context.loading_more": "Weitere Antworten laden", + "status.context.loading_success": "Alle weiteren Antworten geladen", "status.context.more_replies_found": "Weitere Antworten verfügbar", - "status.context.retry": "Wiederholen", + "status.context.retry": "Erneut versuchen", "status.context.show": "Anzeigen", "status.continued_thread": "Fortgeführter Thread", "status.copy": "Link zum Beitrag kopieren", diff --git a/app/javascript/mastodon/locales/et.json b/app/javascript/mastodon/locales/et.json index b57383da23..b6e02a0ba6 100644 --- a/app/javascript/mastodon/locales/et.json +++ b/app/javascript/mastodon/locales/et.json @@ -865,6 +865,13 @@ "status.cannot_quote": "Sul pole õigust seda postitust tsiteerida", "status.cannot_reblog": "Seda postitust ei saa jagada", "status.contains_quote": "Sisaldab tsitaati", + "status.context.loading": "Laadin veel vastuseid", + "status.context.loading_error": "Uute vastuste laadimine ei õnnestunud", + "status.context.loading_more": "Laadin veel vastuseid", + "status.context.loading_success": "Kõik vastused on laaditud", + "status.context.more_replies_found": "Leidub veel vastuseid", + "status.context.retry": "Proovi uuesti", + "status.context.show": "Näita", "status.continued_thread": "Jätkatud lõim", "status.copy": "Kopeeri postituse link", "status.delete": "Kustuta", diff --git a/app/javascript/mastodon/locales/fo.json b/app/javascript/mastodon/locales/fo.json index 348de34eb1..938db30d7a 100644 --- a/app/javascript/mastodon/locales/fo.json +++ b/app/javascript/mastodon/locales/fo.json @@ -865,6 +865,13 @@ "status.cannot_quote": "Tú hevur ikki loyvi at sitera hendan postin", "status.cannot_reblog": "Tað ber ikki til at stimbra hendan postin", "status.contains_quote": "Inniheldur sitat", + "status.context.loading": "Tekur fleiri svar niður", + "status.context.loading_error": "Fekk ikki tikið nýggj svar niður", + "status.context.loading_more": "Tekur fleiri svar niður", + "status.context.loading_success": "Øll svar tikin niður", + "status.context.more_replies_found": "Fleiri svar funnin", + "status.context.retry": "Royn aftur", + "status.context.show": "Vís", "status.continued_thread": "Framhaldandi tráður", "status.copy": "Kopiera leinki til postin", "status.delete": "Strika", diff --git a/app/javascript/mastodon/locales/ga.json b/app/javascript/mastodon/locales/ga.json index 2277128032..88d03ea43c 100644 --- a/app/javascript/mastodon/locales/ga.json +++ b/app/javascript/mastodon/locales/ga.json @@ -865,6 +865,13 @@ "status.cannot_quote": "Ní cheadaítear duit an post seo a lua", "status.cannot_reblog": "Ní féidir an phostáil seo a mholadh", "status.contains_quote": "Tá luachan ann", + "status.context.loading": "Ag lódáil tuilleadh freagraí", + "status.context.loading_error": "Níorbh fhéidir freagraí nua a lódáil", + "status.context.loading_more": "Ag lódáil tuilleadh freagraí", + "status.context.loading_success": "Luchtaithe na freagraí uile", + "status.context.more_replies_found": "Tuilleadh freagraí aimsithe", + "status.context.retry": "Déan iarracht arís", + "status.context.show": "Taispeáin", "status.continued_thread": "Snáithe ar lean", "status.copy": "Cóipeáil an nasc chuig an bpostáil", "status.delete": "Scrios", diff --git a/app/javascript/mastodon/locales/nl.json b/app/javascript/mastodon/locales/nl.json index 894a93b5fb..4250040040 100644 --- a/app/javascript/mastodon/locales/nl.json +++ b/app/javascript/mastodon/locales/nl.json @@ -865,6 +865,13 @@ "status.cannot_quote": "Je bent niet gemachtigd om dit bericht te citeren", "status.cannot_reblog": "Dit bericht kan niet geboost worden", "status.contains_quote": "Bevat citaat", + "status.context.loading": "Meer reacties laden", + "status.context.loading_error": "Kon geen nieuwe reacties laden", + "status.context.loading_more": "Meer reacties laden", + "status.context.loading_success": "Alle reacties zijn geladen", + "status.context.more_replies_found": "Meer reacties gevonden", + "status.context.retry": "Opnieuw proberen", + "status.context.show": "Tonen", "status.continued_thread": "Vervolg van gesprek", "status.copy": "Link naar bericht kopiëren", "status.delete": "Verwijderen", From 238d74fe81b960de974c8a7459ec81f832842261 Mon Sep 17 00:00:00 2001 From: Brad Dunbar Date: Fri, 26 Sep 2025 04:53:08 -0400 Subject: [PATCH 06/16] Refactor `getFocusedItemIndex` to avoid conditionals that `closest` already handles (#36267) --- .../mastodon/features/ui/util/focusUtils.ts | 20 +++++-------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/app/javascript/mastodon/features/ui/util/focusUtils.ts b/app/javascript/mastodon/features/ui/util/focusUtils.ts index a19852e0d2..9bcd3f8943 100644 --- a/app/javascript/mastodon/features/ui/util/focusUtils.ts +++ b/app/javascript/mastodon/features/ui/util/focusUtils.ts @@ -60,23 +60,13 @@ export function focusColumn({ * Get the index of the currently focused item in one of our item lists */ export function getFocusedItemIndex() { - const focusedElement = document.activeElement; - const itemList = focusedElement?.closest('.item-list'); - - if (!focusedElement || !itemList) { - return -1; - } - - let focusedItem: HTMLElement | null = null; - if (focusedElement.parentElement === itemList) { - focusedItem = focusedElement as HTMLElement; - } else { - focusedItem = focusedElement.closest('.item-list > *'); - } - + const focusedItem = document.activeElement?.closest('.item-list > *'); if (!focusedItem) return -1; - const items = Array.from(itemList.children); + const { parentElement } = focusedItem; + if (!parentElement) return -1; + + const items = Array.from(parentElement.children); return items.indexOf(focusedItem); } From 1571514e49ec02a57c050612b3bca856f54933fb Mon Sep 17 00:00:00 2001 From: diondiondion Date: Fri, 26 Sep 2025 11:23:30 +0200 Subject: [PATCH 07/16] Fix page being vertically scrollable in Advanced UI (#36271) --- app/javascript/styles/mastodon/components.scss | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index b390a8a8e5..614b268ec7 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -2972,8 +2972,12 @@ a.account__display-name { justify-content: flex-start; position: relative; - &.unscrollable { - overflow-x: hidden; + .layout-multiple-columns & { + overflow-x: auto; + + &.unscrollable { + overflow-x: hidden; + } } &__panels { From e07b9dfdc12adb9c8b79d89f80049335053c3324 Mon Sep 17 00:00:00 2001 From: Echo Date: Fri, 26 Sep 2025 11:50:59 +0200 Subject: [PATCH 08/16] Adds new HTMLBlock component (#36262) --- app/javascript/config/html-tags.json | 61 +++++++++ .../html_block/html_block.stories.tsx | 40 ++++++ .../mastodon/components/html_block/index.tsx | 50 +++++++ .../mastodon/features/emoji/hooks.ts | 29 +---- .../mastodon/features/emoji/normalize.ts | 23 +++- .../__tests__/__snapshots__/html-test.ts.snap | 3 + .../mastodon/utils/__tests__/html-test.ts | 11 +- app/javascript/mastodon/utils/html.ts | 122 +++++++++++------- 8 files changed, 260 insertions(+), 79 deletions(-) create mode 100644 app/javascript/config/html-tags.json create mode 100644 app/javascript/mastodon/components/html_block/html_block.stories.tsx create mode 100644 app/javascript/mastodon/components/html_block/index.tsx diff --git a/app/javascript/config/html-tags.json b/app/javascript/config/html-tags.json new file mode 100644 index 0000000000..c788113487 --- /dev/null +++ b/app/javascript/config/html-tags.json @@ -0,0 +1,61 @@ +{ + "global": { + "class": "className", + "id": true, + "title": true, + "dir": true, + "lang": true + }, + "tags": { + "p": {}, + "br": { + "children": false + }, + "span": { + "attributes": { + "translate": true + } + }, + "a": { + "attributes": { + "href": true, + "rel": true, + "translate": true, + "target": true + } + }, + "del": {}, + "s": {}, + "pre": {}, + "blockquote": {}, + "code": {}, + "b": {}, + "strong": {}, + "u": {}, + "i": {}, + "img": { + "children": false, + "attributes": { + "src": true, + "alt": true, + "title": true + } + }, + "em": {}, + "ul": {}, + "ol": { + "attributes": { + "start": true, + "reversed": true + } + }, + "li": { + "attributes": { + "value": true + } + }, + "ruby": {}, + "rt": {}, + "rp": {} + } +} diff --git a/app/javascript/mastodon/components/html_block/html_block.stories.tsx b/app/javascript/mastodon/components/html_block/html_block.stories.tsx new file mode 100644 index 0000000000..9c104ba45c --- /dev/null +++ b/app/javascript/mastodon/components/html_block/html_block.stories.tsx @@ -0,0 +1,40 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect } from 'storybook/test'; + +import { HTMLBlock } from './index'; + +const meta = { + title: 'Components/HTMLBlock', + component: HTMLBlock, + args: { + contents: + '

Hello, world!

\n

A link

\n

This should be filtered out:

', + }, + render(args) { + return ( + // Just for visual clarity in Storybook. +
+ +
+ ); + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + async play({ canvas }) { + const link = canvas.queryByRole('link'); + await expect(link).toBeInTheDocument(); + const button = canvas.queryByRole('button'); + await expect(button).not.toBeInTheDocument(); + }, +}; diff --git a/app/javascript/mastodon/components/html_block/index.tsx b/app/javascript/mastodon/components/html_block/index.tsx new file mode 100644 index 0000000000..51baea614d --- /dev/null +++ b/app/javascript/mastodon/components/html_block/index.tsx @@ -0,0 +1,50 @@ +import type { FC, ReactNode } from 'react'; +import { useMemo } from 'react'; + +import { cleanExtraEmojis } from '@/mastodon/features/emoji/normalize'; +import type { CustomEmojiMapArg } from '@/mastodon/features/emoji/types'; +import { createLimitedCache } from '@/mastodon/utils/cache'; + +import { htmlStringToComponents } from '../../utils/html'; + +// Use a module-level cache to avoid re-rendering the same HTML multiple times. +const cache = createLimitedCache({ maxSize: 1000 }); + +interface HTMLBlockProps { + contents: string; + extraEmojis?: CustomEmojiMapArg; +} + +export const HTMLBlock: FC = ({ + contents: raw, + extraEmojis, +}) => { + const customEmojis = useMemo( + () => cleanExtraEmojis(extraEmojis), + [extraEmojis], + ); + const contents = useMemo(() => { + const key = JSON.stringify({ raw, customEmojis }); + if (cache.has(key)) { + return cache.get(key); + } + + const rendered = htmlStringToComponents(raw, { + onText, + extraArgs: { customEmojis }, + }); + + cache.set(key, rendered); + return rendered; + }, [raw, customEmojis]); + + return contents; +}; + +function onText( + text: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Doesn't do anything, just showing how typing would work. + { customEmojis }: { customEmojis: CustomEmojiMapArg | null }, +) { + return text; +} diff --git a/app/javascript/mastodon/features/emoji/hooks.ts b/app/javascript/mastodon/features/emoji/hooks.ts index 7e91486780..b3b27d274a 100644 --- a/app/javascript/mastodon/features/emoji/hooks.ts +++ b/app/javascript/mastodon/features/emoji/hooks.ts @@ -1,19 +1,13 @@ import { useCallback, useLayoutEffect, useMemo, useState } from 'react'; -import { isList } from 'immutable'; - -import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji'; import { useAppSelector } from '@/mastodon/store'; import { isModernEmojiEnabled } from '@/mastodon/utils/environment'; import { toSupportedLocale } from './locale'; import { determineEmojiMode } from './mode'; +import { cleanExtraEmojis } from './normalize'; import { emojifyElement, emojifyText } from './render'; -import type { - CustomEmojiMapArg, - EmojiAppState, - ExtraCustomEmojiMap, -} from './types'; +import type { CustomEmojiMapArg, EmojiAppState } from './types'; import { stringHasAnyEmoji } from './utils'; interface UseEmojifyOptions { @@ -30,20 +24,7 @@ export function useEmojify({ const [emojifiedText, setEmojifiedText] = useState(null); const appState = useEmojiAppState(); - const extra: ExtraCustomEmojiMap = useMemo(() => { - if (!extraEmojis) { - return {}; - } - if (isList(extraEmojis)) { - return ( - extraEmojis.toJS() as ApiCustomEmojiJSON[] - ).reduce( - (acc, emoji) => ({ ...acc, [emoji.shortcode]: emoji }), - {}, - ); - } - return extraEmojis; - }, [extraEmojis]); + const extra = useMemo(() => cleanExtraEmojis(extraEmojis), [extraEmojis]); const emojify = useCallback( async (input: string) => { @@ -51,11 +32,11 @@ export function useEmojify({ if (deep) { const wrapper = document.createElement('div'); wrapper.innerHTML = input; - if (await emojifyElement(wrapper, appState, extra)) { + if (await emojifyElement(wrapper, appState, extra ?? {})) { result = wrapper.innerHTML; } } else { - result = await emojifyText(text, appState, extra); + result = await emojifyText(text, appState, extra ?? {}); } if (result) { setEmojifiedText(result); diff --git a/app/javascript/mastodon/features/emoji/normalize.ts b/app/javascript/mastodon/features/emoji/normalize.ts index 6a64c3b8bf..959732f985 100644 --- a/app/javascript/mastodon/features/emoji/normalize.ts +++ b/app/javascript/mastodon/features/emoji/normalize.ts @@ -1,3 +1,5 @@ +import { isList } from 'immutable'; + import { VARIATION_SELECTOR_CODE, KEYCAP_CODE, @@ -7,7 +9,11 @@ import { EMOJIS_WITH_DARK_BORDER, EMOJIS_WITH_LIGHT_BORDER, } from './constants'; -import type { TwemojiBorderInfo } from './types'; +import type { + CustomEmojiMapArg, + ExtraCustomEmojiMap, + TwemojiBorderInfo, +} from './types'; // Misc codes that have special handling const SKIER_CODE = 0x26f7; @@ -150,6 +156,21 @@ export function twemojiToUnicodeInfo( return hexNumbersToString(mappedCodes); } +export function cleanExtraEmojis(extraEmojis?: CustomEmojiMapArg) { + if (!extraEmojis) { + return null; + } + if (!isList(extraEmojis)) { + return extraEmojis; + } + return extraEmojis + .toJSON() + .reduce( + (acc, emoji) => ({ ...acc, [emoji.shortcode]: emoji }), + {}, + ); +} + function hexStringToNumbers(hexString: string): number[] { return hexString .split('-') diff --git a/app/javascript/mastodon/utils/__tests__/__snapshots__/html-test.ts.snap b/app/javascript/mastodon/utils/__tests__/__snapshots__/html-test.ts.snap index a579efa406..ea4561bc61 100644 --- a/app/javascript/mastodon/utils/__tests__/__snapshots__/html-test.ts.snap +++ b/app/javascript/mastodon/utils/__tests__/__snapshots__/html-test.ts.snap @@ -26,9 +26,11 @@ exports[`html > htmlStringToComponents > handles nested elements 1`] = ` exports[`html > htmlStringToComponents > ignores empty text nodes 1`] = ` [

+ lorem ipsum +

, ] `; @@ -37,6 +39,7 @@ exports[`html > htmlStringToComponents > respects allowedTags option 1`] = ` [

lorem + dolor diff --git a/app/javascript/mastodon/utils/__tests__/html-test.ts b/app/javascript/mastodon/utils/__tests__/html-test.ts index 6c08cc7cbf..6aacc396dc 100644 --- a/app/javascript/mastodon/utils/__tests__/html-test.ts +++ b/app/javascript/mastodon/utils/__tests__/html-test.ts @@ -48,7 +48,7 @@ describe('html', () => { const input = '

lorem ipsum

'; const onText = vi.fn((text: string) => text); html.htmlStringToComponents(input, { onText }); - expect(onText).toHaveBeenCalledExactlyOnceWith('lorem ipsum'); + expect(onText).toHaveBeenCalledExactlyOnceWith('lorem ipsum', {}); }); it('calls onElement callback', () => { @@ -61,6 +61,7 @@ describe('html', () => { expect(onElement).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ tagName: 'P' }), expect.arrayContaining(['lorem ipsum']), + {}, ); }); @@ -71,6 +72,7 @@ describe('html', () => { expect(onElement).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ tagName: 'P' }), expect.arrayContaining(['lorem ipsum']), + {}, ); expect(output).toMatchSnapshot(); }); @@ -88,15 +90,16 @@ describe('html', () => { 'href', 'https://example.com', 'a', + {}, ); - expect(onAttribute).toHaveBeenCalledWith('target', '_blank', 'a'); - expect(onAttribute).toHaveBeenCalledWith('rel', 'nofollow', 'a'); + expect(onAttribute).toHaveBeenCalledWith('target', '_blank', 'a', {}); + expect(onAttribute).toHaveBeenCalledWith('rel', 'nofollow', 'a', {}); }); it('respects allowedTags option', () => { const input = '

lorem ipsum dolor

'; const output = html.htmlStringToComponents(input, { - allowedTags: new Set(['p', 'em']), + allowedTags: { p: {}, em: {} }, }); expect(output).toMatchSnapshot(); }); diff --git a/app/javascript/mastodon/utils/html.ts b/app/javascript/mastodon/utils/html.ts index 1686322300..971aefa6d1 100644 --- a/app/javascript/mastodon/utils/html.ts +++ b/app/javascript/mastodon/utils/html.ts @@ -1,5 +1,7 @@ import React from 'react'; +import htmlConfig from '../../config/html-tags.json'; + // NB: This function can still return unsafe HTML export const unescapeHTML = (html: string) => { const wrapper = document.createElement('div'); @@ -10,64 +12,49 @@ export const unescapeHTML = (html: string) => { return wrapper.textContent; }; +interface AllowedTag { + /* True means allow, false disallows global attributes, string renames the attribute name for React. */ + attributes?: Record; + /* If false, the tag cannot have children. Undefined or true means allowed. */ + children?: boolean; +} + +type AllowedTagsType = { + [Tag in keyof React.ReactHTML]?: AllowedTag; +}; + +const globalAttributes: Record = htmlConfig.global; +const defaultAllowedTags: AllowedTagsType = htmlConfig.tags; + interface QueueItem { node: Node; parent: React.ReactNode[]; depth: number; } -interface Options { +export interface HTMLToStringOptions> { maxDepth?: number; - onText?: (text: string) => React.ReactNode; + onText?: (text: string, extra: Arg) => React.ReactNode; onElement?: ( element: HTMLElement, children: React.ReactNode[], + extra: Arg, ) => React.ReactNode; onAttribute?: ( name: string, value: string, tagName: string, + extra: Arg, ) => [string, unknown] | null; - allowedTags?: Set; + allowedTags?: AllowedTagsType; + extraArgs?: Arg; } -const DEFAULT_ALLOWED_TAGS: ReadonlySet = new Set([ - 'a', - 'abbr', - 'b', - 'blockquote', - 'br', - 'cite', - 'code', - 'del', - 'dfn', - 'dl', - 'dt', - 'em', - 'h1', - 'h2', - 'h3', - 'h4', - 'h5', - 'h6', - 'hr', - 'i', - 'li', - 'ol', - 'p', - 'pre', - 'small', - 'span', - 'strong', - 'sub', - 'sup', - 'time', - 'u', - 'ul', -]); -export function htmlStringToComponents( +let uniqueIdCounter = 0; + +export function htmlStringToComponents>( htmlString: string, - options: Options = {}, + options: HTMLToStringOptions = {}, ) { const wrapper = document.createElement('template'); wrapper.innerHTML = htmlString; @@ -79,10 +66,11 @@ export function htmlStringToComponents( const { maxDepth = 10, - allowedTags = DEFAULT_ALLOWED_TAGS, + allowedTags = defaultAllowedTags, onAttribute, onElement, onText, + extraArgs = {} as Arg, } = options; while (queue.length > 0) { @@ -109,9 +97,9 @@ export function htmlStringToComponents( // Text can be added directly if it has any non-whitespace content. case Node.TEXT_NODE: { const text = node.textContent; - if (text && text.trim() !== '') { + if (text) { if (onText) { - parent.push(onText(text)); + parent.push(onText(text, extraArgs)); } else { parent.push(text); } @@ -127,7 +115,9 @@ export function htmlStringToComponents( } // If the tag is not allowed, skip it and its children. - if (!allowedTags.has(node.tagName.toLowerCase())) { + const tagName = node.tagName.toLowerCase(); + const tagInfo = allowedTags[tagName as keyof typeof allowedTags]; + if (!tagInfo) { continue; } @@ -137,7 +127,8 @@ export function htmlStringToComponents( // If onElement is provided, use it to create the element. if (onElement) { - const component = onElement(node, children); + const component = onElement(node, children, extraArgs); + // Check for undefined to allow returning null. if (component !== undefined) { element = component; @@ -147,25 +138,56 @@ export function htmlStringToComponents( // If the element wasn't created, use the default conversion. if (element === undefined) { const props: Record = {}; + props.key = `html-${uniqueIdCounter++}`; // Get the current key and then increment it. for (const attr of node.attributes) { + let name = attr.name.toLowerCase(); + + // Custom attribute handler. if (onAttribute) { const result = onAttribute( - attr.name, + name, attr.value, node.tagName.toLowerCase(), + extraArgs, ); if (result) { - const [name, value] = result; - props[name] = value; + const [cbName, value] = result; + props[cbName] = value; } } else { - props[attr.name] = attr.value; + // Check global attributes first, then tag-specific ones. + const globalAttr = globalAttributes[name]; + const tagAttr = tagInfo.attributes?.[name]; + + // Exit if neither global nor tag-specific attribute is allowed. + if (!globalAttr && !tagAttr) { + continue; + } + + // Rename if needed. + if (typeof tagAttr === 'string') { + name = tagAttr; + } else if (typeof globalAttr === 'string') { + name = globalAttr; + } + + let value: string | boolean | number = attr.value; + + // Handle boolean attributes. + if (value === 'true') { + value = true; + } else if (value === 'false') { + value = false; + } + + props[name] = value; } } + element = React.createElement( - node.tagName.toLowerCase(), + tagName, props, - children, + tagInfo.children !== false ? children : undefined, ); } From cb5bbbfb051e175afb538bf9b83a7ca2d2b2b867 Mon Sep 17 00:00:00 2001 From: diondiondion Date: Fri, 26 Sep 2025 12:00:50 +0200 Subject: [PATCH 09/16] Update "Follow" button labels (#36264) --- .../mastodon/components/follow_button.tsx | 70 +++++++++- .../directory/components/account_card.tsx | 123 +----------------- .../components/inline_follow_suggestions.tsx | 2 - .../features/ui/hooks/useBreakpoint.tsx | 3 +- app/javascript/mastodon/locales/en.json | 7 +- 5 files changed, 78 insertions(+), 127 deletions(-) diff --git a/app/javascript/mastodon/components/follow_button.tsx b/app/javascript/mastodon/components/follow_button.tsx index 98ef3ba3f1..15a9046848 100644 --- a/app/javascript/mastodon/components/follow_button.tsx +++ b/app/javascript/mastodon/components/follow_button.tsx @@ -5,24 +5,61 @@ import { useIntl, defineMessages } from 'react-intl'; import classNames from 'classnames'; import { useIdentity } from '@/mastodon/identity_context'; -import { fetchRelationships, followAccount } from 'mastodon/actions/accounts'; +import { + fetchRelationships, + followAccount, + unblockAccount, + unmuteAccount, +} from 'mastodon/actions/accounts'; import { openModal } from 'mastodon/actions/modal'; import { Button } from 'mastodon/components/button'; import { LoadingIndicator } from 'mastodon/components/loading_indicator'; import { me } from 'mastodon/initial_state'; import { useAppDispatch, useAppSelector } from 'mastodon/store'; -const messages = defineMessages({ +import { useBreakpoint } from '../features/ui/hooks/useBreakpoint'; + +const longMessages = defineMessages({ unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, + unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' }, + unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' }, follow: { id: 'account.follow', defaultMessage: 'Follow' }, followBack: { id: 'account.follow_back', defaultMessage: 'Follow back' }, + followRequest: { + id: 'account.follow_request', + defaultMessage: 'Request to follow', + }, + followRequestCancel: { + id: 'account.follow_request_cancel', + defaultMessage: 'Cancel request', + }, editProfile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' }, }); +const shortMessages = { + ...longMessages, // Align type signature of shortMessages and longMessages + ...defineMessages({ + followBack: { + id: 'account.follow_back_short', + defaultMessage: 'Follow back', + }, + followRequest: { + id: 'account.follow_request_short', + defaultMessage: 'Request', + }, + followRequestCancel: { + id: 'account.follow_request_cancel_short', + defaultMessage: 'Cancel', + }, + editProfile: { id: 'account.edit_profile_short', defaultMessage: 'Edit' }, + }), +}; + export const FollowButton: React.FC<{ accountId?: string; compact?: boolean; -}> = ({ accountId, compact }) => { + labelLength?: 'auto' | 'short' | 'long'; +}> = ({ accountId, compact, labelLength = 'auto' }) => { const intl = useIntl(); const dispatch = useAppDispatch(); const { signedIn } = useIdentity(); @@ -57,29 +94,48 @@ export const FollowButton: React.FC<{ if (accountId === me) { return; + } else if (relationship.muting) { + dispatch(unmuteAccount(accountId)); } else if (account && (relationship.following || relationship.requested)) { dispatch( openModal({ modalType: 'CONFIRM_UNFOLLOW', modalProps: { account } }), ); + } else if (relationship.blocking) { + dispatch(unblockAccount(accountId)); } else { dispatch(followAccount(accountId)); } }, [dispatch, accountId, relationship, account, signedIn]); + const isNarrow = useBreakpoint('narrow'); + const useShortLabel = + labelLength === 'short' || (labelLength === 'auto' && isNarrow); + const messages = useShortLabel ? shortMessages : longMessages; + + const followMessage = account?.locked + ? messages.followRequest + : messages.follow; + let label; if (!signedIn) { - label = intl.formatMessage(messages.follow); + label = intl.formatMessage(followMessage); } else if (accountId === me) { label = intl.formatMessage(messages.editProfile); } else if (!relationship) { label = ; - } else if (relationship.following || relationship.requested) { + } else if (relationship.muting) { + label = intl.formatMessage(messages.unmute); + } else if (relationship.following) { label = intl.formatMessage(messages.unfollow); - } else if (relationship.followed_by) { + } else if (relationship.blocking) { + label = intl.formatMessage(messages.unblock); + } else if (relationship.requested) { + label = intl.formatMessage(messages.followRequestCancel); + } else if (relationship.followed_by && !account?.locked) { label = intl.formatMessage(messages.followBack); } else { - label = intl.formatMessage(messages.follow); + label = intl.formatMessage(followMessage); } if (accountId === me) { diff --git a/app/javascript/mastodon/features/directory/components/account_card.tsx b/app/javascript/mastodon/features/directory/components/account_card.tsx index 9d317efd43..6dc70532ab 100644 --- a/app/javascript/mastodon/features/directory/components/account_card.tsx +++ b/app/javascript/mastodon/features/directory/components/account_card.tsx @@ -1,134 +1,23 @@ -import { useCallback } from 'react'; +import { FormattedMessage } from 'react-intl'; -import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; - -import classNames from 'classnames'; import { Link } from 'react-router-dom'; -import { - followAccount, - unblockAccount, - unmuteAccount, -} from 'mastodon/actions/accounts'; -import { openModal } from 'mastodon/actions/modal'; import { Avatar } from 'mastodon/components/avatar'; -import { Button } from 'mastodon/components/button'; import { DisplayName } from 'mastodon/components/display_name'; +import { FollowButton } from 'mastodon/components/follow_button'; import { ShortNumber } from 'mastodon/components/short_number'; -import { autoPlayGif, me } from 'mastodon/initial_state'; +import { autoPlayGif } from 'mastodon/initial_state'; import type { Account } from 'mastodon/models/account'; import { makeGetAccount } from 'mastodon/selectors'; -import { useAppDispatch, useAppSelector } from 'mastodon/store'; - -const messages = defineMessages({ - unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, - follow: { id: 'account.follow', defaultMessage: 'Follow' }, - cancel_follow_request: { - id: 'account.cancel_follow_request', - defaultMessage: 'Withdraw follow request', - }, - requested: { - id: 'account.requested', - defaultMessage: 'Awaiting approval. Click to cancel follow request', - }, - unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' }, - unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' }, - edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' }, -}); +import { useAppSelector } from 'mastodon/store'; const getAccount = makeGetAccount(); export const AccountCard: React.FC<{ accountId: string }> = ({ accountId }) => { - const intl = useIntl(); const account = useAppSelector((s) => getAccount(s, accountId)); - const dispatch = useAppDispatch(); - - const handleFollow = useCallback(() => { - if (!account) return; - - if ( - account.getIn(['relationship', 'following']) || - account.getIn(['relationship', 'requested']) - ) { - dispatch( - openModal({ modalType: 'CONFIRM_UNFOLLOW', modalProps: { account } }), - ); - } else { - dispatch(followAccount(account.get('id'))); - } - }, [account, dispatch]); - - const handleBlock = useCallback(() => { - if (account?.relationship?.blocking) { - dispatch(unblockAccount(account.get('id'))); - } - }, [account, dispatch]); - - const handleMute = useCallback(() => { - if (account?.relationship?.muting) { - dispatch(unmuteAccount(account.get('id'))); - } - }, [account, dispatch]); - - const handleEditProfile = useCallback(() => { - window.open('/settings/profile', '_blank'); - }, []); if (!account) return null; - let actionBtn; - - if (me !== account.get('id')) { - if (!account.get('relationship')) { - // Wait until the relationship is loaded - actionBtn = ''; - } else if (account.getIn(['relationship', 'requested'])) { - actionBtn = ( -
-
{actionBtn}
+
+ +
); diff --git a/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.tsx b/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.tsx index 3df6d67ecf..05799ccb82 100644 --- a/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.tsx +++ b/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.tsx @@ -25,8 +25,6 @@ import { domain } from 'mastodon/initial_state'; import { useAppDispatch, useAppSelector } from 'mastodon/store'; const messages = defineMessages({ - follow: { id: 'account.follow', defaultMessage: 'Follow' }, - unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, previous: { id: 'lightbox.previous', defaultMessage: 'Previous' }, next: { id: 'lightbox.next', defaultMessage: 'Next' }, dismiss: { diff --git a/app/javascript/mastodon/features/ui/hooks/useBreakpoint.tsx b/app/javascript/mastodon/features/ui/hooks/useBreakpoint.tsx index af96ab3766..cb7b3551f2 100644 --- a/app/javascript/mastodon/features/ui/hooks/useBreakpoint.tsx +++ b/app/javascript/mastodon/features/ui/hooks/useBreakpoint.tsx @@ -1,11 +1,12 @@ import { useState, useEffect } from 'react'; const breakpoints = { + narrow: 479, // Device width under which horizontal space is constrained openable: 759, // Device width at which the sidebar becomes an openable hamburger menu full: 1174, // Device width at which all 3 columns can be displayed }; -type Breakpoint = 'openable' | 'full'; +type Breakpoint = keyof typeof breakpoints; export const useBreakpoint = (breakpoint: Breakpoint) => { const [isMatching, setIsMatching] = useState(false); diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index f949c30339..9dc405ab14 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -28,6 +28,7 @@ "account.disable_notifications": "Stop notifying me when @{name} posts", "account.domain_blocking": "Blocking domain", "account.edit_profile": "Edit profile", + "account.edit_profile_short": "Edit", "account.enable_notifications": "Notify me when @{name} posts", "account.endorse": "Feature on profile", "account.familiar_followers_many": "Followed by {name1}, {name2}, and {othersCount, plural, one {one other you know} other {# others you know}}", @@ -40,6 +41,11 @@ "account.featured_tags.last_status_never": "No posts", "account.follow": "Follow", "account.follow_back": "Follow back", + "account.follow_back_short": "Follow back", + "account.follow_request": "Request to follow", + "account.follow_request_cancel": "Cancel request", + "account.follow_request_cancel_short": "Cancel", + "account.follow_request_short": "Request", "account.followers": "Followers", "account.followers.empty": "No one follows this user yet.", "account.followers_counter": "{count, plural, one {{counter} follower} other {{counter} followers}}", @@ -70,7 +76,6 @@ "account.posts_with_replies": "Posts and replies", "account.remove_from_followers": "Remove {name} from followers", "account.report": "Report @{name}", - "account.requested": "Awaiting approval. Click to cancel follow request", "account.requested_follow": "{name} has requested to follow you", "account.requests_to_follow_you": "Requests to follow you", "account.share": "Share @{name}'s profile", From a44a3f6d4047568921469ff9fbd212f553b1e7f4 Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 26 Sep 2025 12:00:53 +0200 Subject: [PATCH 10/16] Expand test coverage of `ActivityPub::TagManager` class (#36260) --- spec/lib/activitypub/tag_manager_spec.rb | 255 ++++++++++++++++++++++- 1 file changed, 245 insertions(+), 10 deletions(-) diff --git a/spec/lib/activitypub/tag_manager_spec.rb b/spec/lib/activitypub/tag_manager_spec.rb index 7a4cf3c1b8..e536883a55 100644 --- a/spec/lib/activitypub/tag_manager_spec.rb +++ b/spec/lib/activitypub/tag_manager_spec.rb @@ -7,7 +7,7 @@ RSpec.describe ActivityPub::TagManager do subject { described_class.instance } - let(:domain) { "#{Rails.configuration.x.use_https ? 'https' : 'http'}://#{Rails.configuration.x.web_domain}" } + let(:host_prefix) { "#{Rails.configuration.x.use_https ? 'https' : 'http'}://#{Rails.configuration.x.web_domain}" } describe '#public_collection?' do it 'returns true for the special public collection and common shorthands' do @@ -22,18 +22,123 @@ RSpec.describe ActivityPub::TagManager do end describe '#url_for' do - it 'returns a string starting with web domain' do - account = Fabricate(:account) - expect(subject.url_for(account)).to be_a(String) - .and start_with(domain) + context 'with a local account' do + let(:account) { Fabricate(:account) } + + it 'returns a string starting with web domain and with the expected path' do + expect(subject.url_for(account)) + .to eq("#{host_prefix}/@#{account.username}") + end + end + + context 'with a remote account' do + let(:account) { Fabricate(:account, domain: 'example.com', url: 'https://example.com/profiles/dskjfsdf') } + + it 'returns the expected URL' do + expect(subject.url_for(account)).to eq account.url + end + end + + context 'with a local status' do + let(:status) { Fabricate(:status) } + + it 'returns a string starting with web domain and with the expected path' do + expect(subject.url_for(status)) + .to eq("#{host_prefix}/@#{status.account.username}/#{status.id}") + end + end + + context 'with a remote status' do + let(:account) { Fabricate(:account, domain: 'example.com', url: 'https://example.com/profiles/dskjfsdf') } + let(:status) { Fabricate(:status, account: account, url: 'https://example.com/posts/1234') } + + it 'returns the expected URL' do + expect(subject.url_for(status)).to eq status.url + end end end describe '#uri_for' do - it 'returns a string starting with web domain' do - account = Fabricate(:account) - expect(subject.uri_for(account)).to be_a(String) - .and start_with(domain) + context 'with the instance actor' do + it 'returns a string starting with web domain and with the expected path' do + expect(subject.uri_for(Account.representative)) + .to eq("#{host_prefix}/actor") + end + end + + context 'with a local account' do + let(:account) { Fabricate(:account) } + + it 'returns a string starting with web domain and with the expected path' do + expect(subject.uri_for(account)) + .to eq("#{host_prefix}/users/#{account.username}") + end + end + + context 'with a remote account' do + let(:account) { Fabricate(:account, domain: 'example.com', uri: 'https://example.com/profiles/dskjfsdf') } + + it 'returns the expected URL' do + expect(subject.uri_for(account)).to eq account.uri + end + end + + context 'with a local status' do + let(:status) { Fabricate(:status) } + + it 'returns a string starting with web domain and with the expected path' do + expect(subject.uri_for(status)) + .to eq("#{host_prefix}/users/#{status.account.username}/statuses/#{status.id}") + end + end + + context 'with a remote status' do + let(:account) { Fabricate(:account, domain: 'example.com', uri: 'https://example.com/profiles/dskjfsdf') } + let(:status) { Fabricate(:status, account: account, uri: 'https://example.com/posts/1234') } + + it 'returns the expected URL' do + expect(subject.uri_for(status)).to eq status.uri + end + end + + context 'with a local conversation' do + let(:status) { Fabricate(:status) } + + it 'returns a string starting with web domain and with the expected path' do + expect(subject.uri_for(status.conversation)) + .to eq("#{host_prefix}/contexts/#{status.account.id}-#{status.id}") + end + end + + context 'with a remote conversation' do + let(:account) { Fabricate(:account, domain: 'example.com', uri: 'https://example.com/profiles/dskjfsdf') } + let(:status) { Fabricate(:status, account: account, uri: 'https://example.com/posts/1234') } + + before do + status.conversation.update!(uri: 'https://example.com/conversations/1234') + end + + it 'returns the expected URL' do + expect(subject.uri_for(status.conversation)).to eq status.conversation.uri + end + end + end + + describe '#key_uri_for' do + context 'with the instance actor' do + it 'returns a string starting with web domain and with the expected path' do + expect(subject.key_uri_for(Account.representative)) + .to eq("#{host_prefix}/actor#main-key") + end + end + + context 'with a local account' do + let(:account) { Fabricate(:account) } + + it 'returns a string starting with web domain and with the expected path' do + expect(subject.key_uri_for(account)) + .to eq("#{host_prefix}/users/#{account.username}#main-key") + end end end @@ -49,7 +154,137 @@ RSpec.describe ActivityPub::TagManager do it 'returns a string starting with web domain' do status = Fabricate(:status) expect(subject.uri_for(status)).to be_a(String) - .and start_with(domain) + .and start_with(host_prefix) + end + end + end + + describe '#approval_uri_for' do + context 'with a valid local approval' do + let(:quote) { Fabricate(:quote, state: :accepted) } + + it 'returns a string with the web domain and expected path' do + expect(subject.approval_uri_for(quote)) + .to eq("#{host_prefix}/users/#{quote.quoted_account.username}/quote_authorizations/#{quote.id}") + end + end + + context 'with an unapproved local quote' do + let(:quote) { Fabricate(:quote, state: :rejected) } + + it 'returns nil' do + expect(subject.approval_uri_for(quote)) + .to be_nil + end + end + + context 'with a valid remote approval' do + let(:quoted_account) { Fabricate(:account, domain: 'example.com') } + let(:quoted_status) { Fabricate(:status, account: quoted_account) } + let(:quote) { Fabricate(:quote, state: :accepted, quoted_status: quoted_status, approval_uri: 'https://example.com/approvals/1') } + + it 'returns the expected URI' do + expect(subject.approval_uri_for(quote)).to eq quote.approval_uri + end + end + + context 'with an unapproved local quote but check_approval override' do + let(:quote) { Fabricate(:quote, state: :rejected) } + + it 'returns a string with the web domain and expected path' do + expect(subject.approval_uri_for(quote, check_approval: false)) + .to eq("#{host_prefix}/users/#{quote.quoted_account.username}/quote_authorizations/#{quote.id}") + end + end + end + + describe '#replies_uri_for' do + context 'with a local status' do + let(:status) { Fabricate(:status) } + + it 'returns a string starting with web domain and with the expected path' do + expect(subject.replies_uri_for(status)) + .to eq("#{host_prefix}/users/#{status.account.username}/statuses/#{status.id}/replies") + end + end + end + + describe '#likes_uri_for' do + context 'with a local status' do + let(:status) { Fabricate(:status) } + + it 'returns a string starting with web domain and with the expected path' do + expect(subject.likes_uri_for(status)) + .to eq("#{host_prefix}/users/#{status.account.username}/statuses/#{status.id}/likes") + end + end + end + + describe '#shares_uri_for' do + context 'with a local status' do + let(:status) { Fabricate(:status) } + + it 'returns a string starting with web domain and with the expected path' do + expect(subject.shares_uri_for(status)) + .to eq("#{host_prefix}/users/#{status.account.username}/statuses/#{status.id}/shares") + end + end + end + + describe '#following_uri_for' do + context 'with a local account' do + let(:account) { Fabricate(:account) } + + it 'returns a string starting with web domain and with the expected path' do + expect(subject.following_uri_for(account)) + .to eq("#{host_prefix}/users/#{account.username}/following") + end + end + end + + describe '#followers_uri_for' do + context 'with a local account' do + let(:account) { Fabricate(:account) } + + it 'returns a string starting with web domain and with the expected path' do + expect(subject.followers_uri_for(account)) + .to eq("#{host_prefix}/users/#{account.username}/followers") + end + end + end + + describe '#inbox_uri_for' do + context 'with the instance actor' do + it 'returns a string starting with web domain and with the expected path' do + expect(subject.inbox_uri_for(Account.representative)) + .to eq("#{host_prefix}/actor/inbox") + end + end + + context 'with a local account' do + let(:account) { Fabricate(:account) } + + it 'returns a string starting with web domain and with the expected path' do + expect(subject.inbox_uri_for(account)) + .to eq("#{host_prefix}/users/#{account.username}/inbox") + end + end + end + + describe '#outbox_uri_for' do + context 'with the instance actor' do + it 'returns a string starting with web domain and with the expected path' do + expect(subject.outbox_uri_for(Account.representative)) + .to eq("#{host_prefix}/actor/outbox") + end + end + + context 'with a local account' do + let(:account) { Fabricate(:account) } + + it 'returns a string starting with web domain and with the expected path' do + expect(subject.outbox_uri_for(account)) + .to eq("#{host_prefix}/users/#{account.username}/outbox") end end end From 3cd021d4d985d63594129169e80252b4d3c532cd Mon Sep 17 00:00:00 2001 From: diondiondion Date: Thu, 25 Sep 2025 14:26:50 +0200 Subject: [PATCH 11/16] [Glitch] Replace `react-router-scroll-4` with inlined implementation Port d801cf8e59edaea24cf70f1f62aa4e1ff8d1dcbd to glitch-soc Signed-off-by: Claire --- .../flavours/glitch/components/router.tsx | 5 +- .../glitch/components/scrollable_list.jsx | 2 +- .../flavours/glitch/containers/mastodon.jsx | 9 +- .../glitch/containers/scroll_container.js | 18 --- .../default_should_update_scroll.tsx | 25 ++++ .../containers/scroll_container/index.tsx | 62 ++++++++ .../scroll_container/scroll_context.tsx | 141 ++++++++++++++++++ .../scroll_container/state_storage.ts | 46 ++++++ .../glitch/features/directory/index.tsx | 3 +- .../flavours/glitch/features/status/index.jsx | 6 +- 10 files changed, 286 insertions(+), 31 deletions(-) delete mode 100644 app/javascript/flavours/glitch/containers/scroll_container.js create mode 100644 app/javascript/flavours/glitch/containers/scroll_container/default_should_update_scroll.tsx create mode 100644 app/javascript/flavours/glitch/containers/scroll_container/index.tsx create mode 100644 app/javascript/flavours/glitch/containers/scroll_container/scroll_context.tsx create mode 100644 app/javascript/flavours/glitch/containers/scroll_container/state_storage.ts diff --git a/app/javascript/flavours/glitch/components/router.tsx b/app/javascript/flavours/glitch/components/router.tsx index 0175e1b44f..f027d6cd35 100644 --- a/app/javascript/flavours/glitch/components/router.tsx +++ b/app/javascript/flavours/glitch/components/router.tsx @@ -1,6 +1,7 @@ import type { PropsWithChildren } from 'react'; import type React from 'react'; +import type { useLocation } from 'react-router'; import { Router as OriginalRouter, useHistory } from 'react-router'; import type { @@ -18,7 +19,9 @@ interface MastodonLocationState { mastodonModalKey?: string; } -type LocationState = MastodonLocationState | null | undefined; +export type LocationState = MastodonLocationState | null | undefined; + +export type MastodonLocation = ReturnType>; type HistoryPath = Path | LocationDescriptor; diff --git a/app/javascript/flavours/glitch/components/scrollable_list.jsx b/app/javascript/flavours/glitch/components/scrollable_list.jsx index 836cc5ee20..d1a0ab991c 100644 --- a/app/javascript/flavours/glitch/components/scrollable_list.jsx +++ b/app/javascript/flavours/glitch/components/scrollable_list.jsx @@ -10,7 +10,7 @@ import { connect } from 'react-redux'; import { supportsPassiveEvents } from 'detect-passive-events'; import { throttle } from 'lodash'; -import ScrollContainer from 'flavours/glitch/containers/scroll_container'; +import { ScrollContainer } from 'flavours/glitch/containers/scroll_container'; import IntersectionObserverArticleContainer from '../containers/intersection_observer_article_container'; import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../features/ui/util/fullscreen'; diff --git a/app/javascript/flavours/glitch/containers/mastodon.jsx b/app/javascript/flavours/glitch/containers/mastodon.jsx index d300be1f39..f715ebf9d1 100644 --- a/app/javascript/flavours/glitch/containers/mastodon.jsx +++ b/app/javascript/flavours/glitch/containers/mastodon.jsx @@ -5,7 +5,6 @@ import { Route } from 'react-router-dom'; import { Provider as ReduxProvider } from 'react-redux'; -import { ScrollContext } from 'react-router-scroll-4'; import { fetchCustomEmojis } from 'flavours/glitch/actions/custom_emojis'; import { checkDeprecatedLocalSettings } from 'flavours/glitch/actions/local_settings'; @@ -21,6 +20,8 @@ import { store } from 'flavours/glitch/store'; import { isProduction } from 'flavours/glitch/utils/environment'; import { BodyScrollLock } from 'flavours/glitch/features/ui/components/body_scroll_lock'; +import { ScrollContext } from './scroll_container/scroll_context'; + const title = isProduction() ? siteTitle : `${siteTitle} (Dev)`; const hydrateAction = hydrateStore(initialState); @@ -50,10 +51,6 @@ export default class Mastodon extends PureComponent { } } - shouldUpdateScroll (prevRouterProps, { location }) { - return !(location.state?.mastodonModalKey && location.state?.mastodonModalKey !== prevRouterProps?.location?.state?.mastodonModalKey); - } - render () { return ( @@ -61,7 +58,7 @@ export default class Mastodon extends PureComponent { - + diff --git a/app/javascript/flavours/glitch/containers/scroll_container.js b/app/javascript/flavours/glitch/containers/scroll_container.js deleted file mode 100644 index d21ff63687..0000000000 --- a/app/javascript/flavours/glitch/containers/scroll_container.js +++ /dev/null @@ -1,18 +0,0 @@ -import { ScrollContainer as OriginalScrollContainer } from 'react-router-scroll-4'; - -// ScrollContainer is used to automatically scroll to the top when pushing a -// new history state and remembering the scroll position when going back. -// There are a few things we need to do differently, though. -const defaultShouldUpdateScroll = (prevRouterProps, { location }) => { - // If the change is caused by opening a modal, do not scroll to top - return !(location.state?.mastodonModalKey && location.state?.mastodonModalKey !== prevRouterProps?.location?.state?.mastodonModalKey); -}; - -export default -class ScrollContainer extends OriginalScrollContainer { - - static defaultProps = { - shouldUpdateScroll: defaultShouldUpdateScroll, - }; - -} diff --git a/app/javascript/flavours/glitch/containers/scroll_container/default_should_update_scroll.tsx b/app/javascript/flavours/glitch/containers/scroll_container/default_should_update_scroll.tsx new file mode 100644 index 0000000000..5e5035d973 --- /dev/null +++ b/app/javascript/flavours/glitch/containers/scroll_container/default_should_update_scroll.tsx @@ -0,0 +1,25 @@ +import type { MastodonLocation } from 'flavours/glitch/components/router'; + +export type ShouldUpdateScrollFn = ( + prevLocationContext: MastodonLocation | null, + locationContext: MastodonLocation, +) => boolean; + +/** + * ScrollBehavior will automatically scroll to the top on navigations + * or restore saved scroll positions, but on some location changes we + * need to prevent this. + */ + +export const defaultShouldUpdateScroll: ShouldUpdateScrollFn = ( + prevLocation, + location, +) => { + // If the change is caused by opening a modal, do not scroll to top + const shouldUpdateScroll = !( + location.state?.mastodonModalKey && + location.state.mastodonModalKey !== prevLocation?.state?.mastodonModalKey + ); + + return shouldUpdateScroll; +}; diff --git a/app/javascript/flavours/glitch/containers/scroll_container/index.tsx b/app/javascript/flavours/glitch/containers/scroll_container/index.tsx new file mode 100644 index 0000000000..e7d2726715 --- /dev/null +++ b/app/javascript/flavours/glitch/containers/scroll_container/index.tsx @@ -0,0 +1,62 @@ +import React, { useContext, useEffect, useRef } from 'react'; + +import { defaultShouldUpdateScroll } from './default_should_update_scroll'; +import type { ShouldUpdateScrollFn } from './default_should_update_scroll'; +import { ScrollBehaviorContext } from './scroll_context'; + +interface ScrollContainerProps { + /** + * This key must be static for the element & not change + * while the component is mounted. + */ + scrollKey: string; + shouldUpdateScroll?: ShouldUpdateScrollFn; + children: React.ReactElement; +} + +/** + * `ScrollContainer` is used to manage the scroll position of elements on the page + * that can be scrolled independently of the page body. + * This component is a port of the unmaintained https://github.com/ytase/react-router-scroll/ + */ + +export const ScrollContainer: React.FC = ({ + children, + scrollKey, + shouldUpdateScroll = defaultShouldUpdateScroll, +}) => { + const scrollBehaviorContext = useContext(ScrollBehaviorContext); + + const containerRef = useRef(); + + /** + * Register/unregister scrollable element with ScrollBehavior + */ + useEffect(() => { + if (!scrollBehaviorContext || !containerRef.current) { + return; + } + + scrollBehaviorContext.registerElement( + scrollKey, + containerRef.current, + (prevLocation, location) => { + // Hack to allow accessing scrollBehavior._stateStorage + return shouldUpdateScroll.call( + scrollBehaviorContext.scrollBehavior, + prevLocation, + location, + ); + }, + ); + + return () => { + scrollBehaviorContext.unregisterElement(scrollKey); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return React.Children.only( + React.cloneElement(children, { ref: containerRef }), + ); +}; diff --git a/app/javascript/flavours/glitch/containers/scroll_container/scroll_context.tsx b/app/javascript/flavours/glitch/containers/scroll_container/scroll_context.tsx new file mode 100644 index 0000000000..1f73ffa6f5 --- /dev/null +++ b/app/javascript/flavours/glitch/containers/scroll_container/scroll_context.tsx @@ -0,0 +1,141 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; + +import { useLocation, useHistory } from 'react-router-dom'; + +import type { LocationBase } from 'scroll-behavior'; +import ScrollBehavior from 'scroll-behavior'; + +import type { + LocationState, + MastodonLocation, +} from 'flavours/glitch/components/router'; +import { usePrevious } from 'flavours/glitch/hooks/usePrevious'; + +import { defaultShouldUpdateScroll } from './default_should_update_scroll'; +import type { ShouldUpdateScrollFn } from './default_should_update_scroll'; +import { SessionStorage } from './state_storage'; + +type ScrollBehaviorInstance = InstanceType< + typeof ScrollBehavior +>; + +export interface ScrollBehaviorContextType { + registerElement: ( + key: string, + element: HTMLElement, + shouldUpdateScroll: ( + prevLocationContext: MastodonLocation | null, + locationContext: MastodonLocation, + ) => boolean, + ) => void; + unregisterElement: (key: string) => void; + scrollBehavior?: ScrollBehaviorInstance; +} + +export const ScrollBehaviorContext = + React.createContext(null); + +interface ScrollContextProps { + shouldUpdateScroll?: ShouldUpdateScrollFn; + children: React.ReactElement; +} + +/** + * A top-level wrapper that provides the app with an instance of the + * ScrollBehavior object. scroll-behavior is a library for managing the + * scroll position of a single-page app in the same way the browser would + * normally do for a multi-page app. This means it'll scroll back to top + * when navigating to a new page, and will restore the scroll position + * when navigating e.g. using `history.back`. + * The library keeps a record of scroll positions in session storage. + * + * This component is a port of the unmaintained https://github.com/ytase/react-router-scroll/ + */ + +export const ScrollContext: React.FC = ({ + children, + shouldUpdateScroll = defaultShouldUpdateScroll, +}) => { + const location = useLocation(); + const history = useHistory(); + + /** + * Keep the current location in a mutable ref so that ScrollBehavior's + * `getCurrentLocation` can access it without having to recreate the + * whole ScrollBehavior object + */ + const currentLocationRef = useRef(location); + useEffect(() => { + currentLocationRef.current = location; + }, [location]); + + /** + * Initialise ScrollBehavior object once – using state rather + * than a ref to simplify the types and ensure it's defined immediately. + */ + const [scrollBehavior] = useState( + (): ScrollBehaviorInstance => + new ScrollBehavior({ + addNavigationListener: history.listen.bind(history), + stateStorage: new SessionStorage(), + getCurrentLocation: () => + currentLocationRef.current as unknown as LocationBase, + shouldUpdateScroll: ( + prevLocationContext: MastodonLocation | null, + locationContext: MastodonLocation, + ) => + // Hack to allow accessing scrollBehavior._stateStorage + shouldUpdateScroll.call( + scrollBehavior, + prevLocationContext, + locationContext, + ), + }), + ); + + /** + * Handle scroll update when location changes + */ + const prevLocation = usePrevious(location) ?? null; + useEffect(() => { + scrollBehavior.updateScroll(prevLocation, location); + }, [location, prevLocation, scrollBehavior]); + + /** + * Stop Scrollbehavior on unmount + */ + useEffect(() => { + return () => { + scrollBehavior.stop(); + }; + }, [scrollBehavior]); + + /** + * Provide the app with a way to register separately scrollable + * elements to also be tracked by ScrollBehavior. (By default + * ScrollBehavior only handles scrolling on the main document body.) + */ + const contextValue = useMemo( + () => ({ + registerElement: (key, element, shouldUpdateScroll) => { + scrollBehavior.registerElement( + key, + element, + shouldUpdateScroll, + location, + ); + }, + unregisterElement: (key) => { + scrollBehavior.unregisterElement(key); + }, + scrollBehavior, + }), + [location, scrollBehavior], + ); + + return ( + + {React.Children.only(children)} + + ); +}; diff --git a/app/javascript/flavours/glitch/containers/scroll_container/state_storage.ts b/app/javascript/flavours/glitch/containers/scroll_container/state_storage.ts new file mode 100644 index 0000000000..fe8a208aae --- /dev/null +++ b/app/javascript/flavours/glitch/containers/scroll_container/state_storage.ts @@ -0,0 +1,46 @@ +import type { LocationBase, ScrollPosition } from 'scroll-behavior'; + +const STATE_KEY_PREFIX = '@@scroll|'; + +interface LocationBaseWithKey extends LocationBase { + key?: string; +} + +/** + * This module is part of our port of https://github.com/ytase/react-router-scroll/ + * and handles storing scroll positions in SessionStorage. + * Stored positions (`[x, y]`) are keyed by the location key and an optional + * `scrollKey` that's used for to track separately scrollable elements other + * than the document body. + */ + +export class SessionStorage { + read( + location: LocationBaseWithKey, + key: string | null, + ): ScrollPosition | null { + const stateKey = this.getStateKey(location, key); + + try { + const value = sessionStorage.getItem(stateKey); + return value ? (JSON.parse(value) as ScrollPosition) : null; + } catch { + return null; + } + } + + save(location: LocationBaseWithKey, key: string | null, value: unknown) { + const stateKey = this.getStateKey(location, key); + const storedValue = JSON.stringify(value); + + try { + sessionStorage.setItem(stateKey, storedValue); + } catch {} + } + + getStateKey(location: LocationBaseWithKey, key: string | null) { + const locationKey = location.key; + const stateKeyBase = `${STATE_KEY_PREFIX}${locationKey}`; + return key == null ? stateKeyBase : `${stateKeyBase}|${key}`; + } +} diff --git a/app/javascript/flavours/glitch/features/directory/index.tsx b/app/javascript/flavours/glitch/features/directory/index.tsx index 689f924b8f..3b402bd391 100644 --- a/app/javascript/flavours/glitch/features/directory/index.tsx +++ b/app/javascript/flavours/glitch/features/directory/index.tsx @@ -24,7 +24,7 @@ import { ColumnHeader } from 'flavours/glitch/components/column_header'; import { LoadMore } from 'flavours/glitch/components/load_more'; import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator'; import { RadioButton } from 'flavours/glitch/components/radio_button'; -import ScrollContainer from 'flavours/glitch/containers/scroll_container'; +import { ScrollContainer } from 'flavours/glitch/containers/scroll_container'; import { useSearchParam } from 'flavours/glitch/hooks/useSearchParam'; import { useAppDispatch, useAppSelector } from 'flavours/glitch/store'; @@ -209,7 +209,6 @@ export const Directory: React.FC<{ /> {multiColumn && !pinned ? ( - // @ts-expect-error ScrollContainer is not properly typed yet {scrollableArea} diff --git a/app/javascript/flavours/glitch/features/status/index.jsx b/app/javascript/flavours/glitch/features/status/index.jsx index d196dff11a..a04fce60e4 100644 --- a/app/javascript/flavours/glitch/features/status/index.jsx +++ b/app/javascript/flavours/glitch/features/status/index.jsx @@ -17,7 +17,7 @@ import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?reac import { Hotkeys } from 'flavours/glitch/components/hotkeys'; import { Icon } from 'flavours/glitch/components/icon'; import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator'; -import ScrollContainer from 'flavours/glitch/containers/scroll_container'; +import { ScrollContainer } from 'flavours/glitch/containers/scroll_container'; import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error'; import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context'; import { autoUnfoldCW } from 'flavours/glitch/utils/content_warning'; @@ -551,9 +551,9 @@ class Status extends ImmutablePureComponent { this.setState({ fullscreen: isFullscreen() }); }; - shouldUpdateScroll = (prevRouterProps, { location }) => { + shouldUpdateScroll = (prevLocation, location) => { // Do not change scroll when opening a modal - if (location.state?.mastodonModalKey !== prevRouterProps?.location?.state?.mastodonModalKey) { + if (location.state?.mastodonModalKey !== prevLocation?.state?.mastodonModalKey) { return false; } From 364aced99ec94538b5a00fd96b1b3408e54e70f9 Mon Sep 17 00:00:00 2001 From: diondiondion Date: Thu, 25 Sep 2025 18:14:49 +0200 Subject: [PATCH 12/16] [Glitch] Allow accessing ref of ScrollContainer's child Port 11bd51564898854c652451c9a28d8f73afc6a293 to glitch-soc Signed-off-by: Claire --- .../glitch/components/scrollable_list.jsx | 2 +- .../glitch/containers/scroll_container/index.tsx | 16 +++++++++++++++- .../flavours/glitch/features/status/index.jsx | 2 +- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/app/javascript/flavours/glitch/components/scrollable_list.jsx b/app/javascript/flavours/glitch/components/scrollable_list.jsx index d1a0ab991c..a7c1dccb0a 100644 --- a/app/javascript/flavours/glitch/components/scrollable_list.jsx +++ b/app/javascript/flavours/glitch/components/scrollable_list.jsx @@ -399,7 +399,7 @@ class ScrollableList extends PureComponent { if (trackScroll) { return ( - + {scrollableArea} ); diff --git a/app/javascript/flavours/glitch/containers/scroll_container/index.tsx b/app/javascript/flavours/glitch/containers/scroll_container/index.tsx index e7d2726715..0d0ab364dc 100644 --- a/app/javascript/flavours/glitch/containers/scroll_container/index.tsx +++ b/app/javascript/flavours/glitch/containers/scroll_container/index.tsx @@ -1,4 +1,9 @@ -import React, { useContext, useEffect, useRef } from 'react'; +import React, { + useContext, + useEffect, + useImperativeHandle, + useRef, +} from 'react'; import { defaultShouldUpdateScroll } from './default_should_update_scroll'; import type { ShouldUpdateScrollFn } from './default_should_update_scroll'; @@ -11,6 +16,7 @@ interface ScrollContainerProps { */ scrollKey: string; shouldUpdateScroll?: ShouldUpdateScrollFn; + childRef?: React.ForwardedRef; children: React.ReactElement; } @@ -23,12 +29,20 @@ interface ScrollContainerProps { export const ScrollContainer: React.FC = ({ children, scrollKey, + childRef, shouldUpdateScroll = defaultShouldUpdateScroll, }) => { const scrollBehaviorContext = useContext(ScrollBehaviorContext); const containerRef = useRef(); + /** + * If a childRef is passed, sync it with the containerRef. This + * is necessary because in this component's return statement, + * we're overwriting the immediate child component's ref prop. + */ + useImperativeHandle(childRef, () => containerRef.current, []); + /** * Register/unregister scrollable element with ScrollBehavior */ diff --git a/app/javascript/flavours/glitch/features/status/index.jsx b/app/javascript/flavours/glitch/features/status/index.jsx index a04fce60e4..d4dfe40747 100644 --- a/app/javascript/flavours/glitch/features/status/index.jsx +++ b/app/javascript/flavours/glitch/features/status/index.jsx @@ -634,7 +634,7 @@ class Status extends ImmutablePureComponent { )} /> - +
{ancestors} From 381fc173c7d7a37af9273e0bab28d7c123f1f887 Mon Sep 17 00:00:00 2001 From: Brad Dunbar Date: Fri, 26 Sep 2025 04:53:08 -0400 Subject: [PATCH 13/16] [Glitch] Refactor `getFocusedItemIndex` to avoid conditionals that `closest` already handles Port 238d74fe81b960de974c8a7459ec81f832842261 to glitch-soc Signed-off-by: Claire --- .../glitch/features/ui/util/focusUtils.ts | 20 +++++-------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/app/javascript/flavours/glitch/features/ui/util/focusUtils.ts b/app/javascript/flavours/glitch/features/ui/util/focusUtils.ts index 54601a0e24..9eb1740cab 100644 --- a/app/javascript/flavours/glitch/features/ui/util/focusUtils.ts +++ b/app/javascript/flavours/glitch/features/ui/util/focusUtils.ts @@ -60,23 +60,13 @@ export function focusColumn({ * Get the index of the currently focused item in one of our item lists */ export function getFocusedItemIndex() { - const focusedElement = document.activeElement; - const itemList = focusedElement?.closest('.item-list'); - - if (!focusedElement || !itemList) { - return -1; - } - - let focusedItem: HTMLElement | null = null; - if (focusedElement.parentElement === itemList) { - focusedItem = focusedElement as HTMLElement; - } else { - focusedItem = focusedElement.closest('.item-list > *'); - } - + const focusedItem = document.activeElement?.closest('.item-list > *'); if (!focusedItem) return -1; - const items = Array.from(itemList.children); + const { parentElement } = focusedItem; + if (!parentElement) return -1; + + const items = Array.from(parentElement.children); return items.indexOf(focusedItem); } From eea86088cf6112f1a5f064913b0c26c05c886196 Mon Sep 17 00:00:00 2001 From: diondiondion Date: Fri, 26 Sep 2025 11:23:30 +0200 Subject: [PATCH 14/16] [Glitch] Fix page being vertically scrollable in Advanced UI Port 1571514e49ec02a57c050612b3bca856f54933fb to glitch-soc Signed-off-by: Claire --- app/javascript/flavours/glitch/styles/components.scss | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/javascript/flavours/glitch/styles/components.scss b/app/javascript/flavours/glitch/styles/components.scss index 72c11300e6..04286c16a6 100644 --- a/app/javascript/flavours/glitch/styles/components.scss +++ b/app/javascript/flavours/glitch/styles/components.scss @@ -3037,8 +3037,12 @@ a.account__display-name { justify-content: flex-start; position: relative; - &.unscrollable { - overflow-x: hidden; + .layout-multiple-columns & { + overflow-x: auto; + + &.unscrollable { + overflow-x: hidden; + } } &__panels { From 8cccabb71437ca9e8486eab260bb018c565c1b96 Mon Sep 17 00:00:00 2001 From: Echo Date: Fri, 26 Sep 2025 11:50:59 +0200 Subject: [PATCH 15/16] [Glitch] Adds new HTMLBlock component Port e07b9dfdc12adb9c8b79d89f80049335053c3324 to glitch-soc Signed-off-by: Claire --- app/javascript/config/html-tags.json | 21 ++- .../html_block/html_block.stories.tsx | 40 ++++++ .../glitch/components/html_block/index.tsx | 50 +++++++ .../flavours/glitch/features/emoji/hooks.ts | 29 +---- .../glitch/features/emoji/normalize.ts | 23 +++- app/javascript/flavours/glitch/utils/html.ts | 122 +++++++++++------- 6 files changed, 208 insertions(+), 77 deletions(-) create mode 100644 app/javascript/flavours/glitch/components/html_block/html_block.stories.tsx create mode 100644 app/javascript/flavours/glitch/components/html_block/index.tsx diff --git a/app/javascript/config/html-tags.json b/app/javascript/config/html-tags.json index c788113487..cf5c96540a 100644 --- a/app/javascript/config/html-tags.json +++ b/app/javascript/config/html-tags.json @@ -21,17 +21,29 @@ "href": true, "rel": true, "translate": true, - "target": true + "target": true, + "title": true + } + }, + "abbr": { + "attributes": { + "title": true } }, "del": {}, "s": {}, "pre": {}, - "blockquote": {}, + "blockquote": { + "attributes": { + "cite": true + } + }, "code": {}, "b": {}, "strong": {}, "u": {}, + "sub": {}, + "sup": {}, "i": {}, "img": { "children": false, @@ -42,6 +54,11 @@ } }, "em": {}, + "h1": {}, + "h2": {}, + "h3": {}, + "h4": {}, + "h5": {}, "ul": {}, "ol": { "attributes": { diff --git a/app/javascript/flavours/glitch/components/html_block/html_block.stories.tsx b/app/javascript/flavours/glitch/components/html_block/html_block.stories.tsx new file mode 100644 index 0000000000..9c104ba45c --- /dev/null +++ b/app/javascript/flavours/glitch/components/html_block/html_block.stories.tsx @@ -0,0 +1,40 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect } from 'storybook/test'; + +import { HTMLBlock } from './index'; + +const meta = { + title: 'Components/HTMLBlock', + component: HTMLBlock, + args: { + contents: + '

Hello, world!

\n

A link

\n

This should be filtered out:

', + }, + render(args) { + return ( + // Just for visual clarity in Storybook. +
+ +
+ ); + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + async play({ canvas }) { + const link = canvas.queryByRole('link'); + await expect(link).toBeInTheDocument(); + const button = canvas.queryByRole('button'); + await expect(button).not.toBeInTheDocument(); + }, +}; diff --git a/app/javascript/flavours/glitch/components/html_block/index.tsx b/app/javascript/flavours/glitch/components/html_block/index.tsx new file mode 100644 index 0000000000..8072937331 --- /dev/null +++ b/app/javascript/flavours/glitch/components/html_block/index.tsx @@ -0,0 +1,50 @@ +import type { FC, ReactNode } from 'react'; +import { useMemo } from 'react'; + +import { cleanExtraEmojis } from '@/flavours/glitch/features/emoji/normalize'; +import type { CustomEmojiMapArg } from '@/flavours/glitch/features/emoji/types'; +import { createLimitedCache } from '@/flavours/glitch/utils/cache'; + +import { htmlStringToComponents } from '../../utils/html'; + +// Use a module-level cache to avoid re-rendering the same HTML multiple times. +const cache = createLimitedCache({ maxSize: 1000 }); + +interface HTMLBlockProps { + contents: string; + extraEmojis?: CustomEmojiMapArg; +} + +export const HTMLBlock: FC = ({ + contents: raw, + extraEmojis, +}) => { + const customEmojis = useMemo( + () => cleanExtraEmojis(extraEmojis), + [extraEmojis], + ); + const contents = useMemo(() => { + const key = JSON.stringify({ raw, customEmojis }); + if (cache.has(key)) { + return cache.get(key); + } + + const rendered = htmlStringToComponents(raw, { + onText, + extraArgs: { customEmojis }, + }); + + cache.set(key, rendered); + return rendered; + }, [raw, customEmojis]); + + return contents; +}; + +function onText( + text: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Doesn't do anything, just showing how typing would work. + { customEmojis }: { customEmojis: CustomEmojiMapArg | null }, +) { + return text; +} diff --git a/app/javascript/flavours/glitch/features/emoji/hooks.ts b/app/javascript/flavours/glitch/features/emoji/hooks.ts index 9c9eeb7d17..47baacef4d 100644 --- a/app/javascript/flavours/glitch/features/emoji/hooks.ts +++ b/app/javascript/flavours/glitch/features/emoji/hooks.ts @@ -1,19 +1,13 @@ import { useCallback, useLayoutEffect, useMemo, useState } from 'react'; -import { isList } from 'immutable'; - -import type { ApiCustomEmojiJSON } from '@/flavours/glitch/api_types/custom_emoji'; import { useAppSelector } from '@/flavours/glitch/store'; import { isModernEmojiEnabled } from '@/flavours/glitch/utils/environment'; import { toSupportedLocale } from './locale'; import { determineEmojiMode } from './mode'; +import { cleanExtraEmojis } from './normalize'; import { emojifyElement, emojifyText } from './render'; -import type { - CustomEmojiMapArg, - EmojiAppState, - ExtraCustomEmojiMap, -} from './types'; +import type { CustomEmojiMapArg, EmojiAppState } from './types'; import { stringHasAnyEmoji } from './utils'; interface UseEmojifyOptions { @@ -30,20 +24,7 @@ export function useEmojify({ const [emojifiedText, setEmojifiedText] = useState(null); const appState = useEmojiAppState(); - const extra: ExtraCustomEmojiMap = useMemo(() => { - if (!extraEmojis) { - return {}; - } - if (isList(extraEmojis)) { - return ( - extraEmojis.toJS() as ApiCustomEmojiJSON[] - ).reduce( - (acc, emoji) => ({ ...acc, [emoji.shortcode]: emoji }), - {}, - ); - } - return extraEmojis; - }, [extraEmojis]); + const extra = useMemo(() => cleanExtraEmojis(extraEmojis), [extraEmojis]); const emojify = useCallback( async (input: string) => { @@ -51,11 +32,11 @@ export function useEmojify({ if (deep) { const wrapper = document.createElement('div'); wrapper.innerHTML = input; - if (await emojifyElement(wrapper, appState, extra)) { + if (await emojifyElement(wrapper, appState, extra ?? {})) { result = wrapper.innerHTML; } } else { - result = await emojifyText(text, appState, extra); + result = await emojifyText(text, appState, extra ?? {}); } if (result) { setEmojifiedText(result); diff --git a/app/javascript/flavours/glitch/features/emoji/normalize.ts b/app/javascript/flavours/glitch/features/emoji/normalize.ts index 6a64c3b8bf..959732f985 100644 --- a/app/javascript/flavours/glitch/features/emoji/normalize.ts +++ b/app/javascript/flavours/glitch/features/emoji/normalize.ts @@ -1,3 +1,5 @@ +import { isList } from 'immutable'; + import { VARIATION_SELECTOR_CODE, KEYCAP_CODE, @@ -7,7 +9,11 @@ import { EMOJIS_WITH_DARK_BORDER, EMOJIS_WITH_LIGHT_BORDER, } from './constants'; -import type { TwemojiBorderInfo } from './types'; +import type { + CustomEmojiMapArg, + ExtraCustomEmojiMap, + TwemojiBorderInfo, +} from './types'; // Misc codes that have special handling const SKIER_CODE = 0x26f7; @@ -150,6 +156,21 @@ export function twemojiToUnicodeInfo( return hexNumbersToString(mappedCodes); } +export function cleanExtraEmojis(extraEmojis?: CustomEmojiMapArg) { + if (!extraEmojis) { + return null; + } + if (!isList(extraEmojis)) { + return extraEmojis; + } + return extraEmojis + .toJSON() + .reduce( + (acc, emoji) => ({ ...acc, [emoji.shortcode]: emoji }), + {}, + ); +} + function hexStringToNumbers(hexString: string): number[] { return hexString .split('-') diff --git a/app/javascript/flavours/glitch/utils/html.ts b/app/javascript/flavours/glitch/utils/html.ts index 1686322300..52b0fb7b7e 100644 --- a/app/javascript/flavours/glitch/utils/html.ts +++ b/app/javascript/flavours/glitch/utils/html.ts @@ -1,5 +1,7 @@ import React from 'react'; +import htmlConfig from '../../../config/html-tags.json'; + // NB: This function can still return unsafe HTML export const unescapeHTML = (html: string) => { const wrapper = document.createElement('div'); @@ -10,64 +12,49 @@ export const unescapeHTML = (html: string) => { return wrapper.textContent; }; +interface AllowedTag { + /* True means allow, false disallows global attributes, string renames the attribute name for React. */ + attributes?: Record; + /* If false, the tag cannot have children. Undefined or true means allowed. */ + children?: boolean; +} + +type AllowedTagsType = { + [Tag in keyof React.ReactHTML]?: AllowedTag; +}; + +const globalAttributes: Record = htmlConfig.global; +const defaultAllowedTags: AllowedTagsType = htmlConfig.tags; + interface QueueItem { node: Node; parent: React.ReactNode[]; depth: number; } -interface Options { +export interface HTMLToStringOptions> { maxDepth?: number; - onText?: (text: string) => React.ReactNode; + onText?: (text: string, extra: Arg) => React.ReactNode; onElement?: ( element: HTMLElement, children: React.ReactNode[], + extra: Arg, ) => React.ReactNode; onAttribute?: ( name: string, value: string, tagName: string, + extra: Arg, ) => [string, unknown] | null; - allowedTags?: Set; + allowedTags?: AllowedTagsType; + extraArgs?: Arg; } -const DEFAULT_ALLOWED_TAGS: ReadonlySet = new Set([ - 'a', - 'abbr', - 'b', - 'blockquote', - 'br', - 'cite', - 'code', - 'del', - 'dfn', - 'dl', - 'dt', - 'em', - 'h1', - 'h2', - 'h3', - 'h4', - 'h5', - 'h6', - 'hr', - 'i', - 'li', - 'ol', - 'p', - 'pre', - 'small', - 'span', - 'strong', - 'sub', - 'sup', - 'time', - 'u', - 'ul', -]); -export function htmlStringToComponents( +let uniqueIdCounter = 0; + +export function htmlStringToComponents>( htmlString: string, - options: Options = {}, + options: HTMLToStringOptions = {}, ) { const wrapper = document.createElement('template'); wrapper.innerHTML = htmlString; @@ -79,10 +66,11 @@ export function htmlStringToComponents( const { maxDepth = 10, - allowedTags = DEFAULT_ALLOWED_TAGS, + allowedTags = defaultAllowedTags, onAttribute, onElement, onText, + extraArgs = {} as Arg, } = options; while (queue.length > 0) { @@ -109,9 +97,9 @@ export function htmlStringToComponents( // Text can be added directly if it has any non-whitespace content. case Node.TEXT_NODE: { const text = node.textContent; - if (text && text.trim() !== '') { + if (text) { if (onText) { - parent.push(onText(text)); + parent.push(onText(text, extraArgs)); } else { parent.push(text); } @@ -127,7 +115,9 @@ export function htmlStringToComponents( } // If the tag is not allowed, skip it and its children. - if (!allowedTags.has(node.tagName.toLowerCase())) { + const tagName = node.tagName.toLowerCase(); + const tagInfo = allowedTags[tagName as keyof typeof allowedTags]; + if (!tagInfo) { continue; } @@ -137,7 +127,8 @@ export function htmlStringToComponents( // If onElement is provided, use it to create the element. if (onElement) { - const component = onElement(node, children); + const component = onElement(node, children, extraArgs); + // Check for undefined to allow returning null. if (component !== undefined) { element = component; @@ -147,25 +138,56 @@ export function htmlStringToComponents( // If the element wasn't created, use the default conversion. if (element === undefined) { const props: Record = {}; + props.key = `html-${uniqueIdCounter++}`; // Get the current key and then increment it. for (const attr of node.attributes) { + let name = attr.name.toLowerCase(); + + // Custom attribute handler. if (onAttribute) { const result = onAttribute( - attr.name, + name, attr.value, node.tagName.toLowerCase(), + extraArgs, ); if (result) { - const [name, value] = result; - props[name] = value; + const [cbName, value] = result; + props[cbName] = value; } } else { - props[attr.name] = attr.value; + // Check global attributes first, then tag-specific ones. + const globalAttr = globalAttributes[name]; + const tagAttr = tagInfo.attributes?.[name]; + + // Exit if neither global nor tag-specific attribute is allowed. + if (!globalAttr && !tagAttr) { + continue; + } + + // Rename if needed. + if (typeof tagAttr === 'string') { + name = tagAttr; + } else if (typeof globalAttr === 'string') { + name = globalAttr; + } + + let value: string | boolean | number = attr.value; + + // Handle boolean attributes. + if (value === 'true') { + value = true; + } else if (value === 'false') { + value = false; + } + + props[name] = value; } } + element = React.createElement( - node.tagName.toLowerCase(), + tagName, props, - children, + tagInfo.children !== false ? children : undefined, ); } From a4dc785de326ce60402952e05dc66fb76583d9c4 Mon Sep 17 00:00:00 2001 From: diondiondion Date: Fri, 26 Sep 2025 12:00:50 +0200 Subject: [PATCH 16/16] [Glitch] Update "Follow" button labels Port cb5bbbfb051e175afb538bf9b83a7ca2d2b2b867 to glitch-soc Signed-off-by: Claire --- .../glitch/components/follow_button.tsx | 65 ++++++++- .../directory/components/account_card.tsx | 124 +----------------- .../components/inline_follow_suggestions.tsx | 2 - .../features/ui/hooks/useBreakpoint.tsx | 3 +- 4 files changed, 68 insertions(+), 126 deletions(-) diff --git a/app/javascript/flavours/glitch/components/follow_button.tsx b/app/javascript/flavours/glitch/components/follow_button.tsx index e574b43b25..7e84a2580e 100644 --- a/app/javascript/flavours/glitch/components/follow_button.tsx +++ b/app/javascript/flavours/glitch/components/follow_button.tsx @@ -8,6 +8,8 @@ import { useIdentity } from '@/flavours/glitch/identity_context'; import { fetchRelationships, followAccount, + unblockAccount, + unmuteAccount, } from 'flavours/glitch/actions/accounts'; import { openModal } from 'flavours/glitch/actions/modal'; import { Button } from 'flavours/glitch/components/button'; @@ -15,17 +17,49 @@ import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator'; import { me } from 'flavours/glitch/initial_state'; import { useAppDispatch, useAppSelector } from 'flavours/glitch/store'; -const messages = defineMessages({ +import { useBreakpoint } from '../features/ui/hooks/useBreakpoint'; + +const longMessages = defineMessages({ unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, + unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' }, + unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' }, follow: { id: 'account.follow', defaultMessage: 'Follow' }, followBack: { id: 'account.follow_back', defaultMessage: 'Follow back' }, + followRequest: { + id: 'account.follow_request', + defaultMessage: 'Request to follow', + }, + followRequestCancel: { + id: 'account.follow_request_cancel', + defaultMessage: 'Cancel request', + }, edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' }, }); +const shortMessages = { + ...longMessages, // Align type signature of shortMessages and longMessages + ...defineMessages({ + followBack: { + id: 'account.follow_back_short', + defaultMessage: 'Follow back', + }, + followRequest: { + id: 'account.follow_request_short', + defaultMessage: 'Request', + }, + followRequestCancel: { + id: 'account.follow_request_cancel_short', + defaultMessage: 'Cancel', + }, + editProfile: { id: 'account.edit_profile_short', defaultMessage: 'Edit' }, + }), +}; + export const FollowButton: React.FC<{ accountId?: string; compact?: boolean; -}> = ({ accountId, compact }) => { + labelLength?: 'auto' | 'short' | 'long'; +}> = ({ accountId, compact, labelLength = 'auto' }) => { const intl = useIntl(); const dispatch = useAppDispatch(); const { signedIn } = useIdentity(); @@ -60,29 +94,48 @@ export const FollowButton: React.FC<{ if (accountId === me) { return; + } else if (relationship.muting) { + dispatch(unmuteAccount(accountId)); } else if (account && (relationship.following || relationship.requested)) { dispatch( openModal({ modalType: 'CONFIRM_UNFOLLOW', modalProps: { account } }), ); + } else if (relationship.blocking) { + dispatch(unblockAccount(accountId)); } else { dispatch(followAccount(accountId)); } }, [dispatch, accountId, relationship, account, signedIn]); + const isNarrow = useBreakpoint('narrow'); + const useShortLabel = + labelLength === 'short' || (labelLength === 'auto' && isNarrow); + const messages = useShortLabel ? shortMessages : longMessages; + + const followMessage = account?.locked + ? messages.followRequest + : messages.follow; + let label; if (!signedIn) { - label = intl.formatMessage(messages.follow); + label = intl.formatMessage(followMessage); } else if (accountId === me) { label = intl.formatMessage(messages.edit_profile); } else if (!relationship) { label = ; - } else if (relationship.following || relationship.requested) { + } else if (relationship.muting) { + label = intl.formatMessage(messages.unmute); + } else if (relationship.following) { label = intl.formatMessage(messages.unfollow); - } else if (relationship.followed_by) { + } else if (relationship.blocking) { + label = intl.formatMessage(messages.unblock); + } else if (relationship.requested) { + label = intl.formatMessage(messages.followRequestCancel); + } else if (relationship.followed_by && !account?.locked) { label = intl.formatMessage(messages.followBack); } else { - label = intl.formatMessage(messages.follow); + label = intl.formatMessage(followMessage); } if (accountId === me) { diff --git a/app/javascript/flavours/glitch/features/directory/components/account_card.tsx b/app/javascript/flavours/glitch/features/directory/components/account_card.tsx index deb4f4832d..b07928573e 100644 --- a/app/javascript/flavours/glitch/features/directory/components/account_card.tsx +++ b/app/javascript/flavours/glitch/features/directory/components/account_card.tsx @@ -1,134 +1,22 @@ -import { useCallback } from 'react'; +import { FormattedMessage } from 'react-intl'; -import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; - -import classNames from 'classnames'; - -import { - followAccount, - unblockAccount, - unmuteAccount, -} from 'flavours/glitch/actions/accounts'; -import { openModal } from 'flavours/glitch/actions/modal'; import { Avatar } from 'flavours/glitch/components/avatar'; -import { Button } from 'flavours/glitch/components/button'; import { DisplayName } from 'flavours/glitch/components/display_name'; +import { FollowButton } from 'flavours/glitch/components/follow_button'; import { Permalink } from 'flavours/glitch/components/permalink'; import { ShortNumber } from 'flavours/glitch/components/short_number'; -import { autoPlayGif, me } from 'flavours/glitch/initial_state'; +import { autoPlayGif } from 'flavours/glitch/initial_state'; import type { Account } from 'flavours/glitch/models/account'; import { makeGetAccount } from 'flavours/glitch/selectors'; -import { useAppDispatch, useAppSelector } from 'flavours/glitch/store'; - -const messages = defineMessages({ - unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, - follow: { id: 'account.follow', defaultMessage: 'Follow' }, - cancel_follow_request: { - id: 'account.cancel_follow_request', - defaultMessage: 'Withdraw follow request', - }, - requested: { - id: 'account.requested', - defaultMessage: 'Awaiting approval. Click to cancel follow request', - }, - unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' }, - unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' }, - edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' }, -}); +import { useAppSelector } from 'flavours/glitch/store'; const getAccount = makeGetAccount(); export const AccountCard: React.FC<{ accountId: string }> = ({ accountId }) => { - const intl = useIntl(); const account = useAppSelector((s) => getAccount(s, accountId)); - const dispatch = useAppDispatch(); - - const handleFollow = useCallback(() => { - if (!account) return; - - if ( - account.getIn(['relationship', 'following']) || - account.getIn(['relationship', 'requested']) - ) { - dispatch( - openModal({ modalType: 'CONFIRM_UNFOLLOW', modalProps: { account } }), - ); - } else { - dispatch(followAccount(account.get('id'))); - } - }, [account, dispatch]); - - const handleBlock = useCallback(() => { - if (account?.relationship?.blocking) { - dispatch(unblockAccount(account.get('id'))); - } - }, [account, dispatch]); - - const handleMute = useCallback(() => { - if (account?.relationship?.muting) { - dispatch(unmuteAccount(account.get('id'))); - } - }, [account, dispatch]); - - const handleEditProfile = useCallback(() => { - window.open('/settings/profile', '_blank'); - }, []); if (!account) return null; - let actionBtn; - - if (me !== account.get('id')) { - if (!account.get('relationship')) { - // Wait until the relationship is loaded - actionBtn = ''; - } else if (account.getIn(['relationship', 'requested'])) { - actionBtn = ( -
-
{actionBtn}
+
+ +
); diff --git a/app/javascript/flavours/glitch/features/home_timeline/components/inline_follow_suggestions.tsx b/app/javascript/flavours/glitch/features/home_timeline/components/inline_follow_suggestions.tsx index e5aa8d0297..bcbb0962fb 100644 --- a/app/javascript/flavours/glitch/features/home_timeline/components/inline_follow_suggestions.tsx +++ b/app/javascript/flavours/glitch/features/home_timeline/components/inline_follow_suggestions.tsx @@ -25,8 +25,6 @@ import { domain } from 'flavours/glitch/initial_state'; import { useAppDispatch, useAppSelector } from 'flavours/glitch/store'; const messages = defineMessages({ - follow: { id: 'account.follow', defaultMessage: 'Follow' }, - unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, previous: { id: 'lightbox.previous', defaultMessage: 'Previous' }, next: { id: 'lightbox.next', defaultMessage: 'Next' }, dismiss: { diff --git a/app/javascript/flavours/glitch/features/ui/hooks/useBreakpoint.tsx b/app/javascript/flavours/glitch/features/ui/hooks/useBreakpoint.tsx index af96ab3766..cb7b3551f2 100644 --- a/app/javascript/flavours/glitch/features/ui/hooks/useBreakpoint.tsx +++ b/app/javascript/flavours/glitch/features/ui/hooks/useBreakpoint.tsx @@ -1,11 +1,12 @@ import { useState, useEffect } from 'react'; const breakpoints = { + narrow: 479, // Device width under which horizontal space is constrained openable: 759, // Device width at which the sidebar becomes an openable hamburger menu full: 1174, // Device width at which all 3 columns can be displayed }; -type Breakpoint = 'openable' | 'full'; +type Breakpoint = keyof typeof breakpoints; export const useBreakpoint = (breakpoint: Breakpoint) => { const [isMatching, setIsMatching] = useState(false);