summaryrefslogtreecommitdiff
path: root/internal/processing/status/context.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/processing/status/context.go')
-rw-r--r--internal/processing/status/context.go566
1 files changed, 566 insertions, 0 deletions
diff --git a/internal/processing/status/context.go b/internal/processing/status/context.go
new file mode 100644
index 000000000..4271bd233
--- /dev/null
+++ b/internal/processing/status/context.go
@@ -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
+}