From 478dae0ab3420853d1d9ff2f6b3efcb85b322fd3 Mon Sep 17 00:00:00 2001 From: diondiondion Date: Thu, 23 Apr 2026 11:16:54 +0200 Subject: [PATCH] Show collection preview cards and open collections links locally (#38643) --- .../mastodon/api_types/collections.ts | 5 ++-- app/javascript/mastodon/components/status.jsx | 24 ++++++++++----- .../components/status/handled_link.tsx | 13 +++++++++ .../mastodon/components/status_content.jsx | 21 ++++++-------- .../components/collection_lockup.tsx | 4 ++- .../components/collection_menu.tsx | 5 ++-- .../features/collections/editor/details.tsx | 4 ++- .../mastodon/features/collections/utils.ts | 2 ++ .../status/components/detailed_status.tsx | 29 ++++++++++++++----- app/javascript/mastodon/models/status.ts | 3 ++ app/javascript/mastodon/utils/compare_urls.ts | 14 +++++++++ 11 files changed, 92 insertions(+), 32 deletions(-) create mode 100644 app/javascript/mastodon/utils/compare_urls.ts diff --git a/app/javascript/mastodon/api_types/collections.ts b/app/javascript/mastodon/api_types/collections.ts index 3edaa64c95..2ba20eb514 100644 --- a/app/javascript/mastodon/api_types/collections.ts +++ b/app/javascript/mastodon/api_types/collections.ts @@ -11,7 +11,8 @@ export interface ApiCollectionJSON { account_id: string; id: string; - uri: string | null; + uri: string; + url: string; local: boolean; item_count: number; @@ -56,7 +57,7 @@ export interface CollectionAccountItem { id: string; account_id?: string; // Only present when state is 'accepted' (or the collection is your own) state: 'pending' | 'accepted' | 'rejected' | 'revoked'; - position: number; + created_at: string; } export interface WrappedCollectionAccountItem { diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx index 5685a5a891..a21063496d 100644 --- a/app/javascript/mastodon/components/status.jsx +++ b/app/javascript/mastodon/components/status.jsx @@ -547,13 +547,23 @@ class Status extends ImmutablePureComponent { ); } } else if (status.get('card') && !status.get('quote')) { - media = ( - - ); + const cardUrl = status.getIn(['card', 'url']); + + const taggedCollection = ( + status.get('tagged_collections') + ).find((item) => compareUrls(item.get('url'), cardUrl)); + + if (taggedCollection) { + media = ; + } else { + media = ( + + ); + } } const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status); diff --git a/app/javascript/mastodon/components/status/handled_link.tsx b/app/javascript/mastodon/components/status/handled_link.tsx index 5fcea5f8b9..55d3c95435 100644 --- a/app/javascript/mastodon/components/status/handled_link.tsx +++ b/app/javascript/mastodon/components/status/handled_link.tsx @@ -4,7 +4,9 @@ import type { ComponentProps, FC } from 'react'; import classNames from 'classnames'; import { Link } from 'react-router-dom'; +import type { ApiCollectionJSON } from '@/mastodon/api_types/collections'; import type { ApiMentionJSON } from '@/mastodon/api_types/statuses'; +import { getCollectionPath } from '@/mastodon/features/collections/utils'; import type { OnElementHandler } from '@/mastodon/utils/html'; export interface HandledLinkProps { @@ -13,6 +15,7 @@ export interface HandledLinkProps { prevText?: string; hashtagAccountId?: string; mention?: Pick; + collection?: Pick; } export const HandledLink: FC> = ({ @@ -21,6 +24,7 @@ export const HandledLink: FC> = ({ prevText, hashtagAccountId, mention, + collection, className, children, ...props @@ -57,6 +61,15 @@ export const HandledLink: FC> = ({ {children} ); + } else if (collection) { + return ( + + {children} + + ); } // Non-absolute paths treated as internal links. This shouldn't happen, but just in case. diff --git a/app/javascript/mastodon/components/status_content.jsx b/app/javascript/mastodon/components/status_content.jsx index dbbac83314..920bdbf14e 100644 --- a/app/javascript/mastodon/components/status_content.jsx +++ b/app/javascript/mastodon/components/status_content.jsx @@ -18,6 +18,7 @@ import { languages as preloadedLanguages } from 'mastodon/initial_state'; import { EmojiHTML } from './emoji/html'; import { injectIntl } from './intl'; import { HandledLink } from './status/handled_link'; +import { compareUrls } from '../utils/compare_urls'; const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top) @@ -71,17 +72,6 @@ const mapStateToProps = state => ({ languages: state.getIn(['server', 'translationLanguages', 'items']), }); -const compareUrls = (href1, href2) => { - try { - const url1 = new URL(href1); - const url2 = new URL(href2); - - return url1.origin === url2.origin && url1.pathname === url2.pathname && url1.search === url2.search; - } catch { - return false; - } -}; - class StatusContent extends PureComponent { static propTypes = { identity: identityContextPropShape, @@ -166,7 +156,13 @@ class StatusContent extends PureComponent { handleElement = (element, { key, ...props }, children) => { if (element instanceof HTMLAnchorElement) { - const mention = this.props.status.get('mentions').find(item => compareUrls(element.href, item.get('url'))); + const mention = this.props.status.get('mentions').find( + item => compareUrls(element.href, item.get('url')) + ); + const taggedCollection = this.props.status.get('tagged_collections').find( + item => compareUrls(element.href, item.get('url')) + ) + return ( {children} diff --git a/app/javascript/mastodon/features/collections/components/collection_lockup.tsx b/app/javascript/mastodon/features/collections/components/collection_lockup.tsx index 45cb59b089..bbcde0c3bd 100644 --- a/app/javascript/mastodon/features/collections/components/collection_lockup.tsx +++ b/app/javascript/mastodon/features/collections/components/collection_lockup.tsx @@ -13,6 +13,8 @@ import { RelativeTimestamp } from 'mastodon/components/relative_timestamp'; import { useAccount } from 'mastodon/hooks/useAccount'; import { domain } from 'mastodon/initial_state'; +import { getCollectionPath } from '../utils'; + import classes from './collection_lockup.module.scss'; export const AvatarGrid: React.FC<{ @@ -67,7 +69,7 @@ export const CollectionLockup: React.FC = ({ />

- + {name}

diff --git a/app/javascript/mastodon/features/collections/components/collection_menu.tsx b/app/javascript/mastodon/features/collections/components/collection_menu.tsx index 661e879df5..f5cc518fb8 100644 --- a/app/javascript/mastodon/features/collections/components/collection_menu.tsx +++ b/app/javascript/mastodon/features/collections/components/collection_menu.tsx @@ -15,6 +15,7 @@ import type { MenuItem } from 'mastodon/models/dropdown_menu'; import { useAppDispatch } from 'mastodon/store'; import { messages as editorMessages } from '../editor'; +import { getCollectionPath } from '../utils'; const messages = defineMessages({ view: { @@ -120,7 +121,7 @@ export const CollectionMenu: React.FC<{ const menu = useMemo(() => { const viewCollectionItem: MenuItem = { text: intl.formatMessage(messages.view), - to: `/collections/${id}`, + to: getCollectionPath(id), }; const shareItems: MenuItem[] = [ { @@ -130,7 +131,7 @@ export const CollectionMenu: React.FC<{ { text: intl.formatMessage(messages.copyLink), action: () => { - void navigator.clipboard.writeText(`/collections/${id}`); + void navigator.clipboard.writeText(getCollectionPath(id)); dispatch(showAlert({ message: messages.copyLinkConfirmation })); }, }, diff --git a/app/javascript/mastodon/features/collections/editor/details.tsx b/app/javascript/mastodon/features/collections/editor/details.tsx index fe8a17bb34..de568137e8 100644 --- a/app/javascript/mastodon/features/collections/editor/details.tsx +++ b/app/javascript/mastodon/features/collections/editor/details.tsx @@ -38,6 +38,8 @@ import { } from 'mastodon/reducers/slices/collections'; import { useAppDispatch, useAppSelector } from 'mastodon/store'; +import { getCollectionPath } from '../utils'; + import classes from './styles.module.scss'; import { WizardStepTitle } from './wizard_step_title'; @@ -134,7 +136,7 @@ export const CollectionDetails: React.FC = () => { ).then((result) => { if (isFulfilled(result)) { history.replace(`/@${currentUserName}/collections`); - history.push(`/collections/${result.payload.collection.id}`, { + history.push(getCollectionPath(result.payload.collection.id), { newCollection: true, }); } diff --git a/app/javascript/mastodon/features/collections/utils.ts b/app/javascript/mastodon/features/collections/utils.ts index 60eb5133cb..1905edfef1 100644 --- a/app/javascript/mastodon/features/collections/utils.ts +++ b/app/javascript/mastodon/features/collections/utils.ts @@ -3,3 +3,5 @@ import { isServerFeatureEnabled } from '@/mastodon/utils/environment'; export function areCollectionsEnabled() { return isServerFeatureEnabled('collections'); } + +export const getCollectionPath = (id: string) => `/collections/${id}`; diff --git a/app/javascript/mastodon/features/status/components/detailed_status.tsx b/app/javascript/mastodon/features/status/components/detailed_status.tsx index b85841098a..ded57f02d0 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.tsx +++ b/app/javascript/mastodon/features/status/components/detailed_status.tsx @@ -29,9 +29,12 @@ import StatusContent from 'mastodon/components/status_content'; import { QuotedStatus } from 'mastodon/components/status_quoted'; import { VisibilityIcon } from 'mastodon/components/visibility_icon'; import { Audio } from 'mastodon/features/audio'; +import { CollectionPreviewCard } from 'mastodon/features/collections/components/collection_preview_card'; import scheduleIdleTask from 'mastodon/features/ui/util/schedule_idle_task'; import { Video } from 'mastodon/features/video'; import { useIdentity } from 'mastodon/identity_context'; +import type { CollectionAttachment } from 'mastodon/models/status'; +import { compareUrls } from 'mastodon/utils/compare_urls'; import Card from './card'; @@ -260,13 +263,25 @@ export const DetailedStatus: React.FC<{ ); } } else if (status.get('card') && !status.get('quote')) { - media = ( - - ); + const cardUrl: string = status.getIn(['card', 'url']); + + const taggedCollection = status + .get('tagged_collections') + .find((item: CollectionAttachment) => + compareUrls(item.get('url'), cardUrl), + ); + + if (taggedCollection) { + media = ; + } else { + media = ( + + ); + } } if (status.get('application')) { diff --git a/app/javascript/mastodon/models/status.ts b/app/javascript/mastodon/models/status.ts index b043edb9ca..668546ea26 100644 --- a/app/javascript/mastodon/models/status.ts +++ b/app/javascript/mastodon/models/status.ts @@ -1,5 +1,6 @@ import type { RecordOf } from 'immutable'; +import type { ApiCollectionJSON } from 'mastodon/api_types/collections'; import type { ApiPreviewCardJSON } from 'mastodon/api_types/statuses'; export type { StatusVisibility } from 'mastodon/api_types/statuses'; @@ -10,3 +11,5 @@ export type Status = Immutable.Map; export type Card = RecordOf; export type MediaAttachment = Immutable.Map; + +export type CollectionAttachment = RecordOf; diff --git a/app/javascript/mastodon/utils/compare_urls.ts b/app/javascript/mastodon/utils/compare_urls.ts new file mode 100644 index 0000000000..de4f6220e8 --- /dev/null +++ b/app/javascript/mastodon/utils/compare_urls.ts @@ -0,0 +1,14 @@ +export function compareUrls(href1: string, href2: string) { + try { + const url1 = new URL(href1); + const url2 = new URL(href2); + + return ( + url1.origin === url2.origin && + url1.pathname === url2.pathname && + url1.search === url2.search + ); + } catch { + return false; + } +}