From aeb65bceae97611b8931de2e954df18afedd812f Mon Sep 17 00:00:00 2001 From: tobi <31960611+tsmethurst@users.noreply.github.com> Date: Fri, 12 Jul 2024 20:36:03 +0200 Subject: [PATCH] [feature/frontend] Better visual separation between "main" thread and "replies" (#3093) * [feature/frontend] Better web threading model * fix test * bwap * tweaks * more tweaks to wording * typo * indenting * adjust wording * aaa --- docs/api/swagger.yaml | 44 +- internal/api/client/statuses/statuscontext.go | 10 +- internal/api/model/status.go | 36 +- .../model/{context.go => statuscontext.go} | 42 +- internal/api/util/opengraph.go | 2 +- internal/processing/account/statuses.go | 8 +- internal/processing/status/context.go | 566 ++++++++++++++++++ .../status/{get_test.go => context_test.go} | 149 ++--- internal/processing/status/get.go | 201 ------- internal/typeutils/internaltofrontend.go | 23 +- internal/typeutils/internaltofrontend_test.go | 10 +- internal/web/profile.go | 2 +- internal/web/thread.go | 24 +- web/source/css/thread.css | 39 +- web/template/status_poll.tmpl | 2 +- web/template/thread.tmpl | 122 +++- 16 files changed, 895 insertions(+), 385 deletions(-) rename internal/api/model/{context.go => statuscontext.go} (52%) create mode 100644 internal/processing/status/context.go rename internal/processing/status/{get_test.go => context_test.go} (52%) diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml index f7ce844af..b91b4f4b0 100644 --- a/docs/api/swagger.yaml +++ b/docs/api/swagger.yaml @@ -2516,24 +2516,6 @@ definitions: type: object x-go-name: Status x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model - statusContext: - properties: - ancestors: - description: Parents in the thread. - items: - $ref: '#/definitions/status' - type: array - x-go-name: Ancestors - descendants: - description: Children in the thread. - items: - $ref: '#/definitions/status' - type: array - x-go-name: Descendants - title: Context models the tree around a given status. - type: object - x-go-name: Context - x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model statusEdit: description: |- StatusEdit represents one historical revision of a status, containing @@ -2887,6 +2869,26 @@ definitions: type: object x-go-name: Theme x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model + threadContext: + description: |- + ThreadContext models the tree or + "thread" around a given status. + properties: + ancestors: + description: Parents in the thread. + items: + $ref: '#/definitions/status' + type: array + x-go-name: Ancestors + descendants: + description: Children in the thread. + items: + $ref: '#/definitions/status' + type: array + x-go-name: Descendants + type: object + x-go-name: ThreadContext + x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model user: properties: admin: @@ -8149,7 +8151,7 @@ paths: /api/v1/statuses/{id}/context: get: description: The returned statuses will be ordered in a thread structure, so they are suitable to be displayed in the order in which they were returned. - operationId: statusContext + operationId: threadContext parameters: - description: Target status ID. in: path @@ -8160,9 +8162,9 @@ paths: - application/json responses: "200": - description: Status context object. + description: Thread context object. schema: - $ref: '#/definitions/statusContext' + $ref: '#/definitions/threadContext' "400": description: bad request "401": diff --git a/internal/api/client/statuses/statuscontext.go b/internal/api/client/statuses/statuscontext.go index 6441eb738..0eea50819 100644 --- a/internal/api/client/statuses/statuscontext.go +++ b/internal/api/client/statuses/statuscontext.go @@ -27,7 +27,7 @@ "github.com/superseriousbusiness/gotosocial/internal/oauth" ) -// StatusContextGETHandler swagger:operation GET /api/v1/statuses/{id}/context statusContext +// StatusContextGETHandler swagger:operation GET /api/v1/statuses/{id}/context threadContext // // Return ancestors and descendants of the given status. // @@ -55,9 +55,9 @@ // responses: // '200': // name: statuses -// description: Status context object. +// description: Thread context object. // schema: -// "$ref": "#/definitions/statusContext" +// "$ref": "#/definitions/threadContext" // '400': // description: bad request // '401': @@ -89,11 +89,11 @@ func (m *Module) StatusContextGETHandler(c *gin.Context) { return } - statusContext, errWithCode := m.processor.Status().ContextGet(c.Request.Context(), authed.Account, targetStatusID) + threadContext, errWithCode := m.processor.Status().ContextGet(c.Request.Context(), authed.Account, targetStatusID) if errWithCode != nil { apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) return } - c.JSON(http.StatusOK, statusContext) + c.JSON(http.StatusOK, threadContext) } diff --git a/internal/api/model/status.go b/internal/api/model/status.go index 0d925d211..00be868f1 100644 --- a/internal/api/model/status.go +++ b/internal/api/model/status.go @@ -102,28 +102,34 @@ type Status struct { Text string `json:"text,omitempty"` // A list of filters that matched this status and why they matched, if there are any such filters. Filtered []FilterResult `json:"filtered,omitempty"` +} - // Additional fields not exposed via JSON - // (used only internally for templating etc). +// WebStatus is like *model.Status, but contains +// additional fields used only for HTML templating. +// +// swagger:ignore +type WebStatus struct { + *Status - // Template-ready language tag + string, based - // on *status.Language. Nil for non-web statuses. - // - // swagger:ignore - LanguageTag *language.Language `json:"-"` + // Template-ready language tag and + // string, based on *status.Language. + LanguageTag *language.Language // Template-ready poll options with vote shares // calculated as a percentage of total votes. - // Nil for non-web statuses. - // - // swagger:ignore - WebPollOptions []WebPollOption `json:"-"` + PollOptions []WebPollOption // Status is from a local account. - // Always false for non-web statuses. - // - // swagger:ignore - Local bool `json:"-"` + Local bool + + // Level of indentation at which to + // display this status in the web view. + Indent int + + // This status is the first status after + // the "main" thread, so it and everything + // below it can be considered "replies". + ThreadFirstReply bool } /* diff --git a/internal/api/model/context.go b/internal/api/model/statuscontext.go similarity index 52% rename from internal/api/model/context.go rename to internal/api/model/statuscontext.go index 69bbc6345..205672dc8 100644 --- a/internal/api/model/context.go +++ b/internal/api/model/statuscontext.go @@ -17,12 +17,48 @@ package model -// Context models the tree around a given status. +// ThreadContext models the tree or +// "thread" around a given status. // -// swagger:model statusContext -type Context struct { +// swagger:model threadContext +type ThreadContext struct { // Parents in the thread. Ancestors []Status `json:"ancestors"` // Children in the thread. Descendants []Status `json:"descendants"` } + +type WebThreadContext struct { + // Parents in the thread. + Ancestors []*WebStatus `json:"ancestors"` + + // Children in the thread. + Descendants []*WebStatus `json:"descendants"` + + // The status around which the ancestors + // + descendants context was constructed. + Status *WebStatus `json:"-"` + + // Total length of + // the main thread. + ThreadLength int + + // Number of entries in + // the main thread shown. + ThreadShown int + + // Number of statuses hidden + // from the main thread (not + // visible to requester etc). + ThreadHidden int + + // Total number of replies + // in the replies section. + ThreadReplies int + + // Number of replies shown. + ThreadRepliesShown int + + // Number of replies hidden. + ThreadRepliesHidden int +} diff --git a/internal/api/util/opengraph.go b/internal/api/util/opengraph.go index 185dc8132..062151836 100644 --- a/internal/api/util/opengraph.go +++ b/internal/api/util/opengraph.go @@ -105,7 +105,7 @@ func (og *OGMeta) WithAccount(account *apimodel.Account) *OGMeta { // WithStatus uses the given status to build an ogMeta // struct specific to that status. It's suitable for serving // at status pages. -func (og *OGMeta) WithStatus(status *apimodel.Status) *OGMeta { +func (og *OGMeta) WithStatus(status *apimodel.WebStatus) *OGMeta { og.Title = "Post by " + AccountTitle(status.Account, og.SiteName) og.Type = "article" if status.Language != nil { diff --git a/internal/processing/account/statuses.go b/internal/processing/account/statuses.go index 2513f17c7..593c30e27 100644 --- a/internal/processing/account/statuses.go +++ b/internal/processing/account/statuses.go @@ -179,7 +179,7 @@ func (p *Processor) WebStatusesGet( for _, s := range statuses { // Convert fetched statuses to web view statuses. - item, err := p.converter.StatusToWebStatus(ctx, s, nil) + item, err := p.converter.StatusToWebStatus(ctx, s) if err != nil { log.Errorf(ctx, "error convering to web status: %v", err) continue @@ -198,13 +198,13 @@ func (p *Processor) WebStatusesGet( func (p *Processor) WebStatusesGetPinned( ctx context.Context, targetAccountID string, -) ([]*apimodel.Status, gtserror.WithCode) { +) ([]*apimodel.WebStatus, gtserror.WithCode) { statuses, err := p.state.DB.GetAccountPinnedStatuses(ctx, targetAccountID) if err != nil && !errors.Is(err, db.ErrNoEntries) { return nil, gtserror.NewErrorInternalError(err) } - webStatuses := make([]*apimodel.Status, 0, len(statuses)) + webStatuses := make([]*apimodel.WebStatus, 0, len(statuses)) for _, status := range statuses { if status.Visibility != gtsmodel.VisibilityPublic { // Skip non-public @@ -212,7 +212,7 @@ func (p *Processor) WebStatusesGetPinned( continue } - webStatus, err := p.converter.StatusToWebStatus(ctx, status, nil) + webStatus, err := p.converter.StatusToWebStatus(ctx, status) if err != nil { log.Errorf(ctx, "error convering to web status: %v", err) continue 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 . + +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 +} diff --git a/internal/processing/status/get_test.go b/internal/processing/status/context_test.go similarity index 52% rename from internal/processing/status/get_test.go rename to internal/processing/status/context_test.go index 80482f1f2..aba58e776 100644 --- a/internal/processing/status/get_test.go +++ b/internal/processing/status/context_test.go @@ -18,17 +18,18 @@ package status_test import ( - "github.com/stretchr/testify/suite" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/processing/status" "testing" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/processing/status" ) type topoSortTestSuite struct { suite.Suite } -func statusIDs(apiStatuses []*apimodel.Status) []string { +func statusIDs(apiStatuses []*gtsmodel.Status) []string { ids := make([]string, 0, len(apiStatuses)) for _, apiStatus := range apiStatuses { ids = append(ids, apiStatus.ID) @@ -38,18 +39,18 @@ func statusIDs(apiStatuses []*apimodel.Status) []string { func (suite *topoSortTestSuite) TestBranched() { // https://commons.wikimedia.org/wiki/File:Sorted_binary_tree_ALL_RGB.svg - f := &apimodel.Status{ID: "F"} - b := &apimodel.Status{ID: "B", InReplyToID: &f.ID} - a := &apimodel.Status{ID: "A", InReplyToID: &b.ID} - d := &apimodel.Status{ID: "D", InReplyToID: &b.ID} - c := &apimodel.Status{ID: "C", InReplyToID: &d.ID} - e := &apimodel.Status{ID: "E", InReplyToID: &d.ID} - g := &apimodel.Status{ID: "G", InReplyToID: &f.ID} - i := &apimodel.Status{ID: "I", InReplyToID: &g.ID} - h := &apimodel.Status{ID: "H", InReplyToID: &i.ID} + f := >smodel.Status{ID: "F"} + b := >smodel.Status{ID: "B", InReplyToID: f.ID} + a := >smodel.Status{ID: "A", InReplyToID: b.ID} + d := >smodel.Status{ID: "D", InReplyToID: b.ID} + c := >smodel.Status{ID: "C", InReplyToID: d.ID} + e := >smodel.Status{ID: "E", InReplyToID: d.ID} + g := >smodel.Status{ID: "G", InReplyToID: f.ID} + i := >smodel.Status{ID: "I", InReplyToID: g.ID} + h := >smodel.Status{ID: "H", InReplyToID: i.ID} - expected := statusIDs([]*apimodel.Status{f, b, a, d, c, e, g, i, h}) - list := []*apimodel.Status{a, b, c, d, e, f, g, h, i} + expected := statusIDs([]*gtsmodel.Status{f, b, a, d, c, e, g, i, h}) + list := []*gtsmodel.Status{a, b, c, d, e, f, g, h, i} status.TopoSort(list, "") actual := statusIDs(list) @@ -57,64 +58,72 @@ func (suite *topoSortTestSuite) TestBranched() { } func (suite *topoSortTestSuite) TestBranchedWithSelfReplyChain() { - targetAccount := &apimodel.Account{ID: "1"} - otherAccount := &apimodel.Account{ID: "2"} + targetAccount := >smodel.Account{ID: "1"} + otherAccount := >smodel.Account{ID: "2"} - f := &apimodel.Status{ + f := >smodel.Status{ ID: "F", Account: targetAccount, } - b := &apimodel.Status{ + b := >smodel.Status{ ID: "B", Account: targetAccount, - InReplyToID: &f.ID, - InReplyToAccountID: &f.Account.ID, + AccountID: targetAccount.ID, + InReplyToID: f.ID, + InReplyToAccountID: f.Account.ID, } - a := &apimodel.Status{ - ID: "A", - Account: otherAccount, - InReplyToID: &b.ID, - InReplyToAccountID: &b.Account.ID, - } - d := &apimodel.Status{ + d := >smodel.Status{ ID: "D", Account: targetAccount, - InReplyToID: &b.ID, - InReplyToAccountID: &b.Account.ID, + AccountID: targetAccount.ID, + InReplyToID: b.ID, + InReplyToAccountID: b.Account.ID, } - c := &apimodel.Status{ - ID: "C", - Account: otherAccount, - InReplyToID: &d.ID, - InReplyToAccountID: &d.Account.ID, - } - e := &apimodel.Status{ + e := >smodel.Status{ ID: "E", Account: targetAccount, - InReplyToID: &d.ID, - InReplyToAccountID: &d.Account.ID, + AccountID: targetAccount.ID, + InReplyToID: d.ID, + InReplyToAccountID: d.Account.ID, } - g := &apimodel.Status{ + c := >smodel.Status{ + ID: "C", + Account: otherAccount, + AccountID: otherAccount.ID, + InReplyToID: d.ID, + InReplyToAccountID: d.Account.ID, + } + a := >smodel.Status{ + ID: "A", + Account: otherAccount, + AccountID: otherAccount.ID, + InReplyToID: b.ID, + InReplyToAccountID: b.Account.ID, + } + g := >smodel.Status{ ID: "G", Account: otherAccount, - InReplyToID: &f.ID, - InReplyToAccountID: &f.Account.ID, + AccountID: otherAccount.ID, + InReplyToID: f.ID, + InReplyToAccountID: f.Account.ID, } - i := &apimodel.Status{ + i := >smodel.Status{ ID: "I", Account: targetAccount, - InReplyToID: &g.ID, - InReplyToAccountID: &g.Account.ID, + AccountID: targetAccount.ID, + InReplyToID: g.ID, + InReplyToAccountID: g.Account.ID, } - h := &apimodel.Status{ + h := >smodel.Status{ ID: "H", Account: otherAccount, - InReplyToID: &i.ID, - InReplyToAccountID: &i.Account.ID, + AccountID: otherAccount.ID, + InReplyToID: i.ID, + InReplyToAccountID: i.Account.ID, } - expected := statusIDs([]*apimodel.Status{f, b, d, e, c, a, g, i, h}) - list := []*apimodel.Status{a, b, c, d, e, f, g, h, i} + expected := statusIDs([]*gtsmodel.Status{f, b, d, e, c, a, g, i, h}) + list := []*gtsmodel.Status{a, b, c, d, e, f, g, h, i} status.TopoSort(list, targetAccount.ID) actual := statusIDs(list) @@ -122,13 +131,13 @@ func (suite *topoSortTestSuite) TestBranchedWithSelfReplyChain() { } func (suite *topoSortTestSuite) TestDisconnected() { - f := &apimodel.Status{ID: "F"} - b := &apimodel.Status{ID: "B", InReplyToID: &f.ID} + f := >smodel.Status{ID: "F"} + b := >smodel.Status{ID: "B", InReplyToID: f.ID} dID := "D" - e := &apimodel.Status{ID: "E", InReplyToID: &dID} + e := >smodel.Status{ID: "E", InReplyToID: dID} - expected := statusIDs([]*apimodel.Status{e, f, b}) - list := []*apimodel.Status{b, e, f} + expected := statusIDs([]*gtsmodel.Status{e, f, b}) + list := []*gtsmodel.Status{b, e, f} status.TopoSort(list, "") actual := statusIDs(list) @@ -137,10 +146,10 @@ func (suite *topoSortTestSuite) TestDisconnected() { func (suite *topoSortTestSuite) TestTrivialCycle() { xID := "X" - x := &apimodel.Status{ID: xID, InReplyToID: &xID} + x := >smodel.Status{ID: xID, InReplyToID: xID} - expected := statusIDs([]*apimodel.Status{x}) - list := []*apimodel.Status{x} + expected := statusIDs([]*gtsmodel.Status{x}) + list := []*gtsmodel.Status{x} status.TopoSort(list, "") actual := statusIDs(list) @@ -149,11 +158,11 @@ func (suite *topoSortTestSuite) TestTrivialCycle() { func (suite *topoSortTestSuite) TestCycle() { yID := "Y" - x := &apimodel.Status{ID: "X", InReplyToID: &yID} - y := &apimodel.Status{ID: yID, InReplyToID: &x.ID} + x := >smodel.Status{ID: "X", InReplyToID: yID} + y := >smodel.Status{ID: yID, InReplyToID: x.ID} - expected := statusIDs([]*apimodel.Status{x, y}) - list := []*apimodel.Status{x, y} + expected := statusIDs([]*gtsmodel.Status{x, y}) + list := []*gtsmodel.Status{x, y} status.TopoSort(list, "") actual := statusIDs(list) @@ -162,12 +171,12 @@ func (suite *topoSortTestSuite) TestCycle() { func (suite *topoSortTestSuite) TestMixedCycle() { yID := "Y" - x := &apimodel.Status{ID: "X", InReplyToID: &yID} - y := &apimodel.Status{ID: yID, InReplyToID: &x.ID} - z := &apimodel.Status{ID: "Z"} + x := >smodel.Status{ID: "X", InReplyToID: yID} + y := >smodel.Status{ID: yID, InReplyToID: x.ID} + z := >smodel.Status{ID: "Z"} - expected := statusIDs([]*apimodel.Status{x, y, z}) - list := []*apimodel.Status{x, y, z} + expected := statusIDs([]*gtsmodel.Status{x, y, z}) + list := []*gtsmodel.Status{x, y, z} status.TopoSort(list, "") actual := statusIDs(list) @@ -175,8 +184,8 @@ func (suite *topoSortTestSuite) TestMixedCycle() { } func (suite *topoSortTestSuite) TestEmpty() { - expected := statusIDs([]*apimodel.Status{}) - list := []*apimodel.Status{} + expected := statusIDs([]*gtsmodel.Status{}) + list := []*gtsmodel.Status{} status.TopoSort(list, "") actual := statusIDs(list) @@ -185,7 +194,7 @@ func (suite *topoSortTestSuite) TestEmpty() { func (suite *topoSortTestSuite) TestNil() { expected := statusIDs(nil) - var list []*apimodel.Status + var list []*gtsmodel.Status status.TopoSort(list, "") actual := statusIDs(list) diff --git a/internal/processing/status/get.go b/internal/processing/status/get.go index 16f55b439..75a687db2 100644 --- a/internal/processing/status/get.go +++ b/internal/processing/status/get.go @@ -19,13 +19,8 @@ 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" "github.com/superseriousbusiness/gotosocial/internal/util" @@ -113,199 +108,3 @@ func (p *Processor) SourceGet(ctx context.Context, requestingAccount *gtsmodel.A } return statusSource, nil } - -// WebGet gets the given status for web use, taking account of privacy settings. -func (p *Processor) WebGet(ctx context.Context, targetStatusID string) (*apimodel.Status, gtserror.WithCode) { - targetStatus, errWithCode := p.c.GetVisibleTargetStatus(ctx, - nil, // requester - targetStatusID, - nil, // default freshness - ) - if errWithCode != nil { - return nil, errWithCode - } - - webStatus, err := p.converter.StatusToWebStatus(ctx, targetStatus, nil) - if err != nil { - err = gtserror.Newf("error converting status: %w", err) - return nil, gtserror.NewErrorInternalError(err) - } - return webStatus, nil -} - -func (p *Processor) contextGet( - ctx context.Context, - requestingAccount *gtsmodel.Account, - targetStatusID string, - convert func(context.Context, *gtsmodel.Status, *gtsmodel.Account) (*apimodel.Status, error), -) (*apimodel.Context, gtserror.WithCode) { - targetStatus, errWithCode := p.c.GetVisibleTargetStatus(ctx, - requestingAccount, - targetStatusID, - nil, // default freshness - ) - if errWithCode != nil { - return nil, errWithCode - } - - parents, err := p.state.DB.GetStatusParents(ctx, targetStatus) - if err != nil { - return nil, gtserror.NewErrorInternalError(err) - } - - var ancestors []*apimodel.Status - for _, status := range parents { - if v, err := p.filter.StatusVisible(ctx, requestingAccount, status); err == nil && v { - apiStatus, err := convert(ctx, status, requestingAccount) - if err == nil { - ancestors = append(ancestors, apiStatus) - } - } - } - - slices.SortFunc(ancestors, func(lhs, rhs *apimodel.Status) int { - return strings.Compare(lhs.ID, rhs.ID) - }) - - children, err := p.state.DB.GetStatusChildren(ctx, targetStatus.ID) - if err != nil { - return nil, gtserror.NewErrorInternalError(err) - } - - var descendants []*apimodel.Status - for _, status := range children { - if v, err := p.filter.StatusVisible(ctx, requestingAccount, status); err == nil && v { - apiStatus, err := convert(ctx, status, requestingAccount) - if err == nil { - descendants = append(descendants, apiStatus) - } - } - } - - TopoSort(descendants, targetStatus.AccountID) - - context := &apimodel.Context{ - Ancestors: make([]apimodel.Status, 0, len(ancestors)), - Descendants: make([]apimodel.Status, 0, len(descendants)), - } - for _, ancestor := range ancestors { - context.Ancestors = append(context.Ancestors, *ancestor) - } - for _, descendant := range descendants { - context.Descendants = append(context.Descendants, *descendant) - } - - return context, nil -} - -// TopoSort sorts statuses topologically, by self-reply, and by ID. -// Can handle cycles but the output order will be arbitrary. -// (But if there are cycles, something went wrong upstream.) -func TopoSort(apiStatuses []*apimodel.Status, targetAccountID string) { - if len(apiStatuses) == 0 { - return - } - - // Map of status IDs to statuses. - lookup := make(map[string]*apimodel.Status, len(apiStatuses)) - for _, apiStatus := range apiStatuses { - lookup[apiStatus.ID] = apiStatus - } - - // 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. - tree := make(map[*apimodel.Status][]*apimodel.Status, len(apiStatuses)) - for _, apiStatus := range apiStatuses { - var parent *apimodel.Status - if apiStatus.InReplyToID != nil { - parent = lookup[*apiStatus.InReplyToID] - } - tree[parent] = append(tree[parent], apiStatus) - } - - // Sort children of each status by self-reply status and then ID, *in reverse*. - isSelfReply := func(apiStatus *apimodel.Status) bool { - return apiStatus.GetAccountID() == targetAccountID && - apiStatus.InReplyToAccountID != nil && - *apiStatus.InReplyToAccountID == targetAccountID - } - for id, children := range tree { - slices.SortFunc(children, func(lhs, rhs *apimodel.Status) int { - lhsIsContextSelfReply := isSelfReply(lhs) - rhsIsContextSelfReply := isSelfReply(rhs) - - if lhsIsContextSelfReply && !rhsIsContextSelfReply { - return 1 - } else if !lhsIsContextSelfReply && rhsIsContextSelfReply { - return -1 - } - - return -strings.Compare(lhs.ID, rhs.ID) - }) - tree[id] = children - } - - // Traverse the tree using preorder depth-first search, topologically sorting the statuses. - stack := make([]*apimodel.Status, 1, len(tree)) - apiStatusIndex := 0 - for len(stack) > 0 { - parent := stack[len(stack)-1] - children := tree[parent] - - if len(children) == 0 { - // Remove this node from the tree. - delete(tree, parent) - // Go back to this node's parent. - stack = stack[:len(stack)-1] - continue - } - - // Remove 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. - apiStatuses[apiStatusIndex] = child - apiStatusIndex++ - } - - // There should only be nodes left in the tree in the event of a cycle. - // Append them to the end in arbitrary order. - // This ensures that the slice of statuses has no duplicates. - for node := range tree { - apiStatuses[apiStatusIndex] = node - apiStatusIndex++ - } -} - -// ContextGet returns the context (previous and following posts) from the given status ID. -func (p *Processor) ContextGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*apimodel.Context, gtserror.WithCode) { - filters, err := p.state.DB.GetFiltersForAccountID(ctx, requestingAccount.ID) - if err != nil { - err = gtserror.Newf("couldn't retrieve filters for account %s: %w", requestingAccount.ID, err) - return nil, gtserror.NewErrorInternalError(err) - } - - mutes, err := p.state.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), requestingAccount.ID, nil) - if err != nil { - err = gtserror.Newf("couldn't retrieve mutes for account %s: %w", requestingAccount.ID, err) - return nil, gtserror.NewErrorInternalError(err) - } - compiledMutes := usermute.NewCompiledUserMuteList(mutes) - - convert := func(ctx context.Context, status *gtsmodel.Status, requestingAccount *gtsmodel.Account) (*apimodel.Status, error) { - return p.converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextThread, filters, compiledMutes) - } - return p.contextGet(ctx, requestingAccount, targetStatusID, convert) -} - -// WebContextGet is like ContextGet, but is explicitly -// for viewing statuses via the unauthenticated web UI. -// -// TODO: a more advanced threading model could be implemented here. -func (p *Processor) WebContextGet(ctx context.Context, targetStatusID string) (*apimodel.Context, gtserror.WithCode) { - return p.contextGet(ctx, nil, targetStatusID, p.converter.StatusToWebStatus) -} diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index c0cd3d7e7..9d99205f6 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -982,13 +982,23 @@ func filterAppliesInContext(filter *gtsmodel.Filter, filterContext statusfilter. func (c *Converter) StatusToWebStatus( ctx context.Context, s *gtsmodel.Status, - requestingAccount *gtsmodel.Account, -) (*apimodel.Status, error) { - webStatus, err := c.statusToFrontend(ctx, s, requestingAccount, statusfilter.FilterContextNone, nil, nil) +) (*apimodel.WebStatus, error) { + apiStatus, err := c.statusToFrontend( + ctx, + s, + nil, // No authed requester. + statusfilter.FilterContextNone, + nil, // No filters. + nil, // No mutes. + ) if err != nil { return nil, err } + webStatus := &apimodel.WebStatus{ + Status: apiStatus, + } + // Whack a newline before and after each "pre" to make it easier to outdent it. webStatus.Content = strings.ReplaceAll(webStatus.Content, "
", "\n
")
 	webStatus.Content = strings.ReplaceAll(webStatus.Content, "
", "
\n") @@ -1014,7 +1024,7 @@ func (c *Converter) StatusToWebStatus( // format them for easier template consumption. totalVotes := poll.VotesCount - webPollOptions := make([]apimodel.WebPollOption, len(poll.Options)) + PollOptions := make([]apimodel.WebPollOption, len(poll.Options)) for i, option := range poll.Options { var voteShare float32 @@ -1046,10 +1056,10 @@ func (c *Converter) StatusToWebStatus( VoteShare: voteShare, VoteShareStr: voteShareStr, } - webPollOptions[i] = webPollOption + PollOptions[i] = webPollOption } - webStatus.WebPollOptions = webPollOptions + webStatus.PollOptions = PollOptions } // Set additional templating @@ -1058,6 +1068,7 @@ func (c *Converter) StatusToWebStatus( a.Sensitive = webStatus.Sensitive } + // Mark this as a local status. webStatus.Local = *s.Local return webStatus, nil diff --git a/internal/typeutils/internaltofrontend_test.go b/internal/typeutils/internaltofrontend_test.go index 1195bc137..9ad5d2c08 100644 --- a/internal/typeutils/internaltofrontend_test.go +++ b/internal/typeutils/internaltofrontend_test.go @@ -883,9 +883,8 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendUnknownAttachments func (suite *InternalToFrontendTestSuite) TestStatusToWebStatus() { testStatus := suite.testStatuses["remote_account_2_status_1"] - requestingAccount := suite.testAccounts["admin_account"] - apiStatus, err := suite.typeconverter.StatusToWebStatus(context.Background(), testStatus, requestingAccount) + apiStatus, err := suite.typeconverter.StatusToWebStatus(context.Background(), testStatus) suite.NoError(err) // MediaAttachments should inherit @@ -1010,7 +1009,12 @@ func (suite *InternalToFrontendTestSuite) TestStatusToWebStatus() { "tags": [], "emojis": [], "card": null, - "poll": null + "poll": null, + "LanguageTag": "en", + "PollOptions": null, + "Local": false, + "Indent": 0, + "ThreadFirstReply": false }`, string(b)) } diff --git a/internal/web/profile.go b/internal/web/profile.go index ca613900f..60157fd19 100644 --- a/internal/web/profile.go +++ b/internal/web/profile.go @@ -111,7 +111,7 @@ func (m *Module) profileGETHandler(c *gin.Context) { var ( maxStatusID = apiutil.ParseMaxID(c.Query(apiutil.MaxIDKey), "") paging = maxStatusID != "" - pinnedStatuses []*apimodel.Status + pinnedStatuses []*apimodel.WebStatus ) if !paging { diff --git a/internal/web/thread.go b/internal/web/thread.go index 492d40103..de3d1b361 100644 --- a/internal/web/thread.go +++ b/internal/web/thread.go @@ -20,7 +20,6 @@ import ( "context" "encoding/json" - "errors" "fmt" "net/http" "strings" @@ -101,34 +100,20 @@ func (m *Module) threadGETHandler(c *gin.Context) { return } - // Get the status itself from the processor using provided ID and authorization (if any). - status, errWithCode := m.processor.Status().WebGet(ctx, targetStatusID) + // Get the thread context. This will fetch the target status as well. + context, errWithCode := m.processor.Status().WebContextGet(ctx, targetStatusID) if errWithCode != nil { apiutil.WebErrorHandler(c, errWithCode, instanceGet) return } // Ensure status actually belongs to target account. - if status.GetAccountID() != targetAccount.ID { + if context.Status.GetAccountID() != targetAccount.ID { err := fmt.Errorf("target account %s does not own status %s", targetUsername, targetStatusID) apiutil.WebErrorHandler(c, gtserror.NewErrorNotFound(err), instanceGet) return } - // Don't render boosts/reblogs as top-level statuses. - if status.Reblog != nil { - err := errors.New("status is a boost wrapper / reblog") - apiutil.WebErrorHandler(c, gtserror.NewErrorNotFound(err), instanceGet) - return - } - - // Fill in the rest of the thread context. - context, errWithCode := m.processor.Status().WebContextGet(ctx, targetStatusID) - if errWithCode != nil { - apiutil.WebErrorHandler(c, errWithCode, instanceGet) - return - } - // Prepare stylesheets for thread. stylesheets := make([]string, 0, 5) @@ -159,11 +144,10 @@ func (m *Module) threadGETHandler(c *gin.Context) { page := apiutil.WebPage{ Template: "thread.tmpl", Instance: instance, - OGMeta: apiutil.OGBase(instance).WithStatus(status), + OGMeta: apiutil.OGBase(instance).WithStatus(context.Status), Stylesheets: stylesheets, Javascript: []string{jsFrontend}, Extra: map[string]any{ - "status": status, "context": context, }, } diff --git a/web/source/css/thread.css b/web/source/css/thread.css index f421d82a7..4f4e3e938 100644 --- a/web/source/css/thread.css +++ b/web/source/css/thread.css @@ -17,11 +17,14 @@ along with this program. If not, see . */ -.thread { +.thread, +.thread-wrapper { display: flex; flex-direction: column; gap: 0.4rem; +} +.thread { /* This column header might contain quite some info, so let it wrap. @@ -42,8 +45,40 @@ } .status { - border-radius: 0; + + &.indent-1 { + margin-left: 0.5rem; + } + + &.indent-2 { + margin-left: 1rem; + } + + &.indent-3 { + margin-left: 1.5rem; + } + + &.indent-4 { + margin-left: 2rem; + } + + &.indent-5 { + margin-left: 2.5rem; + } + + &.indent-1, + &.indent-2, + &.indent-3, + &.indent-4, + &.indent-5 { + .status-link { + margin-left: -0.5rem; + border-left: 0.1rem dashed $border-accent; + } + } + + border-radius: 0; &:last-child { border-bottom-left-radius: $br; border-bottom-right-radius: $br; diff --git a/web/template/status_poll.tmpl b/web/template/status_poll.tmpl index 8cb5dde8f..9c2d29166 100644 --- a/web/template/status_poll.tmpl +++ b/web/template/status_poll.tmpl @@ -58,7 +58,7 @@