From 07d099cbf7b646b7dc715c3bfe3a8ea453e9eafb Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Tue, 26 May 2026 14:36:54 +0200 Subject: [PATCH] Add new overview landing page setting (#39074) --- app/javascript/mastodon/actions/server.ts | 14 ++ .../features/custom_homepage/about.tsx | 75 +++++++++ .../custom_homepage/components/footer.tsx | 39 +++++ .../custom_homepage/components/header.tsx | 25 +++ .../features/custom_homepage/index.tsx | 71 +++++++++ .../custom_homepage/latest_activity.tsx | 35 +++++ .../custom_homepage/styles.module.scss | 145 ++++++++++++++++++ .../features/ui/components/columns_area.tsx | 24 ++- .../ui/containers/status_list_container.js | 21 ++- app/javascript/mastodon/features/ui/index.jsx | 24 ++- app/javascript/mastodon/locales/en.json | 6 + app/models/form/admin_settings.rb | 2 +- .../admin/settings/branding/show.html.haml | 5 +- config/locales/en.yml | 12 +- config/routes/web_app.rb | 2 + 15 files changed, 483 insertions(+), 17 deletions(-) create mode 100644 app/javascript/mastodon/features/custom_homepage/about.tsx create mode 100644 app/javascript/mastodon/features/custom_homepage/components/footer.tsx create mode 100644 app/javascript/mastodon/features/custom_homepage/components/header.tsx create mode 100644 app/javascript/mastodon/features/custom_homepage/index.tsx create mode 100644 app/javascript/mastodon/features/custom_homepage/latest_activity.tsx create mode 100644 app/javascript/mastodon/features/custom_homepage/styles.module.scss diff --git a/app/javascript/mastodon/actions/server.ts b/app/javascript/mastodon/actions/server.ts index 4e4795c123..b301919b23 100644 --- a/app/javascript/mastodon/actions/server.ts +++ b/app/javascript/mastodon/actions/server.ts @@ -16,19 +16,33 @@ export const fetchServer = createDataLoadingThunk( dispatch(importFetchedAccount(instance.contact.account)); } }, + { + condition: (_, { getState }) => !getState().server.server.isLoading, + }, ); export const fetchExtendedDescription = createDataLoadingThunk( 'server/extended_description', () => apiGetExtendedDescription(), + { + condition: (_, { getState }) => + !getState().server.extendedDescription.isLoading, + }, ); export const fetchServerTranslationLanguages = createDataLoadingThunk( 'server/translation_languages', () => apiGetTranslationLanguages(), + { + condition: (_, { getState }) => + !getState().server.translationLanguages.isLoading, + }, ); export const fetchDomainBlocks = createDataLoadingThunk( 'server/domain_blocks', () => apiGetDomainBlocks(), + { + condition: (_, { getState }) => !getState().server.domainBlocks.isLoading, + }, ); diff --git a/app/javascript/mastodon/features/custom_homepage/about.tsx b/app/javascript/mastodon/features/custom_homepage/about.tsx new file mode 100644 index 0000000000..7ec3a8e442 --- /dev/null +++ b/app/javascript/mastodon/features/custom_homepage/about.tsx @@ -0,0 +1,75 @@ +import { useEffect } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { fetchExtendedDescription } from 'mastodon/actions/server'; +import { Account } from 'mastodon/components/account'; +import { Skeleton } from 'mastodon/components/skeleton'; +import { useAppSelector, useAppDispatch } from 'mastodon/store'; + +import classes from './styles.module.scss'; + +const Placeholder = () => ( +
+ + + +
+); + +export const About = () => { + const dispatch = useAppDispatch(); + const server = useAppSelector((state) => state.server.server); + const extendedDescription = useAppSelector( + (state) => state.server.extendedDescription, + ); + + const accountId = server.item?.contact.account?.id ?? ''; + const isLoading = extendedDescription.isLoading; + const hasContent = (extendedDescription.item?.content.length ?? 0) > 0; + const content = extendedDescription.item?.content ?? ''; + + useEffect(() => { + void dispatch(fetchExtendedDescription()); + }, [dispatch]); + + return ( + <> +
+

+ +

+ +
+ +
+

+ +

+ {isLoading ? ( + + ) : hasContent ? ( +
+ ) : ( +
+

+ +

+
+ )} +
+ + ); +}; diff --git a/app/javascript/mastodon/features/custom_homepage/components/footer.tsx b/app/javascript/mastodon/features/custom_homepage/components/footer.tsx new file mode 100644 index 0000000000..b1b69cfc76 --- /dev/null +++ b/app/javascript/mastodon/features/custom_homepage/components/footer.tsx @@ -0,0 +1,39 @@ +import { useEffect } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { Link } from 'react-router-dom'; + +import { fetchServer } from 'mastodon/actions/server'; +import { useAppDispatch, useAppSelector } from 'mastodon/store'; + +import classes from '../styles.module.scss'; + +export const Footer = () => { + const dispatch = useAppDispatch(); + const server = useAppSelector((state) => state.server.server); + const email = server.item?.contact.email ?? ''; + + useEffect(() => { + void dispatch(fetchServer()); + }, [dispatch]); + + return ( + + ); +}; diff --git a/app/javascript/mastodon/features/custom_homepage/components/header.tsx b/app/javascript/mastodon/features/custom_homepage/components/header.tsx new file mode 100644 index 0000000000..dd4b7969c3 --- /dev/null +++ b/app/javascript/mastodon/features/custom_homepage/components/header.tsx @@ -0,0 +1,25 @@ +import { FormattedMessage } from 'react-intl'; + +import { Link } from 'react-router-dom'; + +import { domain, sso_redirect } from 'mastodon/initial_state'; + +import classes from '../styles.module.scss'; + +export const Header = () => ( +
+
+ {domain} +
+ +
+ + + +
+
+); diff --git a/app/javascript/mastodon/features/custom_homepage/index.tsx b/app/javascript/mastodon/features/custom_homepage/index.tsx new file mode 100644 index 0000000000..5fc75108f1 --- /dev/null +++ b/app/javascript/mastodon/features/custom_homepage/index.tsx @@ -0,0 +1,71 @@ +import { useEffect } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { Route, Switch, useRouteMatch } from 'react-router-dom'; + +import { Helmet } from '@unhead/react/helmet'; + +import { fetchServer } from 'mastodon/actions/server'; +import { ServerHeroImage } from 'mastodon/components/server_hero_image'; +import { TabLink, TabList } from 'mastodon/components/tab_list'; +import { useAppSelector, useAppDispatch } from 'mastodon/store'; + +import { About } from './about'; +import { LatestActivity } from './latest_activity'; +import classes from './styles.module.scss'; + +export const CustomHomepage: React.FC = () => { + const dispatch = useAppDispatch(); + const server = useAppSelector((state) => state.server.server); + const { path } = useRouteMatch(); + + useEffect(() => { + void dispatch(fetchServer()); + }, [dispatch]); + + return ( +
+ + `${server.item?.thumbnail.versions?.[key]} ${key.replace('@', '')}`, + ) + .join(', ')} + className={classes.header} + /> + +
+

{server.item?.domain}

+

{server.item?.description}

+
+ + + + + + + + + + + + + + + + + + {server.item?.domain} + + +
+ ); +}; diff --git a/app/javascript/mastodon/features/custom_homepage/latest_activity.tsx b/app/javascript/mastodon/features/custom_homepage/latest_activity.tsx new file mode 100644 index 0000000000..9deac2db4c --- /dev/null +++ b/app/javascript/mastodon/features/custom_homepage/latest_activity.tsx @@ -0,0 +1,35 @@ +import { useEffect } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { expandCommunityTimeline } from 'mastodon/actions/timelines'; +import { Callout } from 'mastodon/components/callout'; +import StatusListContainer from 'mastodon/features/ui/containers/status_list_container'; +import { useAppDispatch } from 'mastodon/store'; + +import classes from './styles.module.scss'; + +export const LatestActivity = () => { + const dispatch = useAppDispatch(); + + useEffect(() => { + void dispatch(expandCommunityTimeline()); + }, [dispatch]); + + return ( + + + + } + scrollKey='custom_homepage' + timelineId='community' + maxItems={40} + bindToDocument + /> + ); +}; diff --git a/app/javascript/mastodon/features/custom_homepage/styles.module.scss b/app/javascript/mastodon/features/custom_homepage/styles.module.scss new file mode 100644 index 0000000000..bbf834ffc9 --- /dev/null +++ b/app/javascript/mastodon/features/custom_homepage/styles.module.scss @@ -0,0 +1,145 @@ +.page { + border-radius: 16px; + border: 1px solid var(--color-border-primary); + background: var(--color-bg-primary); + min-height: 100%; + + :global(.item-list) article:last-child :global(.status) { + border-bottom: 0; + } + + @media screen and (width <= 1175px) { + border-radius: 0; + } +} + +.header { + aspect-ratio: 40/21; + border-radius: 16px 16px 0 0; + + @media screen and (width <= 1175px) { + border-radius: 0; + } +} + +.banner { + margin: 16px; + margin-bottom: 0; +} + +.topSection { + display: flex; + padding: 16px; + flex-direction: column; + gap: 8px; + color: var(--color-text-primary); + + h1 { + font-size: 24px; + font-weight: 500; + line-height: 30px; + letter-spacing: -0.12px; + } + + p { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + font-size: 16px; + line-height: 24px; + text-overflow: ellipsis; + } +} + +.block { + padding: 16px; + + h2 { + font-size: 16px; + font-weight: 500; + line-height: 22.4px; + margin-bottom: 8px; + } + + :global(.account) { + border: 1px solid var(--color-border-primary); + padding: 18px 16px; + border-radius: 12px; + + --avatar-border-radius: 50%; + } +} + +.placeholder { + padding: 4px 0; + display: flex; + flex-direction: column; + gap: 12px; + + :global(.skeleton) { + height: 40px; + border-radius: 12px; + background: var(--color-bg-overlay-highlight); + } +} + +.minimalHeader { + padding-top: 24px; + padding-bottom: 12px; + display: flex; + align-items: center; + + @media screen and (width <= 1175px) { + padding-inline-start: 12px; + padding-inline-end: 12px; + } +} + +.leftSide { + font-size: 15px; + font-weight: 600; + max-width: 300px; + + a { + text-decoration: none; + color: inherit; + } +} + +.rightSide { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 4px; + flex: 1 0 0; +} + +.minimalFooter { + display: flex; + justify-content: space-between; + align-items: center; + color: var(--color-text-secondary); + font-size: 16px; + font-weight: 500; + line-height: 22.4px; + padding-top: 28px; + padding-bottom: 54px; + gap: 8px; + flex-wrap: wrap; + + a { + color: inherit; + text-decoration: underline; + } + + @media screen and (width <= 1175px) { + justify-content: center; + padding-inline-start: 12px; + padding-inline-end: 12px; + } +} + +.contact { + display: flex; + gap: 8px; +} diff --git a/app/javascript/mastodon/features/ui/components/columns_area.tsx b/app/javascript/mastodon/features/ui/components/columns_area.tsx index ae735e196f..f5ab3de849 100644 --- a/app/javascript/mastodon/features/ui/components/columns_area.tsx +++ b/app/javascript/mastodon/features/ui/components/columns_area.tsx @@ -12,6 +12,8 @@ import classNames from 'classnames'; import type { List, Record } from 'immutable'; import { useAppSelector } from '@/mastodon/store'; +import { Footer } from 'mastodon/features/custom_homepage/components/footer'; +import { Header } from 'mastodon/features/custom_homepage/components/header'; import { CollapsibleNavigationPanel } from 'mastodon/features/navigation_panel'; import { useBreakpoint } from '../hooks/useBreakpoint'; @@ -85,9 +87,10 @@ export const ColumnsArea = forwardRef< HTMLDivElement, { singleColumn?: boolean; + minimalShell?: boolean; children: React.ReactElement | React.ReactElement[]; } ->(({ children, singleColumn }, ref) => { +>(({ children, minimalShell, singleColumn }, ref) => { const renderComposePanel = !useBreakpoint('full'); const columns = useAppSelector((state) => (state.settings as Record<{ columns: List> }>).get( @@ -98,6 +101,24 @@ export const ColumnsArea = forwardRef< (state) => !state.modal.get('stack').isEmpty(), ); + if (minimalShell) { + return ( +
+
+
+ +
+ +
+ +
{children}
+ +
+
+
+ ); + } + if (singleColumn) { return (
@@ -112,6 +133,7 @@ export const ColumnsArea = forwardRef<
+
{children}
diff --git a/app/javascript/mastodon/features/ui/containers/status_list_container.js b/app/javascript/mastodon/features/ui/containers/status_list_container.js index 1e21730a00..8abbbefe14 100644 --- a/app/javascript/mastodon/features/ui/containers/status_list_container.js +++ b/app/javascript/mastodon/features/ui/containers/status_list_container.js @@ -11,7 +11,15 @@ import { me } from '@/mastodon/initial_state'; const makeGetStatusIds = (pending = false) => createSelector([ (state, { type }) => state.getIn(['settings', type], ImmutableMap()), - (state, { type }) => state.getIn(['timelines', type, pending ? 'pendingItems' : 'items'], ImmutableList()), + (state, { type, maxItems }) => { + const items = state.getIn(['timelines', type, pending ? 'pendingItems' : 'items'], ImmutableList()); + + if (maxItems) { + return items.take(maxItems); + } + + return items; + }, (state) => state.get('statuses'), ], (columnSettings, statusIds, statuses) => { return statusIds.filter(id => { @@ -41,8 +49,15 @@ const makeMapStateToProps = () => { const getStatusIds = makeGetStatusIds(); const getPendingStatusIds = makeGetStatusIds(true); - const mapStateToProps = (state, { timelineId, initialLoadingState = true }) => ({ - statusIds: getStatusIds(state, { type: timelineId }), + /** + * @param {import('mastodon/store').RootState} state + * @param {Object} props + * @param {string} props.timelineId + * @param {boolean} [props.initialLoadingState] + * @param {number} [props.maxItems] + */ + const mapStateToProps = (state, { timelineId, initialLoadingState = true, maxItems }) => ({ + statusIds: getStatusIds(state, { type: timelineId, maxItems }), lastId: state.getIn(['timelines', timelineId, 'items'])?.last(), isLoading: state.getIn(['timelines', timelineId, 'isLoading'], initialLoadingState), isPartial: state.getIn(['timelines', timelineId, 'isPartial'], false), diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx index 733a91d041..1bf0842cb8 100644 --- a/app/javascript/mastodon/features/ui/index.jsx +++ b/app/javascript/mastodon/features/ui/index.jsx @@ -29,7 +29,7 @@ import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../act import { clearHeight } from '../../actions/height_cache'; import { fetchServer, fetchServerTranslationLanguages } from '../../actions/server'; import { expandHomeTimeline } from '../../actions/timelines'; -import { initialState, me, owner, singleUserMode, trendsEnabled, landingPage, localLiveFeedAccess, disableHoverCards } from '../../initial_state'; +import { initialState, me, owner, singleUserMode, trendsEnabled, landingPage, localLiveFeedAccess, disableHoverCards, domain } from '../../initial_state'; import BundleColumnError from './components/bundle_column_error'; import { NavigationBar } from './components/navigation_bar'; @@ -88,6 +88,7 @@ import { import { ColumnsContextProvider } from './util/columns_context'; import { focusColumn, getFocusedItemIndex, focusItemSibling, focusFirstItem } from './util/focusUtils'; import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers'; +import { CustomHomepage } from 'mastodon/features/custom_homepage'; // Dummy import, to make sure that ends up in the application bundle. // Without this it ends up in ~8 very commonly used bundles. @@ -177,13 +178,15 @@ class SwitchingColumnsArea extends PureComponent { rootRedirect = '/explore'; } else if (localLiveFeedAccess === 'public' && landingPage === 'local_feed') { rootRedirect = '/public/local'; + } else if (landingPage === 'overview') { + rootRedirect = '/overview'; } else { rootRedirect = '/about'; } return ( - + @@ -262,6 +265,8 @@ class SwitchingColumnsArea extends PureComponent { + + @@ -633,13 +638,18 @@ class UI extends PureComponent { cheat: this.handleDonate, }; + const minimalShell = !this.props.identity.signedIn && landingPage === 'overview'; + return (
- + {!minimalShell && ( + + )} + - + {!minimalShell && } {layout !== 'mobile' && } {!disableHoverCards && } diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index df12731eda..b97260173b 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -584,6 +584,12 @@ "copy_icon_button.copy_this_text": "Copy link to clipboard", "copypaste.copied": "Copied", "copypaste.copy_to_clipboard": "Copy to clipboard", + "custom_homepage.about": "About", + "custom_homepage.about_this_server": "About this server", + "custom_homepage.administered_by": "Administered by", + "custom_homepage.contact": "Contact:", + "custom_homepage.latest_activity": "Latest activity", + "custom_homepage.these_are_the_latest_posts": "These are the latest 40 posts from accounts on this server.", "directory.federated": "From known fediverse", "directory.local": "From {domain} only", "directory.new_arrivals": "New arrivals", diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb index 0ad5335419..5e97151438 100644 --- a/app/models/form/admin_settings.rb +++ b/app/models/form/admin_settings.rb @@ -94,7 +94,7 @@ class Form::AdminSettings REGISTRATION_MODES = %w(open approved none).freeze FEED_ACCESS_MODES = %w(public authenticated disabled).freeze ALTERNATE_FEED_ACCESS_MODES = %w(public authenticated).freeze - LANDING_PAGE = %w(trends about local_feed).freeze + LANDING_PAGE = %w(trends overview local_feed about).freeze attr_accessor(*KEYS) diff --git a/app/views/admin/settings/branding/show.html.haml b/app/views/admin/settings/branding/show.html.haml index 65aae77d5f..9e9653abf1 100644 --- a/app/views/admin/settings/branding/show.html.haml +++ b/app/views/admin/settings/branding/show.html.haml @@ -75,10 +75,11 @@ .fields-row = f.input :landing_page, + as: :radio_buttons, collection: f.object.class::LANDING_PAGE, include_blank: false, - label_method: ->(page) { I18n.t("admin.settings.landing_page.values.#{page}") }, - wrapper: :with_label + label_method: ->(page) { safe_join([I18n.t("admin.settings.landing_page.values.#{page}"), content_tag(:span, I18n.t("admin.settings.landing_page.hints.#{page}_html"), class: 'hint')]) }, + wrapper: :with_block_label .actions = f.button :button, t('generic.save_changes'), type: :submit diff --git a/config/locales/en.yml b/config/locales/en.yml index 2ed8dd1c1a..f47eac1fe1 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -956,10 +956,16 @@ en: disabled: Require specific user role public: Everyone landing_page: + hints: + about_html: A page with the description, contact information, rules and other information regarding this server. + local_feed_html: A live feed featuring most recent posts by users on this server. + overview_html: A page showcasing the description of your server alongside the most recent local posts by users on this server. + trends_html: A page featuring what's popular on this server right now. values: - about: About - local_feed: Local feed - trends: Trends + about: About page + local_feed: Local live feed + overview: Overview + trends: Trending registrations: moderation_recommandation: Please make sure you have an adequate and reactive moderation team before you open registrations to everyone! preamble: Control who can create an account on your server. diff --git a/config/routes/web_app.rb b/config/routes/web_app.rb index cb85dc8753..22814f294c 100644 --- a/config/routes/web_app.rb +++ b/config/routes/web_app.rb @@ -33,4 +33,6 @@ /search /start/(*any) /statuses/(*any) + /overview + /overview/about ).each { |path| get path, to: 'home#index' }