diff --git a/app/soapbox/actions/status-hover-card.ts b/app/soapbox/actions/status-hover-card.ts new file mode 100644 index 000000000..2ce24a745 --- /dev/null +++ b/app/soapbox/actions/status-hover-card.ts @@ -0,0 +1,27 @@ +const STATUS_HOVER_CARD_OPEN = 'STATUS_HOVER_CARD_OPEN'; +const STATUS_HOVER_CARD_UPDATE = 'STATUS_HOVER_CARD_UPDATE'; +const STATUS_HOVER_CARD_CLOSE = 'STATUS_HOVER_CARD_CLOSE'; + +const openStatusHoverCard = (ref: React.MutableRefObject, statusId: string) => ({ + type: STATUS_HOVER_CARD_OPEN, + ref, + statusId, +}); + +const updateStatusHoverCard = () => ({ + type: STATUS_HOVER_CARD_UPDATE, +}); + +const closeStatusHoverCard = (force = false) => ({ + type: STATUS_HOVER_CARD_CLOSE, + force, +}); + +export { + STATUS_HOVER_CARD_OPEN, + STATUS_HOVER_CARD_UPDATE, + STATUS_HOVER_CARD_CLOSE, + openStatusHoverCard, + updateStatusHoverCard, + closeStatusHoverCard, +}; diff --git a/app/soapbox/components/hover-status-wrapper.tsx b/app/soapbox/components/hover-status-wrapper.tsx new file mode 100644 index 000000000..6860762e7 --- /dev/null +++ b/app/soapbox/components/hover-status-wrapper.tsx @@ -0,0 +1,57 @@ +import classNames from 'classnames'; +import { debounce } from 'lodash'; +import React, { useRef } from 'react'; +import { useDispatch } from 'react-redux'; + +import { + openStatusHoverCard, + closeStatusHoverCard, +} from 'soapbox/actions/status-hover-card'; +import { isMobile } from 'soapbox/is_mobile'; + +const showStatusHoverCard = debounce((dispatch, ref, statusId) => { + dispatch(openStatusHoverCard(ref, statusId)); +}, 300); + +interface IHoverStatusWrapper { + statusId: any, + inline: boolean, + className?: string, +} + +/** Makes a status hover card appear when the wrapped element is hovered. */ +export const HoverStatusWrapper: React.FC = ({ statusId, children, inline = false, className }) => { + const dispatch = useDispatch(); + const ref = useRef(null); + const Elem: keyof JSX.IntrinsicElements = inline ? 'span' : 'div'; + + const handleMouseEnter = () => { + if (!isMobile(window.innerWidth)) { + showStatusHoverCard(dispatch, ref, statusId); + } + }; + + const handleMouseLeave = () => { + showStatusHoverCard.cancel(); + setTimeout(() => dispatch(closeStatusHoverCard()), 200); + }; + + const handleClick = () => { + showStatusHoverCard.cancel(); + dispatch(closeStatusHoverCard(true)); + }; + + return ( + + {children} + + ); +}; + +export { HoverStatusWrapper as default, showStatusHoverCard }; diff --git a/app/soapbox/components/status-hover-card.tsx b/app/soapbox/components/status-hover-card.tsx new file mode 100644 index 000000000..dc02af3f4 --- /dev/null +++ b/app/soapbox/components/status-hover-card.tsx @@ -0,0 +1,102 @@ +import classNames from 'classnames'; +import React, { useEffect, useState, useCallback } from 'react'; +import { usePopper } from 'react-popper'; +import { useHistory } from 'react-router-dom'; + +import { + closeStatusHoverCard, + updateStatusHoverCard, +} from 'soapbox/actions/status-hover-card'; +import { fetchStatus } from 'soapbox/actions/statuses'; +import StatusContainer from 'soapbox/containers/status_container'; +import { useAppSelector, useAppDispatch } from 'soapbox/hooks'; + +import { showStatusHoverCard } from './hover-status-wrapper'; +import { Card, CardBody } from './ui'; + +interface IStatusHoverCard { + visible: boolean, +} + +/** Popup status preview that appears when hovering reply to */ +export const StatusHoverCard: React.FC = ({ visible = true }) => { + const dispatch = useAppDispatch(); + const history = useHistory(); + + const [popperElement, setPopperElement] = useState(null); + + const statusId: string | undefined = useAppSelector(state => state.status_hover_card.statusId || undefined); + const status = useAppSelector(state => state.statuses.get(statusId!)); + const targetRef = useAppSelector(state => state.status_hover_card.ref?.current); + + useEffect(() => { + if (statusId && !status) { + dispatch(fetchStatus(statusId)); + } + }, [statusId, status]); + + useEffect(() => { + const unlisten = history.listen(() => { + showStatusHoverCard.cancel(); + dispatch(closeStatusHoverCard()); + }); + + return () => { + unlisten(); + }; + }, []); + + const { styles, attributes } = usePopper(targetRef, popperElement, { + placement: 'top', + }); + + const handleMouseEnter = useCallback((): React.MouseEventHandler => { + return () => { + dispatch(updateStatusHoverCard()); + }; + }, []); + + const handleMouseLeave = useCallback((): React.MouseEventHandler => { + return () => { + dispatch(closeStatusHoverCard(true)); + }; + }, []); + + if (!statusId) return null; + + const renderStatus = (statusId: string) => { + return ( + // @ts-ignore + + ); + }; + + return ( +
+ + + {renderStatus(statusId)} + + +
+ ); +}; + +export default StatusHoverCard; diff --git a/app/soapbox/components/status-reply-mentions.tsx b/app/soapbox/components/status-reply-mentions.tsx index 9f8d890ae..ce0451fda 100644 --- a/app/soapbox/components/status-reply-mentions.tsx +++ b/app/soapbox/components/status-reply-mentions.tsx @@ -3,6 +3,7 @@ import { FormattedList, FormattedMessage } from 'react-intl'; import { Link } from 'react-router-dom'; import { openModal } from 'soapbox/actions/modals'; +import HoverStatusWrapper from 'soapbox/components/hover-status-wrapper'; import HoverRefWrapper from 'soapbox/components/hover_ref_wrapper'; import { useAppDispatch } from 'soapbox/hooks'; @@ -10,9 +11,10 @@ import type { Account, Status } from 'soapbox/types/entities'; interface IStatusReplyMentions { status: Status, + hoverable?: boolean, } -const StatusReplyMentions: React.FC = ({ status }) => { +const StatusReplyMentions: React.FC = ({ status, hoverable = true }) => { const dispatch = useAppDispatch(); const handleOpenMentionsModal: React.MouseEventHandler = (e) => { @@ -46,11 +48,21 @@ const StatusReplyMentions: React.FC = ({ status }) => { } // The typical case with a reply-to and a list of mentions. - const accounts = to.slice(0, 2).map(account => ( - + const accounts = to.slice(0, 2).map(account => { + const link = ( @{account.username} - - )).toArray(); + ); + + if (hoverable) { + return ( + + {link} + + ); + } else { + return link; + } + }).toArray(); if (to.size > 2) { accounts.push( @@ -64,9 +76,26 @@ const StatusReplyMentions: React.FC = ({ status }) => {
, + hover: (children: React.ReactNode) => { + if (hoverable) { + return ( + + + {children} + + + ); + } else { + return children; + } + }, }} />
diff --git a/app/soapbox/components/status.tsx b/app/soapbox/components/status.tsx index 4389b1fc1..f4bd15495 100644 --- a/app/soapbox/components/status.tsx +++ b/app/soapbox/components/status.tsx @@ -93,6 +93,8 @@ interface IStatus extends RouteComponentProps { history: History, featured?: boolean, withDismiss?: boolean, + hideActionBar?: boolean, + hoverable?: boolean, } interface IStatusState { @@ -105,6 +107,7 @@ class Status extends ImmutablePureComponent { static defaultProps = { focusable: true, + hoverable: true, }; didShowCard = false; @@ -480,6 +483,7 @@ class Status extends ImmutablePureComponent { action={reblogElement} hideActions={!reblogElement} showEdit={!!status.edited_at} + showProfileHoverCard={this.props.hoverable} /> @@ -491,7 +495,10 @@ class Status extends ImmutablePureComponent { )} - + { {poll} {quote} - + {!this.props.hideActionBar && ( + + )} diff --git a/app/soapbox/features/ui/index.tsx b/app/soapbox/features/ui/index.tsx index 9f5bfe503..e6c9f19db 100644 --- a/app/soapbox/features/ui/index.tsx +++ b/app/soapbox/features/ui/index.tsx @@ -102,6 +102,7 @@ import { SidebarMenu, UploadArea, ProfileHoverCard, + StatusHoverCard, Share, NewStatus, IntentionalError, @@ -698,6 +699,10 @@ const UI: React.FC = ({ children }) => { {Component => } + + + {Component => } + diff --git a/app/soapbox/features/ui/util/async-components.ts b/app/soapbox/features/ui/util/async-components.ts index 3038de8b7..a6379b130 100644 --- a/app/soapbox/features/ui/util/async-components.ts +++ b/app/soapbox/features/ui/util/async-components.ts @@ -406,6 +406,10 @@ export function ProfileHoverCard() { return import(/* webpackChunkName: "features/ui" */'soapbox/components/profile-hover-card'); } +export function StatusHoverCard() { + return import(/* webpackChunkName: "features/ui" */'soapbox/components/status-hover-card'); +} + export function CryptoDonate() { return import(/* webpackChunkName: "features/crypto_donate" */'../../crypto_donate'); } diff --git a/app/soapbox/locales/de.json b/app/soapbox/locales/de.json index c7527aa36..366373390 100644 --- a/app/soapbox/locales/de.json +++ b/app/soapbox/locales/de.json @@ -860,7 +860,7 @@ "reply_mentions.account.add": "Add to mentions", "reply_mentions.account.remove": "Remove from mentions", "reply_mentions.more": "{count, plural, one {einen weiteren Nutzer} other {# weitere Nutzer}}", - "reply_mentions.reply": "Antwort an {accounts}", + "reply_mentions.reply": "Antwort an {accounts}", "reply_mentions.reply_empty": "Antwort auf einen Beitrag", "report.block": "{target} blockieren.", "report.block_hint": "Soll dieses Konto zusammen mit der Meldung auch gleich blockiert werden?", diff --git a/app/soapbox/locales/defaultMessages.json b/app/soapbox/locales/defaultMessages.json index cc961bb29..1d554d379 100644 --- a/app/soapbox/locales/defaultMessages.json +++ b/app/soapbox/locales/defaultMessages.json @@ -1001,7 +1001,7 @@ "id": "reply_mentions.reply_empty" }, { - "defaultMessage": "Replying to {accounts}{more}", + "defaultMessage": "Replying to {accounts}{more}", "id": "reply_mentions.reply" }, { @@ -2470,7 +2470,7 @@ "id": "reply_mentions.reply_empty" }, { - "defaultMessage": "Replying to {accounts}{more}", + "defaultMessage": "Replying to {accounts}{more}", "id": "reply_mentions.reply" }, { @@ -5609,7 +5609,7 @@ "id": "reply_indicator.cancel" }, { - "defaultMessage": "Replying to {accounts}{more}", + "defaultMessage": "Replying to {accounts}{more}", "id": "reply_mentions.reply" }, { diff --git a/app/soapbox/locales/en-Shaw.json b/app/soapbox/locales/en-Shaw.json index a0c457f11..027855309 100644 --- a/app/soapbox/locales/en-Shaw.json +++ b/app/soapbox/locales/en-Shaw.json @@ -860,7 +860,7 @@ "reply_mentions.account.add": "๐‘จ๐‘› ๐‘‘ ๐‘ฅ๐‘ง๐‘ฏ๐‘–๐‘ฉ๐‘ฏ๐‘Ÿ", "reply_mentions.account.remove": "๐‘ฎ๐‘ฆ๐‘ฅ๐‘ต๐‘ ๐‘“๐‘ฎ๐‘ช๐‘ฅ ๐‘ฅ๐‘ง๐‘ฏ๐‘–๐‘ฉ๐‘ฏ๐‘Ÿ", "reply_mentions.more": "{count} ๐‘ฅ๐‘น", - "reply_mentions.reply": "๐‘ฎ๐‘ฆ๐‘๐‘ค๐‘ฒ๐‘ฆ๐‘™ ๐‘‘ {accounts}", + "reply_mentions.reply": "๐‘ฎ๐‘ฆ๐‘๐‘ค๐‘ฒ๐‘ฆ๐‘™ ๐‘‘ {accounts}", "reply_mentions.reply_empty": "๐‘ฎ๐‘ฆ๐‘๐‘ค๐‘ฒ๐‘ฆ๐‘™ ๐‘‘ ๐‘๐‘ด๐‘•๐‘‘", "report.block": "๐‘š๐‘ค๐‘ช๐‘’ {target}", "report.block_hint": "๐‘›๐‘ต ๐‘ฟ ๐‘ท๐‘ค๐‘•๐‘ด ๐‘ข๐‘ช๐‘ฏ๐‘‘ ๐‘‘ ๐‘š๐‘ค๐‘ช๐‘’ ๐‘ž๐‘ฆ๐‘• ๐‘ฉ๐‘’๐‘ฌ๐‘ฏ๐‘‘?", diff --git a/app/soapbox/locales/he.json b/app/soapbox/locales/he.json index 21717e89f..66d636c1d 100644 --- a/app/soapbox/locales/he.json +++ b/app/soapbox/locales/he.json @@ -860,7 +860,7 @@ "reply_mentions.account.add": "ื”ื•ืกืฃ ืœืื–ื›ื•ืจื™ื", "reply_mentions.account.remove": "ื”ืกืจ ืžื”ืื–ื›ื•ืจื™ื", "reply_mentions.more": "{count} ืขื•ื“", - "reply_mentions.reply": "ืžืฉื™ื‘ ืœ-{accounts}", + "reply_mentions.reply": "ืžืฉื™ื‘ ืœ-{accounts}", "reply_mentions.reply_empty": "ืžืฉื™ื‘ ืœืคื•ืกื˜", "report.block": "ื—ืกื•ื {target}", "report.block_hint": "ื”ืื ื’ื ืืชื” ืจื•ืฆื” ืœื—ืกื•ื ืืช ื”ื—ืฉื‘ื•ืŸ ื”ื–ื”?", diff --git a/app/soapbox/locales/is.json b/app/soapbox/locales/is.json index 49e906eb5..50e1f1b0f 100644 --- a/app/soapbox/locales/is.json +++ b/app/soapbox/locales/is.json @@ -789,7 +789,7 @@ "reply_mentions.account.add": "Bรฆta viรฐ รญ tilvรญsanirnar", "reply_mentions.account.remove": "Fjarlรฆgja รบr tilvรญsunum", "reply_mentions.more": "{count} fleirum", - "reply_mentions.reply": "Aรฐ svara {accounts}", + "reply_mentions.reply": "Aรฐ svara {accounts}", "reply_mentions.reply_empty": "Aรฐ svara fรฆrslu", "report.block": "Loka รก {target}", "report.block_hint": "Viltu lรญka loka รก รพennan reikning?", diff --git a/app/soapbox/locales/it.json b/app/soapbox/locales/it.json index e565fb5d6..6972de6c1 100644 --- a/app/soapbox/locales/it.json +++ b/app/soapbox/locales/it.json @@ -860,7 +860,7 @@ "reply_mentions.account.add": "Add to mentions", "reply_mentions.account.remove": "Remove from mentions", "reply_mentions.more": "ancora {count}", - "reply_mentions.reply": "Risponde a {accounts}", + "reply_mentions.reply": "Risponde a {accounts}", "reply_mentions.reply_empty": "Rispondendo al contenuto", "report.block": "Blocca {target}", "report.block_hint": "Vuoi anche bloccare questa persona?", diff --git a/app/soapbox/locales/pl.json b/app/soapbox/locales/pl.json index 7740949f5..1184a91f6 100644 --- a/app/soapbox/locales/pl.json +++ b/app/soapbox/locales/pl.json @@ -913,7 +913,7 @@ "reply_mentions.account.add": "Dodaj do wspomnianych", "reply_mentions.account.remove": "Usuล„ z wspomnianych", "reply_mentions.more": "{count} wiฤ™cej", - "reply_mentions.reply": "W odpowiedzi do {accounts}", + "reply_mentions.reply": "W odpowiedzi do {accounts}", "reply_mentions.reply_empty": "W odpowiedzi na wpis", "report.block": "Zablokuj {target}", "report.block_hint": "Czy chcesz teลผ zablokowaฤ‡ to konto?", diff --git a/app/soapbox/reducers/__tests__/status-hover-card.test.tsx b/app/soapbox/reducers/__tests__/status-hover-card.test.tsx new file mode 100644 index 000000000..9983b5b14 --- /dev/null +++ b/app/soapbox/reducers/__tests__/status-hover-card.test.tsx @@ -0,0 +1,72 @@ +import { + STATUS_HOVER_CARD_OPEN, + STATUS_HOVER_CARD_CLOSE, + STATUS_HOVER_CARD_UPDATE, +} from 'soapbox/actions/status-hover-card'; + +import reducer, { ReducerRecord } from '../status-hover-card'; + +describe(STATUS_HOVER_CARD_OPEN, () => { + it('sets the ref and statusId', () => { + const ref = { current: document.createElement('div') }; + + const action = { + type: STATUS_HOVER_CARD_OPEN, + ref, + statusId: '1234', + }; + + const result = reducer(undefined, action); + expect(result.ref).toBe(ref); + expect(result.statusId).toBe('1234'); + }); +}); + +describe(STATUS_HOVER_CARD_CLOSE, () => { + it('flushes the state', () => { + const state = ReducerRecord({ + ref: { current: document.createElement('div') }, + statusId: '1234', + }); + + const action = { type: STATUS_HOVER_CARD_CLOSE }; + + const result = reducer(state, action); + expect(result.ref).toBe(null); + expect(result.statusId).toBe(''); + }); + + it('leaves the state alone if hovered', () => { + const state = ReducerRecord({ + ref: { current: document.createElement('div') }, + statusId: '1234', + hovered: true, + }); + + const action = { type: STATUS_HOVER_CARD_CLOSE }; + const result = reducer(state, action); + expect(result).toEqual(state); + }); + + it('action.force flushes the state even if hovered', () => { + const state = ReducerRecord({ + ref: { current: document.createElement('div') }, + statusId: '1234', + hovered: true, + }); + + const action = { type: STATUS_HOVER_CARD_CLOSE, force: true }; + const result = reducer(state, action); + expect(result.ref).toBe(null); + expect(result.statusId).toBe(''); + }); +}); + +describe(STATUS_HOVER_CARD_UPDATE, () => { + it('sets hovered', () => { + const state = ReducerRecord(); + const action = { type: STATUS_HOVER_CARD_UPDATE }; + const result = reducer(state, action); + expect(result.hovered).toBe(true); + }); +}); diff --git a/app/soapbox/reducers/index.ts b/app/soapbox/reducers/index.ts index a05ef47c4..b0d3ee03f 100644 --- a/app/soapbox/reducers/index.ts +++ b/app/soapbox/reducers/index.ts @@ -53,6 +53,7 @@ import security from './security'; import settings from './settings'; import sidebar from './sidebar'; import soapbox from './soapbox'; +import status_hover_card from './status-hover-card'; import status_lists from './status_lists'; import statuses from './statuses'; import suggestions from './suggestions'; @@ -108,6 +109,7 @@ const reducers = { chat_messages, chat_message_lists, profile_hover_card, + status_hover_card, backups, admin_log, security, diff --git a/app/soapbox/reducers/status-hover-card.ts b/app/soapbox/reducers/status-hover-card.ts new file mode 100644 index 000000000..1d3d1da8d --- /dev/null +++ b/app/soapbox/reducers/status-hover-card.ts @@ -0,0 +1,36 @@ +import { Record as ImmutableRecord } from 'immutable'; + +import { + STATUS_HOVER_CARD_OPEN, + STATUS_HOVER_CARD_CLOSE, + STATUS_HOVER_CARD_UPDATE, +} from 'soapbox/actions/status-hover-card'; + +import type { AnyAction } from 'redux'; + +export const ReducerRecord = ImmutableRecord({ + ref: null as React.MutableRefObject | null, + statusId: '', + hovered: false, +}); + +type State = ReturnType; + +export default function statusHoverCard(state: State = ReducerRecord(), action: AnyAction) { + switch (action.type) { + case STATUS_HOVER_CARD_OPEN: + return state.withMutations((state) => { + state.set('ref', action.ref); + state.set('statusId', action.statusId); + }); + case STATUS_HOVER_CARD_UPDATE: + return state.set('hovered', true); + case STATUS_HOVER_CARD_CLOSE: + if (state.hovered === true && !action.force) + return state; + else + return ReducerRecord(); + default: + return state; + } +}