diff --git a/packages/nicolium/src/features/status/components/thread-status.tsx b/packages/nicolium/src/features/status/components/thread-status.tsx index e1f033aa0..8323b344f 100644 --- a/packages/nicolium/src/features/status/components/thread-status.tsx +++ b/packages/nicolium/src/features/status/components/thread-status.tsx @@ -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 = (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 (
+ {depth > 0 && }
); } - 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 = (props): React.JSX.Element => { ); }; + const status = isLoaded ? ( + + ) : ( + + ); + return (
- {renderConnector()} - {isLoaded ? ( - - ) : ( - - )} + {isIndentMode && depth > 0 && } + {renderTreeConnector()} +
0 ? { marginInlineStart: `${depth}rem` } : undefined}>{status}
{props.linear && (
)} @@ -75,4 +86,16 @@ const ThreadStatus: React.FC = (props): React.JSX.Element => { ); }; +const DepthBorders: React.FC<{ depth: number }> = ({ depth }) => ( + <> + {new Array(depth).fill(0).map((_, d) => ( + + ))} + +); + export { ThreadStatus as default }; diff --git a/packages/nicolium/src/features/status/components/thread.tsx b/packages/nicolium/src/features/status/components/thread.tsx index 5895a9bb1..90fe28466 100644 --- a/packages/nicolium/src/features/status/components/thread.tsx +++ b/packages/nicolium/src/features/status/components/thread.tsx @@ -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(
); return children; - }, [thread, linear, status, isModal]); + }, [thread, displayMode, status, isModal]); const meta = useMemo(() => { const firstAttachment = status.media_attachments && status.media_attachments[0]; diff --git a/packages/nicolium/src/locales/en.json b/packages/nicolium/src/locales/en.json index 00d46c556..0f67c3a61 100644 --- a/packages/nicolium/src/locales/en.json +++ b/packages/nicolium/src/locales/en.json @@ -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", diff --git a/packages/nicolium/src/pages/statuses/status.tsx b/packages/nicolium/src/pages/statuses/status.tsx index b3da65223..a24925b39 100644 --- a/packages/nicolium/src/pages/statuses/status.tsx +++ b/packages/nicolium/src/pages/statuses/status.tsx @@ -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: () => { diff --git a/packages/nicolium/src/schemas/frontend-settings.ts b/packages/nicolium/src/schemas/frontend-settings.ts index 5c4926e3c..b657ea490 100644 --- a/packages/nicolium/src/schemas/frontend-settings.ts +++ b/packages/nicolium/src/schemas/frontend-settings.ts @@ -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({ diff --git a/packages/nicolium/src/stores/contexts.ts b/packages/nicolium/src/stores/contexts.ts index 99ae1c745..567961269 100644 --- a/packages/nicolium/src/stores/contexts.ts +++ b/packages/nicolium/src/stores/contexts.ts @@ -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 = {}; + 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([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, diff --git a/packages/nicolium/src/styles/components/detailed-status.scss b/packages/nicolium/src/styles/components/detailed-status.scss index d53f0088d..fc62a6efb 100644 --- a/packages/nicolium/src/styles/components/detailed-status.scss +++ b/packages/nicolium/src/styles/components/detailed-status.scss @@ -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; + } +}