pl-fe: Allow pleroma-fe-like linear view
Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
@ -21,7 +21,7 @@ interface MenuItem {
|
||||
target?: React.HTMLAttributeAnchorTarget;
|
||||
text: string;
|
||||
to?: string;
|
||||
type?: 'toggle';
|
||||
type?: 'toggle' | 'radio';
|
||||
items?: Array<Omit<MenuItem, 'items'>>;
|
||||
}
|
||||
|
||||
@ -120,7 +120,7 @@ const DropdownMenuItem = ({ index, item, onClick, autoFocus, onSetTab }: IDropdo
|
||||
>
|
||||
{item.icon && <Icon src={item.icon} className='mr-3 size-5 flex-none rtl:ml-3 rtl:mr-0' />}
|
||||
|
||||
<div className={clsx('truncate', { 'text-xs': item.meta, 'text-base': !item.meta, 'mr-2': item.count || item.type === 'toggle' || item.items?.length })}>
|
||||
<div className={clsx('truncate', { 'text-xs': item.meta, 'text-base': !item.meta, 'mr-2': item.count || item.type === 'toggle' || item.type === 'radio' || item.items?.length })}>
|
||||
{item.meta ? (
|
||||
<>
|
||||
<div className='truncate text-base'>{item.text}</div>
|
||||
@ -135,9 +135,9 @@ const DropdownMenuItem = ({ index, item, onClick, autoFocus, onSetTab }: IDropdo
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{item.type === 'toggle' && (
|
||||
{(item.type === 'toggle' || item.type === 'radio') && (
|
||||
<div className='ml-auto'>
|
||||
<Toggle checked={item.checked} onChange={handleChange} />
|
||||
<Toggle checked={item.checked} onChange={handleChange} radio={item.type === 'radio'} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@ -3,10 +3,11 @@ import React, { useRef } from 'react';
|
||||
|
||||
interface IToggle extends Pick<React.InputHTMLAttributes<HTMLInputElement>, 'id' | 'name' | 'checked' | 'onChange' | 'required' | 'disabled'> {
|
||||
size?: 'sm' | 'md';
|
||||
radio?: boolean;
|
||||
}
|
||||
|
||||
/** A glorified checkbox. */
|
||||
const Toggle: React.FC<IToggle> = ({ id, size = 'md', name, checked = false, onChange, required, disabled }) => {
|
||||
const Toggle: React.FC<IToggle> = ({ id, size = 'md', name, checked = false, onChange, required, disabled, radio }) => {
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleClick: React.MouseEventHandler<HTMLButtonElement> = () => {
|
||||
@ -16,7 +17,14 @@ const Toggle: React.FC<IToggle> = ({ id, size = 'md', name, checked = false, onC
|
||||
|
||||
return (
|
||||
<button
|
||||
className={clsx('flex-none rounded-full focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:ring-gray-800 dark:ring-offset-0 dark:focus:ring-primary-500', {
|
||||
className={clsx('flex-none rounded-full focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:ring-gray-800 dark:ring-offset-0 dark:focus:ring-primary-500', radio ? {
|
||||
'p-0.5 border-2': true,
|
||||
'border-gray-500': !checked && !disabled,
|
||||
'border-primary-600': checked && !disabled,
|
||||
'border-gray-200': !checked && disabled,
|
||||
'border-primary-200': checked && disabled,
|
||||
'cursor-default': disabled,
|
||||
} : {
|
||||
'bg-gray-500': !checked && !disabled,
|
||||
'bg-primary-600': checked && !disabled,
|
||||
'bg-gray-200': !checked && disabled,
|
||||
@ -28,12 +36,18 @@ const Toggle: React.FC<IToggle> = ({ id, size = 'md', name, checked = false, onC
|
||||
onClick={handleClick}
|
||||
type='button'
|
||||
>
|
||||
<div className={clsx('rounded-full bg-white transition-transform', {
|
||||
'h-4.5 w-4.5': size === 'sm',
|
||||
'translate-x-3.5 rtl:-translate-x-3.5': size === 'sm' && checked,
|
||||
'h-6 w-6': size === 'md',
|
||||
'translate-x-4 rtl:-translate-x-4': size === 'md' && checked,
|
||||
})}
|
||||
<div
|
||||
className={radio ? clsx('rounded-full', {
|
||||
'h-3 w-3': size === 'sm',
|
||||
'h-4 w-4': size === 'md',
|
||||
'bg-primary-600': checked && !disabled,
|
||||
'bg-primary-200': checked && disabled,
|
||||
}) : clsx('rounded-full bg-white transition-transform', {
|
||||
'h-4.5 w-4.5': size === 'sm',
|
||||
'translate-x-3.5 rtl:-translate-x-3.5': size === 'sm' && checked,
|
||||
'h-6 w-6': size === 'md',
|
||||
'translate-x-4 rtl:-translate-x-4': size === 'md' && checked,
|
||||
})}
|
||||
/>
|
||||
|
||||
<input
|
||||
|
||||
@ -12,6 +12,7 @@ interface IThreadStatus {
|
||||
focusedStatusId: string;
|
||||
onMoveUp: (id: string) => void;
|
||||
onMoveDown: (id: string) => void;
|
||||
linear?: boolean;
|
||||
}
|
||||
|
||||
/** Status with reply-connector in threads. */
|
||||
@ -32,6 +33,8 @@ const ThreadStatus: React.FC<IThreadStatus> = (props): JSX.Element => {
|
||||
}
|
||||
|
||||
const renderConnector = (): JSX.Element | null => {
|
||||
if (props.linear) return null;
|
||||
|
||||
const isConnectedTop = replyToId && replyToId !== focusedStatusId;
|
||||
const isConnectedBottom = replyCount > 0;
|
||||
const isConnected = isConnectedTop || isConnectedBottom;
|
||||
@ -48,7 +51,7 @@ const ThreadStatus: React.FC<IThreadStatus> = (props): JSX.Element => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='thread__status relative pb-4'>
|
||||
<div className={clsx('thread__status relative pb-4', { 'thread__status--linear': props.linear })}>
|
||||
{renderConnector()}
|
||||
{isLoaded ? (
|
||||
// @ts-ignore FIXME
|
||||
@ -56,6 +59,7 @@ const ThreadStatus: React.FC<IThreadStatus> = (props): JSX.Element => {
|
||||
) : (
|
||||
<PlaceholderStatus variant='default' />
|
||||
)}
|
||||
{props.linear && <hr className='-mx-4 mt-2 max-w-[100vw] border-t-2 black:border-t dark:border-gray-800' />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -31,11 +31,11 @@ import type { SelectedStatus } from 'pl-fe/selectors';
|
||||
import type { VirtuosoHandle } from 'react-virtuoso';
|
||||
|
||||
const makeGetAncestorsIds = () => createSelector([
|
||||
(_: RootState, statusId: string | undefined) => statusId,
|
||||
(_: RootState, statusId: string) => statusId,
|
||||
(state: RootState) => state.contexts.inReplyTos,
|
||||
], (statusId, inReplyTos) => {
|
||||
let ancestorsIds: Array<string> = [];
|
||||
let id: string | undefined = statusId;
|
||||
let id: string = statusId;
|
||||
|
||||
while (id && !ancestorsIds.includes(id)) {
|
||||
ancestorsIds = [id, ...ancestorsIds];
|
||||
@ -76,7 +76,34 @@ const makeGetDescendantsIds = () => createSelector([
|
||||
return [...new Set(descendantsIds)];
|
||||
});
|
||||
|
||||
const makeGetThread = () => {
|
||||
const makeGetThreadStatusesIds = () => createSelector([
|
||||
(_: RootState, statusId: string) => statusId,
|
||||
(state: RootState) => state.contexts.inReplyTos,
|
||||
(state: RootState) => state.contexts.replies,
|
||||
], (statusId, inReplyTos, replies) => {
|
||||
let parentStatus: string = statusId;
|
||||
|
||||
while (inReplyTos[parentStatus]) {
|
||||
parentStatus = inReplyTos[parentStatus];
|
||||
}
|
||||
|
||||
const threadStatuses = [parentStatus];
|
||||
|
||||
for (let i = 0; i < threadStatuses.length; i++) {
|
||||
for (const reply of replies[threadStatuses[i]] || []) {
|
||||
if (!threadStatuses.includes(reply)) threadStatuses.push(reply);
|
||||
}
|
||||
}
|
||||
|
||||
return threadStatuses.toSorted();
|
||||
});
|
||||
|
||||
const makeGetThread = (linear = false) => {
|
||||
if (linear) {
|
||||
const getThreadStatusesIds = makeGetThreadStatusesIds();
|
||||
return (state: RootState, statusId: string) => getThreadStatusesIds(state, statusId);
|
||||
}
|
||||
|
||||
const getAncestorsIds = makeGetAncestorsIds();
|
||||
const getDescendantsIds = makeGetDescendantsIds();
|
||||
|
||||
@ -89,10 +116,7 @@ const makeGetThread = () => {
|
||||
ancestorsIds = ancestorsIds.filter(id => id !== statusId && !descendantsIds.includes(id));
|
||||
descendantsIds = descendantsIds.filter(id => id !== statusId && !ancestorsIds.includes(id));
|
||||
|
||||
return {
|
||||
ancestorsIds,
|
||||
descendantsIds,
|
||||
};
|
||||
return [...ancestorsIds, statusId, ...descendantsIds];
|
||||
});
|
||||
};
|
||||
|
||||
@ -115,19 +139,21 @@ const Thread: React.FC<IThread> = ({
|
||||
|
||||
const { toggleStatusMediaHidden } = useStatusMetaStore();
|
||||
const { openModal } = useModalsStore();
|
||||
const { settings } = useSettingsStore();
|
||||
const { settings: { boostModal, threads: { displayMode } } } = useSettingsStore();
|
||||
|
||||
const { mutate: favouriteStatus } = useFavouriteStatus(status.id);
|
||||
const { mutate: unfavouriteStatus } = useUnfavouriteStatus(status.id);
|
||||
const { mutate: reblogStatus } = useReblogStatus(status.id);
|
||||
const { mutate: unreblogStatus } = useUnreblogStatus(status.id);
|
||||
|
||||
const getThread = useCallback(makeGetThread(), []);
|
||||
const linear = displayMode === 'linear';
|
||||
|
||||
const { ancestorsIds, descendantsIds } = useAppSelector((state) => getThread(state, status.id));
|
||||
const getThread = useCallback(makeGetThread(linear), [linear]);
|
||||
|
||||
let initialIndex = ancestorsIds.length;
|
||||
if (isModal && initialIndex !== 0) initialIndex = ancestorsIds.length + 1;
|
||||
const thread = useAppSelector((state) => getThread(state, status.id));
|
||||
|
||||
const statusIndex = thread.indexOf(status.id);
|
||||
const initialIndex = isModal && statusIndex !== 0 ? statusIndex + 1 : statusIndex;
|
||||
|
||||
const node = useRef<HTMLDivElement>(null);
|
||||
const statusRef = useRef<HTMLDivElement>(null);
|
||||
@ -147,7 +173,6 @@ const Thread: React.FC<IThread> = ({
|
||||
const handleReplyClick = (status: ComposeReplyAction['status']) => dispatch(replyCompose(status));
|
||||
|
||||
const handleReblogClick = (status: SelectedStatus, e?: React.MouseEvent) => {
|
||||
const boostModal = settings.boostModal;
|
||||
if (status.reblogged) {
|
||||
unreblogStatus();
|
||||
} else {
|
||||
@ -215,13 +240,13 @@ const Thread: React.FC<IThread> = ({
|
||||
|
||||
const handleMoveUp = (id: string) => {
|
||||
if (id === status.id) {
|
||||
_selectChild(ancestorsIds.length - 1);
|
||||
_selectChild(statusIndex - 1);
|
||||
} else {
|
||||
let index = ancestorsIds.indexOf(id);
|
||||
let index = thread.indexOf(id);
|
||||
|
||||
if (index === -1) {
|
||||
index = descendantsIds.indexOf(id);
|
||||
_selectChild(ancestorsIds.length + index);
|
||||
index = thread.indexOf(id);
|
||||
_selectChild(index);
|
||||
} else {
|
||||
_selectChild(index - 1);
|
||||
}
|
||||
@ -230,13 +255,13 @@ const Thread: React.FC<IThread> = ({
|
||||
|
||||
const handleMoveDown = (id: string) => {
|
||||
if (id === status.id) {
|
||||
_selectChild(ancestorsIds.length + 1);
|
||||
_selectChild(statusIndex + 1);
|
||||
} else {
|
||||
let index = ancestorsIds.indexOf(id);
|
||||
let index = thread.indexOf(id);
|
||||
|
||||
if (index === -1) {
|
||||
index = descendantsIds.indexOf(id);
|
||||
_selectChild(ancestorsIds.length + index + 2);
|
||||
index = thread.indexOf(id);
|
||||
_selectChild(index);
|
||||
} else {
|
||||
_selectChild(index + 1);
|
||||
}
|
||||
@ -279,6 +304,7 @@ const Thread: React.FC<IThread> = ({
|
||||
onMoveUp={handleMoveUp}
|
||||
onMoveDown={handleMoveDown}
|
||||
contextType='thread'
|
||||
linear={linear}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -295,6 +321,8 @@ const Thread: React.FC<IThread> = ({
|
||||
};
|
||||
|
||||
const renderChildren = (list: Array<string>) => list.map(id => {
|
||||
if (id === status.id) return focusedStatus;
|
||||
|
||||
if (id.endsWith('-tombstone')) {
|
||||
return renderTombstone(id);
|
||||
} else if (id.startsWith('末pending-')) {
|
||||
@ -307,20 +335,20 @@ const Thread: React.FC<IThread> = ({
|
||||
// Scroll focused status into view when thread updates.
|
||||
useEffect(() => {
|
||||
scroller.current?.scrollToIndex({
|
||||
index: ancestorsIds.length,
|
||||
index: statusIndex,
|
||||
offset: -146,
|
||||
});
|
||||
|
||||
// TODO: Actually fix this
|
||||
setTimeout(() => {
|
||||
scroller.current?.scrollToIndex({
|
||||
index: ancestorsIds.length,
|
||||
index: linear ? 0 : statusIndex,
|
||||
offset: -146,
|
||||
});
|
||||
|
||||
setTimeout(() => (node.current?.querySelector('.detailed-actualStatus') as HTMLDivElement)?.focus(), 100);
|
||||
}, 0);
|
||||
}, [status.id, ancestorsIds.length]);
|
||||
}, [status.id, statusIndex]);
|
||||
|
||||
const handleOpenCompareHistoryModal = useCallback((status: Pick<Status, 'id'>) => {
|
||||
openModal('COMPARE_HISTORY', {
|
||||
@ -328,7 +356,7 @@ const Thread: React.FC<IThread> = ({
|
||||
});
|
||||
}, [status.id]);
|
||||
|
||||
const hasDescendants = descendantsIds.length > 0;
|
||||
const hasDescendants = thread.length > statusIndex;
|
||||
|
||||
type HotkeyHandlers = { [key: string]: (keyEvent?: KeyboardEvent) => void };
|
||||
|
||||
@ -382,10 +410,9 @@ const Thread: React.FC<IThread> = ({
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderedAncestors = useMemo(() => [...(isModal ? [<div key='padding' className='h-4' />] : []), ...renderChildren(ancestorsIds)], [ancestorsIds]);
|
||||
const renderedDescendants = useMemo(() => renderChildren(descendantsIds), [descendantsIds]);
|
||||
const children = useMemo(() => renderChildren(thread), [thread, linear]);
|
||||
if (isModal) children.unshift(<div key='padding' className='h-4' />);
|
||||
|
||||
const children: (JSX.Element)[] = [...renderedAncestors, focusedStatus, ...renderedDescendants];
|
||||
|
||||
return (
|
||||
<Stack
|
||||
|
||||
@ -1716,6 +1716,8 @@
|
||||
"status.show_original": "Show original",
|
||||
"status.spoiler.collapse": "Collapse",
|
||||
"status.spoiler.expand": "Expand",
|
||||
"status.thread.linear_view": "Linear view",
|
||||
"status.thread.tree_view": "Tree view",
|
||||
"status.title": "Post details",
|
||||
"status.title_direct": "Direct message",
|
||||
"status.translate": "Translate",
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
|
||||
import { changeSetting } from 'pl-fe/actions/settings';
|
||||
import { fetchStatusWithContext } from 'pl-fe/actions/statuses';
|
||||
import DropdownMenu, { type Menu } from 'pl-fe/components/dropdown-menu';
|
||||
import MissingIndicator from 'pl-fe/components/missing-indicator';
|
||||
import PullToRefresh from 'pl-fe/components/pull-to-refresh';
|
||||
import Column from 'pl-fe/components/ui/column';
|
||||
@ -14,6 +16,7 @@ import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
|
||||
import { useAppSelector } from 'pl-fe/hooks/use-app-selector';
|
||||
import { useLoggedIn } from 'pl-fe/hooks/use-logged-in';
|
||||
import { makeGetStatus } from 'pl-fe/selectors';
|
||||
import { useSettingsStore } from 'pl-fe/stores/settings';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'status.title', defaultMessage: 'Post details' },
|
||||
@ -31,6 +34,8 @@ const messages = defineMessages({
|
||||
replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
|
||||
replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
|
||||
blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block and report' },
|
||||
treeView: { id: 'status.thread.tree_view', defaultMessage: 'Tree view' },
|
||||
linearView: { id: 'status.thread.linear_view', defaultMessage: 'Linear view' },
|
||||
});
|
||||
|
||||
type RouteParams = {
|
||||
@ -52,6 +57,8 @@ const StatusPage: React.FC<IStatusDetails> = (props) => {
|
||||
|
||||
const [isLoaded, setIsLoaded] = useState<boolean>(!!status);
|
||||
|
||||
const { settings: { threads: { displayMode } } } = useSettingsStore();
|
||||
|
||||
/** Fetch the status (and context) from the API. */
|
||||
const fetchData = () => {
|
||||
const { params } = props;
|
||||
@ -70,6 +77,23 @@ const StatusPage: React.FC<IStatusDetails> = (props) => {
|
||||
|
||||
const handleRefresh = () => fetchData();
|
||||
|
||||
const items: Menu = useMemo(() => [
|
||||
{
|
||||
text: intl.formatMessage(messages.treeView),
|
||||
action: () => dispatch(changeSetting(['threads', 'displayMode'], 'tree')),
|
||||
icon: require('@tabler/icons/outline/list-tree.svg'),
|
||||
type: 'radio',
|
||||
checked: displayMode === 'tree',
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage(messages.linearView),
|
||||
action: () => dispatch(changeSetting(['threads', 'displayMode'], 'linear')),
|
||||
icon: require('@tabler/icons/outline/list.svg'),
|
||||
type: 'radio',
|
||||
checked: displayMode === 'linear',
|
||||
},
|
||||
], [displayMode]);
|
||||
|
||||
if (status?.event) {
|
||||
return (
|
||||
<Redirect to={`/@${status.account.acct}/events/${status.id}`} />
|
||||
@ -101,7 +125,10 @@ const StatusPage: React.FC<IStatusDetails> = (props) => {
|
||||
|
||||
return (
|
||||
<Stack space={4}>
|
||||
<Column label={intl.formatMessage(titleMessage())}>
|
||||
<Column
|
||||
label={intl.formatMessage(titleMessage())}
|
||||
action={<DropdownMenu items={items} src={require('@tabler/icons/outline/dots-vertical.svg')} />}
|
||||
>
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
<Thread key={status.id} status={status} />
|
||||
</PullToRefresh>
|
||||
|
||||
@ -93,6 +93,10 @@ const settingsSchema = v.object({
|
||||
pinnedHosts: v.optional(v.array(v.string()), []),
|
||||
}),
|
||||
|
||||
threads: coerceObject({
|
||||
displayMode: v.optional(v.picklist(['tree', 'linear']), 'tree'),
|
||||
}),
|
||||
|
||||
notifications: coerceObject({
|
||||
quickFilter: coerceObject({
|
||||
active: v.optional(v.string(), 'all'),
|
||||
|
||||
@ -3,7 +3,11 @@
|
||||
@apply shadow-none p-0;
|
||||
}
|
||||
|
||||
.status__content-wrapper {
|
||||
&:not(&--linear) .status__content-wrapper {
|
||||
@apply pl-[54px] rtl:pl-0 rtl:pr-[calc(54px)];
|
||||
}
|
||||
}
|
||||
|
||||
div:last-child > .thread__status--linear hr {
|
||||
display: none;
|
||||
}
|
||||
Reference in New Issue
Block a user