Browse Source
* [feature/frontend] Better web threading model * fix test * bwap * tweaks * more tweaks to wording * typo * indenting * adjust wording * aaapull/3096/head
16 changed files with 894 additions and 384 deletions
@ -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 |
||||
} |
||||
Loading…
Reference in new issue