[feature] Status thread mute/unmute functionality (#2278)

* add db models + functions for keeping track of threads

* give em the old linty testy

* create, remove, check mutes

* swagger

* testerino

* test mute/unmute via api

* add info log about new index creation

* thread + allow muting of any remote statuses that mention a local account

* IsStatusThreadMutedBy -> IsThreadMutedByAccount

* use common processing functions in status processor

* set = NULL

* favee!

* get rekt darlings, darlings get rekt

* testrig please, have mercy muy liege
This commit is contained in:
tobi 2023-10-25 16:04:53 +02:00 committed by GitHub
commit c7b6cd7770
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
48 changed files with 1750 additions and 198 deletions

View file

@ -135,7 +135,7 @@ func (b *basicDB) CreateAllTables(ctx context.Context) error {
&gtsmodel.StatusToEmoji{},
&gtsmodel.StatusFave{},
&gtsmodel.StatusBookmark{},
&gtsmodel.StatusMute{},
&gtsmodel.ThreadMute{},
&gtsmodel.Tag{},
&gtsmodel.User{},
&gtsmodel.Emoji{},

View file

@ -54,6 +54,7 @@ var registerTables = []interface{}{
&gtsmodel.AccountToEmoji{},
&gtsmodel.StatusToEmoji{},
&gtsmodel.StatusToTag{},
&gtsmodel.ThreadToStatus{},
}
// DBService satisfies the DB interface
@ -79,6 +80,7 @@ type DBService struct {
db.StatusBookmark
db.StatusFave
db.Tag
db.Thread
db.Timeline
db.User
db.Tombstone
@ -236,6 +238,10 @@ func NewBunDBService(ctx context.Context, state *state.State) (db.DB, error) {
conn: db,
state: state,
},
Thread: &threadDB{
db: db,
state: state,
},
Timeline: &timelineDB{
db: db,
state: state,

View file

@ -53,6 +53,7 @@ type BunDBStandardTestSuite struct {
testAccountNotes map[string]*gtsmodel.AccountNote
testMarkers map[string]*gtsmodel.Marker
testRules map[string]*gtsmodel.Rule
testThreads map[string]*gtsmodel.Thread
}
func (suite *BunDBStandardTestSuite) SetupSuite() {
@ -75,6 +76,7 @@ func (suite *BunDBStandardTestSuite) SetupSuite() {
suite.testAccountNotes = testrig.NewTestAccountNotes()
suite.testMarkers = testrig.NewTestMarkers()
suite.testRules = testrig.NewTestRules()
suite.testThreads = testrig.NewTestThreads()
}
func (suite *BunDBStandardTestSuite) SetupTest() {

View file

@ -0,0 +1,148 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package migrations
import (
"context"
"strings"
gtsmodel "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect"
)
func init() {
up := func(ctx context.Context, db *bun.DB) error {
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
// Create thread table.
if _, err := tx.
NewCreateTable().
Model(&gtsmodel.Thread{}).
IfNotExists().
Exec(ctx); err != nil {
return err
}
// Create thread intermediate table.
if _, err := tx.
NewCreateTable().
Model(&gtsmodel.ThreadToStatus{}).
IfNotExists().
Exec(ctx); err != nil {
return err
}
// Drop old pkey constraint from
// deprecated status mute table.
//
// This is only necessary with postgres.
if tx.Dialect().Name() == dialect.PG {
if _, err := tx.ExecContext(
ctx,
"ALTER TABLE ? DROP CONSTRAINT IF EXISTS ?",
bun.Ident("status_mutes"),
bun.Safe("status_mutes_pkey"),
); err != nil {
return err
}
}
// Drop old index.
if _, err := tx.
NewDropIndex().
Index("status_mutes_account_id_target_account_id_status_id_idx").
IfExists().
Exec(ctx); err != nil {
return err
}
// Drop deprecated status mute table.
if _, err := tx.
NewDropTable().
Table("status_mutes").
IfExists().
Exec(ctx); err != nil {
return err
}
// Create new thread mute table.
if _, err := tx.
NewCreateTable().
Model(&gtsmodel.ThreadMute{}).
IfNotExists().
Exec(ctx); err != nil {
return err
}
log.Info(ctx, "creating a new index on the statuses table, please wait and don't interrupt it (this may take a few minutes)")
// Update statuses to add thread ID column.
_, err := tx.ExecContext(
ctx,
"ALTER TABLE ? ADD COLUMN ? CHAR(26)",
bun.Ident("statuses"),
bun.Ident("thread_id"),
)
if err != nil && !(strings.Contains(err.Error(), "already exists") ||
strings.Contains(err.Error(), "duplicate column name") ||
strings.Contains(err.Error(), "SQLSTATE 42701")) {
return err
}
// Index new + existing tables properly.
for table, indexes := range map[string]map[string][]string{
"threads": {
"threads_id_idx": {"id"},
},
"thread_mutes": {
"thread_mutes_id_idx": {"id"},
// Eg., check if target thread is muted by account.
"thread_mutes_thread_id_account_id_idx": {"thread_id", "account_id"},
},
"statuses": {
// Eg., select all statuses in a thread.
"statuses_thread_id_idx": {"thread_id"},
},
} {
for index, columns := range indexes {
if _, err := tx.
NewCreateIndex().
Table(table).
Index(index).
Column(columns...).
Exec(ctx); err != nil {
return err
}
}
}
return nil
})
}
down := func(ctx context.Context, db *bun.DB) error {
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
return nil
})
}
if err := Migrations.Register(up, down); err != nil {
panic(err)
}
}

View file

@ -324,6 +324,23 @@ func (s *statusDB) PutStatus(ctx context.Context, status *gtsmodel.Status) error
}
}
// If the status is threaded, create
// link between thread and status.
if status.ThreadID != "" {
if _, err := tx.
NewInsert().
Model(&gtsmodel.ThreadToStatus{
ThreadID: status.ThreadID,
StatusID: status.ID,
}).
On("CONFLICT (?, ?) DO NOTHING", bun.Ident("thread_id"), bun.Ident("status_id")).
Exec(ctx); err != nil {
if !errors.Is(err, db.ErrAlreadyExists) {
return err
}
}
}
// Finally, insert the status
_, err := tx.NewInsert().Model(status).Exec(ctx)
return err
@ -390,6 +407,23 @@ func (s *statusDB) UpdateStatus(ctx context.Context, status *gtsmodel.Status, co
}
}
// If the status is threaded, create
// link between thread and status.
if status.ThreadID != "" {
if _, err := tx.
NewInsert().
Model(&gtsmodel.ThreadToStatus{
ThreadID: status.ThreadID,
StatusID: status.ID,
}).
On("CONFLICT (?, ?) DO NOTHING", bun.Ident("thread_id"), bun.Ident("status_id")).
Exec(ctx); err != nil {
if !errors.Is(err, db.ErrAlreadyExists) {
return err
}
}
}
// Finally, update the status
_, err := tx.
NewUpdate().
@ -439,6 +473,17 @@ func (s *statusDB) DeleteStatusByID(ctx context.Context, id string) error {
return err
}
// Delete links between this status
// and any threads it was a part of.
_, err = tx.
NewDelete().
TableExpr("? AS ?", bun.Ident("thread_to_statuses"), bun.Ident("thread_to_status")).
Where("? = ?", bun.Ident("thread_to_status.status_id"), id).
Exec(ctx)
if err != nil {
return err
}
// delete the status itself
if _, err := tx.
NewDelete().
@ -634,16 +679,6 @@ func (s *statusDB) getStatusBoostIDs(ctx context.Context, statusID string) ([]st
})
}
func (s *statusDB) IsStatusMutedBy(ctx context.Context, status *gtsmodel.Status, accountID string) (bool, error) {
q := s.db.
NewSelect().
TableExpr("? AS ?", bun.Ident("status_mutes"), bun.Ident("status_mute")).
Where("? = ?", bun.Ident("status_mute.status_id"), status.ID).
Where("? = ?", bun.Ident("status_mute.account_id"), accountID)
return s.db.Exists(ctx, q)
}
func (s *statusDB) IsStatusBookmarkedBy(ctx context.Context, status *gtsmodel.Status, accountID string) (bool, error) {
q := s.db.
NewSelect().

117
internal/db/bundb/thread.go Normal file
View file

@ -0,0 +1,117 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package bundb
import (
"context"
"errors"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/uptrace/bun"
)
type threadDB struct {
db *DB
state *state.State
}
func (t *threadDB) PutThread(ctx context.Context, thread *gtsmodel.Thread) error {
_, err := t.db.
NewInsert().
Model(thread).
Exec(ctx)
return err
}
func (t *threadDB) GetThreadMute(ctx context.Context, id string) (*gtsmodel.ThreadMute, error) {
return t.state.Caches.GTS.ThreadMute().Load("ID", func() (*gtsmodel.ThreadMute, error) {
var threadMute gtsmodel.ThreadMute
q := t.db.
NewSelect().
Model(&threadMute).
Where("? = ?", bun.Ident("thread_mute.id"), id)
if err := q.Scan(ctx); err != nil {
return nil, err
}
return &threadMute, nil
}, id)
}
func (t *threadDB) GetThreadMutedByAccount(
ctx context.Context,
threadID string,
accountID string,
) (*gtsmodel.ThreadMute, error) {
return t.state.Caches.GTS.ThreadMute().Load("ThreadID.AccountID", func() (*gtsmodel.ThreadMute, error) {
var threadMute gtsmodel.ThreadMute
q := t.db.
NewSelect().
Model(&threadMute).
Where("? = ?", bun.Ident("thread_mute.thread_id"), threadID).
Where("? = ?", bun.Ident("thread_mute.account_id"), accountID)
if err := q.Scan(ctx); err != nil {
return nil, err
}
return &threadMute, nil
}, threadID, accountID)
}
func (t *threadDB) IsThreadMutedByAccount(
ctx context.Context,
threadID string,
accountID string,
) (bool, error) {
if threadID == "" {
return false, nil
}
mute, err := t.GetThreadMutedByAccount(ctx, threadID, accountID)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
return false, err
}
return (mute != nil), nil
}
func (t *threadDB) PutThreadMute(ctx context.Context, threadMute *gtsmodel.ThreadMute) error {
return t.state.Caches.GTS.ThreadMute().Store(threadMute, func() error {
_, err := t.db.NewInsert().Model(threadMute).Exec(ctx)
return err
})
}
func (t *threadDB) DeleteThreadMute(ctx context.Context, id string) error {
if _, err := t.db.
NewDelete().
TableExpr("? AS ?", bun.Ident("thread_mutes"), bun.Ident("thread_mute")).
Where("? = ?", bun.Ident("thread_mute.id"), id).Exec(ctx); err != nil {
return err
}
t.state.Caches.GTS.ThreadMute().Invalidate("ID", id)
return nil
}

View file

@ -0,0 +1,91 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package bundb_test
import (
"context"
"testing"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
type ThreadTestSuite struct {
BunDBStandardTestSuite
}
func (suite *ThreadTestSuite) TestPutThread() {
suite.NoError(
suite.db.PutThread(
context.Background(),
&gtsmodel.Thread{
ID: "01HCWK4HVQ4VGSS1G4VQP3AXZF",
},
),
)
}
func (suite *ThreadTestSuite) TestMuteUnmuteThread() {
var (
threadID = suite.testThreads["local_account_1_status_1"].ID
accountID = suite.testAccounts["local_account_1"].ID
ctx = context.Background()
threadMute = &gtsmodel.ThreadMute{
ID: "01HD3K14B62YJHH4RR0DSZ1EQ2",
ThreadID: threadID,
AccountID: accountID,
}
)
// Mute the thread and ensure it's actually muted.
if err := suite.db.PutThreadMute(ctx, threadMute); err != nil {
suite.FailNow(err.Error())
}
muted, err := suite.db.IsThreadMutedByAccount(ctx, threadID, accountID)
if err != nil {
suite.FailNow(err.Error())
}
if !muted {
suite.FailNow("", "expected thread %s to be muted by account %s", threadID, accountID)
}
_, err = suite.db.GetThreadMutedByAccount(ctx, threadID, accountID)
if err != nil {
suite.FailNow(err.Error())
}
// Unmute the thread and ensure it's actually unmuted.
if err := suite.db.DeleteThreadMute(ctx, threadMute.ID); err != nil {
suite.FailNow(err.Error())
}
muted, err = suite.db.IsThreadMutedByAccount(ctx, threadID, accountID)
if err != nil {
suite.FailNow(err.Error())
}
if muted {
suite.FailNow("", "expected thread %s to not be muted by account %s", threadID, accountID)
}
}
func TestThreadTestSuite(t *testing.T) {
suite.Run(t, new(ThreadTestSuite))
}

View file

@ -45,6 +45,7 @@ type DB interface {
StatusBookmark
StatusFave
Tag
Thread
Timeline
User
Tombstone

View file

@ -80,9 +80,6 @@ type Status interface {
// If onlyDirect is true, only the immediate children will be returned.
GetStatusChildren(ctx context.Context, status *gtsmodel.Status, onlyDirect bool, minID string) ([]*gtsmodel.Status, error)
// IsStatusMutedBy checks if a given status has been muted by a given account ID
IsStatusMutedBy(ctx context.Context, status *gtsmodel.Status, accountID string) (bool, error)
// IsStatusBookmarkedBy checks if a given status has been bookmarked by a given account ID
IsStatusBookmarkedBy(ctx context.Context, status *gtsmodel.Status, accountID string) (bool, error)
}

48
internal/db/thread.go Normal file
View file

@ -0,0 +1,48 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package db
import (
"context"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
// Thread contains functions for getting/creating
// status threads and thread mutes in the database.
type Thread interface {
// PutThread inserts a new thread.
PutThread(ctx context.Context, thread *gtsmodel.Thread) error
// GetThreadMute gets a single threadMute by its ID.
GetThreadMute(ctx context.Context, id string) (*gtsmodel.ThreadMute, error)
// GetThreadMutedByAccount gets a threadMute targeting the
// given thread, created by the given accountID, if it exists.
GetThreadMutedByAccount(ctx context.Context, threadID string, accountID string) (*gtsmodel.ThreadMute, error)
// IsThreadMutedByAccount returns true if threadID is muted
// by given account. Empty thread ID will return false early.
IsThreadMutedByAccount(ctx context.Context, threadID string, accountID string) (bool, error)
// PutThreadMute inserts a new threadMute.
PutThreadMute(ctx context.Context, threadMute *gtsmodel.ThreadMute) error
// DeleteThreadMute deletes threadMute with the given ID.
DeleteThreadMute(ctx context.Context, id string) error
}