// 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 . 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 }