Emoji: Announcements (#36397)
Co-authored-by: diondiondion <mail@diondiondion.com>
This commit is contained in:
28
app/javascript/mastodon/api_types/announcements.ts
Normal file
28
app/javascript/mastodon/api_types/announcements.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -160,15 +160,15 @@ export function cleanExtraEmojis(extraEmojis?: CustomEmojiMapArg) {
|
|||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!isList(extraEmojis)) {
|
if (isList(extraEmojis)) {
|
||||||
return extraEmojis;
|
|
||||||
}
|
|
||||||
return extraEmojis
|
return extraEmojis
|
||||||
.toJSON()
|
.toJS()
|
||||||
.reduce<ExtraCustomEmojiMap>(
|
.reduce<ExtraCustomEmojiMap>(
|
||||||
(acc, emoji) => ({ ...acc, [emoji.shortcode]: emoji }),
|
(acc, emoji) => ({ ...acc, [emoji.shortcode]: emoji }),
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
return extraEmojis;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hexStringToNumbers(hexString: string): number[] {
|
function hexStringToNumbers(hexString: string): number[] {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user