Merge branch 'hoverstatus' into 'develop'

Status reply hover

See merge request soapbox-pub/soapbox-fe!1550
settings-alert
Alex Gleason 2022-06-21 18:57:22 +00:00
commit e5d6b4fd7b
17 zmienionych plików z 367 dodań i 24 usunięć

Wyświetl plik

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

Wyświetl plik

@ -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<IHoverStatusWrapper> = ({ statusId, children, inline = false, className }) => {
const dispatch = useDispatch();
const ref = useRef<HTMLDivElement>(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 (
<Elem
ref={ref}
className={classNames('hover-status-wrapper', className)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={handleClick}
>
{children}
</Elem>
);
};
export { HoverStatusWrapper as default, showStatusHoverCard };

Wyświetl plik

@ -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<IStatusHoverCard> = ({ visible = true }) => {
const dispatch = useAppDispatch();
const history = useHistory();
const [popperElement, setPopperElement] = useState<HTMLElement | null>(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
<StatusContainer
key={statusId}
id={statusId}
hoverable={false}
hideActionBar
muted
/>
);
};
return (
<div
className={classNames({
'absolute transition-opacity w-[500px] z-50 top-0 left-0': true,
'opacity-100': visible,
'opacity-0 pointer-events-none': !visible,
})}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
onMouseEnter={handleMouseEnter()}
onMouseLeave={handleMouseLeave()}
>
<Card className='relative'>
<CardBody>
{renderStatus(statusId)}
</CardBody>
</Card>
</div>
);
};
export default StatusHoverCard;

Wyświetl plik

@ -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<IStatusReplyMentions> = ({ status }) => {
const StatusReplyMentions: React.FC<IStatusReplyMentions> = ({ status, hoverable = true }) => {
const dispatch = useAppDispatch();
const handleOpenMentionsModal: React.MouseEventHandler<HTMLSpanElement> = (e) => {
@ -46,11 +48,21 @@ const StatusReplyMentions: React.FC<IStatusReplyMentions> = ({ status }) => {
}
// The typical case with a reply-to and a list of mentions.
const accounts = to.slice(0, 2).map(account => (
<HoverRefWrapper key={account.id} accountId={account.id} inline>
const accounts = to.slice(0, 2).map(account => {
const link = (
<Link to={`/@${account.acct}`} className='reply-mentions__account'>@{account.username}</Link>
</HoverRefWrapper>
)).toArray();
);
if (hoverable) {
return (
<HoverRefWrapper key={account.id} accountId={account.id} inline>
{link}
</HoverRefWrapper>
);
} else {
return link;
}
}).toArray();
if (to.size > 2) {
accounts.push(
@ -64,9 +76,26 @@ const StatusReplyMentions: React.FC<IStatusReplyMentions> = ({ status }) => {
<div className='reply-mentions'>
<FormattedMessage
id='reply_mentions.reply'
defaultMessage='Replying to {accounts}'
defaultMessage='<hover>Replying to</hover> {accounts}'
values={{
accounts: <FormattedList type='conjunction' value={accounts} />,
hover: (children: React.ReactNode) => {
if (hoverable) {
return (
<HoverStatusWrapper statusId={status.in_reply_to_id} inline>
<span
key='hoverstatus'
className='hover:underline cursor-pointer'
role='presentation'
>
{children}
</span>
</HoverStatusWrapper>
);
} else {
return children;
}
},
}}
/>
</div>

Wyświetl plik

@ -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<IStatus, IStatusState> {
static defaultProps = {
focusable: true,
hoverable: true,
};
didShowCard = false;
@ -480,6 +483,7 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
action={reblogElement}
hideActions={!reblogElement}
showEdit={!!status.edited_at}
showProfileHoverCard={this.props.hoverable}
/>
</HStack>
</div>
@ -491,7 +495,10 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
</div>
)}
<StatusReplyMentions status={this._properStatus()} />
<StatusReplyMentions
status={this._properStatus()}
hoverable={this.props.hoverable}
/>
<StatusContent
status={status}
@ -512,14 +519,16 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
{poll}
{quote}
<StatusActionBar
status={status}
// @ts-ignore what?
account={account}
emojiSelectorFocused={this.state.emojiSelectorFocused}
handleEmojiSelectorUnfocus={this.handleEmojiSelectorUnfocus}
{...other}
/>
{!this.props.hideActionBar && (
<StatusActionBar
status={status}
// @ts-ignore what?
account={account}
emojiSelectorFocused={this.state.emojiSelectorFocused}
handleEmojiSelectorUnfocus={this.handleEmojiSelectorUnfocus}
{...other}
/>
)}
</div>
</div>
</div>

Wyświetl plik

@ -102,6 +102,7 @@ import {
SidebarMenu,
UploadArea,
ProfileHoverCard,
StatusHoverCard,
Share,
NewStatus,
IntentionalError,
@ -698,6 +699,10 @@ const UI: React.FC = ({ children }) => {
<BundleContainer fetchComponent={ProfileHoverCard}>
{Component => <Component />}
</BundleContainer>
<BundleContainer fetchComponent={StatusHoverCard}>
{Component => <Component />}
</BundleContainer>
</div>
</div>
</HotKeys>

Wyświetl plik

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

Wyświetl plik

@ -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": "<hover>Antwort an</hover> {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?",

Wyświetl plik

@ -1001,7 +1001,7 @@
"id": "reply_mentions.reply_empty"
},
{
"defaultMessage": "Replying to {accounts}{more}",
"defaultMessage": "<hover>Replying to</hover> {accounts}{more}",
"id": "reply_mentions.reply"
},
{
@ -2470,7 +2470,7 @@
"id": "reply_mentions.reply_empty"
},
{
"defaultMessage": "Replying to {accounts}{more}",
"defaultMessage": "<hover>Replying to</hover> {accounts}{more}",
"id": "reply_mentions.reply"
},
{
@ -5609,7 +5609,7 @@
"id": "reply_indicator.cancel"
},
{
"defaultMessage": "Replying to {accounts}{more}",
"defaultMessage": "<hover>Replying to</hover> {accounts}{more}",
"id": "reply_mentions.reply"
},
{

Wyświetl plik

@ -860,7 +860,7 @@
"reply_mentions.account.add": "𐑨𐑛 𐑑 𐑥𐑧𐑯𐑖𐑩𐑯𐑟",
"reply_mentions.account.remove": "𐑮𐑦𐑥𐑵𐑝 𐑓𐑮𐑪𐑥 𐑥𐑧𐑯𐑖𐑩𐑯𐑟",
"reply_mentions.more": "{count} 𐑥𐑹",
"reply_mentions.reply": "𐑮𐑦𐑐𐑤𐑲𐑦𐑙 𐑑 {accounts}",
"reply_mentions.reply": "<hover>𐑮𐑦𐑐𐑤𐑲𐑦𐑙 𐑑</hover> {accounts}",
"reply_mentions.reply_empty": "𐑮𐑦𐑐𐑤𐑲𐑦𐑙 𐑑 𐑐𐑴𐑕𐑑",
"report.block": "𐑚𐑤𐑪𐑒 {target}",
"report.block_hint": "𐑛𐑵 𐑿 𐑷𐑤𐑕𐑴 𐑢𐑪𐑯𐑑 𐑑 𐑚𐑤𐑪𐑒 𐑞𐑦𐑕 𐑩𐑒𐑬𐑯𐑑?",

Wyświetl plik

@ -860,7 +860,7 @@
"reply_mentions.account.add": "הוסף לאזכורים",
"reply_mentions.account.remove": "הסר מהאזכורים",
"reply_mentions.more": "{count} עוד",
"reply_mentions.reply": "משיב ל-{accounts}",
"reply_mentions.reply": "<hover>משיב ל-</hover>{accounts}",
"reply_mentions.reply_empty": "משיב לפוסט",
"report.block": "חסום {target}",
"report.block_hint": "האם גם אתה רוצה לחסום את החשבון הזה?",

Wyświetl plik

@ -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": "<hover>Að svara</hover> {accounts}",
"reply_mentions.reply_empty": "Að svara færslu",
"report.block": "Loka á {target}",
"report.block_hint": "Viltu líka loka á þennan reikning?",

Wyświetl plik

@ -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": "<hover>Risponde a</hover> {accounts}",
"reply_mentions.reply_empty": "Rispondendo al contenuto",
"report.block": "Blocca {target}",
"report.block_hint": "Vuoi anche bloccare questa persona?",

Wyświetl plik

@ -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": "<hover>W odpowiedzi do</hover> {accounts}",
"reply_mentions.reply_empty": "W odpowiedzi na wpis",
"report.block": "Zablokuj {target}",
"report.block_hint": "Czy chcesz też zablokować to konto?",

Wyświetl plik

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

Wyświetl plik

@ -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,

Wyświetl plik

@ -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<HTMLDivElement> | null,
statusId: '',
hovered: false,
});
type State = ReturnType<typeof ReducerRecord>;
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;
}
}