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 @@ import (
"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 @@ import (
// 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 @@ 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"
"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 @@ package web
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 @@
- {{- range $index, $pollOption := .WebPollOptions }}
+ {{- range $index, $pollOption := .PollOptions }}
-
Option {{ increment $index }},
{{ emojify .Emojis (noescape $pollOption.Title) }}
diff --git a/web/template/thread.tmpl b/web/template/thread.tmpl
index 2231a5ab8..8b4aa2248 100644
--- a/web/template/thread.tmpl
+++ b/web/template/thread.tmpl
@@ -17,45 +17,103 @@
// along with this program. If not, see .
*/ -}}
-{{- define "threadLength" -}}
- {{- with $length := add (len $.context.Ancestors) (len $.context.Descendants) | increment -}}
- {{- if eq $length 1 -}}
- {{- $length }} post
+{{- define "repliesSummary" -}}
+ {{- if .context.ThreadRepliesShown -}}
+ {{- if .context.ThreadRepliesHidden -}}
+ {{- if eq .context.ThreadReplies 1 -}}
+ {{- /* Some replies are hidden. */ -}}
+ {{ .context.ThreadRepliesShown }} visible reply
+ {{- else if gt .context.ThreadRepliesShown 1 -}}
+ {{ .context.ThreadRepliesShown }} visible replies
+ {{- end -}}
+ ; {{ .context.ThreadRepliesHidden }} more {{ if eq .context.ThreadRepliesHidden 1 }}reply{{ else }}replies{{ end }} hidden or not public
{{- else -}}
- {{- $length }} posts
+ {{- /* No hidden replies. */ -}}
+ {{- if eq .context.ThreadReplies 1 -}}
+ {{ .context.ThreadReplies }} reply
+ {{- else if gt .context.ThreadReplies 1 -}}
+ {{ .context.ThreadReplies }} replies
+ {{- end -}}
{{- end -}}
{{- end -}}
{{- end -}}
+{{- define "threadSummary" -}}
+ {{- if .context.ThreadHidden -}}
+ {{- if eq .context.ThreadShown 1 -}}
+ Single visible post
+ {{- else if gt .context.ThreadShown 1 -}}
+ Thread of {{ .context.ThreadShown }} visible posts
+ {{- end -}}
+ ; {{ .context.ThreadHidden }} more {{ if eq .context.ThreadHidden 1 }}post{{ else }}posts{{ end }} hidden or not public
+ {{- else -}}
+ {{- /* No hidden posts */ -}}
+ {{- if eq .context.ThreadLength 1 -}}
+ Single post
+ {{- else if gt .context.ThreadLength 1 -}}
+ Thread of {{ .context.ThreadLength }} posts
+ {{- end -}}
+ {{- end -}}
+{{- end -}}
+
+{{- define "repliesStart" -}}
{{- with . }}
-
-