2
0

Emoji: Announcements (#36397)

Co-authored-by: diondiondion <mail@diondiondion.com>
This commit is contained in:
Echo
2025-10-08 17:07:01 +02:00
committed by GitHub
parent 5c92312d4d
commit babb7b2b9d
7 changed files with 385 additions and 11 deletions

View File

@@ -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;
}

View File

@@ -160,15 +160,15 @@ export function cleanExtraEmojis(extraEmojis?: CustomEmojiMapArg) {
{}, {},
); );
} }
if (!isList(extraEmojis)) { if (isList(extraEmojis)) {
return extraEmojis; return extraEmojis
.toJS()
.reduce<ExtraCustomEmojiMap>(
(acc, emoji) => ({ ...acc, [emoji.shortcode]: emoji }),
{},
);
} }
return extraEmojis return extraEmojis;
.toJSON()
.reduce<ExtraCustomEmojiMap>(
(acc, emoji) => ({ ...acc, [emoji.shortcode]: emoji }),
{},
);
} }
function hexStringToNumbers(hexString: string): number[] { function hexStringToNumbers(hexString: string): number[] {

View File

@@ -57,7 +57,8 @@ export type EmojiStateMap = LimitedCache<string, EmojiState>;
export type CustomEmojiMapArg = export type CustomEmojiMapArg =
| ExtraCustomEmojiMap | ExtraCustomEmojiMap
| ImmutableList<CustomEmoji> | ImmutableList<CustomEmoji>
| CustomEmoji[]; | CustomEmoji[]
| ApiCustomEmojiJSON[];
export type ExtraCustomEmojiMap = Record< export type ExtraCustomEmojiMap = Record<
string, string,

View File

@@ -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<AnnouncementProps> = ({
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 (
<AnimateEmojiProvider className='announcements__item'>
<strong className='announcements__item__range'>
<FormattedMessage
id='announcement.announcement'
defaultMessage='Announcement'
/>
<span>
{' · '}
<Timestamp announcement={announcement} />
</span>
</strong>
<EmojiHTML
className='announcements__item__content translate'
htmlString={announcement.contentHtml}
extraEmojis={announcement.emojis}
/>
<ReactionsBar reactions={announcement.reactions} id={announcement.id} />
{unread && <span className='announcements__item__unread' />}
</AnimateEmojiProvider>
);
};
const Timestamp: FC<Pick<AnnouncementProps, 'announcement'>> = ({
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 (
<>
<FormattedDate
value={startsAt}
year={
skipYear || startsAt.getFullYear() === now.getFullYear()
? undefined
: 'numeric'
}
month='short'
day='2-digit'
hour={skipTime ? undefined : '2-digit'}
minute={skipTime ? undefined : '2-digit'}
/>{' '}
-{' '}
<FormattedDate
value={endsAt}
year={
skipYear || endsAt.getFullYear() === now.getFullYear()
? undefined
: 'numeric'
}
month={skipEndDate ? undefined : 'short'}
day={skipEndDate ? undefined : '2-digit'}
hour={skipTime ? undefined : '2-digit'}
minute={skipTime ? undefined : '2-digit'}
/>
</>
);
}
const publishedAt = new Date(announcement.published_at);
return (
<FormattedDate
value={publishedAt}
year={
publishedAt.getFullYear() === now.getFullYear() ? undefined : 'numeric'
}
month='short'
day='2-digit'
hour={skipTime ? undefined : '2-digit'}
minute={skipTime ? undefined : '2-digit'}
/>
);
};

View File

@@ -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<string, List<Map<string, unknown>>>],
(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 (
<div className='announcements'>
<img
className='announcements__mastodon'
alt=''
draggable='false'
src={mascot ?? elephantUIPlane}
/>
<div className='announcements__container'>
<CustomEmojiProvider emojis={emojis}>
<ReactSwipeableViews
animateHeight
animateTransitions={!reduceMotion}
index={index}
onChangeIndex={handleChangeIndex}
>
{announcements
.map((announcement, idx) => (
<Announcement
key={announcement.id}
announcement={announcement}
selected={index === idx}
/>
))
.reverse()}
</ReactSwipeableViews>
</CustomEmojiProvider>
{announcements.length > 1 && (
<div className='announcements__pagination'>
<IconButton
disabled={announcements.length === 1}
title={intl.formatMessage(messages.previous)}
icon='chevron-left'
iconComponent={ChevronLeftIcon}
onClick={handlePrevIndex}
/>
<span>
{index + 1} / {announcements.length}
</span>
<IconButton
disabled={announcements.length === 1}
title={intl.formatMessage(messages.next)}
icon='chevron-right'
iconComponent={ChevronRightIcon}
onClick={handleNextIndex}
/>
</div>
)}
</div>
</div>
);
};
export const Announcements = isModernEmojiEnabled()
? ModernAnnouncements
: LegacyAnnouncements;

View File

@@ -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 (
<div
className={classNames('reactions-bar', {
'reactions-bar--empty': visibleReactions.length === 0,
})}
>
{transitions(({ scale }, reaction) => (
<Reaction
key={reaction.name}
reaction={reaction}
style={{ transform: scale.to((s) => `scale(${s})`) }}
id={id}
/>
))}
{visibleReactions.length < 8 && (
<EmojiPickerDropdown
onPickEmoji={handleEmojiPick}
button={<Icon id='plus' icon={AddIcon} />}
/>
)}
</div>
);
};
const Reaction: FC<{
reaction: ApiAnnouncementReactionJSON;
id: string;
style: AnimatedProps<HTMLAttributes<HTMLButtonElement>>['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 (
<animated.button
className={classNames('reactions-bar__item', {
active: reaction.me,
})}
onClick={handleClick}
style={style}
>
<span className='reactions-bar__item__emoji'>
<Emoji code={code} />
</span>
<span className='reactions-bar__item__count'>
<AnimatedNumber value={reaction.count} />
</span>
</animated.button>
);
};

View File

@@ -14,7 +14,6 @@ import { SymbolLogo } from 'mastodon/components/logo';
import { fetchAnnouncements, toggleShowAnnouncements } from 'mastodon/actions/announcements'; import { fetchAnnouncements, toggleShowAnnouncements } from 'mastodon/actions/announcements';
import { IconWithBadge } from 'mastodon/components/icon_with_badge'; import { IconWithBadge } from 'mastodon/components/icon_with_badge';
import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator'; 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 { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
import { criticalUpdatesPending } from 'mastodon/initial_state'; import { criticalUpdatesPending } from 'mastodon/initial_state';
import { withBreakpoint } from 'mastodon/features/ui/hooks/useBreakpoint'; 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 { ColumnSettings } from './components/column_settings';
import { CriticalUpdateBanner } from './components/critical_update_banner'; import { CriticalUpdateBanner } from './components/critical_update_banner';
import { Announcements } from './components/announcements';
const messages = defineMessages({ const messages = defineMessages({
title: { id: 'column.home', defaultMessage: 'Home' }, title: { id: 'column.home', defaultMessage: 'Home' },
@@ -162,7 +162,7 @@ class HomeTimeline extends PureComponent {
pinned={pinned} pinned={pinned}
multiColumn={multiColumn} multiColumn={multiColumn}
extraButton={announcementsButton} extraButton={announcementsButton}
appendContent={hasAnnouncements && showAnnouncements && <AnnouncementsContainer />} appendContent={hasAnnouncements && showAnnouncements && <Announcements />}
> >
<ColumnSettings /> <ColumnSettings />
</ColumnHeader> </ColumnHeader>