mirror of
https://github.com/cheeaun/phanpy.git
synced 2025-02-16 07:11:15 +03:00
✨ 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 {
|
||||
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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 && (
|
||||
<>
|
||||
{' '}
|
||||
• {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…{' '}
|
||||
<span class="tag">{showMore > 40 ? '40+' : showMore}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
Loading…
Add table
Reference in a new issue