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:
2
internal/cache/db.go
vendored
2
internal/cache/db.go
vendored
@ -1155,7 +1155,7 @@ func (c *Caches) initNotification() {
|
|||||||
c.DB.Notification.Init(structr.CacheConfig[*gtsmodel.Notification]{
|
c.DB.Notification.Init(structr.CacheConfig[*gtsmodel.Notification]{
|
||||||
Indices: []structr.IndexConfig{
|
Indices: []structr.IndexConfig{
|
||||||
{Fields: "ID"},
|
{Fields: "ID"},
|
||||||
{Fields: "NotificationType,TargetAccountID,OriginAccountID,StatusID", AllowZero: true},
|
{Fields: "NotificationType,TargetAccountID,OriginAccountID,StatusOrEditID", AllowZero: true},
|
||||||
},
|
},
|
||||||
MaxSize: cap,
|
MaxSize: cap,
|
||||||
IgnoreErr: ignoreErrors,
|
IgnoreErr: ignoreErrors,
|
||||||
|
2
internal/cache/size.go
vendored
2
internal/cache/size.go
vendored
@ -573,7 +573,7 @@ func sizeofNotification() uintptr {
|
|||||||
CreatedAt: exampleTime,
|
CreatedAt: exampleTime,
|
||||||
TargetAccountID: exampleID,
|
TargetAccountID: exampleID,
|
||||||
OriginAccountID: exampleID,
|
OriginAccountID: exampleID,
|
||||||
StatusID: exampleID,
|
StatusOrEditID: exampleID,
|
||||||
Read: func() *bool { ok := false; return &ok }(),
|
Read: func() *bool { ok := false; return &ok }(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
@ -54,24 +54,28 @@ func (n *notificationDB) GetNotificationByID(ctx context.Context, id string) (*g
|
|||||||
|
|
||||||
func (n *notificationDB) GetNotification(
|
func (n *notificationDB) GetNotification(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
notificationType gtsmodel.NotificationType,
|
notifType gtsmodel.NotificationType,
|
||||||
targetAccountID string,
|
targetAcctID string,
|
||||||
originAccountID string,
|
originAcctID string,
|
||||||
statusID string,
|
statusOrEditID string,
|
||||||
) (*gtsmodel.Notification, error) {
|
) (*gtsmodel.Notification, error) {
|
||||||
return n.getNotification(
|
return n.getNotification(
|
||||||
ctx,
|
ctx,
|
||||||
"NotificationType,TargetAccountID,OriginAccountID,StatusID",
|
"NotificationType,TargetAccountID,OriginAccountID,StatusOrEditID",
|
||||||
func(notif *gtsmodel.Notification) error {
|
func(notif *gtsmodel.Notification) error {
|
||||||
return n.db.NewSelect().
|
q := n.db.NewSelect().
|
||||||
Model(notif).
|
Model(notif).
|
||||||
Where("? = ?", bun.Ident("notification_type"), notificationType).
|
Where("? = ?", bun.Ident("notification_type"), notifType).
|
||||||
Where("? = ?", bun.Ident("target_account_id"), targetAccountID).
|
Where("? = ?", bun.Ident("target_account_id"), targetAcctID).
|
||||||
Where("? = ?", bun.Ident("origin_account_id"), originAccountID).
|
Where("? = ?", bun.Ident("origin_account_id"), originAcctID)
|
||||||
Where("? = ?", bun.Ident("status_id"), statusID).
|
|
||||||
Scan(ctx)
|
if statusOrEditID != "" {
|
||||||
|
q = q.Where("? = ?", bun.Ident("status_id"), statusOrEditID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return q.Scan(ctx)
|
||||||
},
|
},
|
||||||
notificationType, targetAccountID, originAccountID, statusID,
|
notifType, targetAcctID, originAcctID, statusOrEditID,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -176,14 +180,29 @@ func (n *notificationDB) PopulateNotification(ctx context.Context, notif *gtsmod
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if notif.StatusID != "" && notif.Status == nil {
|
if notif.StatusOrEditID != "" && notif.Status == nil {
|
||||||
|
// Try getting status by ID first.
|
||||||
notif.Status, err = n.state.DB.GetStatusByID(
|
notif.Status, err = n.state.DB.GetStatusByID(
|
||||||
gtscontext.SetBarebones(ctx),
|
gtscontext.SetBarebones(ctx),
|
||||||
notif.StatusID,
|
notif.StatusOrEditID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||||
|
// Only append real db error. It might be an edit ID.
|
||||||
errs.Appendf("error populating notif status: %w", err)
|
errs.Appendf("error populating notif status: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if notif.Status == nil {
|
||||||
|
// If it's still not set, try
|
||||||
|
// getting status by edit ID.
|
||||||
|
notif.Status, err = n.state.DB.GetStatusByEditID(
|
||||||
|
gtscontext.SetBarebones(ctx),
|
||||||
|
notif.StatusOrEditID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
// Append any error here as it's an issue.
|
||||||
|
errs.Appendf("error populating notif status: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return errs.Combine()
|
return errs.Combine()
|
||||||
|
@ -70,7 +70,7 @@ func (suite *NotificationTestSuite) spamNotifs() {
|
|||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
TargetAccountID: targetAccountID,
|
TargetAccountID: targetAccountID,
|
||||||
OriginAccountID: originAccountID,
|
OriginAccountID: originAccountID,
|
||||||
StatusID: statusID,
|
StatusOrEditID: statusID,
|
||||||
Read: util.Ptr(false),
|
Read: util.Ptr(false),
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -263,7 +263,7 @@ func (suite *NotificationTestSuite) TestDeleteNotificationsPertainingToStatusID(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, n := range notif {
|
for _, n := range notif {
|
||||||
if n.StatusID == testStatus.ID {
|
if n.StatusOrEditID == testStatus.ID {
|
||||||
suite.FailNowf("", "no notifications with status id %s should remain", testStatus.ID)
|
suite.FailNowf("", "no notifications with status id %s should remain", testStatus.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -732,3 +732,105 @@ func (s *statusDB) GetDirectStatusIDsBatch(ctx context.Context, minID string, ma
|
|||||||
}
|
}
|
||||||
return statusIDs, nil
|
return statusIDs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *statusDB) GetStatusInteractions(
|
||||||
|
ctx context.Context,
|
||||||
|
statusID string,
|
||||||
|
localOnly bool,
|
||||||
|
) ([]gtsmodel.Interaction, error) {
|
||||||
|
// Prepare to get interactions.
|
||||||
|
interactions := []gtsmodel.Interaction{}
|
||||||
|
|
||||||
|
// Gather faves.
|
||||||
|
faves, err := s.state.DB.GetStatusFaves(ctx, statusID)
|
||||||
|
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, fave := range faves {
|
||||||
|
// Get account at least.
|
||||||
|
if fave.Account == nil {
|
||||||
|
fave.Account, err = s.state.DB.GetAccountByID(ctx, fave.AccountID)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(ctx, "error getting account for fave: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if localOnly && !fave.Account.IsLocal() {
|
||||||
|
// Skip not local.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
interactions = append(interactions, fave)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gather replies.
|
||||||
|
replies, err := s.state.DB.GetStatusReplies(ctx, statusID)
|
||||||
|
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, reply := range replies {
|
||||||
|
// Get account at least.
|
||||||
|
if reply.Account == nil {
|
||||||
|
reply.Account, err = s.state.DB.GetAccountByID(ctx, reply.AccountID)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(ctx, "error getting account for reply: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if localOnly && !reply.Account.IsLocal() {
|
||||||
|
// Skip not local.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
interactions = append(interactions, reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gather boosts.
|
||||||
|
boosts, err := s.state.DB.GetStatusBoosts(ctx, statusID)
|
||||||
|
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, boost := range boosts {
|
||||||
|
// Get account at least.
|
||||||
|
if boost.Account == nil {
|
||||||
|
boost.Account, err = s.state.DB.GetAccountByID(ctx, boost.AccountID)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(ctx, "error getting account for boost: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if localOnly && !boost.Account.IsLocal() {
|
||||||
|
// Skip not local.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
interactions = append(interactions, boost)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(interactions) == 0 {
|
||||||
|
return nil, db.ErrNoEntries
|
||||||
|
}
|
||||||
|
|
||||||
|
return interactions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *statusDB) GetStatusByEditID(
|
||||||
|
ctx context.Context,
|
||||||
|
editID string,
|
||||||
|
) (*gtsmodel.Status, error) {
|
||||||
|
edit, err := s.state.DB.GetStatusEditByID(
|
||||||
|
gtscontext.SetBarebones(ctx),
|
||||||
|
editID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.GetStatusByID(ctx, edit.StatusID)
|
||||||
|
}
|
||||||
|
@ -39,8 +39,14 @@ type Notification interface {
|
|||||||
GetNotificationsByIDs(ctx context.Context, ids []string) ([]*gtsmodel.Notification, error)
|
GetNotificationsByIDs(ctx context.Context, ids []string) ([]*gtsmodel.Notification, error)
|
||||||
|
|
||||||
// GetNotification gets one notification according to the provided parameters, if it exists.
|
// GetNotification gets one notification according to the provided parameters, if it exists.
|
||||||
// Since not all notifications are about a status, statusID can be an empty string.
|
// Since not all notifications are about a status or an edit, statusOrEditID can be empty.
|
||||||
GetNotification(ctx context.Context, notificationType gtsmodel.NotificationType, targetAccountID string, originAccountID string, statusID string) (*gtsmodel.Notification, error)
|
GetNotification(
|
||||||
|
ctx context.Context,
|
||||||
|
notifType gtsmodel.NotificationType,
|
||||||
|
targetAcctID string,
|
||||||
|
originAcctID string,
|
||||||
|
statusOrEditID string,
|
||||||
|
) (*gtsmodel.Notification, error)
|
||||||
|
|
||||||
// PopulateNotification ensures that the notification's struct fields are populated.
|
// PopulateNotification ensures that the notification's struct fields are populated.
|
||||||
PopulateNotification(ctx context.Context, notif *gtsmodel.Notification) error
|
PopulateNotification(ctx context.Context, notif *gtsmodel.Notification) error
|
||||||
|
@ -94,4 +94,12 @@ type Status interface {
|
|||||||
// MaxDirectStatusID, and expects to eventually return the status with that ID.
|
// MaxDirectStatusID, and expects to eventually return the status with that ID.
|
||||||
// It is used only by the conversation advanced migration.
|
// It is used only by the conversation advanced migration.
|
||||||
GetDirectStatusIDsBatch(ctx context.Context, minID string, maxIDInclusive string, count int) ([]string, error)
|
GetDirectStatusIDsBatch(ctx context.Context, minID string, maxIDInclusive string, count int) ([]string, error)
|
||||||
|
|
||||||
|
// GetStatusInteractions gets all abstract "interactions" of a status (likes, replies, boosts).
|
||||||
|
// If localOnly is true, will return only interactions performed by accounts on this instance.
|
||||||
|
// Aside from that, interactions are not filtered or deduplicated, it's up to the caller to do that.
|
||||||
|
GetStatusInteractions(ctx context.Context, statusID string, localOnly bool) ([]gtsmodel.Interaction, error)
|
||||||
|
|
||||||
|
// GetStatusByEditID gets one status corresponding to the given edit ID.
|
||||||
|
GetStatusByEditID(ctx context.Context, editID string) (*gtsmodel.Status, error)
|
||||||
}
|
}
|
||||||
|
@ -95,3 +95,10 @@ func (ir *InteractionRequest) IsAccepted() bool {
|
|||||||
func (ir *InteractionRequest) IsRejected() bool {
|
func (ir *InteractionRequest) IsRejected() bool {
|
||||||
return !ir.RejectedAt.IsZero()
|
return !ir.RejectedAt.IsZero()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Interaction abstractly represents
|
||||||
|
// one interaction with a status, via
|
||||||
|
// liking, replying to, or boosting it.
|
||||||
|
type Interaction interface {
|
||||||
|
GetAccount() *Account
|
||||||
|
}
|
||||||
|
@ -32,8 +32,8 @@ type Notification struct {
|
|||||||
TargetAccount *Account `bun:"-"` // Account corresponding to TargetAccountID. Can be nil, always check first + select using ID if necessary.
|
TargetAccount *Account `bun:"-"` // Account corresponding to TargetAccountID. Can be nil, always check first + select using ID if necessary.
|
||||||
OriginAccountID string `bun:"type:CHAR(26),nullzero,notnull"` // ID of the account that performed the action that created the notification.
|
OriginAccountID string `bun:"type:CHAR(26),nullzero,notnull"` // ID of the account that performed the action that created the notification.
|
||||||
OriginAccount *Account `bun:"-"` // Account corresponding to OriginAccountID. Can be nil, always check first + select using ID if necessary.
|
OriginAccount *Account `bun:"-"` // Account corresponding to OriginAccountID. Can be nil, always check first + select using ID if necessary.
|
||||||
StatusID string `bun:"type:CHAR(26),nullzero"` // If the notification pertains to a status, what is the database ID of that status?
|
StatusOrEditID string `bun:"status_id,type:CHAR(26),nullzero"` // If the notification pertains to a status or a status edit event, what is the database ID of the status or status edit?
|
||||||
Status *Status `bun:"-"` // Status corresponding to StatusID. Can be nil, always check first + select using ID if necessary.
|
Status *Status `bun:"-"` // Status corresponding to StatusOrEditID. Can be nil, always check first + select using ID if necessary.
|
||||||
Read *bool `bun:",nullzero,notnull,default:false"` // Notification has been seen/read
|
Read *bool `bun:",nullzero,notnull,default:false"` // Notification has been seen/read
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -89,6 +89,13 @@ func (s *Status) GetAccountID() string {
|
|||||||
return s.AccountID
|
return s.AccountID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAccount returns the account that owns
|
||||||
|
// this status. May be nil if status not populated.
|
||||||
|
// Fulfils Interaction interface.
|
||||||
|
func (s *Status) GetAccount() *Account {
|
||||||
|
return s.Account
|
||||||
|
}
|
||||||
|
|
||||||
// GetBoostOfID implements timeline.Timelineable{}.
|
// GetBoostOfID implements timeline.Timelineable{}.
|
||||||
func (s *Status) GetBoostOfID() string {
|
func (s *Status) GetBoostOfID() string {
|
||||||
return s.BoostOfID
|
return s.BoostOfID
|
||||||
|
@ -35,3 +35,10 @@ type StatusFave struct {
|
|||||||
PreApproved bool `bun:"-"` // If true, then fave targets 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.
|
PreApproved bool `bun:"-"` // If true, then fave targets 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 this Like.
|
ApprovedByURI string `bun:",nullzero"` // URI of an Accept Activity that approves this Like.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAccount returns the account that owns
|
||||||
|
// this fave. May be nil if fave not populated.
|
||||||
|
// Fulfils Interaction interface.
|
||||||
|
func (f *StatusFave) GetAccount() *Account {
|
||||||
|
return f.Account
|
||||||
|
}
|
||||||
|
@ -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.
|
// Push message that the status has been edited to streams.
|
||||||
if err := p.surface.timelineStatusUpdate(ctx, status); err != nil {
|
if err := p.surface.timelineStatusUpdate(ctx, status); err != nil {
|
||||||
log.Errorf(ctx, "error streaming status edit: %v", err)
|
log.Errorf(ctx, "error streaming status edit: %v", err)
|
||||||
|
@ -2149,6 +2149,96 @@ func (suite *FromClientAPITestSuite) TestProcessUpdateStatusWithFollowedHashtag(
|
|||||||
suite.checkNotWebPushed(testStructs.WebPushSender, receivingAccount.ID)
|
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() {
|
func (suite *FromClientAPITestSuite) TestProcessStatusDelete() {
|
||||||
testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath)
|
testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath)
|
||||||
defer testrig.TearDownTestStructs(testStructs)
|
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.
|
// Push message that the status has been edited to streams.
|
||||||
if err := p.surface.timelineStatusUpdate(ctx, status); err != nil {
|
if err := p.surface.timelineStatusUpdate(ctx, status); err != nil {
|
||||||
log.Errorf(ctx, "error streaming status edit: %v", err)
|
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(gtsmodel.NotificationReblog, notif.NotificationType)
|
||||||
suite.Equal(boostedStatus.AccountID, notif.TargetAccountID)
|
suite.Equal(boostedStatus.AccountID, notif.TargetAccountID)
|
||||||
suite.Equal(announceStatus.AccountID, notif.OriginAccountID)
|
suite.Equal(announceStatus.AccountID, notif.OriginAccountID)
|
||||||
suite.Equal(announceStatus.ID, notif.StatusID)
|
suite.Equal(announceStatus.ID, notif.StatusOrEditID)
|
||||||
suite.False(*notif.Read)
|
suite.False(*notif.Read)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -173,7 +173,7 @@ func (suite *FromFediAPITestSuite) TestProcessReplyMention() {
|
|||||||
suite.Equal(gtsmodel.NotificationMention, notif.NotificationType)
|
suite.Equal(gtsmodel.NotificationMention, notif.NotificationType)
|
||||||
suite.Equal(replyingStatus.InReplyToAccountID, notif.TargetAccountID)
|
suite.Equal(replyingStatus.InReplyToAccountID, notif.TargetAccountID)
|
||||||
suite.Equal(replyingStatus.AccountID, notif.OriginAccountID)
|
suite.Equal(replyingStatus.AccountID, notif.OriginAccountID)
|
||||||
suite.Equal(replyingStatus.ID, notif.StatusID)
|
suite.Equal(replyingStatus.ID, notif.StatusOrEditID)
|
||||||
suite.False(*notif.Read)
|
suite.False(*notif.Read)
|
||||||
|
|
||||||
ctx, _ := context.WithTimeout(context.Background(), time.Second*5)
|
ctx, _ := context.WithTimeout(context.Background(), time.Second*5)
|
||||||
@ -245,7 +245,7 @@ func (suite *FromFediAPITestSuite) TestProcessFave() {
|
|||||||
suite.Equal(gtsmodel.NotificationFavourite, notif.NotificationType)
|
suite.Equal(gtsmodel.NotificationFavourite, notif.NotificationType)
|
||||||
suite.Equal(fave.TargetAccountID, notif.TargetAccountID)
|
suite.Equal(fave.TargetAccountID, notif.TargetAccountID)
|
||||||
suite.Equal(fave.AccountID, notif.OriginAccountID)
|
suite.Equal(fave.AccountID, notif.OriginAccountID)
|
||||||
suite.Equal(fave.StatusID, notif.StatusID)
|
suite.Equal(fave.StatusID, notif.StatusOrEditID)
|
||||||
suite.False(*notif.Read)
|
suite.False(*notif.Read)
|
||||||
|
|
||||||
ctx, _ := context.WithTimeout(context.Background(), time.Second*5)
|
ctx, _ := context.WithTimeout(context.Background(), time.Second*5)
|
||||||
@ -318,7 +318,7 @@ func (suite *FromFediAPITestSuite) TestProcessFaveWithDifferentReceivingAccount(
|
|||||||
suite.Equal(gtsmodel.NotificationFavourite, notif.NotificationType)
|
suite.Equal(gtsmodel.NotificationFavourite, notif.NotificationType)
|
||||||
suite.Equal(fave.TargetAccountID, notif.TargetAccountID)
|
suite.Equal(fave.TargetAccountID, notif.TargetAccountID)
|
||||||
suite.Equal(fave.AccountID, notif.OriginAccountID)
|
suite.Equal(fave.AccountID, notif.OriginAccountID)
|
||||||
suite.Equal(fave.StatusID, notif.StatusID)
|
suite.Equal(fave.StatusID, notif.StatusOrEditID)
|
||||||
suite.False(*notif.Read)
|
suite.False(*notif.Read)
|
||||||
|
|
||||||
// 2. no notification should be streamed to the account that received the fave message, because they weren't the target
|
// 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/gtsmodel"
|
||||||
"code.superseriousbusiness.org/gotosocial/internal/id"
|
"code.superseriousbusiness.org/gotosocial/internal/id"
|
||||||
"code.superseriousbusiness.org/gotosocial/internal/util"
|
"code.superseriousbusiness.org/gotosocial/internal/util"
|
||||||
|
"code.superseriousbusiness.org/gotosocial/internal/util/xslices"
|
||||||
)
|
)
|
||||||
|
|
||||||
// notifyPendingReply notifies the account replied-to
|
// notifyPendingReply notifies the account replied-to
|
||||||
@ -555,19 +556,67 @@ func (s *Surface) notifySignup(ctx context.Context, newUser *gtsmodel.User) erro
|
|||||||
return errs.Combine()
|
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(
|
func getNotifyLockURI(
|
||||||
notificationType gtsmodel.NotificationType,
|
notificationType gtsmodel.NotificationType,
|
||||||
targetAccount *gtsmodel.Account,
|
targetAccount *gtsmodel.Account,
|
||||||
originAccount *gtsmodel.Account,
|
originAccount *gtsmodel.Account,
|
||||||
statusID string,
|
statusOrEditID string,
|
||||||
) string {
|
) string {
|
||||||
builder := strings.Builder{}
|
builder := strings.Builder{}
|
||||||
builder.WriteString("notification:?")
|
builder.WriteString("notification:?")
|
||||||
builder.WriteString("type=" + notificationType.String())
|
builder.WriteString("type=" + notificationType.String())
|
||||||
builder.WriteString("&target=" + targetAccount.URI)
|
builder.WriteString("&targetAcct=" + targetAccount.URI)
|
||||||
builder.WriteString("&origin=" + originAccount.URI)
|
builder.WriteString("&originAcct=" + originAccount.URI)
|
||||||
if statusID != "" {
|
if statusOrEditID != "" {
|
||||||
builder.WriteString("&statusID=" + statusID)
|
builder.WriteString("&statusOrEditID=" + statusOrEditID)
|
||||||
}
|
}
|
||||||
return builder.String()
|
return builder.String()
|
||||||
}
|
}
|
||||||
@ -582,13 +631,13 @@ func getNotifyLockURI(
|
|||||||
// for non-local first.
|
// for non-local first.
|
||||||
//
|
//
|
||||||
// targetAccount and originAccount must be
|
// targetAccount and originAccount must be
|
||||||
// set, but statusID can be an empty string.
|
// set, but statusOrEditID can be empty.
|
||||||
func (s *Surface) Notify(
|
func (s *Surface) Notify(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
notificationType gtsmodel.NotificationType,
|
notificationType gtsmodel.NotificationType,
|
||||||
targetAccount *gtsmodel.Account,
|
targetAccount *gtsmodel.Account,
|
||||||
originAccount *gtsmodel.Account,
|
originAccount *gtsmodel.Account,
|
||||||
statusID string,
|
statusOrEditID string,
|
||||||
) error {
|
) error {
|
||||||
if targetAccount.IsRemote() {
|
if targetAccount.IsRemote() {
|
||||||
// nothing to do.
|
// nothing to do.
|
||||||
@ -601,7 +650,7 @@ func (s *Surface) Notify(
|
|||||||
notificationType,
|
notificationType,
|
||||||
targetAccount,
|
targetAccount,
|
||||||
originAccount,
|
originAccount,
|
||||||
statusID,
|
statusOrEditID,
|
||||||
)
|
)
|
||||||
unlock := s.State.ProcessingLocks.Lock(lockURI)
|
unlock := s.State.ProcessingLocks.Lock(lockURI)
|
||||||
|
|
||||||
@ -617,7 +666,7 @@ func (s *Surface) Notify(
|
|||||||
notificationType,
|
notificationType,
|
||||||
targetAccount.ID,
|
targetAccount.ID,
|
||||||
originAccount.ID,
|
originAccount.ID,
|
||||||
statusID,
|
statusOrEditID,
|
||||||
); err == nil {
|
); err == nil {
|
||||||
// Notification exists;
|
// Notification exists;
|
||||||
// nothing to do.
|
// nothing to do.
|
||||||
@ -636,7 +685,7 @@ func (s *Surface) Notify(
|
|||||||
TargetAccount: targetAccount,
|
TargetAccount: targetAccount,
|
||||||
OriginAccountID: originAccount.ID,
|
OriginAccountID: originAccount.ID,
|
||||||
OriginAccount: originAccount,
|
OriginAccount: originAccount,
|
||||||
StatusID: statusID,
|
StatusOrEditID: statusOrEditID,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.State.DB.PutNotification(ctx, notif); err != nil {
|
if err := s.State.DB.PutNotification(ctx, notif); err != nil {
|
||||||
|
@ -2055,60 +2055,43 @@ func (c *Converter) NotificationToAPINotification(
|
|||||||
filters []*gtsmodel.Filter,
|
filters []*gtsmodel.Filter,
|
||||||
mutes *usermute.CompiledUserMuteList,
|
mutes *usermute.CompiledUserMuteList,
|
||||||
) (*apimodel.Notification, error) {
|
) (*apimodel.Notification, error) {
|
||||||
if n.TargetAccount == nil {
|
// Ensure notif populated.
|
||||||
tAccount, err := c.state.DB.GetAccountByID(ctx, n.TargetAccountID)
|
if err := c.state.DB.PopulateNotification(ctx, n); err != nil {
|
||||||
if err != nil {
|
return nil, gtserror.Newf("error populating notification: %w", err)
|
||||||
return nil, fmt.Errorf("NotificationToapi: error getting target account with id %s from the db: %s", n.TargetAccountID, err)
|
|
||||||
}
|
|
||||||
n.TargetAccount = tAccount
|
|
||||||
}
|
|
||||||
|
|
||||||
if n.OriginAccount == nil {
|
|
||||||
ogAccount, err := c.state.DB.GetAccountByID(ctx, n.OriginAccountID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("NotificationToapi: error getting origin account with id %s from the db: %s", n.OriginAccountID, err)
|
|
||||||
}
|
|
||||||
n.OriginAccount = ogAccount
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get account that triggered this notif.
|
||||||
apiAccount, err := c.AccountToAPIAccountPublic(ctx, n.OriginAccount)
|
apiAccount, err := c.AccountToAPIAccountPublic(ctx, n.OriginAccount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("NotificationToapi: error converting account to api: %s", err)
|
return nil, gtserror.Newf("error converting account to api: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get status that triggered this notif, if set.
|
||||||
var apiStatus *apimodel.Status
|
var apiStatus *apimodel.Status
|
||||||
if n.StatusID != "" {
|
if n.Status != nil {
|
||||||
if n.Status == nil {
|
apiStatus, err = c.StatusToAPIStatus(
|
||||||
status, err := c.state.DB.GetStatusByID(ctx, n.StatusID)
|
ctx, n.Status,
|
||||||
if err != nil {
|
n.TargetAccount,
|
||||||
return nil, fmt.Errorf("NotificationToapi: error getting status with id %s from the db: %s", n.StatusID, err)
|
statusfilter.FilterContextNotifications,
|
||||||
}
|
filters, mutes,
|
||||||
n.Status = status
|
)
|
||||||
|
if err != nil && !errors.Is(err, statusfilter.ErrHideStatus) {
|
||||||
|
return nil, gtserror.Newf("error converting status to api: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if n.Status.Account == nil {
|
if apiStatus == nil {
|
||||||
if n.Status.AccountID == n.TargetAccount.ID {
|
// Notif filtered for this
|
||||||
n.Status.Account = n.TargetAccount
|
// status, nothing to do.
|
||||||
} else if n.Status.AccountID == n.OriginAccount.ID {
|
return nil, err
|
||||||
n.Status.Account = n.OriginAccount
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var err error
|
if apiStatus.Reblog != nil {
|
||||||
apiStatus, err = c.StatusToAPIStatus(ctx, n.Status, n.TargetAccount, statusfilter.FilterContextNotifications, filters, mutes)
|
// Use the actual reblog status
|
||||||
if err != nil {
|
// for the notifications endpoint.
|
||||||
if errors.Is(err, statusfilter.ErrHideStatus) {
|
apiStatus = apiStatus.Reblog.Status
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("NotificationToapi: error converting status to api: %s", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if apiStatus != nil && apiStatus.Reblog != nil {
|
|
||||||
// use the actual reblog status for the notifications endpoint
|
|
||||||
apiStatus = apiStatus.Reblog.Status
|
|
||||||
}
|
|
||||||
|
|
||||||
return &apimodel.Notification{
|
return &apimodel.Notification{
|
||||||
ID: n.ID,
|
ID: n.ID,
|
||||||
Type: n.NotificationType.String(),
|
Type: n.NotificationType.String(),
|
||||||
|
@ -260,7 +260,7 @@ func (suite *RealSenderStandardTestSuite) TestSendPolicyMismatch() {
|
|||||||
NotificationType: gtsmodel.NotificationFavourite,
|
NotificationType: gtsmodel.NotificationFavourite,
|
||||||
TargetAccountID: suite.testAccounts["local_account_1"].ID,
|
TargetAccountID: suite.testAccounts["local_account_1"].ID,
|
||||||
OriginAccountID: suite.testAccounts["remote_account_1"].ID,
|
OriginAccountID: suite.testAccounts["remote_account_1"].ID,
|
||||||
StatusID: "01F8MHAMCHF6Y650WCRSCP4WMY",
|
StatusOrEditID: "01F8MHAMCHF6Y650WCRSCP4WMY",
|
||||||
Read: util.Ptr(false),
|
Read: util.Ptr(false),
|
||||||
}
|
}
|
||||||
if err := suite.db.PutNotification(context.Background(), notification); !suite.NoError(err) {
|
if err := suite.db.PutNotification(context.Background(), notification); !suite.NoError(err) {
|
||||||
|
@ -3193,7 +3193,7 @@ func NewTestNotifications() map[string]*gtsmodel.Notification {
|
|||||||
CreatedAt: TimeMustParse("2022-05-14T13:21:09+02:00"),
|
CreatedAt: TimeMustParse("2022-05-14T13:21:09+02:00"),
|
||||||
TargetAccountID: "01F8MH1H7YV1Z7D2C8K2730QBF",
|
TargetAccountID: "01F8MH1H7YV1Z7D2C8K2730QBF",
|
||||||
OriginAccountID: "01F8MH17FWEB39HZJ76B6VXSKF",
|
OriginAccountID: "01F8MH17FWEB39HZJ76B6VXSKF",
|
||||||
StatusID: "01F8MHAMCHF6Y650WCRSCP4WMY",
|
StatusOrEditID: "01F8MHAMCHF6Y650WCRSCP4WMY",
|
||||||
Read: util.Ptr(false),
|
Read: util.Ptr(false),
|
||||||
},
|
},
|
||||||
"local_account_2_like": {
|
"local_account_2_like": {
|
||||||
@ -3202,7 +3202,7 @@ func NewTestNotifications() map[string]*gtsmodel.Notification {
|
|||||||
CreatedAt: TimeMustParse("2022-01-13T12:45:01+02:00"),
|
CreatedAt: TimeMustParse("2022-01-13T12:45:01+02:00"),
|
||||||
TargetAccountID: "01F8MH17FWEB39HZJ76B6VXSKF",
|
TargetAccountID: "01F8MH17FWEB39HZJ76B6VXSKF",
|
||||||
OriginAccountID: "01F8MH5NBDF2MV7CTC4Q5128HF",
|
OriginAccountID: "01F8MH5NBDF2MV7CTC4Q5128HF",
|
||||||
StatusID: "01F8MH75CBF9JFX4ZAD54N0W0R",
|
StatusOrEditID: "01F8MH75CBF9JFX4ZAD54N0W0R",
|
||||||
Read: util.Ptr(false),
|
Read: util.Ptr(false),
|
||||||
},
|
},
|
||||||
"new_signup": {
|
"new_signup": {
|
||||||
@ -3211,7 +3211,7 @@ func NewTestNotifications() map[string]*gtsmodel.Notification {
|
|||||||
CreatedAt: TimeMustParse("2022-06-04T13:12:00Z"),
|
CreatedAt: TimeMustParse("2022-06-04T13:12:00Z"),
|
||||||
TargetAccountID: "01F8MH17FWEB39HZJ76B6VXSKF",
|
TargetAccountID: "01F8MH17FWEB39HZJ76B6VXSKF",
|
||||||
OriginAccountID: "01F8MH0BBE4FHXPH513MBVFHB0",
|
OriginAccountID: "01F8MH0BBE4FHXPH513MBVFHB0",
|
||||||
StatusID: "",
|
StatusOrEditID: "",
|
||||||
Read: util.Ptr(false),
|
Read: util.Ptr(false),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user