You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
554 lines
16 KiB
554 lines
16 KiB
// 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.visFilter.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.visFilter.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{ |
|
Statuses: make([]*apimodel.WebStatus, 0, len(wholeThread)), |
|
} |
|
|
|
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 |
|
|
|
// Whether we've reached end of "main" |
|
// thread and are now looking at replies. |
|
inReplies bool |
|
|
|
// Index in wholeThread |
|
// where replies begin. |
|
firstReplyIdx int |
|
|
|
// We should mark the next **VISIBLE** |
|
// reply as the first reply. |
|
markNextVisibleAsFirstReply bool |
|
) |
|
|
|
for idx, status := range wholeThread { |
|
if !inReplies { |
|
// Check if we've reached replies |
|
// by looking for the first status |
|
// that's not a self-reply, ie., |
|
// not a post in the "main" thread. |
|
switch { |
|
case idx == 0: |
|
// First post in wholeThread can't |
|
// be a self reply anyway because |
|
// it (very likely) doesn't reply |
|
// to anything, so ignore it. |
|
|
|
case !isSelfReply(status, contextAcctID): |
|
// This is not a self-reply, which |
|
// means it's a reply from another |
|
// account. So, replies start here. |
|
inReplies = true |
|
firstReplyIdx = idx |
|
markNextVisibleAsFirstReply = true |
|
} |
|
} |
|
|
|
// Ensure status is actually |
|
// visible to just anyone, and |
|
// hide / don't include it if not. |
|
v, err := p.visFilter.StatusVisible(ctx, nil, status) |
|
if err != nil || !v { |
|
if !inReplies { |
|
// Main thread entry hidden. |
|
wCtx.ThreadHidden++ |
|
} else { |
|
// Reply hidden. |
|
wCtx.ThreadRepliesHidden++ |
|
} |
|
continue |
|
} |
|
|
|
// Prepare visible status to add to thread context. |
|
webStatus, err := p.converter.StatusToWebStatus(ctx, status) |
|
if err != nil { |
|
continue |
|
} |
|
|
|
if markNextVisibleAsFirstReply { |
|
// This is the first visible |
|
// "reply / comment", so the |
|
// little "x amount of replies" |
|
// header should go above this. |
|
webStatus.ThreadFirstReply = true |
|
markNextVisibleAsFirstReply = 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. |
|
webStatus.Indent = 0 |
|
|
|
case isSelfReply(status, status.AccountID): |
|
// Self reply, so indent at same |
|
// level as own replied-to status. |
|
webStatus.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. |
|
webStatus.Indent = parentIndent |
|
|
|
default: |
|
// Reply to someone else who's |
|
// indented, but not to TO THE MAX. |
|
// Indent by another one. |
|
webStatus.Indent = parentIndent + 1 |
|
} |
|
|
|
// Store the indent for this status. |
|
statusIndents[status.ID] = webStatus.Indent |
|
} |
|
|
|
if webStatus.ID == targetStatusID { |
|
// This is the og |
|
// thread context status. |
|
webStatus.ThreadContextStatus = true |
|
wCtx.Status = webStatus |
|
} |
|
|
|
wCtx.Statuses = append(wCtx.Statuses, webStatus) |
|
} |
|
|
|
// Now we've gone through the whole |
|
// thread, we can add some additional info. |
|
|
|
// Length of the "main" thread. If there are |
|
// visible replies then it's up to where the |
|
// replies start, else 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 |
|
|
|
// Mark the last "main" visible status. |
|
wCtx.Statuses[wCtx.ThreadShown-1].ThreadLastMain = true |
|
|
|
// 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 |
|
}
|
|
|