mirror of
https://github.com/superseriousbusiness/gotosocial
synced 2025-06-05 21:59:39 +02:00
This separates our the user mute handling from the typeconverter code, and creates a new "mutes" filter type (in a similar vein to the visibility filter) subpkg with its own result cache. This is a heavy mix of both chore given that mute calculation shouldn't have been handled in the conversion to frontend API types, and a performance bonus since we don't need to load and calculate so many things each time, just the single result each time with all necessary invalidation handled by database cache hooks. Reviewed-on: https://codeberg.org/superseriousbusiness/gotosocial/pulls/4202 Co-authored-by: kim <grufwub@gmail.com> Co-committed-by: kim <grufwub@gmail.com>
559 lines
16 KiB
Go
559 lines
16 KiB
Go
// 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"
|
|
"errors"
|
|
"slices"
|
|
"strings"
|
|
|
|
apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model"
|
|
statusfilter "code.superseriousbusiness.org/gotosocial/internal/filter/status"
|
|
"code.superseriousbusiness.org/gotosocial/internal/gtserror"
|
|
"code.superseriousbusiness.org/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 the full thread context.
|
|
threadContext, errWithCode := p.contextGet(ctx,
|
|
requester,
|
|
targetStatusID,
|
|
)
|
|
if errWithCode != nil {
|
|
return nil, errWithCode
|
|
}
|
|
|
|
var apiContext apimodel.ThreadContext
|
|
|
|
// Convert and filter the thread context ancestors.
|
|
apiContext.Ancestors = p.c.GetVisibleAPIStatuses(ctx,
|
|
requester,
|
|
threadContext.ancestors,
|
|
statusfilter.FilterContextThread,
|
|
filters,
|
|
)
|
|
|
|
// Convert and filter the thread context descendants
|
|
apiContext.Descendants = p.c.GetVisibleAPIStatuses(ctx,
|
|
requester,
|
|
threadContext.descendants,
|
|
statusfilter.FilterContextThread,
|
|
filters,
|
|
)
|
|
|
|
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
|
|
|
|
// Map of statuses that didn't pass visi
|
|
// checks and won't be shown via the web.
|
|
hiddenStatuses = make(map[string]struct{})
|
|
)
|
|
|
|
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.
|
|
//
|
|
// Include a check to see if the parent status
|
|
// is hidden; if so, we shouldn't show the child
|
|
// as it leads to weird-looking threading where
|
|
// a status seems to reply to nothing.
|
|
_, parentHidden := hiddenStatuses[status.InReplyToID]
|
|
v, err := p.visFilter.StatusVisible(ctx, nil, status)
|
|
if err != nil || !v || parentHidden {
|
|
// If this is the main status whose
|
|
// context we're looking for, and it's
|
|
// not visible for whatever reason, we
|
|
// should just return a 404 here, as we
|
|
// can't meaningfully render the thread.
|
|
if status.ID == targetStatusID {
|
|
var thisErr error
|
|
switch {
|
|
case err != nil:
|
|
thisErr = gtserror.Newf("error checking visibility of target status: %w", err)
|
|
|
|
case !v:
|
|
const errText = "target status not visible"
|
|
thisErr = gtserror.New(errText)
|
|
|
|
case parentHidden:
|
|
const errText = "target status parent is hidden"
|
|
thisErr = gtserror.New(errText)
|
|
}
|
|
|
|
return nil, gtserror.NewErrorNotFound(thisErr)
|
|
}
|
|
|
|
// This isn't the main status whose
|
|
// context we're looking for, just
|
|
// your standard not-visible status,
|
|
// so add it to the count + map.
|
|
if !inReplies {
|
|
// Main thread entry hidden.
|
|
wCtx.ThreadHidden++
|
|
} else {
|
|
// Reply hidden.
|
|
wCtx.ThreadRepliesHidden++
|
|
}
|
|
|
|
hiddenStatuses[status.ID] = struct{}{}
|
|
continue
|
|
}
|
|
|
|
// Prepare visible status to add to thread context.
|
|
webStatus, err := p.converter.StatusToWebStatus(ctx, status)
|
|
if err != nil {
|
|
hiddenStatuses[status.ID] = struct{}{}
|
|
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 "main" thread entries shown.
|
|
wCtx.ThreadShown = wCtx.ThreadLength - wCtx.ThreadHidden
|
|
|
|
// If there's no posts visible in the
|
|
// "main" thread we shouldn't show replies
|
|
// via the web as that's just weird.
|
|
if wCtx.ThreadShown < 1 {
|
|
const text = "no statuses visible in main thread"
|
|
return nil, gtserror.NewErrorNotFound(errors.New(text))
|
|
}
|
|
|
|
// 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 "replies" shown.
|
|
wCtx.ThreadRepliesShown = wCtx.ThreadReplies - wCtx.ThreadRepliesHidden
|
|
|
|
// Return the finished context.
|
|
return wCtx, nil
|
|
}
|