mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2024-11-22 11:46:40 +00:00
[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
This commit is contained in:
parent
cde2fb6244
commit
aeb65bceae
|
@ -2516,24 +2516,6 @@ definitions:
|
||||||
type: object
|
type: object
|
||||||
x-go-name: Status
|
x-go-name: Status
|
||||||
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
|
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:
|
statusEdit:
|
||||||
description: |-
|
description: |-
|
||||||
StatusEdit represents one historical revision of a status, containing
|
StatusEdit represents one historical revision of a status, containing
|
||||||
|
@ -2887,6 +2869,26 @@ definitions:
|
||||||
type: object
|
type: object
|
||||||
x-go-name: Theme
|
x-go-name: Theme
|
||||||
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
|
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:
|
user:
|
||||||
properties:
|
properties:
|
||||||
admin:
|
admin:
|
||||||
|
@ -8149,7 +8151,7 @@ paths:
|
||||||
/api/v1/statuses/{id}/context:
|
/api/v1/statuses/{id}/context:
|
||||||
get:
|
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.
|
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:
|
parameters:
|
||||||
- description: Target status ID.
|
- description: Target status ID.
|
||||||
in: path
|
in: path
|
||||||
|
@ -8160,9 +8162,9 @@ paths:
|
||||||
- application/json
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Status context object.
|
description: Thread context object.
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/statusContext'
|
$ref: '#/definitions/threadContext'
|
||||||
"400":
|
"400":
|
||||||
description: bad request
|
description: bad request
|
||||||
"401":
|
"401":
|
||||||
|
|
|
@ -27,7 +27,7 @@
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
"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.
|
// Return ancestors and descendants of the given status.
|
||||||
//
|
//
|
||||||
|
@ -55,9 +55,9 @@
|
||||||
// responses:
|
// responses:
|
||||||
// '200':
|
// '200':
|
||||||
// name: statuses
|
// name: statuses
|
||||||
// description: Status context object.
|
// description: Thread context object.
|
||||||
// schema:
|
// schema:
|
||||||
// "$ref": "#/definitions/statusContext"
|
// "$ref": "#/definitions/threadContext"
|
||||||
// '400':
|
// '400':
|
||||||
// description: bad request
|
// description: bad request
|
||||||
// '401':
|
// '401':
|
||||||
|
@ -89,11 +89,11 @@ func (m *Module) StatusContextGETHandler(c *gin.Context) {
|
||||||
return
|
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 {
|
if errWithCode != nil {
|
||||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, statusContext)
|
c.JSON(http.StatusOK, threadContext)
|
||||||
}
|
}
|
||||||
|
|
|
@ -102,28 +102,34 @@ type Status struct {
|
||||||
Text string `json:"text,omitempty"`
|
Text string `json:"text,omitempty"`
|
||||||
// A list of filters that matched this status and why they matched, if there are any such filters.
|
// A list of filters that matched this status and why they matched, if there are any such filters.
|
||||||
Filtered []FilterResult `json:"filtered,omitempty"`
|
Filtered []FilterResult `json:"filtered,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// Additional fields not exposed via JSON
|
// WebStatus is like *model.Status, but contains
|
||||||
// (used only internally for templating etc).
|
// additional fields used only for HTML templating.
|
||||||
|
//
|
||||||
|
// swagger:ignore
|
||||||
|
type WebStatus struct {
|
||||||
|
*Status
|
||||||
|
|
||||||
// Template-ready language tag + string, based
|
// Template-ready language tag and
|
||||||
// on *status.Language. Nil for non-web statuses.
|
// string, based on *status.Language.
|
||||||
//
|
LanguageTag *language.Language
|
||||||
// swagger:ignore
|
|
||||||
LanguageTag *language.Language `json:"-"`
|
|
||||||
|
|
||||||
// Template-ready poll options with vote shares
|
// Template-ready poll options with vote shares
|
||||||
// calculated as a percentage of total votes.
|
// calculated as a percentage of total votes.
|
||||||
// Nil for non-web statuses.
|
PollOptions []WebPollOption
|
||||||
//
|
|
||||||
// swagger:ignore
|
|
||||||
WebPollOptions []WebPollOption `json:"-"`
|
|
||||||
|
|
||||||
// Status is from a local account.
|
// Status is from a local account.
|
||||||
// Always false for non-web statuses.
|
Local bool
|
||||||
//
|
|
||||||
// swagger:ignore
|
// Level of indentation at which to
|
||||||
Local bool `json:"-"`
|
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
|
@ -17,12 +17,48 @@
|
||||||
|
|
||||||
package model
|
package model
|
||||||
|
|
||||||
// Context models the tree around a given status.
|
// ThreadContext models the tree or
|
||||||
|
// "thread" around a given status.
|
||||||
//
|
//
|
||||||
// swagger:model statusContext
|
// swagger:model threadContext
|
||||||
type Context struct {
|
type ThreadContext struct {
|
||||||
// Parents in the thread.
|
// Parents in the thread.
|
||||||
Ancestors []Status `json:"ancestors"`
|
Ancestors []Status `json:"ancestors"`
|
||||||
// Children in the thread.
|
// Children in the thread.
|
||||||
Descendants []Status `json:"descendants"`
|
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
|
||||||
|
}
|
|
@ -105,7 +105,7 @@ func (og *OGMeta) WithAccount(account *apimodel.Account) *OGMeta {
|
||||||
// WithStatus uses the given status to build an ogMeta
|
// WithStatus uses the given status to build an ogMeta
|
||||||
// struct specific to that status. It's suitable for serving
|
// struct specific to that status. It's suitable for serving
|
||||||
// at status pages.
|
// 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.Title = "Post by " + AccountTitle(status.Account, og.SiteName)
|
||||||
og.Type = "article"
|
og.Type = "article"
|
||||||
if status.Language != nil {
|
if status.Language != nil {
|
||||||
|
|
|
@ -179,7 +179,7 @@ func (p *Processor) WebStatusesGet(
|
||||||
|
|
||||||
for _, s := range statuses {
|
for _, s := range statuses {
|
||||||
// Convert fetched statuses to web view 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 {
|
if err != nil {
|
||||||
log.Errorf(ctx, "error convering to web status: %v", err)
|
log.Errorf(ctx, "error convering to web status: %v", err)
|
||||||
continue
|
continue
|
||||||
|
@ -198,13 +198,13 @@ func (p *Processor) WebStatusesGet(
|
||||||
func (p *Processor) WebStatusesGetPinned(
|
func (p *Processor) WebStatusesGetPinned(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
targetAccountID string,
|
targetAccountID string,
|
||||||
) ([]*apimodel.Status, gtserror.WithCode) {
|
) ([]*apimodel.WebStatus, gtserror.WithCode) {
|
||||||
statuses, err := p.state.DB.GetAccountPinnedStatuses(ctx, targetAccountID)
|
statuses, err := p.state.DB.GetAccountPinnedStatuses(ctx, targetAccountID)
|
||||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||||
return nil, gtserror.NewErrorInternalError(err)
|
return nil, gtserror.NewErrorInternalError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
webStatuses := make([]*apimodel.Status, 0, len(statuses))
|
webStatuses := make([]*apimodel.WebStatus, 0, len(statuses))
|
||||||
for _, status := range statuses {
|
for _, status := range statuses {
|
||||||
if status.Visibility != gtsmodel.VisibilityPublic {
|
if status.Visibility != gtsmodel.VisibilityPublic {
|
||||||
// Skip non-public
|
// Skip non-public
|
||||||
|
@ -212,7 +212,7 @@ func (p *Processor) WebStatusesGetPinned(
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
webStatus, err := p.converter.StatusToWebStatus(ctx, status, nil)
|
webStatus, err := p.converter.StatusToWebStatus(ctx, status)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf(ctx, "error convering to web status: %v", err)
|
log.Errorf(ctx, "error convering to web status: %v", err)
|
||||||
continue
|
continue
|
||||||
|
|
566
internal/processing/status/context.go
Normal file
566
internal/processing/status/context.go
Normal file
|
@ -0,0 +1,566 @@
|
||||||
|
// GoToSocial
|
||||||
|
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package status
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||||
|
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/filter/usermute"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||||
|
)
|
||||||
|
|
||||||
|
// internalThreadContext is like
|
||||||
|
// *apimodel.ThreadContext, but
|
||||||
|
// for internal use only.
|
||||||
|
type internalThreadContext struct {
|
||||||
|
targetStatus *gtsmodel.Status
|
||||||
|
ancestors []*gtsmodel.Status
|
||||||
|
descendants []*gtsmodel.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Processor) contextGet(
|
||||||
|
ctx context.Context,
|
||||||
|
requester *gtsmodel.Account,
|
||||||
|
targetStatusID string,
|
||||||
|
) (*internalThreadContext, gtserror.WithCode) {
|
||||||
|
targetStatus, errWithCode := p.c.GetVisibleTargetStatus(ctx,
|
||||||
|
requester,
|
||||||
|
targetStatusID,
|
||||||
|
nil, // default freshness
|
||||||
|
)
|
||||||
|
if errWithCode != nil {
|
||||||
|
return nil, errWithCode
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't generate thread for boosts/reblogs.
|
||||||
|
if targetStatus.BoostOfID != "" {
|
||||||
|
err := gtserror.New("target status is a boost wrapper / reblog")
|
||||||
|
return nil, gtserror.NewErrorNotFound(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch up to the top of the thread.
|
||||||
|
ancestors, err := p.state.DB.GetStatusParents(ctx, targetStatus)
|
||||||
|
if err != nil {
|
||||||
|
return nil, gtserror.NewErrorInternalError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do a simple ID sort of ancestors
|
||||||
|
// to arrange them by creation time.
|
||||||
|
slices.SortFunc(ancestors, func(lhs, rhs *gtsmodel.Status) int {
|
||||||
|
return strings.Compare(lhs.ID, rhs.ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fetch down to the bottom of the thread.
|
||||||
|
descendants, err := p.state.DB.GetStatusChildren(ctx, targetStatus.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, gtserror.NewErrorInternalError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Topographically sort descendants,
|
||||||
|
// to place them in sub-threads.
|
||||||
|
TopoSort(descendants, targetStatus.AccountID)
|
||||||
|
|
||||||
|
return &internalThreadContext{
|
||||||
|
targetStatus: targetStatus,
|
||||||
|
ancestors: ancestors,
|
||||||
|
descendants: descendants,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns true if status counts as a self-reply
|
||||||
|
// *within the current context*, ie., status is a
|
||||||
|
// self-reply by contextAcctID to contextAcctID.
|
||||||
|
func isSelfReply(
|
||||||
|
status *gtsmodel.Status,
|
||||||
|
contextAcctID string,
|
||||||
|
) bool {
|
||||||
|
if status.AccountID != contextAcctID {
|
||||||
|
// Doesn't belong
|
||||||
|
// to context acct.
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return status.InReplyToAccountID == contextAcctID
|
||||||
|
}
|
||||||
|
|
||||||
|
// TopoSort sorts the given slice of *descendant*
|
||||||
|
// statuses topologically, by self-reply, and by ID.
|
||||||
|
//
|
||||||
|
// "contextAcctID" should be the ID of the account that owns
|
||||||
|
// the status the thread context is being constructed around.
|
||||||
|
//
|
||||||
|
// Can handle cycles but the output order will be arbitrary.
|
||||||
|
// (But if there are cycles, something went wrong upstream.)
|
||||||
|
func TopoSort(
|
||||||
|
statuses []*gtsmodel.Status,
|
||||||
|
contextAcctID string,
|
||||||
|
) {
|
||||||
|
if len(statuses) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple map of status IDs to statuses.
|
||||||
|
//
|
||||||
|
// Eg.,
|
||||||
|
//
|
||||||
|
// 01J2BC6DQ37A6SQPAVCZ2BYSTN: *gtsmodel.Status
|
||||||
|
// 01J2BC8GT9THMPWMCAZYX48PXJ: *gtsmodel.Status
|
||||||
|
// 01J2BC8M56C5ZAH76KN93D7F0W: *gtsmodel.Status
|
||||||
|
// 01J2BC90QNW65SM2F89R5M0NGE: *gtsmodel.Status
|
||||||
|
// 01J2BC916YVX6D6Q0SA30JV82D: *gtsmodel.Status
|
||||||
|
// 01J2BC91J2Y75D4Z3EEDF3DYAV: *gtsmodel.Status
|
||||||
|
// 01J2BC91VBVPBZACZMDA7NEZY9: *gtsmodel.Status
|
||||||
|
// 01J2BCMM3CXQE70S831YPWT48T: *gtsmodel.Status
|
||||||
|
lookup := make(map[string]*gtsmodel.Status, len(statuses))
|
||||||
|
for _, status := range statuses {
|
||||||
|
lookup[status.ID] = status
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tree of statuses to their children.
|
||||||
|
//
|
||||||
|
// The nil status may have children: any who don't
|
||||||
|
// have a parent, or whose parent isn't in the input.
|
||||||
|
//
|
||||||
|
// Eg.,
|
||||||
|
//
|
||||||
|
// *gtsmodel.Status (01J2BC916YVX6D6Q0SA30JV82D): [ <- parent2 (1 child)
|
||||||
|
// *gtsmodel.Status (01J2BC91J2Y75D4Z3EEDF3DYAV) <- p2 child1
|
||||||
|
// ],
|
||||||
|
// *gtsmodel.Status (01J2BC6DQ37A6SQPAVCZ2BYSTN): [ <- parent1 (3 children)
|
||||||
|
// *gtsmodel.Status (01J2BC8M56C5ZAH76KN93D7F0W) <- p1 child3 |
|
||||||
|
// *gtsmodel.Status (01J2BC90QNW65SM2F89R5M0NGE) <- p1 child1 |- Not sorted
|
||||||
|
// *gtsmodel.Status (01J2BC8GT9THMPWMCAZYX48PXJ) <- p1 child2 |
|
||||||
|
// ],
|
||||||
|
// *gtsmodel.Status (01J2BC91VBVPBZACZMDA7NEZY9): [ <- parent3 (no children 😢)
|
||||||
|
// ]
|
||||||
|
// *gtsmodel.Status (nil): [ <- parent4 (nil status)
|
||||||
|
// *gtsmodel.Status (01J2BCMM3CXQE70S831YPWT48T) <- p4 child1 (no parent 😢)
|
||||||
|
// ]
|
||||||
|
tree := make(map[*gtsmodel.Status][]*gtsmodel.Status, len(statuses))
|
||||||
|
for _, status := range statuses {
|
||||||
|
var parent *gtsmodel.Status
|
||||||
|
if status.InReplyToID != "" {
|
||||||
|
// May be nil if reply is missing.
|
||||||
|
parent = lookup[status.InReplyToID]
|
||||||
|
}
|
||||||
|
|
||||||
|
tree[parent] = append(tree[parent], status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort children of each parent by self-reply status and then ID, *in reverse*.
|
||||||
|
// This results in the tree looking something like:
|
||||||
|
//
|
||||||
|
// *gtsmodel.Status (01J2BC916YVX6D6Q0SA30JV82D): [ <- parent2 (1 child)
|
||||||
|
// *gtsmodel.Status (01J2BC91J2Y75D4Z3EEDF3DYAV) <- p2 child1
|
||||||
|
// ],
|
||||||
|
// *gtsmodel.Status (01J2BC6DQ37A6SQPAVCZ2BYSTN): [ <- parent1 (3 children)
|
||||||
|
// *gtsmodel.Status (01J2BC90QNW65SM2F89R5M0NGE) <- p1 child1 |
|
||||||
|
// *gtsmodel.Status (01J2BC8GT9THMPWMCAZYX48PXJ) <- p1 child2 |- Sorted
|
||||||
|
// *gtsmodel.Status (01J2BC8M56C5ZAH76KN93D7F0W) <- p1 child3 |
|
||||||
|
// ],
|
||||||
|
// *gtsmodel.Status (01J2BC91VBVPBZACZMDA7NEZY9): [ <- parent3 (no children 😢)
|
||||||
|
// ],
|
||||||
|
// *gtsmodel.Status (nil): [ <- parent4 (nil status)
|
||||||
|
// *gtsmodel.Status (01J2BCMM3CXQE70S831YPWT48T) <- p4 child1 (no parent 😢)
|
||||||
|
// ]
|
||||||
|
for id, children := range tree {
|
||||||
|
slices.SortFunc(children, func(lhs, rhs *gtsmodel.Status) int {
|
||||||
|
lhsIsSelfReply := isSelfReply(lhs, contextAcctID)
|
||||||
|
rhsIsSelfReply := isSelfReply(rhs, contextAcctID)
|
||||||
|
|
||||||
|
if lhsIsSelfReply && !rhsIsSelfReply {
|
||||||
|
// lhs is the end
|
||||||
|
// of a sub-thread.
|
||||||
|
return 1
|
||||||
|
} else if !lhsIsSelfReply && rhsIsSelfReply {
|
||||||
|
// lhs is the start
|
||||||
|
// of a sub-thread.
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by created-at descending.
|
||||||
|
return -strings.Compare(lhs.ID, rhs.ID)
|
||||||
|
})
|
||||||
|
tree[id] = children
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traverse the tree using preorder depth-first
|
||||||
|
// search, topologically sorting the statuses
|
||||||
|
// until the stack is empty.
|
||||||
|
//
|
||||||
|
// The stack starts with one nil status in it
|
||||||
|
// to account for potential nil key in the tree,
|
||||||
|
// which means the below "for" loop will always
|
||||||
|
// iterate at least once.
|
||||||
|
//
|
||||||
|
// The result will look something like:
|
||||||
|
//
|
||||||
|
// *gtsmodel.Status (01J2BC6DQ37A6SQPAVCZ2BYSTN) <- parent1 (3 children)
|
||||||
|
// *gtsmodel.Status (01J2BC90QNW65SM2F89R5M0NGE) <- p1 child1 |
|
||||||
|
// *gtsmodel.Status (01J2BC8GT9THMPWMCAZYX48PXJ) <- p1 child2 |- Sorted
|
||||||
|
// *gtsmodel.Status (01J2BC8M56C5ZAH76KN93D7F0W) <- p1 child3 |
|
||||||
|
// *gtsmodel.Status (01J2BC916YVX6D6Q0SA30JV82D) <- parent2 (1 child)
|
||||||
|
// *gtsmodel.Status (01J2BC91J2Y75D4Z3EEDF3DYAV) <- p2 child1
|
||||||
|
// *gtsmodel.Status (01J2BC91VBVPBZACZMDA7NEZY9) <- parent3 (no children 😢)
|
||||||
|
// *gtsmodel.Status (01J2BCMM3CXQE70S831YPWT48T) <- p4 child1 (no parent 😢)
|
||||||
|
|
||||||
|
stack := make([]*gtsmodel.Status, 1, len(tree))
|
||||||
|
statusIndex := 0
|
||||||
|
for len(stack) > 0 {
|
||||||
|
parent := stack[len(stack)-1]
|
||||||
|
children := tree[parent]
|
||||||
|
|
||||||
|
if len(children) == 0 {
|
||||||
|
// No (more) children so we're
|
||||||
|
// done with this node.
|
||||||
|
// Remove it from the tree.
|
||||||
|
delete(tree, parent)
|
||||||
|
|
||||||
|
// Also remove this node from
|
||||||
|
// the stack, then continue
|
||||||
|
// from its parent.
|
||||||
|
stack = stack[:len(stack)-1]
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pop the last child entry
|
||||||
|
// (the first in sorted order).
|
||||||
|
child := children[len(children)-1]
|
||||||
|
tree[parent] = children[:len(children)-1]
|
||||||
|
|
||||||
|
// Explore its children next.
|
||||||
|
stack = append(stack, child)
|
||||||
|
|
||||||
|
// Overwrite the next entry of the input slice.
|
||||||
|
statuses[statusIndex] = child
|
||||||
|
statusIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
// There should only be orphan nodes remaining
|
||||||
|
// (or other nodes in the event of a cycle).
|
||||||
|
// Append them to the end in arbitrary order.
|
||||||
|
//
|
||||||
|
// The fact we put them in a map first just
|
||||||
|
// ensures the slice of statuses has no duplicates.
|
||||||
|
for orphan := range tree {
|
||||||
|
statuses[statusIndex] = orphan
|
||||||
|
statusIndex++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContextGet returns the context (previous
|
||||||
|
// and following posts) from the given status ID.
|
||||||
|
func (p *Processor) ContextGet(
|
||||||
|
ctx context.Context,
|
||||||
|
requester *gtsmodel.Account,
|
||||||
|
targetStatusID string,
|
||||||
|
) (*apimodel.ThreadContext, gtserror.WithCode) {
|
||||||
|
// Retrieve filters as they affect
|
||||||
|
// what should be shown to requester.
|
||||||
|
filters, err := p.state.DB.GetFiltersForAccountID(
|
||||||
|
ctx, // Populate filters.
|
||||||
|
requester.ID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
err = gtserror.Newf(
|
||||||
|
"couldn't retrieve filters for account %s: %w",
|
||||||
|
requester.ID, err,
|
||||||
|
)
|
||||||
|
return nil, gtserror.NewErrorInternalError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve mutes as they affect
|
||||||
|
// what should be shown to requester.
|
||||||
|
mutes, err := p.state.DB.GetAccountMutes(
|
||||||
|
// No need to populate mutes,
|
||||||
|
// IDs are enough here.
|
||||||
|
gtscontext.SetBarebones(ctx),
|
||||||
|
requester.ID,
|
||||||
|
nil, // No paging - get all.
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
err = gtserror.Newf(
|
||||||
|
"couldn't retrieve mutes for account %s: %w",
|
||||||
|
requester.ID, err,
|
||||||
|
)
|
||||||
|
return nil, gtserror.NewErrorInternalError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
convert := func(
|
||||||
|
ctx context.Context,
|
||||||
|
status *gtsmodel.Status,
|
||||||
|
requestingAccount *gtsmodel.Account,
|
||||||
|
) (*apimodel.Status, error) {
|
||||||
|
return p.converter.StatusToAPIStatus(
|
||||||
|
ctx,
|
||||||
|
status,
|
||||||
|
requestingAccount,
|
||||||
|
statusfilter.FilterContextThread,
|
||||||
|
filters,
|
||||||
|
usermute.NewCompiledUserMuteList(mutes),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve the thread context.
|
||||||
|
threadContext, errWithCode := p.contextGet(
|
||||||
|
ctx,
|
||||||
|
requester,
|
||||||
|
targetStatusID,
|
||||||
|
)
|
||||||
|
if errWithCode != nil {
|
||||||
|
return nil, errWithCode
|
||||||
|
}
|
||||||
|
|
||||||
|
apiContext := &apimodel.ThreadContext{
|
||||||
|
Ancestors: make([]apimodel.Status, 0, len(threadContext.ancestors)),
|
||||||
|
Descendants: make([]apimodel.Status, 0, len(threadContext.descendants)),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert ancestors + filter
|
||||||
|
// out ones that aren't visible.
|
||||||
|
for _, status := range threadContext.ancestors {
|
||||||
|
if v, err := p.filter.StatusVisible(ctx, requester, status); err == nil && v {
|
||||||
|
status, err := convert(ctx, status, requester)
|
||||||
|
if err == nil {
|
||||||
|
apiContext.Ancestors = append(apiContext.Ancestors, *status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert descendants + filter
|
||||||
|
// out ones that aren't visible.
|
||||||
|
for _, status := range threadContext.descendants {
|
||||||
|
if v, err := p.filter.StatusVisible(ctx, requester, status); err == nil && v {
|
||||||
|
status, err := convert(ctx, status, requester)
|
||||||
|
if err == nil {
|
||||||
|
apiContext.Descendants = append(apiContext.Descendants, *status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiContext, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebContextGet is like ContextGet, but is explicitly
|
||||||
|
// for viewing statuses via the unauthenticated web UI.
|
||||||
|
//
|
||||||
|
// The returned statuses in the ThreadContext will be
|
||||||
|
// populated with ThreadMeta annotations for more easily
|
||||||
|
// positioning the status in a web view of a thread.
|
||||||
|
func (p *Processor) WebContextGet(
|
||||||
|
ctx context.Context,
|
||||||
|
targetStatusID string,
|
||||||
|
) (*apimodel.WebThreadContext, gtserror.WithCode) {
|
||||||
|
// Retrieve the internal thread context.
|
||||||
|
iCtx, errWithCode := p.contextGet(
|
||||||
|
ctx,
|
||||||
|
nil, // No authed requester.
|
||||||
|
targetStatusID,
|
||||||
|
)
|
||||||
|
if errWithCode != nil {
|
||||||
|
return nil, errWithCode
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recreate the whole thread so we can go
|
||||||
|
// through it again add ThreadMeta annotations
|
||||||
|
// from the perspective of the OG status.
|
||||||
|
// nolint:gocritic
|
||||||
|
wholeThread := append(
|
||||||
|
// Ancestors at the beginning.
|
||||||
|
iCtx.ancestors,
|
||||||
|
append(
|
||||||
|
// Target status in the middle.
|
||||||
|
[]*gtsmodel.Status{iCtx.targetStatus},
|
||||||
|
// Descendants at the end.
|
||||||
|
iCtx.descendants...,
|
||||||
|
)...,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Start preparing web context.
|
||||||
|
wCtx := &apimodel.WebThreadContext{
|
||||||
|
Ancestors: make([]*apimodel.WebStatus, 0, len(iCtx.ancestors)),
|
||||||
|
Descendants: make([]*apimodel.WebStatus, 0, len(iCtx.descendants)),
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
threadLength = len(wholeThread)
|
||||||
|
|
||||||
|
// Track how much each reply status
|
||||||
|
// should be indented (if at all).
|
||||||
|
statusIndents = make(map[string]int, threadLength)
|
||||||
|
|
||||||
|
// Who the current thread "belongs" to,
|
||||||
|
// ie., who created first post in the thread.
|
||||||
|
contextAcctID = wholeThread[0].AccountID
|
||||||
|
|
||||||
|
// Position of target status in wholeThread,
|
||||||
|
// we put it on top of ancestors.
|
||||||
|
targetStatusIdx = len(iCtx.ancestors)
|
||||||
|
|
||||||
|
// Position from which we should add
|
||||||
|
// to descendants and not to ancestors.
|
||||||
|
descendantsIdx = targetStatusIdx + 1
|
||||||
|
|
||||||
|
// Whether we've reached end of "main"
|
||||||
|
// thread and are now looking at replies.
|
||||||
|
inReplies bool
|
||||||
|
|
||||||
|
// Index in wholeThread where
|
||||||
|
// the "main" thread ends.
|
||||||
|
firstReplyIdx int
|
||||||
|
|
||||||
|
// We should mark the next **VISIBLE**
|
||||||
|
// reply as the first reply.
|
||||||
|
markNextVisibleAsReply bool
|
||||||
|
)
|
||||||
|
|
||||||
|
for idx, status := range wholeThread {
|
||||||
|
if !inReplies {
|
||||||
|
// Haven't reached end
|
||||||
|
// of "main" thread yet.
|
||||||
|
//
|
||||||
|
// First post in wholeThread can't
|
||||||
|
// be a self reply, so ignore it.
|
||||||
|
//
|
||||||
|
// That aside, first non-self-reply
|
||||||
|
// in wholeThread means the "main"
|
||||||
|
// thread is now over.
|
||||||
|
if idx != 0 && !isSelfReply(status, contextAcctID) {
|
||||||
|
// Jot some stuff down.
|
||||||
|
firstReplyIdx = idx
|
||||||
|
inReplies = true
|
||||||
|
markNextVisibleAsReply = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure status is actually
|
||||||
|
// visible to just anyone.
|
||||||
|
v, err := p.filter.StatusVisible(ctx, nil, status)
|
||||||
|
if err != nil || !v {
|
||||||
|
// Skip this one.
|
||||||
|
if !inReplies {
|
||||||
|
wCtx.ThreadHidden++
|
||||||
|
} else {
|
||||||
|
wCtx.ThreadRepliesHidden++
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare status to add to thread context.
|
||||||
|
apiStatus, err := p.converter.StatusToWebStatus(ctx, status)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if markNextVisibleAsReply {
|
||||||
|
// This is the first visible
|
||||||
|
// "reply / comment", so the
|
||||||
|
// little "x amount of replies"
|
||||||
|
// header should go above this.
|
||||||
|
apiStatus.ThreadFirstReply = true
|
||||||
|
markNextVisibleAsReply = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this is a reply, work out the indent of
|
||||||
|
// this status based on its parent's indent.
|
||||||
|
if inReplies {
|
||||||
|
parentIndent, ok := statusIndents[status.InReplyToID]
|
||||||
|
switch {
|
||||||
|
case !ok:
|
||||||
|
// No parent with
|
||||||
|
// indent, start at 0.
|
||||||
|
apiStatus.Indent = 0
|
||||||
|
|
||||||
|
case isSelfReply(status, status.AccountID):
|
||||||
|
// Self reply, so indent at same
|
||||||
|
// level as own replied-to status.
|
||||||
|
apiStatus.Indent = parentIndent
|
||||||
|
|
||||||
|
case parentIndent == 5:
|
||||||
|
// Already indented as far as we
|
||||||
|
// can go to keep things readable
|
||||||
|
// on thin screens, so just keep
|
||||||
|
// parent's indent.
|
||||||
|
apiStatus.Indent = parentIndent
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Reply to someone else who's
|
||||||
|
// indented, but not to TO THE MAX.
|
||||||
|
// Indent by another one.
|
||||||
|
apiStatus.Indent = parentIndent + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the indent for this status.
|
||||||
|
statusIndents[status.ID] = apiStatus.Indent
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case idx == targetStatusIdx:
|
||||||
|
// This is the target status itself.
|
||||||
|
wCtx.Status = apiStatus
|
||||||
|
|
||||||
|
case idx < descendantsIdx:
|
||||||
|
// Haven't reached descendants yet,
|
||||||
|
// so this must be an ancestor.
|
||||||
|
wCtx.Ancestors = append(
|
||||||
|
wCtx.Ancestors,
|
||||||
|
apiStatus,
|
||||||
|
)
|
||||||
|
|
||||||
|
default:
|
||||||
|
// We're in descendants town now.
|
||||||
|
wCtx.Descendants = append(
|
||||||
|
wCtx.Descendants,
|
||||||
|
apiStatus,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now we've gone through the whole
|
||||||
|
// thread, we can add some additional info.
|
||||||
|
|
||||||
|
// Length of the "main" thread. If there are
|
||||||
|
// replies then it's up to where the replies
|
||||||
|
// start, otherwise it's the whole thing.
|
||||||
|
if inReplies {
|
||||||
|
wCtx.ThreadLength = firstReplyIdx
|
||||||
|
} else {
|
||||||
|
wCtx.ThreadLength = threadLength
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jot down number of hidden posts so template doesn't have to do it.
|
||||||
|
wCtx.ThreadShown = wCtx.ThreadLength - wCtx.ThreadHidden
|
||||||
|
|
||||||
|
// Number of replies is equal to number
|
||||||
|
// of statuses in the thread that aren't
|
||||||
|
// part of the "main" thread.
|
||||||
|
wCtx.ThreadReplies = threadLength - wCtx.ThreadLength
|
||||||
|
|
||||||
|
// Jot down number of hidden replies so template doesn't have to do it.
|
||||||
|
wCtx.ThreadRepliesShown = wCtx.ThreadReplies - wCtx.ThreadRepliesHidden
|
||||||
|
|
||||||
|
// Return the finished context.
|
||||||
|
return wCtx, nil
|
||||||
|
}
|
|
@ -18,17 +18,18 @@
|
||||||
package status_test
|
package status_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/stretchr/testify/suite"
|
|
||||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/processing/status"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/processing/status"
|
||||||
)
|
)
|
||||||
|
|
||||||
type topoSortTestSuite struct {
|
type topoSortTestSuite struct {
|
||||||
suite.Suite
|
suite.Suite
|
||||||
}
|
}
|
||||||
|
|
||||||
func statusIDs(apiStatuses []*apimodel.Status) []string {
|
func statusIDs(apiStatuses []*gtsmodel.Status) []string {
|
||||||
ids := make([]string, 0, len(apiStatuses))
|
ids := make([]string, 0, len(apiStatuses))
|
||||||
for _, apiStatus := range apiStatuses {
|
for _, apiStatus := range apiStatuses {
|
||||||
ids = append(ids, apiStatus.ID)
|
ids = append(ids, apiStatus.ID)
|
||||||
|
@ -38,18 +39,18 @@ func statusIDs(apiStatuses []*apimodel.Status) []string {
|
||||||
|
|
||||||
func (suite *topoSortTestSuite) TestBranched() {
|
func (suite *topoSortTestSuite) TestBranched() {
|
||||||
// https://commons.wikimedia.org/wiki/File:Sorted_binary_tree_ALL_RGB.svg
|
// https://commons.wikimedia.org/wiki/File:Sorted_binary_tree_ALL_RGB.svg
|
||||||
f := &apimodel.Status{ID: "F"}
|
f := >smodel.Status{ID: "F"}
|
||||||
b := &apimodel.Status{ID: "B", InReplyToID: &f.ID}
|
b := >smodel.Status{ID: "B", InReplyToID: f.ID}
|
||||||
a := &apimodel.Status{ID: "A", InReplyToID: &b.ID}
|
a := >smodel.Status{ID: "A", InReplyToID: b.ID}
|
||||||
d := &apimodel.Status{ID: "D", InReplyToID: &b.ID}
|
d := >smodel.Status{ID: "D", InReplyToID: b.ID}
|
||||||
c := &apimodel.Status{ID: "C", InReplyToID: &d.ID}
|
c := >smodel.Status{ID: "C", InReplyToID: d.ID}
|
||||||
e := &apimodel.Status{ID: "E", InReplyToID: &d.ID}
|
e := >smodel.Status{ID: "E", InReplyToID: d.ID}
|
||||||
g := &apimodel.Status{ID: "G", InReplyToID: &f.ID}
|
g := >smodel.Status{ID: "G", InReplyToID: f.ID}
|
||||||
i := &apimodel.Status{ID: "I", InReplyToID: &g.ID}
|
i := >smodel.Status{ID: "I", InReplyToID: g.ID}
|
||||||
h := &apimodel.Status{ID: "H", InReplyToID: &i.ID}
|
h := >smodel.Status{ID: "H", InReplyToID: i.ID}
|
||||||
|
|
||||||
expected := statusIDs([]*apimodel.Status{f, b, a, d, c, e, g, i, h})
|
expected := statusIDs([]*gtsmodel.Status{f, b, a, d, c, e, g, i, h})
|
||||||
list := []*apimodel.Status{a, b, c, d, e, f, g, h, i}
|
list := []*gtsmodel.Status{a, b, c, d, e, f, g, h, i}
|
||||||
status.TopoSort(list, "")
|
status.TopoSort(list, "")
|
||||||
actual := statusIDs(list)
|
actual := statusIDs(list)
|
||||||
|
|
||||||
|
@ -57,64 +58,72 @@ func (suite *topoSortTestSuite) TestBranched() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (suite *topoSortTestSuite) TestBranchedWithSelfReplyChain() {
|
func (suite *topoSortTestSuite) TestBranchedWithSelfReplyChain() {
|
||||||
targetAccount := &apimodel.Account{ID: "1"}
|
targetAccount := >smodel.Account{ID: "1"}
|
||||||
otherAccount := &apimodel.Account{ID: "2"}
|
otherAccount := >smodel.Account{ID: "2"}
|
||||||
|
|
||||||
f := &apimodel.Status{
|
f := >smodel.Status{
|
||||||
ID: "F",
|
ID: "F",
|
||||||
Account: targetAccount,
|
Account: targetAccount,
|
||||||
}
|
}
|
||||||
b := &apimodel.Status{
|
b := >smodel.Status{
|
||||||
ID: "B",
|
ID: "B",
|
||||||
Account: targetAccount,
|
Account: targetAccount,
|
||||||
InReplyToID: &f.ID,
|
AccountID: targetAccount.ID,
|
||||||
InReplyToAccountID: &f.Account.ID,
|
InReplyToID: f.ID,
|
||||||
|
InReplyToAccountID: f.Account.ID,
|
||||||
}
|
}
|
||||||
a := &apimodel.Status{
|
d := >smodel.Status{
|
||||||
ID: "A",
|
|
||||||
Account: otherAccount,
|
|
||||||
InReplyToID: &b.ID,
|
|
||||||
InReplyToAccountID: &b.Account.ID,
|
|
||||||
}
|
|
||||||
d := &apimodel.Status{
|
|
||||||
ID: "D",
|
ID: "D",
|
||||||
Account: targetAccount,
|
Account: targetAccount,
|
||||||
InReplyToID: &b.ID,
|
AccountID: targetAccount.ID,
|
||||||
InReplyToAccountID: &b.Account.ID,
|
InReplyToID: b.ID,
|
||||||
|
InReplyToAccountID: b.Account.ID,
|
||||||
}
|
}
|
||||||
c := &apimodel.Status{
|
e := >smodel.Status{
|
||||||
ID: "C",
|
|
||||||
Account: otherAccount,
|
|
||||||
InReplyToID: &d.ID,
|
|
||||||
InReplyToAccountID: &d.Account.ID,
|
|
||||||
}
|
|
||||||
e := &apimodel.Status{
|
|
||||||
ID: "E",
|
ID: "E",
|
||||||
Account: targetAccount,
|
Account: targetAccount,
|
||||||
InReplyToID: &d.ID,
|
AccountID: targetAccount.ID,
|
||||||
InReplyToAccountID: &d.Account.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",
|
ID: "G",
|
||||||
Account: otherAccount,
|
Account: otherAccount,
|
||||||
InReplyToID: &f.ID,
|
AccountID: otherAccount.ID,
|
||||||
InReplyToAccountID: &f.Account.ID,
|
InReplyToID: f.ID,
|
||||||
|
InReplyToAccountID: f.Account.ID,
|
||||||
}
|
}
|
||||||
i := &apimodel.Status{
|
i := >smodel.Status{
|
||||||
ID: "I",
|
ID: "I",
|
||||||
Account: targetAccount,
|
Account: targetAccount,
|
||||||
InReplyToID: &g.ID,
|
AccountID: targetAccount.ID,
|
||||||
InReplyToAccountID: &g.Account.ID,
|
InReplyToID: g.ID,
|
||||||
|
InReplyToAccountID: g.Account.ID,
|
||||||
}
|
}
|
||||||
h := &apimodel.Status{
|
h := >smodel.Status{
|
||||||
ID: "H",
|
ID: "H",
|
||||||
Account: otherAccount,
|
Account: otherAccount,
|
||||||
InReplyToID: &i.ID,
|
AccountID: otherAccount.ID,
|
||||||
InReplyToAccountID: &i.Account.ID,
|
InReplyToID: i.ID,
|
||||||
|
InReplyToAccountID: i.Account.ID,
|
||||||
}
|
}
|
||||||
|
|
||||||
expected := statusIDs([]*apimodel.Status{f, b, d, e, c, a, g, i, h})
|
expected := statusIDs([]*gtsmodel.Status{f, b, d, e, c, a, g, i, h})
|
||||||
list := []*apimodel.Status{a, b, c, d, e, f, g, h, i}
|
list := []*gtsmodel.Status{a, b, c, d, e, f, g, h, i}
|
||||||
status.TopoSort(list, targetAccount.ID)
|
status.TopoSort(list, targetAccount.ID)
|
||||||
actual := statusIDs(list)
|
actual := statusIDs(list)
|
||||||
|
|
||||||
|
@ -122,13 +131,13 @@ func (suite *topoSortTestSuite) TestBranchedWithSelfReplyChain() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (suite *topoSortTestSuite) TestDisconnected() {
|
func (suite *topoSortTestSuite) TestDisconnected() {
|
||||||
f := &apimodel.Status{ID: "F"}
|
f := >smodel.Status{ID: "F"}
|
||||||
b := &apimodel.Status{ID: "B", InReplyToID: &f.ID}
|
b := >smodel.Status{ID: "B", InReplyToID: f.ID}
|
||||||
dID := "D"
|
dID := "D"
|
||||||
e := &apimodel.Status{ID: "E", InReplyToID: &dID}
|
e := >smodel.Status{ID: "E", InReplyToID: dID}
|
||||||
|
|
||||||
expected := statusIDs([]*apimodel.Status{e, f, b})
|
expected := statusIDs([]*gtsmodel.Status{e, f, b})
|
||||||
list := []*apimodel.Status{b, e, f}
|
list := []*gtsmodel.Status{b, e, f}
|
||||||
status.TopoSort(list, "")
|
status.TopoSort(list, "")
|
||||||
actual := statusIDs(list)
|
actual := statusIDs(list)
|
||||||
|
|
||||||
|
@ -137,10 +146,10 @@ func (suite *topoSortTestSuite) TestDisconnected() {
|
||||||
|
|
||||||
func (suite *topoSortTestSuite) TestTrivialCycle() {
|
func (suite *topoSortTestSuite) TestTrivialCycle() {
|
||||||
xID := "X"
|
xID := "X"
|
||||||
x := &apimodel.Status{ID: xID, InReplyToID: &xID}
|
x := >smodel.Status{ID: xID, InReplyToID: xID}
|
||||||
|
|
||||||
expected := statusIDs([]*apimodel.Status{x})
|
expected := statusIDs([]*gtsmodel.Status{x})
|
||||||
list := []*apimodel.Status{x}
|
list := []*gtsmodel.Status{x}
|
||||||
status.TopoSort(list, "")
|
status.TopoSort(list, "")
|
||||||
actual := statusIDs(list)
|
actual := statusIDs(list)
|
||||||
|
|
||||||
|
@ -149,11 +158,11 @@ func (suite *topoSortTestSuite) TestTrivialCycle() {
|
||||||
|
|
||||||
func (suite *topoSortTestSuite) TestCycle() {
|
func (suite *topoSortTestSuite) TestCycle() {
|
||||||
yID := "Y"
|
yID := "Y"
|
||||||
x := &apimodel.Status{ID: "X", InReplyToID: &yID}
|
x := >smodel.Status{ID: "X", InReplyToID: yID}
|
||||||
y := &apimodel.Status{ID: yID, InReplyToID: &x.ID}
|
y := >smodel.Status{ID: yID, InReplyToID: x.ID}
|
||||||
|
|
||||||
expected := statusIDs([]*apimodel.Status{x, y})
|
expected := statusIDs([]*gtsmodel.Status{x, y})
|
||||||
list := []*apimodel.Status{x, y}
|
list := []*gtsmodel.Status{x, y}
|
||||||
status.TopoSort(list, "")
|
status.TopoSort(list, "")
|
||||||
actual := statusIDs(list)
|
actual := statusIDs(list)
|
||||||
|
|
||||||
|
@ -162,12 +171,12 @@ func (suite *topoSortTestSuite) TestCycle() {
|
||||||
|
|
||||||
func (suite *topoSortTestSuite) TestMixedCycle() {
|
func (suite *topoSortTestSuite) TestMixedCycle() {
|
||||||
yID := "Y"
|
yID := "Y"
|
||||||
x := &apimodel.Status{ID: "X", InReplyToID: &yID}
|
x := >smodel.Status{ID: "X", InReplyToID: yID}
|
||||||
y := &apimodel.Status{ID: yID, InReplyToID: &x.ID}
|
y := >smodel.Status{ID: yID, InReplyToID: x.ID}
|
||||||
z := &apimodel.Status{ID: "Z"}
|
z := >smodel.Status{ID: "Z"}
|
||||||
|
|
||||||
expected := statusIDs([]*apimodel.Status{x, y, z})
|
expected := statusIDs([]*gtsmodel.Status{x, y, z})
|
||||||
list := []*apimodel.Status{x, y, z}
|
list := []*gtsmodel.Status{x, y, z}
|
||||||
status.TopoSort(list, "")
|
status.TopoSort(list, "")
|
||||||
actual := statusIDs(list)
|
actual := statusIDs(list)
|
||||||
|
|
||||||
|
@ -175,8 +184,8 @@ func (suite *topoSortTestSuite) TestMixedCycle() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (suite *topoSortTestSuite) TestEmpty() {
|
func (suite *topoSortTestSuite) TestEmpty() {
|
||||||
expected := statusIDs([]*apimodel.Status{})
|
expected := statusIDs([]*gtsmodel.Status{})
|
||||||
list := []*apimodel.Status{}
|
list := []*gtsmodel.Status{}
|
||||||
status.TopoSort(list, "")
|
status.TopoSort(list, "")
|
||||||
actual := statusIDs(list)
|
actual := statusIDs(list)
|
||||||
|
|
||||||
|
@ -185,7 +194,7 @@ func (suite *topoSortTestSuite) TestEmpty() {
|
||||||
|
|
||||||
func (suite *topoSortTestSuite) TestNil() {
|
func (suite *topoSortTestSuite) TestNil() {
|
||||||
expected := statusIDs(nil)
|
expected := statusIDs(nil)
|
||||||
var list []*apimodel.Status
|
var list []*gtsmodel.Status
|
||||||
status.TopoSort(list, "")
|
status.TopoSort(list, "")
|
||||||
actual := statusIDs(list)
|
actual := statusIDs(list)
|
||||||
|
|
|
@ -19,13 +19,8 @@
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
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/gtserror"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||||
|
@ -113,199 +108,3 @@ func (p *Processor) SourceGet(ctx context.Context, requestingAccount *gtsmodel.A
|
||||||
}
|
}
|
||||||
return statusSource, nil
|
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)
|
|
||||||
}
|
|
||||||
|
|
|
@ -982,13 +982,23 @@ func filterAppliesInContext(filter *gtsmodel.Filter, filterContext statusfilter.
|
||||||
func (c *Converter) StatusToWebStatus(
|
func (c *Converter) StatusToWebStatus(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
s *gtsmodel.Status,
|
s *gtsmodel.Status,
|
||||||
requestingAccount *gtsmodel.Account,
|
) (*apimodel.WebStatus, error) {
|
||||||
) (*apimodel.Status, error) {
|
apiStatus, err := c.statusToFrontend(
|
||||||
webStatus, err := c.statusToFrontend(ctx, s, requestingAccount, statusfilter.FilterContextNone, nil, nil)
|
ctx,
|
||||||
|
s,
|
||||||
|
nil, // No authed requester.
|
||||||
|
statusfilter.FilterContextNone,
|
||||||
|
nil, // No filters.
|
||||||
|
nil, // No mutes.
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
webStatus := &apimodel.WebStatus{
|
||||||
|
Status: apiStatus,
|
||||||
|
}
|
||||||
|
|
||||||
// Whack a newline before and after each "pre" to make it easier to outdent it.
|
// Whack a newline before and after each "pre" to make it easier to outdent it.
|
||||||
webStatus.Content = strings.ReplaceAll(webStatus.Content, "<pre>", "\n<pre>")
|
webStatus.Content = strings.ReplaceAll(webStatus.Content, "<pre>", "\n<pre>")
|
||||||
webStatus.Content = strings.ReplaceAll(webStatus.Content, "</pre>", "</pre>\n")
|
webStatus.Content = strings.ReplaceAll(webStatus.Content, "</pre>", "</pre>\n")
|
||||||
|
@ -1014,7 +1024,7 @@ func (c *Converter) StatusToWebStatus(
|
||||||
// format them for easier template consumption.
|
// format them for easier template consumption.
|
||||||
totalVotes := poll.VotesCount
|
totalVotes := poll.VotesCount
|
||||||
|
|
||||||
webPollOptions := make([]apimodel.WebPollOption, len(poll.Options))
|
PollOptions := make([]apimodel.WebPollOption, len(poll.Options))
|
||||||
for i, option := range poll.Options {
|
for i, option := range poll.Options {
|
||||||
var voteShare float32
|
var voteShare float32
|
||||||
|
|
||||||
|
@ -1046,10 +1056,10 @@ func (c *Converter) StatusToWebStatus(
|
||||||
VoteShare: voteShare,
|
VoteShare: voteShare,
|
||||||
VoteShareStr: voteShareStr,
|
VoteShareStr: voteShareStr,
|
||||||
}
|
}
|
||||||
webPollOptions[i] = webPollOption
|
PollOptions[i] = webPollOption
|
||||||
}
|
}
|
||||||
|
|
||||||
webStatus.WebPollOptions = webPollOptions
|
webStatus.PollOptions = PollOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set additional templating
|
// Set additional templating
|
||||||
|
@ -1058,6 +1068,7 @@ func (c *Converter) StatusToWebStatus(
|
||||||
a.Sensitive = webStatus.Sensitive
|
a.Sensitive = webStatus.Sensitive
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mark this as a local status.
|
||||||
webStatus.Local = *s.Local
|
webStatus.Local = *s.Local
|
||||||
|
|
||||||
return webStatus, nil
|
return webStatus, nil
|
||||||
|
|
|
@ -883,9 +883,8 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendUnknownAttachments
|
||||||
|
|
||||||
func (suite *InternalToFrontendTestSuite) TestStatusToWebStatus() {
|
func (suite *InternalToFrontendTestSuite) TestStatusToWebStatus() {
|
||||||
testStatus := suite.testStatuses["remote_account_2_status_1"]
|
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)
|
suite.NoError(err)
|
||||||
|
|
||||||
// MediaAttachments should inherit
|
// MediaAttachments should inherit
|
||||||
|
@ -1010,7 +1009,12 @@ func (suite *InternalToFrontendTestSuite) TestStatusToWebStatus() {
|
||||||
"tags": [],
|
"tags": [],
|
||||||
"emojis": [],
|
"emojis": [],
|
||||||
"card": null,
|
"card": null,
|
||||||
"poll": null
|
"poll": null,
|
||||||
|
"LanguageTag": "en",
|
||||||
|
"PollOptions": null,
|
||||||
|
"Local": false,
|
||||||
|
"Indent": 0,
|
||||||
|
"ThreadFirstReply": false
|
||||||
}`, string(b))
|
}`, string(b))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -111,7 +111,7 @@ func (m *Module) profileGETHandler(c *gin.Context) {
|
||||||
var (
|
var (
|
||||||
maxStatusID = apiutil.ParseMaxID(c.Query(apiutil.MaxIDKey), "")
|
maxStatusID = apiutil.ParseMaxID(c.Query(apiutil.MaxIDKey), "")
|
||||||
paging = maxStatusID != ""
|
paging = maxStatusID != ""
|
||||||
pinnedStatuses []*apimodel.Status
|
pinnedStatuses []*apimodel.WebStatus
|
||||||
)
|
)
|
||||||
|
|
||||||
if !paging {
|
if !paging {
|
||||||
|
|
|
@ -20,7 +20,6 @@
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
@ -101,34 +100,20 @@ func (m *Module) threadGETHandler(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the status itself from the processor using provided ID and authorization (if any).
|
// Get the thread context. This will fetch the target status as well.
|
||||||
status, errWithCode := m.processor.Status().WebGet(ctx, targetStatusID)
|
context, errWithCode := m.processor.Status().WebContextGet(ctx, targetStatusID)
|
||||||
if errWithCode != nil {
|
if errWithCode != nil {
|
||||||
apiutil.WebErrorHandler(c, errWithCode, instanceGet)
|
apiutil.WebErrorHandler(c, errWithCode, instanceGet)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure status actually belongs to target account.
|
// 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)
|
err := fmt.Errorf("target account %s does not own status %s", targetUsername, targetStatusID)
|
||||||
apiutil.WebErrorHandler(c, gtserror.NewErrorNotFound(err), instanceGet)
|
apiutil.WebErrorHandler(c, gtserror.NewErrorNotFound(err), instanceGet)
|
||||||
return
|
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.
|
// Prepare stylesheets for thread.
|
||||||
stylesheets := make([]string, 0, 5)
|
stylesheets := make([]string, 0, 5)
|
||||||
|
|
||||||
|
@ -159,11 +144,10 @@ func (m *Module) threadGETHandler(c *gin.Context) {
|
||||||
page := apiutil.WebPage{
|
page := apiutil.WebPage{
|
||||||
Template: "thread.tmpl",
|
Template: "thread.tmpl",
|
||||||
Instance: instance,
|
Instance: instance,
|
||||||
OGMeta: apiutil.OGBase(instance).WithStatus(status),
|
OGMeta: apiutil.OGBase(instance).WithStatus(context.Status),
|
||||||
Stylesheets: stylesheets,
|
Stylesheets: stylesheets,
|
||||||
Javascript: []string{jsFrontend},
|
Javascript: []string{jsFrontend},
|
||||||
Extra: map[string]any{
|
Extra: map[string]any{
|
||||||
"status": status,
|
|
||||||
"context": context,
|
"context": context,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,11 +17,14 @@
|
||||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
.thread {
|
.thread,
|
||||||
|
.thread-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread {
|
||||||
/*
|
/*
|
||||||
This column header might contain
|
This column header might contain
|
||||||
quite some info, so let it wrap.
|
quite some info, so let it wrap.
|
||||||
|
@ -42,8 +45,40 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.status {
|
.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 {
|
&:last-child {
|
||||||
border-bottom-left-radius: $br;
|
border-bottom-left-radius: $br;
|
||||||
border-bottom-right-radius: $br;
|
border-bottom-right-radius: $br;
|
||||||
|
|
|
@ -58,7 +58,7 @@
|
||||||
</span>
|
</span>
|
||||||
</figcaption>
|
</figcaption>
|
||||||
<ul class="poll-options nodot">
|
<ul class="poll-options nodot">
|
||||||
{{- range $index, $pollOption := .WebPollOptions }}
|
{{- range $index, $pollOption := .PollOptions }}
|
||||||
<li class="poll-option">
|
<li class="poll-option">
|
||||||
<span class="sr-only">Option {{ increment $index }},</span>
|
<span class="sr-only">Option {{ increment $index }},</span>
|
||||||
<span lang="{{- .LanguageTag.TagStr -}}">{{ emojify .Emojis (noescape $pollOption.Title) }}</span>
|
<span lang="{{- .LanguageTag.TagStr -}}">{{ emojify .Emojis (noescape $pollOption.Title) }}</span>
|
||||||
|
|
|
@ -17,45 +17,103 @@
|
||||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/ -}}
|
*/ -}}
|
||||||
|
|
||||||
{{- define "threadLength" -}}
|
{{- define "repliesSummary" -}}
|
||||||
{{- with $length := add (len $.context.Ancestors) (len $.context.Descendants) | increment -}}
|
{{- if .context.ThreadRepliesShown -}}
|
||||||
{{- if eq $length 1 -}}
|
{{- if .context.ThreadRepliesHidden -}}
|
||||||
{{- $length }} post
|
{{- 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 -}}
|
{{- 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 -}}
|
{{- 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 . }}
|
{{- with . }}
|
||||||
<main data-nosnippet class="thread" aria-labelledby="thread-summary">
|
</section>
|
||||||
<div class="col-header">
|
<section class="thread thread-replies" aria-labelledby="replies" open>
|
||||||
<h2 id="thread-summary">Thread with {{ template "threadLength" . -}}</h2>
|
<div class="col-header replies">
|
||||||
<a href="#{{- .status.ID -}}">jump to expanded post</a>
|
<h2 id="replies">{{- template "repliesSummary" . -}}</h2>
|
||||||
|
<a href="#thread-summary">back to top</a>
|
||||||
</div>
|
</div>
|
||||||
{{- range .context.Ancestors }}
|
{{- end }}
|
||||||
<article
|
{{- end -}}
|
||||||
class="status"
|
|
||||||
{{- includeAttr "status_attributes.tmpl" . | indentAttr 2 }}
|
{{- with . }}
|
||||||
>
|
<main class="thread-wrapper" data-nosnippet>
|
||||||
{{- include "status.tmpl" . | indent 2 }}
|
<section class="thread thread-main" aria-labelledby="thread-summary">
|
||||||
</article>
|
<div class="col-header">
|
||||||
{{- end }}
|
<h2 id="thread-summary">{{- template "threadSummary" . -}}</h2>
|
||||||
{{- with .status }}
|
{{- if .context.ThreadRepliesShown }}
|
||||||
<article
|
<a href="#replies">jump to replies</a>
|
||||||
class="status expanded"
|
{{- end }}
|
||||||
{{- includeAttr "status_attributes.tmpl" . | indentAttr 2 }}
|
</div>
|
||||||
>
|
|
||||||
{{- include "status.tmpl" . | indent 2 }}
|
{{- range $thisStatus := .context.Ancestors }}
|
||||||
</article>
|
{{- if $thisStatus.ThreadFirstReply }}
|
||||||
{{- end }}
|
{{- include "repliesStart" $ | indent 1 }}
|
||||||
{{- range .context.Descendants }}
|
{{- end }}
|
||||||
<article
|
<article
|
||||||
class="status"
|
class="status{{- if $thisStatus.Indent }} indent-{{ $thisStatus.Indent }}{{- end -}}"
|
||||||
{{- includeAttr "status_attributes.tmpl" . | indentAttr 2 }}
|
{{- includeAttr "status_attributes.tmpl" $thisStatus | indentAttr 3 }}
|
||||||
>
|
>
|
||||||
{{- include "status.tmpl" . | indent 2 }}
|
{{- include "status.tmpl" $thisStatus | indent 3 }}
|
||||||
</article>
|
</article>
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{- with $thisStatus := .context.Status }}
|
||||||
|
{{- if $thisStatus.ThreadFirstReply }}
|
||||||
|
{{- include "repliesStart" $ | indent 1 }}
|
||||||
|
{{- end }}
|
||||||
|
<article
|
||||||
|
class="status expanded{{- if $thisStatus.Indent }} indent-{{ $thisStatus.Indent }}{{- end -}}"
|
||||||
|
{{- includeAttr "status_attributes.tmpl" $thisStatus | indentAttr 3 }}
|
||||||
|
>
|
||||||
|
{{- include "status.tmpl" $thisStatus | indent 3 }}
|
||||||
|
</article>
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{- range $thisStatus := .context.Descendants }}
|
||||||
|
{{- if $thisStatus.ThreadFirstReply }}
|
||||||
|
{{- include "repliesStart" $ | indent 1 }}
|
||||||
|
{{- end }}
|
||||||
|
<article
|
||||||
|
class="status{{- if $thisStatus.Indent }} indent-{{ $thisStatus.Indent }}{{- end -}}"
|
||||||
|
{{- includeAttr "status_attributes.tmpl" $thisStatus | indentAttr 3 }}
|
||||||
|
>
|
||||||
|
{{- include "status.tmpl" $thisStatus | indent 3 }}
|
||||||
|
</article>
|
||||||
|
{{- end }}
|
||||||
|
{{- if .context.ThreadReplies }}
|
||||||
|
</section>
|
||||||
{{- end }}
|
{{- end }}
|
||||||
</main>
|
</main>
|
||||||
{{- end }}
|
{{- end }}
|
Loading…
Reference in a new issue