Merge remote-tracking branch 'origin/develop' into multiple-attachments
This commit is contained in:
commit
14efff51ff
|
@ -14,12 +14,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- Events: allow to repost events in event menu.
|
||||
- Groups: Initial support for groups.
|
||||
- Profile: Add RSS link to user profiles.
|
||||
- Reactions: adds support for reacting to chat messages.
|
||||
- Groups: initial support for groups.
|
||||
- Profile: add RSS link to user profiles.
|
||||
- Posts: fix posts filtering.
|
||||
- Chats: reset chat message field height after sending a message.
|
||||
|
||||
### Changed
|
||||
- Chats: improved display of media attachments.
|
||||
- ServiceWorker: switch to a network-first strategy. The "An update is available!" prompt goes away.
|
||||
- Posts: increased font size of focused status in threads.
|
||||
- Posts: let "mute conversation" be clicked from any feed, not just noficiations.
|
||||
- Posts: display all emoji reactions.
|
||||
- Reactions: improved UI of reactions on statuses.
|
||||
|
||||
### Fixed
|
||||
- Chats: media attachments rendering at the wrong size and/or causing the chat to scroll on load.
|
||||
|
|
|
@ -1,13 +1,8 @@
|
|||
import type { DropdownPlacement } from 'soapbox/components/dropdown-menu';
|
||||
|
||||
const DROPDOWN_MENU_OPEN = 'DROPDOWN_MENU_OPEN';
|
||||
const DROPDOWN_MENU_CLOSE = 'DROPDOWN_MENU_CLOSE';
|
||||
|
||||
const openDropdownMenu = (id: number, placement: DropdownPlacement, keyboard: boolean) =>
|
||||
({ type: DROPDOWN_MENU_OPEN, id, placement, keyboard });
|
||||
|
||||
const closeDropdownMenu = (id: number) =>
|
||||
({ type: DROPDOWN_MENU_CLOSE, id });
|
||||
const openDropdownMenu = () => ({ type: DROPDOWN_MENU_OPEN });
|
||||
const closeDropdownMenu = () => ({ type: DROPDOWN_MENU_CLOSE });
|
||||
|
||||
export {
|
||||
DROPDOWN_MENU_OPEN,
|
||||
|
|
|
@ -32,8 +32,8 @@ const getSoapboxConfig = createSelector([
|
|||
}
|
||||
|
||||
// If RGI reacts aren't supported, strip VS16s
|
||||
// // https://git.pleroma.social/pleroma/pleroma/-/issues/2355
|
||||
if (!features.emojiReactsRGI) {
|
||||
// https://git.pleroma.social/pleroma/pleroma/-/issues/2355
|
||||
if (features.emojiReactsNonRGI) {
|
||||
soapboxConfig.set('allowedEmoji', soapboxConfig.allowedEmoji.map(removeVS16s));
|
||||
}
|
||||
});
|
||||
|
|
|
@ -2,7 +2,7 @@ import { getSettings } from 'soapbox/actions/settings';
|
|||
import messages from 'soapbox/locales/messages';
|
||||
import { ChatKeys, IChat, isLastMessage } from 'soapbox/queries/chats';
|
||||
import { queryClient } from 'soapbox/queries/client';
|
||||
import { getUnreadChatsCount, updateChatListItem } from 'soapbox/utils/chats';
|
||||
import { getUnreadChatsCount, updateChatListItem, updateChatMessage } from 'soapbox/utils/chats';
|
||||
import { removePageItem } from 'soapbox/utils/queries';
|
||||
import { play, soundCache } from 'soapbox/utils/sounds';
|
||||
|
||||
|
@ -170,6 +170,9 @@ const connectTimelineStream = (
|
|||
}
|
||||
});
|
||||
break;
|
||||
case 'chat_message.reaction': // TruthSocial
|
||||
updateChatMessage(JSON.parse(data.payload));
|
||||
break;
|
||||
case 'pleroma:follow_relationships_update':
|
||||
dispatch(updateFollowRelationships(JSON.parse(data.payload)));
|
||||
break;
|
||||
|
|
|
@ -1,16 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import { render, screen } from '../../jest/test-helpers';
|
||||
import EmojiSelector from '../emoji-selector';
|
||||
|
||||
describe('<EmojiSelector />', () => {
|
||||
it('renders correctly', () => {
|
||||
const children = <EmojiSelector />;
|
||||
// @ts-ignore
|
||||
children.__proto__.addEventListener = () => {};
|
||||
|
||||
render(children);
|
||||
|
||||
expect(screen.queryAllByRole('button')).toHaveLength(6);
|
||||
});
|
||||
});
|
|
@ -1,4 +1,3 @@
|
|||
import { Portal } from '@reach/portal';
|
||||
import clsx from 'clsx';
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
import React from 'react';
|
||||
|
@ -6,7 +5,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
|||
|
||||
import AutosuggestEmoji, { Emoji } from 'soapbox/components/autosuggest-emoji';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import { Input } from 'soapbox/components/ui';
|
||||
import { Input, Portal } from 'soapbox/components/ui';
|
||||
import AutosuggestAccount from 'soapbox/features/compose/components/autosuggest-account';
|
||||
import { isRtl } from 'soapbox/rtl';
|
||||
import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions';
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { Portal } from '@reach/portal';
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import Textarea from 'react-textarea-autosize';
|
||||
|
||||
import { Portal } from 'soapbox/components/ui';
|
||||
import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions';
|
||||
|
||||
import AutosuggestAccount from '../features/compose/components/autosuggest-account';
|
||||
|
|
|
@ -29,7 +29,7 @@ const CopyableInput: React.FC<ICopyableInput> = ({ value }) => {
|
|||
type='text'
|
||||
value={value}
|
||||
className='rounded-r-none rtl:rounded-l-none rtl:rounded-r-lg'
|
||||
outerClassName='flex-grow'
|
||||
outerClassName='grow'
|
||||
onClick={selectInput}
|
||||
readOnly
|
||||
/>
|
||||
|
|
|
@ -1,420 +0,0 @@
|
|||
import clsx from 'clsx';
|
||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||
import React from 'react';
|
||||
import { spring } from 'react-motion';
|
||||
// @ts-ignore: TODO: upgrade react-overlays. v3.1 and above have TS definitions
|
||||
import Overlay from 'react-overlays/lib/Overlay';
|
||||
import { withRouter, RouteComponentProps } from 'react-router-dom';
|
||||
|
||||
import { Counter, IconButton } from 'soapbox/components/ui';
|
||||
import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
|
||||
import Motion from 'soapbox/features/ui/util/optional-motion';
|
||||
|
||||
import type { Status } from 'soapbox/types/entities';
|
||||
|
||||
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
|
||||
let id = 0;
|
||||
|
||||
export interface MenuItem {
|
||||
action?: React.EventHandler<React.KeyboardEvent | React.MouseEvent>,
|
||||
middleClick?: React.EventHandler<React.MouseEvent>,
|
||||
text: string,
|
||||
href?: string,
|
||||
to?: string,
|
||||
newTab?: boolean,
|
||||
isLogout?: boolean,
|
||||
icon?: string,
|
||||
count?: number,
|
||||
destructive?: boolean,
|
||||
meta?: string,
|
||||
active?: boolean,
|
||||
}
|
||||
|
||||
export type Menu = Array<MenuItem | null>;
|
||||
|
||||
interface IDropdownMenu extends RouteComponentProps {
|
||||
items: Menu,
|
||||
onClose: () => void,
|
||||
style?: React.CSSProperties,
|
||||
placement?: DropdownPlacement,
|
||||
arrowOffsetLeft?: string,
|
||||
arrowOffsetTop?: string,
|
||||
openedViaKeyboard: boolean,
|
||||
}
|
||||
|
||||
interface IDropdownMenuState {
|
||||
mounted: boolean,
|
||||
}
|
||||
|
||||
class DropdownMenu extends React.PureComponent<IDropdownMenu, IDropdownMenuState> {
|
||||
|
||||
static defaultProps: Partial<IDropdownMenu> = {
|
||||
style: {},
|
||||
placement: 'bottom',
|
||||
};
|
||||
|
||||
state = {
|
||||
mounted: false,
|
||||
};
|
||||
|
||||
node: HTMLDivElement | null = null;
|
||||
focusedItem: HTMLAnchorElement | null = null;
|
||||
|
||||
handleDocumentClick = (e: Event) => {
|
||||
if (this.node && !this.node.contains(e.target as Node)) {
|
||||
this.props.onClose();
|
||||
}
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
document.addEventListener('click', this.handleDocumentClick, false);
|
||||
document.addEventListener('keydown', this.handleKeyDown, false);
|
||||
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
||||
if (this.focusedItem && this.props.openedViaKeyboard) {
|
||||
this.focusedItem.focus({ preventScroll: true });
|
||||
}
|
||||
this.setState({ mounted: true });
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('click', this.handleDocumentClick);
|
||||
document.removeEventListener('keydown', this.handleKeyDown);
|
||||
document.removeEventListener('touchend', this.handleDocumentClick);
|
||||
}
|
||||
|
||||
setRef: React.RefCallback<HTMLDivElement> = c => {
|
||||
this.node = c;
|
||||
};
|
||||
|
||||
setFocusRef: React.RefCallback<HTMLAnchorElement> = c => {
|
||||
this.focusedItem = c;
|
||||
};
|
||||
|
||||
handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!this.node) return;
|
||||
|
||||
const items = Array.from(this.node.getElementsByTagName('a'));
|
||||
const index = items.indexOf(document.activeElement as any);
|
||||
let element = null;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
element = items[index + 1] || items[0];
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
element = items[index - 1] || items[items.length - 1];
|
||||
break;
|
||||
case 'Tab':
|
||||
if (e.shiftKey) {
|
||||
element = items[index - 1] || items[items.length - 1];
|
||||
} else {
|
||||
element = items[index + 1] || items[0];
|
||||
}
|
||||
break;
|
||||
case 'Home':
|
||||
element = items[0];
|
||||
break;
|
||||
case 'End':
|
||||
element = items[items.length - 1];
|
||||
break;
|
||||
case 'Escape':
|
||||
this.props.onClose();
|
||||
break;
|
||||
}
|
||||
|
||||
if (element) {
|
||||
element.focus();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
handleItemKeyPress: React.EventHandler<React.KeyboardEvent> = e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
this.handleClick(e);
|
||||
}
|
||||
};
|
||||
|
||||
handleClick: React.EventHandler<React.MouseEvent | React.KeyboardEvent> = e => {
|
||||
const i = Number(e.currentTarget.getAttribute('data-index'));
|
||||
const item = this.props.items[i];
|
||||
if (!item) return;
|
||||
const { action, to } = item;
|
||||
|
||||
this.props.onClose();
|
||||
|
||||
e.stopPropagation();
|
||||
|
||||
if (to) {
|
||||
e.preventDefault();
|
||||
this.props.history.push(to);
|
||||
} else if (typeof action === 'function') {
|
||||
e.preventDefault();
|
||||
action(e);
|
||||
}
|
||||
};
|
||||
|
||||
handleMiddleClick: React.EventHandler<React.MouseEvent> = e => {
|
||||
const i = Number(e.currentTarget.getAttribute('data-index'));
|
||||
const item = this.props.items[i];
|
||||
if (!item) return;
|
||||
const { middleClick } = item;
|
||||
|
||||
this.props.onClose();
|
||||
|
||||
if (e.button === 1 && typeof middleClick === 'function') {
|
||||
e.preventDefault();
|
||||
middleClick(e);
|
||||
}
|
||||
};
|
||||
|
||||
handleAuxClick: React.EventHandler<React.MouseEvent> = e => {
|
||||
if (e.button === 1) {
|
||||
this.handleMiddleClick(e);
|
||||
}
|
||||
};
|
||||
|
||||
renderItem(option: MenuItem | null, i: number): JSX.Element {
|
||||
if (option === null) {
|
||||
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
|
||||
}
|
||||
|
||||
const { text, href, to, newTab, isLogout, icon, count, destructive } = option;
|
||||
|
||||
return (
|
||||
<li className={clsx('dropdown-menu__item truncate', { destructive })} key={`${text}-${i}`}>
|
||||
<a
|
||||
href={href || to || '#'}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
ref={i === 0 ? this.setFocusRef : null}
|
||||
onClick={this.handleClick}
|
||||
onAuxClick={this.handleAuxClick}
|
||||
onKeyPress={this.handleItemKeyPress}
|
||||
data-index={i}
|
||||
target={newTab ? '_blank' : undefined}
|
||||
data-method={isLogout ? 'delete' : undefined}
|
||||
title={text}
|
||||
>
|
||||
{icon && <SvgIcon src={icon} className='mr-3 h-5 w-5 flex-none rtl:ml-3 rtl:mr-0' />}
|
||||
|
||||
<span className='truncate'>{text}</span>
|
||||
|
||||
{count ? (
|
||||
<span className='ml-auto h-5 w-5 flex-none'>
|
||||
<Counter count={count} />
|
||||
</span>
|
||||
) : null}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { items, style, placement, arrowOffsetLeft, arrowOffsetTop } = this.props;
|
||||
const { mounted } = this.state;
|
||||
return (
|
||||
<Motion defaultStyle={{ opacity: 0, scaleX: 1, scaleY: 1 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
|
||||
{({ opacity, scaleX, scaleY }) => (
|
||||
// It should not be transformed when mounting because the resulting
|
||||
// size will be used to determine the coordinate of the menu by
|
||||
// react-overlays
|
||||
<div
|
||||
className={`dropdown-menu ${placement}`}
|
||||
style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : undefined }}
|
||||
ref={this.setRef}
|
||||
data-testid='dropdown-menu'
|
||||
>
|
||||
<div className={`dropdown-menu__arrow ${placement}`} style={{ left: arrowOffsetLeft, top: arrowOffsetTop }} />
|
||||
<ul>
|
||||
{items.map((option, i) => this.renderItem(option, i))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Motion>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const RouterDropdownMenu = withRouter(DropdownMenu);
|
||||
|
||||
export interface IDropdown extends RouteComponentProps {
|
||||
icon?: string,
|
||||
src?: string,
|
||||
items: Menu,
|
||||
size?: number,
|
||||
active?: boolean,
|
||||
pressed?: boolean,
|
||||
title?: string,
|
||||
disabled?: boolean,
|
||||
status?: Status,
|
||||
isUserTouching?: () => boolean,
|
||||
isModalOpen?: boolean,
|
||||
onOpen?: (
|
||||
id: number,
|
||||
onItemClick: React.EventHandler<React.MouseEvent | React.KeyboardEvent>,
|
||||
dropdownPlacement: DropdownPlacement,
|
||||
keyboard: boolean,
|
||||
) => void,
|
||||
onClose?: (id: number) => void,
|
||||
dropdownPlacement?: string,
|
||||
openDropdownId?: number | null,
|
||||
openedViaKeyboard?: boolean,
|
||||
text?: string,
|
||||
onShiftClick?: React.EventHandler<React.MouseEvent | React.KeyboardEvent>,
|
||||
children?: JSX.Element,
|
||||
dropdownMenuStyle?: React.CSSProperties,
|
||||
}
|
||||
|
||||
interface IDropdownState {
|
||||
id: number,
|
||||
open: boolean,
|
||||
}
|
||||
|
||||
export type DropdownPlacement = 'top' | 'bottom';
|
||||
|
||||
class Dropdown extends React.PureComponent<IDropdown, IDropdownState> {
|
||||
|
||||
static defaultProps: Partial<IDropdown> = {
|
||||
title: 'Menu',
|
||||
};
|
||||
|
||||
state = {
|
||||
id: id++,
|
||||
open: false,
|
||||
};
|
||||
|
||||
target: HTMLButtonElement | null = null;
|
||||
activeElement: Element | null = null;
|
||||
|
||||
handleClick: React.EventHandler<React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>> = e => {
|
||||
const { onOpen, onShiftClick, openDropdownId } = this.props;
|
||||
e.stopPropagation();
|
||||
|
||||
if (onShiftClick && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
onShiftClick(e);
|
||||
} else if (this.state.id === openDropdownId) {
|
||||
this.handleClose();
|
||||
} else if (onOpen) {
|
||||
const { top } = e.currentTarget.getBoundingClientRect();
|
||||
const placement: DropdownPlacement = top * 2 < innerHeight ? 'bottom' : 'top';
|
||||
|
||||
onOpen(this.state.id, this.handleItemClick, placement, e.type !== 'click');
|
||||
}
|
||||
};
|
||||
|
||||
handleClose = () => {
|
||||
if (this.activeElement && this.activeElement === this.target) {
|
||||
(this.activeElement as HTMLButtonElement).focus();
|
||||
this.activeElement = null;
|
||||
}
|
||||
|
||||
if (this.props.onClose) {
|
||||
this.props.onClose(this.state.id);
|
||||
}
|
||||
};
|
||||
|
||||
handleMouseDown: React.EventHandler<React.MouseEvent | React.KeyboardEvent> = () => {
|
||||
if (!this.state.open) {
|
||||
this.activeElement = document.activeElement;
|
||||
}
|
||||
};
|
||||
|
||||
handleButtonKeyDown: React.EventHandler<React.KeyboardEvent> = (e) => {
|
||||
switch (e.key) {
|
||||
case ' ':
|
||||
case 'Enter':
|
||||
this.handleMouseDown(e);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
handleKeyPress: React.EventHandler<React.KeyboardEvent<HTMLButtonElement>> = (e) => {
|
||||
switch (e.key) {
|
||||
case ' ':
|
||||
case 'Enter':
|
||||
this.handleClick(e);
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
handleItemClick: React.EventHandler<React.MouseEvent> = e => {
|
||||
const i = Number(e.currentTarget.getAttribute('data-index'));
|
||||
const item = this.props.items[i];
|
||||
if (!item) return;
|
||||
|
||||
const { action, to } = item;
|
||||
|
||||
this.handleClose();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (typeof action === 'function') {
|
||||
action(e);
|
||||
} else if (to) {
|
||||
this.props.history?.push(to);
|
||||
}
|
||||
};
|
||||
|
||||
setTargetRef: React.RefCallback<HTMLButtonElement> = c => {
|
||||
this.target = c;
|
||||
};
|
||||
|
||||
findTarget = () => {
|
||||
return this.target;
|
||||
};
|
||||
|
||||
componentWillUnmount = () => {
|
||||
if (this.state.id === this.props.openDropdownId) {
|
||||
this.handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { src = require('@tabler/icons/dots.svg'), items, title, disabled, dropdownPlacement, openDropdownId, openedViaKeyboard = false, pressed, text, children, dropdownMenuStyle } = this.props;
|
||||
const open = this.state.id === openDropdownId;
|
||||
|
||||
return (
|
||||
<>
|
||||
{children ? (
|
||||
React.cloneElement(children, {
|
||||
disabled,
|
||||
onClick: this.handleClick,
|
||||
onMouseDown: this.handleMouseDown,
|
||||
onKeyDown: this.handleButtonKeyDown,
|
||||
onKeyPress: this.handleKeyPress,
|
||||
ref: this.setTargetRef,
|
||||
})
|
||||
) : (
|
||||
<IconButton
|
||||
disabled={disabled}
|
||||
className={clsx({
|
||||
'text-gray-600 hover:text-gray-700 dark:hover:text-gray-500': true,
|
||||
'text-gray-700 dark:text-gray-500': open,
|
||||
})}
|
||||
title={title}
|
||||
src={src}
|
||||
aria-pressed={pressed}
|
||||
text={text}
|
||||
onClick={this.handleClick}
|
||||
onMouseDown={this.handleMouseDown}
|
||||
onKeyDown={this.handleButtonKeyDown}
|
||||
onKeyPress={this.handleKeyPress}
|
||||
ref={this.setTargetRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Overlay show={open} placement={dropdownPlacement} target={this.findTarget}>
|
||||
<RouterDropdownMenu items={items} onClose={this.handleClose} openedViaKeyboard={openedViaKeyboard} style={dropdownMenuStyle} />
|
||||
</Overlay>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default withRouter(Dropdown);
|
|
@ -0,0 +1,109 @@
|
|||
import clsx from 'clsx';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import { Counter, Icon } from '../ui';
|
||||
|
||||
export interface MenuItem {
|
||||
action?: React.EventHandler<React.KeyboardEvent | React.MouseEvent>
|
||||
active?: boolean
|
||||
count?: number
|
||||
destructive?: boolean
|
||||
href?: string
|
||||
icon?: string
|
||||
meta?: string
|
||||
middleClick?(event: React.MouseEvent): void
|
||||
target?: React.HTMLAttributeAnchorTarget
|
||||
text: string
|
||||
to?: string
|
||||
}
|
||||
|
||||
interface IDropdownMenuItem {
|
||||
index: number
|
||||
item: MenuItem | null
|
||||
onClick?(): void
|
||||
}
|
||||
|
||||
const DropdownMenuItem = ({ index, item, onClick }: IDropdownMenuItem) => {
|
||||
const history = useHistory();
|
||||
|
||||
const itemRef = useRef<HTMLAnchorElement>(null);
|
||||
|
||||
const handleClick: React.EventHandler<React.MouseEvent | React.KeyboardEvent> = (event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
if (!item) return;
|
||||
if (onClick) onClick();
|
||||
|
||||
|
||||
if (item.to) {
|
||||
event.preventDefault();
|
||||
history.push(item.to);
|
||||
} else if (typeof item.action === 'function') {
|
||||
event.preventDefault();
|
||||
item.action(event);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAuxClick: React.EventHandler<React.MouseEvent> = (event) => {
|
||||
if (!item) return;
|
||||
if (onClick) onClick();
|
||||
|
||||
if (event.button === 1 && item.middleClick) {
|
||||
item.middleClick(event);
|
||||
}
|
||||
};
|
||||
|
||||
const handleItemKeyPress: React.EventHandler<React.KeyboardEvent> = (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
handleClick(event);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const firstItem = index === 0;
|
||||
|
||||
if (itemRef.current && firstItem) {
|
||||
itemRef.current.focus();
|
||||
}
|
||||
}, [itemRef.current, index]);
|
||||
|
||||
if (item === null) {
|
||||
return <li className='my-1 mx-2 h-[2px] bg-gray-100 dark:bg-gray-800' />;
|
||||
}
|
||||
|
||||
return (
|
||||
<li className='truncate focus-within:ring-2 focus-within:ring-primary-500'>
|
||||
<a
|
||||
href={item.href || item.to || '#'}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
ref={itemRef}
|
||||
data-index={index}
|
||||
onClick={handleClick}
|
||||
onAuxClick={handleAuxClick}
|
||||
onKeyPress={handleItemKeyPress}
|
||||
target={item.target}
|
||||
title={item.text}
|
||||
className={
|
||||
clsx({
|
||||
'flex px-4 py-2.5 text-sm text-gray-700 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 focus:outline-none cursor-pointer': true,
|
||||
'text-danger-600 dark:text-danger-400': item.destructive,
|
||||
})
|
||||
}
|
||||
>
|
||||
{item.icon && <Icon src={item.icon} className='mr-3 h-5 w-5 flex-none rtl:ml-3 rtl:mr-0' />}
|
||||
|
||||
<span className='truncate'>{item.text}</span>
|
||||
|
||||
{item.count ? (
|
||||
<span className='ml-auto h-5 w-5 flex-none'>
|
||||
<Counter count={item.count} />
|
||||
</span>
|
||||
) : null}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export default DropdownMenuItem;
|
|
@ -0,0 +1,300 @@
|
|||
import { offset, Placement, useFloating } from '@floating-ui/react';
|
||||
import clsx from 'clsx';
|
||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import { closeDropdownMenu, openDropdownMenu } from 'soapbox/actions/dropdown-menu';
|
||||
import { closeModal, openModal } from 'soapbox/actions/modals';
|
||||
import { useAppDispatch } from 'soapbox/hooks';
|
||||
import { isUserTouching } from 'soapbox/is-mobile';
|
||||
|
||||
import { IconButton, Portal } from '../ui';
|
||||
|
||||
import DropdownMenuItem, { MenuItem } from './dropdown-menu-item';
|
||||
|
||||
import type { Status } from 'soapbox/types/entities';
|
||||
|
||||
export type Menu = Array<MenuItem | null>;
|
||||
|
||||
interface IDropdownMenu {
|
||||
children?: React.ReactElement
|
||||
disabled?: boolean
|
||||
items: Menu
|
||||
onClose?: () => void
|
||||
onOpen?: () => void
|
||||
onShiftClick?: React.EventHandler<React.MouseEvent | React.KeyboardEvent>
|
||||
placement?: Placement
|
||||
src?: string
|
||||
status?: Status
|
||||
title?: string
|
||||
}
|
||||
|
||||
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
|
||||
|
||||
const DropdownMenu = (props: IDropdownMenu) => {
|
||||
const {
|
||||
children,
|
||||
disabled,
|
||||
items,
|
||||
onClose,
|
||||
onOpen,
|
||||
onShiftClick,
|
||||
placement = 'top',
|
||||
src = require('@tabler/icons/dots.svg'),
|
||||
title = 'Menu',
|
||||
...filteredProps
|
||||
} = props;
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const history = useHistory();
|
||||
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
|
||||
const activeElement = useRef<Element | null>(null);
|
||||
const target = useRef<Element>(null);
|
||||
|
||||
const isOnMobile = isUserTouching();
|
||||
|
||||
const { x, y, strategy, refs } = useFloating<HTMLButtonElement>({
|
||||
placement,
|
||||
middleware: [offset(12)],
|
||||
});
|
||||
|
||||
const handleClick: React.EventHandler<
|
||||
React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>
|
||||
> = (event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
if (onShiftClick && event.shiftKey) {
|
||||
event.preventDefault();
|
||||
|
||||
onShiftClick(event);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
handleClose();
|
||||
} else {
|
||||
handleOpen();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* On mobile screens, let's replace the Popper dropdown with a Modal.
|
||||
*/
|
||||
const handleOpen = () => {
|
||||
if (isOnMobile) {
|
||||
dispatch(
|
||||
openModal('ACTIONS', {
|
||||
status: filteredProps.status,
|
||||
actions: items,
|
||||
onClick: handleItemClick,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
dispatch(openDropdownMenu());
|
||||
setIsOpen(true);
|
||||
}
|
||||
|
||||
if (onOpen) {
|
||||
onOpen();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (activeElement.current && activeElement.current === target.current) {
|
||||
(activeElement.current as any).focus();
|
||||
activeElement.current = null;
|
||||
}
|
||||
|
||||
if (isOnMobile) {
|
||||
dispatch(closeModal('ACTIONS'));
|
||||
} else {
|
||||
dispatch(closeDropdownMenu());
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseDown: React.EventHandler<React.MouseEvent | React.KeyboardEvent> = () => {
|
||||
if (!isOpen) {
|
||||
activeElement.current = document.activeElement;
|
||||
}
|
||||
};
|
||||
|
||||
const handleButtonKeyDown: React.EventHandler<React.KeyboardEvent> = (event) => {
|
||||
switch (event.key) {
|
||||
case ' ':
|
||||
case 'Enter':
|
||||
handleMouseDown(event);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress: React.EventHandler<React.KeyboardEvent<HTMLButtonElement>> = (event) => {
|
||||
switch (event.key) {
|
||||
case ' ':
|
||||
case 'Enter':
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
handleClick(event);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleItemClick: React.EventHandler<React.MouseEvent> = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const i = Number(event.currentTarget.getAttribute('data-index'));
|
||||
const item = items[i];
|
||||
if (!item) return;
|
||||
|
||||
const { action, to } = item;
|
||||
|
||||
handleClose();
|
||||
|
||||
if (typeof action === 'function') {
|
||||
action(event);
|
||||
} else if (to) {
|
||||
history.push(to);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDocumentClick = (event: Event) => {
|
||||
if (refs.floating.current && !refs.floating.current.contains(event.target as Node)) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!refs.floating.current) return;
|
||||
|
||||
const items = Array.from(refs.floating.current.getElementsByTagName('a'));
|
||||
const index = items.indexOf(document.activeElement as any);
|
||||
|
||||
let element = null;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
element = items[index + 1] || items[0];
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
element = items[index - 1] || items[items.length - 1];
|
||||
break;
|
||||
case 'Tab':
|
||||
if (e.shiftKey) {
|
||||
element = items[index - 1] || items[items.length - 1];
|
||||
} else {
|
||||
element = items[index + 1] || items[0];
|
||||
}
|
||||
break;
|
||||
case 'Home':
|
||||
element = items[0];
|
||||
break;
|
||||
case 'End':
|
||||
element = items[items.length - 1];
|
||||
break;
|
||||
case 'Escape':
|
||||
handleClose();
|
||||
break;
|
||||
}
|
||||
|
||||
if (element) {
|
||||
element.focus();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('click', handleDocumentClick, false);
|
||||
document.addEventListener('keydown', handleKeyDown, false);
|
||||
document.addEventListener('touchend', handleDocumentClick, listenerOptions);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', handleDocumentClick);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.removeEventListener('touchend', handleDocumentClick);
|
||||
};
|
||||
}, [refs.floating.current]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{children ? (
|
||||
React.cloneElement(children, {
|
||||
disabled,
|
||||
onClick: handleClick,
|
||||
onMouseDown: handleMouseDown,
|
||||
onKeyDown: handleButtonKeyDown,
|
||||
onKeyPress: handleKeyPress,
|
||||
ref: refs.setReference,
|
||||
})
|
||||
) : (
|
||||
<IconButton
|
||||
disabled={disabled}
|
||||
className={clsx({
|
||||
'text-gray-600 hover:text-gray-700 dark:hover:text-gray-500': true,
|
||||
'text-gray-700 dark:text-gray-500': isOpen,
|
||||
})}
|
||||
title={title}
|
||||
src={src}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
onKeyDown={handleButtonKeyDown}
|
||||
onKeyPress={handleKeyPress}
|
||||
ref={refs.setReference}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isOpen ? (
|
||||
<Portal>
|
||||
<div
|
||||
data-testid='dropdown-menu'
|
||||
ref={refs.setFloating}
|
||||
className={
|
||||
clsx('relative z-[1001] w-56 rounded-md bg-white py-1 shadow-lg transition-opacity duration-100 focus:outline-none dark:bg-gray-900 dark:ring-2 dark:ring-primary-700', {
|
||||
'opacity-0 pointer-events-none': !isOpen,
|
||||
})
|
||||
}
|
||||
style={{
|
||||
position: strategy,
|
||||
top: y ?? 0,
|
||||
left: x ?? 0,
|
||||
}}
|
||||
>
|
||||
<ul>
|
||||
{items.map((item, idx) => (
|
||||
<DropdownMenuItem
|
||||
key={idx}
|
||||
item={item}
|
||||
index={idx}
|
||||
onClick={handleClose}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Arrow */}
|
||||
<div
|
||||
className={
|
||||
clsx({
|
||||
'absolute w-0 h-0 border-0 border-solid border-transparent': true,
|
||||
'border-t-white dark:border-t-gray-900 -bottom-[5px] -ml-[5px] left-[calc(50%-2.5px)] border-t-[5px] border-x-[5px] border-b-0': placement === 'top',
|
||||
'border-b-white dark:border-b-gray-900 -top-[5px] -ml-[5px] left-[calc(50%-2.5px)] border-t-0 border-x-[5px] border-b-[5px]': placement === 'bottom',
|
||||
'border-b-white dark:border-b-gray-900 -top-[5px] -ml-[5px] left-[92.5%] border-t-0 border-x-[5px] border-b-[5px]': placement === 'bottom-end',
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Portal>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DropdownMenu;
|
|
@ -0,0 +1,3 @@
|
|||
export { default } from './dropdown-menu';
|
||||
export type { Menu } from './dropdown-menu';
|
||||
export type { MenuItem } from './dropdown-menu-item';
|
|
@ -1,142 +0,0 @@
|
|||
// import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import { HotKeys } from 'react-hotkeys';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
|
||||
import { EmojiSelector as RealEmojiSelector } from 'soapbox/components/ui';
|
||||
|
||||
import type { List as ImmutableList } from 'immutable';
|
||||
import type { RootState } from 'soapbox/store';
|
||||
|
||||
const mapStateToProps = (state: RootState) => ({
|
||||
allowedEmoji: getSoapboxConfig(state).allowedEmoji,
|
||||
});
|
||||
|
||||
interface IEmojiSelector {
|
||||
allowedEmoji: ImmutableList<string>,
|
||||
onReact: (emoji: string) => void,
|
||||
onUnfocus: () => void,
|
||||
visible: boolean,
|
||||
focused?: boolean,
|
||||
}
|
||||
|
||||
class EmojiSelector extends ImmutablePureComponent<IEmojiSelector> {
|
||||
|
||||
static defaultProps: Partial<IEmojiSelector> = {
|
||||
onReact: () => { },
|
||||
onUnfocus: () => { },
|
||||
visible: false,
|
||||
};
|
||||
|
||||
node?: HTMLDivElement = undefined;
|
||||
|
||||
handleBlur: React.FocusEventHandler<HTMLDivElement> = e => {
|
||||
const { focused, onUnfocus } = this.props;
|
||||
|
||||
if (focused && (!e.currentTarget || !e.currentTarget.classList.contains('emoji-react-selector__emoji'))) {
|
||||
onUnfocus();
|
||||
}
|
||||
};
|
||||
|
||||
_selectPreviousEmoji = (i: number): void => {
|
||||
if (!this.node) return;
|
||||
|
||||
if (i !== 0) {
|
||||
const button: HTMLButtonElement | null = this.node.querySelector(`.emoji-react-selector__emoji:nth-child(${i})`);
|
||||
button?.focus();
|
||||
} else {
|
||||
const button: HTMLButtonElement | null = this.node.querySelector('.emoji-react-selector__emoji:last-child');
|
||||
button?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
_selectNextEmoji = (i: number) => {
|
||||
if (!this.node) return;
|
||||
|
||||
if (i !== this.props.allowedEmoji.size - 1) {
|
||||
const button: HTMLButtonElement | null = this.node.querySelector(`.emoji-react-selector__emoji:nth-child(${i + 2})`);
|
||||
button?.focus();
|
||||
} else {
|
||||
const button: HTMLButtonElement | null = this.node.querySelector('.emoji-react-selector__emoji:first-child');
|
||||
button?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
handleKeyDown = (i: number): React.KeyboardEventHandler => e => {
|
||||
const { onUnfocus } = this.props;
|
||||
|
||||
switch (e.key) {
|
||||
case 'Tab':
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) this._selectPreviousEmoji(i);
|
||||
else this._selectNextEmoji(i);
|
||||
break;
|
||||
case 'Left':
|
||||
case 'ArrowLeft':
|
||||
this._selectPreviousEmoji(i);
|
||||
break;
|
||||
case 'Right':
|
||||
case 'ArrowRight':
|
||||
this._selectNextEmoji(i);
|
||||
break;
|
||||
case 'Escape':
|
||||
onUnfocus();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
handleReact = (emoji: string) => (): void => {
|
||||
const { onReact, focused, onUnfocus } = this.props;
|
||||
|
||||
onReact(emoji);
|
||||
|
||||
if (focused) {
|
||||
onUnfocus();
|
||||
}
|
||||
};
|
||||
|
||||
handlers = {
|
||||
open: () => { },
|
||||
};
|
||||
|
||||
setRef = (c: HTMLDivElement): void => {
|
||||
this.node = c;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { visible, focused, allowedEmoji, onReact } = this.props;
|
||||
|
||||
return (
|
||||
<HotKeys handlers={this.handlers}>
|
||||
{/*<div
|
||||
className={clsx('flex absolute bg-white dark:bg-gray-500 px-2 py-3 rounded-full shadow-md opacity-0 pointer-events-none duration-100 w-max', { 'opacity-100 pointer-events-auto z-[999]': visible || focused })}
|
||||
onBlur={this.handleBlur}
|
||||
ref={this.setRef}
|
||||
>
|
||||
{allowedEmoji.map((emoji, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className='emoji-react-selector__emoji'
|
||||
onClick={this.handleReact(emoji)}
|
||||
onKeyDown={this.handleKeyDown(i)}
|
||||
tabIndex={(visible || focused) ? 0 : -1}
|
||||
>
|
||||
<Emoji emoji={emoji} />
|
||||
</button>
|
||||
))}
|
||||
</div>*/}
|
||||
<RealEmojiSelector
|
||||
emojis={allowedEmoji.toArray()}
|
||||
onReact={onReact}
|
||||
visible={visible}
|
||||
focused={focused}
|
||||
/>
|
||||
</HotKeys>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(EmojiSelector);
|
|
@ -18,4 +18,4 @@ const OutlineBox: React.FC<IOutlineBox> = ({ children, className, ...rest }) =>
|
|||
);
|
||||
};
|
||||
|
||||
export default OutlineBox;
|
||||
export default OutlineBox;
|
||||
|
|
|
@ -2,15 +2,13 @@ import React from 'react';
|
|||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { Stack } from 'soapbox/components/ui';
|
||||
import DropdownMenu from 'soapbox/containers/dropdown-menu-container';
|
||||
import { useStatContext } from 'soapbox/contexts/stat-context';
|
||||
import ComposeButton from 'soapbox/features/ui/components/compose-button';
|
||||
import { useAppSelector, useFeatures, useOwnAccount, useSettings } from 'soapbox/hooks';
|
||||
|
||||
import DropdownMenu, { Menu } from './dropdown-menu';
|
||||
import SidebarNavigationLink from './sidebar-navigation-link';
|
||||
|
||||
import type { Menu } from 'soapbox/components/dropdown-menu';
|
||||
|
||||
const messages = defineMessages({
|
||||
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
|
||||
bookmarks: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
|
||||
|
@ -185,7 +183,7 @@ const SidebarNavigation = () => {
|
|||
)}
|
||||
|
||||
{menu.length > 0 && (
|
||||
<DropdownMenu items={menu}>
|
||||
<DropdownMenu items={menu} placement='top'>
|
||||
<SidebarNavigationLink
|
||||
icon={require('@tabler/icons/dots-circle-horizontal.svg')}
|
||||
text={<FormattedMessage id='tabs_bar.more' defaultMessage='More' />}
|
||||
|
|
|
@ -14,10 +14,10 @@ import { deleteStatusModal, toggleStatusSensitivityModal } from 'soapbox/actions
|
|||
import { initMuteModal } from 'soapbox/actions/mutes';
|
||||
import { initReport } from 'soapbox/actions/reports';
|
||||
import { deleteStatus, editStatus, toggleMuteStatus } from 'soapbox/actions/statuses';
|
||||
import EmojiButtonWrapper from 'soapbox/components/emoji-button-wrapper';
|
||||
import DropdownMenu from 'soapbox/components/dropdown-menu';
|
||||
import StatusActionButton from 'soapbox/components/status-action-button';
|
||||
import StatusReactionWrapper from 'soapbox/components/status-reaction-wrapper';
|
||||
import { HStack } from 'soapbox/components/ui';
|
||||
import DropdownMenuContainer from 'soapbox/containers/dropdown-menu-container';
|
||||
import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount, useSettings, useSoapboxConfig } from 'soapbox/hooks';
|
||||
import toast from 'soapbox/toast';
|
||||
import { isLocal, isRemote } from 'soapbox/utils/accounts';
|
||||
|
@ -617,19 +617,19 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
/>
|
||||
|
||||
{(features.quotePosts && me) ? (
|
||||
<DropdownMenuContainer
|
||||
<DropdownMenu
|
||||
items={reblogMenu}
|
||||
disabled={!publicStatus}
|
||||
onShiftClick={handleReblogClick}
|
||||
>
|
||||
{reblogButton}
|
||||
</DropdownMenuContainer>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
reblogButton
|
||||
)}
|
||||
|
||||
{features.emojiReacts ? (
|
||||
<EmojiButtonWrapper statusId={status.id}>
|
||||
<StatusReactionWrapper statusId={status.id}>
|
||||
<StatusActionButton
|
||||
title={meEmojiTitle}
|
||||
icon={require('@tabler/icons/heart.svg')}
|
||||
|
@ -640,7 +640,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
emoji={meEmojiReact}
|
||||
text={withLabels ? meEmojiTitle : undefined}
|
||||
/>
|
||||
</EmojiButtonWrapper>
|
||||
</StatusReactionWrapper>
|
||||
) : (
|
||||
<StatusActionButton
|
||||
title={intl.formatMessage(messages.favourite)}
|
||||
|
@ -662,12 +662,12 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
/>
|
||||
)}
|
||||
|
||||
<DropdownMenuContainer items={menu} status={status}>
|
||||
<DropdownMenu items={menu} status={status}>
|
||||
<StatusActionButton
|
||||
title={intl.formatMessage(messages.more)}
|
||||
icon={require('@tabler/icons/dots.svg')}
|
||||
/>
|
||||
</DropdownMenuContainer>
|
||||
</DropdownMenu>
|
||||
</HStack>
|
||||
</HStack>
|
||||
);
|
||||
|
|
|
@ -1,21 +1,19 @@
|
|||
import clsx from 'clsx';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { usePopper } from 'react-popper';
|
||||
|
||||
import { simpleEmojiReact } from 'soapbox/actions/emoji-reacts';
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import { EmojiSelector } from 'soapbox/components/ui';
|
||||
import { EmojiSelector, Portal } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector, useOwnAccount, useSoapboxConfig } from 'soapbox/hooks';
|
||||
import { isUserTouching } from 'soapbox/is-mobile';
|
||||
import { getReactForStatus } from 'soapbox/utils/emoji-reacts';
|
||||
|
||||
interface IEmojiButtonWrapper {
|
||||
interface IStatusReactionWrapper {
|
||||
statusId: string,
|
||||
children: JSX.Element,
|
||||
}
|
||||
|
||||
/** Provides emoji reaction functionality to the underlying button component */
|
||||
const EmojiButtonWrapper: React.FC<IEmojiButtonWrapper> = ({ statusId, children }): JSX.Element | null => {
|
||||
const StatusReactionWrapper: React.FC<IStatusReactionWrapper> = ({ statusId, children }): JSX.Element | null => {
|
||||
const dispatch = useAppDispatch();
|
||||
const ownAccount = useOwnAccount();
|
||||
const status = useAppSelector(state => state.statuses.get(statusId));
|
||||
|
@ -23,24 +21,8 @@ const EmojiButtonWrapper: React.FC<IEmojiButtonWrapper> = ({ statusId, children
|
|||
|
||||
const timeout = useRef<NodeJS.Timeout>();
|
||||
const [visible, setVisible] = useState(false);
|
||||
// const [focused, setFocused] = useState(false);
|
||||
|
||||
// `useRef` won't trigger a re-render, while `useState` does.
|
||||
// https://popper.js.org/react-popper/v2/
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLDivElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: 'top-start',
|
||||
modifiers: [
|
||||
{
|
||||
name: 'offset',
|
||||
options: {
|
||||
offset: [-10, 0],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
@ -116,28 +98,6 @@ const EmojiButtonWrapper: React.FC<IEmojiButtonWrapper> = ({ statusId, children
|
|||
}));
|
||||
};
|
||||
|
||||
// const handleUnfocus: React.EventHandler<React.KeyboardEvent> = () => {
|
||||
// setFocused(false);
|
||||
// };
|
||||
|
||||
const selector = (
|
||||
<div
|
||||
className={clsx('z-50 transition-opacity duration-100', {
|
||||
'opacity-0 pointer-events-none': !visible,
|
||||
})}
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<EmojiSelector
|
||||
emojis={soapboxConfig.allowedEmoji}
|
||||
onReact={handleReact}
|
||||
// focused={focused}
|
||||
// onUnfocus={handleUnfocus}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='relative' onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
|
||||
{React.cloneElement(children, {
|
||||
|
@ -145,9 +105,16 @@ const EmojiButtonWrapper: React.FC<IEmojiButtonWrapper> = ({ statusId, children
|
|||
ref: setReferenceElement,
|
||||
})}
|
||||
|
||||
{selector}
|
||||
<Portal>
|
||||
<EmojiSelector
|
||||
placement='top-start'
|
||||
referenceElement={referenceElement}
|
||||
onReact={handleReact}
|
||||
visible={visible}
|
||||
/>
|
||||
</Portal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmojiButtonWrapper;
|
||||
export default StatusReactionWrapper;
|
|
@ -289,8 +289,10 @@ const Status: React.FC<IStatus> = (props) => {
|
|||
|
||||
return (
|
||||
<HotKeys handlers={minHandlers}>
|
||||
<div className={clsx('status__wrapper', 'status__wrapper--filtered', { focusable })} tabIndex={focusable ? 0 : undefined} ref={node}>
|
||||
<FormattedMessage id='status.filtered' defaultMessage='Filtered' />
|
||||
<div className={clsx('status__wrapper text-center', { focusable })} tabIndex={focusable ? 0 : undefined} ref={node}>
|
||||
<Text theme='muted'>
|
||||
<FormattedMessage id='status.filtered' defaultMessage='Filtered' />
|
||||
</Text>
|
||||
</div>
|
||||
</HotKeys>
|
||||
);
|
||||
|
|
|
@ -4,10 +4,10 @@ import { defineMessages, useIntl } from 'react-intl';
|
|||
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import { deleteStatus } from 'soapbox/actions/statuses';
|
||||
import DropdownMenu from 'soapbox/containers/dropdown-menu-container';
|
||||
import { useAppDispatch, useOwnAccount, useSettings, useSoapboxConfig } from 'soapbox/hooks';
|
||||
import { defaultMediaVisibility } from 'soapbox/utils/status';
|
||||
|
||||
import DropdownMenu from '../dropdown-menu';
|
||||
import { Button, HStack, Text } from '../ui';
|
||||
|
||||
import type { Status as StatusEntity } from 'soapbox/types/entities';
|
||||
|
|
|
@ -2,7 +2,7 @@ import clsx from 'clsx';
|
|||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import DropdownMenu from 'soapbox/containers/dropdown-menu-container';
|
||||
import DropdownMenu from 'soapbox/components/dropdown-menu';
|
||||
|
||||
import HStack from '../hstack/hstack';
|
||||
import Icon from '../icon/icon';
|
||||
|
|
|
@ -8,7 +8,7 @@ const themes = {
|
|||
tertiary:
|
||||
'bg-transparent border-gray-400 dark:border-gray-800 hover:border-primary-300 dark:hover:border-primary-700 focus:border-primary-500 text-gray-900 dark:text-gray-100 focus:ring-primary-500',
|
||||
accent: 'border-transparent bg-secondary-500 hover:bg-secondary-400 focus:bg-secondary-500 text-gray-100 focus:ring-secondary-300',
|
||||
danger: 'border-transparent bg-danger-100 dark:bg-danger-900 text-danger-600 dark:text-danger-200 hover:bg-danger-600 hover:text-gray-100 dark:hover:text-gray-100 dark:hover:bg-danger-500 focus:bg-danger-800 dark:focus:bg-danger-600',
|
||||
danger: 'border-transparent bg-danger-100 dark:bg-danger-900 text-danger-600 dark:text-danger-200 hover:bg-danger-600 hover:text-gray-100 dark:hover:text-gray-100 dark:hover:bg-danger-500 focus:bg-danger-800 focus:text-gray-200 dark:focus:bg-danger-600 dark:focus:text-gray-100',
|
||||
transparent: 'border-transparent text-gray-800 backdrop-blur-sm bg-white/75 hover:bg-white/80',
|
||||
outline: 'border-gray-100 border-2 bg-transparent text-gray-100 hover:bg-white/10',
|
||||
muted: 'border border-solid bg-transparent border-gray-400 dark:border-gray-800 hover:border-primary-300 dark:hover:border-primary-700 focus:border-primary-500 text-gray-900 dark:text-gray-100 focus:ring-primary-500',
|
||||
|
|
|
@ -1,13 +1,17 @@
|
|||
import { Placement } from '@popperjs/core';
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { usePopper } from 'react-popper';
|
||||
|
||||
import { Emoji, HStack } from 'soapbox/components/ui';
|
||||
import { Emoji, HStack, IconButton } from 'soapbox/components/ui';
|
||||
import { Picker } from 'soapbox/features/emoji/emoji-picker';
|
||||
import { useSoapboxConfig } from 'soapbox/hooks';
|
||||
|
||||
interface IEmojiButton {
|
||||
/** Unicode emoji character. */
|
||||
emoji: string,
|
||||
/** Event handler when the emoji is clicked. */
|
||||
onClick: React.EventHandler<React.MouseEvent>,
|
||||
onClick(emoji: string): void
|
||||
/** Extra class name on the <button> element. */
|
||||
className?: string,
|
||||
/** Tab order of the button. */
|
||||
|
@ -16,48 +20,135 @@ interface IEmojiButton {
|
|||
|
||||
/** Clickable emoji button that scales when hovered. */
|
||||
const EmojiButton: React.FC<IEmojiButton> = ({ emoji, className, onClick, tabIndex }): JSX.Element => {
|
||||
const handleClick: React.EventHandler<React.MouseEvent> = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
onClick(emoji);
|
||||
};
|
||||
|
||||
return (
|
||||
<button className={clsx(className)} onClick={onClick} tabIndex={tabIndex}>
|
||||
<Emoji className='h-8 w-8 duration-100 hover:scale-125' emoji={emoji} />
|
||||
<button className={clsx(className)} onClick={handleClick} tabIndex={tabIndex}>
|
||||
<Emoji className='h-6 w-6 duration-100 hover:scale-110' emoji={emoji} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
interface IEmojiSelector {
|
||||
/** List of Unicode emoji characters. */
|
||||
emojis: Iterable<string>,
|
||||
onClose?(): void
|
||||
/** Event handler when an emoji is clicked. */
|
||||
onReact: (emoji: string) => void,
|
||||
onReact(emoji: string): void
|
||||
/** Element that triggers the EmojiSelector Popper */
|
||||
referenceElement: HTMLElement | null
|
||||
placement?: Placement
|
||||
/** Whether the selector should be visible. */
|
||||
visible?: boolean,
|
||||
/** Whether the selector should be focused. */
|
||||
focused?: boolean,
|
||||
visible?: boolean
|
||||
/** X/Y offset of the floating picker. */
|
||||
offset?: [number, number]
|
||||
/** Whether to allow any emoji to be chosen. */
|
||||
all?: boolean
|
||||
}
|
||||
|
||||
/** Panel with a row of emoji buttons. */
|
||||
const EmojiSelector: React.FC<IEmojiSelector> = ({ emojis, onReact, visible = false, focused = false }): JSX.Element => {
|
||||
const EmojiSelector: React.FC<IEmojiSelector> = ({
|
||||
referenceElement,
|
||||
onClose,
|
||||
onReact,
|
||||
placement = 'top',
|
||||
visible = false,
|
||||
offset = [-10, 0],
|
||||
all = true,
|
||||
}): JSX.Element => {
|
||||
const soapboxConfig = useSoapboxConfig();
|
||||
|
||||
const handleReact = (emoji: string): React.EventHandler<React.MouseEvent> => {
|
||||
return (e) => {
|
||||
onReact(emoji);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
// `useRef` won't trigger a re-render, while `useState` does.
|
||||
// https://popper.js.org/react-popper/v2/
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (referenceElement?.contains(event.target as Node) || popperElement?.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const { styles, attributes, update } = usePopper(referenceElement, popperElement, {
|
||||
placement,
|
||||
modifiers: [
|
||||
{
|
||||
name: 'offset',
|
||||
options: {
|
||||
offset,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const handleExpand: React.MouseEventHandler = () => {
|
||||
setExpanded(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setExpanded(false);
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [referenceElement]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && update) {
|
||||
update();
|
||||
}
|
||||
}, [visible, update]);
|
||||
|
||||
return (
|
||||
<HStack
|
||||
className={clsx('z-[999] w-max max-w-[100vw] flex-wrap gap-2 rounded-full bg-white p-3 shadow-md dark:bg-gray-900')}
|
||||
<div
|
||||
className={clsx('z-50 transition-opacity duration-100', {
|
||||
'opacity-0 pointer-events-none': !visible,
|
||||
})}
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
{Array.from(emojis).map((emoji, i) => (
|
||||
<EmojiButton
|
||||
key={i}
|
||||
emoji={emoji}
|
||||
onClick={handleReact(emoji)}
|
||||
tabIndex={(visible || focused) ? 0 : -1}
|
||||
{expanded ? (
|
||||
<Picker
|
||||
set='twitter'
|
||||
backgroundImageFn={() => require('emoji-datasource/img/twitter/sheets/32.png')}
|
||||
onClick={(emoji: any) => onReact(emoji.native)}
|
||||
/>
|
||||
))}
|
||||
</HStack>
|
||||
) : (
|
||||
<HStack
|
||||
className={clsx('z-[999] flex w-max max-w-[100vw] flex-wrap space-x-3 rounded-full bg-white px-3 py-2.5 shadow-lg focus:outline-none dark:bg-gray-900 dark:ring-2 dark:ring-primary-700')}
|
||||
>
|
||||
{Array.from(soapboxConfig.allowedEmoji).map((emoji, i) => (
|
||||
<EmojiButton
|
||||
key={i}
|
||||
emoji={emoji}
|
||||
onClick={onReact}
|
||||
tabIndex={visible ? 0 : -1}
|
||||
/>
|
||||
))}
|
||||
|
||||
{all && (
|
||||
<IconButton
|
||||
className='text-gray-600 hover:text-gray-600 dark:hover:text-white'
|
||||
src={require('@tabler/icons/dots.svg')}
|
||||
onClick={handleExpand}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -15,6 +15,8 @@ interface IIconButton extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|||
transparent?: boolean,
|
||||
/** Predefined styles to display for the button. */
|
||||
theme?: 'seamless' | 'outlined',
|
||||
/** Override the data-testid */
|
||||
'data-testid'?: string
|
||||
}
|
||||
|
||||
/** A clickable icon. */
|
||||
|
@ -31,7 +33,7 @@ const IconButton = React.forwardRef((props: IIconButton, ref: React.ForwardedRef
|
|||
'opacity-50': filteredProps.disabled,
|
||||
}, className)}
|
||||
{...filteredProps}
|
||||
data-testid='icon-button'
|
||||
data-testid={filteredProps['data-testid'] || 'icon-button'}
|
||||
>
|
||||
<SvgIcon src={src} className={iconClassName} />
|
||||
|
||||
|
|
|
@ -38,6 +38,7 @@ export {
|
|||
} from './menu/menu';
|
||||
export { default as Modal } from './modal/modal';
|
||||
export { default as PhoneInput } from './phone-input/phone-input';
|
||||
export { default as Portal } from './portal/portal';
|
||||
export { default as ProgressBar } from './progress-bar/progress-bar';
|
||||
export { default as RadioButton } from './radio-button/radio-button';
|
||||
export { default as Select } from './select/select';
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
import React, { useLayoutEffect, useState } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
interface IPortal {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Portal
|
||||
*/
|
||||
const Portal: React.FC<IPortal> = ({ children }) => {
|
||||
const [isRendered, setIsRendered] = useState<boolean>(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setIsRendered(true);
|
||||
}, []);
|
||||
|
||||
|
||||
if (!isRendered) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
ReactDOM.createPortal(
|
||||
children,
|
||||
document.getElementById('soapbox') as HTMLDivElement,
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default Portal;
|
|
@ -142,4 +142,4 @@ const Toast = (props: IToast) => {
|
|||
);
|
||||
};
|
||||
|
||||
export default Toast;
|
||||
export default Toast;
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import { Portal } from '@reach/portal';
|
||||
import { TooltipPopup, useTooltip } from '@reach/tooltip';
|
||||
import React from 'react';
|
||||
|
||||
import Portal from '../portal/portal';
|
||||
|
||||
import './tooltip.css';
|
||||
|
||||
interface ITooltip {
|
||||
|
|
|
@ -1,38 +0,0 @@
|
|||
import { connect } from 'react-redux';
|
||||
|
||||
import { openDropdownMenu, closeDropdownMenu } from '../actions/dropdown-menu';
|
||||
import { openModal, closeModal } from '../actions/modals';
|
||||
import DropdownMenu from '../components/dropdown-menu';
|
||||
import { isUserTouching } from '../is-mobile';
|
||||
|
||||
import type { Dispatch } from 'redux';
|
||||
import type { DropdownPlacement, IDropdown } from 'soapbox/components/dropdown-menu';
|
||||
import type { RootState } from 'soapbox/store';
|
||||
|
||||
const mapStateToProps = (state: RootState) => ({
|
||||
isModalOpen: Boolean(state.modals.size && state.modals.last()!.modalType === 'ACTIONS'),
|
||||
dropdownPlacement: state.dropdown_menu.placement,
|
||||
openDropdownId: state.dropdown_menu.openId,
|
||||
openedViaKeyboard: state.dropdown_menu.keyboard,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch: Dispatch, { status, items }: Partial<IDropdown>) => ({
|
||||
onOpen(
|
||||
id: number,
|
||||
onItemClick: React.EventHandler<React.MouseEvent | React.KeyboardEvent>,
|
||||
dropdownPlacement: DropdownPlacement,
|
||||
keyboard: boolean,
|
||||
) {
|
||||
dispatch(isUserTouching() ? openModal('ACTIONS', {
|
||||
status,
|
||||
actions: items,
|
||||
onClick: onItemClick,
|
||||
}) : openDropdownMenu(id, dropdownPlacement, keyboard));
|
||||
},
|
||||
onClose(id: number) {
|
||||
dispatch(closeModal('ACTIONS'));
|
||||
dispatch(closeDropdownMenu(id));
|
||||
},
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(DropdownMenu);
|
|
@ -6,8 +6,7 @@ import { makeGetStatus } from 'soapbox/selectors';
|
|||
|
||||
interface IStatusContainer extends Omit<IStatus, 'status'> {
|
||||
id: string,
|
||||
/** @deprecated Unused. */
|
||||
contextType?: any,
|
||||
contextType?: string,
|
||||
/** @deprecated Unused. */
|
||||
otherAccounts?: any,
|
||||
/** @deprecated Unused. */
|
||||
|
@ -21,10 +20,10 @@ interface IStatusContainer extends Omit<IStatus, 'status'> {
|
|||
* @deprecated Use the Status component directly.
|
||||
*/
|
||||
const StatusContainer: React.FC<IStatusContainer> = (props) => {
|
||||
const { id, ...rest } = props;
|
||||
const { id, contextType, ...rest } = props;
|
||||
|
||||
const getStatus = useCallback(makeGetStatus(), []);
|
||||
const status = useAppSelector(state => getStatus(state, { id }));
|
||||
const status = useAppSelector(state => getStatus(state, { id, contextType }));
|
||||
|
||||
if (status) {
|
||||
return <Status status={status} {...rest} />;
|
||||
|
|
|
@ -5,7 +5,7 @@ import { AxiosError } from 'axios';
|
|||
import { List as ImmutableList } from 'immutable';
|
||||
import React from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import { Link, useHistory } from 'react-router-dom';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import { blockAccount, followAccount, pinAccount, removeFromFollowers, unblockAccount, unmuteAccount, unpinAccount } from 'soapbox/actions/accounts';
|
||||
import { mentionCompose, directCompose } from 'soapbox/actions/compose';
|
||||
|
@ -16,9 +16,9 @@ import { initReport } from 'soapbox/actions/reports';
|
|||
import { setSearchAccount } from 'soapbox/actions/search';
|
||||
import { getSettings } from 'soapbox/actions/settings';
|
||||
import Badge from 'soapbox/components/badge';
|
||||
import DropdownMenu, { Menu } from 'soapbox/components/dropdown-menu';
|
||||
import StillImage from 'soapbox/components/still-image';
|
||||
import { Avatar, HStack, IconButton, Menu, MenuButton, MenuDivider, MenuItem, MenuLink, MenuList } from 'soapbox/components/ui';
|
||||
import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
|
||||
import { Avatar, HStack, IconButton } from 'soapbox/components/ui';
|
||||
import MovedNote from 'soapbox/features/account-timeline/components/moved-note';
|
||||
import ActionButton from 'soapbox/features/ui/components/action-button';
|
||||
import SubscriptionButton from 'soapbox/features/ui/components/subscription-button';
|
||||
|
@ -31,8 +31,6 @@ import { Account } from 'soapbox/types/entities';
|
|||
import { isDefaultHeader, isLocal, isRemote } from 'soapbox/utils/accounts';
|
||||
import { MASTODON, parseVersion } from 'soapbox/utils/features';
|
||||
|
||||
import type { Menu as MenuType } from 'soapbox/components/dropdown-menu';
|
||||
|
||||
const messages = defineMessages({
|
||||
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
|
||||
linkVerifiedOn: { id: 'account.link_verified_on', defaultMessage: 'Ownership of this link was checked on {date}' },
|
||||
|
@ -275,7 +273,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
|
|||
};
|
||||
|
||||
const makeMenu = () => {
|
||||
const menu: MenuType = [];
|
||||
const menu: Menu = [];
|
||||
|
||||
if (!account) {
|
||||
return [];
|
||||
|
@ -645,39 +643,15 @@ const Header: React.FC<IHeader> = ({ account }) => {
|
|||
{renderShareButton()}
|
||||
|
||||
{menu.length > 0 && (
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
<DropdownMenu items={menu} placement='bottom-end'>
|
||||
<IconButton
|
||||
src={require('@tabler/icons/dots.svg')}
|
||||
theme='outlined'
|
||||
className='px-2'
|
||||
iconClassName='w-4 h-4'
|
||||
children={null}
|
||||
/>
|
||||
|
||||
<MenuList className='w-56'>
|
||||
{menu.map((menuItem, idx) => {
|
||||
if (typeof menuItem?.text === 'undefined') {
|
||||
return <MenuDivider key={idx} />;
|
||||
} else {
|
||||
const Comp = (menuItem.action ? MenuItem : MenuLink) as any;
|
||||
const itemProps = menuItem.action ? { onSelect: menuItem.action } : { to: menuItem.to, as: Link, target: menuItem.newTab ? '_blank' : '_self' };
|
||||
|
||||
return (
|
||||
<Comp key={idx} {...itemProps} className='group'>
|
||||
<HStack space={3} alignItems='center'>
|
||||
{menuItem.icon && (
|
||||
<SvgIcon src={menuItem.icon} className='h-5 w-5 flex-none text-gray-400 group-hover:text-gray-500' />
|
||||
)}
|
||||
|
||||
<div className='truncate'>{menuItem.text}</div>
|
||||
</HStack>
|
||||
</Comp>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
<ActionButton account={account} />
|
||||
|
|
|
@ -2,10 +2,10 @@ import React from 'react';
|
|||
import { useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import { deleteStatusModal } from 'soapbox/actions/moderation';
|
||||
import DropdownMenu from 'soapbox/components/dropdown-menu';
|
||||
import StatusContent from 'soapbox/components/status-content';
|
||||
import StatusMedia from 'soapbox/components/status-media';
|
||||
import { HStack, Stack } from 'soapbox/components/ui';
|
||||
import DropdownMenu from 'soapbox/containers/dropdown-menu-container';
|
||||
import { useAppDispatch } from 'soapbox/hooks';
|
||||
|
||||
import type { AdminReport, Status } from 'soapbox/types/entities';
|
||||
|
|
|
@ -4,9 +4,9 @@ import { Link } from 'react-router-dom';
|
|||
|
||||
import { closeReports } from 'soapbox/actions/admin';
|
||||
import { deactivateUserModal, deleteUserModal } from 'soapbox/actions/moderation';
|
||||
import DropdownMenu from 'soapbox/components/dropdown-menu';
|
||||
import HoverRefWrapper from 'soapbox/components/hover-ref-wrapper';
|
||||
import { Accordion, Avatar, Button, Stack, HStack, Text } from 'soapbox/components/ui';
|
||||
import DropdownMenu from 'soapbox/containers/dropdown-menu-container';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { makeGetReport } from 'soapbox/selectors';
|
||||
import toast from 'soapbox/toast';
|
||||
|
|
|
@ -4,9 +4,8 @@ import { defineMessages, useIntl } from 'react-intl';
|
|||
|
||||
import { expandUserIndex, fetchUserIndex, setUserIndexQuery } from 'soapbox/actions/admin';
|
||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||
import { Column } from 'soapbox/components/ui';
|
||||
import { Column, Input } from 'soapbox/components/ui';
|
||||
import AccountContainer from 'soapbox/containers/account-container';
|
||||
import { SimpleForm, TextInput } from 'soapbox/features/forms';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
const messages = defineMessages({
|
||||
|
@ -22,7 +21,7 @@ const UserIndex: React.FC = () => {
|
|||
const { isLoading, items, total, query, next } = useAppSelector((state) => state.admin_user_index);
|
||||
|
||||
const handleLoadMore = () => {
|
||||
dispatch(expandUserIndex());
|
||||
if (!isLoading) dispatch(expandUserIndex());
|
||||
};
|
||||
|
||||
const updateQuery = useCallback(debounce(() => {
|
||||
|
@ -31,25 +30,25 @@ const UserIndex: React.FC = () => {
|
|||
|
||||
const handleQueryChange: React.ChangeEventHandler<HTMLInputElement> = e => {
|
||||
dispatch(setUserIndexQuery(e.target.value));
|
||||
updateQuery();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updateQuery();
|
||||
}, [query]);
|
||||
}, []);
|
||||
|
||||
const hasMore = items.count() < total && next !== null;
|
||||
|
||||
const hasMore = items.count() < total && !!next;
|
||||
|
||||
const showLoading = isLoading && items.isEmpty();
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)}>
|
||||
<SimpleForm style={{ paddingBottom: 0 }}>
|
||||
<TextInput
|
||||
value={query}
|
||||
onChange={handleQueryChange}
|
||||
placeholder={intl.formatMessage(messages.searchPlaceholder)}
|
||||
/>
|
||||
</SimpleForm>
|
||||
<Input
|
||||
value={query}
|
||||
onChange={handleQueryChange}
|
||||
placeholder={intl.formatMessage(messages.searchPlaceholder)}
|
||||
/>
|
||||
<ScrollableList
|
||||
scrollKey='user-index'
|
||||
hasMore={hasMore}
|
||||
|
|
|
@ -581,4 +581,4 @@ const Audio: React.FC<IAudio> = (props) => {
|
|||
);
|
||||
};
|
||||
|
||||
export default Audio;
|
||||
export default Audio;
|
||||
|
|
|
@ -2,13 +2,15 @@ import userEvent from '@testing-library/user-event';
|
|||
import React from 'react';
|
||||
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||
|
||||
|
||||
import { ChatContext } from 'soapbox/contexts/chat-context';
|
||||
import { normalizeInstance } from 'soapbox/normalizers';
|
||||
import { normalizeChatMessage, normalizeInstance } from 'soapbox/normalizers';
|
||||
import { IAccount } from 'soapbox/queries/accounts';
|
||||
import { ChatMessage } from 'soapbox/types/entities';
|
||||
|
||||
import { __stub } from '../../../../api';
|
||||
import { queryClient, render, rootState, screen, waitFor } from '../../../../jest/test-helpers';
|
||||
import { IChat, IChatMessage } from '../../../../queries/chats';
|
||||
import { IChat } from '../../../../queries/chats';
|
||||
import ChatMessageList from '../chat-message-list';
|
||||
|
||||
const chat: IChat = {
|
||||
|
@ -22,6 +24,7 @@ const chat: IChat = {
|
|||
avatar_static: 'avatar',
|
||||
display_name: 'my name',
|
||||
} as IAccount,
|
||||
chat_type: 'direct',
|
||||
created_at: '2020-06-10T02:05:06.000Z',
|
||||
created_by_account: '2',
|
||||
discarded_at: null,
|
||||
|
@ -33,25 +36,29 @@ const chat: IChat = {
|
|||
unread: 5,
|
||||
};
|
||||
|
||||
const chatMessages: IChatMessage[] = [
|
||||
{
|
||||
const chatMessages: ChatMessage[] = [
|
||||
normalizeChatMessage({
|
||||
account_id: '1',
|
||||
chat_id: '14',
|
||||
content: 'this is the first chat',
|
||||
created_at: '2022-09-09T16:02:26.186Z',
|
||||
emoji_reactions: null,
|
||||
expiration: 1209600,
|
||||
id: '1',
|
||||
unread: false,
|
||||
pending: false,
|
||||
},
|
||||
{
|
||||
}),
|
||||
normalizeChatMessage({
|
||||
account_id: '2',
|
||||
chat_id: '14',
|
||||
content: 'this is the second chat',
|
||||
created_at: '2022-09-09T16:04:26.186Z',
|
||||
emoji_reactions: null,
|
||||
expiration: 1209600,
|
||||
id: '2',
|
||||
unread: true,
|
||||
pending: false,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
// Mock scrollIntoView function.
|
||||
|
|
|
@ -0,0 +1,78 @@
|
|||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
|
||||
import { normalizeEmojiReaction } from 'soapbox/normalizers/emoji-reaction';
|
||||
|
||||
import { render, screen } from '../../../../jest/test-helpers';
|
||||
import ChatMessageReaction from '../chat-message-reaction';
|
||||
|
||||
const emojiReaction = normalizeEmojiReaction({
|
||||
name: '👍',
|
||||
count: 1,
|
||||
me: false,
|
||||
});
|
||||
|
||||
describe('<ChatMessageReaction />', () => {
|
||||
it('renders properly', () => {
|
||||
render(
|
||||
<ChatMessageReaction
|
||||
emojiReaction={emojiReaction}
|
||||
onAddReaction={jest.fn()}
|
||||
onRemoveReaction={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('img').getAttribute('alt')).toEqual(emojiReaction.name);
|
||||
expect(screen.getByRole('button')).toHaveTextContent(String(emojiReaction.count));
|
||||
});
|
||||
|
||||
it('triggers the "onAddReaction" function', async () => {
|
||||
const onAddFn = jest.fn();
|
||||
const onRemoveFn = jest.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<ChatMessageReaction
|
||||
emojiReaction={emojiReaction}
|
||||
onAddReaction={onAddFn}
|
||||
onRemoveReaction={onRemoveFn}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(onAddFn).not.toBeCalled();
|
||||
expect(onRemoveFn).not.toBeCalled();
|
||||
|
||||
await user.click(screen.getByRole('button'));
|
||||
|
||||
// add function triggered
|
||||
expect(onAddFn).toBeCalled();
|
||||
expect(onRemoveFn).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('triggers the "onRemoveReaction" function', async () => {
|
||||
const onAddFn = jest.fn();
|
||||
const onRemoveFn = jest.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<ChatMessageReaction
|
||||
emojiReaction={normalizeEmojiReaction({
|
||||
name: '👍',
|
||||
count: 1,
|
||||
me: true,
|
||||
})}
|
||||
onAddReaction={onAddFn}
|
||||
onRemoveReaction={onRemoveFn}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(onAddFn).not.toBeCalled();
|
||||
expect(onRemoveFn).not.toBeCalled();
|
||||
|
||||
await user.click(screen.getByRole('button'));
|
||||
|
||||
// remove function triggered
|
||||
expect(onAddFn).not.toBeCalled();
|
||||
expect(onRemoveFn).toBeCalled();
|
||||
});
|
||||
});
|
|
@ -42,6 +42,7 @@ interface IChatComposer extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaEl
|
|||
errorMessage: string | undefined
|
||||
onSelectFile: (files: FileList, intl: IntlShape) => void
|
||||
resetFileKey: number | null
|
||||
resetContentKey: number | null
|
||||
attachments?: Attachment[]
|
||||
onDeleteAttachment?: (i: number) => void
|
||||
isUploading?: boolean
|
||||
|
@ -58,6 +59,7 @@ const ChatComposer = React.forwardRef<HTMLTextAreaElement | null, IChatComposer>
|
|||
disabled = false,
|
||||
onSelectFile,
|
||||
resetFileKey,
|
||||
resetContentKey,
|
||||
onPaste,
|
||||
attachments = [],
|
||||
onDeleteAttachment,
|
||||
|
@ -181,6 +183,7 @@ const ChatComposer = React.forwardRef<HTMLTextAreaElement | null, IChatComposer>
|
|||
<Stack grow>
|
||||
<Combobox onSelect={onSelectComboboxOption}>
|
||||
<ComboboxInput
|
||||
key={resetContentKey}
|
||||
as={ChatTextarea}
|
||||
autoFocus
|
||||
ref={ref}
|
||||
|
@ -252,4 +255,4 @@ const ChatComposer = React.forwardRef<HTMLTextAreaElement | null, IChatComposer>
|
|||
);
|
||||
});
|
||||
|
||||
export default ChatComposer;
|
||||
export default ChatComposer;
|
||||
|
|
|
@ -3,10 +3,10 @@ import { defineMessages, useIntl } from 'react-intl';
|
|||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import DropdownMenu from 'soapbox/components/dropdown-menu';
|
||||
import RelativeTimestamp from 'soapbox/components/relative-timestamp';
|
||||
import { Avatar, HStack, IconButton, Stack, Text } from 'soapbox/components/ui';
|
||||
import VerificationBadge from 'soapbox/components/verification-badge';
|
||||
import DropdownMenuContainer from 'soapbox/containers/dropdown-menu-container';
|
||||
import { useChatContext } from 'soapbox/contexts/chat-context';
|
||||
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
|
||||
import { IChat, useChatActions } from 'soapbox/queries/chats';
|
||||
|
@ -115,14 +115,14 @@ const ChatListItem: React.FC<IChatListItemInterface> = ({ chat, onClick }) => {
|
|||
<HStack alignItems='center' space={2}>
|
||||
{features.chatsDelete && (
|
||||
<div className='hidden text-gray-600 hover:text-gray-100 group-hover:block'>
|
||||
<DropdownMenuContainer items={menu}>
|
||||
<DropdownMenu items={menu}>
|
||||
<IconButton
|
||||
src={require('@tabler/icons/dots.svg')}
|
||||
title='Settings'
|
||||
className='text-gray-600 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-500'
|
||||
iconClassName='w-4 h-4'
|
||||
/>
|
||||
</DropdownMenuContainer>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
@ -1,33 +1,17 @@
|
|||
import { useMutation } from '@tanstack/react-query';
|
||||
import clsx from 'clsx';
|
||||
import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
|
||||
import escape from 'lodash/escape';
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { useIntl, defineMessages } from 'react-intl';
|
||||
import { Components, Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
||||
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import { initReport } from 'soapbox/actions/reports';
|
||||
import { Avatar, Button, Divider, HStack, Icon, IconButton, Spinner, Stack, Text } from 'soapbox/components/ui';
|
||||
import DropdownMenuContainer from 'soapbox/containers/dropdown-menu-container';
|
||||
import emojify from 'soapbox/features/emoji/emoji';
|
||||
import { Avatar, Button, Divider, Spinner, Stack, Text } from 'soapbox/components/ui';
|
||||
import PlaceholderChatMessage from 'soapbox/features/placeholder/components/placeholder-chat-message';
|
||||
import Bundle from 'soapbox/features/ui/components/bundle';
|
||||
import { MediaGallery } from 'soapbox/features/ui/util/async-components';
|
||||
import { useAppSelector, useAppDispatch, useOwnAccount, useFeatures } from 'soapbox/hooks';
|
||||
import { normalizeAccount } from 'soapbox/normalizers';
|
||||
import { ChatKeys, IChat, IChatMessage, useChatActions, useChatMessages } from 'soapbox/queries/chats';
|
||||
import { queryClient } from 'soapbox/queries/client';
|
||||
import { stripHTML } from 'soapbox/utils/html';
|
||||
import { onlyEmoji } from 'soapbox/utils/rich-content';
|
||||
import { useAppSelector, useOwnAccount } from 'soapbox/hooks';
|
||||
import { IChat, useChatActions, useChatMessages } from 'soapbox/queries/chats';
|
||||
|
||||
import ChatMessage from './chat-message';
|
||||
import ChatMessageListIntro from './chat-message-list-intro';
|
||||
|
||||
import type { Menu } from 'soapbox/components/dropdown-menu';
|
||||
import type { ChatMessage as ChatMessageEntity } from 'soapbox/types/entities';
|
||||
|
||||
const BIG_EMOJI_LIMIT = 3;
|
||||
|
||||
const messages = defineMessages({
|
||||
today: { id: 'chats.dividers.today', defaultMessage: 'Today' },
|
||||
more: { id: 'chats.actions.more', defaultMessage: 'More' },
|
||||
|
@ -43,7 +27,7 @@ const messages = defineMessages({
|
|||
|
||||
type TimeFormat = 'today' | 'date';
|
||||
|
||||
const timeChange = (prev: IChatMessage, curr: IChatMessage): TimeFormat | null => {
|
||||
const timeChange = (prev: ChatMessageEntity, curr: ChatMessageEntity): TimeFormat | null => {
|
||||
const prevDate = new Date(prev.created_at).getDate();
|
||||
const currDate = new Date(curr.created_at).getDate();
|
||||
const nowDate = new Date().getDate();
|
||||
|
@ -55,10 +39,6 @@ const timeChange = (prev: IChatMessage, curr: IChatMessage): TimeFormat | null =
|
|||
return null;
|
||||
};
|
||||
|
||||
const makeEmojiMap = (record: any) => record.get('emojis', ImmutableList()).reduce((map: ImmutableMap<string, any>, emoji: ImmutableMap<string, any>) => {
|
||||
return map.set(`:${emoji.get('shortcode')}:`, emoji);
|
||||
}, ImmutableMap());
|
||||
|
||||
const START_INDEX = 10000;
|
||||
|
||||
const List: Components['List'] = React.forwardRef((props, ref) => {
|
||||
|
@ -89,19 +69,15 @@ interface IChatMessageList {
|
|||
/** Scrollable list of chat messages. */
|
||||
const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const account = useOwnAccount();
|
||||
const features = useFeatures();
|
||||
|
||||
const lastReadMessageDateString = chat.latest_read_message_by_account?.find((latest) => latest.id === chat.account.id)?.date;
|
||||
const myLastReadMessageDateString = chat.latest_read_message_by_account?.find((latest) => latest.id === account?.id)?.date;
|
||||
const lastReadMessageTimestamp = lastReadMessageDateString ? new Date(lastReadMessageDateString) : null;
|
||||
const myLastReadMessageTimestamp = myLastReadMessageDateString ? new Date(myLastReadMessageDateString) : null;
|
||||
|
||||
const node = useRef<VirtuosoHandle>(null);
|
||||
const [firstItemIndex, setFirstItemIndex] = useState(START_INDEX - 20);
|
||||
|
||||
const { deleteChatMessage, markChatAsRead } = useChatActions(chat.id);
|
||||
const { markChatAsRead } = useChatActions(chat.id);
|
||||
const {
|
||||
data: chatMessages,
|
||||
fetchNextPage,
|
||||
|
@ -115,24 +91,24 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
|
|||
|
||||
const formattedChatMessages = chatMessages || [];
|
||||
|
||||
const me = useAppSelector((state) => state.me);
|
||||
const isBlocked = useAppSelector((state) => state.getIn(['relationships', chat.account.id, 'blocked_by']));
|
||||
|
||||
const handleDeleteMessage = useMutation((chatMessageId: string) => deleteChatMessage(chatMessageId), {
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries(ChatKeys.chatMessages(chat.id));
|
||||
},
|
||||
});
|
||||
|
||||
const lastChatMessage = chatMessages ? chatMessages[chatMessages.length - 1] : null;
|
||||
|
||||
const cachedChatMessages = useMemo(() => {
|
||||
useEffect(() => {
|
||||
if (!chatMessages) {
|
||||
return [];
|
||||
return;
|
||||
}
|
||||
|
||||
const nextFirstItemIndex = START_INDEX - chatMessages.length;
|
||||
setFirstItemIndex(nextFirstItemIndex);
|
||||
}, [lastChatMessage]);
|
||||
|
||||
const buildCachedMessages = () => {
|
||||
if (!chatMessages) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return chatMessages.reduce((acc: any, curr: any, idx: number) => {
|
||||
const lastMessage = formattedChatMessages[idx - 1];
|
||||
|
||||
|
@ -156,32 +132,19 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
|
|||
acc.push(curr);
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
}, [chatMessages?.length, lastChatMessage]);
|
||||
|
||||
const initialTopMostItemIndex = process.env.NODE_ENV === 'test' ? 0 : cachedChatMessages.length - 1;
|
||||
|
||||
const getFormattedTimestamp = (chatMessage: ChatMessageEntity) => {
|
||||
return intl.formatDate(new Date(chatMessage.created_at), {
|
||||
hour12: false,
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
const cachedChatMessages = buildCachedMessages();
|
||||
|
||||
const setBubbleRef = (c: HTMLDivElement) => {
|
||||
if (!c) return;
|
||||
const links = c.querySelectorAll('a[rel="ugc"]');
|
||||
const initialScrollPositionProps = useMemo(() => {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
return {};
|
||||
}
|
||||
|
||||
links.forEach(link => {
|
||||
link.classList.add('chat-link');
|
||||
link.setAttribute('rel', 'ugc nofollow noopener');
|
||||
link.setAttribute('target', '_blank');
|
||||
});
|
||||
};
|
||||
return {
|
||||
initialTopMostItemIndex: cachedChatMessages.length - 1,
|
||||
firstItemIndex: Math.max(0, firstItemIndex),
|
||||
};
|
||||
}, [cachedChatMessages.length, firstItemIndex]);
|
||||
|
||||
const handleStartReached = useCallback(() => {
|
||||
if (hasNextPage && !isFetching) {
|
||||
|
@ -190,212 +153,8 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
|
|||
return false;
|
||||
}, [firstItemIndex, hasNextPage, isFetching]);
|
||||
|
||||
const onOpenMedia = (media: any, index: number) => {
|
||||
dispatch(openModal('MEDIA', { media, index }));
|
||||
};
|
||||
|
||||
const maybeRenderMedia = (chatMessage: ChatMessageEntity) => {
|
||||
if (!chatMessage.media_attachments.size) return null;
|
||||
return (
|
||||
<Bundle fetchComponent={MediaGallery}>
|
||||
{(Component: any) => (
|
||||
<Component
|
||||
media={chatMessage.media_attachments}
|
||||
onOpenMedia={onOpenMedia}
|
||||
visible
|
||||
/>
|
||||
)}
|
||||
</Bundle>
|
||||
);
|
||||
};
|
||||
|
||||
const parsePendingContent = (content: string) => {
|
||||
return escape(content).replace(/(?:\r\n|\r|\n)/g, '<br>');
|
||||
};
|
||||
|
||||
const parseContent = (chatMessage: ChatMessageEntity) => {
|
||||
const content = chatMessage.content || '';
|
||||
const pending = chatMessage.pending;
|
||||
const deleting = chatMessage.deleting;
|
||||
const formatted = (pending && !deleting) ? parsePendingContent(content) : content;
|
||||
const emojiMap = makeEmojiMap(chatMessage);
|
||||
return emojify(formatted, emojiMap.toJS());
|
||||
};
|
||||
|
||||
const renderDivider = (key: React.Key, text: string) => <Divider key={key} text={text} textSize='xs' />;
|
||||
|
||||
const handleCopyText = (chatMessage: ChatMessageEntity) => {
|
||||
if (navigator.clipboard) {
|
||||
const text = stripHTML(chatMessage.content);
|
||||
navigator.clipboard.writeText(text);
|
||||
}
|
||||
};
|
||||
|
||||
const renderMessage = (chatMessage: ChatMessageEntity) => {
|
||||
const content = parseContent(chatMessage);
|
||||
const hiddenEl = document.createElement('div');
|
||||
hiddenEl.innerHTML = content;
|
||||
const isOnlyEmoji = onlyEmoji(hiddenEl, BIG_EMOJI_LIMIT, false);
|
||||
|
||||
const isMyMessage = chatMessage.account_id === me;
|
||||
// did this occur before this time?
|
||||
const isRead = isMyMessage
|
||||
&& lastReadMessageTimestamp
|
||||
&& lastReadMessageTimestamp >= new Date(chatMessage.created_at);
|
||||
|
||||
const menu: Menu = [];
|
||||
|
||||
if (navigator.clipboard && chatMessage.content) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.copy),
|
||||
action: () => handleCopyText(chatMessage),
|
||||
icon: require('@tabler/icons/copy.svg'),
|
||||
});
|
||||
}
|
||||
|
||||
if (isMyMessage) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.delete),
|
||||
action: () => handleDeleteMessage.mutate(chatMessage.id),
|
||||
icon: require('@tabler/icons/trash.svg'),
|
||||
destructive: true,
|
||||
});
|
||||
} else {
|
||||
if (features.reportChats) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.report),
|
||||
action: () => dispatch(initReport(normalizeAccount(chat.account) as any, { chatMessage } as any)),
|
||||
icon: require('@tabler/icons/flag.svg'),
|
||||
});
|
||||
}
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.deleteForMe),
|
||||
action: () => handleDeleteMessage.mutate(chatMessage.id),
|
||||
icon: require('@tabler/icons/trash.svg'),
|
||||
destructive: true,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={chatMessage.id} className='group' data-testid='chat-message'>
|
||||
<Stack
|
||||
space={1.5}
|
||||
className={clsx({
|
||||
'ml-auto': isMyMessage,
|
||||
})}
|
||||
>
|
||||
<HStack
|
||||
alignItems='center'
|
||||
justifyContent={isMyMessage ? 'end' : 'start'}
|
||||
className={clsx({
|
||||
'opacity-50': chatMessage.pending,
|
||||
})}
|
||||
>
|
||||
{menu.length > 0 && (
|
||||
<div
|
||||
className={clsx({
|
||||
'hidden focus:block group-hover:block text-gray-500': true,
|
||||
'mr-2 order-1': isMyMessage,
|
||||
'ml-2 order-2': !isMyMessage,
|
||||
})}
|
||||
data-testid='chat-message-menu'
|
||||
>
|
||||
<DropdownMenuContainer items={menu}>
|
||||
<IconButton
|
||||
src={require('@tabler/icons/dots.svg')}
|
||||
title={intl.formatMessage(messages.more)}
|
||||
className='text-gray-600 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-500'
|
||||
iconClassName='w-4 h-4'
|
||||
/>
|
||||
</DropdownMenuContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Stack
|
||||
space={0.5}
|
||||
className={clsx({
|
||||
'max-w-[85%]': true,
|
||||
'flex-1': !!chatMessage.media_attachments.size,
|
||||
'order-2': isMyMessage,
|
||||
'order-1': !isMyMessage,
|
||||
})}
|
||||
alignItems={isMyMessage ? 'end' : 'start'}
|
||||
>
|
||||
{maybeRenderMedia(chatMessage)}
|
||||
|
||||
{content && (
|
||||
<HStack alignItems='bottom' className='max-w-full'>
|
||||
<div
|
||||
title={getFormattedTimestamp(chatMessage)}
|
||||
className={
|
||||
clsx({
|
||||
'text-ellipsis break-words relative rounded-md py-2 px-3 max-w-full space-y-2 [&_.mention]:underline': true,
|
||||
'rounded-tr-sm': (!!chatMessage.media_attachments.size) && isMyMessage,
|
||||
'rounded-tl-sm': (!!chatMessage.media_attachments.size) && !isMyMessage,
|
||||
'[&_.mention]:text-primary-600 dark:[&_.mention]:text-accent-blue': !isMyMessage,
|
||||
'[&_.mention]:text-white dark:[&_.mention]:white': isMyMessage,
|
||||
'bg-primary-500 text-white': isMyMessage,
|
||||
'bg-gray-200 dark:bg-gray-800 text-gray-900 dark:text-gray-100': !isMyMessage,
|
||||
'!bg-transparent !p-0 emoji-lg': isOnlyEmoji,
|
||||
})
|
||||
}
|
||||
ref={setBubbleRef}
|
||||
tabIndex={0}
|
||||
>
|
||||
<Text
|
||||
size='sm'
|
||||
theme='inherit'
|
||||
className='break-word-nested'
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
/>
|
||||
</div>
|
||||
</HStack>
|
||||
)}
|
||||
</Stack>
|
||||
</HStack>
|
||||
|
||||
<HStack
|
||||
alignItems='center'
|
||||
space={2}
|
||||
className={clsx({
|
||||
'ml-auto': isMyMessage,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={clsx({
|
||||
'text-right': isMyMessage,
|
||||
'order-2': !isMyMessage,
|
||||
})}
|
||||
>
|
||||
<span className='flex items-center space-x-1.5'>
|
||||
<Text
|
||||
theme='muted'
|
||||
size='xs'
|
||||
>
|
||||
{intl.formatTime(chatMessage.created_at)}
|
||||
</Text>
|
||||
|
||||
{(isMyMessage && features.chatsReadReceipts) ? (
|
||||
<>
|
||||
{isRead ? (
|
||||
<span className='flex flex-col items-center justify-center rounded-full border border-solid border-primary-500 bg-primary-500 p-0.5 text-white dark:border-primary-400 dark:bg-primary-400 dark:text-primary-900'>
|
||||
<Icon src={require('@tabler/icons/check.svg')} strokeWidth={3} className='h-2.5 w-2.5' />
|
||||
</span>
|
||||
) : (
|
||||
<span className='flex flex-col items-center justify-center rounded-full border border-solid border-primary-500 bg-transparent p-0.5 text-primary-500 dark:border-primary-400 dark:text-primary-400'>
|
||||
<Icon src={require('@tabler/icons/check.svg')} strokeWidth={3} className='h-2.5 w-2.5' />
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
</HStack>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const lastMessage = formattedChatMessages[formattedChatMessages.length - 1];
|
||||
if (!lastMessage) {
|
||||
|
@ -476,8 +235,7 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
|
|||
<Virtuoso
|
||||
ref={node}
|
||||
alignToBottom
|
||||
firstItemIndex={Math.max(0, firstItemIndex)}
|
||||
initialTopMostItemIndex={initialTopMostItemIndex}
|
||||
{...initialScrollPositionProps}
|
||||
data={cachedChatMessages}
|
||||
startReached={handleStartReached}
|
||||
followOutput='auto'
|
||||
|
@ -485,11 +243,7 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
|
|||
if (chatMessage.type === 'divider') {
|
||||
return renderDivider(index, chatMessage.text);
|
||||
} else {
|
||||
return (
|
||||
<div className='px-4 py-2'>
|
||||
{renderMessage(chatMessage)}
|
||||
</div>
|
||||
);
|
||||
return <ChatMessage chat={chat} chatMessage={chatMessage} />;
|
||||
}
|
||||
}}
|
||||
components={{
|
||||
|
|
|
@ -0,0 +1,51 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
import EmojiSelector from '../../../../components/ui/emoji-selector/emoji-selector';
|
||||
|
||||
interface IChatMessageReactionWrapper {
|
||||
onOpen(isOpen: boolean): void
|
||||
onSelect(emoji: string): void
|
||||
children: JSX.Element
|
||||
}
|
||||
|
||||
/**
|
||||
* Emoji Reaction Selector
|
||||
*/
|
||||
function ChatMessageReactionWrapper(props: IChatMessageReactionWrapper) {
|
||||
const { onOpen, onSelect, children } = props;
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const handleSelect = (emoji: string) => {
|
||||
onSelect(emoji);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const onToggleVisibility = () => setIsOpen((prevValue) => !prevValue);
|
||||
|
||||
useEffect(() => {
|
||||
onOpen(isOpen);
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{React.cloneElement(children, {
|
||||
ref: setReferenceElement,
|
||||
onClick: onToggleVisibility,
|
||||
})}
|
||||
|
||||
<EmojiSelector
|
||||
visible={isOpen}
|
||||
referenceElement={referenceElement}
|
||||
onReact={handleSelect}
|
||||
onClose={() => setIsOpen(false)}
|
||||
offset={[-10, 12]}
|
||||
all={false}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChatMessageReactionWrapper;
|
|
@ -0,0 +1,45 @@
|
|||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
import { Text } from 'soapbox/components/ui';
|
||||
import emojify from 'soapbox/features/emoji/emoji';
|
||||
import { EmojiReaction } from 'soapbox/types/entities';
|
||||
|
||||
interface IChatMessageReaction {
|
||||
emojiReaction: EmojiReaction
|
||||
onRemoveReaction(emoji: string): void
|
||||
onAddReaction(emoji: string): void
|
||||
}
|
||||
|
||||
const ChatMessageReaction = (props: IChatMessageReaction) => {
|
||||
const { emojiReaction, onAddReaction, onRemoveReaction } = props;
|
||||
|
||||
const isAlreadyReacted = emojiReaction.me;
|
||||
|
||||
const handleClick = () => {
|
||||
if (isAlreadyReacted) {
|
||||
onRemoveReaction(emojiReaction.name);
|
||||
} else {
|
||||
onAddReaction(emojiReaction.name);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleClick}
|
||||
className={
|
||||
clsx({
|
||||
'w-12 rounded-lg flex justify-between text-sm border items-center border-solid text-gray-700 dark:text-gray-600 px-2 py-1 space-x-1.5 transition-colors hover:bg-gray-200 dark:hover:bg-gray-800 whitespace-nowrap': true,
|
||||
'border-primary-500 dark:border-primary-400': emojiReaction.me,
|
||||
'border-gray-300 dark:border-gray-800': !emojiReaction.me,
|
||||
})
|
||||
}
|
||||
>
|
||||
<span dangerouslySetInnerHTML={{ __html: emojify(emojiReaction.name) }} />
|
||||
<Text tag='span' weight='medium' size='sm'>{emojiReaction.count}</Text>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatMessageReaction;
|
|
@ -0,0 +1,388 @@
|
|||
import { useMutation } from '@tanstack/react-query';
|
||||
import clsx from 'clsx';
|
||||
import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
|
||||
import { escape } from 'lodash';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import { initReport } from 'soapbox/actions/reports';
|
||||
import DropdownMenu from 'soapbox/components/dropdown-menu';
|
||||
import { HStack, Icon, Stack, Text } from 'soapbox/components/ui';
|
||||
import emojify from 'soapbox/features/emoji/emoji';
|
||||
import Bundle from 'soapbox/features/ui/components/bundle';
|
||||
import { MediaGallery } from 'soapbox/features/ui/util/async-components';
|
||||
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
|
||||
import { normalizeAccount } from 'soapbox/normalizers';
|
||||
import { ChatKeys, IChat, useChatActions } from 'soapbox/queries/chats';
|
||||
import { queryClient } from 'soapbox/queries/client';
|
||||
import { stripHTML } from 'soapbox/utils/html';
|
||||
import { onlyEmoji } from 'soapbox/utils/rich-content';
|
||||
|
||||
import ChatMessageReaction from './chat-message-reaction';
|
||||
import ChatMessageReactionWrapper from './chat-message-reaction-wrapper/chat-message-reaction-wrapper';
|
||||
|
||||
import type { Menu as IMenu } from 'soapbox/components/dropdown-menu';
|
||||
import type { ChatMessage as ChatMessageEntity } from 'soapbox/types/entities';
|
||||
|
||||
const messages = defineMessages({
|
||||
copy: { id: 'chats.actions.copy', defaultMessage: 'Copy' },
|
||||
delete: { id: 'chats.actions.delete', defaultMessage: 'Delete for both' },
|
||||
deleteForMe: { id: 'chats.actions.deleteForMe', defaultMessage: 'Delete for me' },
|
||||
more: { id: 'chats.actions.more', defaultMessage: 'More' },
|
||||
report: { id: 'chats.actions.report', defaultMessage: 'Report' },
|
||||
});
|
||||
|
||||
const BIG_EMOJI_LIMIT = 3;
|
||||
|
||||
const makeEmojiMap = (record: any) => record.get('emojis', ImmutableList()).reduce((map: ImmutableMap<string, any>, emoji: ImmutableMap<string, any>) => {
|
||||
return map.set(`:${emoji.get('shortcode')}:`, emoji);
|
||||
}, ImmutableMap());
|
||||
|
||||
const parsePendingContent = (content: string) => {
|
||||
return escape(content).replace(/(?:\r\n|\r|\n)/g, '<br>');
|
||||
};
|
||||
|
||||
const parseContent = (chatMessage: ChatMessageEntity) => {
|
||||
const content = chatMessage.content || '';
|
||||
const pending = chatMessage.pending;
|
||||
const deleting = chatMessage.deleting;
|
||||
const formatted = (pending && !deleting) ? parsePendingContent(content) : content;
|
||||
const emojiMap = makeEmojiMap(chatMessage);
|
||||
return emojify(formatted, emojiMap.toJS());
|
||||
};
|
||||
|
||||
interface IChatMessage {
|
||||
chat: IChat
|
||||
chatMessage: ChatMessageEntity
|
||||
}
|
||||
|
||||
const ChatMessage = (props: IChatMessage) => {
|
||||
const { chat, chatMessage } = props;
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const features = useFeatures();
|
||||
const intl = useIntl();
|
||||
|
||||
const me = useAppSelector((state) => state.me);
|
||||
const { createReaction, deleteChatMessage, deleteReaction } = useChatActions(chat.id);
|
||||
|
||||
const [isReactionSelectorOpen, setIsReactionSelectorOpen] = useState<boolean>(false);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState<boolean>(false);
|
||||
|
||||
const handleDeleteMessage = useMutation((chatMessageId: string) => deleteChatMessage(chatMessageId), {
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries(ChatKeys.chatMessages(chat.id));
|
||||
},
|
||||
});
|
||||
|
||||
const content = parseContent(chatMessage);
|
||||
const lastReadMessageDateString = chat.latest_read_message_by_account?.find((latest) => latest.id === chat.account.id)?.date;
|
||||
const lastReadMessageTimestamp = lastReadMessageDateString ? new Date(lastReadMessageDateString) : null;
|
||||
const isMyMessage = chatMessage.account_id === me;
|
||||
|
||||
// did this occur before this time?
|
||||
const isRead = isMyMessage
|
||||
&& lastReadMessageTimestamp
|
||||
&& lastReadMessageTimestamp >= new Date(chatMessage.created_at);
|
||||
|
||||
const isOnlyEmoji = useMemo(() => {
|
||||
const hiddenEl = document.createElement('div');
|
||||
hiddenEl.innerHTML = content;
|
||||
return onlyEmoji(hiddenEl, BIG_EMOJI_LIMIT, false);
|
||||
}, []);
|
||||
|
||||
const emojiReactionRows = useMemo(() => {
|
||||
if (!chatMessage.emoji_reactions) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return chatMessage.emoji_reactions.reduce((rows: any, key: any, index) => {
|
||||
return (index % 4 === 0 ? rows.push([key])
|
||||
: rows[rows.length - 1].push(key)) && rows;
|
||||
}, []);
|
||||
}, [chatMessage.emoji_reactions]);
|
||||
|
||||
const onOpenMedia = (media: any, index: number) => {
|
||||
dispatch(openModal('MEDIA', { media, index }));
|
||||
};
|
||||
|
||||
const maybeRenderMedia = (chatMessage: ChatMessageEntity) => {
|
||||
if (!chatMessage.media_attachments.size) return null;
|
||||
|
||||
return (
|
||||
<Bundle fetchComponent={MediaGallery}>
|
||||
{(Component: any) => (
|
||||
<Component
|
||||
media={chatMessage.media_attachments}
|
||||
onOpenMedia={onOpenMedia}
|
||||
visible
|
||||
/>
|
||||
)}
|
||||
</Bundle>
|
||||
);
|
||||
};
|
||||
|
||||
const handleCopyText = (chatMessage: ChatMessageEntity) => {
|
||||
if (navigator.clipboard) {
|
||||
const text = stripHTML(chatMessage.content);
|
||||
navigator.clipboard.writeText(text);
|
||||
}
|
||||
};
|
||||
const setBubbleRef = (c: HTMLDivElement) => {
|
||||
if (!c) return;
|
||||
const links = c.querySelectorAll('a[rel="ugc"]');
|
||||
|
||||
links.forEach(link => {
|
||||
link.classList.add('chat-link');
|
||||
link.setAttribute('rel', 'ugc nofollow noopener');
|
||||
link.setAttribute('target', '_blank');
|
||||
});
|
||||
};
|
||||
|
||||
const getFormattedTimestamp = (chatMessage: ChatMessageEntity) => {
|
||||
return intl.formatDate(new Date(chatMessage.created_at), {
|
||||
hour12: false,
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const menu = useMemo(() => {
|
||||
const menu: IMenu = [];
|
||||
|
||||
if (navigator.clipboard && chatMessage.content) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.copy),
|
||||
action: () => handleCopyText(chatMessage),
|
||||
icon: require('@tabler/icons/copy.svg'),
|
||||
});
|
||||
}
|
||||
|
||||
if (isMyMessage) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.delete),
|
||||
action: () => handleDeleteMessage.mutate(chatMessage.id),
|
||||
icon: require('@tabler/icons/trash.svg'),
|
||||
destructive: true,
|
||||
});
|
||||
} else {
|
||||
if (features.reportChats) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.report),
|
||||
action: () => dispatch(initReport(normalizeAccount(chat.account) as any, { chatMessage } as any)),
|
||||
icon: require('@tabler/icons/flag.svg'),
|
||||
});
|
||||
}
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.deleteForMe),
|
||||
action: () => handleDeleteMessage.mutate(chatMessage.id),
|
||||
icon: require('@tabler/icons/trash.svg'),
|
||||
destructive: true,
|
||||
});
|
||||
}
|
||||
|
||||
return menu;
|
||||
}, [chatMessage, chat]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
clsx({
|
||||
'group relative px-4 py-2 hover:bg-gray-200/40 dark:hover:bg-gray-800/40': true,
|
||||
'bg-gray-200/40 dark:bg-gray-800/40': isMenuOpen || isReactionSelectorOpen,
|
||||
})
|
||||
}
|
||||
data-testid='chat-message'
|
||||
>
|
||||
<div
|
||||
className={
|
||||
clsx({
|
||||
'p-1 flex items-center space-x-0.5 z-10 absolute opacity-0 transition-opacity group-hover:opacity-100 focus:opacity-100 rounded-md shadow-lg bg-white dark:bg-gray-900 dark:ring-2 dark:ring-primary-700': true,
|
||||
'top-2 right-2': !isMyMessage,
|
||||
'top-2 left-2': isMyMessage,
|
||||
'!opacity-100': isMenuOpen || isReactionSelectorOpen,
|
||||
})
|
||||
}
|
||||
>
|
||||
{!features.chatEmojiReactions ? (
|
||||
<ChatMessageReactionWrapper
|
||||
onOpen={setIsReactionSelectorOpen}
|
||||
onSelect={(emoji) => createReaction.mutate({ emoji, messageId: chatMessage.id, chatMessage })}
|
||||
>
|
||||
<button
|
||||
title={intl.formatMessage(messages.more)}
|
||||
className={clsx({
|
||||
'p-1.5 hover:bg-gray-200 dark:hover:bg-gray-800 rounded-md text-gray-600 dark:text-gray-600 hover:text-gray-700 dark:hover:text-gray-500 focus:text-gray-700 dark:focus:text-gray-500 focus:ring-0': true,
|
||||
'!text-gray-700 dark:!text-gray-500': isReactionSelectorOpen,
|
||||
})}
|
||||
>
|
||||
<Icon
|
||||
src={require('@tabler/icons/mood-smile.svg')}
|
||||
className='h-4 w-4'
|
||||
/>
|
||||
</button>
|
||||
</ChatMessageReactionWrapper>
|
||||
) : null}
|
||||
|
||||
{menu.length > 0 && (
|
||||
<DropdownMenu
|
||||
items={menu}
|
||||
onOpen={() => setIsMenuOpen(true)}
|
||||
onClose={() => setIsMenuOpen(false)}
|
||||
>
|
||||
<button
|
||||
title={intl.formatMessage(messages.more)}
|
||||
className={clsx({
|
||||
'p-1.5 hover:bg-gray-200 dark:hover:bg-gray-800 rounded-md text-gray-600 dark:text-gray-600 hover:text-gray-700 dark:hover:text-gray-500 focus:text-gray-700 dark:focus:text-gray-500 focus:ring-0': true,
|
||||
'!text-gray-700 dark:!text-gray-500': isMenuOpen,
|
||||
})}
|
||||
data-testid='chat-message-menu'
|
||||
>
|
||||
<Icon
|
||||
src={require('@tabler/icons/dots.svg')}
|
||||
className='h-4 w-4'
|
||||
/>
|
||||
</button>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Stack
|
||||
space={1.5}
|
||||
className={clsx({
|
||||
'ml-auto': isMyMessage,
|
||||
})}
|
||||
>
|
||||
<HStack
|
||||
alignItems='center'
|
||||
justifyContent={isMyMessage ? 'end' : 'start'}
|
||||
className={clsx({
|
||||
'opacity-50': chatMessage.pending,
|
||||
})}
|
||||
>
|
||||
<Stack
|
||||
space={0.5}
|
||||
className={clsx({
|
||||
'max-w-[85%]': true,
|
||||
'flex-1': !!chatMessage.media_attachments.size,
|
||||
'order-3': isMyMessage,
|
||||
'order-1': !isMyMessage,
|
||||
})}
|
||||
alignItems={isMyMessage ? 'end' : 'start'}
|
||||
>
|
||||
{maybeRenderMedia(chatMessage)}
|
||||
|
||||
{content && (
|
||||
<HStack alignItems='bottom' className='max-w-full'>
|
||||
<div
|
||||
title={getFormattedTimestamp(chatMessage)}
|
||||
className={
|
||||
clsx({
|
||||
'text-ellipsis break-words relative rounded-md py-2 px-3 max-w-full space-y-2 [&_.mention]:underline': true,
|
||||
'rounded-tr-sm': (!!chatMessage.media_attachments.size) && isMyMessage,
|
||||
'rounded-tl-sm': (!!chatMessage.media_attachments.size) && !isMyMessage,
|
||||
'[&_.mention]:text-primary-600 dark:[&_.mention]:text-accent-blue': !isMyMessage,
|
||||
'[&_.mention]:text-white dark:[&_.mention]:white': isMyMessage,
|
||||
'bg-primary-500 text-white': isMyMessage,
|
||||
'bg-gray-200 dark:bg-gray-800 text-gray-900 dark:text-gray-100': !isMyMessage,
|
||||
'!bg-transparent !p-0 emoji-lg': isOnlyEmoji,
|
||||
})
|
||||
}
|
||||
ref={setBubbleRef}
|
||||
tabIndex={0}
|
||||
>
|
||||
<Text
|
||||
size='sm'
|
||||
theme='inherit'
|
||||
className='break-word-nested'
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
/>
|
||||
</div>
|
||||
</HStack>
|
||||
)}
|
||||
</Stack>
|
||||
</HStack>
|
||||
|
||||
{(features.chatEmojiReactions && chatMessage.emoji_reactions) ? (
|
||||
<div
|
||||
className={clsx({
|
||||
'space-y-1': true,
|
||||
'ml-auto': isMyMessage,
|
||||
'mr-auto': !isMyMessage,
|
||||
})}
|
||||
>
|
||||
{emojiReactionRows?.map((emojiReactionRow: any, idx: number) => (
|
||||
<HStack
|
||||
key={idx}
|
||||
className={
|
||||
clsx({
|
||||
'flex items-center gap-1': true,
|
||||
'flex-row-reverse': isMyMessage,
|
||||
})
|
||||
}
|
||||
>
|
||||
{emojiReactionRow.map((emojiReaction: any, idx: number) => (
|
||||
<ChatMessageReaction
|
||||
key={idx}
|
||||
emojiReaction={emojiReaction}
|
||||
onAddReaction={(emoji) => createReaction.mutate({ emoji, messageId: chatMessage.id, chatMessage })}
|
||||
onRemoveReaction={(emoji) => deleteReaction.mutate({ emoji, messageId: chatMessage.id })}
|
||||
/>
|
||||
))}
|
||||
</HStack>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<HStack
|
||||
alignItems='center'
|
||||
space={2}
|
||||
className={clsx({
|
||||
'ml-auto': isMyMessage,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={clsx({
|
||||
'text-right': isMyMessage,
|
||||
'order-2': !isMyMessage,
|
||||
})}
|
||||
>
|
||||
<span className='flex items-center space-x-1.5'>
|
||||
<Text theme='muted' size='xs'>
|
||||
{intl.formatTime(chatMessage.created_at)}
|
||||
</Text>
|
||||
|
||||
{(isMyMessage && features.chatsReadReceipts) ? (
|
||||
<>
|
||||
{isRead ? (
|
||||
<span className='flex flex-col items-center justify-center rounded-full border border-solid border-primary-500 bg-primary-500 p-0.5 text-white dark:border-primary-400 dark:bg-primary-400 dark:text-primary-900'>
|
||||
<Icon
|
||||
src={require('@tabler/icons/check.svg')}
|
||||
strokeWidth={3}
|
||||
className='h-2.5 w-2.5'
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
<span className='flex flex-col items-center justify-center rounded-full border border-solid border-primary-500 bg-transparent p-0.5 text-primary-500 dark:border-primary-400 dark:text-primary-400'>
|
||||
<Icon
|
||||
src={require('@tabler/icons/check.svg')}
|
||||
strokeWidth={3}
|
||||
className='h-2.5 w-2.5'
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
</HStack>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatMessage;
|
|
@ -59,6 +59,7 @@ const Chat: React.FC<ChatInterface> = ({ chat, inputRef, className }) => {
|
|||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [resetContentKey, setResetContentKey] = useState<number>(fileKeyGen());
|
||||
const [resetFileKey, setResetFileKey] = useState<number>(fileKeyGen());
|
||||
const [errorMessage, setErrorMessage] = useState<string>();
|
||||
|
||||
|
@ -88,6 +89,7 @@ const Chat: React.FC<ChatInterface> = ({ chat, inputRef, className }) => {
|
|||
setIsUploading(false);
|
||||
setUploadProgress(0);
|
||||
setResetFileKey(fileKeyGen());
|
||||
setResetContentKey(fileKeyGen());
|
||||
};
|
||||
|
||||
const sendMessage = () => {
|
||||
|
@ -183,6 +185,7 @@ const Chat: React.FC<ChatInterface> = ({ chat, inputRef, className }) => {
|
|||
errorMessage={errorMessage}
|
||||
onSelectFile={handleFiles}
|
||||
resetFileKey={resetFileKey}
|
||||
resetContentKey={resetContentKey}
|
||||
onPaste={handlePaste}
|
||||
attachments={attachments}
|
||||
onDeleteAttachment={handleRemoveFile}
|
||||
|
|
|
@ -242,7 +242,10 @@ const PrivacyDropdown: React.FC<IPrivacyDropdown> = ({
|
|||
<div className={clsx('privacy-dropdown', placement, { active: open })} onKeyDown={handleKeyDown} ref={node}>
|
||||
<div className={clsx('privacy-dropdown__value', { active: valueOption && options.indexOf(valueOption) === 0 })}>
|
||||
<IconButton
|
||||
className='text-gray-600 hover:text-gray-700 dark:hover:text-gray-500'
|
||||
className={clsx({
|
||||
'text-gray-600 hover:text-gray-700 dark:hover:text-gray-500': !open,
|
||||
'text-primary-500 hover:text-primary-600 dark:text-primary-500 dark:hover:text-primary-400': open,
|
||||
})}
|
||||
src={valueOption?.icon}
|
||||
title={intl.formatMessage(messages.change_privacy)}
|
||||
onClick={handleToggle}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
import AttachmentThumbs from 'soapbox/components/attachment-thumbs';
|
||||
|
@ -8,12 +9,13 @@ import { isRtl } from 'soapbox/rtl';
|
|||
import type { Status } from 'soapbox/types/entities';
|
||||
|
||||
interface IReplyIndicator {
|
||||
className?: string,
|
||||
status?: Status,
|
||||
onCancel?: () => void,
|
||||
hideActions: boolean,
|
||||
}
|
||||
|
||||
const ReplyIndicator: React.FC<IReplyIndicator> = ({ status, hideActions, onCancel }) => {
|
||||
const ReplyIndicator: React.FC<IReplyIndicator> = ({ className, status, hideActions, onCancel }) => {
|
||||
const handleClick = () => {
|
||||
onCancel!();
|
||||
};
|
||||
|
@ -33,7 +35,7 @@ const ReplyIndicator: React.FC<IReplyIndicator> = ({ status, hideActions, onCanc
|
|||
}
|
||||
|
||||
return (
|
||||
<Stack space={2} className='rounded-lg bg-gray-100 p-4 dark:bg-gray-800'>
|
||||
<Stack space={2} className={clsx('rounded-lg bg-gray-100 p-4 dark:bg-gray-800', className)}>
|
||||
<AccountContainer
|
||||
{...actions}
|
||||
id={status.getIn(['account', 'id']) as string}
|
||||
|
|
|
@ -21,4 +21,4 @@ const Indicator: React.FC<IIndicator> = ({ state = 'inactive', size = 'sm' }) =>
|
|||
);
|
||||
};
|
||||
|
||||
export default Indicator;
|
||||
export default Indicator;
|
||||
|
|
|
@ -152,14 +152,14 @@ const ProfileField: StreamfieldComponent<AccountCredentialsField> = ({ value, on
|
|||
<HStack space={2} grow>
|
||||
<Input
|
||||
type='text'
|
||||
outerClassName='w-2/5 flex-grow'
|
||||
outerClassName='w-2/5 grow'
|
||||
value={value.name}
|
||||
onChange={handleChange('name')}
|
||||
placeholder={intl.formatMessage(messages.metaFieldLabel)}
|
||||
/>
|
||||
<Input
|
||||
type='text'
|
||||
outerClassName='w-3/5 flex-grow'
|
||||
outerClassName='w-3/5 grow'
|
||||
value={value.value}
|
||||
onChange={handleChange('value')}
|
||||
placeholder={intl.formatMessage(messages.metaFieldContent)}
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
// @ts-ignore no types
|
||||
import Emoji from 'emoji-mart/dist-es/components/emoji/emoji';
|
||||
// @ts-ignore no types
|
||||
import Picker from 'emoji-mart/dist-es/components/picker/picker';
|
||||
|
||||
export {
|
|
@ -396,7 +396,7 @@ const EventHeader: React.FC<IEventHeader> = ({ status }) => {
|
|||
return <MenuDivider key={idx} />;
|
||||
} else {
|
||||
const Comp = (menuItem.action ? MenuItem : MenuLink) as any;
|
||||
const itemProps = menuItem.action ? { onSelect: menuItem.action } : { to: menuItem.to, as: Link, target: menuItem.newTab ? '_blank' : '_self' };
|
||||
const itemProps = menuItem.action ? { onSelect: menuItem.action } : { to: menuItem.to, as: Link, target: menuItem.target || '_self' };
|
||||
|
||||
return (
|
||||
<Comp key={idx} {...itemProps} className='group'>
|
||||
|
|
|
@ -2,13 +2,9 @@ import React, { useEffect, useState } from 'react';
|
|||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { fetchFilters, createFilter, deleteFilter } from 'soapbox/actions/filters';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import List, { ListItem } from 'soapbox/components/list';
|
||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||
import { Button, CardHeader, CardTitle, Column, Form, FormActions, FormGroup, Input, Text } from 'soapbox/components/ui';
|
||||
import {
|
||||
FieldsGroup,
|
||||
Checkbox,
|
||||
} from 'soapbox/features/forms';
|
||||
import { Button, CardHeader, CardTitle, Column, Form, FormActions, FormGroup, HStack, IconButton, Input, Stack, Text, Toggle } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import toast from 'soapbox/toast';
|
||||
|
||||
|
@ -33,6 +29,13 @@ const messages = defineMessages({
|
|||
delete: { id: 'column.filters.delete', defaultMessage: 'Delete' },
|
||||
});
|
||||
|
||||
const contexts = {
|
||||
home: messages.home_timeline,
|
||||
public: messages.public_timeline,
|
||||
notifications: messages.notifications,
|
||||
thread: messages.conversations,
|
||||
};
|
||||
|
||||
// const expirations = {
|
||||
// null: 'Never',
|
||||
// // 3600: '30 minutes',
|
||||
|
@ -85,8 +88,8 @@ const Filters = () => {
|
|||
});
|
||||
};
|
||||
|
||||
const handleFilterDelete: React.MouseEventHandler<HTMLDivElement> = e => {
|
||||
dispatch(deleteFilter(e.currentTarget.dataset.value!)).then(() => {
|
||||
const handleFilterDelete = (id: string) => () => {
|
||||
dispatch(deleteFilter(id)).then(() => {
|
||||
return dispatch(fetchFilters());
|
||||
}).catch(() => {
|
||||
toast.error(intl.formatMessage(messages.delete_error));
|
||||
|
@ -121,58 +124,68 @@ const Filters = () => {
|
|||
/>
|
||||
</FormGroup> */}
|
||||
|
||||
<FieldsGroup>
|
||||
<Text tag='label'>
|
||||
<Stack>
|
||||
<Text size='sm' weight='medium'>
|
||||
<FormattedMessage id='filters.context_header' defaultMessage='Filter contexts' />
|
||||
</Text>
|
||||
<Text theme='muted' size='xs'>
|
||||
<Text size='xs' theme='muted'>
|
||||
<FormattedMessage id='filters.context_hint' defaultMessage='One or multiple contexts where the filter should apply' />
|
||||
</Text>
|
||||
<div className='two-col'>
|
||||
<Checkbox
|
||||
label={intl.formatMessage(messages.home_timeline)}
|
||||
</Stack>
|
||||
|
||||
<List>
|
||||
<ListItem label={intl.formatMessage(messages.home_timeline)}>
|
||||
<Toggle
|
||||
name='home_timeline'
|
||||
checked={homeTimeline}
|
||||
onChange={({ target }) => setHomeTimeline(target.checked)}
|
||||
/>
|
||||
<Checkbox
|
||||
label={intl.formatMessage(messages.public_timeline)}
|
||||
</ListItem>
|
||||
<ListItem label={intl.formatMessage(messages.public_timeline)}>
|
||||
<Toggle
|
||||
name='public_timeline'
|
||||
checked={publicTimeline}
|
||||
onChange={({ target }) => setPublicTimeline(target.checked)}
|
||||
/>
|
||||
<Checkbox
|
||||
label={intl.formatMessage(messages.notifications)}
|
||||
</ListItem>
|
||||
<ListItem label={intl.formatMessage(messages.notifications)}>
|
||||
<Toggle
|
||||
name='notifications'
|
||||
checked={notifications}
|
||||
onChange={({ target }) => setNotifications(target.checked)}
|
||||
/>
|
||||
<Checkbox
|
||||
label={intl.formatMessage(messages.conversations)}
|
||||
</ListItem>
|
||||
<ListItem label={intl.formatMessage(messages.conversations)}>
|
||||
<Toggle
|
||||
name='conversations'
|
||||
checked={conversations}
|
||||
onChange={({ target }) => setConversations(target.checked)}
|
||||
/>
|
||||
</div>
|
||||
</ListItem>
|
||||
</List>
|
||||
|
||||
</FieldsGroup>
|
||||
|
||||
<FieldsGroup>
|
||||
<Checkbox
|
||||
<List>
|
||||
<ListItem
|
||||
label={intl.formatMessage(messages.drop_header)}
|
||||
hint={intl.formatMessage(messages.drop_hint)}
|
||||
name='irreversible'
|
||||
checked={irreversible}
|
||||
onChange={({ target }) => setIrreversible(target.checked)}
|
||||
/>
|
||||
<Checkbox
|
||||
>
|
||||
<Toggle
|
||||
name='irreversible'
|
||||
checked={irreversible}
|
||||
onChange={({ target }) => setIrreversible(target.checked)}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
label={intl.formatMessage(messages.whole_word_header)}
|
||||
hint={intl.formatMessage(messages.whole_word_hint)}
|
||||
name='whole_word'
|
||||
checked={wholeWord}
|
||||
onChange={({ target }) => setWholeWord(target.checked)}
|
||||
/>
|
||||
</FieldsGroup>
|
||||
>
|
||||
<Toggle
|
||||
name='whole_word'
|
||||
checked={wholeWord}
|
||||
onChange={({ target }) => setWholeWord(target.checked)}
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
|
||||
<FormActions>
|
||||
<Button type='submit' theme='primary'>{intl.formatMessage(messages.add_new)}</Button>
|
||||
|
@ -186,40 +199,41 @@ const Filters = () => {
|
|||
<ScrollableList
|
||||
scrollKey='filters'
|
||||
emptyMessage={emptyMessage}
|
||||
itemClassName='pb-4 last:pb-0'
|
||||
>
|
||||
{filters.map((filter, i) => (
|
||||
<div key={i} className='filter__container'>
|
||||
<div className='filter__details'>
|
||||
<div className='filter__phrase'>
|
||||
<span className='filter__list-label'><FormattedMessage id='filters.filters_list_phrase_label' defaultMessage='Keyword or phrase:' /></span>
|
||||
<span className='filter__list-value'>{filter.phrase}</span>
|
||||
</div>
|
||||
<div className='filter__contexts'>
|
||||
<span className='filter__list-label'><FormattedMessage id='filters.filters_list_context_label' defaultMessage='Filter contexts:' /></span>
|
||||
<span className='filter__list-value'>
|
||||
{filter.context.map((context, i) => (
|
||||
<span key={i} className='context'>{context}</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
<div className='filter__details'>
|
||||
<span className='filter__list-label'><FormattedMessage id='filters.filters_list_details_label' defaultMessage='Filter settings:' /></span>
|
||||
<span className='filter__list-value'>
|
||||
<HStack space={1} justifyContent='between'>
|
||||
<Stack space={1}>
|
||||
<Text weight='medium'>
|
||||
<FormattedMessage id='filters.filters_list_phrase_label' defaultMessage='Keyword or phrase:' />
|
||||
{' '}
|
||||
<Text theme='muted' tag='span'>{filter.phrase}</Text>
|
||||
</Text>
|
||||
<Text weight='medium'>
|
||||
<FormattedMessage id='filters.filters_list_context_label' defaultMessage='Filter contexts:' />
|
||||
{' '}
|
||||
<Text theme='muted' tag='span'>{filter.context.map(context => contexts[context] ? intl.formatMessage(contexts[context]) : context).join(', ')}</Text>
|
||||
</Text>
|
||||
<HStack space={4}>
|
||||
<Text weight='medium'>
|
||||
{filter.irreversible ?
|
||||
<span><FormattedMessage id='filters.filters_list_drop' defaultMessage='Drop' /></span> :
|
||||
<span><FormattedMessage id='filters.filters_list_hide' defaultMessage='Hide' /></span>
|
||||
}
|
||||
{filter.whole_word &&
|
||||
<span><FormattedMessage id='filters.filters_list_whole-word' defaultMessage='Whole word' /></span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='filter__delete' role='button' tabIndex={0} onClick={handleFilterDelete} data-value={filter.id} aria-label={intl.formatMessage(messages.delete)}>
|
||||
<Icon className='filter__delete-icon' src={require('@tabler/icons/x.svg')} />
|
||||
<span className='filter__delete-label'><FormattedMessage id='filters.filters_list_delete' defaultMessage='Delete' /></span>
|
||||
</div>
|
||||
</div>
|
||||
<FormattedMessage id='filters.filters_list_drop' defaultMessage='Drop' /> :
|
||||
<FormattedMessage id='filters.filters_list_hide' defaultMessage='Hide' />}
|
||||
</Text>
|
||||
{filter.whole_word && (
|
||||
<Text weight='medium'>
|
||||
<FormattedMessage id='filters.filters_list_whole-word' defaultMessage='Whole word' />
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
</Stack>
|
||||
<IconButton
|
||||
iconClassName='h-5 w-5 text-gray-700 dark:text-gray-600 hover:text-gray-800 dark:hover:text-gray-500'
|
||||
src={require('@tabler/icons/trash.svg')}
|
||||
onClick={handleFilterDelete(filter.id)}
|
||||
title={intl.formatMessage(messages.delete)}
|
||||
/>
|
||||
</HStack>
|
||||
))}
|
||||
</ScrollableList>
|
||||
</Column>
|
||||
|
|
|
@ -103,71 +103,6 @@ export const SimpleInput: React.FC<ISimpleInput> = (props) => {
|
|||
);
|
||||
};
|
||||
|
||||
interface ISimpleTextarea {
|
||||
label?: React.ReactNode,
|
||||
hint?: React.ReactNode,
|
||||
value?: string,
|
||||
onChange?: React.ChangeEventHandler<HTMLTextAreaElement>,
|
||||
rows?: number,
|
||||
name?: string,
|
||||
maxLength?: number,
|
||||
required?: boolean,
|
||||
}
|
||||
|
||||
export const SimpleTextarea: React.FC<ISimpleTextarea> = (props) => {
|
||||
const { hint, label, ...rest } = props;
|
||||
const Input = label ? LabelTextarea : 'textarea';
|
||||
|
||||
return (
|
||||
<InputContainer {...props}>
|
||||
<Input {...rest} />
|
||||
</InputContainer>
|
||||
);
|
||||
};
|
||||
|
||||
interface ISimpleForm {
|
||||
className?: string,
|
||||
onSubmit?: React.FormEventHandler,
|
||||
acceptCharset?: string,
|
||||
style?: React.CSSProperties,
|
||||
children: React.ReactNode,
|
||||
}
|
||||
|
||||
export const SimpleForm: React.FC<ISimpleForm> = (props) => {
|
||||
const {
|
||||
className,
|
||||
children,
|
||||
onSubmit = () => {},
|
||||
acceptCharset = 'UTF-8',
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const handleSubmit: React.FormEventHandler = e => {
|
||||
onSubmit(e);
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
className={clsx('simple_form', className)}
|
||||
method='post'
|
||||
onSubmit={handleSubmit}
|
||||
acceptCharset={acceptCharset}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
interface IFieldsGroup {
|
||||
children: React.ReactNode,
|
||||
}
|
||||
|
||||
export const FieldsGroup: React.FC<IFieldsGroup> = ({ children }) => (
|
||||
<div className='fields-group'>{children}</div>
|
||||
);
|
||||
|
||||
interface ICheckbox {
|
||||
label?: React.ReactNode,
|
||||
hint?: React.ReactNode,
|
||||
|
|
|
@ -180,7 +180,7 @@ const GroupMember: React.FC<IGroupMember> = ({ accountId, accountRole, groupId,
|
|||
return <MenuDivider key={idx} />;
|
||||
} else {
|
||||
const Comp = (menuItem.action ? MenuItem : MenuLink) as any;
|
||||
const itemProps = menuItem.action ? { onSelect: menuItem.action } : { to: menuItem.to, as: Link, target: menuItem.newTab ? '_blank' : '_self' };
|
||||
const itemProps = menuItem.action ? { onSelect: menuItem.action } : { to: menuItem.to, as: Link, target: menuItem.target || '_self' };
|
||||
|
||||
return (
|
||||
<Comp key={idx} {...itemProps} className='group'>
|
||||
|
|
|
@ -36,7 +36,7 @@ const ListForm = () => {
|
|||
<Form onSubmit={handleSubmit}>
|
||||
<HStack space={2}>
|
||||
<Input
|
||||
outerClassName='flex-grow'
|
||||
outerClassName='grow'
|
||||
type='text'
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
|
|
|
@ -1,18 +0,0 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import Icon from 'soapbox/components/icon';
|
||||
|
||||
interface IClearColumnButton {
|
||||
onClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
const ClearColumnButton: React.FC<IClearColumnButton> = ({ onClick }) => (
|
||||
<button className='text-btn column-header__setting-btn' tabIndex={0} onClick={onClick}>
|
||||
<Icon src={require('@tabler/icons/eraser.svg')} />
|
||||
{' '}
|
||||
<FormattedMessage id='notifications.clear' defaultMessage='Clear notifications' />
|
||||
</button>
|
||||
);
|
||||
|
||||
export default ClearColumnButton;
|
|
@ -329,6 +329,7 @@ const Notification: React.FC<INotificaton> = (props) => {
|
|||
onMoveDown={handleMoveDown}
|
||||
onMoveUp={handleMoveUp}
|
||||
avatarSize={avatarSize}
|
||||
contextType='notifications'
|
||||
/>
|
||||
) : null;
|
||||
default:
|
||||
|
|
|
@ -78,7 +78,7 @@ const PlaceholderMediaGallery: React.FC<IPlaceholderMediaGallery> = ({ media, de
|
|||
const float = dimensions.float as any || 'left';
|
||||
const position = dimensions.pos as any || 'relative';
|
||||
|
||||
return <div key={i} className='media-gallery__item' style={{ position, float, left, top, right, bottom, height, width }} />;
|
||||
return <div key={i} className='media-gallery__item animate-pulse bg-primary-200' style={{ position, float, left, top, right, bottom, height, width }} />;
|
||||
};
|
||||
|
||||
const sizeData = getSizeData(media.size);
|
||||
|
|
|
@ -1,58 +0,0 @@
|
|||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { getSettings, changeSettingImmediate } from 'soapbox/actions/settings';
|
||||
import List, { ListItem } from 'soapbox/components/list';
|
||||
import { Card, CardBody, CardHeader, CardTitle } from 'soapbox/components/ui';
|
||||
import { SimpleForm, SelectDropdown } from 'soapbox/features/forms';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
const messages = defineMessages({
|
||||
mediaDisplay: { id: 'preferences.fields.media_display_label', defaultMessage: 'Media display' },
|
||||
display_media_default: { id: 'preferences.fields.display_media.default', defaultMessage: 'Hide media marked as sensitive' },
|
||||
display_media_hide_all: { id: 'preferences.fields.display_media.hide_all', defaultMessage: 'Always hide media' },
|
||||
display_media_show_all: { id: 'preferences.fields.display_media.show_all', defaultMessage: 'Always show media' },
|
||||
});
|
||||
|
||||
const MediaDisplay = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
const settings = useAppSelector((state) => getSettings(state));
|
||||
|
||||
const displayMediaOptions = {
|
||||
default: intl.formatMessage(messages.display_media_default),
|
||||
hide_all: intl.formatMessage(messages.display_media_hide_all),
|
||||
show_all: intl.formatMessage(messages.display_media_show_all),
|
||||
};
|
||||
|
||||
const onSelectChange: (path: string[]) => React.ChangeEventHandler<HTMLSelectElement> = path => {
|
||||
return e => {
|
||||
dispatch(changeSettingImmediate(path, e.target.value));
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<Card variant='rounded'>
|
||||
<CardHeader>
|
||||
<CardTitle title={intl.formatMessage(messages.mediaDisplay)} />
|
||||
</CardHeader>
|
||||
|
||||
<CardBody>
|
||||
<SimpleForm className='space-y-3 p-0'>
|
||||
<List>
|
||||
<ListItem label={intl.formatMessage(messages.mediaDisplay)}>
|
||||
<SelectDropdown
|
||||
items={displayMediaOptions}
|
||||
defaultValue={settings.get('displayMedia') as string}
|
||||
onChange={onSelectChange(['displayMedia'])}
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
</SimpleForm>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default MediaDisplay;
|
|
@ -25,21 +25,21 @@ const CryptoAddressInput: StreamfieldComponent<CryptoAddress> = ({ value, onChan
|
|||
<HStack space={2} grow>
|
||||
<Input
|
||||
type='text'
|
||||
outerClassName='w-1/6 flex-grow'
|
||||
outerClassName='w-1/6 grow'
|
||||
value={value.ticker}
|
||||
onChange={handleChange('ticker')}
|
||||
placeholder={intl.formatMessage(messages.ticker)}
|
||||
/>
|
||||
<Input
|
||||
type='text'
|
||||
outerClassName='w-3/6 flex-grow'
|
||||
outerClassName='w-3/6 grow'
|
||||
value={value.address}
|
||||
onChange={handleChange('address')}
|
||||
placeholder={intl.formatMessage(messages.address)}
|
||||
/>
|
||||
<Input
|
||||
type='text'
|
||||
outerClassName='w-2/6 flex-grow'
|
||||
outerClassName='w-2/6 grow'
|
||||
value={value.note}
|
||||
onChange={handleChange('note')}
|
||||
placeholder={intl.formatMessage(messages.note)}
|
||||
|
|
|
@ -24,14 +24,14 @@ const PromoPanelInput: StreamfieldComponent<FooterItem> = ({ value, onChange })
|
|||
<HStack space={2} grow>
|
||||
<Input
|
||||
type='text'
|
||||
outerClassName='w-full flex-grow'
|
||||
outerClassName='w-full grow'
|
||||
placeholder={intl.formatMessage(messages.label)}
|
||||
value={value.title}
|
||||
onChange={handleChange('title')}
|
||||
/>
|
||||
<Input
|
||||
type='text'
|
||||
outerClassName='w-full flex-grow'
|
||||
outerClassName='w-full grow'
|
||||
placeholder={intl.formatMessage(messages.url)}
|
||||
value={value.url}
|
||||
onChange={handleChange('url')}
|
||||
|
|
|
@ -36,14 +36,14 @@ const PromoPanelInput: StreamfieldComponent<PromoPanelItem> = ({ value, onChange
|
|||
|
||||
<Input
|
||||
type='text'
|
||||
outerClassName='w-full flex-grow'
|
||||
outerClassName='w-full grow'
|
||||
placeholder={intl.formatMessage(messages.label)}
|
||||
value={value.text}
|
||||
onChange={handleChange('text')}
|
||||
/>
|
||||
<Input
|
||||
type='text'
|
||||
outerClassName='w-full flex-grow'
|
||||
outerClassName='w-full grow'
|
||||
placeholder={intl.formatMessage(messages.url)}
|
||||
value={value.url}
|
||||
onChange={handleChange('url')}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import classnames from 'clsx';
|
||||
import clsx from 'clsx';
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
|
@ -112,7 +112,7 @@ const Card: React.FC<ICard> = ({
|
|||
|
||||
const interactive = card.type !== 'link';
|
||||
horizontal = typeof horizontal === 'boolean' ? horizontal : interactive || embedded;
|
||||
const className = classnames('status-card', { horizontal, compact, interactive }, `status-card--${card.type}`);
|
||||
const className = clsx('status-card', { horizontal, compact, interactive }, `status-card--${card.type}`);
|
||||
const ratio = getRatio(card);
|
||||
const height = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio);
|
||||
|
||||
|
@ -223,7 +223,7 @@ const Card: React.FC<ICard> = ({
|
|||
);
|
||||
} else if (card.image) {
|
||||
embed = (
|
||||
<div className={classnames(
|
||||
<div className={clsx(
|
||||
'status-card__image',
|
||||
'w-full flex-none rounded-l md:h-auto md:w-auto md:flex-auto',
|
||||
{
|
||||
|
|
|
@ -8,6 +8,7 @@ import { useAppSelector } from 'soapbox/hooks';
|
|||
|
||||
interface IThreadStatus {
|
||||
id: string,
|
||||
contextType?: string,
|
||||
focusedStatusId: string,
|
||||
onMoveUp: (id: string) => void,
|
||||
onMoveDown: (id: string) => void,
|
||||
|
|
|
@ -361,6 +361,7 @@ const Thread: React.FC<IThread> = (props) => {
|
|||
focusedStatusId={status!.id}
|
||||
onMoveUp={handleMoveUp}
|
||||
onMoveDown={handleMoveDown}
|
||||
contextType='thread'
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -5,9 +5,9 @@ import { v4 as uuidv4 } from 'uuid';
|
|||
import { updateSoapboxConfig } from 'soapbox/actions/admin';
|
||||
import { getHost } from 'soapbox/actions/instance';
|
||||
import { fetchSoapboxConfig } from 'soapbox/actions/soapbox';
|
||||
import DropdownMenu from 'soapbox/components/dropdown-menu';
|
||||
import List, { ListItem } from 'soapbox/components/list';
|
||||
import { Button, Column, Form, FormActions } from 'soapbox/components/ui';
|
||||
import DropdownMenuContainer from 'soapbox/containers/dropdown-menu-container';
|
||||
import ColorWithPicker from 'soapbox/features/soapbox-config/components/color-with-picker';
|
||||
import { useAppDispatch, useAppSelector, useSoapboxConfig } from 'soapbox/hooks';
|
||||
import { normalizeSoapboxConfig } from 'soapbox/normalizers';
|
||||
|
@ -194,7 +194,7 @@ const ThemeEditor: React.FC<IThemeEditor> = () => {
|
|||
</List>
|
||||
|
||||
<FormActions>
|
||||
<DropdownMenuContainer
|
||||
<DropdownMenu
|
||||
items={[{
|
||||
text: intl.formatMessage(messages.restore),
|
||||
action: restoreDefaultTheme,
|
||||
|
|
|
@ -39,4 +39,4 @@ const FloatingActionButton: React.FC<IFloatingActionButton> = () => {
|
|||
);
|
||||
};
|
||||
|
||||
export default FloatingActionButton;
|
||||
export default FloatingActionButton;
|
||||
|
|
|
@ -162,4 +162,4 @@ class ImageLoader extends React.PureComponent<IImageLoader> {
|
|||
|
||||
}
|
||||
|
||||
export default ImageLoader;
|
||||
export default ImageLoader;
|
||||
|
|
|
@ -4,8 +4,8 @@ import React from 'react';
|
|||
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import DropdownMenu from 'soapbox/components/dropdown-menu';
|
||||
import { Widget } from 'soapbox/components/ui';
|
||||
import DropdownMenu from 'soapbox/containers/dropdown-menu-container';
|
||||
import InstanceRestrictions from 'soapbox/features/federation-restrictions/components/instance-restrictions';
|
||||
import { useAppSelector, useAppDispatch, useOwnAccount } from 'soapbox/hooks';
|
||||
import { makeGetRemoteInstance } from 'soapbox/selectors';
|
||||
|
|
|
@ -4,9 +4,8 @@ import { FormattedMessage } from 'react-intl';
|
|||
import { spring } from 'react-motion';
|
||||
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import StatusContent from 'soapbox/components/status-content';
|
||||
import { HStack, Stack } from 'soapbox/components/ui';
|
||||
import AccountContainer from 'soapbox/containers/account-container';
|
||||
import { HStack } from 'soapbox/components/ui';
|
||||
import ReplyIndicator from 'soapbox/features/compose/components/reply-indicator';
|
||||
|
||||
import Motion from '../../util/optional-motion';
|
||||
|
||||
|
@ -26,7 +25,7 @@ const ActionsModal: React.FC<IActionsModal> = ({ status, actions, onClick, onClo
|
|||
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
|
||||
}
|
||||
|
||||
const { icon = null, text, meta = null, active = false, href = '#', isLogout, destructive } = action;
|
||||
const { icon = null, text, meta = null, active = false, href = '#', destructive } = action;
|
||||
|
||||
const Comp = href === '#' ? 'button' : 'a';
|
||||
const compProps = href === '#' ? { onClick: onClick } : { href: href, rel: 'noopener' };
|
||||
|
@ -38,7 +37,6 @@ const ActionsModal: React.FC<IActionsModal> = ({ status, actions, onClick, onClo
|
|||
space={2.5}
|
||||
data-index={i}
|
||||
className={clsx('w-full', { active, destructive })}
|
||||
data-method={isLogout ? 'delete' : null}
|
||||
element={Comp}
|
||||
>
|
||||
{icon && <Icon title={text} src={icon} role='presentation' tabIndex={-1} />}
|
||||
|
@ -56,16 +54,7 @@ const ActionsModal: React.FC<IActionsModal> = ({ status, actions, onClick, onClo
|
|||
{({ top }) => (
|
||||
<div className='modal-root__modal actions-modal' style={{ top: `${top}%` }}>
|
||||
{status && (
|
||||
<Stack space={2} className='border-b border-solid border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800'>
|
||||
<AccountContainer
|
||||
key={status.account as string}
|
||||
id={status.account as string}
|
||||
showProfileHoverCard={false}
|
||||
withLinkToProfile={false}
|
||||
timestamp={status.created_at}
|
||||
/>
|
||||
<StatusContent status={status} />
|
||||
</Stack>
|
||||
<ReplyIndicator className='actions-modal__status rounded-b-none' status={status} hideActions />
|
||||
)}
|
||||
|
||||
<ul className={clsx({ 'with-status': !!status })}>
|
||||
|
|
|
@ -3,7 +3,7 @@ import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
|||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import { remoteInteraction } from 'soapbox/actions/interactions';
|
||||
import { Button, Modal, Stack, Text } from 'soapbox/components/ui';
|
||||
import { Button, Form, Input, Modal, Stack, Text } from 'soapbox/components/ui';
|
||||
import { useAppSelector, useAppDispatch, useFeatures, useInstance, useRegistrationStatus } from 'soapbox/hooks';
|
||||
import toast from 'soapbox/toast';
|
||||
|
||||
|
@ -104,9 +104,8 @@ const UnauthorizedModal: React.FC<IUnauthorizedModal> = ({ action, onClose, acco
|
|||
secondaryText={isOpen ? <FormattedMessage id='account.register' defaultMessage='Sign up' /> : undefined}
|
||||
>
|
||||
<div className='remote-interaction-modal__content'>
|
||||
<form className='simple_form remote-interaction-modal__fields' onSubmit={onSubmit}>
|
||||
<input
|
||||
type='text'
|
||||
<Form className='remote-interaction-modal__fields' onSubmit={onSubmit}>
|
||||
<Input
|
||||
placeholder={intl.formatMessage(messages.accountPlaceholder)}
|
||||
name='remote_follow[acct]'
|
||||
value={account}
|
||||
|
@ -116,7 +115,7 @@ const UnauthorizedModal: React.FC<IUnauthorizedModal> = ({ action, onClose, acco
|
|||
required
|
||||
/>
|
||||
<Button type='submit' theme='primary'>{button}</Button>
|
||||
</form>
|
||||
</Form>
|
||||
<div className='remote-interaction-modal__divider'>
|
||||
<Text align='center'>
|
||||
<FormattedMessage id='remote_interaction.divider' defaultMessage='or' />
|
||||
|
|
|
@ -56,7 +56,7 @@ const UploadArea: React.FC<IUploadArea> = ({ active, onClose }) => {
|
|||
<Stack space={3} justifyContent='center' alignItems='center'>
|
||||
<Icon
|
||||
src={require('@tabler/icons/cloud-upload.svg')}
|
||||
className='h-12 w-12 text-white text-opacity-90'
|
||||
className='h-12 w-12 text-white/90'
|
||||
/>
|
||||
|
||||
<Text size='xl' theme='white'>
|
||||
|
|
|
@ -72,7 +72,6 @@ import {
|
|||
Lists,
|
||||
Bookmarks,
|
||||
Settings,
|
||||
MediaDisplay,
|
||||
EditProfile,
|
||||
EditEmail,
|
||||
EditPassword,
|
||||
|
@ -301,7 +300,6 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ children }) =>
|
|||
<WrappedRoute path='/settings/email' page={DefaultPage} component={EditEmail} content={children} />
|
||||
<WrappedRoute path='/settings/password' page={DefaultPage} component={EditPassword} content={children} />
|
||||
<WrappedRoute path='/settings/account' page={DefaultPage} component={DeleteAccount} content={children} />
|
||||
<WrappedRoute path='/settings/media_display' page={DefaultPage} component={MediaDisplay} content={children} />
|
||||
<WrappedRoute path='/settings/mfa' page={DefaultPage} component={MfaForm} exact />
|
||||
<WrappedRoute path='/settings/tokens' page={DefaultPage} component={AuthTokenList} content={children} />
|
||||
<WrappedRoute path='/settings' page={DefaultPage} component={Settings} content={children} />
|
||||
|
@ -357,7 +355,7 @@ const UI: React.FC<IUI> = ({ children }) => {
|
|||
const features = useFeatures();
|
||||
const vapidKey = useAppSelector(state => getVapidKey(state));
|
||||
|
||||
const dropdownMenuIsOpen = useAppSelector(state => state.dropdown_menu.openId !== null);
|
||||
const dropdownMenuIsOpen = useAppSelector(state => state.dropdown_menu.isOpen);
|
||||
const accessToken = useAppSelector(state => getAccessToken(state));
|
||||
const streamingUrl = instance.urls.get('streaming_api');
|
||||
const standalone = useAppSelector(isStandalone);
|
||||
|
|
|
@ -238,10 +238,6 @@ export function Settings() {
|
|||
return import(/* webpackChunkName: "features/settings" */'../../settings');
|
||||
}
|
||||
|
||||
export function MediaDisplay() {
|
||||
return import(/* webpackChunkName: "features/settings" */'../../settings/media-display');
|
||||
}
|
||||
|
||||
export function EditProfile() {
|
||||
return import(/* webpackChunkName: "features/edit_profile" */'../../edit-profile');
|
||||
}
|
||||
|
|
|
@ -51,21 +51,23 @@ const TestApp: FC<any> = ({ children, storeProps, routerProps = {} }) => {
|
|||
};
|
||||
|
||||
return (
|
||||
<Provider store={props.store}>
|
||||
<MemoryRouter {...routerProps}>
|
||||
<StatProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ChatProvider>
|
||||
<IntlProvider locale={props.locale}>
|
||||
{children}
|
||||
<div id='soapbox'>
|
||||
<Provider store={props.store}>
|
||||
<MemoryRouter {...routerProps}>
|
||||
<StatProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ChatProvider>
|
||||
<IntlProvider locale={props.locale}>
|
||||
{children}
|
||||
|
||||
<Toaster />
|
||||
</IntlProvider>
|
||||
</ChatProvider>
|
||||
</QueryClientProvider>
|
||||
</StatProvider>
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
<Toaster />
|
||||
</IntlProvider>
|
||||
</ChatProvider>
|
||||
</QueryClientProvider>
|
||||
</StatProvider>
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -57,7 +57,7 @@
|
|||
"account.unblock": "@{name} entblocken",
|
||||
"account.unblock_domain": "{domain} wieder anzeigen",
|
||||
"account.unendorse": "Nicht auf Profil hervorheben",
|
||||
"account.unendorse.success": "Du bietest @{acct} nicht mehr an",
|
||||
"account.unendorse.success": "Du schlägst @{acct} nicht mehr vor",
|
||||
"account.unfollow": "Entfolgen",
|
||||
"account.unmute": "Stummsch. aufheben",
|
||||
"account.unsubscribe": "Benachrichtigungen von @{name} entabonnieren",
|
||||
|
@ -139,7 +139,7 @@
|
|||
"admin_nav.awaiting_approval": "Wartet auf Bestätigung",
|
||||
"admin_nav.dashboard": "Steuerung",
|
||||
"admin_nav.reports": "Beschwerden",
|
||||
"age_verification.body": "{siteTitle} erfordert, dass Benutzer mindestens {ageMinimum, plural, one {# year} other {# years}} Jahre alt sind, um auf die Plattform zugreifen zu können. Personen unter {ageMinimum, plural, one {# year} other {# years}} alt können nicht auf diese Plattform zugreifen.",
|
||||
"age_verification.body": "{siteTitle} erfordert, dass Mitglieder mindestens {ageMinimum, plural, one {# year} other {# years}} Jahre alt sind, um an der Plattform teilzunehmen. Personen unter {ageMinimum, plural, one {# year} other {# years}} alt können nicht Mitglied werden.",
|
||||
"age_verification.fail": "Du musst {ageMinimum, plural, one {# Jahr} other {# Jahre}} alt oder älter sein.",
|
||||
"age_verification.header": "Gib dein Geburtsdatum ein",
|
||||
"alert.unexpected.body": "Wir bedauern die Störung. Wende dich an den Support, wenn das Problem über längere Zeit besteht. Möglicherweise hilft es, {clearCookies}. Hierdurch wirst du abgemeldet.",
|
||||
|
@ -169,7 +169,7 @@
|
|||
"app_create.scopes_placeholder": "z.B. 'lesen schreiben folgen'",
|
||||
"app_create.submit": "App erstellen",
|
||||
"app_create.website_label": "Webseite",
|
||||
"auth.awaiting_approval": "Dein Konto ist noch nicht genehmigt",
|
||||
"auth.awaiting_approval": "Dein Konto wurde noch nicht genehmigt",
|
||||
"auth.invalid_credentials": "Falsches Passwort oder falscher Nutzername",
|
||||
"auth.logged_out": "Abgemeldet.",
|
||||
"auth_layout.register": "Ein Konto erstellen",
|
||||
|
@ -199,30 +199,30 @@
|
|||
"chat.page_settings.privacy": "Privatsphäre",
|
||||
"chat.page_settings.submit": "Speichern",
|
||||
"chat.page_settings.title": "Nachrichteneinstellungen",
|
||||
"chat.retry": "Wiederholen?",
|
||||
"chat.retry": "Erneut versuchen?",
|
||||
"chat.welcome.accepting_messages.label": "Erlaube Benutzer mit dir einen neuen Chat zu starten",
|
||||
"chat.welcome.notice": "Du kannst diese Einstellungen später ändern.",
|
||||
"chat.welcome.submit": "Speichern & Fortfahren",
|
||||
"chat.welcome.subtitle": "Direkte Nachrichten mit anderen Nutzern austauschen.",
|
||||
"chat.welcome.submit": "Speichern & fortfahren",
|
||||
"chat.welcome.subtitle": "Direktnachrichten mit anderen Nutzern austauschen.",
|
||||
"chat.welcome.title": "Willkommen zu {br} Chats!",
|
||||
"chat_composer.unblock": "Entblocken",
|
||||
"chat_list_item.blocked_you": "Dieser Benutzer hat dich blockiert",
|
||||
"chat_list_item.blocked_you": "Dieser Nutzer hat dich blockiert",
|
||||
"chat_list_item.blocking": "Du hast diesen Benutzer blockiert",
|
||||
"chat_message_list.blocked": "Du blockiertest diesen Benutzer",
|
||||
"chat_message_list.blockedBy": "Du bist blockiert von",
|
||||
"chat_message_list.blocked": "Du hast diesen Nutzer blockiert",
|
||||
"chat_message_list.blockedBy": "Du wurdest blockiert von",
|
||||
"chat_message_list.network_failure.action": "Erneut versuchen",
|
||||
"chat_message_list.network_failure.subtitle": "Wir haben einen Netzwerkfehler festgestellt.",
|
||||
"chat_message_list.network_failure.title": "Huch!",
|
||||
"chat_message_list_intro.actions.accept": "Akzeptieren",
|
||||
"chat_message_list_intro.actions.leave_chat": "Chat verlassen",
|
||||
"chat_message_list_intro.actions.message_lifespan": "Nachrichten, die älter als {day, plural, one {# day} other {# days}} Tage sind, werden gelöscht.",
|
||||
"chat_message_list_intro.actions.report": "Meldung",
|
||||
"chat_message_list_intro.intro": "möchte einen Chat mit Dir beginnen",
|
||||
"chat_message_list_intro.actions.report": "Melden",
|
||||
"chat_message_list_intro.intro": "möchte mit dir chatten",
|
||||
"chat_message_list_intro.leave_chat.confirm": "Chat verlassen",
|
||||
"chat_message_list_intro.leave_chat.heading": "Chat verlassen",
|
||||
"chat_message_list_intro.leave_chat.message": "Bist Du sicher, dass Du diesen Chat verlassen möchtest? Alle Nachrichten werden gelöscht und dieser Chat wird aus deinem Postfach entfernt.",
|
||||
"chat_search.blankslate.body": "Suche nach jemandem zum Chatten.",
|
||||
"chat_search.blankslate.title": "Einen Chat beginnen",
|
||||
"chat_message_list_intro.leave_chat.message": "Bist du sicher, dass du diesen Chat verlassen möchtest? Alle Nachrichten werden gelöscht und dieser Chat wird aus deinen Nachrichten entfernt.",
|
||||
"chat_search.blankslate.body": "Suche nach jemandem zum chatten.",
|
||||
"chat_search.blankslate.title": "Neuen Chat eröffnen",
|
||||
"chat_search.empty_results_blankslate.action": "Schreibe jemandem",
|
||||
"chat_search.empty_results_blankslate.body": "Versuche, nach einem anderen Namen zu suchen.",
|
||||
"chat_search.empty_results_blankslate.title": "Keine Treffer gefunden",
|
||||
|
@ -237,19 +237,19 @@
|
|||
"chat_settings.auto_delete.hint": "Gesendete Nachrichten werden nach der gewählten Zeitspanne automatisch gelöscht",
|
||||
"chat_settings.auto_delete.label": "Automatisches Löschen von Nachrichten",
|
||||
"chat_settings.block.confirm": "Blockieren",
|
||||
"chat_settings.block.heading": "Blockieren",
|
||||
"chat_settings.block.message": "Durch das Blockieren wird dieses Profil daran gehindert, dir direkte Nachrichten zu senden und deine Inhalte anzusehen. Du kannst die Blockierung später aufheben.",
|
||||
"chat_settings.block.heading": "@{acct} blockieren",
|
||||
"chat_settings.block.message": "Durch das Blockieren wird dieses Profil daran gehindert, dir Nachrichten zu senden und deine Inhalte anzusehen. Du kannst die Blockierung später aufheben.",
|
||||
"chat_settings.leave.confirm": "Chat verlassen",
|
||||
"chat_settings.leave.heading": "Chat verlassen",
|
||||
"chat_settings.leave.message": "Bist du sicher, dass du diesen Chat verlassen willst? Die Nachrichten werden für dich gelöscht und dieser Chat wird aus deinem Posteingang entfernt.",
|
||||
"chat_settings.options.block_user": "Blockiere @{acct}",
|
||||
"chat_settings.options.block_user": "@{acct} blockieren",
|
||||
"chat_settings.options.leave_chat": "Chat verlassen",
|
||||
"chat_settings.options.report_user": "Melden",
|
||||
"chat_settings.options.unblock_user": "Entblocke @{acct}",
|
||||
"chat_settings.title": "Chateinzelheiten",
|
||||
"chat_settings.options.report_user": "@{acct} melden",
|
||||
"chat_settings.options.unblock_user": "@{acct} nicht mehr blockieren",
|
||||
"chat_settings.title": "Chat-Details",
|
||||
"chat_settings.unblock.confirm": "Entblocken",
|
||||
"chat_settings.unblock.heading": "Entblocke@{acct}",
|
||||
"chat_settings.unblock.message": "Wenn du die Blockierung aufhebst, kann dieses Profil dir direkte Nachrichten schicken und deine Inhalte sehen.",
|
||||
"chat_settings.unblock.heading": "Entblocke @{acct}",
|
||||
"chat_settings.unblock.message": "Wenn du die Blockierung aufhebst, kann dieser Nutzer dir Nachrichten schicken und deine Inhalte sehen.",
|
||||
"chat_window.auto_delete_label": "Automatisch löschen nach {day, plural, one {# Tag} other {# Tagen}}",
|
||||
"chat_window.auto_delete_tooltip": "Chatnachrichten werden nach {day, plural, one {# Tag} other {# Tagen}} nach dem Senden automatisch gelöscht.",
|
||||
"chats.actions.copy": "Kopieren",
|
||||
|
@ -258,10 +258,10 @@
|
|||
"chats.actions.more": "Mehr",
|
||||
"chats.actions.report": "Nutzer melden",
|
||||
"chats.dividers.today": "Heute",
|
||||
"chats.main.blankslate.new_chat": "Jemanden anschreiben",
|
||||
"chats.main.blankslate.new_chat": "Nachricht verfassen",
|
||||
"chats.main.blankslate.subtitle": "Jemanden zum chatten suchen",
|
||||
"chats.main.blankslate.title": "Noch keine Nachrichten",
|
||||
"chats.main.blankslate_with_chats.subtitle": "Wähle aus einem deiner offenen Chats oder erstelle eine neue Nachricht.",
|
||||
"chats.main.blankslate_with_chats.subtitle": "Wähle aus einem deiner bestehenden Chats oder erstelle eine neue Nachricht.",
|
||||
"chats.main.blankslate_with_chats.title": "Chat auswählen",
|
||||
"chats.search_placeholder": "Chatten mit…",
|
||||
"column.admin.awaiting_approval": "Wartet auf Bestätigung",
|
||||
|
@ -352,37 +352,37 @@
|
|||
"common.cancel": "Abbrechen",
|
||||
"common.error": "Ein Fehler ist aufgetreten. Versuche die Seite neu zu laden.",
|
||||
"compare_history_modal.header": "Verlauf bearbeiten",
|
||||
"compose.character_counter.title": "{chars} von {maxChars} Zeichen verwendet",
|
||||
"compose.character_counter.title": "{chars} von {maxChars} zulässigen Zeichen verwendet",
|
||||
"compose.edit_success": "Dein Beitrag wurde bearbeitet",
|
||||
"compose.invalid_schedule": "Der gewählte Zeitpunkt für vorbereitete Beiträge muss mindesten 5 Minuten in der Zukunft liegen.",
|
||||
"compose.submit_success": "Beitrag gesendet!",
|
||||
"compose_event.create": "Erstellen",
|
||||
"compose_event.edit_success": "Deine Veranstaltung wurde bearbeitet",
|
||||
"compose_event.fields.approval_required": "Ich möchte Teilnahmeanträge manuell genehmigen",
|
||||
"compose_event.fields.approval_required": "Ich möchte Teilnehmende manuell genehmigen",
|
||||
"compose_event.fields.banner_label": "Veranstaltungsbanner",
|
||||
"compose_event.fields.description_hint": "Markdownsyntax wird unterstützt",
|
||||
"compose_event.fields.description_label": "Veranstaltungsbeschreibung",
|
||||
"compose_event.fields.description_placeholder": "Beschreibung",
|
||||
"compose_event.fields.end_time_label": "Veranstaltungsenddatum",
|
||||
"compose_event.fields.end_time_label": "Veranstaltungsende",
|
||||
"compose_event.fields.end_time_placeholder": "Veranstaltung endet am…",
|
||||
"compose_event.fields.has_end_time": "Die Veranstaltung hat Endtermin",
|
||||
"compose_event.fields.has_end_time": "Die Veranstaltung endet zu einer bestimmten Zeit",
|
||||
"compose_event.fields.location_label": "Veranstaltungsort",
|
||||
"compose_event.fields.name_label": "Veranstaltungsname",
|
||||
"compose_event.fields.name_placeholder": "Name",
|
||||
"compose_event.fields.start_time_label": "Veranstaltungsstarttermin",
|
||||
"compose_event.fields.start_time_label": "Veranstaltungsstart",
|
||||
"compose_event.fields.start_time_placeholder": "Veranstaltung beginnt am…",
|
||||
"compose_event.participation_requests.authorize": "Autorisieren",
|
||||
"compose_event.participation_requests.authorize_success": "Benutzer akzeptiert",
|
||||
"compose_event.participation_requests.authorize": "Zulassen",
|
||||
"compose_event.participation_requests.authorize_success": "Nutzer akzeptiert",
|
||||
"compose_event.participation_requests.reject": "Ablehnen",
|
||||
"compose_event.participation_requests.reject_success": "Benutzer abgelehnt",
|
||||
"compose_event.participation_requests.reject_success": "Nutzer abgelehnt",
|
||||
"compose_event.reset_location": "Standort zurücksetzen",
|
||||
"compose_event.submit_success": "Deine Veranstaltung wurde erstellt",
|
||||
"compose_event.tabs.edit": "Einzelheiten bearbeiten",
|
||||
"compose_event.tabs.edit": "Details bearbeiten",
|
||||
"compose_event.tabs.pending": "Anfragen verwalten",
|
||||
"compose_event.update": "Updaten",
|
||||
"compose_event.update": "Aktualisieren",
|
||||
"compose_event.upload_banner": "Veranstaltungsbanner hochladen",
|
||||
"compose_form.direct_message_warning": "Dieser Beitrag wird nur für die erwähnten Nutzer sichtbar sein.",
|
||||
"compose_form.event_placeholder": "Zu dieser Veranstaltung beitragen",
|
||||
"compose_form.event_placeholder": "In dieser Veranstaltung posten",
|
||||
"compose_form.hashtag_warning": "Dieser Beitrag wird nicht durch Hashtags auffindbar sein, weil er ungelistet ist. Nur öffentliche Beiträge tauchen in Hashtag-Zeitleisten auf.",
|
||||
"compose_form.lock_disclaimer": "Dein Profil ist nicht {locked}. Wer dir folgen will, kann das jederzeit tun und dann auch deine privaten Beiträge sehen.",
|
||||
"compose_form.lock_disclaimer.lock": "auf privat gestellt",
|
||||
|
@ -437,39 +437,40 @@
|
|||
"confirmations.block.message": "Bist du dir sicher, dass du {name} blockieren möchtest?",
|
||||
"confirmations.block_from_group.confirm": "Blockieren",
|
||||
"confirmations.block_from_group.heading": "Gruppenmitglied blockieren",
|
||||
"confirmations.block_from_group.message": "Möchtest du @{name} wirklich aus dieser Gruppe ausschließen?",
|
||||
"confirmations.cancel.confirm": "Verwerfen",
|
||||
"confirmations.cancel.heading": "Beitrag verwerfen",
|
||||
"confirmations.cancel.message": "Bist du sicher, dass du die Erstellung dieses Beitrags abbrechen willst?",
|
||||
"confirmations.cancel.message": "Bist du sicher, dass du diesen Entwurf löschen möchtest?",
|
||||
"confirmations.cancel_editing.confirm": "Bearbeitung abbrechen",
|
||||
"confirmations.cancel_editing.heading": "Beitragsbearbeitung abbrechen",
|
||||
"confirmations.cancel_editing.message": "Bist du sicher, dass du die Bearbeitung dieses Beitrags abbrechen willst? Alle Änderungen gehen dann verloren.",
|
||||
"confirmations.cancel_event_editing.heading": "Veranstaltungsbearbeitung abbrechen",
|
||||
"confirmations.cancel_event_editing.message": "Bist du sicher, dass du die Bearbeitung dieser Veranstaltung abbrechen willst? Alle Änderungen gehen dann verloren.",
|
||||
"confirmations.cancel_event_editing.message": "Bist du sicher, dass du die Bearbeitung dieser Veranstaltung abbrechen willst? Alle Änderungen gehen hierdurch verloren.",
|
||||
"confirmations.delete.confirm": "Löschen",
|
||||
"confirmations.delete.heading": "Beitrag löschen",
|
||||
"confirmations.delete.message": "Bist du dir sicher, dass du diesen Beitrag löschen möchtest?",
|
||||
"confirmations.delete_event.confirm": "Löschen",
|
||||
"confirmations.delete_event.heading": "Lösche Veranstaltung",
|
||||
"confirmations.delete_event.message": "Bist du sicher, dass du dieses Ereignis löschen willst?",
|
||||
"confirmations.delete_event.heading": "Veranstaltung löschen",
|
||||
"confirmations.delete_event.message": "Bist du sicher, dass du diese Veranstaltung löschen willst?",
|
||||
"confirmations.delete_from_group.heading": "Aus der Gruppe löschen",
|
||||
"confirmations.delete_from_group.message": "Soll der Beitrag von @{name} wirklich gelöscht werden?",
|
||||
"confirmations.delete_group.confirm": "Löschen",
|
||||
"confirmations.delete_group.heading": "Gruppe löschen",
|
||||
"confirmations.delete_group.message": "Soll diese Gruppe wirklich gelöscht werden? Die Löschung kann nicht rückgängig gemacht werden.",
|
||||
"confirmations.delete_group.message": "Soll diese Gruppe wirklich gelöscht werden? Die Entfernung kann nicht rückgängig gemacht werden.",
|
||||
"confirmations.delete_list.confirm": "Löschen",
|
||||
"confirmations.delete_list.heading": "Liste löschen",
|
||||
"confirmations.delete_list.message": "Bist du dir sicher, dass du diese Liste permanent löschen möchtest?",
|
||||
"confirmations.domain_block.confirm": "Die ganze Domain verbergen",
|
||||
"confirmations.domain_block.heading": "Blockiere {domain}",
|
||||
"confirmations.domain_block.message": "Bist du dir wirklich sicher, dass du die ganze Domain {domain} blockieren willst? In den meisten Fällen reichen ein paar gezielte Blockierungen oder Stummschaltungen aus. Du wirst den Inhalt von dieser Domain dann nicht mehr in irgendwelchen öffentlichen Timelines oder den Benachrichtigungen finden. Von dieser Domain kann dir auch niemand mehr folgen.",
|
||||
"confirmations.kick_from_group.confirm": "Entfernen",
|
||||
"confirmations.kick_from_group.heading": "Gruppenmitglied entfernen",
|
||||
"confirmations.kick_from_group.confirm": "Rauswerfen",
|
||||
"confirmations.kick_from_group.heading": "Gruppenmitglied rauswerfen",
|
||||
"confirmations.kick_from_group.message": "@{name} wirklich aus der Gruppe entfernen?",
|
||||
"confirmations.leave_event.confirm": "Verlasse Veranstaltung",
|
||||
"confirmations.leave_event.confirm": "Veranstaltung verlassen",
|
||||
"confirmations.leave_event.message": "Wenn du der Veranstaltung wieder beitreten möchtest, wird der Antrag erneut manuell geprüft. Bist du sicher, dass du fortfahren möchtest?",
|
||||
"confirmations.leave_group.confirm": "Verlassen",
|
||||
"confirmations.leave_group.heading": "Gruppe verlassen",
|
||||
"confirmations.leave_group.message": "Gruppe wirklich dauerhaft verlassen?",
|
||||
"confirmations.leave_group.message": "Gruppe wirklich verlassen?",
|
||||
"confirmations.mute.confirm": "Stummschalten",
|
||||
"confirmations.mute.heading": "Stummschalten",
|
||||
"confirmations.mute.message": "Bist du dir sicher, dass du {name} stummschalten möchtest?",
|
||||
|
@ -618,13 +619,16 @@
|
|||
"empty_column.community": "Die lokale Zeitleiste ist leer. Schreibe selbst den ersten Beitrag!",
|
||||
"empty_column.direct": "Du hast noch keine Direktnachrichten erhalten. Wenn du eine sendest oder empfängst, wird sie hier zu sehen sein.",
|
||||
"empty_column.domain_blocks": "Bisher wurden noch keine Domains blockiert.",
|
||||
"empty_column.event_participant_requests": "Es sind keine Anträge auf Teilnahme an Veranstaltungen anhängig.",
|
||||
"empty_column.event_participants": "Bisher ist noch niemand diesem Ereignis beigetreten. Wenn es jemand tut, wird er hier auftauchen.",
|
||||
"empty_column.event_participant_requests": "Es stehen keine Personen zu dieser Veranstaltungen aus.",
|
||||
"empty_column.event_participants": "Bisher ist noch niemand dieser Veranstaltung beigetreten. Wenn es jemand tut, wird sie bzw. er hier auftauchen.",
|
||||
"empty_column.favourited_statuses": "Du hast noch keine Beiträge favorisiert. Favorisierte Beiträge erscheinen hier.",
|
||||
"empty_column.favourites": "Diesen Beitrag hat noch niemand favorisiert. Sobald es jemand tut, wird das hier angezeigt.",
|
||||
"empty_column.filters": "Du hast keine Wörter stummgeschaltet.",
|
||||
"empty_column.follow_recommendations": "Sieht so aus, als gibt es gerade keine Vorschläge für dich. Versuche, über die Suche bekannte Personen zu finden oder schaue dich in aktuellen Hashtags um.",
|
||||
"empty_column.follow_requests": "Du hast noch keine Folgeanfragen. Sobald du eine erhältst, wird sie hier angezeigt.",
|
||||
"empty_column.group": "Es gibt in dieser Gruppe noch keine Beiträge.",
|
||||
"empty_column.group_blocks": "In dieser Gruppe wurden noch keine Personen blockiert.",
|
||||
"empty_column.group_membership_requests": "Es gibt in dieser Gruppe noch keine ausstehenden Mitglieder.",
|
||||
"empty_column.hashtag": "Unter diesem Hashtag gibt es noch nichts.",
|
||||
"empty_column.home": "Deine Startseite ist leer! Besuche {public} oder nutze die Suche, um andere Nutzer zu finden.",
|
||||
"empty_column.home.local_tab": "den Reiter {site_title}",
|
||||
|
@ -640,6 +644,7 @@
|
|||
"empty_column.remote": "Hier gibt es nichts! Verfolge manuell die Benutzer von {instance}, um es aufzufüllen.",
|
||||
"empty_column.scheduled_statuses": "Bisher wurden keine vorbereiteten Beiträge erstellt. Vorbereitete Beiträge werden hier angezeigt.",
|
||||
"empty_column.search.accounts": "Es wurden keine Nutzer unter \"{term}\" gefunden",
|
||||
"empty_column.search.groups": "Es wurden keine Gruppen bei der Suche nach \"{term}\" gefunden",
|
||||
"empty_column.search.hashtags": "Es wurden keine Hashtags unter \"{term}\" gefunden",
|
||||
"empty_column.search.statuses": "Es wurden keine Beiträge unter \"{term}\" gefunden",
|
||||
"empty_column.test": "Die Testzeitleiste ist leer.",
|
||||
|
@ -647,26 +652,26 @@
|
|||
"event.copy": "Link zur Veranstaltung kopieren",
|
||||
"event.date": "Datum",
|
||||
"event.description": "Beschreibung",
|
||||
"event.discussion.empty": "Bisher hat noch niemand diese Veranstaltung kommentiert. Wenn dies jemand tut, wird er hier erscheinen.",
|
||||
"event.discussion.empty": "Bisher hat noch niemand diese Veranstaltung kommentiert. Wenn jemand einen Kommentar erstellt, wird er hier erscheinen.",
|
||||
"event.export_ics": "Zum Kalender exportieren",
|
||||
"event.external": "Veranstaltung auf {domain} anzeigen",
|
||||
"event.join_state.accept": "Gehen hin",
|
||||
"event.join_state.empty": "Nehmen teil",
|
||||
"event.join_state.accept": "Teilnehmen",
|
||||
"event.join_state.empty": "Teilnehmen",
|
||||
"event.join_state.pending": "Ausstehend",
|
||||
"event.join_state.rejected": "Gehen hin",
|
||||
"event.join_state.rejected": "Nehme teil",
|
||||
"event.location": "Ort",
|
||||
"event.manage": "Verwalten",
|
||||
"event.organized_by": "Organisiert von {name}",
|
||||
"event.organized_by": "Veranstaltet von {name}",
|
||||
"event.participants": "{count} {rawCount, plural, eine {person} andere {people}} gehen",
|
||||
"event.quote": "Veranstaltung zitieren",
|
||||
"event.reblog": "Veranstaltung teilen",
|
||||
"event.show_on_map": "Auf Karte anzeigen",
|
||||
"event.unreblog": "Veranstaltung unteilen",
|
||||
"event.website": "Externe links",
|
||||
"event.unreblog": "Veranstaltung nicht mehr teilen",
|
||||
"event.website": "Externe Links",
|
||||
"event_map.navigate": "Navigieren",
|
||||
"events.create_event": "Veranstaltung erstellen",
|
||||
"events.joined_events": "Beigetretene Veranstaltungen",
|
||||
"events.joined_events.empty": "Du bist noch keiner Veranstaltung beigetreten.",
|
||||
"events.joined_events": "Veranstaltungen, an denen ich teilnehme",
|
||||
"events.joined_events.empty": "Du hast bisher noch an keiner Veranstaltung teilgenommen.",
|
||||
"events.recent_events": "Kürzliche Veranstaltungen",
|
||||
"events.recent_events.empty": "Es gibt noch keine öffentlichen Veranstaltungen.",
|
||||
"export_data.actions.export": "Exportieren",
|
||||
|
@ -715,6 +720,42 @@
|
|||
"gdpr.message": "{siteTitle} verwendet Sitzungscookies, die für das Funktionieren der Website unerlässlich sind.",
|
||||
"gdpr.title": "{siteTitle} verwendet Cookies",
|
||||
"getting_started.open_source_notice": "{code_name} ist quelloffene Software. Du kannst auf GitLab unter {code_link} (v{code_version}) mitarbeiten oder Probleme melden.",
|
||||
"group.admin_subheading": "Gruppenadministratoren",
|
||||
"group.cancel_request": "Anfrage zurückziehen",
|
||||
"group.group_mod_authorize": "Annehmen",
|
||||
"group.group_mod_authorize.success": "Aufnahmeanfrage von @{name} annehmen",
|
||||
"group.group_mod_block": "@{name} aus der Gruppe blockieren",
|
||||
"group.group_mod_block.success": "@{name} in der Gruppe blockiert",
|
||||
"group.group_mod_demote": "@{name} herunterstufen",
|
||||
"group.group_mod_demote.success": "@{name} heruntergestuft",
|
||||
"group.group_mod_kick": "@{name} aus der Gruppe werfen",
|
||||
"group.group_mod_kick.success": "@{name} aus der Gruppe entfernt",
|
||||
"group.group_mod_promote_admin": "@{name} zum Gruppenadmin ernennen",
|
||||
"group.group_mod_promote_admin.success": "@{name} als Gruppen-Admin ernannt",
|
||||
"group.group_mod_promote_mod": "@{name} zur Moderator:in der Gruppe ernennen",
|
||||
"group.group_mod_promote_mod.success": "@{name} als Moderator:in ernannt",
|
||||
"group.group_mod_reject": "Ablehnen",
|
||||
"group.group_mod_reject.success": "@{name} in der Gruppe abgelehnt",
|
||||
"group.group_mod_unblock": "Entblocken",
|
||||
"group.group_mod_unblock.success": "@{name} in der Gruppe entblockt",
|
||||
"group.header.alt": "Gruppentitel",
|
||||
"group.join": "Gruppe beitreten",
|
||||
"group.join.request_success": "Mitgliedschaft in der Gruppe angefragt",
|
||||
"group.join.success": "Gruppe beigetreten",
|
||||
"group.leave": "Gruppe verlassen",
|
||||
"group.leave.success": "Gruppe verlassen",
|
||||
"group.manage": "Gruppe verwalten",
|
||||
"group.moderator_subheading": "Moderator:innen der Gruppe",
|
||||
"group.privacy.locked": "Privat",
|
||||
"group.privacy.public": "Öffentlich",
|
||||
"group.request_join": "Mitgliedschaft in der Gruppe anfragen",
|
||||
"group.role.admin": "Administrator:in",
|
||||
"group.role.moderator": "Moderator:in",
|
||||
"group.tabs.all": "Alle",
|
||||
"group.tabs.members": "Mitglieder",
|
||||
"group.user_subheading": "Nutzer:innen",
|
||||
"groups.empty.subtitle": "Entdecke Gruppen zum teilnehmen oder erstelle deine eigene.",
|
||||
"groups.empty.title": "Noch keine Gruppen",
|
||||
"hashtag.column_header.tag_mode.all": "und {additional}",
|
||||
"hashtag.column_header.tag_mode.any": "oder {additional}",
|
||||
"hashtag.column_header.tag_mode.none": "ohne {additional}",
|
||||
|
@ -749,7 +790,7 @@
|
|||
"intervals.full.days": "{number, plural, one {# Tag} other {# Tage}}",
|
||||
"intervals.full.hours": "{number, plural, one {# Stunde} other {# Stunden}}",
|
||||
"intervals.full.minutes": "{number, plural, one {# Minute} other {# Minuten}}",
|
||||
"join_event.hint": "Du kannst dem Veranstalter mitteilen, warum du an dieser Veranstaltung teilnehmen möchtest:",
|
||||
"join_event.hint": "Du kannst dem Veranstalter mitteilen, warum du an dieser Veranstaltung teilnehmen möchtest:",
|
||||
"join_event.join": "Beitritt beantragen",
|
||||
"join_event.placeholder": "Nachricht an Veranstalter",
|
||||
"join_event.request_success": "Veranstaltungsbeitrittsantrag gesendet",
|
||||
|
@ -818,6 +859,27 @@
|
|||
"login_external.errors.instance_fail": "Die Instanz gab einen Fehler zurück.",
|
||||
"login_external.errors.network_fail": "Verbindung fehlgeschlagen. Wird sie durch eine Browsererweiterung blockiert?",
|
||||
"login_form.header": "Sign In",
|
||||
"manage_group.blocked_members": "Blockierte Mitglieder",
|
||||
"manage_group.create": "Erstellen",
|
||||
"manage_group.delete_group": "Gruppe löschen",
|
||||
"manage_group.edit_group": "Gruppe bearbeiten",
|
||||
"manage_group.edit_success": "Gruppe wurde bearbeitet",
|
||||
"manage_group.fields.description_label": "Beschreibung",
|
||||
"manage_group.fields.description_placeholder": "Beschreibung",
|
||||
"manage_group.fields.name_label": "Gruppennname (Pflichtfeld)",
|
||||
"manage_group.fields.name_placeholder": "Gruppenname",
|
||||
"manage_group.get_started": "Lass uns loslegen!",
|
||||
"manage_group.next": "Weiter",
|
||||
"manage_group.pending_requests": "Ausstehende Anfragen",
|
||||
"manage_group.privacy.hint": "Diese Einstellungen könnnen später nicht geändert werden.",
|
||||
"manage_group.privacy.label": "Privatsphäreneinstellungen",
|
||||
"manage_group.privacy.private.hint": "Gelistet. Leute können teilnehmen, nachdem ihre Anfrage bestätigt wurde.",
|
||||
"manage_group.privacy.private.label": "Privat (Bestätigung durch Veranstalter erforderlich)",
|
||||
"manage_group.privacy.public.hint": "Gelistet. Jede:r kann teilnehmen.",
|
||||
"manage_group.privacy.public.label": "Öffentlich",
|
||||
"manage_group.submit_success": "Die Gruppe wurde erstellt",
|
||||
"manage_group.tagline": "Gruppen ermöglichen es dir, neue Leute auf Grundlage gemeinsamer Interessen zu finden.",
|
||||
"manage_group.update": "Update",
|
||||
"media_panel.empty_message": "Keine Medien gefunden.",
|
||||
"media_panel.title": "Media",
|
||||
"mfa.confirm.success_message": "MFA bestätigt",
|
||||
|
@ -886,6 +948,7 @@
|
|||
"navigation_bar.create_event": "Neue Veranstaltung erstellen",
|
||||
"navigation_bar.create_group": "Gruppe erstellen",
|
||||
"navigation_bar.domain_blocks": "Versteckte Domains",
|
||||
"navigation_bar.edit_group": "Gruppe bearbeiten",
|
||||
"navigation_bar.favourites": "Favoriten",
|
||||
"navigation_bar.filters": "Stummgeschaltete Wörter",
|
||||
"navigation_bar.follow_requests": "Folgeanfragen",
|
||||
|
@ -897,6 +960,9 @@
|
|||
"navigation_bar.preferences": "Einstellungen",
|
||||
"navigation_bar.profile_directory": "Profilverzeichnis",
|
||||
"navigation_bar.soapbox_config": "Soapbox-Konfiguration",
|
||||
"new_group_panel.action": "Gruppe erstellen",
|
||||
"new_group_panel.subtitle": "Du findest nicht, wonach du gesucht hast? Erstelle deine eigene private oder öffentliche Gruppe.",
|
||||
"new_group_panel.title": "Neue Gruppe erstellen",
|
||||
"notification.favourite": "{name} hat deinen Beitrag favorisiert",
|
||||
"notification.follow": "{name} folgt dir",
|
||||
"notification.follow_request": "{name} möchte dir folgen",
|
||||
|
@ -1127,6 +1193,7 @@
|
|||
"search.placeholder": "Suche",
|
||||
"search_results.accounts": "Personen",
|
||||
"search_results.filter_message": "Du suchst nach Beiträgen von @{acct}.",
|
||||
"search_results.groups": "Gruppen",
|
||||
"search_results.hashtags": "Hashtags",
|
||||
"search_results.statuses": "Beiträge",
|
||||
"security.codes.fail": "Abrufen von Sicherheitskopiecodes fehlgeschlagen",
|
||||
|
@ -1237,6 +1304,8 @@
|
|||
"sponsored.subtitle": "Werbebeitrag",
|
||||
"status.admin_account": "Öffne Moderationsoberfläche für @{name}",
|
||||
"status.admin_status": "Öffne Beitrag in der Moderationsoberfläche",
|
||||
"status.approval.pending": "Ausstehende Anfrage",
|
||||
"status.approval.rejected": "Abgelehnt",
|
||||
"status.bookmark": "Lesezeichen",
|
||||
"status.bookmarked": "Lesezeichen angelegt.",
|
||||
"status.cancel_reblog_private": "Teilen zurücknehmen",
|
||||
|
@ -1246,11 +1315,16 @@
|
|||
"status.delete": "Löschen",
|
||||
"status.detailed_status": "Detaillierte Ansicht der Unterhaltung",
|
||||
"status.direct": "Direktnachricht",
|
||||
"status.disabled_replies.group_membership": "Nur Gruppenmitglieder können antworten",
|
||||
"status.edit": "Bearbeiten",
|
||||
"status.embed": "Einbetten",
|
||||
"status.external": "Öffne auf Heimatdomäne",
|
||||
"status.favourite": "Favorisieren",
|
||||
"status.filtered": "Gefiltert",
|
||||
"status.group": "Gepostet in {group}",
|
||||
"status.group_mod_block": "@{name} in der Gruppe blockieren",
|
||||
"status.group_mod_delete": "Post in der Gruppe löschen",
|
||||
"status.group_mod_kick": "@{name} aus der Gruppe entfernen",
|
||||
"status.interactions.favourites": "{count, plural, one {Mal favorisiert} other {Mal favorisiert}}",
|
||||
"status.interactions.quotes": "{count, plural, one {Mal zitiert} other {Mal zitiert}}",
|
||||
"status.interactions.reblogs": "{count, plural, one {Mal geteilt} other {Mal geteilt}}",
|
||||
|
@ -1311,6 +1385,7 @@
|
|||
"tabs_bar.all": "Alle",
|
||||
"tabs_bar.dashboard": "Steuerung",
|
||||
"tabs_bar.fediverse": "Fediverse",
|
||||
"tabs_bar.groups": "Gruppen",
|
||||
"tabs_bar.home": "Start",
|
||||
"tabs_bar.local": "Lokal",
|
||||
"tabs_bar.more": "Mehr",
|
||||
|
|
|
@ -706,8 +706,6 @@
|
|||
"filters.context_header": "Filter contexts",
|
||||
"filters.context_hint": "One or multiple contexts where the filter should apply",
|
||||
"filters.filters_list_context_label": "Filter contexts:",
|
||||
"filters.filters_list_delete": "Delete",
|
||||
"filters.filters_list_details_label": "Filter settings:",
|
||||
"filters.filters_list_drop": "Drop",
|
||||
"filters.filters_list_hide": "Hide",
|
||||
"filters.filters_list_phrase_label": "Keyword or phrase:",
|
||||
|
|
|
@ -47,6 +47,7 @@
|
|||
"account.report": "Reportar a @{name}",
|
||||
"account.requested": "Esperando aprobación",
|
||||
"account.requested_small": "En espera de aprobación",
|
||||
"account.rss_feed": "Suscríbete a la fuente RSS",
|
||||
"account.search": "Buscar en base a @{name}",
|
||||
"account.search_self": "Busca en tus entradas",
|
||||
"account.share": "Compartir el perfil de @{name}",
|
||||
|
@ -960,6 +961,9 @@
|
|||
"navigation_bar.preferences": "Preferencias",
|
||||
"navigation_bar.profile_directory": "Profile directory",
|
||||
"navigation_bar.soapbox_config": "Soapbox config",
|
||||
"new_event_panel.action": "Crear un evento",
|
||||
"new_event_panel.subtitle": "¿No encuentra lo que busca? Programe su propio evento.",
|
||||
"new_event_panel.title": "Crear un nuevo evento",
|
||||
"new_group_panel.action": "Crear un grupo",
|
||||
"new_group_panel.subtitle": "¿No encuentra lo que busca? Crea tu propio grupo privado o público.",
|
||||
"new_group_panel.title": "Crear un nuevo grupo",
|
||||
|
|
|
@ -47,6 +47,7 @@
|
|||
"account.report": "Segnala @{name}",
|
||||
"account.requested": "In attesa di approvazione",
|
||||
"account.requested_small": "In approvazione",
|
||||
"account.rss_feed": "Iscriviti al feed RSS",
|
||||
"account.search": "Cerca da @{name}",
|
||||
"account.search_self": "Cerca tra le tue pubblicazioni",
|
||||
"account.share": "Condividi il profilo di @{name}",
|
||||
|
@ -960,6 +961,9 @@
|
|||
"navigation_bar.preferences": "Preferenze",
|
||||
"navigation_bar.profile_directory": "Esplora i profili",
|
||||
"navigation_bar.soapbox_config": "Configura Soapbox",
|
||||
"new_event_panel.action": "Crea evento",
|
||||
"new_event_panel.subtitle": "Non riesci a trovare niente? Pianifica tu un evento.",
|
||||
"new_event_panel.title": "Crea un nuovo evento",
|
||||
"new_group_panel.action": "Crea gruppo",
|
||||
"new_group_panel.subtitle": "Non riesci a trovare qualcosa sul tema? Crea un gruppo privato o pubblico.",
|
||||
"new_group_panel.title": "Crea nuovo gruppo",
|
||||
|
|
|
@ -47,6 +47,7 @@
|
|||
"account.report": "举报 @{name}",
|
||||
"account.requested": "正在等待对方批准。点击以取消发送关注请求",
|
||||
"account.requested_small": "等待批准",
|
||||
"account.rss_feed": "订阅 RSS 源",
|
||||
"account.search": "在 @{name} 的内容中搜索",
|
||||
"account.search_self": "搜索您的帖文",
|
||||
"account.share": "分享 @{name} 的个人资料",
|
||||
|
@ -960,6 +961,9 @@
|
|||
"navigation_bar.preferences": "首选项",
|
||||
"navigation_bar.profile_directory": "发现用户",
|
||||
"navigation_bar.soapbox_config": "Soapbox 设置",
|
||||
"new_event_panel.action": "创建活动",
|
||||
"new_event_panel.subtitle": "找不到您要查找的内容?安排您自己的活动。",
|
||||
"new_event_panel.title": "创建新活动",
|
||||
"new_group_panel.action": "创建群组",
|
||||
"new_group_panel.subtitle": "找不到你要找的东西?开始你自己的私有或公共群组。",
|
||||
"new_group_panel.title": "创建新群组",
|
||||
|
@ -1123,11 +1127,11 @@
|
|||
"registrations.unprocessable_entity": "此用户名已被占用。",
|
||||
"registrations.username.hint": "只能包含字母、数字和下划线",
|
||||
"registrations.username.label": "您的用户名",
|
||||
"relative_time.days": "{number}d",
|
||||
"relative_time.hours": "{number}h",
|
||||
"relative_time.days": "{number}天",
|
||||
"relative_time.hours": "{number}时",
|
||||
"relative_time.just_now": "刚刚",
|
||||
"relative_time.minutes": "{number}m",
|
||||
"relative_time.seconds": "{number}s",
|
||||
"relative_time.minutes": "{number}分",
|
||||
"relative_time.seconds": "{number}秒",
|
||||
"remote_instance.edit_federation": "编辑联邦设置",
|
||||
"remote_instance.federation_panel.heading": "联邦站点限制",
|
||||
"remote_instance.federation_panel.no_restrictions_message": "{siteTitle} 未对 {host} 设置限制。",
|
||||
|
|
|
@ -7,7 +7,9 @@ import {
|
|||
|
||||
import { normalizeAttachment } from 'soapbox/normalizers/attachment';
|
||||
|
||||
import type { Attachment, Card, Emoji } from 'soapbox/types/entities';
|
||||
import { normalizeEmojiReaction } from './emoji-reaction';
|
||||
|
||||
import type { Attachment, Card, Emoji, EmojiReaction } from 'soapbox/types/entities';
|
||||
|
||||
export const ChatMessageRecord = ImmutableRecord({
|
||||
account_id: '',
|
||||
|
@ -17,6 +19,8 @@ export const ChatMessageRecord = ImmutableRecord({
|
|||
content: '',
|
||||
created_at: '',
|
||||
emojis: ImmutableList<Emoji>(),
|
||||
expiration: null as number | null,
|
||||
emoji_reactions: ImmutableList<EmojiReaction>(),
|
||||
id: '',
|
||||
unread: false,
|
||||
deleting: false,
|
||||
|
@ -36,10 +40,21 @@ const normalizeMedia = (status: ImmutableMap<string, any>) => {
|
|||
}
|
||||
};
|
||||
|
||||
const normalizeChatMessageEmojiReaction = (chatMessage: ImmutableMap<string, any>) => {
|
||||
const emojiReactions = chatMessage.get('emoji_reactions');
|
||||
|
||||
if (emojiReactions) {
|
||||
return chatMessage.set('emoji_reactions', ImmutableList(emojiReactions.map(normalizeEmojiReaction)));
|
||||
} else {
|
||||
return chatMessage;
|
||||
}
|
||||
};
|
||||
|
||||
export const normalizeChatMessage = (chatMessage: Record<string, any>) => {
|
||||
return ChatMessageRecord(
|
||||
ImmutableMap(fromJS(chatMessage)).withMutations(chatMessage => {
|
||||
normalizeMedia(chatMessage);
|
||||
normalizeChatMessageEmojiReaction(chatMessage);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
import { Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable';
|
||||
|
||||
// https://docs.joinmastodon.org/entities/emoji/
|
||||
export const EmojiReactionRecord = ImmutableRecord({
|
||||
name: '',
|
||||
count: null as number | null,
|
||||
me: false,
|
||||
});
|
||||
|
||||
export const normalizeEmojiReaction = (emojiReaction: Record<string, any>) => {
|
||||
return EmojiReactionRecord(
|
||||
ImmutableMap(fromJS(emojiReaction)),
|
||||
);
|
||||
};
|
|
@ -5,11 +5,13 @@
|
|||
*/
|
||||
import { List as ImmutableList, Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable';
|
||||
|
||||
export type ContextType = 'home' | 'public' | 'notifications' | 'thread';
|
||||
|
||||
// https://docs.joinmastodon.org/entities/filter/
|
||||
export const FilterRecord = ImmutableRecord({
|
||||
id: '',
|
||||
phrase: '',
|
||||
context: ImmutableList<string>(),
|
||||
context: ImmutableList<ContextType>(),
|
||||
whole_word: false,
|
||||
expires_at: '',
|
||||
irreversible: false,
|
||||
|
@ -19,4 +21,4 @@ export const normalizeFilter = (filter: Record<string, any>) => {
|
|||
return FilterRecord(
|
||||
ImmutableMap(fromJS(filter)),
|
||||
);
|
||||
};
|
||||
};
|
||||
|
|
|
@ -8,6 +8,7 @@ export { CardRecord, normalizeCard } from './card';
|
|||
export { ChatRecord, normalizeChat } from './chat';
|
||||
export { ChatMessageRecord, normalizeChatMessage } from './chat-message';
|
||||
export { EmojiRecord, normalizeEmoji } from './emoji';
|
||||
export { EmojiReactionRecord } from './emoji-reaction';
|
||||
export { FilterRecord, normalizeFilter } from './filter';
|
||||
export { GroupRecord, normalizeGroup } from './group';
|
||||
export { GroupRelationshipRecord, normalizeGroupRelationship } from './group-relationship';
|
||||
|
|
|
@ -1,15 +1,17 @@
|
|||
import { Map as ImmutableMap } from 'immutable';
|
||||
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
|
||||
import sumBy from 'lodash/sumBy';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { __stub } from 'soapbox/api';
|
||||
import { createTestStore, mockStore, queryClient, renderHook, rootState, waitFor } from 'soapbox/jest/test-helpers';
|
||||
import { normalizeRelationship } from 'soapbox/normalizers';
|
||||
import { normalizeChatMessage, normalizeRelationship } from 'soapbox/normalizers';
|
||||
import { normalizeEmojiReaction } from 'soapbox/normalizers/emoji-reaction';
|
||||
import { Store } from 'soapbox/store';
|
||||
import { ChatMessage } from 'soapbox/types/entities';
|
||||
import { flattenPages } from 'soapbox/utils/queries';
|
||||
|
||||
import { IAccount } from '../accounts';
|
||||
import { ChatKeys, IChat, IChatMessage, isLastMessage, useChat, useChatActions, useChatMessages, useChats } from '../chats';
|
||||
import { ChatKeys, IChat, isLastMessage, useChat, useChatActions, useChatMessages, useChats } from '../chats';
|
||||
|
||||
const chat: IChat = {
|
||||
accepted: true,
|
||||
|
@ -22,6 +24,7 @@ const chat: IChat = {
|
|||
avatar_static: 'avatar',
|
||||
display_name: 'my name',
|
||||
} as IAccount,
|
||||
chat_type: 'direct',
|
||||
created_at: '2020-06-10T02:05:06.000Z',
|
||||
created_by_account: '1',
|
||||
discarded_at: null,
|
||||
|
@ -33,12 +36,14 @@ const chat: IChat = {
|
|||
unread: 0,
|
||||
};
|
||||
|
||||
const buildChatMessage = (id: string): IChatMessage => ({
|
||||
const buildChatMessage = (id: string) => normalizeChatMessage({
|
||||
id,
|
||||
chat_id: '1',
|
||||
account_id: '1',
|
||||
content: `chat message #${id}`,
|
||||
created_at: '2020-06-10T02:05:06.000Z',
|
||||
emoji_reactions: null,
|
||||
expiration: 1209600,
|
||||
unread: true,
|
||||
});
|
||||
|
||||
|
@ -365,7 +370,7 @@ describe('useChatActions', () => {
|
|||
const { updateChat } = useChatActions(chat.id);
|
||||
|
||||
useEffect(() => {
|
||||
updateChat.mutate({ message_expiration: 1200 });
|
||||
updateChat.mutate({ message_expiration: 1200 });
|
||||
}, []);
|
||||
|
||||
return updateChat;
|
||||
|
@ -379,4 +384,52 @@ describe('useChatActions', () => {
|
|||
expect((nextQueryData as any).message_expiration).toBe(1200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createReaction()', () => {
|
||||
const chatMessage = buildChatMessage('1');
|
||||
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock
|
||||
.onPost(`/api/v1/pleroma/chats/${chat.id}/messages/${chatMessage.id}/reactions`)
|
||||
.reply(200, { ...chatMessage.toJS(), emoji_reactions: [{ name: '👍', count: 1, me: true }] });
|
||||
});
|
||||
});
|
||||
|
||||
it('successfully updates the Chat Message record', async () => {
|
||||
const initialQueryData = {
|
||||
pages: [
|
||||
{ result: [chatMessage], hasMore: false, link: undefined },
|
||||
],
|
||||
pageParams: [undefined],
|
||||
};
|
||||
|
||||
queryClient.setQueryData(ChatKeys.chatMessages(chat.id), initialQueryData);
|
||||
|
||||
const { result } = renderHook(() => {
|
||||
const { createReaction } = useChatActions(chat.id);
|
||||
|
||||
useEffect(() => {
|
||||
createReaction.mutate({
|
||||
messageId: chatMessage.id,
|
||||
emoji: '👍',
|
||||
chatMessage,
|
||||
});
|
||||
}, []);
|
||||
|
||||
return createReaction;
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
const updatedChatMessage = (queryClient.getQueryData(ChatKeys.chatMessages(chat.id)) as any).pages[0].result[0] as ChatMessage;
|
||||
expect(updatedChatMessage.emoji_reactions).toEqual(ImmutableList([normalizeEmojiReaction({
|
||||
name: '👍',
|
||||
count: 1,
|
||||
me: true,
|
||||
})]));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -8,7 +8,8 @@ import { useStatContext } from 'soapbox/contexts/stat-context';
|
|||
import { useApi, useAppDispatch, useAppSelector, useFeatures, useOwnAccount } from 'soapbox/hooks';
|
||||
import { normalizeChatMessage } from 'soapbox/normalizers';
|
||||
import toast from 'soapbox/toast';
|
||||
import { reOrderChatListItems } from 'soapbox/utils/chats';
|
||||
import { ChatMessage } from 'soapbox/types/entities';
|
||||
import { reOrderChatListItems, updateChatMessage } from 'soapbox/utils/chats';
|
||||
import { flattenPages, PaginatedResult, updatePageItem } from 'soapbox/utils/queries';
|
||||
|
||||
import { queryClient } from './client';
|
||||
|
@ -28,6 +29,7 @@ export enum MessageExpirationValues {
|
|||
export interface IChat {
|
||||
accepted: boolean
|
||||
account: IAccount
|
||||
chat_type: 'channel' | 'direct'
|
||||
created_at: string
|
||||
created_by_account: string
|
||||
discarded_at: null | string
|
||||
|
@ -50,20 +52,16 @@ export interface IChat {
|
|||
unread: number
|
||||
}
|
||||
|
||||
export interface IChatMessage {
|
||||
account_id: string
|
||||
chat_id: string
|
||||
content: string
|
||||
created_at: string
|
||||
id: string
|
||||
unread: boolean
|
||||
pending?: boolean
|
||||
}
|
||||
|
||||
type UpdateChatVariables = {
|
||||
message_expiration: MessageExpirationValues
|
||||
}
|
||||
|
||||
type CreateReactionVariables = {
|
||||
messageId: string
|
||||
emoji: string
|
||||
chatMessage?: ChatMessage
|
||||
}
|
||||
|
||||
const ChatKeys = {
|
||||
chat: (chatId?: string) => ['chats', 'chat', chatId] as const,
|
||||
chatMessages: (chatId: string) => ['chats', 'messages', chatId] as const,
|
||||
|
@ -83,7 +81,7 @@ const useChatMessages = (chat: IChat) => {
|
|||
const api = useApi();
|
||||
const isBlocked = useAppSelector((state) => state.getIn(['relationships', chat.account.id, 'blocked_by']));
|
||||
|
||||
const getChatMessages = async (chatId: string, pageParam?: any): Promise<PaginatedResult<IChatMessage>> => {
|
||||
const getChatMessages = async (chatId: string, pageParam?: any): Promise<PaginatedResult<ChatMessage>> => {
|
||||
const nextPageLink = pageParam?.link;
|
||||
const uri = nextPageLink || `/api/v1/pleroma/chats/${chatId}/messages`;
|
||||
const response = await api.get<any[]>(uri);
|
||||
|
@ -234,7 +232,7 @@ const useChatActions = (chatId: string) => {
|
|||
|
||||
const createChatMessage = useMutation(
|
||||
({ chatId, content, mediaIds }: { chatId: string, content: string, mediaIds?: string[] }) => {
|
||||
return api.post<IChatMessage>(`/api/v1/pleroma/chats/${chatId}/messages`, {
|
||||
return api.post<ChatMessage>(`/api/v1/pleroma/chats/${chatId}/messages`, {
|
||||
content,
|
||||
media_id: (mediaIds && mediaIds.length === 1) ? mediaIds[0] : undefined, // Pleroma backwards-compat
|
||||
media_ids: mediaIds,
|
||||
|
@ -249,6 +247,7 @@ const useChatActions = (chatId: string) => {
|
|||
// Snapshot the previous value
|
||||
const prevContent = variables.content;
|
||||
const prevChatMessages = queryClient.getQueryData(['chats', 'messages', variables.chatId]);
|
||||
const pendingId = String(Number(new Date()));
|
||||
|
||||
// Optimistically update to the new value
|
||||
queryClient.setQueryData(ChatKeys.chatMessages(variables.chatId), (prevResult: any) => {
|
||||
|
@ -260,7 +259,7 @@ const useChatActions = (chatId: string) => {
|
|||
result: [
|
||||
normalizeChatMessage({
|
||||
content: variables.content,
|
||||
id: String(Number(new Date())),
|
||||
id: pendingId,
|
||||
created_at: new Date(),
|
||||
account_id: account?.id,
|
||||
pending: true,
|
||||
|
@ -277,18 +276,21 @@ const useChatActions = (chatId: string) => {
|
|||
return newResult;
|
||||
});
|
||||
|
||||
return { prevChatMessages, prevContent };
|
||||
return { prevChatMessages, prevContent, pendingId };
|
||||
},
|
||||
// If the mutation fails, use the context returned from onMutate to roll back
|
||||
onError: (_error: any, variables, context: any) => {
|
||||
queryClient.setQueryData(['chats', 'messages', variables.chatId], context.prevChatMessages);
|
||||
},
|
||||
onSuccess: (response, variables) => {
|
||||
onSuccess: (response: any, variables, context) => {
|
||||
const nextChat = { ...chat, last_message: response.data };
|
||||
updatePageItem(ChatKeys.chatSearch(), nextChat, (o, n) => o.id === n.id);
|
||||
updatePageItem(
|
||||
ChatKeys.chatMessages(variables.chatId),
|
||||
normalizeChatMessage(response.data),
|
||||
(o) => o.id === context.pendingId,
|
||||
);
|
||||
reOrderChatListItems();
|
||||
|
||||
queryClient.invalidateQueries(ChatKeys.chatMessages(variables.chatId));
|
||||
},
|
||||
},
|
||||
);
|
||||
|
@ -340,7 +342,34 @@ const useChatActions = (chatId: string) => {
|
|||
},
|
||||
});
|
||||
|
||||
return { createChatMessage, markChatAsRead, deleteChatMessage, updateChat, acceptChat, deleteChat };
|
||||
const createReaction = useMutation((data: CreateReactionVariables) => api.post(`/api/v1/pleroma/chats/${chatId}/messages/${data.messageId}/reactions`, {
|
||||
emoji: data.emoji,
|
||||
}), {
|
||||
// TODO: add optimistic updates
|
||||
onSuccess(response) {
|
||||
updateChatMessage(response.data);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteReaction = useMutation(
|
||||
(data: CreateReactionVariables) => api.delete(`/api/v1/pleroma/chats/${chatId}/messages/${data.messageId}/reactions/${data.emoji}`),
|
||||
{
|
||||
onSuccess() {
|
||||
queryClient.invalidateQueries(ChatKeys.chatMessages(chatId));
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
acceptChat,
|
||||
createChatMessage,
|
||||
createReaction,
|
||||
deleteChat,
|
||||
deleteChatMessage,
|
||||
deleteReaction,
|
||||
markChatAsRead,
|
||||
updateChat,
|
||||
};
|
||||
};
|
||||
|
||||
export { ChatKeys, useChat, useChatActions, useChats, useChatMessages, isLastMessage };
|
||||
|
|
|
@ -3,9 +3,7 @@ import reducer from '../dropdown-menu';
|
|||
describe('dropdown_menu reducer', () => {
|
||||
it('should return the initial state', () => {
|
||||
expect(reducer(undefined, {} as any).toJS()).toEqual({
|
||||
openId: null,
|
||||
placement: null,
|
||||
keyboard: false,
|
||||
isOpen: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -6,12 +6,9 @@ import {
|
|||
} from '../actions/dropdown-menu';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { DropdownPlacement } from 'soapbox/components/dropdown-menu';
|
||||
|
||||
const ReducerRecord = ImmutableRecord({
|
||||
openId: null as number | null,
|
||||
placement: null as any as DropdownPlacement,
|
||||
keyboard: false,
|
||||
isOpen: false,
|
||||
});
|
||||
|
||||
type State = ReturnType<typeof ReducerRecord>;
|
||||
|
@ -19,9 +16,9 @@ type State = ReturnType<typeof ReducerRecord>;
|
|||
export default function dropdownMenu(state: State = ReducerRecord(), action: AnyAction) {
|
||||
switch (action.type) {
|
||||
case DROPDOWN_MENU_OPEN:
|
||||
return state.merge({ openId: action.id, placement: action.placement, keyboard: action.keyboard });
|
||||
return state.set('isOpen', true);
|
||||
case DROPDOWN_MENU_CLOSE:
|
||||
return state.openId === action.id ? state.set('openId', null) : state;
|
||||
return state.set('isOpen', false);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
|
|
@ -12,6 +12,7 @@ import { validId } from 'soapbox/utils/auth';
|
|||
import ConfigDB from 'soapbox/utils/config-db';
|
||||
import { shouldFilter } from 'soapbox/utils/timelines';
|
||||
|
||||
import type { ContextType } from 'soapbox/normalizers/filter';
|
||||
import type { ReducerChat } from 'soapbox/reducers/chats';
|
||||
import type { RootState } from 'soapbox/store';
|
||||
import type { Filter as FilterEntity, Notification } from 'soapbox/types/entities';
|
||||
|
@ -85,7 +86,7 @@ export const findAccountByUsername = (state: RootState, username: string) => {
|
|||
}
|
||||
};
|
||||
|
||||
const toServerSideType = (columnType: string): string => {
|
||||
const toServerSideType = (columnType: string): ContextType => {
|
||||
switch (columnType) {
|
||||
case 'home':
|
||||
case 'notifications':
|
||||
|
@ -105,10 +106,8 @@ type FilterContext = { contextType?: string };
|
|||
|
||||
export const getFilters = (state: RootState, query: FilterContext) => {
|
||||
return state.filters.filter((filter) => {
|
||||
return query?.contextType
|
||||
&& filter.context.includes(toServerSideType(query.contextType))
|
||||
&& (filter.expires_at === null
|
||||
|| Date.parse(filter.expires_at) > new Date().getTime());
|
||||
return (!query?.contextType || filter.context.includes(toServerSideType(query.contextType)))
|
||||
&& (filter.expires_at === null || Date.parse(filter.expires_at) > new Date().getTime());
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
@ -9,6 +9,7 @@ import {
|
|||
ChatRecord,
|
||||
ChatMessageRecord,
|
||||
EmojiRecord,
|
||||
EmojiReactionRecord,
|
||||
FieldRecord,
|
||||
FilterRecord,
|
||||
GroupRecord,
|
||||
|
@ -40,6 +41,7 @@ type Card = ReturnType<typeof CardRecord>;
|
|||
type Chat = ReturnType<typeof ChatRecord>;
|
||||
type ChatMessage = ReturnType<typeof ChatMessageRecord>;
|
||||
type Emoji = ReturnType<typeof EmojiRecord>;
|
||||
type EmojiReaction = ReturnType<typeof EmojiReactionRecord>;
|
||||
type Field = ReturnType<typeof FieldRecord>;
|
||||
type Filter = ReturnType<typeof FilterRecord>;
|
||||
type Group = ReturnType<typeof GroupRecord>;
|
||||
|
@ -84,6 +86,7 @@ export {
|
|||
Chat,
|
||||
ChatMessage,
|
||||
Emoji,
|
||||
EmojiReaction,
|
||||
Field,
|
||||
Filter,
|
||||
Group,
|
||||
|
|
|
@ -0,0 +1,73 @@
|
|||
import { normalizeChatMessage } from 'soapbox/normalizers';
|
||||
import { IAccount } from 'soapbox/queries/accounts';
|
||||
import { ChatKeys, IChat } from 'soapbox/queries/chats';
|
||||
import { queryClient } from 'soapbox/queries/client';
|
||||
|
||||
import { updateChatMessage } from '../chats';
|
||||
|
||||
const chat: IChat = {
|
||||
accepted: true,
|
||||
account: {
|
||||
username: 'username',
|
||||
verified: true,
|
||||
id: '1',
|
||||
acct: 'acct',
|
||||
avatar: 'avatar',
|
||||
avatar_static: 'avatar',
|
||||
display_name: 'my name',
|
||||
} as IAccount,
|
||||
chat_type: 'direct',
|
||||
created_at: '2020-06-10T02:05:06.000Z',
|
||||
created_by_account: '1',
|
||||
discarded_at: null,
|
||||
id: '1',
|
||||
last_message: null,
|
||||
latest_read_message_by_account: [],
|
||||
latest_read_message_created_at: null,
|
||||
message_expiration: 1209600,
|
||||
unread: 0,
|
||||
};
|
||||
|
||||
const buildChatMessage = (id: string) => normalizeChatMessage({
|
||||
id,
|
||||
chat_id: '1',
|
||||
account_id: '1',
|
||||
content: `chat message #${id}`,
|
||||
created_at: '2020-06-10T02:05:06.000Z',
|
||||
emoji_reactions: null,
|
||||
expiration: 1209600,
|
||||
unread: true,
|
||||
});
|
||||
|
||||
describe('chat utils', () => {
|
||||
describe('updateChatMessage()', () => {
|
||||
const initialChatMessage = buildChatMessage('1');
|
||||
|
||||
beforeEach(() => {
|
||||
const initialQueryData = {
|
||||
pages: [
|
||||
{ result: [initialChatMessage], hasMore: false, link: undefined },
|
||||
],
|
||||
pageParams: [undefined],
|
||||
};
|
||||
|
||||
queryClient.setQueryData(ChatKeys.chatMessages(chat.id), initialQueryData);
|
||||
});
|
||||
|
||||
it('correctly updates the chat message', () => {
|
||||
expect(
|
||||
(queryClient.getQueryData(ChatKeys.chatMessages(chat.id)) as any).pages[0].result[0].content,
|
||||
).toEqual(initialChatMessage.content);
|
||||
|
||||
const nextChatMessage = normalizeChatMessage({
|
||||
...initialChatMessage.toJS(),
|
||||
content: 'new content',
|
||||
});
|
||||
|
||||
updateChatMessage(nextChatMessage);
|
||||
expect(
|
||||
(queryClient.getQueryData(ChatKeys.chatMessages(chat.id)) as any).pages[0].result[0].content,
|
||||
).toEqual(nextChatMessage.content);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -5,7 +5,6 @@ import { normalizeStatus } from 'soapbox/normalizers';
|
|||
import {
|
||||
sortEmoji,
|
||||
mergeEmojiFavourites,
|
||||
filterEmoji,
|
||||
oneEmojiPerAccount,
|
||||
reduceEmoji,
|
||||
getReactForStatus,
|
||||
|
@ -22,29 +21,10 @@ const ALLOWED_EMOJI = ImmutableList([
|
|||
'😡',
|
||||
]);
|
||||
|
||||
describe('filterEmoji', () => {
|
||||
describe('with a mix of allowed and disallowed emoji', () => {
|
||||
const emojiReacts = fromJS([
|
||||
{ 'count': 1, 'me': true, 'name': '🌵' },
|
||||
{ 'count': 1, 'me': true, 'name': '😂' },
|
||||
{ 'count': 1, 'me': true, 'name': '👀' },
|
||||
{ 'count': 1, 'me': true, 'name': '🍩' },
|
||||
{ 'count': 1, 'me': true, 'name': '😡' },
|
||||
{ 'count': 1, 'me': true, 'name': '🔪' },
|
||||
{ 'count': 1, 'me': true, 'name': '😠' },
|
||||
]) as ImmutableList<ImmutableMap<string, any>>;
|
||||
it('filters only allowed emoji', () => {
|
||||
expect(filterEmoji(emojiReacts, ALLOWED_EMOJI)).toEqual(fromJS([
|
||||
{ 'count': 1, 'me': true, 'name': '😂' },
|
||||
{ 'count': 1, 'me': true, 'name': '😡' },
|
||||
]));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortEmoji', () => {
|
||||
describe('with an unsorted list of emoji', () => {
|
||||
const emojiReacts = fromJS([
|
||||
{ 'count': 7, 'me': true, 'name': '😃' },
|
||||
{ 'count': 7, 'me': true, 'name': '😯' },
|
||||
{ 'count': 3, 'me': true, 'name': '😢' },
|
||||
{ 'count': 1, 'me': true, 'name': '😡' },
|
||||
|
@ -53,11 +33,12 @@ describe('sortEmoji', () => {
|
|||
{ 'count': 15, 'me': true, 'name': '❤' },
|
||||
]) as ImmutableList<ImmutableMap<string, any>>;
|
||||
it('sorts the emoji by count', () => {
|
||||
expect(sortEmoji(emojiReacts)).toEqual(fromJS([
|
||||
expect(sortEmoji(emojiReacts, ALLOWED_EMOJI)).toEqual(fromJS([
|
||||
{ 'count': 20, 'me': true, 'name': '👍' },
|
||||
{ 'count': 15, 'me': true, 'name': '❤' },
|
||||
{ 'count': 7, 'me': true, 'name': '😯' },
|
||||
{ 'count': 7, 'me': true, 'name': '😂' },
|
||||
{ 'count': 7, 'me': true, 'name': '😃' },
|
||||
{ 'count': 3, 'me': true, 'name': '😢' },
|
||||
{ 'count': 1, 'me': true, 'name': '😡' },
|
||||
]));
|
||||
|
@ -127,6 +108,10 @@ describe('reduceEmoji', () => {
|
|||
{ 'count': 7, 'me': false, 'name': '😂' },
|
||||
{ 'count': 3, 'me': false, 'name': '😢' },
|
||||
{ 'count': 1, 'me': false, 'name': '😡' },
|
||||
{ 'count': 1, 'me': true, 'name': '🔪' },
|
||||
{ 'count': 1, 'me': true, 'name': '🌵' },
|
||||
{ 'count': 1, 'me': false, 'name': '👀' },
|
||||
{ 'count': 1, 'me': false, 'name': '🍩' },
|
||||
]));
|
||||
});
|
||||
});
|
||||
|
|
|
@ -84,4 +84,11 @@ const getUnreadChatsCount = (): number => {
|
|||
return sumBy(chats, chat => chat.unread);
|
||||
};
|
||||
|
||||
export { updateChatListItem, getUnreadChatsCount, reOrderChatListItems };
|
||||
/** Update the query cache for an individual Chat Message */
|
||||
const updateChatMessage = (chatMessage: ChatMessage) => updatePageItem(
|
||||
ChatKeys.chatMessages(chatMessage.chat_id),
|
||||
normalizeChatMessage(chatMessage),
|
||||
(o, n) => o.id === n.id,
|
||||
);
|
||||
|
||||
export { updateChatListItem, updateChatMessage, getUnreadChatsCount, reOrderChatListItems };
|
|
@ -19,12 +19,10 @@ export const ALLOWED_EMOJI = ImmutableList([
|
|||
type Account = ImmutableMap<string, any>;
|
||||
type EmojiReact = ImmutableMap<string, any>;
|
||||
|
||||
export const sortEmoji = (emojiReacts: ImmutableList<EmojiReact>): ImmutableList<EmojiReact> => (
|
||||
emojiReacts.sortBy(emojiReact => -emojiReact.get('count'))
|
||||
);
|
||||
|
||||
export const mergeEmoji = (emojiReacts: ImmutableList<EmojiReact>): ImmutableList<EmojiReact> => (
|
||||
emojiReacts // TODO: Merge similar emoji
|
||||
export const sortEmoji = (emojiReacts: ImmutableList<EmojiReact>, allowedEmoji: ImmutableList<string>): ImmutableList<EmojiReact> => (
|
||||
emojiReacts
|
||||
.sortBy(emojiReact =>
|
||||
-(emojiReact.get('count') + Number(allowedEmoji.includes(emojiReact.get('name')))))
|
||||
);
|
||||
|
||||
export const mergeEmojiFavourites = (emojiReacts = ImmutableList<EmojiReact>(), favouritesCount: number, favourited: boolean) => {
|
||||
|
@ -70,15 +68,11 @@ export const oneEmojiPerAccount = (emojiReacts: ImmutableList<EmojiReact>, me: M
|
|||
.reverse();
|
||||
};
|
||||
|
||||
export const filterEmoji = (emojiReacts: ImmutableList<EmojiReact>, allowedEmoji = ALLOWED_EMOJI): ImmutableList<EmojiReact> => (
|
||||
emojiReacts.filter(emojiReact => (
|
||||
allowedEmoji.includes(emojiReact.get('name'))
|
||||
)));
|
||||
|
||||
export const reduceEmoji = (emojiReacts: ImmutableList<EmojiReact>, favouritesCount: number, favourited: boolean, allowedEmoji = ALLOWED_EMOJI): ImmutableList<EmojiReact> => (
|
||||
filterEmoji(sortEmoji(mergeEmoji(mergeEmojiFavourites(
|
||||
emojiReacts, favouritesCount, favourited,
|
||||
))), allowedEmoji));
|
||||
sortEmoji(
|
||||
mergeEmojiFavourites(emojiReacts, favouritesCount, favourited),
|
||||
allowedEmoji,
|
||||
));
|
||||
|
||||
export const getReactForStatus = (status: any, allowedEmoji = ALLOWED_EMOJI): string | undefined => {
|
||||
const result = reduceEmoji(
|
||||
|
|
|
@ -248,6 +248,11 @@ const getInstanceFeatures = (instance: Instance) => {
|
|||
*/
|
||||
chatAcceptance: v.software === TRUTHSOCIAL,
|
||||
|
||||
/**
|
||||
* Ability to add reactions to chat messages.
|
||||
*/
|
||||
chatEmojiReactions: false, // v.software === TRUTHSOCIAL,
|
||||
|
||||
/**
|
||||
* Pleroma chats API.
|
||||
* @see {@link https://docs.pleroma.social/backend/development/API/chats/}
|
||||
|
@ -374,10 +379,10 @@ const getInstanceFeatures = (instance: Instance) => {
|
|||
emojiReacts: v.software === PLEROMA && gte(v.version, '2.0.0'),
|
||||
|
||||
/**
|
||||
* The backend allows only RGI ("Recommended for General Interchange") emoji reactions.
|
||||
* The backend allows only non-RGI ("Recommended for General Interchange") emoji reactions.
|
||||
* @see PUT /api/v1/pleroma/statuses/:id/reactions/:emoji
|
||||
*/
|
||||
emojiReactsRGI: v.software === PLEROMA && gte(v.version, '2.2.49'),
|
||||
emojiReactsNonRGI: v.software === PLEROMA && lt(v.version, '2.2.49'),
|
||||
|
||||
/**
|
||||
* Sign in with an Ethereum wallet.
|
||||
|
|
|
@ -1,34 +0,0 @@
|
|||
// THEME MIXINS
|
||||
|
||||
// standard container drop shadow
|
||||
@mixin standard-panel-shadow {
|
||||
box-shadow: 0 0 6px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
// common properties for all standard containers
|
||||
@mixin standard-panel {
|
||||
@include standard-panel-shadow;
|
||||
border-radius: 10px;
|
||||
background: var(--foreground-color);
|
||||
}
|
||||
|
||||
// SHORTCUTS
|
||||
@mixin input-placeholder($color) {
|
||||
&::-webkit-input-placeholder { color: $color; }
|
||||
&::-moz-placeholder { color: $color; }
|
||||
&:-ms-input-placeholder { color: $color; }
|
||||
&:-moz-placeholder { color: $color; }
|
||||
}
|
||||
|
||||
@mixin avatar-radius {
|
||||
border-radius: 50%;
|
||||
background: transparent no-repeat;
|
||||
background-position: 50%;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
@mixin avatar-size($size: 48px) {
|
||||
width: $size;
|
||||
height: $size;
|
||||
background-size: $size $size;
|
||||
}
|
|
@ -1,5 +1,3 @@
|
|||
@import 'mixins';
|
||||
@import 'themes';
|
||||
@import 'variables';
|
||||
@import 'fonts';
|
||||
@import 'basics';
|
||||
|
@ -9,15 +7,12 @@
|
|||
@import 'rtl';
|
||||
@import 'accessibility';
|
||||
@import 'navigation';
|
||||
@import 'placeholder';
|
||||
@import 'autosuggest';
|
||||
|
||||
// COMPONENTS
|
||||
@import 'components/buttons';
|
||||
@import 'components/dropdown-menu';
|
||||
@import 'components/modal';
|
||||
@import 'components/compose-form';
|
||||
@import 'components/emoji-reacts';
|
||||
@import 'components/status';
|
||||
@import 'components/reply-mentions';
|
||||
@import 'components/detailed-status';
|
||||
|
@ -29,7 +24,6 @@
|
|||
@import 'components/react-toggle';
|
||||
@import 'components/video-player';
|
||||
@import 'components/audio-player';
|
||||
@import 'components/filters';
|
||||
@import 'components/crypto-donate';
|
||||
@import 'components/aliases';
|
||||
@import 'components/icon';
|
||||
|
|
|
@ -1,8 +1,3 @@
|
|||
.autosuggest-input {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.autosuggest-input input,
|
||||
.react-datepicker__input-container input {
|
||||
// display: block;
|
||||
// box-sizing: border-box;
|
||||
|
|
|
@ -8,64 +8,9 @@ body.with-modals {
|
|||
@apply overflow-hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
&.lighter {
|
||||
background: var(--brand-color--med);
|
||||
}
|
||||
|
||||
&.player {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&.embed {
|
||||
background: var(--brand-color--faint);
|
||||
margin: 0;
|
||||
padding-bottom: 0;
|
||||
|
||||
.container {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
&.admin {
|
||||
background: var(--brand-color--med);
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&.error {
|
||||
@apply text-gray-400;
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
background: var(--brand-color--med);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.dialog {
|
||||
vertical-align: middle;
|
||||
margin: 20px;
|
||||
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
line-height: 28px;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Note: this is needed for React HotKeys performance. Removing this
|
||||
// will cause severe performance degradation on Safari.
|
||||
div[tabindex="-1"]:focus {
|
||||
div[tabindex='-1']:focus {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
|
@ -75,28 +20,6 @@ div[tabindex="-1"]:focus {
|
|||
|
||||
noscript {
|
||||
text-align: center;
|
||||
|
||||
img {
|
||||
width: 200px;
|
||||
opacity: 0.5;
|
||||
animation: flicker 4s infinite;
|
||||
}
|
||||
|
||||
div {
|
||||
@apply text-gray-400;
|
||||
font-size: 14px;
|
||||
margin: 30px auto;
|
||||
max-width: 400px;
|
||||
|
||||
a {
|
||||
color: var(--highlight-text-color);
|
||||
text-decoration: underline;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.emojione {
|
||||
|
@ -105,6 +28,6 @@ noscript {
|
|||
|
||||
// Virtuoso empty placeholder fix.
|
||||
// https://gitlab.com/petyosi/soapbox-fe/-/commit/1e22c39934b60e5e186de804060ecfdf1955b506
|
||||
div[data-viewport-type="window"] {
|
||||
div[data-viewport-type='window'] {
|
||||
position: static !important;
|
||||
}
|
||||
|
|
|
@ -1,69 +1,51 @@
|
|||
.audio-player {
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
background: $base-shadow-color;
|
||||
border-radius: 10px;
|
||||
padding-bottom: 44px;
|
||||
@apply relative box-border overflow-hidden rounded-[10px] bg-black pb-11;
|
||||
direction: ltr;
|
||||
|
||||
&.editable {
|
||||
border-radius: 0;
|
||||
height: 100%;
|
||||
@apply rounded-none h-full;
|
||||
}
|
||||
|
||||
.video-player__volume::before,
|
||||
.video-player__seek::before {
|
||||
background: currentcolor;
|
||||
opacity: 0.15;
|
||||
@apply bg-current opacity-[15];
|
||||
}
|
||||
|
||||
.video-player__seek__buffer {
|
||||
background: currentcolor;
|
||||
opacity: 0.2;
|
||||
@apply bg-current opacity-20;
|
||||
}
|
||||
|
||||
.video-player__buttons button {
|
||||
color: currentcolor;
|
||||
opacity: 0.75;
|
||||
@apply text-current opacity-[75];
|
||||
|
||||
&:active,
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: currentcolor;
|
||||
opacity: 1;
|
||||
@apply text-current opacity-100;
|
||||
}
|
||||
}
|
||||
|
||||
.video-player__time-sep,
|
||||
.video-player__time-total,
|
||||
.video-player__time-current {
|
||||
color: currentcolor;
|
||||
@apply text-current;
|
||||
}
|
||||
|
||||
.video-player__seek::before,
|
||||
.video-player__seek__buffer,
|
||||
.video-player__seek__progress {
|
||||
top: 0;
|
||||
@apply top-0;
|
||||
}
|
||||
|
||||
.video-player__seek__handle {
|
||||
top: -4px;
|
||||
@apply -top-1;
|
||||
}
|
||||
|
||||
.video-player__controls {
|
||||
padding-top: 10px;
|
||||
background: transparent;
|
||||
@apply pt-2.5 bg-transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.media-spoiler-audio {
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
cursor: pointer;
|
||||
margin-top: 8px;
|
||||
position: relative;
|
||||
border: 0;
|
||||
display: block;
|
||||
@apply relative mt-2 block cursor-pointer border-0 bg-cover bg-center bg-no-repeat;
|
||||
}
|
||||
|
|
|
@ -1,79 +1,3 @@
|
|||
.column {
|
||||
width: 350px;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 631px) {
|
||||
.column {
|
||||
flex: 0 0 auto;
|
||||
padding: 10px;
|
||||
padding-left: 5px;
|
||||
padding-right: 5px;
|
||||
|
||||
&:first-child {
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
padding-right: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.column-link {
|
||||
@apply text-gray-900;
|
||||
background: var(--brand-color--med);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
padding: 15px;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
background: var(--brand-color--faint);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
&--transparent {
|
||||
@apply bg-transparent;
|
||||
color: var(--background-color);
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
@apply text-gray-900 bg-transparent;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--brand-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.svg-icon.column-link__icon {
|
||||
display: inline-block;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.column-header__setting-btn {
|
||||
&--link {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@apply text-gray-400 underline;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-column-indicator,
|
||||
.error-column {
|
||||
@apply bg-primary-50 dark:bg-gray-700 text-gray-900 dark:text-gray-300 text-center p-10 flex flex-1 items-center justify-center min-h-[160px] rounded-lg;
|
||||
|
|
|
@ -13,7 +13,6 @@
|
|||
}
|
||||
|
||||
a {
|
||||
color: var(--brand-color--hicontrast);
|
||||
font-weight: 500;
|
||||
text-decoration: underline;
|
||||
|
||||
|
@ -28,171 +27,153 @@
|
|||
&__modifiers {
|
||||
@apply text-gray-900 text-sm;
|
||||
font-family: inherit;
|
||||
background: var(--background-color);
|
||||
}
|
||||
}
|
||||
|
||||
.compose-form__upload-wrapper { overflow: hidden; }
|
||||
&__upload-wrapper { overflow: hidden; }
|
||||
|
||||
.compose-form__uploads-wrapper {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
&__uploads-wrapper {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
|
||||
&.contains-media {
|
||||
padding: 5px;
|
||||
border-top: 1px solid var(--foreground-color);
|
||||
}
|
||||
}
|
||||
|
||||
.compose-form__upload {
|
||||
flex: 1 1 0;
|
||||
min-width: 40%;
|
||||
margin: 5px;
|
||||
position: relative;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
|
||||
&__actions {
|
||||
@apply bg-gradient-to-b from-gray-900/80 via-gray-900/50 to-transparent flex items-start justify-between opacity-0 transition-opacity duration-100 ease-linear;
|
||||
|
||||
&.active {
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
@apply text-gray-200 hover:text-white text-sm font-medium p-2.5 space-x-1 rtl:space-x-reverse flex items-center;
|
||||
&.contains-media {
|
||||
padding: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
&-description {
|
||||
@apply bg-gradient-to-b from-transparent via-gray-900/50 to-gray-900/80 absolute z-[2px] bottom-0 left-0 right-0 p-2.5 opacity-0 transition-opacity duration-100 ease-linear;
|
||||
&__upload {
|
||||
flex: 1 1 0;
|
||||
min-width: 40%;
|
||||
margin: 5px;
|
||||
position: relative;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
|
||||
&.active {
|
||||
@apply opacity-100;
|
||||
}
|
||||
&__actions {
|
||||
@apply bg-gradient-to-b from-gray-900/80 via-gray-900/50 to-transparent flex items-start justify-between opacity-0 transition-opacity duration-100 ease-linear;
|
||||
|
||||
textarea {
|
||||
@apply bg-transparent text-white border-solid border border-white/25 p-2.5 rounded-md text-sm w-full m-0;
|
||||
|
||||
&::placeholder {
|
||||
@apply text-white/60;
|
||||
&.active {
|
||||
@apply opacity-100;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-preview {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: -1;
|
||||
|
||||
video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.compose-form__upload-thumbnail {
|
||||
background-position: center;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
height: 160px;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
&.video {
|
||||
background-image: url('../assets/images/video-placeholder.png');
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
&.audio {
|
||||
background-image: url('../assets/images/audio-placeholder.png');
|
||||
background-size: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.privacy-dropdown__dropdown {
|
||||
@apply absolute bg-white dark:bg-gray-900 z-[1000] rounded-md shadow-lg ml-10 text-sm;
|
||||
|
||||
&.top {
|
||||
transform-origin: 50% 100%;
|
||||
}
|
||||
|
||||
&.bottom {
|
||||
transform-origin: 50% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.privacy-dropdown__option {
|
||||
@apply flex p-2.5 text-sm text-gray-700 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer;
|
||||
|
||||
&.active {
|
||||
@apply bg-gray-100 dark:bg-gray-800;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.active {
|
||||
.privacy-dropdown__option__content,
|
||||
.privacy-dropdown__option__content strong {
|
||||
@apply text-black dark:text-white;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
@apply hover:bg-gray-200 dark:hover:bg-gray-700;
|
||||
}
|
||||
}
|
||||
|
||||
.privacy-dropdown__option__icon {
|
||||
@apply flex items-center justify-center mr-2.5;
|
||||
}
|
||||
|
||||
.privacy-dropdown__option__content {
|
||||
@apply flex-auto text-primary-600 dark:text-primary-400;
|
||||
|
||||
strong {
|
||||
@apply block font-medium text-black dark:text-white;
|
||||
|
||||
@each $lang in $cjk-langs {
|
||||
&:lang(#{$lang}) {
|
||||
@apply font-bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.privacy-dropdown.active {
|
||||
.privacy-dropdown__value {
|
||||
background: var(--foreground-color);
|
||||
border-radius: 4px 4px 0 0;
|
||||
box-shadow: 0 -4px 4px rgba($base-shadow-color, 0.1);
|
||||
|
||||
.icon-button {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--brand-color);
|
||||
|
||||
.icon-button {
|
||||
@apply text-gray-900;
|
||||
@apply text-gray-200 hover:text-white text-sm font-medium p-2.5 space-x-1 rtl:space-x-reverse flex items-center;
|
||||
}
|
||||
}
|
||||
|
||||
&-description {
|
||||
@apply bg-gradient-to-b from-transparent via-gray-900/50 to-gray-900/80 absolute z-[2px] bottom-0 left-0 right-0 p-2.5 opacity-0 transition-opacity duration-100 ease-linear;
|
||||
|
||||
&.active {
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
textarea {
|
||||
@apply bg-transparent text-white border-solid border border-white/25 p-2.5 rounded-md text-sm w-full m-0;
|
||||
|
||||
&::placeholder {
|
||||
@apply text-white/60;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-preview {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: -1;
|
||||
|
||||
video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.top .privacy-dropdown__value {
|
||||
@apply rounded-t-md;
|
||||
}
|
||||
&__upload-thumbnail {
|
||||
background-position: center;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
height: 160px;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
.privacy-dropdown__dropdown {
|
||||
@apply block shadow-md;
|
||||
&.video {
|
||||
background-image: url('../assets/images/video-placeholder.png');
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
&.audio {
|
||||
background-image: url('../assets/images/audio-placeholder.png');
|
||||
background-size: cover;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.privacy-dropdown {
|
||||
&.active {
|
||||
&.top .privacy-dropdown__value {
|
||||
@apply rounded-t-md;
|
||||
}
|
||||
|
||||
.privacy-dropdown__dropdown {
|
||||
@apply block shadow-md;
|
||||
}
|
||||
}
|
||||
|
||||
&__dropdown {
|
||||
@apply absolute bg-white dark:bg-gray-900 z-[1000] rounded-md shadow-lg ml-10 text-sm overflow-hidden;
|
||||
|
||||
&.top {
|
||||
transform-origin: 50% 100%;
|
||||
}
|
||||
|
||||
&.bottom {
|
||||
transform-origin: 50% 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__option {
|
||||
@apply flex p-2.5 text-sm text-gray-700 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer;
|
||||
|
||||
&.active {
|
||||
@apply bg-gray-100 dark:bg-gray-800;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.active {
|
||||
.privacy-dropdown__option__content,
|
||||
.privacy-dropdown__option__content strong {
|
||||
@apply text-black dark:text-white;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
@apply hover:bg-gray-200 dark:hover:bg-gray-700;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
@apply flex items-center justify-center mr-2.5 rtl:mr-0 rtl:ml-2.5;
|
||||
}
|
||||
|
||||
&__content {
|
||||
@apply flex-auto text-primary-600 dark:text-primary-400;
|
||||
|
||||
strong {
|
||||
@apply block font-medium text-black dark:text-white;
|
||||
|
||||
@each $lang in $cjk-langs {
|
||||
&:lang(#{$lang}) {
|
||||
@apply font-bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue