[feature] Notify accounts when a status they've interacted with has been edited (#4157)

This pull request adds sending notifications to local accounts that have interacted with a status, if we receive or create a new edit for that status.

closes https://codeberg.org/superseriousbusiness/gotosocial/issues/3991
Reviewed-on: https://codeberg.org/superseriousbusiness/gotosocial/pulls/4157
Co-authored-by: tobi <tobi.smethurst@protonmail.com>
Co-committed-by: tobi <tobi.smethurst@protonmail.com>
This commit is contained in:
tobi
2025-05-11 13:38:13 +00:00
committed by kim
parent 6b6946f42a
commit 8480a75808
19 changed files with 375 additions and 81 deletions

View File

@@ -748,6 +748,14 @@ func (p *clientAPI) UpdateStatus(ctx context.Context, cMsg *messages.FromClientA
}
}
// Notify of the latest edit.
if editsLen := len(status.EditIDs); editsLen != 0 {
editID := status.EditIDs[editsLen-1]
if err := p.surface.notifyStatusEdit(ctx, status, editID); err != nil {
log.Errorf(ctx, "error notifying status edit: %v", err)
}
}
// Push message that the status has been edited to streams.
if err := p.surface.timelineStatusUpdate(ctx, status); err != nil {
log.Errorf(ctx, "error streaming status edit: %v", err)

View File

@@ -2149,6 +2149,96 @@ func (suite *FromClientAPITestSuite) TestProcessUpdateStatusWithFollowedHashtag(
suite.checkNotWebPushed(testStructs.WebPushSender, receivingAccount.ID)
}
// Test that when someone edits a status that's been interacted with,
// the interacter gets a notification that the status has been edited.
func (suite *FromClientAPITestSuite) TestProcessUpdateStatusInteractedWith() {
testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath)
defer testrig.TearDownTestStructs(testStructs)
var (
ctx = context.Background()
postingAccount = suite.testAccounts["local_account_1"]
receivingAccount = suite.testAccounts["admin_account"]
streams = suite.openStreams(ctx,
testStructs.Processor,
receivingAccount,
nil,
)
notifStream = streams[stream.TimelineNotifications]
)
// Copy the test status.
//
// This is one that the receiving account
// has interacted with (by replying).
testStatus := new(gtsmodel.Status)
*testStatus = *suite.testStatuses["local_account_1_status_1"]
// Create + store an edit.
edit := &gtsmodel.StatusEdit{
// Just set the ID + status ID, other
// fields don't matter for this test.
ID: "01JTR74W15VS6A6MK15N5JVJ55",
StatusID: testStatus.ID,
}
if err := testStructs.State.DB.PutStatusEdit(ctx, edit); err != nil {
suite.FailNow(err.Error())
}
// Set edit on status as
// it would be for real.
testStatus.EditIDs = []string{edit.ID}
testStatus.Edits = []*gtsmodel.StatusEdit{edit}
// Update the status.
if err := testStructs.Processor.Workers().ProcessFromClientAPI(
ctx,
&messages.FromClientAPI{
APObjectType: ap.ObjectNote,
APActivityType: ap.ActivityUpdate,
GTSModel: testStatus,
Origin: postingAccount,
},
); err != nil {
suite.FailNow(err.Error())
}
// Wait for a notification to appear for the status.
var notif *gtsmodel.Notification
if !testrig.WaitFor(func() bool {
var err error
notif, err = testStructs.State.DB.GetNotification(
ctx,
gtsmodel.NotificationUpdate,
receivingAccount.ID,
postingAccount.ID,
edit.ID,
)
return err == nil
}) {
suite.FailNow("timed out waiting for edited status notification")
}
apiNotif, err := testStructs.TypeConverter.NotificationToAPINotification(ctx, notif, nil, nil)
if err != nil {
suite.FailNow(err.Error())
}
notifJSON, err := json.Marshal(apiNotif)
if err != nil {
suite.FailNow(err.Error())
}
// Check notif in stream.
suite.checkStreamed(
notifStream,
true,
string(notifJSON),
stream.EventTypeNotification,
)
}
func (suite *FromClientAPITestSuite) TestProcessStatusDelete() {
testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath)
defer testrig.TearDownTestStructs(testStructs)

View File

@@ -1010,6 +1010,14 @@ func (p *fediAPI) UpdateStatus(ctx context.Context, fMsg *messages.FromFediAPI)
}
}
// Notify of the latest edit.
if editsLen := len(status.EditIDs); editsLen != 0 {
editID := status.EditIDs[editsLen-1]
if err := p.surface.notifyStatusEdit(ctx, status, editID); err != nil {
log.Errorf(ctx, "error notifying status edit: %v", err)
}
}
// Push message that the status has been edited to streams.
if err := p.surface.timelineStatusUpdate(ctx, status); err != nil {
log.Errorf(ctx, "error streaming status edit: %v", err)

View File

@@ -102,7 +102,7 @@ func (suite *FromFediAPITestSuite) TestProcessFederationAnnounce() {
suite.Equal(gtsmodel.NotificationReblog, notif.NotificationType)
suite.Equal(boostedStatus.AccountID, notif.TargetAccountID)
suite.Equal(announceStatus.AccountID, notif.OriginAccountID)
suite.Equal(announceStatus.ID, notif.StatusID)
suite.Equal(announceStatus.ID, notif.StatusOrEditID)
suite.False(*notif.Read)
}
@@ -173,7 +173,7 @@ func (suite *FromFediAPITestSuite) TestProcessReplyMention() {
suite.Equal(gtsmodel.NotificationMention, notif.NotificationType)
suite.Equal(replyingStatus.InReplyToAccountID, notif.TargetAccountID)
suite.Equal(replyingStatus.AccountID, notif.OriginAccountID)
suite.Equal(replyingStatus.ID, notif.StatusID)
suite.Equal(replyingStatus.ID, notif.StatusOrEditID)
suite.False(*notif.Read)
ctx, _ := context.WithTimeout(context.Background(), time.Second*5)
@@ -245,7 +245,7 @@ func (suite *FromFediAPITestSuite) TestProcessFave() {
suite.Equal(gtsmodel.NotificationFavourite, notif.NotificationType)
suite.Equal(fave.TargetAccountID, notif.TargetAccountID)
suite.Equal(fave.AccountID, notif.OriginAccountID)
suite.Equal(fave.StatusID, notif.StatusID)
suite.Equal(fave.StatusID, notif.StatusOrEditID)
suite.False(*notif.Read)
ctx, _ := context.WithTimeout(context.Background(), time.Second*5)
@@ -318,7 +318,7 @@ func (suite *FromFediAPITestSuite) TestProcessFaveWithDifferentReceivingAccount(
suite.Equal(gtsmodel.NotificationFavourite, notif.NotificationType)
suite.Equal(fave.TargetAccountID, notif.TargetAccountID)
suite.Equal(fave.AccountID, notif.OriginAccountID)
suite.Equal(fave.StatusID, notif.StatusID)
suite.Equal(fave.StatusID, notif.StatusOrEditID)
suite.False(*notif.Read)
// 2. no notification should be streamed to the account that received the fave message, because they weren't the target

View File

@@ -30,6 +30,7 @@ import (
"code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
"code.superseriousbusiness.org/gotosocial/internal/id"
"code.superseriousbusiness.org/gotosocial/internal/util"
"code.superseriousbusiness.org/gotosocial/internal/util/xslices"
)
// notifyPendingReply notifies the account replied-to
@@ -555,19 +556,67 @@ func (s *Surface) notifySignup(ctx context.Context, newUser *gtsmodel.User) erro
return errs.Combine()
}
func (s *Surface) notifyStatusEdit(
ctx context.Context,
status *gtsmodel.Status,
editID string,
) error {
// Get local-only interactions (we can't/don't notify remotes).
interactions, err := s.State.DB.GetStatusInteractions(ctx, status.ID, true)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
return gtserror.Newf("db error getting status interactions: %w", err)
}
// Deduplicate interactions by account ID,
// we don't need to notify someone twice
// if they've both boosted *and* replied
// to an edited status, for example.
interactions = xslices.DeduplicateFunc(
interactions,
func(v gtsmodel.Interaction) string {
return v.GetAccount().ID
},
)
// Notify each account that's
// interacted with the status.
var errs gtserror.MultiError
for _, i := range interactions {
targetAcct := i.GetAccount()
if targetAcct.ID == status.AccountID {
// Don't notify an account
// if they've interacted
// with their *own* status.
continue
}
if err := s.Notify(ctx,
gtsmodel.NotificationUpdate,
targetAcct,
status.Account,
editID,
); err != nil {
errs.Appendf("error notifying status edit: %w", err)
continue
}
}
return errs.Combine()
}
func getNotifyLockURI(
notificationType gtsmodel.NotificationType,
targetAccount *gtsmodel.Account,
originAccount *gtsmodel.Account,
statusID string,
statusOrEditID string,
) string {
builder := strings.Builder{}
builder.WriteString("notification:?")
builder.WriteString("type=" + notificationType.String())
builder.WriteString("&target=" + targetAccount.URI)
builder.WriteString("&origin=" + originAccount.URI)
if statusID != "" {
builder.WriteString("&statusID=" + statusID)
builder.WriteString("&targetAcct=" + targetAccount.URI)
builder.WriteString("&originAcct=" + originAccount.URI)
if statusOrEditID != "" {
builder.WriteString("&statusOrEditID=" + statusOrEditID)
}
return builder.String()
}
@@ -582,13 +631,13 @@ func getNotifyLockURI(
// for non-local first.
//
// targetAccount and originAccount must be
// set, but statusID can be an empty string.
// set, but statusOrEditID can be empty.
func (s *Surface) Notify(
ctx context.Context,
notificationType gtsmodel.NotificationType,
targetAccount *gtsmodel.Account,
originAccount *gtsmodel.Account,
statusID string,
statusOrEditID string,
) error {
if targetAccount.IsRemote() {
// nothing to do.
@@ -601,7 +650,7 @@ func (s *Surface) Notify(
notificationType,
targetAccount,
originAccount,
statusID,
statusOrEditID,
)
unlock := s.State.ProcessingLocks.Lock(lockURI)
@@ -617,7 +666,7 @@ func (s *Surface) Notify(
notificationType,
targetAccount.ID,
originAccount.ID,
statusID,
statusOrEditID,
); err == nil {
// Notification exists;
// nothing to do.
@@ -636,7 +685,7 @@ func (s *Surface) Notify(
TargetAccount: targetAccount,
OriginAccountID: originAccount.ID,
OriginAccount: originAccount,
StatusID: statusID,
StatusOrEditID: statusOrEditID,
}
if err := s.State.DB.PutNotification(ctx, notif); err != nil {