nicolium: add Pleroma-style indented tree view
Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
@ -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 };
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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: () => {
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user