diff options
Diffstat (limited to 'internal/typeutils')
-rw-r--r-- | internal/typeutils/internaltofrontend.go | 172 | ||||
-rw-r--r-- | internal/typeutils/internaltofrontend_test.go | 130 |
2 files changed, 279 insertions, 23 deletions
diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index e0276a53b..3208fcb51 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -1216,21 +1216,6 @@ func (c *Converter) StatusToWebStatus( return webStatus, nil } -// StatusToAPIStatusSource returns the *apimodel.StatusSource of the given status. -// Callers should check beforehand whether a requester has permission to view the -// source of the status, and ensure they're passing only a local status into this function. -func (c *Converter) StatusToAPIStatusSource(ctx context.Context, s *gtsmodel.Status) (*apimodel.StatusSource, error) { - // TODO: remove this when edit support is added. - text := "**STATUS EDITS ARE NOT CURRENTLY SUPPORTED IN GOTOSOCIAL (coming in 2024)**\n" + - "You can review the original text of your status below, but you will not be able to submit this edit.\n\n---\n\n" + s.Text - - return &apimodel.StatusSource{ - ID: s.ID, - Text: text, - SpoilerText: s.ContentWarning, - }, nil -} - // statusToFrontend is a package internal function for // parsing a status into its initial frontend representation. // @@ -1472,6 +1457,149 @@ func (c *Converter) baseStatusToFrontend( return apiStatus, nil } +// StatusToAPIEdits converts a status and its historical edits (if any) to a slice of API model status edits. +func (c *Converter) StatusToAPIEdits(ctx context.Context, status *gtsmodel.Status) ([]*apimodel.StatusEdit, error) { + var media map[string]*gtsmodel.MediaAttachment + + // Gather attachments of status AND edits. + attachmentIDs := status.AllAttachmentIDs() + if len(attachmentIDs) > 0 { + + // Fetch all of the gathered status attachments from the database. + attachments, err := c.state.DB.GetAttachmentsByIDs(ctx, attachmentIDs) + if err != nil { + return nil, gtserror.Newf("error getting attachments from db: %w", err) + } + + // Generate a lookup map in 'media' of status attachments by their IDs. + media = util.KeyBy(attachments, func(m *gtsmodel.MediaAttachment) string { + return m.ID + }) + } + + // Convert the status author account to API model. + apiAccount, err := c.AccountToAPIAccountPublic(ctx, + status.Account, + ) + if err != nil { + return nil, gtserror.Newf("error converting account: %w", err) + } + + // Convert status emojis to their API models, + // this includes all status emojis both current + // and historic, so it gets passed to each edit. + apiEmojis, err := c.convertEmojisToAPIEmojis(ctx, + nil, + status.EmojiIDs, + ) + if err != nil { + return nil, gtserror.Newf("error converting emojis: %w", err) + } + + var votes []int + var options []string + + if status.Poll != nil { + // Extract status poll options. + options = status.Poll.Options + + // Show votes only if closed / allowed. + if !status.Poll.ClosedAt.IsZero() || + !*status.Poll.HideCounts { + votes = status.Poll.Votes + } + } + + // Append status itself to final slot in the edits + // so we can add its revision using the below loop. + edits := append(status.Edits, >smodel.StatusEdit{ //nolint:gocritic + Content: status.Content, + ContentWarning: status.ContentWarning, + Sensitive: status.Sensitive, + PollOptions: options, + PollVotes: votes, + AttachmentIDs: status.AttachmentIDs, + AttachmentDescriptions: nil, // no change from current + CreatedAt: status.UpdatedAt, + }) + + // Iterate through status edits, starting at newest. + apiEdits := make([]*apimodel.StatusEdit, 0, len(edits)) + for i := len(edits) - 1; i >= 0; i-- { + edit := edits[i] + + // Iterate through edit attachment IDs, getting model from 'media' lookup. + apiAttachments := make([]*apimodel.Attachment, 0, len(edit.AttachmentIDs)) + for _, id := range edit.AttachmentIDs { + attachment, ok := media[id] + if !ok { + continue + } + + // Convert each media attachment to frontend API model. + apiAttachment, err := c.AttachmentToAPIAttachment(ctx, + attachment, + ) + if err != nil { + log.Error(ctx, "error converting attachment: %v", err) + continue + } + + // Append converted media attachment to return slice. + apiAttachments = append(apiAttachments, &apiAttachment) + } + + // If media descriptions are set, update API model descriptions. + if len(edit.AttachmentIDs) == len(edit.AttachmentDescriptions) { + var j int + for i, id := range edit.AttachmentIDs { + descr := edit.AttachmentDescriptions[i] + for ; j < len(apiAttachments); j++ { + if apiAttachments[j].ID == id { + apiAttachments[j].Description = &descr + break + } + } + } + } + + // Attach status poll if set. + var apiPoll *apimodel.Poll + if len(edit.PollOptions) > 0 { + apiPoll = new(apimodel.Poll) + + // Iterate through poll options and attach to API poll model. + apiPoll.Options = make([]apimodel.PollOption, len(edit.PollOptions)) + for i, option := range edit.PollOptions { + apiPoll.Options[i] = apimodel.PollOption{ + Title: option, + } + } + + // If poll votes are attached, set vote counts. + if len(edit.PollVotes) == len(apiPoll.Options) { + for i, votes := range edit.PollVotes { + apiPoll.Options[i].VotesCount = &votes + } + } + } + + // Append this status edit to the return slice. + apiEdits = append(apiEdits, &apimodel.StatusEdit{ + CreatedAt: util.FormatISO8601(edit.CreatedAt), + Content: edit.Content, + SpoilerText: edit.ContentWarning, + Sensitive: util.PtrOrZero(edit.Sensitive), + Account: apiAccount, + Poll: apiPoll, + MediaAttachments: apiAttachments, + Emojis: apiEmojis, // same models used for whole status + all edits + }) + } + + return apiEdits, nil +} + // VisToAPIVis converts a gts visibility into its api equivalent func (c *Converter) VisToAPIVis(ctx context.Context, m gtsmodel.Visibility) apimodel.Visibility { switch m { @@ -1488,7 +1616,7 @@ func (c *Converter) VisToAPIVis(ctx context.Context, m gtsmodel.Visibility) apim } // InstanceRuleToAdminAPIRule converts a local instance rule into its api equivalent for serving at /api/v1/admin/instance/rules/:id -func (c *Converter) InstanceRuleToAPIRule(r gtsmodel.Rule) apimodel.InstanceRule { +func InstanceRuleToAPIRule(r gtsmodel.Rule) apimodel.InstanceRule { return apimodel.InstanceRule{ ID: r.ID, Text: r.Text, @@ -1496,18 +1624,16 @@ func (c *Converter) InstanceRuleToAPIRule(r gtsmodel.Rule) apimodel.InstanceRule } // InstanceRulesToAPIRules converts all local instance rules into their api equivalent for serving at /api/v1/instance/rules -func (c *Converter) InstanceRulesToAPIRules(r []gtsmodel.Rule) []apimodel.InstanceRule { +func InstanceRulesToAPIRules(r []gtsmodel.Rule) []apimodel.InstanceRule { rules := make([]apimodel.InstanceRule, len(r)) - for i, v := range r { - rules[i] = c.InstanceRuleToAPIRule(v) + rules[i] = InstanceRuleToAPIRule(v) } - return rules } // InstanceRuleToAdminAPIRule converts a local instance rule into its api equivalent for serving at /api/v1/admin/instance/rules/:id -func (c *Converter) InstanceRuleToAdminAPIRule(r *gtsmodel.Rule) *apimodel.AdminInstanceRule { +func InstanceRuleToAdminAPIRule(r *gtsmodel.Rule) *apimodel.AdminInstanceRule { return &apimodel.AdminInstanceRule{ ID: r.ID, CreatedAt: util.FormatISO8601(r.CreatedAt), @@ -1540,7 +1666,7 @@ func (c *Converter) InstanceToAPIV1Instance(ctx context.Context, i *gtsmodel.Ins ApprovalRequired: true, // approval always required InvitesEnabled: false, // todo: not supported yet MaxTootChars: uint(config.GetStatusesMaxChars()), // #nosec G115 -- Already validated. - Rules: c.InstanceRulesToAPIRules(i.Rules), + Rules: InstanceRulesToAPIRules(i.Rules), Terms: i.Terms, TermsRaw: i.TermsText, } @@ -1674,7 +1800,7 @@ func (c *Converter) InstanceToAPIV2Instance(ctx context.Context, i *gtsmodel.Ins CustomCSS: i.CustomCSS, Usage: apimodel.InstanceV2Usage{}, // todo: not implemented Languages: config.GetInstanceLanguages().TagStrs(), - Rules: c.InstanceRulesToAPIRules(i.Rules), + Rules: InstanceRulesToAPIRules(i.Rules), Terms: i.Terms, TermsText: i.TermsText, } diff --git a/internal/typeutils/internaltofrontend_test.go b/internal/typeutils/internaltofrontend_test.go index 0ec9ea05f..39a9bd9d4 100644 --- a/internal/typeutils/internaltofrontend_test.go +++ b/internal/typeutils/internaltofrontend_test.go @@ -3737,6 +3737,136 @@ func (suite *InternalToFrontendTestSuite) TestConversationToAPI() { }`, string(b)) } +func (suite *InternalToFrontendTestSuite) TestStatusToAPIEdits() { + ctx, cncl := context.WithCancel(context.Background()) + defer cncl() + + statusID := suite.testStatuses["local_account_1_status_9"].ID + + status, err := suite.state.DB.GetStatusByID(ctx, statusID) + suite.NoError(err) + + err = suite.state.DB.PopulateStatusEdits(ctx, status) + suite.NoError(err) + + apiEdits, err := suite.typeconverter.StatusToAPIEdits(ctx, status) + suite.NoError(err) + + b, err := json.MarshalIndent(apiEdits, "", " ") + suite.NoError(err) + + suite.Equal(`[ + { + "content": "\u003cp\u003ethis is the latest revision of the status, with a content-warning\u003c/p\u003e", + "spoiler_text": "edited status", + "sensitive": false, + "created_at": "2024-11-01T09:02:00.000Z", + "account": { + "id": "01F8MH1H7YV1Z7D2C8K2730QBF", + "username": "the_mighty_zork", + "acct": "the_mighty_zork", + "display_name": "original zork (he/they)", + "locked": false, + "discoverable": true, + "bot": false, + "created_at": "2022-05-20T11:09:18.000Z", + "note": "\u003cp\u003ehey yo this is my profile!\u003c/p\u003e", + "url": "http://localhost:8080/@the_mighty_zork", + "avatar": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg", + "avatar_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.webp", + "avatar_description": "a green goblin looking nasty", + "avatar_media_id": "01F8MH58A357CV5K7R7TJMSH6S", + "header": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", + "header_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.webp", + "header_description": "A very old-school screenshot of the original team fortress mod for quake", + "header_media_id": "01PFPMWK2FF0D9WMHEJHR07C3Q", + "followers_count": 2, + "following_count": 2, + "statuses_count": 9, + "last_status_at": "2024-11-01", + "emojis": [], + "fields": [], + "enable_rss": true + }, + "poll": null, + "media_attachments": [], + "emojis": [] + }, + { + "content": "\u003cp\u003ethis is the first status edit! now with content-warning\u003c/p\u003e", + "spoiler_text": "edited status", + "sensitive": false, + "created_at": "2024-11-01T09:01:00.000Z", + "account": { + "id": "01F8MH1H7YV1Z7D2C8K2730QBF", + "username": "the_mighty_zork", + "acct": "the_mighty_zork", + "display_name": "original zork (he/they)", + "locked": false, + "discoverable": true, + "bot": false, + "created_at": "2022-05-20T11:09:18.000Z", + "note": "\u003cp\u003ehey yo this is my profile!\u003c/p\u003e", + "url": "http://localhost:8080/@the_mighty_zork", + "avatar": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg", + "avatar_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.webp", + "avatar_description": "a green goblin looking nasty", + "avatar_media_id": "01F8MH58A357CV5K7R7TJMSH6S", + "header": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", + "header_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.webp", + "header_description": "A very old-school screenshot of the original team fortress mod for quake", + "header_media_id": "01PFPMWK2FF0D9WMHEJHR07C3Q", + "followers_count": 2, + "following_count": 2, + "statuses_count": 9, + "last_status_at": "2024-11-01", + "emojis": [], + "fields": [], + "enable_rss": true + }, + "poll": null, + "media_attachments": [], + "emojis": [] + }, + { + "content": "\u003cp\u003ethis is the original status\u003c/p\u003e", + "spoiler_text": "", + "sensitive": false, + "created_at": "2024-11-01T09:00:00.000Z", + "account": { + "id": "01F8MH1H7YV1Z7D2C8K2730QBF", + "username": "the_mighty_zork", + "acct": "the_mighty_zork", + "display_name": "original zork (he/they)", + "locked": false, + "discoverable": true, + "bot": false, + "created_at": "2022-05-20T11:09:18.000Z", + "note": "\u003cp\u003ehey yo this is my profile!\u003c/p\u003e", + "url": "http://localhost:8080/@the_mighty_zork", + "avatar": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg", + "avatar_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.webp", + "avatar_description": "a green goblin looking nasty", + "avatar_media_id": "01F8MH58A357CV5K7R7TJMSH6S", + "header": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", + "header_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.webp", + "header_description": "A very old-school screenshot of the original team fortress mod for quake", + "header_media_id": "01PFPMWK2FF0D9WMHEJHR07C3Q", + "followers_count": 2, + "following_count": 2, + "statuses_count": 9, + "last_status_at": "2024-11-01", + "emojis": [], + "fields": [], + "enable_rss": true + }, + "poll": null, + "media_attachments": [], + "emojis": [] + } +]`, string(b)) +} + func TestInternalToFrontendTestSuite(t *testing.T) { suite.Run(t, new(InternalToFrontendTestSuite)) } |