pl-fe: Allow pleroma-fe-like linear view

Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
nicole mikołajczyk
2025-09-01 15:15:10 +02:00
parent f09ea01f9e
commit db3460eb04
8 changed files with 126 additions and 44 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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