✨ Real nested comments
- Collapsed/expandable replies - Pagination for many many comments
This commit is contained in:
parent
9eb40d165f
commit
2f24713d71
3 changed files with 189 additions and 57 deletions
108
src/app.css
108
src/app.css
|
@ -146,7 +146,41 @@ a.mention span {
|
||||||
.timeline.contextual > li.descendant {
|
.timeline.contextual > li.descendant {
|
||||||
position: relative;
|
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;
|
--radius: 10px;
|
||||||
--diameter: calc(var(--radius) * 2);
|
--diameter: calc(var(--radius) * 2);
|
||||||
content: '';
|
content: '';
|
||||||
|
@ -161,9 +195,79 @@ a.mention span {
|
||||||
border-color: transparent transparent var(--comment-line-color) transparent;
|
border-color: transparent transparent var(--comment-line-color) transparent;
|
||||||
transform: rotate(45deg);
|
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;
|
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 {
|
.timeline-deck.compact .status {
|
||||||
max-height: max(25vh, 160px);
|
max-height: max(25vh, 160px);
|
||||||
|
|
|
@ -90,14 +90,6 @@
|
||||||
.status.skeleton > .avatar {
|
.status.skeleton > .avatar {
|
||||||
background-color: var(--outline-color);
|
background-color: var(--outline-color);
|
||||||
}
|
}
|
||||||
.indirect .status {
|
|
||||||
padding-left: 57px;
|
|
||||||
}
|
|
||||||
.indirect .status .avatar {
|
|
||||||
width: 25px !important;
|
|
||||||
height: 25px !important;
|
|
||||||
transform: translateX(5px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status .container {
|
.status .container {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
|
|
@ -28,15 +28,18 @@ function StatusPage({ id }) {
|
||||||
|
|
||||||
setUIState('loading');
|
setUIState('loading');
|
||||||
|
|
||||||
if (!states.statuses.has(id)) {
|
const hasStatus = snapStates.statuses.has(id);
|
||||||
try {
|
let heroStatus = snapStates.statuses.get(id);
|
||||||
const status = await masto.statuses.fetch(id);
|
try {
|
||||||
states.statuses.set(id, status);
|
heroStatus = await masto.statuses.fetch(id);
|
||||||
} catch (e) {
|
states.statuses.set(id, heroStatus);
|
||||||
|
} catch (e) {
|
||||||
|
// Silent fail if status is cached
|
||||||
|
if (!hasStatus) {
|
||||||
setUIState('error');
|
setUIState('error');
|
||||||
alert('Error fetching status');
|
alert('Error fetching status');
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
@ -46,38 +49,46 @@ function StatusPage({ id }) {
|
||||||
ancestors.forEach((status) => {
|
ancestors.forEach((status) => {
|
||||||
states.statuses.set(status.id, status);
|
states.statuses.set(status.id, status);
|
||||||
});
|
});
|
||||||
const directReplies = [];
|
const nestedDescendants = [];
|
||||||
descendants.forEach((status) => {
|
descendants.forEach((status) => {
|
||||||
states.statuses.set(status.id, status);
|
states.statuses.set(status.id, status);
|
||||||
if (status.inReplyToId === id) {
|
if (status.inReplyToAccountId === status.account.id) {
|
||||||
directReplies.push(status);
|
// 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) {
|
console.log({ ancestors, descendants, nestedDescendants });
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const allStatuses = [
|
const allStatuses = [
|
||||||
...ancestors.map((s) => ({ id: s.id, ancestor: true })),
|
...ancestors.map((s) => ({ id: s.id, ancestor: true })),
|
||||||
{ id },
|
{ id },
|
||||||
...descendants.map((s) => ({
|
...nestedDescendants.map((s) => ({
|
||||||
id: s.id,
|
id: s.id,
|
||||||
descendant: true,
|
descendant: true,
|
||||||
directReply:
|
thread: s.account.id === heroStatus.account.id,
|
||||||
s.inReplyToId === id || s.inReplyToAccountId === s.account.id,
|
replies: s.__replies?.map((r) => r.id),
|
||||||
// I can assume if the reply is to the same account, it's a direct reply. In other words, it's a thread?!?
|
|
||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
|
console.log({ allStatuses });
|
||||||
setStatuses(allStatuses);
|
setStatuses(allStatuses);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
setUIState('error');
|
setUIState('error');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -103,7 +114,7 @@ function StatusPage({ id }) {
|
||||||
}
|
}
|
||||||
}, [statuses]);
|
}, [statuses]);
|
||||||
|
|
||||||
const heroStatus = states.statuses.get(id);
|
const heroStatus = snapStates.statuses.get(id);
|
||||||
const heroDisplayName = useMemo(() => {
|
const heroDisplayName = useMemo(() => {
|
||||||
// Remove shortcodes from display name
|
// Remove shortcodes from display name
|
||||||
if (!heroStatus) return '';
|
if (!heroStatus) return '';
|
||||||
|
@ -136,14 +147,17 @@ function StatusPage({ id }) {
|
||||||
: 'Status',
|
: 'Status',
|
||||||
);
|
);
|
||||||
|
|
||||||
const comments = statuses.filter((s) => s.descendant);
|
|
||||||
const replies = comments.filter((s) => s.directReply);
|
|
||||||
|
|
||||||
const prevRoute = states.history.findLast((h) => {
|
const prevRoute = states.history.findLast((h) => {
|
||||||
return h === '/' || /notifications/i.test(h);
|
return h === '/' || /notifications/i.test(h);
|
||||||
});
|
});
|
||||||
const closeLink = `#${prevRoute || '/'}`;
|
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 (
|
return (
|
||||||
<div class="deck-backdrop">
|
<div class="deck-backdrop">
|
||||||
<Link href={closeLink}></Link>
|
<Link href={closeLink}></Link>
|
||||||
|
@ -162,8 +176,14 @@ function StatusPage({ id }) {
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<ul class="timeline flat contextual">
|
<ul class="timeline flat contextual">
|
||||||
{statuses.map((status) => {
|
{statuses.slice(0, limit).map((status) => {
|
||||||
const { id: statusID, ancestor, descendant, directReply } = status;
|
const {
|
||||||
|
id: statusID,
|
||||||
|
ancestor,
|
||||||
|
descendant,
|
||||||
|
thread,
|
||||||
|
replies,
|
||||||
|
} = status;
|
||||||
const isHero = statusID === id;
|
const isHero = statusID === id;
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
|
@ -171,7 +191,7 @@ function StatusPage({ id }) {
|
||||||
ref={isHero ? heroStatusRef : null}
|
ref={isHero ? heroStatusRef : null}
|
||||||
class={`${ancestor ? 'ancestor' : ''} ${
|
class={`${ancestor ? 'ancestor' : ''} ${
|
||||||
descendant ? 'descendant' : ''
|
descendant ? 'descendant' : ''
|
||||||
} ${descendant && !directReply ? 'indirect' : ''}`}
|
} ${thread ? 'thread' : ''}`}
|
||||||
>
|
>
|
||||||
{isHero ? (
|
{isHero ? (
|
||||||
<Status statusID={statusID} withinContext size="l" />
|
<Status statusID={statusID} withinContext size="l" />
|
||||||
|
@ -182,37 +202,53 @@ function StatusPage({ id }) {
|
||||||
"
|
"
|
||||||
href={`#/s/${statusID}`}
|
href={`#/s/${statusID}`}
|
||||||
>
|
>
|
||||||
<Status statusID={statusID} withinContext />
|
<Status
|
||||||
|
statusID={statusID}
|
||||||
|
withinContext
|
||||||
|
size={thread || ancestor ? 'm' : 's'}
|
||||||
|
/>
|
||||||
</Link>
|
</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' &&
|
{uiState === 'loading' &&
|
||||||
isHero &&
|
isHero &&
|
||||||
!!heroStatus?.repliesCount &&
|
!!heroStatus?.repliesCount &&
|
||||||
statuses.length === 1 && (
|
statuses.length === 1 && (
|
||||||
<div class="status-loading">
|
<div class="status-loading">
|
||||||
<Loader />
|
<Loader />
|
||||||
{/* {' '}<span>
|
|
||||||
{!!replies.length &&
|
|
||||||
replies.length !== comments.length && (
|
|
||||||
<>
|
|
||||||
{replies.length} repl
|
|
||||||
{replies.length > 1 ? 'ies' : 'y'}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{!!comments.length && (
|
|
||||||
<>
|
|
||||||
{' '}
|
|
||||||
• {comments.length} comment
|
|
||||||
{comments.length > 1 ? 's' : ''}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</span> */}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
|
{showMore > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="plain block"
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
onClick={() => setLimit((l) => l + 40)}
|
||||||
|
style={{ marginBlockEnd: '6em' }}
|
||||||
|
>
|
||||||
|
Show more…{' '}
|
||||||
|
<span class="tag">{showMore > 40 ? '40+' : showMore}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
Loading…
Add table
Reference in a new issue