mirror of
https://github.com/superseriousbusiness/gotosocial
synced 2025-06-05 21:59:39 +02:00
[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:
@@ -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)
|
||||
|
@@ -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 := >smodel.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)
|
||||
|
@@ -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)
|
||||
|
@@ -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
|
||||
|
@@ -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 {
|
||||
|
Reference in New Issue
Block a user