Real nested comments

- Collapsed/expandable replies
- Pagination for many many comments
This commit is contained in:
Lim Chee Aun 2022-12-18 20:46:13 +08:00
parent 9eb40d165f
commit 2f24713d71
3 changed files with 189 additions and 57 deletions

View file

@ -146,7 +146,41 @@ a.mention span {
.timeline.contextual > li.descendant {
position: relative;
}
.timeline.contextual > li.descendant.indirect:before {
.timeline.contextual > li.descendant:not(.thread) {
padding-bottom: 1em;
}
.timeline.contextual > li.descendant:not(.thread) > .status-link {
padding-left: 40px;
}
.timeline.contextual
> li.descendant.thread
> .status-link
+ .replies
> summary {
margin-left: calc(50px + 16px + 16px);
}
.timeline.contextual
> li.descendant.thread
> .status-link
+ .replies
.status-link {
padding-left: calc(50px + 16px + 16px);
}
.timeline.contextual
> li.descendant:not(.thread)
> .status-link
+ .replies
> summary {
margin-left: calc(40px + 16px);
}
.timeline.contextual
> li.descendant:not(.thread)
> .status-link
+ .replies
.status-link {
padding-left: calc(40px + 16px);
}
.timeline.contextual > li.descendant:not(.thread):before {
--radius: 10px;
--diameter: calc(var(--radius) * 2);
content: '';
@ -161,9 +195,79 @@ a.mention span {
border-color: transparent transparent var(--comment-line-color) transparent;
transform: rotate(45deg);
}
.timeline.contextual > li.descendant.indirect .status-link {
.timeline.contextual > li .replies {
margin-top: -16px;
font-size: 90%;
}
.timeline.contextual > li .replies :is(ul, li) {
margin: 0;
padding: 0;
list-style: none;
}
.timeline.contextual > li .replies summary {
padding: 8px 16px;
background-color: var(--bg-faded-color);
display: inline-block;
border-radius: 8px;
cursor: pointer;
text-transform: uppercase;
font-weight: bold;
font-size: 12px;
color: var(--text-insignificant-color);
user-select: none;
}
.timeline.contextual > li .replies summary:active,
.timeline.contextual > li .replies[open] summary {
color: var(--text-color);
background-color: var(--comment-line-color);
background-image: linear-gradient(
to top right,
var(--comment-line-color),
var(--bg-faded-color)
);
}
.timeline.contextual > li .replies[open] summary {
border-bottom-left-radius: 0;
}
.timeline.contextual > li .replies li {
position: relative;
}
.timeline.contextual > li .replies li .status {
--width: 3px;
--left: 0px;
--right: calc(var(--left) + var(--width));
background-image: linear-gradient(
to right,
transparent,
transparent var(--left),
var(--comment-line-color) var(--left),
var(--comment-line-color) var(--right),
transparent var(--right),
transparent
);
background-repeat: no-repeat;
}
.timeline.contextual > li .replies li:last-child .status {
background-size: 100% 20px;
}
.timeline.contextual > li .replies li:before {
--radius: 10px;
--diameter: calc(var(--radius) * 2);
content: '';
position: absolute;
top: 10px;
left: calc(40px + 16px);
width: var(--diameter);
height: var(--diameter);
border-radius: var(--radius);
border-style: solid;
border-width: var(--width);
border-color: transparent transparent var(--comment-line-color) transparent;
transform: rotate(45deg);
}
.timeline.contextual > li.thread .replies li:before {
left: calc(50px + 16px + 16px);
}
.timeline-deck.compact .status {
max-height: max(25vh, 160px);

View file

@ -90,14 +90,6 @@
.status.skeleton > .avatar {
background-color: var(--outline-color);
}
.indirect .status {
padding-left: 57px;
}
.indirect .status .avatar {
width: 25px !important;
height: 25px !important;
transform: translateX(5px);
}
.status .container {
flex-grow: 1;

View file

@ -28,15 +28,18 @@ function StatusPage({ id }) {
setUIState('loading');
if (!states.statuses.has(id)) {
try {
const status = await masto.statuses.fetch(id);
states.statuses.set(id, status);
} catch (e) {
const hasStatus = snapStates.statuses.has(id);
let heroStatus = snapStates.statuses.get(id);
try {
heroStatus = await masto.statuses.fetch(id);
states.statuses.set(id, heroStatus);
} catch (e) {
// Silent fail if status is cached
if (!hasStatus) {
setUIState('error');
alert('Error fetching status');
return;
}
return;
}
try {
@ -46,38 +49,46 @@ function StatusPage({ id }) {
ancestors.forEach((status) => {
states.statuses.set(status.id, status);
});
const directReplies = [];
const nestedDescendants = [];
descendants.forEach((status) => {
states.statuses.set(status.id, status);
if (status.inReplyToId === id) {
directReplies.push(status);
if (status.inReplyToAccountId === status.account.id) {
// If replying to self, it's part of the thread, level 1
nestedDescendants.push(status);
} else if (status.inReplyToId === heroStatus.id) {
// If replying to the hero status, it's a reply, level 1
nestedDescendants.push(status);
} else {
// If replying to someone else, it's a reply to a reply, level 2
const parent = descendants.find((s) => s.id === status.inReplyToId);
if (parent) {
if (!parent.__replies) {
parent.__replies = [];
}
parent.__replies.push(status);
} else {
// If no parent, it's probably a reply to a reply to a reply, level 3
console.warn('[LEVEL 3] No parent found for', status);
}
}
});
console.log({ ancestors, descendants, directReplies });
if (directReplies.length) {
const heroStatus = states.statuses.get(id);
const heroStatusRepliesCount = heroStatus.repliesCount;
if (heroStatusRepliesCount != directReplies.length) {
// If replies count doesn't match, refetch the status
const status = await masto.statuses.fetch(id);
states.statuses.set(id, status);
}
}
console.log({ ancestors, descendants, nestedDescendants });
const allStatuses = [
...ancestors.map((s) => ({ id: s.id, ancestor: true })),
{ id },
...descendants.map((s) => ({
...nestedDescendants.map((s) => ({
id: s.id,
descendant: true,
directReply:
s.inReplyToId === id || s.inReplyToAccountId === s.account.id,
// I can assume if the reply is to the same account, it's a direct reply. In other words, it's a thread?!?
thread: s.account.id === heroStatus.account.id,
replies: s.__replies?.map((r) => r.id),
})),
];
console.log({ allStatuses });
setStatuses(allStatuses);
} catch (e) {
console.error(e);
setUIState('error');
}
@ -103,7 +114,7 @@ function StatusPage({ id }) {
}
}, [statuses]);
const heroStatus = states.statuses.get(id);
const heroStatus = snapStates.statuses.get(id);
const heroDisplayName = useMemo(() => {
// Remove shortcodes from display name
if (!heroStatus) return '';
@ -136,14 +147,17 @@ function StatusPage({ id }) {
: 'Status',
);
const comments = statuses.filter((s) => s.descendant);
const replies = comments.filter((s) => s.directReply);
const prevRoute = states.history.findLast((h) => {
return h === '/' || /notifications/i.test(h);
});
const closeLink = `#${prevRoute || '/'}`;
const [limit, setLimit] = useState(40);
const showMore = useMemo(() => {
// return number of statuses to show
return statuses.length - limit;
}, [statuses.length, limit]);
return (
<div class="deck-backdrop">
<Link href={closeLink}></Link>
@ -162,8 +176,14 @@ function StatusPage({ id }) {
</div>
</header>
<ul class="timeline flat contextual">
{statuses.map((status) => {
const { id: statusID, ancestor, descendant, directReply } = status;
{statuses.slice(0, limit).map((status) => {
const {
id: statusID,
ancestor,
descendant,
thread,
replies,
} = status;
const isHero = statusID === id;
return (
<li
@ -171,7 +191,7 @@ function StatusPage({ id }) {
ref={isHero ? heroStatusRef : null}
class={`${ancestor ? 'ancestor' : ''} ${
descendant ? 'descendant' : ''
} ${descendant && !directReply ? 'indirect' : ''}`}
} ${thread ? 'thread' : ''}`}
>
{isHero ? (
<Status statusID={statusID} withinContext size="l" />
@ -182,37 +202,53 @@ function StatusPage({ id }) {
"
href={`#/s/${statusID}`}
>
<Status statusID={statusID} withinContext />
<Status
statusID={statusID}
withinContext
size={thread || ancestor ? 'm' : 's'}
/>
</Link>
)}
{descendant && replies?.length > 0 && (
<details class="replies">
<summary>
{replies.length} repl{replies.length === 1 ? 'y' : 'ies'}
</summary>
<ul>
{replies.map((replyID) => (
<li key={replyID}>
<Link class="status-link" href={`#/s/${replyID}`}>
<Status statusID={replyID} withinContext size="s" />
</Link>
</li>
))}
</ul>
</details>
)}
{uiState === 'loading' &&
isHero &&
!!heroStatus?.repliesCount &&
statuses.length === 1 && (
<div class="status-loading">
<Loader />
{/* {' '}<span>
{!!replies.length &&
replies.length !== comments.length && (
<>
{replies.length} repl
{replies.length > 1 ? 'ies' : 'y'}
</>
)}
{!!comments.length && (
<>
{' '}
&bull; {comments.length} comment
{comments.length > 1 ? 's' : ''}
</>
)}
</span> */}
</div>
)}
</li>
);
})}
</ul>
{showMore > 0 && (
<button
type="button"
class="plain block"
disabled={uiState === 'loading'}
onClick={() => setLimit((l) => l + 40)}
style={{ marginBlockEnd: '6em' }}
>
Show more&hellip;{' '}
<span class="tag">{showMore > 40 ? '40+' : showMore}</span>
</button>
)}
</div>
</div>
);