Merge remote-tracking branch 'origin/develop' into multiple-attachments

This commit is contained in:
Alex Gleason 2023-02-14 09:24:12 -06:00
commit 14efff51ff
No known key found for this signature in database
GPG Key ID: 7211D1F99744FBB7
121 changed files with 2260 additions and 3173 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@
export { default } from './dropdown-menu';
export type { Menu } from './dropdown-menu';
export type { MenuItem } from './dropdown-menu-item';

View File

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

View File

@ -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' />}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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) => (
{expanded ? (
<Picker
set='twitter'
backgroundImageFn={() => require('emoji-datasource/img/twitter/sheets/32.png')}
onClick={(emoji: any) => onReact(emoji.native)}
/>
) : (
<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={handleReact(emoji)}
tabIndex={(visible || focused) ? 0 : -1}
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>
);
};

View File

@ -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} />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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} />

View File

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

View File

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

View File

@ -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
<Input
value={query}
onChange={handleQueryChange}
placeholder={intl.formatMessage(messages.searchPlaceholder)}
/>
</SimpleForm>
<ScrollableList
scrollKey='user-index'
hasMore={hasMore}

View File

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

View File

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

View File

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

View File

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

View File

@ -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={{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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)}
>
<Toggle
name='irreversible'
checked={irreversible}
onChange={({ target }) => setIrreversible(target.checked)}
/>
<Checkbox
</ListItem>
<ListItem
label={intl.formatMessage(messages.whole_word_header)}
hint={intl.formatMessage(messages.whole_word_hint)}
>
<Toggle
name='whole_word'
checked={wholeWord}
onChange={({ target }) => setWholeWord(target.checked)}
/>
</FieldsGroup>
</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>

View File

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

View File

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

View File

@ -36,7 +36,7 @@ const ListForm = () => {
<Form onSubmit={handleSubmit}>
<HStack space={2}>
<Input
outerClassName='flex-grow'
outerClassName='grow'
type='text'
value={value}
onChange={handleChange}

View File

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

View File

@ -329,6 +329,7 @@ const Notification: React.FC<INotificaton> = (props) => {
onMoveDown={handleMoveDown}
onMoveUp={handleMoveUp}
avatarSize={avatarSize}
contextType='notifications'
/>
) : null;
default:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -361,6 +361,7 @@ const Thread: React.FC<IThread> = (props) => {
focusedStatusId={status!.id}
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
contextType='thread'
/>
);
};

View File

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

View File

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

View File

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

View File

@ -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' />

View File

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

View File

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

View File

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

View File

@ -51,6 +51,7 @@ const TestApp: FC<any> = ({ children, storeProps, routerProps = {} }) => {
};
return (
<div id='soapbox'>
<Provider store={props.store}>
<MemoryRouter {...routerProps}>
<StatProvider>
@ -66,6 +67,7 @@ const TestApp: FC<any> = ({ children, storeProps, routerProps = {} }) => {
</StatProvider>
</MemoryRouter>
</Provider>
</div>
);
};

View File

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

View File

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

View File

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

View File

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

View File

@ -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} 设置限制。",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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': '🍩' },
]));
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,8 +1,3 @@
.autosuggest-input {
position: relative;
}
.autosuggest-input input,
.react-datepicker__input-container input {
// display: block;
// box-sizing: border-box;

View File

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

View File

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

View File

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

View File

@ -13,7 +13,6 @@
}
a {
color: var(--brand-color--hicontrast);
font-weight: 500;
text-decoration: underline;
@ -28,24 +27,21 @@
&__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 {
&__uploads-wrapper {
display: flex;
flex-direction: row;
flex-wrap: wrap;
&.contains-media {
padding: 5px;
border-top: 1px solid var(--foreground-color);
}
}
}
.compose-form__upload {
&__upload {
flex: 1 1 0;
min-width: 40%;
margin: 5px;
@ -97,9 +93,9 @@
object-fit: cover;
}
}
}
}
.compose-form__upload-thumbnail {
&__upload-thumbnail {
background-position: center;
background-size: contain;
background-repeat: no-repeat;
@ -117,10 +113,22 @@
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;
.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%;
@ -129,9 +137,9 @@
&.bottom {
transform-origin: 50% 0;
}
}
}
.privacy-dropdown__option {
&__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 {
@ -149,13 +157,12 @@
&.active {
@apply hover:bg-gray-200 dark:hover:bg-gray-700;
}
}
.privacy-dropdown__option__icon {
@apply flex items-center justify-center mr-2.5;
}
&__icon {
@apply flex items-center justify-center mr-2.5 rtl:mr-0 rtl:ml-2.5;
}
.privacy-dropdown__option__content {
&__content {
@apply flex-auto text-primary-600 dark:text-primary-400;
strong {
@ -167,32 +174,6 @@
}
}
}
}
.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;
}
}
}
&.top .privacy-dropdown__value {
@apply rounded-t-md;
}
.privacy-dropdown__dropdown {
@apply block shadow-md;
}
}

View File

@ -1,69 +0,0 @@
.dropdown-menu {
@apply absolute bg-white dark:bg-gray-900 z-[1001] rounded-md shadow-lg py-1 w-56 dark:ring-2 dark:ring-primary-700 focus:outline-none;
&.left { transform-origin: 100% 50%; }
&.top { transform-origin: 50% 100%; }
&.bottom { transform-origin: 50% 0; }
&.right { transform-origin: 0 50%; }
&__arrow {
@apply absolute w-0 h-0;
border: 0 solid transparent;
&.left {
@apply border-l-white dark:border-l-gray-900;
right: -5px;
margin-top: -5px;
border-width: 5px 0 5px 5px;
}
&.top {
@apply border-t-white dark:border-t-gray-900;
bottom: -5px;
margin-left: -5px;
border-width: 5px 5px 0;
}
&.bottom {
@apply border-b-white dark:border-b-gray-900;
top: -5px;
margin-left: -5px;
border-width: 0 5px 5px;
}
&.right {
@apply border-r-white dark:border-r-gray-900;
left: -5px;
margin-top: -5px;
border-width: 5px 5px 5px 0;
}
}
ul {
overflow: hidden;
}
&__item {
@apply focus-within:ring-primary-500 focus-within:ring-2;
a {
@apply 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:bg-gray-100 dark:focus:bg-primary-800 cursor-pointer;
> .svg-icon:first-child {
@apply h-5 w-5 mr-2.5 transition-none;
svg {
@apply stroke-[1.5px] transition-none;
}
}
}
&.destructive a {
@apply text-danger-600 dark:text-danger-400;
}
}
&__separator {
@apply block my-2 h-[1px] bg-gray-100 dark:bg-gray-800;
}
}

View File

@ -1,98 +0,0 @@
.emoji-react {
@apply inline-block text-gray-900 dark:text-gray-300 no-underline;
transition: 0.2s;
&__emoji {
img {
@apply w-5 h-5;
filter: drop-shadow(2px 0 0 var(--foreground-color));
}
}
&__count {
@apply hidden;
}
+ .emoji-react {
@apply -mr-3;
}
&[type='button'] {
cursor: pointer;
}
}
.emoji-reacts {
display: inline-flex;
flex-direction: row-reverse;
}
.emoji-reacts-container {
display: inline-flex;
&:hover {
.emoji-react {
margin: 0;
&__count {
display: inline;
}
}
.emoji-reacts__count {
display: none;
}
}
}
.emoji-react-selector {
position: absolute;
display: flex;
background-color: var(--foreground-color);
padding: 5px 8px;
border-radius: 9999px;
box-shadow: 0 0 6px 0 rgba(0, 0, 0, 0.1);
opacity: 0;
pointer-events: none;
transition: 0.1s;
z-index: 999;
&--visible,
&--focused {
opacity: 1;
pointer-events: all;
}
&__emoji {
display: block;
padding: 0;
margin: 0;
border: 0;
background: transparent;
img {
width: 36px;
height: 36px;
padding: 3px;
transition: 0.1s;
}
&:hover,
&:focus {
img {
transform: scale(1.2);
}
}
}
}
.status .emoji-react-selector {
bottom: 100%;
left: -20px;
@media (max-width: 455px) {
bottom: 31px;
right: 10px;
left: auto;
}
}

View File

@ -1,93 +0,0 @@
.filter-settings-panel {
.fields-group .two-col {
display: flex;
align-items: flex-start;
width: 100%;
justify-content: flex-start;
flex-wrap: wrap;
div.input {
width: 45%;
margin-right: 20px;
.label_input {
width: 100%;
}
}
@media (max-width: 485px) {
div.input {
width: 100%;
margin-right: 5px;
.label_input {
width: auto;
}
}
}
}
.input.boolean {
.label_input {
@apply relative pl-7 text-black dark:text-white;
label {
@apply text-sm;
}
&__wrapper {
@apply static;
}
input[type='checkbox'] {
position: absolute;
top: 3px;
left: 0;
}
}
.hint {
@apply block pl-7 text-xs text-gray-500 dark:text-gray-400;
}
}
.filter__container {
@apply flex justify-between py-5 px-2 text-sm text-black dark:text-white;
.filter__phrase,
.filter__contexts,
.filter__details {
@apply py-1;
}
span.filter__list-label {
@apply pr-1 text-gray-500 dark:text-gray-400;
}
span.filter__list-value span {
@apply pr-1 capitalize;
&::after {
content: ',';
}
&:last-of-type {
&::after {
content: '';
}
}
}
.filter__delete {
@apply flex items-center h-5 m-2.5 cursor-pointer;
span.filter__delete-label {
@apply text-gray-500 dark:text-gray-400 font-semibold;
}
.filter__delete-icon {
@apply mx-1 text-gray-500 dark:text-gray-400;
}
}
}
}

View File

@ -12,46 +12,6 @@
height: 100%;
transition: 0.2s;
}
&--active {
&.svg-icon--home svg {
fill: currentcolor;
}
svg.icon-tabler-search,
svg.icon-tabler-code {
stroke-width: 2.3px;
}
svg.icon-tabler-bell,
svg.icon-tabler-messages {
path:nth-child(2) {
fill: currentcolor;
}
}
svg.icon-tabler-users {
circle,
circle + path {
fill: currentcolor;
}
}
svg.icon-tabler-mail {
stroke: var(--background-color);
rect {
fill: currentcolor;
stroke: currentcolor;
}
}
}
&--unread {
svg.icon-tabler-bell {
transform: rotate(45deg);
}
}
}
.icon-button > div {

View File

@ -1,3 +1,5 @@
$media-compact-size: 50px;
.media-gallery {
box-sizing: border-box;
overflow: hidden;
@ -6,10 +8,8 @@
position: relative;
width: 100%;
height: auto;
background-color: var(--brand-color--faint);
}
.media-gallery__item {
&__item {
border: 0;
box-sizing: border-box;
display: block;
@ -48,9 +48,8 @@
justify-content: center;
pointer-events: none;
}
}
.media-gallery__item-thumbnail {
&-thumbnail {
@apply text-gray-400;
cursor: zoom-in;
display: block;
@ -66,9 +65,10 @@
height: 100%;
object-fit: cover;
}
}
}
}
.media-gallery__preview {
&__preview {
@apply bg-gray-200 dark:bg-gray-900 rounded-lg;
width: 100%;
height: 100%;
@ -81,16 +81,16 @@
&--hidden {
display: none;
}
}
}
.media-gallery__gifv {
&__gifv {
height: 100%;
overflow: hidden;
position: relative;
width: 100%;
}
}
.media-gallery__item-gifv-thumbnail {
&__item-gifv-thumbnail {
@apply rounded-md;
cursor: zoom-in;
height: 100%;
@ -100,29 +100,18 @@
z-index: 1;
transform: none;
top: 0;
}
}
.media-gallery__gifv__label,
.media-gallery__filename__label,
.media-gallery__file-extension__label {
display: block;
position: absolute;
color: #fff;
background: rgba($base-overlay-background, 0.5);
bottom: 6px;
left: 6px;
padding: 2px 6px;
border-radius: 2px;
&__gifv__label,
&__filename__label,
&__file-extension__label {
@apply pointer-events-none absolute bottom-1.5 left-1.5 z-[1] block bg-black/50 py-0.5 px-1.5 font-semibold text-white opacity-90;
font-size: 11px;
font-weight: 600;
z-index: 1;
pointer-events: none;
opacity: 0.9;
transition: opacity 0.1s ease;
line-height: 18px;
}
}
.media-gallery__gifv {
&__gifv {
&.autoplay {
.media-gallery__gifv__label {
display: none;
@ -134,11 +123,9 @@
opacity: 1;
}
}
}
}
$media-compact-size: 50px;
.media-gallery--compact {
&--compact {
height: $media-compact-size !important;
background: transparent;
@ -161,4 +148,5 @@ $media-compact-size: 50px;
.media-gallery__file-extension__label {
display: none;
}
}
}

View File

@ -27,21 +27,19 @@
height: 100%;
video {
max-width: $media-modal-media-max-width;
max-height: $media-modal-media-max-height;
@apply max-w-full max-h-[80%];
}
}
}
.media-modal__closer {
&__closer {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
}
.media-modal__navigation {
&__navigation {
position: absolute;
top: 0;
left: 0;
@ -55,57 +53,46 @@
pointer-events: auto;
}
&.media-modal__navigation--hidden {
&--hidden {
opacity: 0;
* {
pointer-events: none;
}
}
}
}
.media-modal__nav {
background: rgba($base-overlay-background, 0.5);
box-sizing: border-box;
border: 0;
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
font-size: 24px;
height: 20vmax;
margin: auto 0;
&__nav {
@apply absolute top-0 bottom-0 my-auto mx-0 box-border flex h-[20vmax] cursor-pointer items-center border-0 bg-black/50 text-2xl text-white;
padding: 30px 15px;
position: absolute;
top: 0;
bottom: 0;
@media screen and (max-width: 600px) { padding: 30px 2px; }
@media screen and (max-width: 600px) {
@apply px-0.5;
}
.svg-icon {
width: 24px;
height: 24px;
@apply h-6 w-6;
}
}
.media-modal__nav--left {
&--left {
left: 0;
}
}
.media-modal__nav--right {
&--right {
right: 0;
}
}
}
.media-modal__pagination {
&__pagination {
width: 100%;
text-align: center;
position: absolute;
left: 0;
bottom: 20px;
pointer-events: none;
}
}
.media-modal__meta {
&__meta {
text-align: center;
position: absolute;
left: 0;
@ -128,13 +115,13 @@
text-decoration: underline;
}
}
}
}
.media-modal__page-dot {
&__page-dot {
display: inline-block;
}
}
.media-modal__button {
&__button {
background-color: #fff;
height: 12px;
width: 12px;
@ -143,13 +130,13 @@
padding: 0;
border: 0;
font-size: 0;
}
.media-modal__button--active {
&--active {
@apply bg-accent-500;
}
}
}
.media-modal__close {
&__close {
position: absolute;
right: 8px;
top: 8px;
@ -162,23 +149,27 @@
height: 48px;
width: 48px;
}
}
}
.error-modal {
@apply text-gray-900;
background: var(--background-color);
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.error-modal__body {
&__body {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 80vh;
width: 80vw;
max-width: 520px;
max-height: 420px;
position: relative;
text-align: center;
& > div {
position: absolute;
@ -195,19 +186,10 @@
opacity: 0;
user-select: text;
}
}
}
.error-modal__body {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
}
.error-modal__footer {
&__footer {
flex: 0 0 auto;
background: var(--background-color);
display: flex;
justify-content: center;
padding: 25px;
@ -215,9 +197,9 @@
& > div {
min-width: 33px;
}
}
.error-modal__nav {
color: var(--highlight-text-color);
&__nav {
border: 0;
font-size: 14px;
font-weight: 500;
@ -232,45 +214,39 @@
&:focus,
&:active {
@apply text-gray-400;
background-color: var(--background-color);
}
}
}
.actions-modal {
@apply flex-col relative text-gray-400 overflow-hidden;
border-radius: 10px;
border: 1px solid var(--background-color);
@apply flex-col relative text-gray-400 overflow-hidden w-full max-w-lg m-auto bg-white dark:bg-gray-900 shadow-xl rounded-2xl;
max-height: calc(100vh - 3rem);
&__item-label {
font-weight: 500;
}
.dropdown-menu__separator {
@apply block m-2 h-[1px] bg-gray-200 dark:bg-gray-600;
}
}
.actions-modal {
@apply w-full max-h-full max-w-lg m-auto mb-2 bg-white dark:bg-gray-800;
.status {
overflow-y: auto;
max-height: 300px;
&__status {
@apply overflow-y-auto max-h-[300px];
}
.actions-modal__item-label { font-weight: 500; }
ul {
@apply my-2 flex-shrink-0 overflow-y-auto;
max-height: calc(100vh - 147px);
// NOTE - not sure what this is yet, leaving alone for now until I find out.
&.with-status { max-height: calc(80vh - 75px); }
li:not(:empty) {
a,
button {
@apply flex items-center px-4 py-3 text-gray-600 dark:text-gray-300 no-underline hover:bg-gray-100 dark:bg-gray-800 hover:text-gray-800 dark:hover:text-gray-200 text-left;
@apply flex items-center px-4 py-3 text-gray-700 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 focus:bg-gray-100 dark:focus:bg-primary-800 no-underline text-left;
&.destructive {
@apply text-danger-600;
@apply text-danger-600 dark:text-danger-400;
}
.svg-icon:first-child {
@ -278,11 +254,6 @@
svg {
stroke-width: 1.5;
&.feather {
// Feather icons are a little larger
transform: scale(0.9);
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More