sforkowany z mirror/soapbox
Announcements
Signed-off-by: marcin mikołajczak <git@mkljczk.pl>update-emoji-mart^2
rodzic
f5c3497ece
commit
d5a6e978e6
|
@ -0,0 +1,63 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { FormattedNumber } from 'react-intl';
|
||||
import { TransitionMotion, spring } from 'react-motion';
|
||||
|
||||
import { useSettings } from 'soapbox/hooks';
|
||||
|
||||
const obfuscatedCount = (count: number) => {
|
||||
if (count < 0) {
|
||||
return 0;
|
||||
} else if (count <= 1) {
|
||||
return count;
|
||||
} else {
|
||||
return '1+';
|
||||
}
|
||||
};
|
||||
|
||||
interface IAnimatedNumber {
|
||||
value: number;
|
||||
obfuscate?: boolean;
|
||||
}
|
||||
|
||||
const AnimatedNumber: React.FC<IAnimatedNumber> = ({ value, obfuscate }) => {
|
||||
const reduceMotion = useSettings().get('reduceMotion');
|
||||
|
||||
const [direction, setDirection] = useState(1);
|
||||
const [displayedValue, setDisplayedValue] = useState<number>(value);
|
||||
|
||||
useEffect(() => {
|
||||
if (displayedValue !== undefined) {
|
||||
if (value > displayedValue) setDirection(1);
|
||||
else if (value < displayedValue) setDirection(-1);
|
||||
}
|
||||
setDisplayedValue(value);
|
||||
}, [value]);
|
||||
|
||||
const willEnter = () => ({ y: -1 * direction });
|
||||
|
||||
const willLeave = () => ({ y: spring(1 * direction, { damping: 35, stiffness: 400 }) });
|
||||
|
||||
if (reduceMotion) {
|
||||
return obfuscate ? <>{obfuscatedCount(displayedValue)}</> : <FormattedNumber value={displayedValue} />;
|
||||
}
|
||||
|
||||
const styles = [{
|
||||
key: `${displayedValue}`,
|
||||
data: displayedValue,
|
||||
style: { y: spring(0, { damping: 35, stiffness: 400 }) },
|
||||
}];
|
||||
|
||||
return (
|
||||
<TransitionMotion styles={styles} willEnter={willEnter} willLeave={willLeave}>
|
||||
{items => (
|
||||
<span className='inline-flex flex-col items-stretch relative overflow-hidden'>
|
||||
{items.map(({ key, data, style }) => (
|
||||
<span key={key} style={{ position: (direction * style.y) > 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}>{obfuscate ? obfuscatedCount(data) : <FormattedNumber value={data} />}</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</TransitionMotion>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnimatedNumber;
|
|
@ -0,0 +1,80 @@
|
|||
import React, { useEffect, useRef } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import type { Announcement as AnnouncementEntity, Mention as MentionEntity } from 'soapbox/types/entities';
|
||||
|
||||
interface IAnnouncementContent {
|
||||
announcement: AnnouncementEntity;
|
||||
}
|
||||
|
||||
const AnnouncementContent: React.FC<IAnnouncementContent> = ({ announcement }) => {
|
||||
const history = useHistory();
|
||||
|
||||
const node = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
updateLinks();
|
||||
});
|
||||
|
||||
const onMentionClick = (mention: MentionEntity, e: MouseEvent) => {
|
||||
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
history.push(`/@${mention.acct}`);
|
||||
}
|
||||
};
|
||||
|
||||
const onHashtagClick = (hashtag: string, e: MouseEvent) => {
|
||||
hashtag = hashtag.replace(/^#/, '').toLowerCase();
|
||||
|
||||
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
history.push(`/tags/${hashtag}`);
|
||||
}
|
||||
};
|
||||
|
||||
/** For regular links, just stop propogation */
|
||||
const onLinkClick = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const updateLinks = () => {
|
||||
if (!node.current) return;
|
||||
|
||||
const links = node.current.querySelectorAll('a');
|
||||
|
||||
links.forEach(link => {
|
||||
// Skip already processed
|
||||
if (link.classList.contains('status-link')) return;
|
||||
|
||||
// Add attributes
|
||||
link.classList.add('status-link');
|
||||
link.setAttribute('rel', 'nofollow noopener');
|
||||
link.setAttribute('target', '_blank');
|
||||
|
||||
const mention = announcement.mentions.find(mention => link.href === `${mention.url}`);
|
||||
|
||||
// Add event listeners on mentions and hashtags
|
||||
if (mention) {
|
||||
link.addEventListener('click', onMentionClick.bind(link, mention), false);
|
||||
link.setAttribute('title', mention.acct);
|
||||
} else if (link.textContent?.charAt(0) === '#' || (link.previousSibling?.textContent?.charAt(link.previousSibling.textContent.length - 1) === '#')) {
|
||||
link.addEventListener('click', onHashtagClick.bind(link, link.text), false);
|
||||
} else {
|
||||
link.setAttribute('title', link.href);
|
||||
link.addEventListener('click', onLinkClick.bind(link), false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className='translate text-sm'
|
||||
ref={node}
|
||||
dangerouslySetInnerHTML={{ __html: announcement.contentHtml }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnnouncementContent;
|
|
@ -0,0 +1,70 @@
|
|||
import React from 'react';
|
||||
import { FormattedDate } from 'react-intl';
|
||||
|
||||
import { Stack, Text } from 'soapbox/components/ui';
|
||||
|
||||
import AnnouncementContent from './announcement-content';
|
||||
import ReactionsBar from './reactions-bar';
|
||||
|
||||
import type { Map as ImmutableMap } from 'immutable';
|
||||
import type { Announcement as AnnouncementEntity } from 'soapbox/types/entities';
|
||||
|
||||
interface IAnnouncement {
|
||||
announcement: AnnouncementEntity;
|
||||
addReaction: (id: string, name: string) => void;
|
||||
removeReaction: (id: string, name: string) => void;
|
||||
emojiMap: ImmutableMap<string, ImmutableMap<string, string>>;
|
||||
}
|
||||
|
||||
const Announcement: React.FC<IAnnouncement> = ({ announcement, addReaction, removeReaction, emojiMap }) => {
|
||||
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 skipYear = hasTimeRange && startsAt.getFullYear() === endsAt.getFullYear() && endsAt.getFullYear() === now.getFullYear();
|
||||
const skipEndDate = hasTimeRange && startsAt.getDate() === endsAt.getDate() && startsAt.getMonth() === endsAt.getMonth() && startsAt.getFullYear() === endsAt.getFullYear();
|
||||
const skipTime = announcement.all_day;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Stack space={2}>
|
||||
{hasTimeRange && (
|
||||
<Text theme='muted'>
|
||||
<FormattedDate
|
||||
value={startsAt}
|
||||
hour12={false}
|
||||
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}
|
||||
hour12={false}
|
||||
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'}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<AnnouncementContent announcement={announcement} />
|
||||
|
||||
<ReactionsBar
|
||||
reactions={announcement.reactions}
|
||||
announcementId={announcement.id}
|
||||
addReaction={addReaction}
|
||||
removeReaction={removeReaction}
|
||||
emojiMap={emojiMap}
|
||||
/>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Announcement;
|
|
@ -0,0 +1,69 @@
|
|||
import classNames from 'classnames';
|
||||
import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
|
||||
import React, { useState } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import ReactSwipeableViews from 'react-swipeable-views';
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import { addReaction as addReactionAction, removeReaction as removeReactionAction } from 'soapbox/actions/announcements';
|
||||
import { Card, HStack, Widget } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
import Announcement from './announcement';
|
||||
|
||||
import type { RootState } from 'soapbox/store';
|
||||
|
||||
const customEmojiMap = createSelector([(state: RootState) => state.custom_emojis], items => (items as ImmutableList<ImmutableMap<string, string>>).reduce((map, emoji) => map.set(emoji.get('shortcode')!, emoji), ImmutableMap<string, ImmutableMap<string, string>>()));
|
||||
|
||||
const AnnouncementsPanel = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const emojiMap = useAppSelector(state => customEmojiMap(state));
|
||||
const [index, setIndex] = useState(0);
|
||||
|
||||
const announcements = useAppSelector((state) => state.announcements.items);
|
||||
|
||||
const addReaction = (id: string, name: string) => dispatch(addReactionAction(id, name));
|
||||
const removeReaction = (id: string, name: string) => dispatch(removeReactionAction(id, name));
|
||||
|
||||
if (announcements.size === 0) return null;
|
||||
|
||||
const handleChangeIndex = (index: number) => {
|
||||
setIndex(index % announcements.size);
|
||||
};
|
||||
|
||||
return (
|
||||
<Widget title={<FormattedMessage id='announcements.title' defaultMessage='Announcements' />}>
|
||||
<Card className='relative' size='md' variant='rounded'>
|
||||
<ReactSwipeableViews animateHeight index={index} onChangeIndex={handleChangeIndex}>
|
||||
{announcements.map((announcement) => (
|
||||
<Announcement
|
||||
key={announcement.id}
|
||||
announcement={announcement}
|
||||
emojiMap={emojiMap}
|
||||
addReaction={addReaction}
|
||||
removeReaction={removeReaction}
|
||||
/>
|
||||
)).reverse()}
|
||||
</ReactSwipeableViews>
|
||||
{announcements.size > 2 && (
|
||||
<HStack space={2} alignItems='center' justifyContent='center' className='relative'>
|
||||
{announcements.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
tabIndex={0}
|
||||
onClick={() => setIndex(i)}
|
||||
className={classNames({
|
||||
'w-2 h-2 rounded-full focus:ring-primary-600 focus:ring-2 focus:ring-offset-2': true,
|
||||
'bg-gray-200 hover:bg-gray-300': i !== index,
|
||||
'bg-primary-600': i === index,
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</HStack>
|
||||
)}
|
||||
</Card>
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnnouncementsPanel;
|
|
@ -0,0 +1,51 @@
|
|||
import React from 'react';
|
||||
|
||||
import unicodeMapping from 'soapbox/features/emoji/emoji_unicode_mapping_light';
|
||||
import { useSettings } from 'soapbox/hooks';
|
||||
import { joinPublicPath } from 'soapbox/utils/static';
|
||||
|
||||
import type { Map as ImmutableMap } from 'immutable';
|
||||
|
||||
interface IEmoji {
|
||||
emoji: string;
|
||||
emojiMap: ImmutableMap<string, ImmutableMap<string, string>>;
|
||||
hovered: boolean;
|
||||
}
|
||||
|
||||
const Emoji: React.FC<IEmoji> = ({ emoji, emojiMap, hovered }) => {
|
||||
const autoPlayGif = useSettings().get('autoPlayGif');
|
||||
|
||||
// @ts-ignore
|
||||
if (unicodeMapping[emoji]) {
|
||||
// @ts-ignore
|
||||
const { filename, shortCode } = unicodeMapping[emoji];
|
||||
const title = shortCode ? `:${shortCode}:` : '';
|
||||
|
||||
return (
|
||||
<img
|
||||
draggable='false'
|
||||
className='emojione block m-0'
|
||||
alt={emoji}
|
||||
title={title}
|
||||
src={joinPublicPath(`/emoji/${filename}.svg`)}
|
||||
/>
|
||||
);
|
||||
} else if (emojiMap.get(emoji as any)) {
|
||||
const filename = (autoPlayGif || hovered) ? emojiMap.getIn([emoji, 'url']) : emojiMap.getIn([emoji, 'static_url']);
|
||||
const shortCode = `:${emoji}:`;
|
||||
|
||||
return (
|
||||
<img
|
||||
draggable='false'
|
||||
className='emojione block m-0'
|
||||
alt={shortCode}
|
||||
title={shortCode}
|
||||
src={filename as string}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default Emoji;
|
|
@ -0,0 +1,66 @@
|
|||
import classNames from 'classnames';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import AnimatedNumber from 'soapbox/components/animated-number';
|
||||
import unicodeMapping from 'soapbox/features/emoji/emoji_unicode_mapping_light';
|
||||
|
||||
import Emoji from './emoji';
|
||||
|
||||
import type { Map as ImmutableMap } from 'immutable';
|
||||
import type { AnnouncementReaction } from 'soapbox/types/entities';
|
||||
|
||||
interface IReaction {
|
||||
announcementId: string;
|
||||
reaction: AnnouncementReaction;
|
||||
emojiMap: ImmutableMap<string, ImmutableMap<string, string>>;
|
||||
addReaction: (id: string, name: string) => void;
|
||||
removeReaction: (id: string, name: string) => void;
|
||||
style: React.CSSProperties;
|
||||
}
|
||||
|
||||
const Reaction: React.FC<IReaction> = ({ announcementId, reaction, addReaction, removeReaction, emojiMap, style }) => {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
const handleClick = () => {
|
||||
if (reaction.me) {
|
||||
removeReaction(announcementId, reaction.name);
|
||||
} else {
|
||||
addReaction(announcementId, reaction.name);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => setHovered(true);
|
||||
|
||||
const handleMouseLeave = () => setHovered(false);
|
||||
|
||||
let shortCode = reaction.name;
|
||||
|
||||
// @ts-ignore
|
||||
if (unicodeMapping[shortCode]) {
|
||||
// @ts-ignore
|
||||
shortCode = unicodeMapping[shortCode].shortCode;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={classNames('flex shrink-0 items-center gap-1.5 bg-gray-100 dark:bg-primary-900 rounded-sm px-1.5 py-1 transition-colors', {
|
||||
'bg-gray-200 dark:bg-primary-800': hovered,
|
||||
'bg-primary-200 dark:bg-primary-500': reaction.me,
|
||||
})}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
title={`:${shortCode}:`}
|
||||
style={style}
|
||||
>
|
||||
<span className='block h-4 w-4'>
|
||||
<Emoji hovered={hovered} emoji={reaction.name} emojiMap={emojiMap} />
|
||||
</span>
|
||||
<span className='block min-w-[9px] text-center text-xs font-medium text-primary-600 dark:text-white'>
|
||||
<AnimatedNumber value={reaction.count} />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default Reaction;
|
|
@ -0,0 +1,65 @@
|
|||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import { TransitionMotion, spring } from 'react-motion';
|
||||
|
||||
import { Icon } from 'soapbox/components/ui';
|
||||
import EmojiPickerDropdown from 'soapbox/features/compose/containers/emoji_picker_dropdown_container';
|
||||
import { useSettings } from 'soapbox/hooks';
|
||||
|
||||
import Reaction from './reaction';
|
||||
|
||||
import type { List as ImmutableList, Map as ImmutableMap } from 'immutable';
|
||||
import type { Emoji } from 'soapbox/components/autosuggest_emoji';
|
||||
import type { AnnouncementReaction } from 'soapbox/types/entities';
|
||||
|
||||
interface IReactionsBar {
|
||||
announcementId: string;
|
||||
reactions: ImmutableList<AnnouncementReaction>;
|
||||
emojiMap: ImmutableMap<string, ImmutableMap<string, string>>;
|
||||
addReaction: (id: string, name: string) => void;
|
||||
removeReaction: (id: string, name: string) => void;
|
||||
}
|
||||
|
||||
const ReactionsBar: React.FC<IReactionsBar> = ({ announcementId, reactions, addReaction, removeReaction, emojiMap }) => {
|
||||
const reduceMotion = useSettings().get('reduceMotion');
|
||||
|
||||
const handleEmojiPick = (data: Emoji) => {
|
||||
addReaction(announcementId, data.native.replace(/:/g, ''));
|
||||
};
|
||||
|
||||
const willEnter = () => ({ scale: reduceMotion ? 1 : 0 });
|
||||
|
||||
const willLeave = () => ({ scale: reduceMotion ? 0 : spring(0, { stiffness: 170, damping: 26 }) });
|
||||
|
||||
const visibleReactions = reactions.filter(x => x.count > 0);
|
||||
|
||||
const styles = visibleReactions.map(reaction => ({
|
||||
key: reaction.name,
|
||||
data: reaction,
|
||||
style: { scale: reduceMotion ? 1 : spring(1, { stiffness: 150, damping: 13 }) },
|
||||
})).toArray();
|
||||
|
||||
return (
|
||||
<TransitionMotion styles={styles} willEnter={willEnter} willLeave={willLeave}>
|
||||
{items => (
|
||||
<div className={classNames('flex flex-wrap items-center gap-1', { 'reactions-bar--empty': visibleReactions.isEmpty() })}>
|
||||
{items.map(({ key, data, style }) => (
|
||||
<Reaction
|
||||
key={key}
|
||||
reaction={data}
|
||||
style={{ transform: `scale(${style.scale})`, position: style.scale < 0.5 ? 'absolute' : 'static' }}
|
||||
announcementId={announcementId}
|
||||
addReaction={addReaction}
|
||||
removeReaction={removeReaction}
|
||||
emojiMap={emojiMap}
|
||||
/>
|
||||
))}
|
||||
|
||||
{visibleReactions.size < 8 && <EmojiPickerDropdown onPickEmoji={handleEmojiPick} button={<Icon className='h-4 w-4 text-gray-400 hover:text-gray-600 dark:hover:text-white' src={require('@tabler/icons/icons/plus.svg')} />} />}
|
||||
</div>
|
||||
)}
|
||||
</TransitionMotion>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReactionsBar;
|
|
@ -290,6 +290,7 @@ class EmojiPickerDropdown extends React.PureComponent {
|
|||
onPickEmoji: PropTypes.func.isRequired,
|
||||
onSkinTone: PropTypes.func.isRequired,
|
||||
skinTone: PropTypes.number.isRequired,
|
||||
button: PropTypes.node,
|
||||
};
|
||||
|
||||
state = {
|
||||
|
@ -352,20 +353,14 @@ class EmojiPickerDropdown extends React.PureComponent {
|
|||
}
|
||||
|
||||
render() {
|
||||
const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props;
|
||||
const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis, button } = this.props;
|
||||
const title = intl.formatMessage(messages.emoji);
|
||||
const { active, loading, placement } = this.state;
|
||||
|
||||
return (
|
||||
<div className='relative' onKeyDown={this.handleKeyDown}>
|
||||
<IconButton
|
||||
<div
|
||||
ref={this.setTargetRef}
|
||||
className={classNames({
|
||||
'text-gray-400 hover:text-gray-600': true,
|
||||
'pulse-loading': active && loading,
|
||||
})}
|
||||
alt='😀'
|
||||
src={require('@tabler/icons/icons/mood-happy.svg')}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
aria-expanded={active}
|
||||
|
@ -373,7 +368,16 @@ class EmojiPickerDropdown extends React.PureComponent {
|
|||
onClick={this.onToggle}
|
||||
onKeyDown={this.onToggle}
|
||||
tabIndex={0}
|
||||
/>
|
||||
>
|
||||
{button || <IconButton
|
||||
className={classNames({
|
||||
'text-gray-400 hover:text-gray-600': true,
|
||||
'pulse-loading': active && loading,
|
||||
})}
|
||||
alt='😀'
|
||||
src={require('@tabler/icons/icons/mood-happy.svg')}
|
||||
/>}
|
||||
</div>
|
||||
|
||||
<Overlay show={active} placement={placement} target={this.findTarget}>
|
||||
<EmojiPickerMenu
|
||||
|
|
|
@ -67,7 +67,7 @@ const mapStateToProps = state => ({
|
|||
frequentlyUsedEmojis: getFrequentlyUsedEmojis(state),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch, { onPickEmoji }) => ({
|
||||
const mapDispatchToProps = (dispatch, props) => ({
|
||||
onSkinTone: skinTone => {
|
||||
dispatch(changeSetting(['skinTone'], skinTone));
|
||||
},
|
||||
|
@ -75,8 +75,8 @@ const mapDispatchToProps = (dispatch, { onPickEmoji }) => ({
|
|||
onPickEmoji: emoji => {
|
||||
dispatch(useEmoji(emoji)); // eslint-disable-line react-hooks/rules-of-hooks
|
||||
|
||||
if (onPickEmoji) {
|
||||
onPickEmoji(emoji);
|
||||
if (props.onPickEmoji) {
|
||||
props.onPickEmoji(emoji);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
@ -1,285 +0,0 @@
|
|||
import classNames from 'classnames';
|
||||
import { Map as ImmutableMap } from 'immutable';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { FormattedDate, FormattedMessage } from 'react-intl';
|
||||
import { TransitionMotion, spring } from 'react-motion';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import ReactSwipeableViews from 'react-swipeable-views';
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import { Card, HStack, Widget } from 'soapbox/components/ui';
|
||||
import EmojiPickerDropdown from 'soapbox/features/compose/containers/emoji_picker_dropdown_container';
|
||||
import unicodeMapping from 'soapbox/features/emoji/emoji_unicode_mapping_light';
|
||||
import { useAppDispatch, useAppSelector, useSettings } from 'soapbox/hooks';
|
||||
import { joinPublicPath } from 'soapbox/utils/static';
|
||||
|
||||
import type { RootState } from 'soapbox/store';
|
||||
import type { Announcement as AnnouncementEntity, Mention as MentionEntity } from 'soapbox/types/entities';
|
||||
|
||||
const customEmojiMap = createSelector([(state: RootState) => state.custom_emojis], items => items.reduce((map, emoji) => map.set(emoji.shortcode, emoji), ImmutableMap()));
|
||||
|
||||
const AnnouncementContent = ({ announcement }: { announcement: AnnouncementEntity }) => {
|
||||
const history = useHistory();
|
||||
|
||||
const node = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
updateLinks();
|
||||
});
|
||||
|
||||
const onMentionClick = (mention: MentionEntity, e: MouseEvent) => {
|
||||
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
history.push(`/@${mention.acct}`);
|
||||
}
|
||||
};
|
||||
|
||||
const onHashtagClick = (hashtag: string, e: MouseEvent) => {
|
||||
hashtag = hashtag.replace(/^#/, '').toLowerCase();
|
||||
|
||||
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
history.push(`/tags/${hashtag}`);
|
||||
}
|
||||
};
|
||||
|
||||
/** For regular links, just stop propogation */
|
||||
const onLinkClick = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const updateLinks = () => {
|
||||
if (!node.current) return;
|
||||
|
||||
const links = node.current.querySelectorAll('a');
|
||||
|
||||
links.forEach(link => {
|
||||
// Skip already processed
|
||||
if (link.classList.contains('status-link')) return;
|
||||
|
||||
// Add attributes
|
||||
link.classList.add('status-link');
|
||||
link.setAttribute('rel', 'nofollow noopener');
|
||||
link.setAttribute('target', '_blank');
|
||||
|
||||
const mention = announcement.mentions.find(mention => link.href === `${mention.url}`);
|
||||
|
||||
// Add event listeners on mentions and hashtags
|
||||
if (mention) {
|
||||
link.addEventListener('click', onMentionClick.bind(link, mention), false);
|
||||
link.setAttribute('title', mention.acct);
|
||||
} else if (link.textContent?.charAt(0) === '#' || (link.previousSibling?.textContent?.charAt(link.previousSibling.textContent.length - 1) === '#')) {
|
||||
link.addEventListener('click', onHashtagClick.bind(link, link.text), false);
|
||||
} else {
|
||||
link.setAttribute('title', link.href);
|
||||
link.addEventListener('click', onLinkClick.bind(link), false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
className='translate text-sm'
|
||||
ref={node}
|
||||
dangerouslySetInnerHTML={{ __html: announcement.content }}
|
||||
// onMouseEnter={handleMouseEnter}
|
||||
// onMouseLeave={handleMouseLeave}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const Emoji = ({ emoji, emojiMap, hovered }) => {
|
||||
const autoPlayGif = useSettings().get('autoPlayGif');
|
||||
|
||||
if (unicodeMapping[emoji]) {
|
||||
const { filename, shortCode } = unicodeMapping[emoji];
|
||||
const title = shortCode ? `:${shortCode}:` : '';
|
||||
|
||||
return (
|
||||
<img
|
||||
draggable='false'
|
||||
className='emojione'
|
||||
alt={emoji}
|
||||
title={title}
|
||||
src={joinPublicPath(`/emoji/${filename}.svg`)}
|
||||
/>
|
||||
);
|
||||
} else if (emojiMap.get(emoji)) {
|
||||
const filename = (autoPlayGif || hovered) ? emojiMap.getIn([emoji, 'url']) : emojiMap.getIn([emoji, 'static_url']);
|
||||
const shortCode = `:${emoji}:`;
|
||||
|
||||
return (
|
||||
<img
|
||||
draggable='false'
|
||||
className='emojione custom-emoji'
|
||||
alt={shortCode}
|
||||
title={shortCode}
|
||||
src={filename}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const Reaction = ({ announcementId, reaction, addReaction, removeReaction, emojiMap, style }) => {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
const handleClick = () => {
|
||||
if (reaction.get('me')) {
|
||||
removeReaction(announcementId, reaction.get('name'));
|
||||
} else {
|
||||
addReaction(announcementId, reaction.get('name'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => setHovered(true);
|
||||
|
||||
const handleMouseLeave = () => setHovered(false);
|
||||
|
||||
let shortCode = reaction.get('name');
|
||||
|
||||
if (unicodeMapping[shortCode]) {
|
||||
shortCode = unicodeMapping[shortCode].shortCode;
|
||||
}
|
||||
|
||||
return (
|
||||
<button className={classNames('reactions-bar__item', { active: reaction.get('me') })} onClick={handleClick} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} title={`:${shortCode}:`} style={style}>
|
||||
<span className='reactions-bar__item__emoji'><Emoji hovered={hovered} emoji={reaction.get('name')} emojiMap={emojiMap} /></span>
|
||||
<span className='reactions-bar__item__count'>
|
||||
{reaction.get('count')}
|
||||
{/* <AnimatedNumber value={reaction.get('count')} /> */}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
|
||||
};
|
||||
|
||||
const ReactionsBar = ({ announcementId, reactions, addReaction, removeReaction, emojiMap }) => {
|
||||
const reduceMotion = useSettings().get('reduceMotion');
|
||||
|
||||
const handleEmojiPick = data => {
|
||||
addReaction(announcementId, data.native.replace(/:/g, ''));
|
||||
};
|
||||
|
||||
const willEnter = () => reduceMotion ? 1 : 0;
|
||||
|
||||
const willLeave = () => reduceMotion ? 0 : spring(0, { stiffness: 170, damping: 26 });
|
||||
|
||||
const visibleReactions = reactions.filter(x => x.get('count') > 0);
|
||||
|
||||
const styles = visibleReactions.map(reaction => ({
|
||||
key: reaction.get('name'),
|
||||
data: reaction,
|
||||
style: { scale: reduceMotion ? 1 : spring(1, { stiffness: 150, damping: 13 }) },
|
||||
})).toArray();
|
||||
|
||||
return (
|
||||
<TransitionMotion styles={styles} willEnter={willEnter} willLeave={willLeave}>
|
||||
{items => (
|
||||
<div className={classNames('reactions-bar', { 'reactions-bar--empty': visibleReactions.isEmpty() })}>
|
||||
{items.map(({ key, data, style }) => (
|
||||
<Reaction
|
||||
key={key}
|
||||
reaction={data}
|
||||
style={{ transform: `scale(${style.scale})`, position: style.scale < 0.5 ? 'absolute' : 'static' }}
|
||||
announcementId={announcementId}
|
||||
addReaction={addReaction}
|
||||
removeReaction={removeReaction}
|
||||
emojiMap={emojiMap}
|
||||
/>
|
||||
))}
|
||||
|
||||
{visibleReactions.size < 8 && <EmojiPickerDropdown onPickEmoji={handleEmojiPick} />}
|
||||
</div>
|
||||
)}
|
||||
</TransitionMotion>
|
||||
);
|
||||
};
|
||||
|
||||
const Announcement = ({ announcement, addReaction, removeReaction, emojiMap }: { announcement: AnnouncementEntity }) => {
|
||||
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 skipYear = hasTimeRange && startsAt.getFullYear() === endsAt.getFullYear() && endsAt.getFullYear() === now.getFullYear();
|
||||
const skipEndDate = hasTimeRange && startsAt.getDate() === endsAt.getDate() && startsAt.getMonth() === endsAt.getMonth() && startsAt.getFullYear() === endsAt.getFullYear();
|
||||
const skipTime = announcement.all_day;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<strong>
|
||||
{hasTimeRange && <span> · <FormattedDate value={startsAt} hour12={false} 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} hour12={false} 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'} /></span>}
|
||||
</strong>
|
||||
|
||||
<AnnouncementContent announcement={announcement} />
|
||||
|
||||
{/* <ReactionsBar
|
||||
reactions={announcement.get('reactions')}
|
||||
announcementId={announcement.get('id')}
|
||||
addReaction={addReaction}
|
||||
removeReaction={removeReaction}
|
||||
emojiMap={emojiMap}
|
||||
/> */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AnnouncementsPanel = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
// const emojiMap = useAppSelector(state => customEmojiMap(state));
|
||||
const [index, setIndex] = useState(0);
|
||||
|
||||
const announcements = useAppSelector((state) => state.announcements.items);
|
||||
|
||||
const addReaction = (id: string, name: string) => dispatch(addReaction(id, name));
|
||||
const removeReaction = (id: string, name: string) => dispatch(removeReaction(id, name));
|
||||
|
||||
if (announcements.size === 0) return null;
|
||||
|
||||
const handleChangeIndex = (index: number) => {
|
||||
setIndex(index % announcements.size);
|
||||
};
|
||||
|
||||
return (
|
||||
<Widget title={<FormattedMessage id='announcements.title' defaultMessage='Announcements' />}>
|
||||
<Card className='relative' size='lg' variant='rounded'>
|
||||
<ReactSwipeableViews animateHeight index={index} onChangeIndex={handleChangeIndex}>
|
||||
{announcements.map((announcement) => (
|
||||
<Announcement
|
||||
key={announcement.id}
|
||||
announcement={announcement}
|
||||
// emojiMap={emojiMap}
|
||||
addReaction={addReaction}
|
||||
removeReaction={removeReaction}
|
||||
// selected={index === idx}
|
||||
// disabled={disableSwiping}
|
||||
/>
|
||||
)).reverse()}
|
||||
</ReactSwipeableViews>
|
||||
|
||||
<HStack space={2} alignItems='center' justifyContent='center' className='relative'>
|
||||
{announcements.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
tabIndex={0}
|
||||
onClick={() => setIndex(i)}
|
||||
className={classNames({
|
||||
'w-2 h-2 rounded-full focus:ring-primary-600 focus:ring-2 focus:ring-offset-2': true,
|
||||
'bg-gray-200 hover:bg-gray-300': i !== index,
|
||||
'bg-primary-600': i === index,
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</HStack>
|
||||
</Card>
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnnouncementsPanel;
|
|
@ -523,5 +523,5 @@ export function FamiliarFollowersModal() {
|
|||
}
|
||||
|
||||
export function AnnouncementsPanel() {
|
||||
return import(/* webpackChunkName: "features/announcements" */'../components/announcements-panel');
|
||||
return import(/* webpackChunkName: "features/announcements" */'../../../components/announcements/announcements-panel');
|
||||
}
|
||||
|
|
|
@ -10,7 +10,9 @@ import {
|
|||
fromJS,
|
||||
} from 'immutable';
|
||||
|
||||
import emojify from 'soapbox/features/emoji/emoji';
|
||||
import { normalizeEmoji } from 'soapbox/normalizers/emoji';
|
||||
import { makeEmojiMap } from 'soapbox/utils/normalizers';
|
||||
|
||||
import { normalizeAnnouncementReaction } from './announcement_reaction';
|
||||
import { normalizeMention } from './mention';
|
||||
|
@ -32,6 +34,9 @@ export const AnnouncementRecord = ImmutableRecord({
|
|||
tags: ImmutableList<ImmutableMap<string, any>>(),
|
||||
emojis: ImmutableList<Emoji>(),
|
||||
updated_at: Date,
|
||||
|
||||
// Internal fields
|
||||
contentHtml: '',
|
||||
});
|
||||
|
||||
const normalizeMentions = (announcement: ImmutableMap<string, any>) => {
|
||||
|
@ -54,12 +59,20 @@ const normalizeEmojis = (announcement: ImmutableMap<string, any>) => {
|
|||
});
|
||||
};
|
||||
|
||||
const normalizeContent = (announcement: ImmutableMap<string, any>) => {
|
||||
const emojiMap = makeEmojiMap(announcement.get('emojis'));
|
||||
const contentHtml = emojify(announcement.get('content'), emojiMap);
|
||||
|
||||
return announcement.set('contentHtml', contentHtml);
|
||||
};
|
||||
|
||||
export const normalizeAnnouncement = (announcement: Record<string, any>) => {
|
||||
return AnnouncementRecord(
|
||||
ImmutableMap(fromJS(announcement)).withMutations(announcement => {
|
||||
normalizeMentions(announcement);
|
||||
normalizeReactions(announcement);
|
||||
normalizeEmojis(announcement);
|
||||
normalizeContent(announcement);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { List as ImmutableList, Record as ImmutableRecord, Set as ImmutableSet } from 'immutable';
|
||||
|
||||
|
||||
import {
|
||||
ANNOUNCEMENTS_FETCH_REQUEST,
|
||||
ANNOUNCEMENTS_FETCH_SUCCESS,
|
||||
|
|
Ładowanie…
Reference in New Issue