From babb7b2b9d13d6c9041edfe3ab6fe9fba708709c Mon Sep 17 00:00:00 2001 From: Echo Date: Wed, 8 Oct 2025 17:07:01 +0200 Subject: [PATCH] Emoji: Announcements (#36397) Co-authored-by: diondiondion --- .../mastodon/api_types/announcements.ts | 28 +++++ .../mastodon/features/emoji/normalize.ts | 16 +-- .../mastodon/features/emoji/types.ts | 3 +- .../components/announcements/announcement.tsx | 119 ++++++++++++++++++ .../components/announcements/index.tsx | 118 +++++++++++++++++ .../components/announcements/reactions.tsx | 108 ++++++++++++++++ .../mastodon/features/home_timeline/index.jsx | 4 +- 7 files changed, 385 insertions(+), 11 deletions(-) create mode 100644 app/javascript/mastodon/api_types/announcements.ts create mode 100644 app/javascript/mastodon/features/home_timeline/components/announcements/announcement.tsx create mode 100644 app/javascript/mastodon/features/home_timeline/components/announcements/index.tsx create mode 100644 app/javascript/mastodon/features/home_timeline/components/announcements/reactions.tsx diff --git a/app/javascript/mastodon/api_types/announcements.ts b/app/javascript/mastodon/api_types/announcements.ts new file mode 100644 index 000000000..03e8922d8 --- /dev/null +++ b/app/javascript/mastodon/api_types/announcements.ts @@ -0,0 +1,28 @@ +// See app/serializers/rest/announcement_serializer.rb + +import type { ApiCustomEmojiJSON } from './custom_emoji'; +import type { ApiMentionJSON, ApiStatusJSON, ApiTagJSON } from './statuses'; + +export interface ApiAnnouncementJSON { + id: string; + content: string; + starts_at: null | string; + ends_at: null | string; + all_day: boolean; + published_at: string; + updated_at: null | string; + read: boolean; + mentions: ApiMentionJSON[]; + statuses: ApiStatusJSON[]; + tags: ApiTagJSON[]; + emojis: ApiCustomEmojiJSON[]; + reactions: ApiAnnouncementReactionJSON[]; +} + +export interface ApiAnnouncementReactionJSON { + name: string; + count: number; + me: boolean; + url?: string; + static_url?: string; +} diff --git a/app/javascript/mastodon/features/emoji/normalize.ts b/app/javascript/mastodon/features/emoji/normalize.ts index 7c4252017..a09505e97 100644 --- a/app/javascript/mastodon/features/emoji/normalize.ts +++ b/app/javascript/mastodon/features/emoji/normalize.ts @@ -160,15 +160,15 @@ export function cleanExtraEmojis(extraEmojis?: CustomEmojiMapArg) { {}, ); } - if (!isList(extraEmojis)) { - return extraEmojis; + if (isList(extraEmojis)) { + return extraEmojis + .toJS() + .reduce( + (acc, emoji) => ({ ...acc, [emoji.shortcode]: emoji }), + {}, + ); } - return extraEmojis - .toJSON() - .reduce( - (acc, emoji) => ({ ...acc, [emoji.shortcode]: emoji }), - {}, - ); + return extraEmojis; } function hexStringToNumbers(hexString: string): number[] { diff --git a/app/javascript/mastodon/features/emoji/types.ts b/app/javascript/mastodon/features/emoji/types.ts index a98d931ea..b55cefb0a 100644 --- a/app/javascript/mastodon/features/emoji/types.ts +++ b/app/javascript/mastodon/features/emoji/types.ts @@ -57,7 +57,8 @@ export type EmojiStateMap = LimitedCache; export type CustomEmojiMapArg = | ExtraCustomEmojiMap | ImmutableList - | CustomEmoji[]; + | CustomEmoji[] + | ApiCustomEmojiJSON[]; export type ExtraCustomEmojiMap = Record< string, diff --git a/app/javascript/mastodon/features/home_timeline/components/announcements/announcement.tsx b/app/javascript/mastodon/features/home_timeline/components/announcements/announcement.tsx new file mode 100644 index 000000000..8513e6169 --- /dev/null +++ b/app/javascript/mastodon/features/home_timeline/components/announcements/announcement.tsx @@ -0,0 +1,119 @@ +import { useEffect, useState } from 'react'; +import type { FC } from 'react'; + +import { FormattedDate, FormattedMessage } from 'react-intl'; + +import type { ApiAnnouncementJSON } from '@/mastodon/api_types/announcements'; +import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context'; +import { EmojiHTML } from '@/mastodon/components/emoji/html'; + +import { ReactionsBar } from './reactions'; + +export interface IAnnouncement extends ApiAnnouncementJSON { + contentHtml: string; +} + +interface AnnouncementProps { + announcement: IAnnouncement; + selected: boolean; +} + +export const Announcement: FC = ({ + announcement, + selected, +}) => { + const [unread, setUnread] = useState(!announcement.read); + useEffect(() => { + // Only update `unread` marker once the announcement is out of view + if (!selected && unread !== !announcement.read) { + setUnread(!announcement.read); + } + }, [announcement.read, selected, unread]); + + return ( + + + + + {' ยท '} + + + + + + + + + {unread && } + + ); +}; + +const Timestamp: FC> = ({ + announcement, +}) => { + const startsAt = announcement.starts_at && new Date(announcement.starts_at); + const endsAt = announcement.ends_at && new Date(announcement.ends_at); + const now = new Date(); + const hasTimeRange = startsAt && endsAt; + const skipTime = announcement.all_day; + + if (hasTimeRange) { + const skipYear = + startsAt.getFullYear() === endsAt.getFullYear() && + endsAt.getFullYear() === now.getFullYear(); + const skipEndDate = + startsAt.getDate() === endsAt.getDate() && + startsAt.getMonth() === endsAt.getMonth() && + startsAt.getFullYear() === endsAt.getFullYear(); + return ( + <> + {' '} + -{' '} + + + ); + } + const publishedAt = new Date(announcement.published_at); + return ( + + ); +}; diff --git a/app/javascript/mastodon/features/home_timeline/components/announcements/index.tsx b/app/javascript/mastodon/features/home_timeline/components/announcements/index.tsx new file mode 100644 index 000000000..8c7c70484 --- /dev/null +++ b/app/javascript/mastodon/features/home_timeline/components/announcements/index.tsx @@ -0,0 +1,118 @@ +import { useCallback, useState } from 'react'; +import type { FC } from 'react'; + +import { defineMessages, useIntl } from 'react-intl'; + +import type { Map, List } from 'immutable'; + +import ReactSwipeableViews from 'react-swipeable-views'; + +import elephantUIPlane from '@/images/elephant_ui_plane.svg'; +import { CustomEmojiProvider } from '@/mastodon/components/emoji/context'; +import { IconButton } from '@/mastodon/components/icon_button'; +import LegacyAnnouncements from '@/mastodon/features/getting_started/containers/announcements_container'; +import { mascot, reduceMotion } from '@/mastodon/initial_state'; +import { createAppSelector, useAppSelector } from '@/mastodon/store'; +import { isModernEmojiEnabled } from '@/mastodon/utils/environment'; +import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react'; +import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react'; + +import type { IAnnouncement } from './announcement'; +import { Announcement } from './announcement'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' }, + previous: { id: 'lightbox.previous', defaultMessage: 'Previous' }, + next: { id: 'lightbox.next', defaultMessage: 'Next' }, +}); + +const announcementSelector = createAppSelector( + [(state) => state.announcements as Map>>], + (announcements) => + (announcements.get('items')?.toJS() as IAnnouncement[] | undefined) ?? [], +); + +export const ModernAnnouncements: FC = () => { + const intl = useIntl(); + + const announcements = useAppSelector(announcementSelector); + const emojis = useAppSelector((state) => state.custom_emojis); + + const [index, setIndex] = useState(0); + const handleChangeIndex = useCallback( + (idx: number) => { + setIndex(idx % announcements.length); + }, + [announcements.length], + ); + const handleNextIndex = useCallback(() => { + setIndex((prevIndex) => (prevIndex + 1) % announcements.length); + }, [announcements.length]); + const handlePrevIndex = useCallback(() => { + setIndex((prevIndex) => + prevIndex === 0 ? announcements.length - 1 : prevIndex - 1, + ); + }, [announcements.length]); + + if (announcements.length === 0) { + return null; + } + + return ( +
+ + +
+ + + {announcements + .map((announcement, idx) => ( + + )) + .reverse()} + + + + {announcements.length > 1 && ( +
+ + + {index + 1} / {announcements.length} + + +
+ )} +
+
+ ); +}; + +export const Announcements = isModernEmojiEnabled() + ? ModernAnnouncements + : LegacyAnnouncements; diff --git a/app/javascript/mastodon/features/home_timeline/components/announcements/reactions.tsx b/app/javascript/mastodon/features/home_timeline/components/announcements/reactions.tsx new file mode 100644 index 000000000..481e87f19 --- /dev/null +++ b/app/javascript/mastodon/features/home_timeline/components/announcements/reactions.tsx @@ -0,0 +1,108 @@ +import { useCallback, useMemo } from 'react'; +import type { FC, HTMLAttributes } from 'react'; + +import classNames from 'classnames'; + +import type { AnimatedProps } from '@react-spring/web'; +import { animated, useTransition } from '@react-spring/web'; + +import { addReaction, removeReaction } from '@/mastodon/actions/announcements'; +import type { ApiAnnouncementReactionJSON } from '@/mastodon/api_types/announcements'; +import { AnimatedNumber } from '@/mastodon/components/animated_number'; +import { Emoji } from '@/mastodon/components/emoji'; +import { Icon } from '@/mastodon/components/icon'; +import EmojiPickerDropdown from '@/mastodon/features/compose/containers/emoji_picker_dropdown_container'; +import { isUnicodeEmoji } from '@/mastodon/features/emoji/utils'; +import { useAppDispatch } from '@/mastodon/store'; +import AddIcon from '@/material-icons/400-24px/add.svg?react'; + +export const ReactionsBar: FC<{ + reactions: ApiAnnouncementReactionJSON[]; + id: string; +}> = ({ reactions, id }) => { + const visibleReactions = useMemo( + () => reactions.filter((x) => x.count > 0), + [reactions], + ); + + const dispatch = useAppDispatch(); + const handleEmojiPick = useCallback( + (emoji: { native: string }) => { + dispatch(addReaction(id, emoji.native.replaceAll(/:/g, ''))); + }, + [dispatch, id], + ); + + const transitions = useTransition(visibleReactions, { + from: { + scale: 0, + }, + enter: { + scale: 1, + }, + leave: { + scale: 0, + }, + keys: visibleReactions.map((x) => x.name), + }); + + return ( +
+ {transitions(({ scale }, reaction) => ( + `scale(${s})`) }} + id={id} + /> + ))} + + {visibleReactions.length < 8 && ( + } + /> + )} +
+ ); +}; + +const Reaction: FC<{ + reaction: ApiAnnouncementReactionJSON; + id: string; + style: AnimatedProps>['style']; +}> = ({ id, reaction, style }) => { + const dispatch = useAppDispatch(); + const handleClick = useCallback(() => { + if (reaction.me) { + dispatch(removeReaction(id, reaction.name)); + } else { + dispatch(addReaction(id, reaction.name)); + } + }, [dispatch, id, reaction.me, reaction.name]); + + const code = isUnicodeEmoji(reaction.name) + ? reaction.name + : `:${reaction.name}:`; + + return ( + + + + + + + + + ); +}; diff --git a/app/javascript/mastodon/features/home_timeline/index.jsx b/app/javascript/mastodon/features/home_timeline/index.jsx index 39a8355b8..8c5555fd4 100644 --- a/app/javascript/mastodon/features/home_timeline/index.jsx +++ b/app/javascript/mastodon/features/home_timeline/index.jsx @@ -14,7 +14,6 @@ import { SymbolLogo } from 'mastodon/components/logo'; import { fetchAnnouncements, toggleShowAnnouncements } from 'mastodon/actions/announcements'; import { IconWithBadge } from 'mastodon/components/icon_with_badge'; import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator'; -import AnnouncementsContainer from 'mastodon/features/getting_started/containers/announcements_container'; import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; import { criticalUpdatesPending } from 'mastodon/initial_state'; import { withBreakpoint } from 'mastodon/features/ui/hooks/useBreakpoint'; @@ -27,6 +26,7 @@ import StatusListContainer from '../ui/containers/status_list_container'; import { ColumnSettings } from './components/column_settings'; import { CriticalUpdateBanner } from './components/critical_update_banner'; +import { Announcements } from './components/announcements'; const messages = defineMessages({ title: { id: 'column.home', defaultMessage: 'Home' }, @@ -162,7 +162,7 @@ class HomeTimeline extends PureComponent { pinned={pinned} multiColumn={multiColumn} extraButton={announcementsButton} - appendContent={hasAnnouncements && showAnnouncements && } + appendContent={hasAnnouncements && showAnnouncements && } >