[feature/frontend] Better visual separation between "main" thread and "replies" (#3093)

* [feature/frontend] Better web threading model

* fix test

* bwap

* tweaks

* more tweaks to wording

* typo

* indenting

* adjust wording

* aaa
This commit is contained in:
tobi 2024-07-12 20:36:03 +02:00 committed by GitHub
parent cde2fb6244
commit aeb65bceae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 895 additions and 385 deletions

View file

@ -2516,24 +2516,6 @@ definitions:
type: object
x-go-name: Status
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
statusContext:
properties:
ancestors:
description: Parents in the thread.
items:
$ref: '#/definitions/status'
type: array
x-go-name: Ancestors
descendants:
description: Children in the thread.
items:
$ref: '#/definitions/status'
type: array
x-go-name: Descendants
title: Context models the tree around a given status.
type: object
x-go-name: Context
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
statusEdit:
description: |-
StatusEdit represents one historical revision of a status, containing
@ -2887,6 +2869,26 @@ definitions:
type: object
x-go-name: Theme
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
threadContext:
description: |-
ThreadContext models the tree or
"thread" around a given status.
properties:
ancestors:
description: Parents in the thread.
items:
$ref: '#/definitions/status'
type: array
x-go-name: Ancestors
descendants:
description: Children in the thread.
items:
$ref: '#/definitions/status'
type: array
x-go-name: Descendants
type: object
x-go-name: ThreadContext
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
user:
properties:
admin:
@ -8149,7 +8151,7 @@ paths:
/api/v1/statuses/{id}/context:
get:
description: The returned statuses will be ordered in a thread structure, so they are suitable to be displayed in the order in which they were returned.
operationId: statusContext
operationId: threadContext
parameters:
- description: Target status ID.
in: path
@ -8160,9 +8162,9 @@ paths:
- application/json
responses:
"200":
description: Status context object.
description: Thread context object.
schema:
$ref: '#/definitions/statusContext'
$ref: '#/definitions/threadContext'
"400":
description: bad request
"401":

View file

@ -27,7 +27,7 @@
"github.com/superseriousbusiness/gotosocial/internal/oauth"
)
// StatusContextGETHandler swagger:operation GET /api/v1/statuses/{id}/context statusContext
// StatusContextGETHandler swagger:operation GET /api/v1/statuses/{id}/context threadContext
//
// Return ancestors and descendants of the given status.
//
@ -55,9 +55,9 @@
// responses:
// '200':
// name: statuses
// description: Status context object.
// description: Thread context object.
// schema:
// "$ref": "#/definitions/statusContext"
// "$ref": "#/definitions/threadContext"
// '400':
// description: bad request
// '401':
@ -89,11 +89,11 @@ func (m *Module) StatusContextGETHandler(c *gin.Context) {
return
}
statusContext, errWithCode := m.processor.Status().ContextGet(c.Request.Context(), authed.Account, targetStatusID)
threadContext, errWithCode := m.processor.Status().ContextGet(c.Request.Context(), authed.Account, targetStatusID)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
c.JSON(http.StatusOK, statusContext)
c.JSON(http.StatusOK, threadContext)
}

View file

@ -102,28 +102,34 @@ type Status struct {
Text string `json:"text,omitempty"`
// A list of filters that matched this status and why they matched, if there are any such filters.
Filtered []FilterResult `json:"filtered,omitempty"`
}
// Additional fields not exposed via JSON
// (used only internally for templating etc).
// WebStatus is like *model.Status, but contains
// additional fields used only for HTML templating.
//
// swagger:ignore
type WebStatus struct {
*Status
// Template-ready language tag + string, based
// on *status.Language. Nil for non-web statuses.
//
// swagger:ignore
LanguageTag *language.Language `json:"-"`
// Template-ready language tag and
// string, based on *status.Language.
LanguageTag *language.Language
// Template-ready poll options with vote shares
// calculated as a percentage of total votes.
// Nil for non-web statuses.
//
// swagger:ignore
WebPollOptions []WebPollOption `json:"-"`
PollOptions []WebPollOption
// Status is from a local account.
// Always false for non-web statuses.
//
// swagger:ignore
Local bool `json:"-"`
Local bool
// Level of indentation at which to
// display this status in the web view.
Indent int
// This status is the first status after
// the "main" thread, so it and everything
// below it can be considered "replies".
ThreadFirstReply bool
}
/*

View file

@ -17,12 +17,48 @@
package model
// Context models the tree around a given status.
// ThreadContext models the tree or
// "thread" around a given status.
//
// swagger:model statusContext
type Context struct {
// swagger:model threadContext
type ThreadContext struct {
// Parents in the thread.
Ancestors []Status `json:"ancestors"`
// Children in the thread.
Descendants []Status `json:"descendants"`
}
type WebThreadContext struct {
// Parents in the thread.
Ancestors []*WebStatus `json:"ancestors"`
// Children in the thread.
Descendants []*WebStatus `json:"descendants"`
// The status around which the ancestors
// + descendants context was constructed.
Status *WebStatus `json:"-"`
// Total length of
// the main thread.
ThreadLength int
// Number of entries in
// the main thread shown.
ThreadShown int
// Number of statuses hidden
// from the main thread (not
// visible to requester etc).
ThreadHidden int
// Total number of replies
// in the replies section.
ThreadReplies int
// Number of replies shown.
ThreadRepliesShown int
// Number of replies hidden.
ThreadRepliesHidden int
}

View file

@ -105,7 +105,7 @@ func (og *OGMeta) WithAccount(account *apimodel.Account) *OGMeta {
// WithStatus uses the given status to build an ogMeta
// struct specific to that status. It's suitable for serving
// at status pages.
func (og *OGMeta) WithStatus(status *apimodel.Status) *OGMeta {
func (og *OGMeta) WithStatus(status *apimodel.WebStatus) *OGMeta {
og.Title = "Post by " + AccountTitle(status.Account, og.SiteName)
og.Type = "article"
if status.Language != nil {

View file

@ -179,7 +179,7 @@ func (p *Processor) WebStatusesGet(
for _, s := range statuses {
// Convert fetched statuses to web view statuses.
item, err := p.converter.StatusToWebStatus(ctx, s, nil)
item, err := p.converter.StatusToWebStatus(ctx, s)
if err != nil {
log.Errorf(ctx, "error convering to web status: %v", err)
continue
@ -198,13 +198,13 @@ func (p *Processor) WebStatusesGet(
func (p *Processor) WebStatusesGetPinned(
ctx context.Context,
targetAccountID string,
) ([]*apimodel.Status, gtserror.WithCode) {
) ([]*apimodel.WebStatus, gtserror.WithCode) {
statuses, err := p.state.DB.GetAccountPinnedStatuses(ctx, targetAccountID)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
return nil, gtserror.NewErrorInternalError(err)
}
webStatuses := make([]*apimodel.Status, 0, len(statuses))
webStatuses := make([]*apimodel.WebStatus, 0, len(statuses))
for _, status := range statuses {
if status.Visibility != gtsmodel.VisibilityPublic {
// Skip non-public
@ -212,7 +212,7 @@ func (p *Processor) WebStatusesGetPinned(
continue
}
webStatus, err := p.converter.StatusToWebStatus(ctx, status, nil)
webStatus, err := p.converter.StatusToWebStatus(ctx, status)
if err != nil {
log.Errorf(ctx, "error convering to web status: %v", err)
continue

View file

@ -0,0 +1,566 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package status
import (
"context"
"slices"
"strings"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
"github.com/superseriousbusiness/gotosocial/internal/filter/usermute"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
// internalThreadContext is like
// *apimodel.ThreadContext, but
// for internal use only.
type internalThreadContext struct {
targetStatus *gtsmodel.Status
ancestors []*gtsmodel.Status
descendants []*gtsmodel.Status
}
func (p *Processor) contextGet(
ctx context.Context,
requester *gtsmodel.Account,
targetStatusID string,
) (*internalThreadContext, gtserror.WithCode) {
targetStatus, errWithCode := p.c.GetVisibleTargetStatus(ctx,
requester,
targetStatusID,
nil, // default freshness
)
if errWithCode != nil {
return nil, errWithCode
}
// Don't generate thread for boosts/reblogs.
if targetStatus.BoostOfID != "" {
err := gtserror.New("target status is a boost wrapper / reblog")
return nil, gtserror.NewErrorNotFound(err)
}
// Fetch up to the top of the thread.
ancestors, err := p.state.DB.GetStatusParents(ctx, targetStatus)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
// Do a simple ID sort of ancestors
// to arrange them by creation time.
slices.SortFunc(ancestors, func(lhs, rhs *gtsmodel.Status) int {
return strings.Compare(lhs.ID, rhs.ID)
})
// Fetch down to the bottom of the thread.
descendants, err := p.state.DB.GetStatusChildren(ctx, targetStatus.ID)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
// Topographically sort descendants,
// to place them in sub-threads.
TopoSort(descendants, targetStatus.AccountID)
return &internalThreadContext{
targetStatus: targetStatus,
ancestors: ancestors,
descendants: descendants,
}, nil
}
// Returns true if status counts as a self-reply
// *within the current context*, ie., status is a
// self-reply by contextAcctID to contextAcctID.
func isSelfReply(
status *gtsmodel.Status,
contextAcctID string,
) bool {
if status.AccountID != contextAcctID {
// Doesn't belong
// to context acct.
return false
}
return status.InReplyToAccountID == contextAcctID
}
// TopoSort sorts the given slice of *descendant*
// statuses topologically, by self-reply, and by ID.
//
// "contextAcctID" should be the ID of the account that owns
// the status the thread context is being constructed around.
//
// Can handle cycles but the output order will be arbitrary.
// (But if there are cycles, something went wrong upstream.)
func TopoSort(
statuses []*gtsmodel.Status,
contextAcctID string,
) {
if len(statuses) == 0 {
return
}
// Simple map of status IDs to statuses.
//
// Eg.,
//
// 01J2BC6DQ37A6SQPAVCZ2BYSTN: *gtsmodel.Status
// 01J2BC8GT9THMPWMCAZYX48PXJ: *gtsmodel.Status
// 01J2BC8M56C5ZAH76KN93D7F0W: *gtsmodel.Status
// 01J2BC90QNW65SM2F89R5M0NGE: *gtsmodel.Status
// 01J2BC916YVX6D6Q0SA30JV82D: *gtsmodel.Status
// 01J2BC91J2Y75D4Z3EEDF3DYAV: *gtsmodel.Status
// 01J2BC91VBVPBZACZMDA7NEZY9: *gtsmodel.Status
// 01J2BCMM3CXQE70S831YPWT48T: *gtsmodel.Status
lookup := make(map[string]*gtsmodel.Status, len(statuses))
for _, status := range statuses {
lookup[status.ID] = status
}
// Tree of statuses to their children.
//
// The nil status may have children: any who don't
// have a parent, or whose parent isn't in the input.
//
// Eg.,
//
// *gtsmodel.Status (01J2BC916YVX6D6Q0SA30JV82D): [ <- parent2 (1 child)
// *gtsmodel.Status (01J2BC91J2Y75D4Z3EEDF3DYAV) <- p2 child1
// ],
// *gtsmodel.Status (01J2BC6DQ37A6SQPAVCZ2BYSTN): [ <- parent1 (3 children)
// *gtsmodel.Status (01J2BC8M56C5ZAH76KN93D7F0W) <- p1 child3 |
// *gtsmodel.Status (01J2BC90QNW65SM2F89R5M0NGE) <- p1 child1 |- Not sorted
// *gtsmodel.Status (01J2BC8GT9THMPWMCAZYX48PXJ) <- p1 child2 |
// ],
// *gtsmodel.Status (01J2BC91VBVPBZACZMDA7NEZY9): [ <- parent3 (no children 😢)
// ]
// *gtsmodel.Status (nil): [ <- parent4 (nil status)
// *gtsmodel.Status (01J2BCMM3CXQE70S831YPWT48T) <- p4 child1 (no parent 😢)
// ]
tree := make(map[*gtsmodel.Status][]*gtsmodel.Status, len(statuses))
for _, status := range statuses {
var parent *gtsmodel.Status
if status.InReplyToID != "" {
// May be nil if reply is missing.
parent = lookup[status.InReplyToID]
}
tree[parent] = append(tree[parent], status)
}
// Sort children of each parent by self-reply status and then ID, *in reverse*.
// This results in the tree looking something like:
//
// *gtsmodel.Status (01J2BC916YVX6D6Q0SA30JV82D): [ <- parent2 (1 child)
// *gtsmodel.Status (01J2BC91J2Y75D4Z3EEDF3DYAV) <- p2 child1
// ],
// *gtsmodel.Status (01J2BC6DQ37A6SQPAVCZ2BYSTN): [ <- parent1 (3 children)
// *gtsmodel.Status (01J2BC90QNW65SM2F89R5M0NGE) <- p1 child1 |
// *gtsmodel.Status (01J2BC8GT9THMPWMCAZYX48PXJ) <- p1 child2 |- Sorted
// *gtsmodel.Status (01J2BC8M56C5ZAH76KN93D7F0W) <- p1 child3 |
// ],
// *gtsmodel.Status (01J2BC91VBVPBZACZMDA7NEZY9): [ <- parent3 (no children 😢)
// ],
// *gtsmodel.Status (nil): [ <- parent4 (nil status)
// *gtsmodel.Status (01J2BCMM3CXQE70S831YPWT48T) <- p4 child1 (no parent 😢)
// ]
for id, children := range tree {
slices.SortFunc(children, func(lhs, rhs *gtsmodel.Status) int {
lhsIsSelfReply := isSelfReply(lhs, contextAcctID)
rhsIsSelfReply := isSelfReply(rhs, contextAcctID)
if lhsIsSelfReply && !rhsIsSelfReply {
// lhs is the end
// of a sub-thread.
return 1
} else if !lhsIsSelfReply && rhsIsSelfReply {
// lhs is the start
// of a sub-thread.
return -1
}
// Sort by created-at descending.
return -strings.Compare(lhs.ID, rhs.ID)
})
tree[id] = children
}
// Traverse the tree using preorder depth-first
// search, topologically sorting the statuses
// until the stack is empty.
//
// The stack starts with one nil status in it
// to account for potential nil key in the tree,
// which means the below "for" loop will always
// iterate at least once.
//
// The result will look something like:
//
// *gtsmodel.Status (01J2BC6DQ37A6SQPAVCZ2BYSTN) <- parent1 (3 children)
// *gtsmodel.Status (01J2BC90QNW65SM2F89R5M0NGE) <- p1 child1 |
// *gtsmodel.Status (01J2BC8GT9THMPWMCAZYX48PXJ) <- p1 child2 |- Sorted
// *gtsmodel.Status (01J2BC8M56C5ZAH76KN93D7F0W) <- p1 child3 |
// *gtsmodel.Status (01J2BC916YVX6D6Q0SA30JV82D) <- parent2 (1 child)
// *gtsmodel.Status (01J2BC91J2Y75D4Z3EEDF3DYAV) <- p2 child1
// *gtsmodel.Status (01J2BC91VBVPBZACZMDA7NEZY9) <- parent3 (no children 😢)
// *gtsmodel.Status (01J2BCMM3CXQE70S831YPWT48T) <- p4 child1 (no parent 😢)
stack := make([]*gtsmodel.Status, 1, len(tree))
statusIndex := 0
for len(stack) > 0 {
parent := stack[len(stack)-1]
children := tree[parent]
if len(children) == 0 {
// No (more) children so we're
// done with this node.
// Remove it from the tree.
delete(tree, parent)
// Also remove this node from
// the stack, then continue
// from its parent.
stack = stack[:len(stack)-1]
continue
}
// Pop the last child entry
// (the first in sorted order).
child := children[len(children)-1]
tree[parent] = children[:len(children)-1]
// Explore its children next.
stack = append(stack, child)
// Overwrite the next entry of the input slice.
statuses[statusIndex] = child
statusIndex++
}
// There should only be orphan nodes remaining
// (or other nodes in the event of a cycle).
// Append them to the end in arbitrary order.
//
// The fact we put them in a map first just
// ensures the slice of statuses has no duplicates.
for orphan := range tree {
statuses[statusIndex] = orphan
statusIndex++
}
}
// ContextGet returns the context (previous
// and following posts) from the given status ID.
func (p *Processor) ContextGet(
ctx context.Context,
requester *gtsmodel.Account,
targetStatusID string,
) (*apimodel.ThreadContext, gtserror.WithCode) {
// Retrieve filters as they affect
// what should be shown to requester.
filters, err := p.state.DB.GetFiltersForAccountID(
ctx, // Populate filters.
requester.ID,
)
if err != nil {
err = gtserror.Newf(
"couldn't retrieve filters for account %s: %w",
requester.ID, err,
)
return nil, gtserror.NewErrorInternalError(err)
}
// Retrieve mutes as they affect
// what should be shown to requester.
mutes, err := p.state.DB.GetAccountMutes(
// No need to populate mutes,
// IDs are enough here.
gtscontext.SetBarebones(ctx),
requester.ID,
nil, // No paging - get all.
)
if err != nil {
err = gtserror.Newf(
"couldn't retrieve mutes for account %s: %w",
requester.ID, err,
)
return nil, gtserror.NewErrorInternalError(err)
}
convert := func(
ctx context.Context,
status *gtsmodel.Status,
requestingAccount *gtsmodel.Account,
) (*apimodel.Status, error) {
return p.converter.StatusToAPIStatus(
ctx,
status,
requestingAccount,
statusfilter.FilterContextThread,
filters,
usermute.NewCompiledUserMuteList(mutes),
)
}
// Retrieve the thread context.
threadContext, errWithCode := p.contextGet(
ctx,
requester,
targetStatusID,
)
if errWithCode != nil {
return nil, errWithCode
}
apiContext := &apimodel.ThreadContext{
Ancestors: make([]apimodel.Status, 0, len(threadContext.ancestors)),
Descendants: make([]apimodel.Status, 0, len(threadContext.descendants)),
}
// Convert ancestors + filter
// out ones that aren't visible.
for _, status := range threadContext.ancestors {
if v, err := p.filter.StatusVisible(ctx, requester, status); err == nil && v {
status, err := convert(ctx, status, requester)
if err == nil {
apiContext.Ancestors = append(apiContext.Ancestors, *status)
}
}
}
// Convert descendants + filter
// out ones that aren't visible.
for _, status := range threadContext.descendants {
if v, err := p.filter.StatusVisible(ctx, requester, status); err == nil && v {
status, err := convert(ctx, status, requester)
if err == nil {
apiContext.Descendants = append(apiContext.Descendants, *status)
}
}
}
return apiContext, nil
}
// WebContextGet is like ContextGet, but is explicitly
// for viewing statuses via the unauthenticated web UI.
//
// The returned statuses in the ThreadContext will be
// populated with ThreadMeta annotations for more easily
// positioning the status in a web view of a thread.
func (p *Processor) WebContextGet(
ctx context.Context,
targetStatusID string,
) (*apimodel.WebThreadContext, gtserror.WithCode) {
// Retrieve the internal thread context.
iCtx, errWithCode := p.contextGet(
ctx,
nil, // No authed requester.
targetStatusID,
)
if errWithCode != nil {
return nil, errWithCode
}
// Recreate the whole thread so we can go
// through it again add ThreadMeta annotations
// from the perspective of the OG status.
// nolint:gocritic
wholeThread := append(
// Ancestors at the beginning.
iCtx.ancestors,
append(
// Target status in the middle.
[]*gtsmodel.Status{iCtx.targetStatus},
// Descendants at the end.
iCtx.descendants...,
)...,
)
// Start preparing web context.
wCtx := &apimodel.WebThreadContext{
Ancestors: make([]*apimodel.WebStatus, 0, len(iCtx.ancestors)),
Descendants: make([]*apimodel.WebStatus, 0, len(iCtx.descendants)),
}
var (
threadLength = len(wholeThread)
// Track how much each reply status
// should be indented (if at all).
statusIndents = make(map[string]int, threadLength)
// Who the current thread "belongs" to,
// ie., who created first post in the thread.
contextAcctID = wholeThread[0].AccountID
// Position of target status in wholeThread,
// we put it on top of ancestors.
targetStatusIdx = len(iCtx.ancestors)
// Position from which we should add
// to descendants and not to ancestors.
descendantsIdx = targetStatusIdx + 1
// Whether we've reached end of "main"
// thread and are now looking at replies.
inReplies bool
// Index in wholeThread where
// the "main" thread ends.
firstReplyIdx int
// We should mark the next **VISIBLE**
// reply as the first reply.
markNextVisibleAsReply bool
)
for idx, status := range wholeThread {
if !inReplies {
// Haven't reached end
// of "main" thread yet.
//
// First post in wholeThread can't
// be a self reply, so ignore it.
//
// That aside, first non-self-reply
// in wholeThread means the "main"
// thread is now over.
if idx != 0 && !isSelfReply(status, contextAcctID) {
// Jot some stuff down.
firstReplyIdx = idx
inReplies = true
markNextVisibleAsReply = true
}
}
// Ensure status is actually
// visible to just anyone.
v, err := p.filter.StatusVisible(ctx, nil, status)
if err != nil || !v {
// Skip this one.
if !inReplies {
wCtx.ThreadHidden++
} else {
wCtx.ThreadRepliesHidden++
}
continue
}
// Prepare status to add to thread context.
apiStatus, err := p.converter.StatusToWebStatus(ctx, status)
if err != nil {
continue
}
if markNextVisibleAsReply {
// This is the first visible
// "reply / comment", so the
// little "x amount of replies"
// header should go above this.
apiStatus.ThreadFirstReply = true
markNextVisibleAsReply = false
}
// If this is a reply, work out the indent of
// this status based on its parent's indent.
if inReplies {
parentIndent, ok := statusIndents[status.InReplyToID]
switch {
case !ok:
// No parent with
// indent, start at 0.
apiStatus.Indent = 0
case isSelfReply(status, status.AccountID):
// Self reply, so indent at same
// level as own replied-to status.
apiStatus.Indent = parentIndent
case parentIndent == 5:
// Already indented as far as we
// can go to keep things readable
// on thin screens, so just keep
// parent's indent.
apiStatus.Indent = parentIndent
default:
// Reply to someone else who's
// indented, but not to TO THE MAX.
// Indent by another one.
apiStatus.Indent = parentIndent + 1
}
// Store the indent for this status.
statusIndents[status.ID] = apiStatus.Indent
}
switch {
case idx == targetStatusIdx:
// This is the target status itself.
wCtx.Status = apiStatus
case idx < descendantsIdx:
// Haven't reached descendants yet,
// so this must be an ancestor.
wCtx.Ancestors = append(
wCtx.Ancestors,
apiStatus,
)
default:
// We're in descendants town now.
wCtx.Descendants = append(
wCtx.Descendants,
apiStatus,
)
}
}
// Now we've gone through the whole
// thread, we can add some additional info.
// Length of the "main" thread. If there are
// replies then it's up to where the replies
// start, otherwise it's the whole thing.
if inReplies {
wCtx.ThreadLength = firstReplyIdx
} else {
wCtx.ThreadLength = threadLength
}
// Jot down number of hidden posts so template doesn't have to do it.
wCtx.ThreadShown = wCtx.ThreadLength - wCtx.ThreadHidden
// Number of replies is equal to number
// of statuses in the thread that aren't
// part of the "main" thread.
wCtx.ThreadReplies = threadLength - wCtx.ThreadLength
// Jot down number of hidden replies so template doesn't have to do it.
wCtx.ThreadRepliesShown = wCtx.ThreadReplies - wCtx.ThreadRepliesHidden
// Return the finished context.
return wCtx, nil
}

View file

@ -18,17 +18,18 @@
package status_test
import (
"github.com/stretchr/testify/suite"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/processing/status"
"testing"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/processing/status"
)
type topoSortTestSuite struct {
suite.Suite
}
func statusIDs(apiStatuses []*apimodel.Status) []string {
func statusIDs(apiStatuses []*gtsmodel.Status) []string {
ids := make([]string, 0, len(apiStatuses))
for _, apiStatus := range apiStatuses {
ids = append(ids, apiStatus.ID)
@ -38,18 +39,18 @@ func statusIDs(apiStatuses []*apimodel.Status) []string {
func (suite *topoSortTestSuite) TestBranched() {
// https://commons.wikimedia.org/wiki/File:Sorted_binary_tree_ALL_RGB.svg
f := &apimodel.Status{ID: "F"}
b := &apimodel.Status{ID: "B", InReplyToID: &f.ID}
a := &apimodel.Status{ID: "A", InReplyToID: &b.ID}
d := &apimodel.Status{ID: "D", InReplyToID: &b.ID}
c := &apimodel.Status{ID: "C", InReplyToID: &d.ID}
e := &apimodel.Status{ID: "E", InReplyToID: &d.ID}
g := &apimodel.Status{ID: "G", InReplyToID: &f.ID}
i := &apimodel.Status{ID: "I", InReplyToID: &g.ID}
h := &apimodel.Status{ID: "H", InReplyToID: &i.ID}
f := &gtsmodel.Status{ID: "F"}
b := &gtsmodel.Status{ID: "B", InReplyToID: f.ID}
a := &gtsmodel.Status{ID: "A", InReplyToID: b.ID}
d := &gtsmodel.Status{ID: "D", InReplyToID: b.ID}
c := &gtsmodel.Status{ID: "C", InReplyToID: d.ID}
e := &gtsmodel.Status{ID: "E", InReplyToID: d.ID}
g := &gtsmodel.Status{ID: "G", InReplyToID: f.ID}
i := &gtsmodel.Status{ID: "I", InReplyToID: g.ID}
h := &gtsmodel.Status{ID: "H", InReplyToID: i.ID}
expected := statusIDs([]*apimodel.Status{f, b, a, d, c, e, g, i, h})
list := []*apimodel.Status{a, b, c, d, e, f, g, h, i}
expected := statusIDs([]*gtsmodel.Status{f, b, a, d, c, e, g, i, h})
list := []*gtsmodel.Status{a, b, c, d, e, f, g, h, i}
status.TopoSort(list, "")
actual := statusIDs(list)
@ -57,64 +58,72 @@ func (suite *topoSortTestSuite) TestBranched() {
}
func (suite *topoSortTestSuite) TestBranchedWithSelfReplyChain() {
targetAccount := &apimodel.Account{ID: "1"}
otherAccount := &apimodel.Account{ID: "2"}
targetAccount := &gtsmodel.Account{ID: "1"}
otherAccount := &gtsmodel.Account{ID: "2"}
f := &apimodel.Status{
f := &gtsmodel.Status{
ID: "F",
Account: targetAccount,
}
b := &apimodel.Status{
b := &gtsmodel.Status{
ID: "B",
Account: targetAccount,
InReplyToID: &f.ID,
InReplyToAccountID: &f.Account.ID,
AccountID: targetAccount.ID,
InReplyToID: f.ID,
InReplyToAccountID: f.Account.ID,
}
a := &apimodel.Status{
ID: "A",
Account: otherAccount,
InReplyToID: &b.ID,
InReplyToAccountID: &b.Account.ID,
}
d := &apimodel.Status{
d := &gtsmodel.Status{
ID: "D",
Account: targetAccount,
InReplyToID: &b.ID,
InReplyToAccountID: &b.Account.ID,
AccountID: targetAccount.ID,
InReplyToID: b.ID,
InReplyToAccountID: b.Account.ID,
}
c := &apimodel.Status{
ID: "C",
Account: otherAccount,
InReplyToID: &d.ID,
InReplyToAccountID: &d.Account.ID,
}
e := &apimodel.Status{
e := &gtsmodel.Status{
ID: "E",
Account: targetAccount,
InReplyToID: &d.ID,
InReplyToAccountID: &d.Account.ID,
AccountID: targetAccount.ID,
InReplyToID: d.ID,
InReplyToAccountID: d.Account.ID,
}
g := &apimodel.Status{
c := &gtsmodel.Status{
ID: "C",
Account: otherAccount,
AccountID: otherAccount.ID,
InReplyToID: d.ID,
InReplyToAccountID: d.Account.ID,
}
a := &gtsmodel.Status{
ID: "A",
Account: otherAccount,
AccountID: otherAccount.ID,
InReplyToID: b.ID,
InReplyToAccountID: b.Account.ID,
}
g := &gtsmodel.Status{
ID: "G",
Account: otherAccount,
InReplyToID: &f.ID,
InReplyToAccountID: &f.Account.ID,
AccountID: otherAccount.ID,
InReplyToID: f.ID,
InReplyToAccountID: f.Account.ID,
}
i := &apimodel.Status{
i := &gtsmodel.Status{
ID: "I",
Account: targetAccount,
InReplyToID: &g.ID,
InReplyToAccountID: &g.Account.ID,
AccountID: targetAccount.ID,
InReplyToID: g.ID,
InReplyToAccountID: g.Account.ID,
}
h := &apimodel.Status{
h := &gtsmodel.Status{
ID: "H",
Account: otherAccount,
InReplyToID: &i.ID,
InReplyToAccountID: &i.Account.ID,
AccountID: otherAccount.ID,
InReplyToID: i.ID,
InReplyToAccountID: i.Account.ID,
}
expected := statusIDs([]*apimodel.Status{f, b, d, e, c, a, g, i, h})
list := []*apimodel.Status{a, b, c, d, e, f, g, h, i}
expected := statusIDs([]*gtsmodel.Status{f, b, d, e, c, a, g, i, h})
list := []*gtsmodel.Status{a, b, c, d, e, f, g, h, i}
status.TopoSort(list, targetAccount.ID)
actual := statusIDs(list)
@ -122,13 +131,13 @@ func (suite *topoSortTestSuite) TestBranchedWithSelfReplyChain() {
}
func (suite *topoSortTestSuite) TestDisconnected() {
f := &apimodel.Status{ID: "F"}
b := &apimodel.Status{ID: "B", InReplyToID: &f.ID}
f := &gtsmodel.Status{ID: "F"}
b := &gtsmodel.Status{ID: "B", InReplyToID: f.ID}
dID := "D"
e := &apimodel.Status{ID: "E", InReplyToID: &dID}
e := &gtsmodel.Status{ID: "E", InReplyToID: dID}
expected := statusIDs([]*apimodel.Status{e, f, b})
list := []*apimodel.Status{b, e, f}
expected := statusIDs([]*gtsmodel.Status{e, f, b})
list := []*gtsmodel.Status{b, e, f}
status.TopoSort(list, "")
actual := statusIDs(list)
@ -137,10 +146,10 @@ func (suite *topoSortTestSuite) TestDisconnected() {
func (suite *topoSortTestSuite) TestTrivialCycle() {
xID := "X"
x := &apimodel.Status{ID: xID, InReplyToID: &xID}
x := &gtsmodel.Status{ID: xID, InReplyToID: xID}
expected := statusIDs([]*apimodel.Status{x})
list := []*apimodel.Status{x}
expected := statusIDs([]*gtsmodel.Status{x})
list := []*gtsmodel.Status{x}
status.TopoSort(list, "")
actual := statusIDs(list)
@ -149,11 +158,11 @@ func (suite *topoSortTestSuite) TestTrivialCycle() {
func (suite *topoSortTestSuite) TestCycle() {
yID := "Y"
x := &apimodel.Status{ID: "X", InReplyToID: &yID}
y := &apimodel.Status{ID: yID, InReplyToID: &x.ID}
x := &gtsmodel.Status{ID: "X", InReplyToID: yID}
y := &gtsmodel.Status{ID: yID, InReplyToID: x.ID}
expected := statusIDs([]*apimodel.Status{x, y})
list := []*apimodel.Status{x, y}
expected := statusIDs([]*gtsmodel.Status{x, y})
list := []*gtsmodel.Status{x, y}
status.TopoSort(list, "")
actual := statusIDs(list)
@ -162,12 +171,12 @@ func (suite *topoSortTestSuite) TestCycle() {
func (suite *topoSortTestSuite) TestMixedCycle() {
yID := "Y"
x := &apimodel.Status{ID: "X", InReplyToID: &yID}
y := &apimodel.Status{ID: yID, InReplyToID: &x.ID}
z := &apimodel.Status{ID: "Z"}
x := &gtsmodel.Status{ID: "X", InReplyToID: yID}
y := &gtsmodel.Status{ID: yID, InReplyToID: x.ID}
z := &gtsmodel.Status{ID: "Z"}
expected := statusIDs([]*apimodel.Status{x, y, z})
list := []*apimodel.Status{x, y, z}
expected := statusIDs([]*gtsmodel.Status{x, y, z})
list := []*gtsmodel.Status{x, y, z}
status.TopoSort(list, "")
actual := statusIDs(list)
@ -175,8 +184,8 @@ func (suite *topoSortTestSuite) TestMixedCycle() {
}
func (suite *topoSortTestSuite) TestEmpty() {
expected := statusIDs([]*apimodel.Status{})
list := []*apimodel.Status{}
expected := statusIDs([]*gtsmodel.Status{})
list := []*gtsmodel.Status{}
status.TopoSort(list, "")
actual := statusIDs(list)
@ -185,7 +194,7 @@ func (suite *topoSortTestSuite) TestEmpty() {
func (suite *topoSortTestSuite) TestNil() {
expected := statusIDs(nil)
var list []*apimodel.Status
var list []*gtsmodel.Status
status.TopoSort(list, "")
actual := statusIDs(list)

View file

@ -19,13 +19,8 @@
import (
"context"
"slices"
"strings"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
"github.com/superseriousbusiness/gotosocial/internal/filter/usermute"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/util"
@ -113,199 +108,3 @@ func (p *Processor) SourceGet(ctx context.Context, requestingAccount *gtsmodel.A
}
return statusSource, nil
}
// WebGet gets the given status for web use, taking account of privacy settings.
func (p *Processor) WebGet(ctx context.Context, targetStatusID string) (*apimodel.Status, gtserror.WithCode) {
targetStatus, errWithCode := p.c.GetVisibleTargetStatus(ctx,
nil, // requester
targetStatusID,
nil, // default freshness
)
if errWithCode != nil {
return nil, errWithCode
}
webStatus, err := p.converter.StatusToWebStatus(ctx, targetStatus, nil)
if err != nil {
err = gtserror.Newf("error converting status: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
return webStatus, nil
}
func (p *Processor) contextGet(
ctx context.Context,
requestingAccount *gtsmodel.Account,
targetStatusID string,
convert func(context.Context, *gtsmodel.Status, *gtsmodel.Account) (*apimodel.Status, error),
) (*apimodel.Context, gtserror.WithCode) {
targetStatus, errWithCode := p.c.GetVisibleTargetStatus(ctx,
requestingAccount,
targetStatusID,
nil, // default freshness
)
if errWithCode != nil {
return nil, errWithCode
}
parents, err := p.state.DB.GetStatusParents(ctx, targetStatus)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
var ancestors []*apimodel.Status
for _, status := range parents {
if v, err := p.filter.StatusVisible(ctx, requestingAccount, status); err == nil && v {
apiStatus, err := convert(ctx, status, requestingAccount)
if err == nil {
ancestors = append(ancestors, apiStatus)
}
}
}
slices.SortFunc(ancestors, func(lhs, rhs *apimodel.Status) int {
return strings.Compare(lhs.ID, rhs.ID)
})
children, err := p.state.DB.GetStatusChildren(ctx, targetStatus.ID)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
var descendants []*apimodel.Status
for _, status := range children {
if v, err := p.filter.StatusVisible(ctx, requestingAccount, status); err == nil && v {
apiStatus, err := convert(ctx, status, requestingAccount)
if err == nil {
descendants = append(descendants, apiStatus)
}
}
}
TopoSort(descendants, targetStatus.AccountID)
context := &apimodel.Context{
Ancestors: make([]apimodel.Status, 0, len(ancestors)),
Descendants: make([]apimodel.Status, 0, len(descendants)),
}
for _, ancestor := range ancestors {
context.Ancestors = append(context.Ancestors, *ancestor)
}
for _, descendant := range descendants {
context.Descendants = append(context.Descendants, *descendant)
}
return context, nil
}
// TopoSort sorts statuses topologically, by self-reply, and by ID.
// Can handle cycles but the output order will be arbitrary.
// (But if there are cycles, something went wrong upstream.)
func TopoSort(apiStatuses []*apimodel.Status, targetAccountID string) {
if len(apiStatuses) == 0 {
return
}
// Map of status IDs to statuses.
lookup := make(map[string]*apimodel.Status, len(apiStatuses))
for _, apiStatus := range apiStatuses {
lookup[apiStatus.ID] = apiStatus
}
// Tree of statuses to their children.
// The nil status may have children: any who don't have a parent, or whose parent isn't in the input.
tree := make(map[*apimodel.Status][]*apimodel.Status, len(apiStatuses))
for _, apiStatus := range apiStatuses {
var parent *apimodel.Status
if apiStatus.InReplyToID != nil {
parent = lookup[*apiStatus.InReplyToID]
}
tree[parent] = append(tree[parent], apiStatus)
}
// Sort children of each status by self-reply status and then ID, *in reverse*.
isSelfReply := func(apiStatus *apimodel.Status) bool {
return apiStatus.GetAccountID() == targetAccountID &&
apiStatus.InReplyToAccountID != nil &&
*apiStatus.InReplyToAccountID == targetAccountID
}
for id, children := range tree {
slices.SortFunc(children, func(lhs, rhs *apimodel.Status) int {
lhsIsContextSelfReply := isSelfReply(lhs)
rhsIsContextSelfReply := isSelfReply(rhs)
if lhsIsContextSelfReply && !rhsIsContextSelfReply {
return 1
} else if !lhsIsContextSelfReply && rhsIsContextSelfReply {
return -1
}
return -strings.Compare(lhs.ID, rhs.ID)
})
tree[id] = children
}
// Traverse the tree using preorder depth-first search, topologically sorting the statuses.
stack := make([]*apimodel.Status, 1, len(tree))
apiStatusIndex := 0
for len(stack) > 0 {
parent := stack[len(stack)-1]
children := tree[parent]
if len(children) == 0 {
// Remove this node from the tree.
delete(tree, parent)
// Go back to this node's parent.
stack = stack[:len(stack)-1]
continue
}
// Remove the last child entry (the first in sorted order).
child := children[len(children)-1]
tree[parent] = children[:len(children)-1]
// Explore its children next.
stack = append(stack, child)
// Overwrite the next entry of the input slice.
apiStatuses[apiStatusIndex] = child
apiStatusIndex++
}
// There should only be nodes left in the tree in the event of a cycle.
// Append them to the end in arbitrary order.
// This ensures that the slice of statuses has no duplicates.
for node := range tree {
apiStatuses[apiStatusIndex] = node
apiStatusIndex++
}
}
// ContextGet returns the context (previous and following posts) from the given status ID.
func (p *Processor) ContextGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*apimodel.Context, gtserror.WithCode) {
filters, err := p.state.DB.GetFiltersForAccountID(ctx, requestingAccount.ID)
if err != nil {
err = gtserror.Newf("couldn't retrieve filters for account %s: %w", requestingAccount.ID, err)
return nil, gtserror.NewErrorInternalError(err)
}
mutes, err := p.state.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), requestingAccount.ID, nil)
if err != nil {
err = gtserror.Newf("couldn't retrieve mutes for account %s: %w", requestingAccount.ID, err)
return nil, gtserror.NewErrorInternalError(err)
}
compiledMutes := usermute.NewCompiledUserMuteList(mutes)
convert := func(ctx context.Context, status *gtsmodel.Status, requestingAccount *gtsmodel.Account) (*apimodel.Status, error) {
return p.converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextThread, filters, compiledMutes)
}
return p.contextGet(ctx, requestingAccount, targetStatusID, convert)
}
// WebContextGet is like ContextGet, but is explicitly
// for viewing statuses via the unauthenticated web UI.
//
// TODO: a more advanced threading model could be implemented here.
func (p *Processor) WebContextGet(ctx context.Context, targetStatusID string) (*apimodel.Context, gtserror.WithCode) {
return p.contextGet(ctx, nil, targetStatusID, p.converter.StatusToWebStatus)
}

View file

@ -982,13 +982,23 @@ func filterAppliesInContext(filter *gtsmodel.Filter, filterContext statusfilter.
func (c *Converter) StatusToWebStatus(
ctx context.Context,
s *gtsmodel.Status,
requestingAccount *gtsmodel.Account,
) (*apimodel.Status, error) {
webStatus, err := c.statusToFrontend(ctx, s, requestingAccount, statusfilter.FilterContextNone, nil, nil)
) (*apimodel.WebStatus, error) {
apiStatus, err := c.statusToFrontend(
ctx,
s,
nil, // No authed requester.
statusfilter.FilterContextNone,
nil, // No filters.
nil, // No mutes.
)
if err != nil {
return nil, err
}
webStatus := &apimodel.WebStatus{
Status: apiStatus,
}
// Whack a newline before and after each "pre" to make it easier to outdent it.
webStatus.Content = strings.ReplaceAll(webStatus.Content, "<pre>", "\n<pre>")
webStatus.Content = strings.ReplaceAll(webStatus.Content, "</pre>", "</pre>\n")
@ -1014,7 +1024,7 @@ func (c *Converter) StatusToWebStatus(
// format them for easier template consumption.
totalVotes := poll.VotesCount
webPollOptions := make([]apimodel.WebPollOption, len(poll.Options))
PollOptions := make([]apimodel.WebPollOption, len(poll.Options))
for i, option := range poll.Options {
var voteShare float32
@ -1046,10 +1056,10 @@ func (c *Converter) StatusToWebStatus(
VoteShare: voteShare,
VoteShareStr: voteShareStr,
}
webPollOptions[i] = webPollOption
PollOptions[i] = webPollOption
}
webStatus.WebPollOptions = webPollOptions
webStatus.PollOptions = PollOptions
}
// Set additional templating
@ -1058,6 +1068,7 @@ func (c *Converter) StatusToWebStatus(
a.Sensitive = webStatus.Sensitive
}
// Mark this as a local status.
webStatus.Local = *s.Local
return webStatus, nil

View file

@ -883,9 +883,8 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendUnknownAttachments
func (suite *InternalToFrontendTestSuite) TestStatusToWebStatus() {
testStatus := suite.testStatuses["remote_account_2_status_1"]
requestingAccount := suite.testAccounts["admin_account"]
apiStatus, err := suite.typeconverter.StatusToWebStatus(context.Background(), testStatus, requestingAccount)
apiStatus, err := suite.typeconverter.StatusToWebStatus(context.Background(), testStatus)
suite.NoError(err)
// MediaAttachments should inherit
@ -1010,7 +1009,12 @@ func (suite *InternalToFrontendTestSuite) TestStatusToWebStatus() {
"tags": [],
"emojis": [],
"card": null,
"poll": null
"poll": null,
"LanguageTag": "en",
"PollOptions": null,
"Local": false,
"Indent": 0,
"ThreadFirstReply": false
}`, string(b))
}

View file

@ -111,7 +111,7 @@ func (m *Module) profileGETHandler(c *gin.Context) {
var (
maxStatusID = apiutil.ParseMaxID(c.Query(apiutil.MaxIDKey), "")
paging = maxStatusID != ""
pinnedStatuses []*apimodel.Status
pinnedStatuses []*apimodel.WebStatus
)
if !paging {

View file

@ -20,7 +20,6 @@
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
@ -101,34 +100,20 @@ func (m *Module) threadGETHandler(c *gin.Context) {
return
}
// Get the status itself from the processor using provided ID and authorization (if any).
status, errWithCode := m.processor.Status().WebGet(ctx, targetStatusID)
// Get the thread context. This will fetch the target status as well.
context, errWithCode := m.processor.Status().WebContextGet(ctx, targetStatusID)
if errWithCode != nil {
apiutil.WebErrorHandler(c, errWithCode, instanceGet)
return
}
// Ensure status actually belongs to target account.
if status.GetAccountID() != targetAccount.ID {
if context.Status.GetAccountID() != targetAccount.ID {
err := fmt.Errorf("target account %s does not own status %s", targetUsername, targetStatusID)
apiutil.WebErrorHandler(c, gtserror.NewErrorNotFound(err), instanceGet)
return
}
// Don't render boosts/reblogs as top-level statuses.
if status.Reblog != nil {
err := errors.New("status is a boost wrapper / reblog")
apiutil.WebErrorHandler(c, gtserror.NewErrorNotFound(err), instanceGet)
return
}
// Fill in the rest of the thread context.
context, errWithCode := m.processor.Status().WebContextGet(ctx, targetStatusID)
if errWithCode != nil {
apiutil.WebErrorHandler(c, errWithCode, instanceGet)
return
}
// Prepare stylesheets for thread.
stylesheets := make([]string, 0, 5)
@ -159,11 +144,10 @@ func (m *Module) threadGETHandler(c *gin.Context) {
page := apiutil.WebPage{
Template: "thread.tmpl",
Instance: instance,
OGMeta: apiutil.OGBase(instance).WithStatus(status),
OGMeta: apiutil.OGBase(instance).WithStatus(context.Status),
Stylesheets: stylesheets,
Javascript: []string{jsFrontend},
Extra: map[string]any{
"status": status,
"context": context,
},
}

View file

@ -17,11 +17,14 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
.thread {
.thread,
.thread-wrapper {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.thread {
/*
This column header might contain
quite some info, so let it wrap.
@ -42,8 +45,40 @@
}
.status {
border-radius: 0;
&.indent-1 {
margin-left: 0.5rem;
}
&.indent-2 {
margin-left: 1rem;
}
&.indent-3 {
margin-left: 1.5rem;
}
&.indent-4 {
margin-left: 2rem;
}
&.indent-5 {
margin-left: 2.5rem;
}
&.indent-1,
&.indent-2,
&.indent-3,
&.indent-4,
&.indent-5 {
.status-link {
margin-left: -0.5rem;
border-left: 0.1rem dashed $border-accent;
}
}
border-radius: 0;
&:last-child {
border-bottom-left-radius: $br;
border-bottom-right-radius: $br;

View file

@ -58,7 +58,7 @@
</span>
</figcaption>
<ul class="poll-options nodot">
{{- range $index, $pollOption := .WebPollOptions }}
{{- range $index, $pollOption := .PollOptions }}
<li class="poll-option">
<span class="sr-only">Option {{ increment $index }},</span>
<span lang="{{- .LanguageTag.TagStr -}}">{{ emojify .Emojis (noescape $pollOption.Title) }}</span>

View file

@ -17,45 +17,103 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ -}}
{{- define "threadLength" -}}
{{- with $length := add (len $.context.Ancestors) (len $.context.Descendants) | increment -}}
{{- if eq $length 1 -}}
{{- $length }} post
{{- define "repliesSummary" -}}
{{- if .context.ThreadRepliesShown -}}
{{- if .context.ThreadRepliesHidden -}}
{{- if eq .context.ThreadReplies 1 -}}
{{- /* Some replies are hidden. */ -}}
{{ .context.ThreadRepliesShown }} visible reply
{{- else if gt .context.ThreadRepliesShown 1 -}}
{{ .context.ThreadRepliesShown }} visible replies
{{- end -}}
; {{ .context.ThreadRepliesHidden }} more {{ if eq .context.ThreadRepliesHidden 1 }}reply{{ else }}replies{{ end }} hidden or not public
{{- else -}}
{{- $length }} posts
{{- /* No hidden replies. */ -}}
{{- if eq .context.ThreadReplies 1 -}}
{{ .context.ThreadReplies }} reply
{{- else if gt .context.ThreadReplies 1 -}}
{{ .context.ThreadReplies }} replies
{{- end -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- define "threadSummary" -}}
{{- if .context.ThreadHidden -}}
{{- if eq .context.ThreadShown 1 -}}
Single visible post
{{- else if gt .context.ThreadShown 1 -}}
Thread of {{ .context.ThreadShown }} visible posts
{{- end -}}
; {{ .context.ThreadHidden }} more {{ if eq .context.ThreadHidden 1 }}post{{ else }}posts{{ end }} hidden or not public
{{- else -}}
{{- /* No hidden posts */ -}}
{{- if eq .context.ThreadLength 1 -}}
Single post
{{- else if gt .context.ThreadLength 1 -}}
Thread of {{ .context.ThreadLength }} posts
{{- end -}}
{{- end -}}
{{- end -}}
{{- define "repliesStart" -}}
{{- with . }}
<main data-nosnippet class="thread" aria-labelledby="thread-summary">
<div class="col-header">
<h2 id="thread-summary">Thread with {{ template "threadLength" . -}}</h2>
<a href="#{{- .status.ID -}}">jump to expanded post</a>
</section>
<section class="thread thread-replies" aria-labelledby="replies" open>
<div class="col-header replies">
<h2 id="replies">{{- template "repliesSummary" . -}}</h2>
<a href="#thread-summary">back to top</a>
</div>
{{- range .context.Ancestors }}
{{- end }}
{{- end -}}
{{- with . }}
<main class="thread-wrapper" data-nosnippet>
<section class="thread thread-main" aria-labelledby="thread-summary">
<div class="col-header">
<h2 id="thread-summary">{{- template "threadSummary" . -}}</h2>
{{- if .context.ThreadRepliesShown }}
<a href="#replies">jump to replies</a>
{{- end }}
</div>
{{- range $thisStatus := .context.Ancestors }}
{{- if $thisStatus.ThreadFirstReply }}
{{- include "repliesStart" $ | indent 1 }}
{{- end }}
<article
class="status"
{{- includeAttr "status_attributes.tmpl" . | indentAttr 2 }}
class="status{{- if $thisStatus.Indent }} indent-{{ $thisStatus.Indent }}{{- end -}}"
{{- includeAttr "status_attributes.tmpl" $thisStatus | indentAttr 3 }}
>
{{- include "status.tmpl" . | indent 2 }}
{{- include "status.tmpl" $thisStatus | indent 3 }}
</article>
{{- end }}
{{- with .status }}
{{- with $thisStatus := .context.Status }}
{{- if $thisStatus.ThreadFirstReply }}
{{- include "repliesStart" $ | indent 1 }}
{{- end }}
<article
class="status expanded"
{{- includeAttr "status_attributes.tmpl" . | indentAttr 2 }}
class="status expanded{{- if $thisStatus.Indent }} indent-{{ $thisStatus.Indent }}{{- end -}}"
{{- includeAttr "status_attributes.tmpl" $thisStatus | indentAttr 3 }}
>
{{- include "status.tmpl" . | indent 2 }}
{{- include "status.tmpl" $thisStatus | indent 3 }}
</article>
{{- end }}
{{- range .context.Descendants }}
{{- range $thisStatus := .context.Descendants }}
{{- if $thisStatus.ThreadFirstReply }}
{{- include "repliesStart" $ | indent 1 }}
{{- end }}
<article
class="status"
{{- includeAttr "status_attributes.tmpl" . | indentAttr 2 }}
class="status{{- if $thisStatus.Indent }} indent-{{ $thisStatus.Indent }}{{- end -}}"
{{- includeAttr "status_attributes.tmpl" $thisStatus | indentAttr 3 }}
>
{{- include "status.tmpl" . | indent 2 }}
{{- include "status.tmpl" $thisStatus | indent 3 }}
</article>
{{- end }}
{{- if .context.ThreadReplies }}
</section>
{{- end }}
</main>
{{- end }}