nicolium: add Pleroma-style indented tree view

Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
nicole mikołajczyk
2026-03-17 10:35:19 +01:00
parent 686dd0ae6a
commit 1dcc2e1ca3
7 changed files with 108 additions and 13 deletions

View File

@ -7,6 +7,7 @@ import PlaceholderStatus from '@/features/placeholder/components/placeholder-sta
import { useMinimalStatus } from '@/queries/statuses/use-status';
import { useReplyCount, useReplyToId } from '@/stores/contexts';
import { useStatusMeta } from '@/stores/status-meta';
import { isMobile } from '@/utils/is-mobile';
import type { FilterContextType } from '@/queries/settings/use-filters';
@ -17,6 +18,7 @@ interface IThreadStatus {
onMoveUp: (id: string) => void;
onMoveDown: (id: string) => void;
linear?: boolean;
depth?: number;
}
/** Status with reply-connector in threads. */
@ -29,16 +31,22 @@ const ThreadStatus: React.FC<IThreadStatus> = (props): React.JSX.Element => {
const isLoaded = Boolean(statusData);
const { deleted } = useStatusMeta(id);
const [maxIndentDepth] = React.useState(isMobile() ? 6 : 8);
const isIndentMode = props.depth !== undefined;
const depth = Math.min(props.depth ?? 0, maxIndentDepth);
if (deleted) {
return (
<div className='py-4 pb-8'>
{depth > 0 && <DepthBorders depth={depth} />}
<Tombstone id={id} onMoveUp={props.onMoveUp} onMoveDown={props.onMoveDown} deleted />
</div>
);
}
const renderConnector = (): React.JSX.Element | null => {
if (props.linear) return null;
const renderTreeConnector = (): React.JSX.Element | null => {
if (props.linear || isIndentMode) return null;
const isConnectedTop = replyToId && replyToId !== focusedStatusId;
const isConnectedBottom = replyCount > 0;
@ -58,16 +66,19 @@ const ThreadStatus: React.FC<IThreadStatus> = (props): React.JSX.Element => {
);
};
const status = isLoaded ? (
<StatusContainer {...props} showGroup={false} />
) : (
<PlaceholderStatus variant='default' />
);
return (
<div
className={clsx('thread__status relative pb-4', { 'thread__status--linear': props.linear })}
>
{renderConnector()}
{isLoaded ? (
<StatusContainer {...props} showGroup={false} />
) : (
<PlaceholderStatus variant='default' />
)}
{isIndentMode && depth > 0 && <DepthBorders depth={depth} />}
{renderTreeConnector()}
<div style={depth > 0 ? { marginInlineStart: `${depth}rem` } : undefined}>{status}</div>
{props.linear && (
<hr className='-mx-4 mt-2 max-w-[100vw] border-t-2 black:border-t dark:border-gray-800' />
)}
@ -75,4 +86,16 @@ const ThreadStatus: React.FC<IThreadStatus> = (props): React.JSX.Element => {
);
};
const DepthBorders: React.FC<{ depth: number }> = ({ depth }) => (
<>
{new Array(depth).fill(0).map((_, d) => (
<span
key={d}
className='thread-indent-border'
style={{ insetInlineStart: `${d + 0.5}rem` }}
/>
))}
</>
);
export { ThreadStatus as default };

View File

@ -16,7 +16,7 @@ import {
useUnreblogStatus,
} from '@/queries/statuses/use-status-interactions';
import { useComposeActions } from '@/stores/compose';
import { useThread } from '@/stores/contexts';
import { useThread, useThreadDepths } from '@/stores/contexts';
import { useModalsActions } from '@/stores/modals';
import { useSettings } from '@/stores/settings';
import { useStatusMeta, useStatusMetaActions } from '@/stores/status-meta';
@ -64,7 +64,9 @@ const Thread = ({
const { mutate: unreblogStatus } = useUnreblogStatus(status.id);
const linear = displayMode === 'linear';
const treeIndent = displayMode === 'tree-indent';
const thread = useThread(status.id, linear);
const depths = useThreadDepths(treeIndent ? status.id : undefined);
const statusIndex = thread.indexOf(status.id);
const initialIndex = isModal && statusIndex !== 0 ? statusIndex + 1 : statusIndex;
@ -225,6 +227,7 @@ const Thread = ({
onMoveDown={handleMoveDown}
contextType='thread'
linear={linear}
depth={treeIndent ? depths[id] : undefined}
/>
);
@ -339,7 +342,7 @@ const Thread = ({
if (isModal) children.unshift(<div key='padding' className='h-4' />);
return children;
}, [thread, linear, status, isModal]);
}, [thread, displayMode, status, isModal]);
const meta = useMemo(() => {
const firstAttachment = status.media_attachments && status.media_attachments[0];

View File

@ -1925,6 +1925,7 @@
"status.spoiler.expand": "Expand",
"status.thread.expand_all": "Expand all posts",
"status.thread.linear_view": "Linear view",
"status.thread.tree_indent_view": "Tree (indented)",
"status.thread.tree_view": "Tree view",
"status.title": "Post details",
"status.title_direct": "Direct message",

View File

@ -39,6 +39,7 @@ const messages = defineMessages({
'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?',
},
treeView: { id: 'status.thread.tree_view', defaultMessage: 'Tree view' },
treeIndentView: { id: 'status.thread.tree_indent_view', defaultMessage: 'Tree (indented)' },
linearView: { id: 'status.thread.linear_view', defaultMessage: 'Linear view' },
expandAll: { id: 'status.thread.expand_all', defaultMessage: 'Expand all posts' },
});
@ -77,6 +78,15 @@ const StatusPage: React.FC = () => {
type: 'radio',
checked: displayMode === 'tree',
},
{
text: intl.formatMessage(messages.treeIndentView),
action: () => {
changeSetting(['threads', 'displayMode'], 'tree-indent');
},
icon: require('@phosphor-icons/core/regular/tree-structure.svg'),
type: 'radio',
checked: displayMode === 'tree-indent',
},
{
text: intl.formatMessage(messages.linearView),
action: () => {

View File

@ -104,7 +104,7 @@ const settingsSchema = v.object({
}),
threads: coerceObject({
displayMode: v.optional(v.picklist(['tree', 'linear']), 'tree'),
displayMode: v.optional(v.picklist(['tree', 'tree-indent', 'linear']), 'tree'),
}),
notifications: coerceObject({

View File

@ -256,6 +256,35 @@ const useDescendantsIds = (statusId?: string) => {
);
};
const useThreadDepths = (statusId?: string) => {
const inReplyTos = useContextStore((state) => state.inReplyTos);
const replies = useContextStore((state) => state.replies);
return useMemo(() => {
const depths: Record<string, number> = {};
if (!statusId) return depths;
const ancestorsIds = getAncestorsIds(statusId, inReplyTos);
for (const id of ancestorsIds) depths[id] = 0;
depths[statusId] = 0;
const queue: Array<{ id: string; depth: number }> = [{ id: statusId, depth: -1 }];
const visited = new Set<string>([statusId]);
while (queue.length > 0) {
const { id, depth } = queue.shift()!;
for (const childId of replies[id] || []) {
if (visited.has(childId)) continue;
visited.add(childId);
depths[childId] = Math.max(0, depth + 1);
queue.push({ id: childId, depth: depth + 1 });
}
}
return depths;
}, [inReplyTos, replies, statusId]);
};
const useThread = (statusId?: string, linear?: boolean) => {
const inReplyTos = useContextStore((state) => state.inReplyTos);
const replies = useContextStore((state) => state.replies);
@ -307,6 +336,7 @@ export {
useContextStore,
useDescendantsIds,
useThread,
useThreadDepths,
useReplyToId,
useReplyCount,
useContextsActions,

View File

@ -1,13 +1,41 @@
.thread__status {
.status__wrapper {
@apply shadow-none p-0;
padding: 0;
box-shadow: none;
}
&:not(&--linear) .status__content-wrapper {
@apply pl-[54px] rtl:pl-0 rtl:pr-[calc(54px)];
padding-left: 54px;
dir[dir="rtl"] & {
padding-right: 54px;
padding-left: 0;
}
}
}
div:last-child > .thread__status--linear hr {
display: none;
}
.thread-indent-border {
position: absolute;
top: -1rem;
bottom: 1rem;
width: 2px;
background: rgb(var(--color-gray-200));
.dark & {
background: rgb(var(--color-primary-800));
}
.black & {
background: rgb(var(--color-gray-800));
}
&:last-of-type:not(:first-child) {
top: 21px;
}
}