[feature] add support for clients editing statuses and fetching status revision history (#3628)

* start adding client support for making status edits and viewing history

* modify 'freshest' freshness window to be 5s, add typeutils test for status -> api edits

* only populate the status edits when specifically requested

* start adding some simple processor status edit tests

* add test editing status but adding a poll

* test edits appropriately adding poll expiry handlers

* finish adding status edit tests

* store both new and old revision emojis in status

* add code comment

* ensure the requester's account is populated before status edits

* add code comments for status edit tests

* update status edit form swagger comments

* remove unused function

* fix status source test

* add more code comments, move media description check back to media process in status create

* fix tests, add necessary form struct tag
This commit is contained in:
kim
2024-12-23 17:54:44 +00:00
committed by GitHub
parent 1aa7f70660
commit fe8d5f2307
29 changed files with 2546 additions and 523 deletions

View File

@ -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, &gtsmodel.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,
}