[bugfix] Store and expose status content type (#3870)

* Add ContentType to internal models

* Add ContentType to API models StatusSource and StatusEdit

* Add helpers to convert between API/internal StatusContentType

* Write status content type on create/edit

* Add migration

* Update API docs

go run github.com/go-swagger/go-swagger/cmd/swagger generate spec --scan-models --exclude-deps --output docs/api/swagger.yaml

* ensure ContentType is updated anywhere Text is

* Update docs, take care of TODOs

* Set ContentType in more places where Text is set

* We don't actually use ContentType on the API status model

* Update StatusSource test

* Remove unused helper function I copied

* Revert change to StatusContentType swagger annotation

I'm going to include this in a follow-on PR instead.

* Add test for updating content type in edits

* Return a value from processContentType instead of modifying the existing status

Fixes an issue that was caught by the test I just added - the recorded edit would be marked with the *new* content type instead of the old one, which is obviously bad

* Add test for handling of statuses with no stored content type

* repurpose an existing test status instead of adding a new one to avoid breaking other tests

* Add test to ensure newly created statuses always have content type saved

* Do include content type on status API model actually

This is mostly important when deleting and redrafting.

The comment on `apimodel.Status.Text` implies that it's not sent except in response to status deletion, but actually this doesn't seem to be the case; it also appears to be present in responses to creations and normal fetches and stuff. So I'm treating `ContentType` the same here.

* Update new tests to check content type on API statuses

* Check content type of API statuses in all tests where text is checked

* update other api tests with status content type field

* Add test ensuring text and content type are returned when deleting a status

* Convert processContentType to free function and remove unused parameter

* check for the correct value in the deletion test

* Be explicit about this test status having an empty content type

* Use omitempty consistently on API models

* clean up the final diff a bit

* one more swagger regen for the road

* Handle nil statuses in processContentType

* Don't pass processContentType the entire edit form, it doesn't need it

* Move processContentType to common.go and use for creation as well

* Remove unused parameters to ContentTypeToAPIContentType
This commit is contained in:
ewwwin 2025-03-06 11:31:52 -05:00 committed by GitHub
parent 69461c461b
commit 424f62dd70
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 560 additions and 14 deletions

View file

@ -2840,6 +2840,13 @@ definitions:
example: <p>Hey this is a status!</p> example: <p>Hey this is a status!</p>
type: string type: string
x-go-name: Content x-go-name: Content
content_type:
description: |-
Content type that was used to parse the status's text. Returned when
status is deleted, so if the user is redrafting the message the client
can default to the same content type.
type: string
x-go-name: ContentType
created_at: created_at:
description: The date when this status was created (ISO 8601 Datetime). description: The date when this status was created (ISO 8601 Datetime).
example: "2021-07-30T09:20:25+00:00" example: "2021-07-30T09:20:25+00:00"
@ -3042,6 +3049,13 @@ definitions:
example: <p>Hey this is a status!</p> example: <p>Hey this is a status!</p>
type: string type: string
x-go-name: Content x-go-name: Content
content_type:
description: |-
Content type that was used to parse the status's text. Returned when
status is deleted, so if the user is redrafting the message the client
can default to the same content type.
type: string
x-go-name: ContentType
created_at: created_at:
description: The date when this status was created (ISO 8601 Datetime). description: The date when this status was created (ISO 8601 Datetime).
example: "2021-07-30T09:20:25+00:00" example: "2021-07-30T09:20:25+00:00"
@ -3186,6 +3200,10 @@ definitions:
StatusSource represents the source text of a StatusSource represents the source text of a
status as submitted to the API when it was created. status as submitted to the API when it was created.
properties: properties:
content_type:
description: Content type that was used to parse the text.
type: string
x-go-name: ContentType
id: id:
description: ID of the status. description: ID of the status.
example: 01FBVD42CQ3ZEEVMW180SBX03B example: 01FBVD42CQ3ZEEVMW180SBX03B

View file

@ -145,6 +145,7 @@ func (suite *StatusBoostTestSuite) TestPostBoost() {
"bookmarked": true, "bookmarked": true,
"card": null, "card": null,
"content": "hello world! #welcome ! first post on the instance :rainbow: !", "content": "hello world! #welcome ! first post on the instance :rainbow: !",
"content_type": "text/plain",
"created_at": "right the hell just now babyee", "created_at": "right the hell just now babyee",
"edited_at": null, "edited_at": null,
"emojis": [ "emojis": [
@ -331,6 +332,7 @@ func (suite *StatusBoostTestSuite) TestPostBoostOwnFollowersOnly() {
"bookmarked": false, "bookmarked": false,
"card": null, "card": null,
"content": "hi!", "content": "hi!",
"content_type": "text/plain",
"created_at": "right the hell just now babyee", "created_at": "right the hell just now babyee",
"edited_at": null, "edited_at": null,
"emojis": [], "emojis": [],
@ -543,6 +545,7 @@ func (suite *StatusBoostTestSuite) TestPostBoostImplicitAccept() {
"bookmarked": false, "bookmarked": false,
"card": null, "card": null,
"content": "<p>Hi <span class=\"h-card\"><a href=\"http://localhost:8080/@1happyturtle\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>1happyturtle</span></a></span>, can I reply?</p>", "content": "<p>Hi <span class=\"h-card\"><a href=\"http://localhost:8080/@1happyturtle\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>1happyturtle</span></a></span>, can I reply?</p>",
"content_type": "text/markdown",
"created_at": "right the hell just now babyee", "created_at": "right the hell just now babyee",
"edited_at": null, "edited_at": null,
"emojis": [], "emojis": [],

View file

@ -139,6 +139,7 @@ func (suite *StatusCreateTestSuite) TestPostNewStatus() {
"bookmarked": false, "bookmarked": false,
"card": null, "card": null,
"content": "<p>this is a brand new status! <a href=\"http://localhost:8080/tags/helloworld\" class=\"mention hashtag\" rel=\"tag nofollow noreferrer noopener\" target=\"_blank\">#<span>helloworld</span></a></p>", "content": "<p>this is a brand new status! <a href=\"http://localhost:8080/tags/helloworld\" class=\"mention hashtag\" rel=\"tag nofollow noreferrer noopener\" target=\"_blank\">#<span>helloworld</span></a></p>",
"content_type": "text/plain",
"created_at": "right the hell just now babyee", "created_at": "right the hell just now babyee",
"edited_at": null, "edited_at": null,
"emojis": [], "emojis": [],
@ -225,6 +226,7 @@ func (suite *StatusCreateTestSuite) TestPostNewStatusIntPolicy() {
"bookmarked": false, "bookmarked": false,
"card": null, "card": null,
"content": "<p>this is a brand new status! <a href=\"http://localhost:8080/tags/helloworld\" class=\"mention hashtag\" rel=\"tag nofollow noreferrer noopener\" target=\"_blank\">#<span>helloworld</span></a></p>", "content": "<p>this is a brand new status! <a href=\"http://localhost:8080/tags/helloworld\" class=\"mention hashtag\" rel=\"tag nofollow noreferrer noopener\" target=\"_blank\">#<span>helloworld</span></a></p>",
"content_type": "text/plain",
"created_at": "right the hell just now babyee", "created_at": "right the hell just now babyee",
"edited_at": null, "edited_at": null,
"emojis": [], "emojis": [],
@ -321,6 +323,7 @@ func (suite *StatusCreateTestSuite) TestPostNewStatusIntPolicyJSON() {
"bookmarked": false, "bookmarked": false,
"card": null, "card": null,
"content": "<p>this is a brand new status! <a href=\"http://localhost:8080/tags/helloworld\" class=\"mention hashtag\" rel=\"tag nofollow noreferrer noopener\" target=\"_blank\">#<span>helloworld</span></a></p>", "content": "<p>this is a brand new status! <a href=\"http://localhost:8080/tags/helloworld\" class=\"mention hashtag\" rel=\"tag nofollow noreferrer noopener\" target=\"_blank\">#<span>helloworld</span></a></p>",
"content_type": "text/plain",
"created_at": "right the hell just now babyee", "created_at": "right the hell just now babyee",
"edited_at": null, "edited_at": null,
"emojis": [], "emojis": [],
@ -535,6 +538,7 @@ func (suite *StatusCreateTestSuite) TestPostNewStatusMarkdown() {
"bookmarked": false, "bookmarked": false,
"card": null, "card": null,
"content": "<h1>Title</h1><h2>Smaller title</h2><p>This is a post written in <a href=\"https://www.markdownguide.org/\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">markdown</a></p>", "content": "<h1>Title</h1><h2>Smaller title</h2><p>This is a post written in <a href=\"https://www.markdownguide.org/\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">markdown</a></p>",
"content_type": "text/markdown",
"created_at": "right the hell just now babyee", "created_at": "right the hell just now babyee",
"edited_at": null, "edited_at": null,
"emojis": [], "emojis": [],
@ -619,6 +623,7 @@ func (suite *StatusCreateTestSuite) TestMentionUnknownAccount() {
"bookmarked": false, "bookmarked": false,
"card": null, "card": null,
"content": "<p>hello <span class=\"h-card\"><a href=\"https://unknown-instance.com/@brand_new_person\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>brand_new_person</span></a></span></p>", "content": "<p>hello <span class=\"h-card\"><a href=\"https://unknown-instance.com/@brand_new_person\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>brand_new_person</span></a></span></p>",
"content_type": "text/plain",
"created_at": "right the hell just now babyee", "created_at": "right the hell just now babyee",
"edited_at": null, "edited_at": null,
"emojis": [], "emojis": [],
@ -697,6 +702,7 @@ func (suite *StatusCreateTestSuite) TestPostStatusWithLinksAndTags() {
"bookmarked": false, "bookmarked": false,
"card": null, "card": null,
"content": "<p><a href=\"http://localhost:8080/tags/test\" class=\"mention hashtag\" rel=\"tag nofollow noreferrer noopener\" target=\"_blank\">#<span>test</span></a> alright, should be able to post <a href=\"http://localhost:8080/tags/links\" class=\"mention hashtag\" rel=\"tag nofollow noreferrer noopener\" target=\"_blank\">#<span>links</span></a> with fragments in them now, let's see........<br><br><a href=\"https://docs.gotosocial.org/en/latest/user_guide/posts/#links\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">https://docs.gotosocial.org/en/latest/user_guide/posts/#links</a><br><br><a href=\"http://localhost:8080/tags/gotosocial\" class=\"mention hashtag\" rel=\"tag nofollow noreferrer noopener\" target=\"_blank\">#<span>gotosocial</span></a><br><br>(tobi remember to pull the docker image challenge)</p>", "content": "<p><a href=\"http://localhost:8080/tags/test\" class=\"mention hashtag\" rel=\"tag nofollow noreferrer noopener\" target=\"_blank\">#<span>test</span></a> alright, should be able to post <a href=\"http://localhost:8080/tags/links\" class=\"mention hashtag\" rel=\"tag nofollow noreferrer noopener\" target=\"_blank\">#<span>links</span></a> with fragments in them now, let's see........<br><br><a href=\"https://docs.gotosocial.org/en/latest/user_guide/posts/#links\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">https://docs.gotosocial.org/en/latest/user_guide/posts/#links</a><br><br><a href=\"http://localhost:8080/tags/gotosocial\" class=\"mention hashtag\" rel=\"tag nofollow noreferrer noopener\" target=\"_blank\">#<span>gotosocial</span></a><br><br>(tobi remember to pull the docker image challenge)</p>",
"content_type": "text/plain",
"created_at": "right the hell just now babyee", "created_at": "right the hell just now babyee",
"edited_at": null, "edited_at": null,
"emojis": [], "emojis": [],
@ -781,6 +787,7 @@ func (suite *StatusCreateTestSuite) TestPostNewStatusWithEmoji() {
"bookmarked": false, "bookmarked": false,
"card": null, "card": null,
"content": "<p>here is a rainbow emoji a few times! :rainbow: :rainbow: :rainbow:<br>here's an emoji that isn't in the db: :test_emoji:</p>", "content": "<p>here is a rainbow emoji a few times! :rainbow: :rainbow: :rainbow:<br>here's an emoji that isn't in the db: :test_emoji:</p>",
"content_type": "text/plain",
"created_at": "right the hell just now babyee", "created_at": "right the hell just now babyee",
"edited_at": null, "edited_at": null,
"emojis": [ "emojis": [
@ -879,6 +886,7 @@ func (suite *StatusCreateTestSuite) TestReplyToLocalStatus() {
"bookmarked": false, "bookmarked": false,
"card": null, "card": null,
"content": "<p>hello <span class=\"h-card\"><a href=\"http://localhost:8080/@1happyturtle\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>1happyturtle</span></a></span> this reply should work!</p>", "content": "<p>hello <span class=\"h-card\"><a href=\"http://localhost:8080/@1happyturtle\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>1happyturtle</span></a></span> this reply should work!</p>",
"content_type": "text/plain",
"created_at": "right the hell just now babyee", "created_at": "right the hell just now babyee",
"edited_at": null, "edited_at": null,
"emojis": [], "emojis": [],
@ -962,6 +970,7 @@ func (suite *StatusCreateTestSuite) TestAttachNewMediaSuccess() {
"bookmarked": false, "bookmarked": false,
"card": null, "card": null,
"content": "<p>here's an image attachment</p>", "content": "<p>here's an image attachment</p>",
"content_type": "text/plain",
"created_at": "right the hell just now babyee", "created_at": "right the hell just now babyee",
"edited_at": null, "edited_at": null,
"emojis": [], "emojis": [],
@ -1067,6 +1076,7 @@ func (suite *StatusCreateTestSuite) TestPostNewStatusWithNoncanonicalLanguageTag
"bookmarked": false, "bookmarked": false,
"card": null, "card": null,
"content": "<p>English? what's English? i speak American</p>", "content": "<p>English? what's English? i speak American</p>",
"content_type": "text/plain",
"created_at": "right the hell just now babyee", "created_at": "right the hell just now babyee",
"edited_at": null, "edited_at": null,
"emojis": [], "emojis": [],
@ -1142,6 +1152,7 @@ func (suite *StatusCreateTestSuite) TestPostNewStatusWithPollForm() {
"bookmarked": false, "bookmarked": false,
"card": null, "card": null,
"content": "<p>this is a status with a poll!</p>", "content": "<p>this is a status with a poll!</p>",
"content_type": "text/plain",
"created_at": "right the hell just now babyee", "created_at": "right the hell just now babyee",
"edited_at": null, "edited_at": null,
"emojis": [], "emojis": [],
@ -1239,6 +1250,7 @@ func (suite *StatusCreateTestSuite) TestPostNewStatusWithPollJSON() {
"bookmarked": false, "bookmarked": false,
"card": null, "card": null,
"content": "<p>this is a status with a poll!</p>", "content": "<p>this is a status with a poll!</p>",
"content_type": "text/plain",
"created_at": "right the hell just now babyee", "created_at": "right the hell just now babyee",
"edited_at": null, "edited_at": null,
"emojis": [], "emojis": [],

View file

@ -77,6 +77,10 @@ func (suite *StatusDeleteTestSuite) TestPostDelete() {
suite.NoError(err) suite.NoError(err)
suite.NotNil(statusReply) suite.NotNil(statusReply)
// Check that text and content type are returned for delete and redraft
suite.Equal("hello everyone!", statusReply.Text)
suite.Equal(apimodel.StatusContentTypePlain, statusReply.ContentType)
if !testrig.WaitFor(func() bool { if !testrig.WaitFor(func() bool {
_, err := suite.db.GetStatusByID(ctx, targetStatus.ID) _, err := suite.db.GetStatusByID(ctx, targetStatus.ID)
return errors.Is(err, db.ErrNoEntries) return errors.Is(err, db.ErrNoEntries)

View file

@ -104,6 +104,7 @@ func (suite *StatusFaveTestSuite) TestPostFave() {
"bookmarked": false, "bookmarked": false,
"card": null, "card": null,
"content": "🐕🐕🐕🐕🐕", "content": "🐕🐕🐕🐕🐕",
"content_type": "text/plain",
"created_at": "right the hell just now babyee", "created_at": "right the hell just now babyee",
"edited_at": null, "edited_at": null,
"emojis": [], "emojis": [],
@ -228,6 +229,7 @@ func (suite *StatusFaveTestSuite) TestPostFaveImplicitAccept() {
"bookmarked": false, "bookmarked": false,
"card": null, "card": null,
"content": "<p>Hi <span class=\"h-card\"><a href=\"http://localhost:8080/@1happyturtle\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>1happyturtle</span></a></span>, can I reply?</p>", "content": "<p>Hi <span class=\"h-card\"><a href=\"http://localhost:8080/@1happyturtle\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>1happyturtle</span></a></span>, can I reply?</p>",
"content_type": "text/markdown",
"created_at": "right the hell just now babyee", "created_at": "right the hell just now babyee",
"edited_at": null, "edited_at": null,
"emojis": [], "emojis": [],

View file

@ -149,6 +149,7 @@ func (suite *StatusMuteTestSuite) TestMuteUnmuteStatus() {
"card": null, "card": null,
"poll": null, "poll": null,
"text": "hello everyone!", "text": "hello everyone!",
"content_type": "text/plain",
"interaction_policy": { "interaction_policy": {
"can_favourite": { "can_favourite": {
"always": [ "always": [
@ -238,6 +239,7 @@ func (suite *StatusMuteTestSuite) TestMuteUnmuteStatus() {
"card": null, "card": null,
"poll": null, "poll": null,
"text": "hello everyone!", "text": "hello everyone!",
"content_type": "text/plain",
"interaction_policy": { "interaction_policy": {
"can_favourite": { "can_favourite": {
"always": [ "always": [

View file

@ -92,7 +92,8 @@ func (suite *StatusSourceTestSuite) TestGetSource() {
suite.Equal(`{ suite.Equal(`{
"id": "01F8MHAMCHF6Y650WCRSCP4WMY", "id": "01F8MHAMCHF6Y650WCRSCP4WMY",
"text": "hello everyone!", "text": "hello everyone!",
"spoiler_text": "introduction post" "spoiler_text": "introduction post",
"content_type": "text/plain"
}`, dst.String()) }`, dst.String())
} }

View file

@ -110,6 +110,10 @@ type Status struct {
// so the user may redraft from the source text without the client having to reverse-engineer // so the user may redraft from the source text without the client having to reverse-engineer
// the original text from the HTML content. // the original text from the HTML content.
Text string `json:"text,omitempty"` Text string `json:"text,omitempty"`
// Content type that was used to parse the status's text. Returned when
// status is deleted, so if the user is redrafting the message the client
// can default to the same content type.
ContentType StatusContentType `json:"content_type,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"`
// The interaction policy for this status, as set by the status author. // The interaction policy for this status, as set by the status author.
@ -320,6 +324,9 @@ type StatusSource struct {
// Plain-text version of spoiler text. // Plain-text version of spoiler text.
SpoilerText string `json:"spoiler_text"` SpoilerText string `json:"spoiler_text"`
// Content type that was used to parse the text.
ContentType StatusContentType `json:"content_type,omitempty"`
} }
// StatusEdit represents one historical revision of a status, containing // StatusEdit represents one historical revision of a status, containing

View file

@ -649,6 +649,7 @@ func sizeofStatus() uintptr {
URL: exampleURI, URL: exampleURI,
Content: exampleText, Content: exampleText,
Text: exampleText, Text: exampleText,
ContentType: gtsmodel.StatusContentTypePlain,
AttachmentIDs: []string{exampleID, exampleID, exampleID}, AttachmentIDs: []string{exampleID, exampleID, exampleID},
TagIDs: []string{exampleID, exampleID, exampleID}, TagIDs: []string{exampleID, exampleID, exampleID},
MentionIDs: []string{}, MentionIDs: []string{},
@ -694,6 +695,7 @@ func sizeofStatusEdit() uintptr {
Content: exampleText, Content: exampleText,
ContentWarning: exampleUsername, // similar length ContentWarning: exampleUsername, // similar length
Text: exampleText, Text: exampleText,
ContentType: gtsmodel.StatusContentTypePlain,
Language: "en", Language: "en",
Sensitive: func() *bool { ok := false; return &ok }(), Sensitive: func() *bool { ok := false; return &ok }(),
AttachmentIDs: []string{exampleID, exampleID, exampleID}, AttachmentIDs: []string{exampleID, exampleID, exampleID},

View file

@ -0,0 +1,78 @@
// 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 migrations
import (
"context"
"reflect"
gtsmodel "github.com/superseriousbusiness/gotosocial/internal/db/bundb/migrations/20250226013442_add_status_content_type"
"github.com/uptrace/bun"
)
func init() {
up := func(ctx context.Context, db *bun.DB) error {
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
// Generate new Status.ContentType column definition from bun.
statusType := reflect.TypeOf((*gtsmodel.Status)(nil))
colDef, err := getBunColumnDef(tx, statusType, "ContentType")
if err != nil {
return err
}
// Add column to Status table.
_, err = tx.NewAddColumn().
Model((*gtsmodel.Status)(nil)).
ColumnExpr(colDef).
Exec(ctx)
if err != nil {
return err
}
// same for StatusEdit
statusEditType := reflect.TypeOf((*gtsmodel.StatusEdit)(nil))
colDef, err = getBunColumnDef(tx, statusEditType, "ContentType")
if err != nil {
return err
}
_, err = tx.NewAddColumn().
Model((*gtsmodel.StatusEdit)(nil)).
ColumnExpr(colDef).
Exec(ctx)
if err != nil {
return err
}
return nil
})
}
down := func(ctx context.Context, db *bun.DB) error {
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
return nil
})
}
if err := Migrations.Register(up, down); err != nil {
panic(err)
}
}

View file

@ -0,0 +1,89 @@
// 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 gtsmodel
import (
"time"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
// Status represents a user-created 'post' or 'status' in the database, either remote or local
type Status struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created
EditedAt time.Time `bun:"type:timestamptz,nullzero"` // when this status was last edited (if set)
FetchedAt time.Time `bun:"type:timestamptz,nullzero"` // when was item (remote) last fetched.
PinnedAt time.Time `bun:"type:timestamptz,nullzero"` // Status was pinned by owning account at this time.
URI string `bun:",unique,nullzero,notnull"` // activitypub URI of this status
URL string `bun:",nullzero"` // web url for viewing this status
Content string `bun:""` // content of this status; likely html-formatted but not guaranteed
AttachmentIDs []string `bun:"attachments,array"` // Database IDs of any media attachments associated with this status
Attachments []*gtsmodel.MediaAttachment `bun:"attached_media,rel:has-many"` // Attachments corresponding to attachmentIDs
TagIDs []string `bun:"tags,array"` // Database IDs of any tags used in this status
Tags []*gtsmodel.Tag `bun:"attached_tags,m2m:status_to_tags"` // Tags corresponding to tagIDs. https://bun.uptrace.dev/guide/relations.html#many-to-many-relation
MentionIDs []string `bun:"mentions,array"` // Database IDs of any mentions in this status
Mentions []*gtsmodel.Mention `bun:"attached_mentions,rel:has-many"` // Mentions corresponding to mentionIDs
EmojiIDs []string `bun:"emojis,array"` // Database IDs of any emojis used in this status
Emojis []*gtsmodel.Emoji `bun:"attached_emojis,m2m:status_to_emojis"` // Emojis corresponding to emojiIDs. https://bun.uptrace.dev/guide/relations.html#many-to-many-relation
Local *bool `bun:",nullzero,notnull,default:false"` // is this status from a local account?
AccountID string `bun:"type:CHAR(26),nullzero,notnull"` // which account posted this status?
Account *gtsmodel.Account `bun:"rel:belongs-to"` // account corresponding to accountID
AccountURI string `bun:",nullzero,notnull"` // activitypub uri of the owner of this status
InReplyToID string `bun:"type:CHAR(26),nullzero"` // id of the status this status replies to
InReplyToURI string `bun:",nullzero"` // activitypub uri of the status this status is a reply to
InReplyToAccountID string `bun:"type:CHAR(26),nullzero"` // id of the account that this status replies to
InReplyTo *Status `bun:"-"` // status corresponding to inReplyToID
InReplyToAccount *gtsmodel.Account `bun:"rel:belongs-to"` // account corresponding to inReplyToAccountID
BoostOfID string `bun:"type:CHAR(26),nullzero"` // id of the status this status is a boost of
BoostOfURI string `bun:"-"` // URI of the status this status is a boost of; field not inserted in the db, just for dereferencing purposes.
BoostOfAccountID string `bun:"type:CHAR(26),nullzero"` // id of the account that owns the boosted status
BoostOf *Status `bun:"-"` // status that corresponds to boostOfID
BoostOfAccount *gtsmodel.Account `bun:"rel:belongs-to"` // account that corresponds to boostOfAccountID
ThreadID string `bun:"type:CHAR(26),nullzero"` // id of the thread to which this status belongs; only set for remote statuses if a local account is involved at some point in the thread, otherwise null
EditIDs []string `bun:"edits,array"` //
Edits []*gtsmodel.StatusEdit `bun:"-"` //
PollID string `bun:"type:CHAR(26),nullzero"` //
Poll *gtsmodel.Poll `bun:"-"` //
ContentWarning string `bun:",nullzero"` // cw string for this status
Visibility gtsmodel.Visibility `bun:",nullzero,notnull"` // visibility entry for this status
Sensitive *bool `bun:",nullzero,notnull,default:false"` // mark the status as sensitive?
Language string `bun:",nullzero"` // what language is this status written in?
CreatedWithApplicationID string `bun:"type:CHAR(26),nullzero"` // Which application was used to create this status?
CreatedWithApplication *gtsmodel.Application `bun:"rel:belongs-to"` // application corresponding to createdWithApplicationID
ActivityStreamsType string `bun:",nullzero,notnull"` // What is the activitystreams type of this status? See: https://www.w3.org/TR/activitystreams-vocabulary/#object-types. Will probably almost always be Note but who knows!.
Text string `bun:""` // Original text of the status without formatting
ContentType StatusContentType `bun:",nullzero"` // Content type used to process the original text of the status
Federated *bool `bun:",notnull"` // This status will be federated beyond the local timeline(s)
InteractionPolicy *gtsmodel.InteractionPolicy `bun:""` // InteractionPolicy for this status. If null then the default InteractionPolicy should be assumed for this status's Visibility. Always null for boost wrappers.
PendingApproval *bool `bun:",nullzero,notnull,default:false"` // If true then status is a reply or boost wrapper that must be Approved by the reply-ee or boost-ee before being fully distributed.
PreApproved bool `bun:"-"` // If true, then status is a reply to or boost wrapper of a status on our instance, has permission to do the interaction, and an Accept should be sent out for it immediately. Field not stored in the DB.
ApprovedByURI string `bun:",nullzero"` // URI of an Accept Activity that approves the Announce or Create Activity that this status was/will be attached to.
}
type enumType int16
// StatusContentType is the content type with which a status's text is
// parsed. Can be either plain or markdown. Empty will default to plain.
type StatusContentType enumType
const (
StatusContentTypePlain StatusContentType = 1
StatusContentTypeMarkdown StatusContentType = 2
StatusContentTypeDefault = StatusContentTypePlain
)

View file

@ -0,0 +1,51 @@
// 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 gtsmodel
import (
"time"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
// StatusEdit represents a **historical** view of a Status
// after a received edit. The Status itself will always
// contain the latest up-to-date information.
//
// Note that stored status edits may not exactly match that
// of the origin server, they are a best-effort by receiver
// to store version history. There is no AP history endpoint.
type StatusEdit struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // ID of this item in the database.
Content string `bun:""` // Content of status at time of edit; likely html-formatted but not guaranteed.
ContentWarning string `bun:",nullzero"` // Content warning of status at time of edit.
Text string `bun:""` // Original status text, without formatting, at time of edit.
ContentType StatusContentType `bun:",nullzero"` // Content type used to process the original text of the status.
Language string `bun:",nullzero"` // Status language at time of edit.
Sensitive *bool `bun:",nullzero,notnull,default:false"` // Status sensitive flag at time of edit.
AttachmentIDs []string `bun:"attachments,array"` // Database IDs of media attachments associated with status at time of edit.
AttachmentDescriptions []string `bun:",array"` // Previous media descriptions of media attachments associated with status at time of edit.
Attachments []*gtsmodel.MediaAttachment `bun:"-"` // Media attachments relating to .AttachmentIDs field (not always populated).
PollOptions []string `bun:",array"` // Poll options of status at time of edit, only set if status contains a poll.
PollVotes []int `bun:",array"` // Poll vote count at time of status edit, only set if poll votes were reset.
StatusID string `bun:"type:CHAR(26),nullzero,notnull"` // The originating status ID this is a historical edit of.
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // The creation time of this version of the status content (according to receiving server).
// We don't bother having a *gtsmodel.Status model here
// as the StatusEdit is always just attached to a Status,
// so it doesn't need a self-reference back to it.
}

View file

@ -45,6 +45,7 @@ func getFutureStatus() *gtsmodel.Status {
URL: "http://localhost:8080/@admin/statuses/" + id, URL: "http://localhost:8080/@admin/statuses/" + id,
Content: "it's the future, wooooooooooooooooooooooooooooooooo", Content: "it's the future, wooooooooooooooooooooooooooooooooo",
Text: "it's the future, wooooooooooooooooooooooooooooooooo", Text: "it's the future, wooooooooooooooooooooooooooooooooo",
ContentType: gtsmodel.StatusContentTypePlain,
AttachmentIDs: []string{}, AttachmentIDs: []string{},
TagIDs: []string{}, TagIDs: []string{},
MentionIDs: []string{}, MentionIDs: []string{},

View file

@ -1239,6 +1239,7 @@ func (d *Dereferencer) handleStatusEdit(
edit.Content = existing.Content edit.Content = existing.Content
edit.ContentWarning = existing.ContentWarning edit.ContentWarning = existing.ContentWarning
edit.Text = existing.Text edit.Text = existing.Text
edit.ContentType = existing.ContentType
edit.Language = existing.Language edit.Language = existing.Language
edit.Sensitive = existing.Sensitive edit.Sensitive = existing.Sensitive
edit.StatusID = status.ID edit.StatusID = status.ID

View file

@ -69,6 +69,7 @@ type Status struct {
CreatedWithApplication *Application `bun:"rel:belongs-to"` // application corresponding to createdWithApplicationID CreatedWithApplication *Application `bun:"rel:belongs-to"` // application corresponding to createdWithApplicationID
ActivityStreamsType string `bun:",nullzero,notnull"` // What is the activitystreams type of this status? See: https://www.w3.org/TR/activitystreams-vocabulary/#object-types. Will probably almost always be Note but who knows!. ActivityStreamsType string `bun:",nullzero,notnull"` // What is the activitystreams type of this status? See: https://www.w3.org/TR/activitystreams-vocabulary/#object-types. Will probably almost always be Note but who knows!.
Text string `bun:""` // Original text of the status without formatting Text string `bun:""` // Original text of the status without formatting
ContentType StatusContentType `bun:",nullzero"` // Content type used to process the original text of the status
Federated *bool `bun:",notnull"` // This status will be federated beyond the local timeline(s) Federated *bool `bun:",notnull"` // This status will be federated beyond the local timeline(s)
InteractionPolicy *InteractionPolicy `bun:""` // InteractionPolicy for this status. If null then the default InteractionPolicy should be assumed for this status's Visibility. Always null for boost wrappers. InteractionPolicy *InteractionPolicy `bun:""` // InteractionPolicy for this status. If null then the default InteractionPolicy should be assumed for this status's Visibility. Always null for boost wrappers.
PendingApproval *bool `bun:",nullzero,notnull,default:false"` // If true then status is a reply or boost wrapper that must be Approved by the reply-ee or boost-ee before being fully distributed. PendingApproval *bool `bun:",nullzero,notnull,default:false"` // If true then status is a reply or boost wrapper that must be Approved by the reply-ee or boost-ee before being fully distributed.
@ -376,6 +377,16 @@ func (v Visibility) String() string {
} }
} }
// StatusContentType is the content type with which a status's text is
// parsed. Can be either plain or markdown. Empty will default to plain.
type StatusContentType enumType
const (
StatusContentTypePlain StatusContentType = 1
StatusContentTypeMarkdown StatusContentType = 2
StatusContentTypeDefault = StatusContentTypePlain
)
// Content models the simple string content // Content models the simple string content
// of a status along with its ContentMap, // of a status along with its ContentMap,
// which contains content entries keyed by // which contains content entries keyed by

View file

@ -31,6 +31,7 @@ type StatusEdit struct {
Content string `bun:""` // Content of status at time of edit; likely html-formatted but not guaranteed. Content string `bun:""` // Content of status at time of edit; likely html-formatted but not guaranteed.
ContentWarning string `bun:",nullzero"` // Content warning of status at time of edit. ContentWarning string `bun:",nullzero"` // Content warning of status at time of edit.
Text string `bun:""` // Original status text, without formatting, at time of edit. Text string `bun:""` // Original status text, without formatting, at time of edit.
ContentType StatusContentType `bun:",nullzero"` // Content type used to process the original text of the status.
Language string `bun:",nullzero"` // Status language at time of edit. Language string `bun:",nullzero"` // Status language at time of edit.
Sensitive *bool `bun:",nullzero,notnull,default:false"` // Status sensitive flag at time of edit. Sensitive *bool `bun:",nullzero,notnull,default:false"` // Status sensitive flag at time of edit.
AttachmentIDs []string `bun:"attachments,array"` // Database IDs of media attachments associated with status at time of edit. AttachmentIDs []string `bun:"attachments,array"` // Database IDs of media attachments associated with status at time of edit.

View file

@ -30,6 +30,7 @@
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/text" "github.com/superseriousbusiness/gotosocial/internal/text"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
"github.com/superseriousbusiness/gotosocial/internal/util/xslices" "github.com/superseriousbusiness/gotosocial/internal/util/xslices"
"github.com/superseriousbusiness/gotosocial/internal/validate" "github.com/superseriousbusiness/gotosocial/internal/validate"
) )
@ -106,11 +107,39 @@ type statusContent struct {
Tags []*gtsmodel.Tag Tags []*gtsmodel.Tag
} }
// Returns the final content type to use when creating or editing a status.
func processContentType(
requestContentType apimodel.StatusContentType,
existingStatus *gtsmodel.Status,
accountDefaultContentType string,
) gtsmodel.StatusContentType {
switch {
// Content type set in the request, return the new value.
case requestContentType != "":
return typeutils.APIContentTypeToContentType(requestContentType)
// No content type in the request, return the existing
// status's current content type if we know of one.
case existingStatus != nil && existingStatus.ContentType != 0:
return existingStatus.ContentType
// We aren't editing an existing status, or if we are
// it's an old one that doesn't have a saved content
// type. Use the user's default content type setting.
case accountDefaultContentType != "":
return typeutils.APIContentTypeToContentType(apimodel.StatusContentType(accountDefaultContentType))
// uhh.. Fall back to global default.
default:
return gtsmodel.StatusContentTypeDefault
}
}
func (p *Processor) processContent( func (p *Processor) processContent(
ctx context.Context, ctx context.Context,
author *gtsmodel.Account, author *gtsmodel.Account,
statusID string, statusID string,
contentType string, contentType gtsmodel.StatusContentType,
content string, content string,
contentWarning string, contentWarning string,
language string, language string,
@ -146,20 +175,14 @@ func (p *Processor) processContent(
// function, according to the provided content-type. // function, according to the provided content-type.
var format text.FormatFunc var format text.FormatFunc
if contentType == "" {
// If content type wasn't specified, use
// the author's preferred content-type.
contentType = author.Settings.StatusContentType
}
switch contentType { switch contentType {
// Format status according to text/plain. // Format status according to text/plain.
case "", string(apimodel.StatusContentTypePlain): case gtsmodel.StatusContentTypePlain:
format = p.formatter.FromPlain format = p.formatter.FromPlain
// Format status according to text/markdown. // Format status according to text/markdown.
case string(apimodel.StatusContentTypeMarkdown): case gtsmodel.StatusContentTypeMarkdown:
format = p.formatter.FromMarkdown format = p.formatter.FromMarkdown
// Unknown. // Unknown.

View file

@ -66,11 +66,14 @@ func (p *Processor) Create(
// Generate new ID for status. // Generate new ID for status.
statusID := id.NewULID() statusID := id.NewULID()
// Process incoming content type.
contentType := processContentType(form.ContentType, nil, requester.Settings.StatusContentType)
// Process incoming status content fields. // Process incoming status content fields.
content, errWithCode := p.processContent(ctx, content, errWithCode := p.processContent(ctx,
requester, requester,
statusID, statusID,
string(form.ContentType), contentType,
form.Status, form.Status,
form.SpoilerText, form.SpoilerText,
form.Language, form.Language,
@ -163,6 +166,7 @@ func (p *Processor) Create(
Content: content.Content, Content: content.Content,
ContentWarning: content.ContentWarning, ContentWarning: content.ContentWarning,
Text: form.Status, // raw Text: form.Status, // raw
ContentType: contentType,
// Set gathered mentions. // Set gathered mentions.
MentionIDs: content.MentionIDs, MentionIDs: content.MentionIDs,

View file

@ -238,6 +238,36 @@ func (suite *StatusCreateTestSuite) TestProcessReplyToUnthreadedRemoteStatus() {
suite.NotEmpty(dbStatus.ThreadID) suite.NotEmpty(dbStatus.ThreadID)
} }
func (suite *StatusCreateTestSuite) TestProcessNoContentTypeUsesDefault() {
ctx := context.Background()
creatingAccount := suite.testAccounts["local_account_1"]
creatingApplication := suite.testApplications["application_1"]
statusCreateForm := &apimodel.StatusCreateRequest{
Status: "poopoo peepee",
SpoilerText: "",
MediaIDs: []string{},
Poll: nil,
InReplyToID: "",
Sensitive: false,
Visibility: apimodel.VisibilityPublic,
LocalOnly: util.Ptr(false),
ScheduledAt: nil,
Language: "en",
ContentType: "",
}
apiStatus, errWithCode := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm)
suite.NoError(errWithCode)
suite.NotNil(apiStatus)
suite.Equal("<p>poopoo peepee</p>", apiStatus.Content)
// the test accounts don't have settings, so we're comparing to
// the global default value instead of the requester's default
suite.Equal(apimodel.StatusContentTypeDefault, apiStatus.ContentType)
}
func TestStatusCreateTestSuite(t *testing.T) { func TestStatusCreateTestSuite(t *testing.T) {
suite.Run(t, new(StatusCreateTestSuite)) suite.Run(t, new(StatusCreateTestSuite))
} }

View file

@ -84,11 +84,14 @@ func (p *Processor) Edit(
return nil, errWithCode return nil, errWithCode
} }
// Process incoming content type.
contentType := processContentType(form.ContentType, status, requester.Settings.StatusContentType)
// Process incoming status edit content fields. // Process incoming status edit content fields.
content, errWithCode := p.processContent(ctx, content, errWithCode := p.processContent(ctx,
requester, requester,
statusID, statusID,
string(form.ContentType), contentType,
form.Status, form.Status,
form.SpoilerText, form.SpoilerText,
form.Language, form.Language,
@ -256,6 +259,7 @@ func (p *Processor) Edit(
edit.Content = status.Content edit.Content = status.Content
edit.ContentWarning = status.ContentWarning edit.ContentWarning = status.ContentWarning
edit.Text = status.Text edit.Text = status.Text
edit.ContentType = status.ContentType
edit.Language = status.Language edit.Language = status.Language
edit.Sensitive = status.Sensitive edit.Sensitive = status.Sensitive
edit.StatusID = status.ID edit.StatusID = status.ID
@ -298,6 +302,7 @@ func (p *Processor) Edit(
status.Content = content.Content status.Content = content.Content
status.ContentWarning = content.ContentWarning status.ContentWarning = content.ContentWarning
status.Text = form.Status status.Text = form.Status
status.ContentType = contentType
status.Language = content.Language status.Language = content.Language
status.Sensitive = &form.Sensitive status.Sensitive = &form.Sensitive
status.AttachmentIDs = form.MediaIDs status.AttachmentIDs = form.MediaIDs

View file

@ -26,6 +26,7 @@
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
"github.com/superseriousbusiness/gotosocial/internal/util" "github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/superseriousbusiness/gotosocial/internal/util/xslices" "github.com/superseriousbusiness/gotosocial/internal/util/xslices"
) )
@ -90,6 +91,142 @@ func (suite *StatusEditTestSuite) TestSimpleEdit() {
previousEdit := latestStatus.Edits[len(latestStatus.Edits)-1] previousEdit := latestStatus.Edits[len(latestStatus.Edits)-1]
suite.Equal(status.Content, previousEdit.Content) suite.Equal(status.Content, previousEdit.Content)
suite.Equal(status.Text, previousEdit.Text) suite.Equal(status.Text, previousEdit.Text)
suite.Equal(status.ContentType, previousEdit.ContentType)
suite.Equal(status.ContentWarning, previousEdit.ContentWarning)
suite.Equal(*status.Sensitive, *previousEdit.Sensitive)
suite.Equal(status.Language, previousEdit.Language)
suite.Equal(status.UpdatedAt(), previousEdit.CreatedAt)
}
func (suite *StatusEditTestSuite) TestEditChangeContentType() {
// Create cancellable context to use for test.
ctx, cncl := context.WithCancel(context.Background())
defer cncl()
// Get a local account to use as test requester.
requester := suite.testAccounts["local_account_1"]
requester, _ = suite.state.DB.GetAccountByID(ctx, requester.ID)
// Get requester's existing plain text status to perform an edit on.
status := suite.testStatuses["local_account_1_status_6"]
status, _ = suite.state.DB.GetStatusByID(ctx, status.ID)
// Prepare edit with a Markdown body.
form := &apimodel.StatusEditRequest{
Status: "ooh the status is *fancy* now!",
ContentType: apimodel.StatusContentTypeMarkdown,
SpoilerText: "shhhhh",
Sensitive: true,
Language: "fr", // hoh hoh hoh
MediaIDs: nil,
MediaAttributes: nil,
Poll: nil,
}
// Pass the prepared form to the status processor to perform the edit.
apiStatus, errWithCode := suite.status.Edit(ctx, requester, status.ID, form)
suite.NotNil(apiStatus)
suite.NoError(errWithCode)
// Check response against input form data.
suite.Equal(form.Status, apiStatus.Text)
suite.Equal(form.ContentType, apiStatus.ContentType)
suite.Equal(form.SpoilerText, apiStatus.SpoilerText)
suite.Equal(form.Sensitive, apiStatus.Sensitive)
suite.Equal(form.Language, *apiStatus.Language)
suite.NotEqual(util.FormatISO8601(status.EditedAt), *apiStatus.EditedAt)
// Fetched the latest version of edited status from the database.
latestStatus, err := suite.state.DB.GetStatusByID(ctx, status.ID)
suite.NoError(err)
// Check latest status against input form data.
suite.Equal(form.Status, latestStatus.Text)
suite.Equal(typeutils.APIContentTypeToContentType(form.ContentType), latestStatus.ContentType)
suite.Equal(form.SpoilerText, latestStatus.ContentWarning)
suite.Equal(form.Sensitive, *latestStatus.Sensitive)
suite.Equal(form.Language, latestStatus.Language)
suite.Equal(len(status.EditIDs)+1, len(latestStatus.EditIDs))
suite.NotEqual(status.UpdatedAt(), latestStatus.UpdatedAt())
// Populate all historical edits for this status.
err = suite.state.DB.PopulateStatusEdits(ctx, latestStatus)
suite.NoError(err)
// Check previous status edit matches original status content.
previousEdit := latestStatus.Edits[len(latestStatus.Edits)-1]
suite.Equal(status.Content, previousEdit.Content)
suite.Equal(status.Text, previousEdit.Text)
suite.Equal(status.ContentType, previousEdit.ContentType)
suite.Equal(status.ContentWarning, previousEdit.ContentWarning)
suite.Equal(*status.Sensitive, *previousEdit.Sensitive)
suite.Equal(status.Language, previousEdit.Language)
suite.Equal(status.UpdatedAt(), previousEdit.CreatedAt)
}
func (suite *StatusEditTestSuite) TestEditOnStatusWithNoContentType() {
// Create cancellable context to use for test.
ctx, cncl := context.WithCancel(context.Background())
defer cncl()
// Get a local account to use as test requester.
requester := suite.testAccounts["local_account_1"]
requester, _ = suite.state.DB.GetAccountByID(ctx, requester.ID)
// Get requester's existing status, which has no
// stored content type, to perform an edit on.
status := suite.testStatuses["local_account_1_status_2"]
status, _ = suite.state.DB.GetStatusByID(ctx, status.ID)
// Prepare edit without setting a new content type.
form := &apimodel.StatusEditRequest{
Status: "how will this text be parsed? it is a mystery",
SpoilerText: "shhhhh",
Sensitive: true,
Language: "fr", // hoh hoh hoh
MediaIDs: nil,
MediaAttributes: nil,
Poll: nil,
}
// Pass the prepared form to the status processor to perform the edit.
apiStatus, errWithCode := suite.status.Edit(ctx, requester, status.ID, form)
suite.NotNil(apiStatus)
suite.NoError(errWithCode)
// Check response against input form data.
suite.Equal(form.Status, apiStatus.Text)
suite.NotEqual(util.FormatISO8601(status.EditedAt), *apiStatus.EditedAt)
// Check response against requester's default content type setting
// (the test accounts don't actually have settings on them, so
// instead we check that the global default content type is used)
suite.Equal(apimodel.StatusContentTypeDefault, apiStatus.ContentType)
// Fetched the latest version of edited status from the database.
latestStatus, err := suite.state.DB.GetStatusByID(ctx, status.ID)
suite.NoError(err)
// Check latest status against input form data
suite.Equal(form.Status, latestStatus.Text)
suite.Equal(form.Sensitive, *latestStatus.Sensitive)
suite.Equal(form.Language, latestStatus.Language)
suite.Equal(len(status.EditIDs)+1, len(latestStatus.EditIDs))
suite.NotEqual(status.UpdatedAt(), latestStatus.UpdatedAt())
// Check latest status against requester's default content
// type (again, actually just checking for the global default)
suite.Equal(gtsmodel.StatusContentTypeDefault, latestStatus.ContentType)
// Populate all historical edits for this status.
err = suite.state.DB.PopulateStatusEdits(ctx, latestStatus)
suite.NoError(err)
// Check previous status edit matches original status content.
previousEdit := latestStatus.Edits[len(latestStatus.Edits)-1]
suite.Equal(status.Content, previousEdit.Content)
suite.Equal(status.Text, previousEdit.Text)
suite.Equal(status.ContentType, previousEdit.ContentType)
suite.Equal(status.ContentWarning, previousEdit.ContentWarning) suite.Equal(status.ContentWarning, previousEdit.ContentWarning)
suite.Equal(*status.Sensitive, *previousEdit.Sensitive) suite.Equal(*status.Sensitive, *previousEdit.Sensitive)
suite.Equal(status.Language, previousEdit.Language) suite.Equal(status.Language, previousEdit.Language)

View file

@ -24,6 +24,7 @@
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"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/typeutils"
) )
// Get gets the given status, taking account of privacy settings and blocks etc. // Get gets the given status, taking account of privacy settings and blocks etc.
@ -56,5 +57,6 @@ func (p *Processor) SourceGet(ctx context.Context, requester *gtsmodel.Account,
ID: status.ID, ID: status.ID,
Text: status.Text, Text: status.Text,
SpoilerText: status.ContentWarning, SpoilerText: status.ContentWarning,
ContentType: typeutils.ContentTypeToAPIContentType(status.ContentType),
}, nil }, nil
} }

View file

@ -44,6 +44,16 @@ func APIVisToVis(m apimodel.Visibility) gtsmodel.Visibility {
return 0 return 0
} }
func APIContentTypeToContentType(m apimodel.StatusContentType) gtsmodel.StatusContentType {
switch m {
case apimodel.StatusContentTypePlain:
return gtsmodel.StatusContentTypePlain
case apimodel.StatusContentTypeMarkdown:
return gtsmodel.StatusContentTypeMarkdown
}
return 0
}
func APIMarkerNameToMarkerName(m apimodel.MarkerName) gtsmodel.MarkerName { func APIMarkerNameToMarkerName(m apimodel.MarkerName) gtsmodel.MarkerName {
switch m { switch m {
case apimodel.MarkerNameHome: case apimodel.MarkerNameHome:

View file

@ -1391,6 +1391,7 @@ func (c *Converter) baseStatusToFrontend(
Emojis: apiEmojis, Emojis: apiEmojis,
Card: nil, // TODO: implement cards Card: nil, // TODO: implement cards
Text: s.Text, Text: s.Text,
ContentType: ContentTypeToAPIContentType(s.ContentType),
InteractionPolicy: *apiInteractionPolicy, InteractionPolicy: *apiInteractionPolicy,
} }
@ -1626,6 +1627,17 @@ func (c *Converter) VisToAPIVis(ctx context.Context, m gtsmodel.Visibility) apim
return "" return ""
} }
// Converts a gts status content type into its api equivalent
func ContentTypeToAPIContentType(m gtsmodel.StatusContentType) apimodel.StatusContentType {
switch m {
case gtsmodel.StatusContentTypePlain:
return apimodel.StatusContentTypePlain
case gtsmodel.StatusContentTypeMarkdown:
return apimodel.StatusContentTypeMarkdown
}
return ""
}
// InstanceRuleToAdminAPIRule converts a local instance rule into its api equivalent for serving at /api/v1/admin/instance/rules/:id // InstanceRuleToAdminAPIRule converts a local instance rule into its api equivalent for serving at /api/v1/admin/instance/rules/:id
func InstanceRuleToAPIRule(r gtsmodel.Rule) apimodel.InstanceRule { func InstanceRuleToAPIRule(r gtsmodel.Rule) apimodel.InstanceRule {
return apimodel.InstanceRule{ return apimodel.InstanceRule{

View file

@ -578,6 +578,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontend() {
"card": null, "card": null,
"poll": null, "poll": null,
"text": "hello world! #welcome ! first post on the instance :rainbow: !", "text": "hello world! #welcome ! first post on the instance :rainbow: !",
"content_type": "text/plain",
"interaction_policy": { "interaction_policy": {
"can_favourite": { "can_favourite": {
"always": [ "always": [
@ -758,6 +759,7 @@ func (suite *InternalToFrontendTestSuite) TestWarnFilteredStatusToFrontend() {
"card": null, "card": null,
"poll": null, "poll": null,
"text": "hello world! #welcome ! first post on the instance :rainbow: ! fnord", "text": "hello world! #welcome ! first post on the instance :rainbow: ! fnord",
"content_type": "text/plain",
"filtered": [ "filtered": [
{ {
"filter": { "filter": {
@ -943,6 +945,7 @@ func (suite *InternalToFrontendTestSuite) TestWarnFilteredBoostToFrontend() {
"card": null, "card": null,
"poll": null, "poll": null,
"text": "hello world! #welcome ! first post on the instance :rainbow: ! fnord", "text": "hello world! #welcome ! first post on the instance :rainbow: ! fnord",
"content_type": "text/plain",
"filtered": [ "filtered": [
{ {
"filter": { "filter": {
@ -1676,6 +1679,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendUnknownLanguage()
"card": null, "card": null,
"poll": null, "poll": null,
"text": "hello world! #welcome ! first post on the instance :rainbow: !", "text": "hello world! #welcome ! first post on the instance :rainbow: !",
"content_type": "text/plain",
"interaction_policy": { "interaction_policy": {
"can_favourite": { "can_favourite": {
"always": [ "always": [
@ -1774,6 +1778,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendPartialInteraction
"card": null, "card": null,
"poll": null, "poll": null,
"text": "this is a very personal post that I don't want anyone to interact with at all, and i only want mutuals to see it", "text": "this is a very personal post that I don't want anyone to interact with at all, and i only want mutuals to see it",
"content_type": "text/plain",
"interaction_policy": { "interaction_policy": {
"can_favourite": { "can_favourite": {
"always": [ "always": [
@ -1897,6 +1902,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToAPIStatusPendingApproval()
"card": null, "card": null,
"poll": null, "poll": null,
"text": "Hi @1happyturtle, can I reply?", "text": "Hi @1happyturtle, can I reply?",
"content_type": "text/markdown",
"interaction_policy": { "interaction_policy": {
"can_favourite": { "can_favourite": {
"always": [ "always": [
@ -3375,6 +3381,7 @@ func (suite *InternalToFrontendTestSuite) TestIntReqToAPI() {
"card": null, "card": null,
"poll": null, "poll": null,
"text": "🐢 i don't mind people sharing and liking this one but I want to moderate replies to it 🐢", "text": "🐢 i don't mind people sharing and liking this one but I want to moderate replies to it 🐢",
"content_type": "text/plain",
"interaction_policy": { "interaction_policy": {
"can_favourite": { "can_favourite": {
"always": [ "always": [
@ -3473,6 +3480,7 @@ func (suite *InternalToFrontendTestSuite) TestIntReqToAPI() {
"card": null, "card": null,
"poll": null, "poll": null,
"text": "Hi @1happyturtle, can I reply?", "text": "Hi @1happyturtle, can I reply?",
"content_type": "text/markdown",
"interaction_policy": { "interaction_policy": {
"can_favourite": { "can_favourite": {
"always": [ "always": [
@ -3632,6 +3640,7 @@ func (suite *InternalToFrontendTestSuite) TestConversationToAPISelfConvo() {
"card": null, "card": null,
"poll": null, "poll": null,
"text": "hello everyone!", "text": "hello everyone!",
"content_type": "text/plain",
"interaction_policy": { "interaction_policy": {
"can_favourite": { "can_favourite": {
"always": [ "always": [
@ -3801,6 +3810,7 @@ func (suite *InternalToFrontendTestSuite) TestConversationToAPI() {
"card": null, "card": null,
"poll": null, "poll": null,
"text": "hello everyone!", "text": "hello everyone!",
"content_type": "text/plain",
"interaction_policy": { "interaction_policy": {
"can_favourite": { "can_favourite": {
"always": [ "always": [

View file

@ -1408,6 +1408,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status {
URL: "http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R", URL: "http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R",
Content: "hello world! #welcome ! first post on the instance :rainbow: !", Content: "hello world! #welcome ! first post on the instance :rainbow: !",
Text: "hello world! #welcome ! first post on the instance :rainbow: !", Text: "hello world! #welcome ! first post on the instance :rainbow: !",
ContentType: gtsmodel.StatusContentTypePlain,
AttachmentIDs: []string{"01F8MH6NEM8D7527KZAECTCR76"}, AttachmentIDs: []string{"01F8MH6NEM8D7527KZAECTCR76"},
TagIDs: []string{"01F8MHA1A2NF9MJ3WCCQ3K8BSZ"}, TagIDs: []string{"01F8MHA1A2NF9MJ3WCCQ3K8BSZ"},
MentionIDs: []string{}, MentionIDs: []string{},
@ -1436,6 +1437,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status {
URL: "http://localhost:8080/@admin/statuses/01F8MHAAY43M6RJ473VQFCVH37", URL: "http://localhost:8080/@admin/statuses/01F8MHAAY43M6RJ473VQFCVH37",
Content: "🐕🐕🐕🐕🐕", Content: "🐕🐕🐕🐕🐕",
Text: "🐕🐕🐕🐕🐕", Text: "🐕🐕🐕🐕🐕",
ContentType: gtsmodel.StatusContentTypePlain,
CreatedAt: TimeMustParse("2021-10-20T12:36:45Z"), CreatedAt: TimeMustParse("2021-10-20T12:36:45Z"),
EditedAt: time.Time{}, EditedAt: time.Time{},
Local: util.Ptr(true), Local: util.Ptr(true),
@ -1459,6 +1461,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status {
URL: "http://localhost:8080/@admin/statuses/01FF25D5Q0DH7CHD57CTRS6WK0", URL: "http://localhost:8080/@admin/statuses/01FF25D5Q0DH7CHD57CTRS6WK0",
Content: "hi @the_mighty_zork welcome to the instance!", Content: "hi @the_mighty_zork welcome to the instance!",
Text: "hi @the_mighty_zork welcome to the instance!", Text: "hi @the_mighty_zork welcome to the instance!",
ContentType: gtsmodel.StatusContentTypePlain,
CreatedAt: TimeMustParse("2021-11-20T13:32:16Z"), CreatedAt: TimeMustParse("2021-11-20T13:32:16Z"),
EditedAt: time.Time{}, EditedAt: time.Time{},
Local: util.Ptr(true), Local: util.Ptr(true),
@ -1506,6 +1509,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status {
URL: "http://localhost:8080/@admin/statuses/01J5QVB9VC76NPPRQ207GG4DRZ", URL: "http://localhost:8080/@admin/statuses/01J5QVB9VC76NPPRQ207GG4DRZ",
Content: `<p>Hi <span class="h-card"><a href="http://localhost:8080/@1happyturtle" class="u-url mention" rel="nofollow noreferrer noopener" target="_blank">@<span>1happyturtle</span></a></span>, can I reply?</p>`, Content: `<p>Hi <span class="h-card"><a href="http://localhost:8080/@1happyturtle" class="u-url mention" rel="nofollow noreferrer noopener" target="_blank">@<span>1happyturtle</span></a></span>, can I reply?</p>`,
Text: "Hi @1happyturtle, can I reply?", Text: "Hi @1happyturtle, can I reply?",
ContentType: gtsmodel.StatusContentTypeMarkdown,
CreatedAt: TimeMustParse("2024-02-20T12:41:37+02:00"), CreatedAt: TimeMustParse("2024-02-20T12:41:37+02:00"),
EditedAt: time.Time{}, EditedAt: time.Time{},
Local: util.Ptr(true), Local: util.Ptr(true),
@ -1531,6 +1535,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status {
URL: "http://localhost:8080/@the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY", URL: "http://localhost:8080/@the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY",
Content: "hello everyone!", Content: "hello everyone!",
Text: "hello everyone!", Text: "hello everyone!",
ContentType: gtsmodel.StatusContentTypePlain,
CreatedAt: TimeMustParse("2021-10-20T12:40:37+02:00"), CreatedAt: TimeMustParse("2021-10-20T12:40:37+02:00"),
EditedAt: time.Time{}, EditedAt: time.Time{},
Local: util.Ptr(true), Local: util.Ptr(true),
@ -1552,8 +1557,9 @@ func NewTestStatuses() map[string]*gtsmodel.Status {
ID: "01F8MHAYFKS4KMXF8K5Y1C0KRN", ID: "01F8MHAYFKS4KMXF8K5Y1C0KRN",
URI: "http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAYFKS4KMXF8K5Y1C0KRN", URI: "http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAYFKS4KMXF8K5Y1C0KRN",
URL: "http://localhost:8080/@the_mighty_zork/statuses/01F8MHAYFKS4KMXF8K5Y1C0KRN", URL: "http://localhost:8080/@the_mighty_zork/statuses/01F8MHAYFKS4KMXF8K5Y1C0KRN",
Content: "this is a Public local-only post that shouldn't federate, but it's still boostable, replyable, and likeable", Content: "this is a Public local-only post that shouldn't federate, but it's still boostable, replyable, and likeable. also it has no stored content type",
Text: "this is a Public local-only post that shouldn't federate, but it's still boostable, replyable, and likeable", Text: "this is a Public local-only post that shouldn't federate, but it's still boostable, replyable, and likeable. also it has no stored content type",
ContentType: 0,
CreatedAt: TimeMustParse("2021-10-20T12:40:37+02:00"), CreatedAt: TimeMustParse("2021-10-20T12:40:37+02:00"),
EditedAt: time.Time{}, EditedAt: time.Time{},
Local: util.Ptr(true), Local: util.Ptr(true),
@ -1577,6 +1583,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status {
URL: "http://localhost:8080/@the_mighty_zork/statuses/01F8MHBBN8120SYH7D5S050MGK", URL: "http://localhost:8080/@the_mighty_zork/statuses/01F8MHBBN8120SYH7D5S050MGK",
Content: "this is a very personal post that I don't want anyone to interact with at all, and i only want mutuals to see it", Content: "this is a very personal post that I don't want anyone to interact with at all, and i only want mutuals to see it",
Text: "this is a very personal post that I don't want anyone to interact with at all, and i only want mutuals to see it", Text: "this is a very personal post that I don't want anyone to interact with at all, and i only want mutuals to see it",
ContentType: gtsmodel.StatusContentTypePlain,
CreatedAt: TimeMustParse("2021-10-20T12:40:37+02:00"), CreatedAt: TimeMustParse("2021-10-20T12:40:37+02:00"),
EditedAt: time.Time{}, EditedAt: time.Time{},
Local: util.Ptr(true), Local: util.Ptr(true),
@ -1611,6 +1618,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status {
URL: "http://localhost:8080/@the_mighty_zork/statuses/01F8MH82FYRXD2RC6108DAJ5HB", URL: "http://localhost:8080/@the_mighty_zork/statuses/01F8MH82FYRXD2RC6108DAJ5HB",
Content: "here's a little gif of trent.... and also a cow", Content: "here's a little gif of trent.... and also a cow",
Text: "here's a little gif of trent.... and also a cow", Text: "here's a little gif of trent.... and also a cow",
ContentType: gtsmodel.StatusContentTypePlain,
AttachmentIDs: []string{"01F8MH7TDVANYKWVE8VVKFPJTJ", "01CDR64G398ADCHXK08WWTHEZ5"}, AttachmentIDs: []string{"01F8MH7TDVANYKWVE8VVKFPJTJ", "01CDR64G398ADCHXK08WWTHEZ5"},
CreatedAt: TimeMustParse("2021-10-20T12:40:37+02:00"), CreatedAt: TimeMustParse("2021-10-20T12:40:37+02:00"),
EditedAt: time.Time{}, EditedAt: time.Time{},
@ -1635,6 +1643,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status {
URL: "http://localhost:8080/@the_mighty_zork/statuses/01FCTA44PW9H1TB328S9AQXKDS", URL: "http://localhost:8080/@the_mighty_zork/statuses/01FCTA44PW9H1TB328S9AQXKDS",
Content: "hi!", Content: "hi!",
Text: "hi!", Text: "hi!",
ContentType: gtsmodel.StatusContentTypePlain,
AttachmentIDs: []string{}, AttachmentIDs: []string{},
CreatedAt: TimeMustParse("2022-05-20T11:37:55Z"), CreatedAt: TimeMustParse("2022-05-20T11:37:55Z"),
EditedAt: time.Time{}, EditedAt: time.Time{},
@ -1659,6 +1668,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status {
URL: "http://localhost:8080/@the_mighty_zork/statuses/065TKBPE0H2AH8S5X8JCK4XC58", URL: "http://localhost:8080/@the_mighty_zork/statuses/065TKBPE0H2AH8S5X8JCK4XC58",
Content: "what do you think of sloths?", Content: "what do you think of sloths?",
Text: "what do you think of sloths?", Text: "what do you think of sloths?",
ContentType: gtsmodel.StatusContentTypePlain,
AttachmentIDs: nil, AttachmentIDs: nil,
CreatedAt: TimeMustParse("2022-05-20T11:41:10Z"), CreatedAt: TimeMustParse("2022-05-20T11:41:10Z"),
EditedAt: time.Time{}, EditedAt: time.Time{},
@ -1684,6 +1694,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status {
URL: "http://localhost:8080/@the_mighty_zork/statuses/01HH9KYNQPA416TNJ53NSATP40", URL: "http://localhost:8080/@the_mighty_zork/statuses/01HH9KYNQPA416TNJ53NSATP40",
Content: "<p>Here's a bunch of HTML, read it and weep, weep then!</p><pre><code class=\"language-html\">&lt;section class=&#34;about-user&#34;&gt;\n &lt;div class=&#34;col-header&#34;&gt;\n &lt;h2&gt;About&lt;/h2&gt;\n &lt;/div&gt; \n &lt;div class=&#34;fields&#34;&gt;\n &lt;h3 class=&#34;sr-only&#34;&gt;Fields&lt;/h3&gt;\n &lt;dl&gt;\n &lt;div class=&#34;field&#34;&gt;\n &lt;dt&gt;should you follow me?&lt;/dt&gt;\n &lt;dd&gt;maybe!&lt;/dd&gt;\n &lt;/div&gt;\n &lt;div class=&#34;field&#34;&gt;\n &lt;dt&gt;age&lt;/dt&gt;\n &lt;dd&gt;120&lt;/dd&gt;\n &lt;/div&gt;\n &lt;/dl&gt;\n &lt;/div&gt;\n &lt;div class=&#34;bio&#34;&gt;\n &lt;h3 class=&#34;sr-only&#34;&gt;Bio&lt;/h3&gt;\n &lt;p&gt;i post about things that concern me&lt;/p&gt;\n &lt;/div&gt;\n &lt;div class=&#34;sr-only&#34; role=&#34;group&#34;&gt;\n &lt;h3 class=&#34;sr-only&#34;&gt;Stats&lt;/h3&gt;\n &lt;span&gt;Joined in Jun, 2022.&lt;/span&gt;\n &lt;span&gt;8 posts.&lt;/span&gt;\n &lt;span&gt;Followed by 1.&lt;/span&gt;\n &lt;span&gt;Following 1.&lt;/span&gt;\n &lt;/div&gt;\n &lt;div class=&#34;accountstats&#34; aria-hidden=&#34;true&#34;&gt;\n &lt;b&gt;Joined&lt;/b&gt;&lt;time datetime=&#34;2022-06-04T13:12:00.000Z&#34;&gt;Jun, 2022&lt;/time&gt;\n &lt;b&gt;Posts&lt;/b&gt;&lt;span&gt;8&lt;/span&gt;\n &lt;b&gt;Followed by&lt;/b&gt;&lt;span&gt;1&lt;/span&gt;\n &lt;b&gt;Following&lt;/b&gt;&lt;span&gt;1&lt;/span&gt;\n &lt;/div&gt;\n&lt;/section&gt;\n</code></pre><p>There, hope you liked that!</p>", Content: "<p>Here's a bunch of HTML, read it and weep, weep then!</p><pre><code class=\"language-html\">&lt;section class=&#34;about-user&#34;&gt;\n &lt;div class=&#34;col-header&#34;&gt;\n &lt;h2&gt;About&lt;/h2&gt;\n &lt;/div&gt; \n &lt;div class=&#34;fields&#34;&gt;\n &lt;h3 class=&#34;sr-only&#34;&gt;Fields&lt;/h3&gt;\n &lt;dl&gt;\n &lt;div class=&#34;field&#34;&gt;\n &lt;dt&gt;should you follow me?&lt;/dt&gt;\n &lt;dd&gt;maybe!&lt;/dd&gt;\n &lt;/div&gt;\n &lt;div class=&#34;field&#34;&gt;\n &lt;dt&gt;age&lt;/dt&gt;\n &lt;dd&gt;120&lt;/dd&gt;\n &lt;/div&gt;\n &lt;/dl&gt;\n &lt;/div&gt;\n &lt;div class=&#34;bio&#34;&gt;\n &lt;h3 class=&#34;sr-only&#34;&gt;Bio&lt;/h3&gt;\n &lt;p&gt;i post about things that concern me&lt;/p&gt;\n &lt;/div&gt;\n &lt;div class=&#34;sr-only&#34; role=&#34;group&#34;&gt;\n &lt;h3 class=&#34;sr-only&#34;&gt;Stats&lt;/h3&gt;\n &lt;span&gt;Joined in Jun, 2022.&lt;/span&gt;\n &lt;span&gt;8 posts.&lt;/span&gt;\n &lt;span&gt;Followed by 1.&lt;/span&gt;\n &lt;span&gt;Following 1.&lt;/span&gt;\n &lt;/div&gt;\n &lt;div class=&#34;accountstats&#34; aria-hidden=&#34;true&#34;&gt;\n &lt;b&gt;Joined&lt;/b&gt;&lt;time datetime=&#34;2022-06-04T13:12:00.000Z&#34;&gt;Jun, 2022&lt;/time&gt;\n &lt;b&gt;Posts&lt;/b&gt;&lt;span&gt;8&lt;/span&gt;\n &lt;b&gt;Followed by&lt;/b&gt;&lt;span&gt;1&lt;/span&gt;\n &lt;b&gt;Following&lt;/b&gt;&lt;span&gt;1&lt;/span&gt;\n &lt;/div&gt;\n&lt;/section&gt;\n</code></pre><p>There, hope you liked that!</p>",
Text: "Here's a bunch of HTML, read it and weep, weep then!\n\n```html\n<section class=\"about-user\">\n <div class=\"col-header\">\n <h2>About</h2>\n </div> \n <div class=\"fields\">\n <h3 class=\"sr-only\">Fields</h3>\n <dl>\n <div class=\"field\">\n <dt>should you follow me?</dt>\n <dd>maybe!</dd>\n </div>\n <div class=\"field\">\n <dt>age</dt>\n <dd>120</dd>\n </div>… <h3 class=\"sr-only\">Stats</h3>\n <span>Joined in Jun, 2022.</span>\n <span>8 posts.</span>\n <span>Followed by 1.</span>\n <span>Following 1.</span>\n </div>\n <div class=\"accountstats\" aria-hidden=\"true\">\n <b>Joined</b><time datetime=\"2022-06-04T13:12:00.000Z\">Jun, 2022</time>\n <b>Posts</b><span>8</span>\n <b>Followed by</b><span>1</span>\n <b>Following</b><span>1</span>\n </div>\n</section>\n```\n\nThere, hope you liked that!", Text: "Here's a bunch of HTML, read it and weep, weep then!\n\n```html\n<section class=\"about-user\">\n <div class=\"col-header\">\n <h2>About</h2>\n </div> \n <div class=\"fields\">\n <h3 class=\"sr-only\">Fields</h3>\n <dl>\n <div class=\"field\">\n <dt>should you follow me?</dt>\n <dd>maybe!</dd>\n </div>\n <div class=\"field\">\n <dt>age</dt>\n <dd>120</dd>\n </div>… <h3 class=\"sr-only\">Stats</h3>\n <span>Joined in Jun, 2022.</span>\n <span>8 posts.</span>\n <span>Followed by 1.</span>\n <span>Following 1.</span>\n </div>\n <div class=\"accountstats\" aria-hidden=\"true\">\n <b>Joined</b><time datetime=\"2022-06-04T13:12:00.000Z\">Jun, 2022</time>\n <b>Posts</b><span>8</span>\n <b>Followed by</b><span>1</span>\n <b>Following</b><span>1</span>\n </div>\n</section>\n```\n\nThere, hope you liked that!",
ContentType: gtsmodel.StatusContentTypeMarkdown,
CreatedAt: TimeMustParse("2023-12-10T11:24:00+02:00"), CreatedAt: TimeMustParse("2023-12-10T11:24:00+02:00"),
EditedAt: time.Time{}, EditedAt: time.Time{},
Local: util.Ptr(true), Local: util.Ptr(true),
@ -1707,6 +1718,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status {
URL: "http://localhost:8080/@the_mighty_zork/statuses/01J2M1HPFSS54S60Y0KYV23KJE", URL: "http://localhost:8080/@the_mighty_zork/statuses/01J2M1HPFSS54S60Y0KYV23KJE",
Content: "<p>Thanks! Here's a NIN track</p>", Content: "<p>Thanks! Here's a NIN track</p>",
Text: "Thanks! Here's a NIN track", Text: "Thanks! Here's a NIN track",
ContentType: gtsmodel.StatusContentTypeMarkdown,
AttachmentIDs: []string{"01J2M20K6K9XQC4WSB961YJHV6"}, AttachmentIDs: []string{"01J2M20K6K9XQC4WSB961YJHV6"},
CreatedAt: TimeMustParse("2024-01-10T11:24:00+02:00"), CreatedAt: TimeMustParse("2024-01-10T11:24:00+02:00"),
EditedAt: time.Time{}, EditedAt: time.Time{},
@ -1732,6 +1744,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status {
URL: "http://localhost:8080/@the_mighty_zork/statuses/01JDPZC707CKDN8N4QVWM4Z1NR", URL: "http://localhost:8080/@the_mighty_zork/statuses/01JDPZC707CKDN8N4QVWM4Z1NR",
Content: "<p>this is the latest revision of the status, with a content-warning</p>", Content: "<p>this is the latest revision of the status, with a content-warning</p>",
Text: "this is the latest revision of the status, with a content-warning", Text: "this is the latest revision of the status, with a content-warning",
ContentType: gtsmodel.StatusContentTypeMarkdown,
ContentWarning: "edited status", ContentWarning: "edited status",
AttachmentIDs: nil, AttachmentIDs: nil,
CreatedAt: TimeMustParse("2024-11-01T11:00:00+02:00"), CreatedAt: TimeMustParse("2024-11-01T11:00:00+02:00"),
@ -1758,6 +1771,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status {
URL: "http://localhost:8080/@1happyturtle/statuses/01F8MHBQCBTDKN6X5VHGMMN4MA", URL: "http://localhost:8080/@1happyturtle/statuses/01F8MHBQCBTDKN6X5VHGMMN4MA",
Content: "🐢 hi everyone i post about turtles 🐢", Content: "🐢 hi everyone i post about turtles 🐢",
Text: "🐢 hi everyone i post about turtles 🐢", Text: "🐢 hi everyone i post about turtles 🐢",
ContentType: gtsmodel.StatusContentTypePlain,
CreatedAt: TimeMustParse("2021-10-20T12:40:37+02:00"), CreatedAt: TimeMustParse("2021-10-20T12:40:37+02:00"),
EditedAt: time.Time{}, EditedAt: time.Time{},
Local: util.Ptr(true), Local: util.Ptr(true),
@ -1781,6 +1795,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status {
URL: "http://localhost:8080/@1happyturtle/statuses/01F8MHC0H0A7XHTVH5F596ZKBM", URL: "http://localhost:8080/@1happyturtle/statuses/01F8MHC0H0A7XHTVH5F596ZKBM",
Content: "🐢 this one is federated, likeable, and boostable but not replyable 🐢", Content: "🐢 this one is federated, likeable, and boostable but not replyable 🐢",
Text: "🐢 this one is federated, likeable, and boostable but not replyable 🐢", Text: "🐢 this one is federated, likeable, and boostable but not replyable 🐢",
ContentType: gtsmodel.StatusContentTypePlain,
CreatedAt: TimeMustParse("2021-10-20T12:40:37+02:00"), CreatedAt: TimeMustParse("2021-10-20T12:40:37+02:00"),
EditedAt: time.Time{}, EditedAt: time.Time{},
Local: util.Ptr(true), Local: util.Ptr(true),
@ -1815,6 +1830,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status {
URL: "http://localhost:8080/@1happyturtle/statuses/01F8MHC8VWDRBQR0N1BATDDEM5", URL: "http://localhost:8080/@1happyturtle/statuses/01F8MHC8VWDRBQR0N1BATDDEM5",
Content: "🐢 i don't mind people sharing and liking this one but I want to moderate replies to it 🐢", Content: "🐢 i don't mind people sharing and liking this one but I want to moderate replies to it 🐢",
Text: "🐢 i don't mind people sharing and liking this one but I want to moderate replies to it 🐢", Text: "🐢 i don't mind people sharing and liking this one but I want to moderate replies to it 🐢",
ContentType: gtsmodel.StatusContentTypePlain,
CreatedAt: TimeMustParse("2021-10-20T12:40:37+02:00"), CreatedAt: TimeMustParse("2021-10-20T12:40:37+02:00"),
EditedAt: time.Time{}, EditedAt: time.Time{},
Local: util.Ptr(true), Local: util.Ptr(true),
@ -1850,6 +1866,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status {
URL: "http://localhost:8080/@1happyturtle/statuses/01F8MHCP5P2NWYQ416SBA0XSEV", URL: "http://localhost:8080/@1happyturtle/statuses/01F8MHCP5P2NWYQ416SBA0XSEV",
Content: "🐢 this is a public status but I want it local only and not boostable 🐢", Content: "🐢 this is a public status but I want it local only and not boostable 🐢",
Text: "🐢 this is a public status but I want it local only and not boostable 🐢", Text: "🐢 this is a public status but I want it local only and not boostable 🐢",
ContentType: gtsmodel.StatusContentTypePlain,
CreatedAt: TimeMustParse("2021-10-20T12:40:37+02:00"), CreatedAt: TimeMustParse("2021-10-20T12:40:37+02:00"),
EditedAt: time.Time{}, EditedAt: time.Time{},
Local: util.Ptr(true), Local: util.Ptr(true),
@ -1884,6 +1901,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status {
URL: "http://localhost:8080/@1happyturtle/statuses/01FCQSQ667XHJ9AV9T27SJJSX5", URL: "http://localhost:8080/@1happyturtle/statuses/01FCQSQ667XHJ9AV9T27SJJSX5",
Content: "🐢 @the_mighty_zork hi zork! 🐢", Content: "🐢 @the_mighty_zork hi zork! 🐢",
Text: "🐢 @the_mighty_zork hi zork! 🐢", Text: "🐢 @the_mighty_zork hi zork! 🐢",
ContentType: gtsmodel.StatusContentTypePlain,
CreatedAt: TimeMustParse("2021-10-20T12:40:37+02:00"), CreatedAt: TimeMustParse("2021-10-20T12:40:37+02:00"),
EditedAt: time.Time{}, EditedAt: time.Time{},
Local: util.Ptr(true), Local: util.Ptr(true),
@ -1910,6 +1928,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status {
URL: "http://localhost:8080/@1happyturtle/statuses/01FN3VJGFH10KR7S2PB0GFJZYG", URL: "http://localhost:8080/@1happyturtle/statuses/01FN3VJGFH10KR7S2PB0GFJZYG",
Content: "🐢 @the_mighty_zork hi zork, this is a direct message, shhhhhh! 🐢", Content: "🐢 @the_mighty_zork hi zork, this is a direct message, shhhhhh! 🐢",
Text: "🐢 @the_mighty_zork hi zork, this is a direct message, shhhhhh! 🐢", Text: "🐢 @the_mighty_zork hi zork, this is a direct message, shhhhhh! 🐢",
ContentType: gtsmodel.StatusContentTypePlain,
CreatedAt: TimeMustParse("2021-10-20T12:40:37+02:00"), CreatedAt: TimeMustParse("2021-10-20T12:40:37+02:00"),
EditedAt: time.Time{}, EditedAt: time.Time{},
Local: util.Ptr(true), Local: util.Ptr(true),
@ -1937,6 +1956,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status {
URL: "http://localhost:8080/@1happyturtle/statuses/01G20ZM733MGN8J344T4ZDDFY1", URL: "http://localhost:8080/@1happyturtle/statuses/01G20ZM733MGN8J344T4ZDDFY1",
Content: "🐢 hi followers! did u know i'm a turtle? 🐢", Content: "🐢 hi followers! did u know i'm a turtle? 🐢",
Text: "🐢 hi followers! did u know i'm a turtle? 🐢", Text: "🐢 hi followers! did u know i'm a turtle? 🐢",
ContentType: gtsmodel.StatusContentTypePlain,
AttachmentIDs: []string{}, AttachmentIDs: []string{},
CreatedAt: TimeMustParse("2021-10-20T12:40:37+02:00"), CreatedAt: TimeMustParse("2021-10-20T12:40:37+02:00"),
EditedAt: time.Time{}, EditedAt: time.Time{},
@ -1961,6 +1981,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status {
URL: "http://localhost:8080/@1happyturtle/statuses/01HEN2PRXT0TF4YDRA64FZZRN7", URL: "http://localhost:8080/@1happyturtle/statuses/01HEN2PRXT0TF4YDRA64FZZRN7",
Content: "hey everyone i got stuck in a shed. any ideas for how to get out?", Content: "hey everyone i got stuck in a shed. any ideas for how to get out?",
Text: "hey everyone i got stuck in a shed. any ideas for how to get out?", Text: "hey everyone i got stuck in a shed. any ideas for how to get out?",
ContentType: gtsmodel.StatusContentTypePlain,
AttachmentIDs: nil, AttachmentIDs: nil,
CreatedAt: TimeMustParse("2021-07-28T10:40:37+02:00"), CreatedAt: TimeMustParse("2021-07-28T10:40:37+02:00"),
EditedAt: time.Time{}, EditedAt: time.Time{},
@ -1986,6 +2007,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status {
URL: "http://localhost:8080/@1happyturtle/statuses/01JDPZEZ77X1NX0TY9M10BK1HM", URL: "http://localhost:8080/@1happyturtle/statuses/01JDPZEZ77X1NX0TY9M10BK1HM",
Content: "<p>now edited to bring back the previous edit's media!</p>", Content: "<p>now edited to bring back the previous edit's media!</p>",
Text: "now edited to bring back the previous edit's media!", Text: "now edited to bring back the previous edit's media!",
ContentType: gtsmodel.StatusContentTypeMarkdown,
ContentWarning: "edit with media attachments", ContentWarning: "edit with media attachments",
AttachmentIDs: []string{"01JDQ164HM08SGJ7ZEK9003Z4B"}, AttachmentIDs: []string{"01JDQ164HM08SGJ7ZEK9003Z4B"},
CreatedAt: TimeMustParse("2024-11-01T10:00:00+02:00"), CreatedAt: TimeMustParse("2024-11-01T10:00:00+02:00"),
@ -2087,6 +2109,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status {
URL: "http://fossbros-anonymous.io/@foss_satan/statuses/______", URL: "http://fossbros-anonymous.io/@foss_satan/statuses/______",
Content: "<p>this is the latest status edit without poll change</p>", Content: "<p>this is the latest status edit without poll change</p>",
Text: "this is the latest status edit without poll change", Text: "this is the latest status edit without poll change",
ContentType: gtsmodel.StatusContentTypeMarkdown,
ContentWarning: "", ContentWarning: "",
AttachmentIDs: nil, AttachmentIDs: nil,
CreatedAt: TimeMustParse("2024-11-01T09:00:00+02:00"), CreatedAt: TimeMustParse("2024-11-01T09:00:00+02:00"),
@ -3617,6 +3640,7 @@ func NewTestStatusEdits() map[string]*gtsmodel.StatusEdit {
Content: "<p>this is the original status</p>", Content: "<p>this is the original status</p>",
ContentWarning: "", ContentWarning: "",
Text: "this is the original status", Text: "this is the original status",
ContentType: gtsmodel.StatusContentTypeMarkdown,
Language: "en", Language: "en",
Sensitive: util.Ptr(false), Sensitive: util.Ptr(false),
AttachmentIDs: nil, AttachmentIDs: nil,
@ -3630,6 +3654,7 @@ func NewTestStatusEdits() map[string]*gtsmodel.StatusEdit {
Content: "<p>this is the first status edit! now with content-warning</p>", Content: "<p>this is the first status edit! now with content-warning</p>",
ContentWarning: "edited status", ContentWarning: "edited status",
Text: "this is the first status edit! now with content-warning", Text: "this is the first status edit! now with content-warning",
ContentType: gtsmodel.StatusContentTypeMarkdown,
Language: "en", Language: "en",
Sensitive: util.Ptr(false), Sensitive: util.Ptr(false),
AttachmentIDs: nil, AttachmentIDs: nil,
@ -3643,6 +3668,7 @@ func NewTestStatusEdits() map[string]*gtsmodel.StatusEdit {
Content: "<p>this is the original status</p>", Content: "<p>this is the original status</p>",
ContentWarning: "", ContentWarning: "",
Text: "this is the original status", Text: "this is the original status",
ContentType: gtsmodel.StatusContentTypeMarkdown,
Language: "en", Language: "en",
Sensitive: util.Ptr(false), Sensitive: util.Ptr(false),
AttachmentIDs: nil, AttachmentIDs: nil,
@ -3656,6 +3682,7 @@ func NewTestStatusEdits() map[string]*gtsmodel.StatusEdit {
Content: "<p>now edited to have some media!</p>", Content: "<p>now edited to have some media!</p>",
ContentWarning: "edit with media attachments", ContentWarning: "edit with media attachments",
Text: "now edited to have some media!", Text: "now edited to have some media!",
ContentType: gtsmodel.StatusContentTypeMarkdown,
Language: "en", Language: "en",
Sensitive: util.Ptr(true), Sensitive: util.Ptr(true),
AttachmentIDs: []string{"01JDQ164HM08SGJ7ZEK9003Z4B"}, AttachmentIDs: []string{"01JDQ164HM08SGJ7ZEK9003Z4B"},
@ -3669,6 +3696,7 @@ func NewTestStatusEdits() map[string]*gtsmodel.StatusEdit {
Content: "<p>now edited to remove the media</p>", Content: "<p>now edited to remove the media</p>",
ContentWarning: "edit missing previous media attachments", ContentWarning: "edit missing previous media attachments",
Text: "now edited to remove the media", Text: "now edited to remove the media",
ContentType: gtsmodel.StatusContentTypeMarkdown,
Language: "en", Language: "en",
Sensitive: util.Ptr(false), Sensitive: util.Ptr(false),
AttachmentIDs: nil, AttachmentIDs: nil,
@ -3682,6 +3710,7 @@ func NewTestStatusEdits() map[string]*gtsmodel.StatusEdit {
Content: "<p>this is the original status, with a poll!</p>", Content: "<p>this is the original status, with a poll!</p>",
ContentWarning: "", ContentWarning: "",
Text: "this is the original status, with a poll!", Text: "this is the original status, with a poll!",
ContentType: gtsmodel.StatusContentTypeMarkdown,
Language: "en", Language: "en",
Sensitive: util.Ptr(false), Sensitive: util.Ptr(false),
AttachmentIDs: nil, AttachmentIDs: nil,
@ -3695,6 +3724,7 @@ func NewTestStatusEdits() map[string]*gtsmodel.StatusEdit {
Content: "<p>this is the first status edit! now with a different poll!</p>", Content: "<p>this is the first status edit! now with a different poll!</p>",
ContentWarning: "edited status", ContentWarning: "edited status",
Text: "this is the first status edit! now with a different poll!", Text: "this is the first status edit! now with a different poll!",
ContentType: gtsmodel.StatusContentTypeMarkdown,
Language: "en", Language: "en",
Sensitive: util.Ptr(false), Sensitive: util.Ptr(false),
AttachmentIDs: nil, AttachmentIDs: nil,