From d5a6e978e634d1c00219604ea784115337088df1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sat, 9 Jul 2022 23:47:58 +0200 Subject: [PATCH] Announcements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/components/animated-number.tsx | 63 ++++ .../announcements/announcement-content.tsx | 80 +++++ .../components/announcements/announcement.tsx | 70 +++++ .../announcements/announcements-panel.tsx | 69 +++++ .../components/announcements/emoji.tsx | 51 ++++ .../components/announcements/reaction.tsx | 66 ++++ .../announcements/reactions-bar.tsx | 65 ++++ .../components/emoji_picker_dropdown.js | 22 +- .../emoji_picker_dropdown_container.js | 6 +- .../ui/components/announcements-panel.tsx | 285 ------------------ .../features/ui/util/async-components.ts | 2 +- app/soapbox/normalizers/announcement.ts | 13 + app/soapbox/reducers/announcements.ts | 1 - 13 files changed, 494 insertions(+), 299 deletions(-) create mode 100644 app/soapbox/components/animated-number.tsx create mode 100644 app/soapbox/components/announcements/announcement-content.tsx create mode 100644 app/soapbox/components/announcements/announcement.tsx create mode 100644 app/soapbox/components/announcements/announcements-panel.tsx create mode 100644 app/soapbox/components/announcements/emoji.tsx create mode 100644 app/soapbox/components/announcements/reaction.tsx create mode 100644 app/soapbox/components/announcements/reactions-bar.tsx delete mode 100644 app/soapbox/features/ui/components/announcements-panel.tsx diff --git a/app/soapbox/components/animated-number.tsx b/app/soapbox/components/animated-number.tsx new file mode 100644 index 000000000..0f6908fde --- /dev/null +++ b/app/soapbox/components/animated-number.tsx @@ -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 = ({ value, obfuscate }) => { + const reduceMotion = useSettings().get('reduceMotion'); + + const [direction, setDirection] = useState(1); + const [displayedValue, setDisplayedValue] = useState(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)} : ; + } + + const styles = [{ + key: `${displayedValue}`, + data: displayedValue, + style: { y: spring(0, { damping: 35, stiffness: 400 }) }, + }]; + + return ( + + {items => ( + + {items.map(({ key, data, style }) => ( + 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}>{obfuscate ? obfuscatedCount(data) : } + ))} + + )} + + ); +}; + +export default AnimatedNumber; \ No newline at end of file diff --git a/app/soapbox/components/announcements/announcement-content.tsx b/app/soapbox/components/announcements/announcement-content.tsx new file mode 100644 index 000000000..01c21b39f --- /dev/null +++ b/app/soapbox/components/announcements/announcement-content.tsx @@ -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 = ({ announcement }) => { + const history = useHistory(); + + const node = useRef(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 ( +
+ ); +}; + +export default AnnouncementContent; diff --git a/app/soapbox/components/announcements/announcement.tsx b/app/soapbox/components/announcements/announcement.tsx new file mode 100644 index 000000000..4f8ffa3bc --- /dev/null +++ b/app/soapbox/components/announcements/announcement.tsx @@ -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>; +} + +const Announcement: React.FC = ({ 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 ( +
+ + {hasTimeRange && ( + + + {' '} + - + {' '} + + + )} + + + + + +
+ ); +}; + +export default Announcement; diff --git a/app/soapbox/components/announcements/announcements-panel.tsx b/app/soapbox/components/announcements/announcements-panel.tsx new file mode 100644 index 000000000..ade25057f --- /dev/null +++ b/app/soapbox/components/announcements/announcements-panel.tsx @@ -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>).reduce((map, emoji) => map.set(emoji.get('shortcode')!, emoji), ImmutableMap>())); + +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 ( + }> + + + {announcements.map((announcement) => ( + + )).reverse()} + + {announcements.size > 2 && ( + + {announcements.map((_, i) => ( + + ); +}; + +export default Reaction; diff --git a/app/soapbox/components/announcements/reactions-bar.tsx b/app/soapbox/components/announcements/reactions-bar.tsx new file mode 100644 index 000000000..13e880b9f --- /dev/null +++ b/app/soapbox/components/announcements/reactions-bar.tsx @@ -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; + emojiMap: ImmutableMap>; + addReaction: (id: string, name: string) => void; + removeReaction: (id: string, name: string) => void; +} + +const ReactionsBar: React.FC = ({ 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 ( + + {items => ( +
+ {items.map(({ key, data, style }) => ( + + ))} + + {visibleReactions.size < 8 && } />} +
+ )} +
+ ); +}; + +export default ReactionsBar; diff --git a/app/soapbox/features/compose/components/emoji_picker_dropdown.js b/app/soapbox/features/compose/components/emoji_picker_dropdown.js index ca1fff018..e9af12031 100644 --- a/app/soapbox/features/compose/components/emoji_picker_dropdown.js +++ b/app/soapbox/features/compose/components/emoji_picker_dropdown.js @@ -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 (
- + > + {button || } +
({ 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); } }, }); diff --git a/app/soapbox/features/ui/components/announcements-panel.tsx b/app/soapbox/features/ui/components/announcements-panel.tsx deleted file mode 100644 index d249d84a9..000000000 --- a/app/soapbox/features/ui/components/announcements-panel.tsx +++ /dev/null @@ -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(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 ( -
- ); -}; - -const Emoji = ({ emoji, emojiMap, hovered }) => { - const autoPlayGif = useSettings().get('autoPlayGif'); - - if (unicodeMapping[emoji]) { - const { filename, shortCode } = unicodeMapping[emoji]; - const title = shortCode ? `:${shortCode}:` : ''; - - return ( - {emoji} - ); - } else if (emojiMap.get(emoji)) { - const filename = (autoPlayGif || hovered) ? emojiMap.getIn([emoji, 'url']) : emojiMap.getIn([emoji, 'static_url']); - const shortCode = `:${emoji}:`; - - return ( - {shortCode} - ); - } 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 ( - - ); - - -}; - -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 ( - - {items => ( -
- {items.map(({ key, data, style }) => ( - - ))} - - {visibleReactions.size < 8 && } -
- )} -
- ); -}; - -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 ( -
- - {hasTimeRange && · - } - - - - - {/* */} -
- ); -}; - -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 ( - }> - - - {announcements.map((announcement) => ( - - )).reverse()} - - - - {announcements.map((_, i) => ( -