From c4a08292ee44bc731ff90bad18a3f37e5ee8ef22 Mon Sep 17 00:00:00 2001
From: tobi <31960611+tsmethurst@users.noreply.github.com>
Date: Mon, 26 Sep 2022 11:56:01 +0200
Subject: [PATCH] [feature] Show + federate emojis in accounts (#837)
* Start adding account emoji
* get emojis serialized + deserialized nicely
* update tests
* set / retrieve emojis on accounts
* show account emojis in web view
* fetch emojis from db based on ids
* fix typo in test
* lint
* fix pg migration
* update tests
* update emoji checking logic
* update comment
* clarify comments + add some spacing
* tidy up loops a lil (thanks kim)
---
internal/ap/interfaces.go | 1 +
.../api/client/account/accountupdate_test.go | 14 +-
internal/api/s2s/user/inboxpost_test.go | 30 ++-
internal/cache/account.go | 2 +
internal/db/account.go | 3 +
internal/db/bundb/account.go | 60 +++++-
internal/db/bundb/account_test.go | 59 +++++-
internal/db/bundb/bundb.go | 17 +-
.../20220916122701_emojis_in_accounts.go | 69 ++++++
internal/federation/dereferencing/account.go | 193 ++++++++++++++---
.../federation/dereferencing/account_test.go | 200 ++++++++++++++++++
.../dereferencing/dereferencer_test.go | 2 +
internal/federation/dereferencing/emoji.go | 58 +++++
internal/federation/dereferencing/status.go | 59 +-----
internal/federation/federatingdb/update.go | 11 +-
internal/gtsmodel/account.go | 10 +
internal/processing/account/delete.go | 2 +
internal/processing/account/update.go | 28 +++
internal/processing/fromfederator.go | 10 +-
internal/typeutils/astointernal.go | 7 +
internal/typeutils/converter_test.go | 2 +
internal/typeutils/internaltoas.go | 33 ++-
internal/typeutils/internaltoas_test.go | 32 ++-
internal/typeutils/internaltofrontend.go | 25 ++-
internal/typeutils/internaltofrontend_test.go | 30 +++
testrig/db.go | 1 +
testrig/media/kip-original.gif | Bin 0 -> 1428 bytes
testrig/media/kip-static.png | Bin 0 -> 802 bytes
testrig/media/yell-original.png | Bin 0 -> 10889 bytes
testrig/media/yell-static.png | Bin 0 -> 10808 bytes
testrig/testmodels.go | 82 +++++++
testrig/transportcontroller.go | 15 ++
web/template/profile.tmpl | 4 +-
web/template/status.tmpl | 2 +-
34 files changed, 934 insertions(+), 127 deletions(-)
create mode 100644 internal/db/bundb/migrations/20220916122701_emojis_in_accounts.go
create mode 100644 testrig/media/kip-original.gif
create mode 100644 testrig/media/kip-static.png
create mode 100644 testrig/media/yell-original.png
create mode 100644 testrig/media/yell-static.png
diff --git a/internal/ap/interfaces.go b/internal/ap/interfaces.go
index 803eda640..05e030d68 100644
--- a/internal/ap/interfaces.go
+++ b/internal/ap/interfaces.go
@@ -41,6 +41,7 @@ type Accountable interface {
WithFeatured
WithManuallyApprovesFollowers
WithEndpoints
+ WithTag
}
// Statusable represents the minimum activitypub interface for representing a 'status'.
diff --git a/internal/api/client/account/accountupdate_test.go b/internal/api/client/account/accountupdate_test.go
index d59cd02a5..259bb69e9 100644
--- a/internal/api/client/account/accountupdate_test.go
+++ b/internal/api/client/account/accountupdate_test.go
@@ -200,7 +200,7 @@ func (suite *AccountUpdateTestSuite) TestAccountUpdateCredentialsPATCHHandlerGet
func (suite *AccountUpdateTestSuite) TestAccountUpdateCredentialsPATCHHandlerTwoFields() {
// set up the request
// we're updating the note of zork, and setting locked to true
- newBio := "this is my new bio read it and weep"
+ newBio := "this is my new bio read it and weep :rainbow:"
requestBody, w, err := testrig.CreateMultipartFormData(
"", "",
map[string]string{
@@ -235,9 +235,19 @@ func (suite *AccountUpdateTestSuite) TestAccountUpdateCredentialsPATCHHandlerTwo
// check the returned api model account
// fields should be updated
- suite.Equal("
this is my new bio read it and weep
", apimodelAccount.Note)
+ suite.Equal("this is my new bio read it and weep :rainbow:
", apimodelAccount.Note)
suite.Equal(newBio, apimodelAccount.Source.Note)
suite.True(apimodelAccount.Locked)
+ suite.NotEmpty(apimodelAccount.Emojis)
+ suite.Equal(apimodelAccount.Emojis[0].Shortcode, "rainbow")
+
+ // check the account in the database
+ dbZork, err := suite.db.GetAccountByID(context.Background(), apimodelAccount.ID)
+ suite.NoError(err)
+ suite.Equal(newBio, dbZork.NoteRaw)
+ suite.Equal("this is my new bio read it and weep :rainbow:
", dbZork.Note)
+ suite.True(*dbZork.Locked)
+ suite.NotEmpty(dbZork.EmojiIDs)
}
func (suite *AccountUpdateTestSuite) TestAccountUpdateCredentialsPATCHHandlerWithMedia() {
diff --git a/internal/api/s2s/user/inboxpost_test.go b/internal/api/s2s/user/inboxpost_test.go
index ff3ec47d3..7180fd2f9 100644
--- a/internal/api/s2s/user/inboxpost_test.go
+++ b/internal/api/s2s/user/inboxpost_test.go
@@ -237,6 +237,8 @@ func (suite *InboxPostTestSuite) TestPostUnblock() {
func (suite *InboxPostTestSuite) TestPostUpdate() {
updatedAccount := *suite.testAccounts["remote_account_1"]
updatedAccount.DisplayName = "updated display name!"
+ testEmoji := testrig.NewTestEmojis()["rainbow"]
+ updatedAccount.Emojis = []*gtsmodel.Emoji{testEmoji}
asAccount, err := suite.tc.AccountToAS(context.Background(), &updatedAccount)
suite.NoError(err)
@@ -288,6 +290,15 @@ func (suite *InboxPostTestSuite) TestPostUpdate() {
federator := testrig.NewTestFederator(suite.db, tc, suite.storage, suite.mediaManager, fedWorker)
emailSender := testrig.NewEmailSender("../../../../web/template/", nil)
processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender, suite.mediaManager, clientWorker, fedWorker)
+ if err := processor.Start(); err != nil {
+ panic(err)
+ }
+ defer func() {
+ if err := processor.Stop(); err != nil {
+ panic(err)
+ }
+ }()
+
userModule := user.New(processor).(*user.Module)
// setup request
@@ -322,11 +333,21 @@ func (suite *InboxPostTestSuite) TestPostUpdate() {
suite.Equal(http.StatusOK, result.StatusCode)
// account should be changed in the database now
- dbUpdatedAccount, err := suite.db.GetAccountByID(context.Background(), updatedAccount.ID)
- suite.NoError(err)
+ var dbUpdatedAccount *gtsmodel.Account
- // displayName should be updated
- suite.Equal("updated display name!", dbUpdatedAccount.DisplayName)
+ if !testrig.WaitFor(func() bool {
+ // displayName should be updated
+ dbUpdatedAccount, _ = suite.db.GetAccountByID(context.Background(), updatedAccount.ID)
+ return dbUpdatedAccount.DisplayName == "updated display name!"
+ }) {
+ suite.FailNow("timed out waiting for account update")
+ }
+
+ // emojis should be updated
+ suite.Contains(dbUpdatedAccount.EmojiIDs, testEmoji.ID)
+
+ // account should be freshly webfingered
+ suite.WithinDuration(time.Now(), dbUpdatedAccount.LastWebfingeredAt, 10*time.Second)
// everything else should be the same as it was before
suite.EqualValues(updatedAccount.Username, dbUpdatedAccount.Username)
@@ -350,7 +371,6 @@ func (suite *InboxPostTestSuite) TestPostUpdate() {
suite.EqualValues(updatedAccount.Language, dbUpdatedAccount.Language)
suite.EqualValues(updatedAccount.URI, dbUpdatedAccount.URI)
suite.EqualValues(updatedAccount.URL, dbUpdatedAccount.URL)
- suite.EqualValues(updatedAccount.LastWebfingeredAt, dbUpdatedAccount.LastWebfingeredAt)
suite.EqualValues(updatedAccount.InboxURI, dbUpdatedAccount.InboxURI)
suite.EqualValues(updatedAccount.OutboxURI, dbUpdatedAccount.OutboxURI)
suite.EqualValues(updatedAccount.FollowingURI, dbUpdatedAccount.FollowingURI)
diff --git a/internal/cache/account.go b/internal/cache/account.go
index f478c81d3..7e23c3194 100644
--- a/internal/cache/account.go
+++ b/internal/cache/account.go
@@ -116,6 +116,8 @@ func copyAccount(account *gtsmodel.Account) *gtsmodel.Account {
HeaderMediaAttachment: nil,
HeaderRemoteURL: account.HeaderRemoteURL,
DisplayName: account.DisplayName,
+ EmojiIDs: account.EmojiIDs,
+ Emojis: nil,
Fields: account.Fields,
Note: account.Note,
NoteRaw: account.NoteRaw,
diff --git a/internal/db/account.go b/internal/db/account.go
index 5f1336872..351d6d01c 100644
--- a/internal/db/account.go
+++ b/internal/db/account.go
@@ -42,6 +42,9 @@ type Account interface {
// GetAccountByPubkeyID returns one account with the given public key URI (ID), or an error if something goes wrong.
GetAccountByPubkeyID(ctx context.Context, id string) (*gtsmodel.Account, Error)
+ // PutAccount puts one account in the database.
+ PutAccount(ctx context.Context, account *gtsmodel.Account) (*gtsmodel.Account, Error)
+
// UpdateAccount updates one account by ID.
UpdateAccount(ctx context.Context, account *gtsmodel.Account) (*gtsmodel.Account, Error)
diff --git a/internal/db/bundb/account.go b/internal/db/bundb/account.go
index 2105368d3..074804690 100644
--- a/internal/db/bundb/account.go
+++ b/internal/db/bundb/account.go
@@ -45,7 +45,8 @@ func (a *accountDB) newAccountQ(account *gtsmodel.Account) *bun.SelectQuery {
NewSelect().
Model(account).
Relation("AvatarMediaAttachment").
- Relation("HeaderMediaAttachment")
+ Relation("HeaderMediaAttachment").
+ Relation("Emojis")
}
func (a *accountDB) GetAccountByID(ctx context.Context, id string) (*gtsmodel.Account, db.Error) {
@@ -138,24 +139,61 @@ func (a *accountDB) getAccount(ctx context.Context, cacheGet func() (*gtsmodel.A
return account, nil
}
+func (a *accountDB) PutAccount(ctx context.Context, account *gtsmodel.Account) (*gtsmodel.Account, db.Error) {
+ if err := a.conn.RunInTx(ctx, func(tx bun.Tx) error {
+ // create links between this account and any emojis it uses
+ for _, i := range account.EmojiIDs {
+ if _, err := tx.NewInsert().Model(>smodel.AccountToEmoji{
+ AccountID: account.ID,
+ EmojiID: i,
+ }).Exec(ctx); err != nil {
+ return err
+ }
+ }
+
+ // insert the account
+ _, err := tx.NewInsert().Model(account).Exec(ctx)
+ return err
+ }); err != nil {
+ return nil, a.conn.ProcessError(err)
+ }
+
+ a.cache.Put(account)
+ return account, nil
+}
+
func (a *accountDB) UpdateAccount(ctx context.Context, account *gtsmodel.Account) (*gtsmodel.Account, db.Error) {
// Update the account's last-updated
account.UpdatedAt = time.Now()
- // Update the account model in the DB
- _, err := a.conn.
- NewUpdate().
- Model(account).
- WherePK().
- Exec(ctx)
- if err != nil {
+ if err := a.conn.RunInTx(ctx, func(tx bun.Tx) error {
+ // create links between this account and any emojis it uses
+ // first clear out any old emoji links
+ if _, err := tx.NewDelete().
+ Model(&[]*gtsmodel.AccountToEmoji{}).
+ Where("account_id = ?", account.ID).
+ Exec(ctx); err != nil {
+ return err
+ }
+
+ // now populate new emoji links
+ for _, i := range account.EmojiIDs {
+ if _, err := tx.NewInsert().Model(>smodel.AccountToEmoji{
+ AccountID: account.ID,
+ EmojiID: i,
+ }).Exec(ctx); err != nil {
+ return err
+ }
+ }
+
+ // update the account
+ _, err := tx.NewUpdate().Model(account).WherePK().Exec(ctx)
+ return err
+ }); err != nil {
return nil, a.conn.ProcessError(err)
}
- // Place updated account in cache
- // (this will replace existing, i.e. invalidating)
a.cache.Put(account)
-
return account, nil
}
diff --git a/internal/db/bundb/account_test.go b/internal/db/bundb/account_test.go
index 3c19e84d9..1e6dc4436 100644
--- a/internal/db/bundb/account_test.go
+++ b/internal/db/bundb/account_test.go
@@ -27,7 +27,9 @@ import (
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/ap"
+ "github.com/superseriousbusiness/gotosocial/internal/db/bundb"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/uptrace/bun"
)
type AccountTestSuite struct {
@@ -71,17 +73,70 @@ func (suite *AccountTestSuite) TestGetAccountByUsernameDomain() {
}
func (suite *AccountTestSuite) TestUpdateAccount() {
+ ctx := context.Background()
+
testAccount := suite.testAccounts["local_account_1"]
testAccount.DisplayName = "new display name!"
+ testAccount.EmojiIDs = []string{"01GD36ZKWTKY3T1JJ24JR7KY1Q", "01GD36ZV904SHBHNAYV6DX5QEF"}
- _, err := suite.db.UpdateAccount(context.Background(), testAccount)
+ _, err := suite.db.UpdateAccount(ctx, testAccount)
suite.NoError(err)
- updated, err := suite.db.GetAccountByID(context.Background(), testAccount.ID)
+ updated, err := suite.db.GetAccountByID(ctx, testAccount.ID)
suite.NoError(err)
suite.Equal("new display name!", updated.DisplayName)
+ suite.Equal([]string{"01GD36ZKWTKY3T1JJ24JR7KY1Q", "01GD36ZV904SHBHNAYV6DX5QEF"}, updated.EmojiIDs)
suite.WithinDuration(time.Now(), updated.UpdatedAt, 5*time.Second)
+
+ // get account without cache + make sure it's really in the db as desired
+ dbService, ok := suite.db.(*bundb.DBService)
+ if !ok {
+ panic("db was not *bundb.DBService")
+ }
+
+ noCache := >smodel.Account{}
+ err = dbService.GetConn().
+ NewSelect().
+ Model(noCache).
+ Where("account.id = ?", bun.Ident(testAccount.ID)).
+ Relation("AvatarMediaAttachment").
+ Relation("HeaderMediaAttachment").
+ Relation("Emojis").
+ Scan(ctx)
+
+ suite.NoError(err)
+ suite.Equal("new display name!", noCache.DisplayName)
+ suite.Equal([]string{"01GD36ZKWTKY3T1JJ24JR7KY1Q", "01GD36ZV904SHBHNAYV6DX5QEF"}, noCache.EmojiIDs)
+ suite.WithinDuration(time.Now(), noCache.UpdatedAt, 5*time.Second)
+ suite.NotNil(noCache.AvatarMediaAttachment)
+ suite.NotNil(noCache.HeaderMediaAttachment)
+
+ // update again to remove emoji associations
+ testAccount.EmojiIDs = []string{}
+
+ _, err = suite.db.UpdateAccount(ctx, testAccount)
+ suite.NoError(err)
+
+ updated, err = suite.db.GetAccountByID(ctx, testAccount.ID)
+ suite.NoError(err)
+ suite.Equal("new display name!", updated.DisplayName)
+ suite.Empty(updated.EmojiIDs)
+ suite.WithinDuration(time.Now(), updated.UpdatedAt, 5*time.Second)
+
+ err = dbService.GetConn().
+ NewSelect().
+ Model(noCache).
+ Where("account.id = ?", bun.Ident(testAccount.ID)).
+ Relation("AvatarMediaAttachment").
+ Relation("HeaderMediaAttachment").
+ Relation("Emojis").
+ Scan(ctx)
+
+ suite.NoError(err)
+ suite.Equal("new display name!", noCache.DisplayName)
+ suite.Empty(noCache.EmojiIDs)
+ suite.WithinDuration(time.Now(), noCache.UpdatedAt, 5*time.Second)
}
func (suite *AccountTestSuite) TestInsertAccountWithDefaults() {
diff --git a/internal/db/bundb/bundb.go b/internal/db/bundb/bundb.go
index b944ae3ea..2fc65364f 100644
--- a/internal/db/bundb/bundb.go
+++ b/internal/db/bundb/bundb.go
@@ -67,12 +67,13 @@ const (
)
var registerTables = []interface{}{
+ >smodel.AccountToEmoji{},
>smodel.StatusToEmoji{},
>smodel.StatusToTag{},
}
-// bunDBService satisfies the DB interface
-type bunDBService struct {
+// DBService satisfies the DB interface
+type DBService struct {
db.Account
db.Admin
db.Basic
@@ -89,6 +90,12 @@ type bunDBService struct {
conn *DBConn
}
+// GetConn returns the underlying bun connection.
+// Should only be used in testing + exceptional circumstance.
+func (dbService *DBService) GetConn() *DBConn {
+ return dbService.conn
+}
+
func doMigration(ctx context.Context, db *bun.DB) error {
migrator := migrate.NewMigrator(db, migrations.Migrations)
@@ -177,7 +184,7 @@ func NewBunDBService(ctx context.Context) (db.DB, error) {
// Prepare domain block cache
blockCache := cache.NewDomainBlockCache()
- ps := &bunDBService{
+ ps := &DBService{
Account: accounts,
Admin: &adminDB{
conn: conn,
@@ -399,7 +406,7 @@ func tweakConnectionValues(sqldb *sql.DB) {
CONVERSION FUNCTIONS
*/
-func (ps *bunDBService) TagStringsToTags(ctx context.Context, tags []string, originAccountID string) ([]*gtsmodel.Tag, error) {
+func (dbService *DBService) TagStringsToTags(ctx context.Context, tags []string, originAccountID string) ([]*gtsmodel.Tag, error) {
protocol := config.GetProtocol()
host := config.GetHost()
@@ -408,7 +415,7 @@ func (ps *bunDBService) TagStringsToTags(ctx context.Context, tags []string, ori
tag := >smodel.Tag{}
// we can use selectorinsert here to create the new tag if it doesn't exist already
// inserted will be true if this is a new tag we just created
- if err := ps.conn.NewSelect().Model(tag).Where("LOWER(?) = LOWER(?)", bun.Ident("name"), t).Scan(ctx); err != nil {
+ if err := dbService.conn.NewSelect().Model(tag).Where("LOWER(?) = LOWER(?)", bun.Ident("name"), t).Scan(ctx); err != nil {
if err == sql.ErrNoRows {
// tag doesn't exist yet so populate it
newID, err := id.NewRandomULID()
diff --git a/internal/db/bundb/migrations/20220916122701_emojis_in_accounts.go b/internal/db/bundb/migrations/20220916122701_emojis_in_accounts.go
new file mode 100644
index 000000000..91468a4c9
--- /dev/null
+++ b/internal/db/bundb/migrations/20220916122701_emojis_in_accounts.go
@@ -0,0 +1,69 @@
+/*
+ GoToSocial
+ Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
+
+ 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 .
+*/
+
+package migrations
+
+import (
+ "context"
+
+ "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 {
+ q := tx.NewAddColumn().Model(>smodel.Account{})
+
+ switch tx.Dialect().Name() {
+ case dialect.PG:
+ q = q.ColumnExpr("? VARCHAR[]", bun.Ident("emojis"))
+ case dialect.SQLite:
+ q = q.ColumnExpr("? VARCHAR", bun.Ident("emojis"))
+ default:
+ log.Panic("db dialect was neither pg nor sqlite")
+ }
+
+ if _, err := q.Exec(ctx); err != nil {
+ return err
+ }
+
+ if _, err := tx.
+ NewCreateTable().
+ Model(>smodel.AccountToEmoji{}).
+ IfNotExists().
+ 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)
+ }
+}
diff --git a/internal/federation/dereferencing/account.go b/internal/federation/dereferencing/account.go
index 6a633a54a..41a8aa8a9 100644
--- a/internal/federation/dereferencing/account.go
+++ b/internal/federation/dereferencing/account.go
@@ -76,6 +76,11 @@ type GetRemoteAccountParams struct {
// quickly fetch a remote account from the database or fail, and don't want to cause
// http requests to go flying around.
SkipResolve bool
+ // PartialAccount can be used if the GetRemoteAccount call results from a federated/ap
+ // account update. In this case, we will already have a partial representation of the account,
+ // derived from converting the AP representation to a gtsmodel representation. If this field
+ // is provided, then GetRemoteAccount will use this as a basis for building the full account.
+ PartialAccount *gtsmodel.Account
}
// GetRemoteAccount completely dereferences a remote account, converts it to a GtS model account,
@@ -107,8 +112,16 @@ func (d *deref) GetRemoteAccount(ctx context.Context, params GetRemoteAccountPar
skipResolve := params.SkipResolve
// this first step checks if we have the
- // account in the database somewhere already
+ // account in the database somewhere already,
+ // or if we've been provided it as a partial
switch {
+ case params.PartialAccount != nil:
+ foundAccount = params.PartialAccount
+ if foundAccount.Domain == "" || foundAccount.Domain == config.GetHost() || foundAccount.Domain == config.GetAccountDomain() {
+ // this is actually a local account,
+ // make sure we don't try to resolve
+ skipResolve = true
+ }
case params.RemoteAccountID != nil:
uri := params.RemoteAccountID
host := uri.Host
@@ -163,7 +176,7 @@ func (d *deref) GetRemoteAccount(ctx context.Context, params GetRemoteAccountPar
params.RemoteAccountHost = params.RemoteAccountID.Host
// ... but we still need the username so we can do a finger for the accountDomain
- // check if we had the account stored already and got it earlier
+ // check if we got the account earlier
if foundAccount != nil {
params.RemoteAccountUsername = foundAccount.Username
} else {
@@ -201,9 +214,10 @@ func (d *deref) GetRemoteAccount(ctx context.Context, params GetRemoteAccountPar
// to save on remote calls, only webfinger if:
// - we don't know the remote account ActivityPub ID yet OR
// - we haven't found the account yet in some other way OR
+ // - we were passed a partial account in params OR
// - we haven't webfingered the account for two days AND the account isn't an instance account
var fingered time.Time
- if params.RemoteAccountID == nil || foundAccount == nil || (foundAccount.LastWebfingeredAt.Before(time.Now().Add(webfingerInterval)) && !instanceAccount(foundAccount)) {
+ if params.RemoteAccountID == nil || foundAccount == nil || params.PartialAccount != nil || (foundAccount.LastWebfingeredAt.Before(time.Now().Add(webfingerInterval)) && !instanceAccount(foundAccount)) {
accountDomain, params.RemoteAccountID, err = d.fingerRemoteAccount(ctx, params.RequestingUsername, params.RemoteAccountUsername, params.RemoteAccountHost)
if err != nil {
err = fmt.Errorf("GetRemoteAccount: error while fingering: %s", err)
@@ -263,7 +277,7 @@ func (d *deref) GetRemoteAccount(ctx context.Context, params GetRemoteAccountPar
foundAccount.LastWebfingeredAt = fingered
foundAccount.UpdatedAt = time.Now()
- err = d.db.Put(ctx, foundAccount)
+ foundAccount, err = d.db.PutAccount(ctx, foundAccount)
if err != nil {
err = fmt.Errorf("GetRemoteAccount: error putting new account: %s", err)
return
@@ -273,13 +287,10 @@ func (d *deref) GetRemoteAccount(ctx context.Context, params GetRemoteAccountPar
}
// we had the account already, but now we know the account domain, so update it if it's different
+ var accountDomainChanged bool
if !strings.EqualFold(foundAccount.Domain, accountDomain) {
+ accountDomainChanged = true
foundAccount.Domain = accountDomain
- foundAccount, err = d.db.UpdateAccount(ctx, foundAccount)
- if err != nil {
- err = fmt.Errorf("GetRemoteAccount: error updating account: %s", err)
- return
- }
}
// if SharedInboxURI is nil, that means we don't know yet if this account has
@@ -327,8 +338,7 @@ func (d *deref) GetRemoteAccount(ctx context.Context, params GetRemoteAccountPar
foundAccount.LastWebfingeredAt = fingered
}
- if fieldsChanged || fingeredChanged || sharedInboxChanged {
- foundAccount.UpdatedAt = time.Now()
+ if accountDomainChanged || sharedInboxChanged || fieldsChanged || fingeredChanged {
foundAccount, err = d.db.UpdateAccount(ctx, foundAccount)
if err != nil {
return nil, fmt.Errorf("GetRemoteAccount: error updating remoteAccount: %s", err)
@@ -423,15 +433,20 @@ func (d *deref) populateAccountFields(ctx context.Context, account *gtsmodel.Acc
return false, fmt.Errorf("populateAccountFields: domain %s is blocked", accountURI.Host)
}
- t, err := d.transportController.NewTransportForUsername(ctx, requestingUsername)
- if err != nil {
- return false, fmt.Errorf("populateAccountFields: error getting transport for user: %s", err)
- }
+ var changed bool
// fetch the header and avatar
- changed, err := d.fetchRemoteAccountMedia(ctx, account, t, blocking)
- if err != nil {
+ if mediaChanged, err := d.fetchRemoteAccountMedia(ctx, account, requestingUsername, blocking); err != nil {
return false, fmt.Errorf("populateAccountFields: error fetching header/avi for account: %s", err)
+ } else if mediaChanged {
+ changed = mediaChanged
+ }
+
+ // fetch any emojis used in note, fields, display name, etc
+ if emojisChanged, err := d.fetchRemoteAccountEmojis(ctx, account, requestingUsername); err != nil {
+ return false, fmt.Errorf("populateAccountFields: error fetching emojis for account: %s", err)
+ } else if emojisChanged {
+ changed = emojisChanged
}
return changed, nil
@@ -449,17 +464,11 @@ func (d *deref) populateAccountFields(ctx context.Context, account *gtsmodel.Acc
//
// If blocking is true, then the calls to the media manager made by this function will be blocking:
// in other words, the function won't return until the header and the avatar have been fully processed.
-func (d *deref) fetchRemoteAccountMedia(ctx context.Context, targetAccount *gtsmodel.Account, t transport.Transport, blocking bool) (bool, error) {
- changed := false
-
- accountURI, err := url.Parse(targetAccount.URI)
- if err != nil {
- return changed, fmt.Errorf("fetchRemoteAccountMedia: couldn't parse account URI %s: %s", targetAccount.URI, err)
- }
-
- if blocked, err := d.db.IsDomainBlocked(ctx, accountURI.Host); blocked || err != nil {
- return changed, fmt.Errorf("fetchRemoteAccountMedia: domain %s is blocked", accountURI.Host)
- }
+func (d *deref) fetchRemoteAccountMedia(ctx context.Context, targetAccount *gtsmodel.Account, requestingUsername string, blocking bool) (bool, error) {
+ var (
+ changed bool
+ t transport.Transport
+ )
if targetAccount.AvatarRemoteURL != "" && (targetAccount.AvatarMediaAttachmentID == "") {
var processingMedia *media.ProcessingMedia
@@ -479,6 +488,14 @@ func (d *deref) fetchRemoteAccountMedia(ctx context.Context, targetAccount *gtsm
return changed, err
}
+ if t == nil {
+ var err error
+ t, err = d.transportController.NewTransportForUsername(ctx, requestingUsername)
+ if err != nil {
+ return false, fmt.Errorf("fetchRemoteAccountMedia: error getting transport for user: %s", err)
+ }
+ }
+
data := func(innerCtx context.Context) (io.Reader, int, error) {
return t.DereferenceMedia(innerCtx, avatarIRI)
}
@@ -537,6 +554,14 @@ func (d *deref) fetchRemoteAccountMedia(ctx context.Context, targetAccount *gtsm
return changed, err
}
+ if t == nil {
+ var err error
+ t, err = d.transportController.NewTransportForUsername(ctx, requestingUsername)
+ if err != nil {
+ return false, fmt.Errorf("fetchRemoteAccountMedia: error getting transport for user: %s", err)
+ }
+ }
+
data := func(innerCtx context.Context) (io.Reader, int, error) {
return t.DereferenceMedia(innerCtx, headerIRI)
}
@@ -580,6 +605,118 @@ func (d *deref) fetchRemoteAccountMedia(ctx context.Context, targetAccount *gtsm
return changed, nil
}
+func (d *deref) fetchRemoteAccountEmojis(ctx context.Context, targetAccount *gtsmodel.Account, requestingUsername string) (bool, error) {
+ maybeEmojis := targetAccount.Emojis
+ maybeEmojiIDs := targetAccount.EmojiIDs
+
+ // It's possible that the account had emoji IDs set on it, but not Emojis
+ // themselves, depending on how it was fetched before being passed to us.
+ //
+ // If we only have IDs, fetch the emojis from the db. We know they're in
+ // there or else they wouldn't have IDs.
+ if len(maybeEmojiIDs) > len(maybeEmojis) {
+ maybeEmojis = []*gtsmodel.Emoji{}
+ for _, emojiID := range maybeEmojiIDs {
+ maybeEmoji, err := d.db.GetEmojiByID(ctx, emojiID)
+ if err != nil {
+ return false, err
+ }
+ maybeEmojis = append(maybeEmojis, maybeEmoji)
+ }
+ }
+
+ // For all the maybe emojis we have, we either fetch them from the database
+ // (if we haven't already), or dereference them from the remote instance.
+ gotEmojis, err := d.populateEmojis(ctx, maybeEmojis, requestingUsername)
+ if err != nil {
+ return false, err
+ }
+
+ // Extract the ID of each fetched or dereferenced emoji, so we can attach
+ // this to the account if necessary.
+ gotEmojiIDs := make([]string, 0, len(gotEmojis))
+ for _, e := range gotEmojis {
+ gotEmojiIDs = append(gotEmojiIDs, e.ID)
+ }
+
+ var (
+ changed = false // have the emojis for this account changed?
+ maybeLen = len(maybeEmojis)
+ gotLen = len(gotEmojis)
+ )
+
+ // if the length of everything is zero, this is simple:
+ // nothing has changed and there's nothing to do
+ if maybeLen == 0 && gotLen == 0 {
+ return changed, nil
+ }
+
+ // if the *amount* of emojis on the account has changed, then the got emojis
+ // are definitely different from the previous ones (if there were any) --
+ // the account has either more or fewer emojis set on it now, so take the
+ // discovered emojis as the new correct ones.
+ if maybeLen != gotLen {
+ changed = true
+ targetAccount.Emojis = gotEmojis
+ targetAccount.EmojiIDs = gotEmojiIDs
+ return changed, nil
+ }
+
+ // if the lengths are the same but not all of the slices are
+ // zero, something *might* have changed, so we have to check
+
+ // 1. did we have emojis before that we don't have now?
+ for _, maybeEmoji := range maybeEmojis {
+ var stillPresent bool
+
+ for _, gotEmoji := range gotEmojis {
+ if maybeEmoji.URI == gotEmoji.URI {
+ // the emoji we maybe had is still present now,
+ // so we can stop checking gotEmojis
+ stillPresent = true
+ break
+ }
+ }
+
+ if !stillPresent {
+ // at least one maybeEmoji is no longer present in
+ // the got emojis, so we can stop checking now
+ changed = true
+ targetAccount.Emojis = gotEmojis
+ targetAccount.EmojiIDs = gotEmojiIDs
+ return changed, nil
+ }
+ }
+
+ // 2. do we have emojis now that we didn't have before?
+ for _, gotEmoji := range gotEmojis {
+ var wasPresent bool
+
+ for _, maybeEmoji := range maybeEmojis {
+ // check emoji IDs here as well, because unreferenced
+ // maybe emojis we didn't already have would not have
+ // had IDs set on them yet
+ if gotEmoji.URI == maybeEmoji.URI && gotEmoji.ID == maybeEmoji.ID {
+ // this got emoji was present already in the maybeEmoji,
+ // so we can stop checking through maybeEmojis
+ wasPresent = true
+ break
+ }
+ }
+
+ if !wasPresent {
+ // at least one gotEmojis was not present in
+ // the maybeEmojis, so we can stop checking now
+ changed = true
+ targetAccount.Emojis = gotEmojis
+ targetAccount.EmojiIDs = gotEmojiIDs
+ return changed, nil
+ }
+ }
+
+ return changed, nil
+}
+
func lockAndLoad(ctx context.Context, lock *sync.Mutex, processing *media.ProcessingMedia, processingMap map[string]*media.ProcessingMedia, accountID string) error {
// whatever happens, remove the in-process media from the map
defer func() {
diff --git a/internal/federation/dereferencing/account_test.go b/internal/federation/dereferencing/account_test.go
index 4f1a83a96..aec612ac8 100644
--- a/internal/federation/dereferencing/account_test.go
+++ b/internal/federation/dereferencing/account_test.go
@@ -27,6 +27,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/ap"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/federation/dereferencing"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/testrig"
)
@@ -195,6 +196,205 @@ func (suite *AccountTestSuite) TestDereferenceLocalAccountWithUnknownUserURI() {
suite.Nil(fetchedAccount)
}
+func (suite *AccountTestSuite) TestDereferenceRemoteAccountWithPartial() {
+ fetchingAccount := suite.testAccounts["local_account_1"]
+
+ remoteAccount := suite.testAccounts["remote_account_1"]
+ remoteAccountPartial := >smodel.Account{
+ ID: remoteAccount.ID,
+ ActorType: remoteAccount.ActorType,
+ Language: remoteAccount.Language,
+ CreatedAt: remoteAccount.CreatedAt,
+ UpdatedAt: remoteAccount.UpdatedAt,
+ Username: remoteAccount.Username,
+ Domain: remoteAccount.Domain,
+ DisplayName: remoteAccount.DisplayName,
+ URI: remoteAccount.URI,
+ InboxURI: remoteAccount.URI,
+ SharedInboxURI: remoteAccount.SharedInboxURI,
+ PublicKeyURI: remoteAccount.PublicKeyURI,
+ URL: remoteAccount.URL,
+ FollowingURI: remoteAccount.FollowingURI,
+ FollowersURI: remoteAccount.FollowersURI,
+ OutboxURI: remoteAccount.OutboxURI,
+ FeaturedCollectionURI: remoteAccount.FeaturedCollectionURI,
+ Emojis: []*gtsmodel.Emoji{
+ // dereference an emoji we don't have stored yet
+ {
+ URI: "http://fossbros-anonymous.io/emoji/01GD5HCC2YECT012TK8PAGX4D1",
+ Shortcode: "kip_van_den_bos",
+ UpdatedAt: testrig.TimeMustParse("2022-09-13T12:13:12+02:00"),
+ ImageRemoteURL: "http://fossbros-anonymous.io/emoji/kip.gif",
+ Disabled: testrig.FalseBool(),
+ VisibleInPicker: testrig.FalseBool(),
+ Domain: "fossbros-anonymous.io",
+ },
+ },
+ }
+
+ fetchedAccount, err := suite.dereferencer.GetRemoteAccount(context.Background(), dereferencing.GetRemoteAccountParams{
+ RequestingUsername: fetchingAccount.Username,
+ RemoteAccountID: testrig.URLMustParse(remoteAccount.URI),
+ RemoteAccountHost: remoteAccount.Domain,
+ RemoteAccountUsername: remoteAccount.Username,
+ PartialAccount: remoteAccountPartial,
+ Blocking: true,
+ })
+ suite.NoError(err)
+ suite.NotNil(fetchedAccount)
+ suite.NotNil(fetchedAccount.EmojiIDs)
+ suite.NotNil(fetchedAccount.Emojis)
+}
+
+func (suite *AccountTestSuite) TestDereferenceRemoteAccountWithPartial2() {
+ fetchingAccount := suite.testAccounts["local_account_1"]
+
+ knownEmoji := suite.testEmojis["yell"]
+
+ remoteAccount := suite.testAccounts["remote_account_1"]
+ remoteAccountPartial := >smodel.Account{
+ ID: remoteAccount.ID,
+ ActorType: remoteAccount.ActorType,
+ Language: remoteAccount.Language,
+ CreatedAt: remoteAccount.CreatedAt,
+ UpdatedAt: remoteAccount.UpdatedAt,
+ Username: remoteAccount.Username,
+ Domain: remoteAccount.Domain,
+ DisplayName: remoteAccount.DisplayName,
+ URI: remoteAccount.URI,
+ InboxURI: remoteAccount.URI,
+ SharedInboxURI: remoteAccount.SharedInboxURI,
+ PublicKeyURI: remoteAccount.PublicKeyURI,
+ URL: remoteAccount.URL,
+ FollowingURI: remoteAccount.FollowingURI,
+ FollowersURI: remoteAccount.FollowersURI,
+ OutboxURI: remoteAccount.OutboxURI,
+ FeaturedCollectionURI: remoteAccount.FeaturedCollectionURI,
+ Emojis: []*gtsmodel.Emoji{
+ // an emoji we already have
+ {
+ URI: knownEmoji.URI,
+ Shortcode: knownEmoji.Shortcode,
+ UpdatedAt: knownEmoji.CreatedAt,
+ ImageRemoteURL: knownEmoji.ImageRemoteURL,
+ Disabled: knownEmoji.Disabled,
+ VisibleInPicker: knownEmoji.VisibleInPicker,
+ },
+ },
+ }
+
+ fetchedAccount, err := suite.dereferencer.GetRemoteAccount(context.Background(), dereferencing.GetRemoteAccountParams{
+ RequestingUsername: fetchingAccount.Username,
+ RemoteAccountID: testrig.URLMustParse(remoteAccount.URI),
+ RemoteAccountHost: remoteAccount.Domain,
+ RemoteAccountUsername: remoteAccount.Username,
+ PartialAccount: remoteAccountPartial,
+ Blocking: true,
+ })
+ suite.NoError(err)
+ suite.NotNil(fetchedAccount)
+ suite.NotNil(fetchedAccount.EmojiIDs)
+ suite.NotNil(fetchedAccount.Emojis)
+}
+
+func (suite *AccountTestSuite) TestDereferenceRemoteAccountWithPartial3() {
+ fetchingAccount := suite.testAccounts["local_account_1"]
+
+ knownEmoji := suite.testEmojis["yell"]
+
+ remoteAccount := suite.testAccounts["remote_account_1"]
+ remoteAccountPartial := >smodel.Account{
+ ID: remoteAccount.ID,
+ ActorType: remoteAccount.ActorType,
+ Language: remoteAccount.Language,
+ CreatedAt: remoteAccount.CreatedAt,
+ UpdatedAt: remoteAccount.UpdatedAt,
+ Username: remoteAccount.Username,
+ Domain: remoteAccount.Domain,
+ DisplayName: remoteAccount.DisplayName,
+ URI: remoteAccount.URI,
+ InboxURI: remoteAccount.URI,
+ SharedInboxURI: remoteAccount.SharedInboxURI,
+ PublicKeyURI: remoteAccount.PublicKeyURI,
+ URL: remoteAccount.URL,
+ FollowingURI: remoteAccount.FollowingURI,
+ FollowersURI: remoteAccount.FollowersURI,
+ OutboxURI: remoteAccount.OutboxURI,
+ FeaturedCollectionURI: remoteAccount.FeaturedCollectionURI,
+ Emojis: []*gtsmodel.Emoji{
+ // an emoji we already have
+ {
+ URI: knownEmoji.URI,
+ Shortcode: knownEmoji.Shortcode,
+ UpdatedAt: knownEmoji.CreatedAt,
+ ImageRemoteURL: knownEmoji.ImageRemoteURL,
+ Disabled: knownEmoji.Disabled,
+ VisibleInPicker: knownEmoji.VisibleInPicker,
+ },
+ },
+ }
+
+ fetchedAccount, err := suite.dereferencer.GetRemoteAccount(context.Background(), dereferencing.GetRemoteAccountParams{
+ RequestingUsername: fetchingAccount.Username,
+ RemoteAccountID: testrig.URLMustParse(remoteAccount.URI),
+ RemoteAccountHost: remoteAccount.Domain,
+ RemoteAccountUsername: remoteAccount.Username,
+ PartialAccount: remoteAccountPartial,
+ Blocking: true,
+ })
+ suite.NoError(err)
+ suite.NotNil(fetchedAccount)
+ suite.NotNil(fetchedAccount.EmojiIDs)
+ suite.NotNil(fetchedAccount.Emojis)
+ suite.Equal(knownEmoji.URI, fetchedAccount.Emojis[0].URI)
+
+ remoteAccountPartial2 := >smodel.Account{
+ ID: remoteAccount.ID,
+ ActorType: remoteAccount.ActorType,
+ Language: remoteAccount.Language,
+ CreatedAt: remoteAccount.CreatedAt,
+ UpdatedAt: remoteAccount.UpdatedAt,
+ Username: remoteAccount.Username,
+ Domain: remoteAccount.Domain,
+ DisplayName: remoteAccount.DisplayName,
+ URI: remoteAccount.URI,
+ InboxURI: remoteAccount.URI,
+ SharedInboxURI: remoteAccount.SharedInboxURI,
+ PublicKeyURI: remoteAccount.PublicKeyURI,
+ URL: remoteAccount.URL,
+ FollowingURI: remoteAccount.FollowingURI,
+ FollowersURI: remoteAccount.FollowersURI,
+ OutboxURI: remoteAccount.OutboxURI,
+ FeaturedCollectionURI: remoteAccount.FeaturedCollectionURI,
+ Emojis: []*gtsmodel.Emoji{
+ // dereference an emoji we don't have stored yet
+ {
+ URI: "http://fossbros-anonymous.io/emoji/01GD5HCC2YECT012TK8PAGX4D1",
+ Shortcode: "kip_van_den_bos",
+ UpdatedAt: testrig.TimeMustParse("2022-09-13T12:13:12+02:00"),
+ ImageRemoteURL: "http://fossbros-anonymous.io/emoji/kip.gif",
+ Disabled: testrig.FalseBool(),
+ VisibleInPicker: testrig.FalseBool(),
+ Domain: "fossbros-anonymous.io",
+ },
+ },
+ }
+
+ fetchedAccount2, err := suite.dereferencer.GetRemoteAccount(context.Background(), dereferencing.GetRemoteAccountParams{
+ RequestingUsername: fetchingAccount.Username,
+ RemoteAccountID: testrig.URLMustParse(remoteAccount.URI),
+ RemoteAccountHost: remoteAccount.Domain,
+ RemoteAccountUsername: remoteAccount.Username,
+ PartialAccount: remoteAccountPartial2,
+ Blocking: true,
+ })
+ suite.NoError(err)
+ suite.NotNil(fetchedAccount2)
+ suite.NotNil(fetchedAccount2.EmojiIDs)
+ suite.NotNil(fetchedAccount2.Emojis)
+ suite.Equal("http://fossbros-anonymous.io/emoji/01GD5HCC2YECT012TK8PAGX4D1", fetchedAccount2.Emojis[0].URI)
+}
+
func TestAccountTestSuite(t *testing.T) {
suite.Run(t, new(AccountTestSuite))
}
diff --git a/internal/federation/dereferencing/dereferencer_test.go b/internal/federation/dereferencing/dereferencer_test.go
index c0343a6b8..1bf11d668 100644
--- a/internal/federation/dereferencing/dereferencer_test.go
+++ b/internal/federation/dereferencing/dereferencer_test.go
@@ -41,6 +41,7 @@ type DereferencerStandardTestSuite struct {
testRemoteServices map[string]vocab.ActivityStreamsService
testRemoteAttachments map[string]testrig.RemoteAttachmentFile
testAccounts map[string]*gtsmodel.Account
+ testEmojis map[string]*gtsmodel.Emoji
dereferencer dereferencing.Dereferencer
}
@@ -55,6 +56,7 @@ func (suite *DereferencerStandardTestSuite) SetupTest() {
suite.testRemoteGroups = testrig.NewTestFediGroups()
suite.testRemoteServices = testrig.NewTestFediServices()
suite.testRemoteAttachments = testrig.NewTestFediAttachments("../../../testrig/media")
+ suite.testEmojis = testrig.NewTestEmojis()
suite.db = testrig.NewTestDB()
suite.storage = testrig.NewInMemoryStorage()
diff --git a/internal/federation/dereferencing/emoji.go b/internal/federation/dereferencing/emoji.go
index 49811b131..87d0bd515 100644
--- a/internal/federation/dereferencing/emoji.go
+++ b/internal/federation/dereferencing/emoji.go
@@ -24,6 +24,10 @@ import (
"io"
"net/url"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/id"
+ "github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/media"
)
@@ -49,3 +53,57 @@ func (d *deref) GetRemoteEmoji(ctx context.Context, requestingUsername string, r
return processingMedia, nil
}
+
+func (d *deref) populateEmojis(ctx context.Context, rawEmojis []*gtsmodel.Emoji, requestingUsername string) ([]*gtsmodel.Emoji, error) {
+ // At this point we should know:
+ // * the AP uri of the emoji
+ // * the domain of the emoji
+ // * the shortcode of the emoji
+ // * the remote URL of the image
+ // This should be enough to dereference the emoji
+
+ gotEmojis := make([]*gtsmodel.Emoji, 0, len(rawEmojis))
+
+ for _, e := range rawEmojis {
+ var gotEmoji *gtsmodel.Emoji
+ var err error
+
+ // check if we've already got this emoji in the db
+ if gotEmoji, err = d.db.GetEmojiByURI(ctx, e.URI); err != nil && err != db.ErrNoEntries {
+ log.Errorf("populateEmojis: error checking database for emoji %s: %s", e.URI, err)
+ continue
+ }
+
+ if gotEmoji == nil {
+ // it's new! go get it!
+ newEmojiID, err := id.NewRandomULID()
+ if err != nil {
+ log.Errorf("populateEmojis: error generating id for remote emoji %s: %s", e.URI, err)
+ continue
+ }
+
+ processingEmoji, err := d.GetRemoteEmoji(ctx, requestingUsername, e.ImageRemoteURL, e.Shortcode, newEmojiID, e.URI, &media.AdditionalEmojiInfo{
+ Domain: &e.Domain,
+ ImageRemoteURL: &e.ImageRemoteURL,
+ ImageStaticRemoteURL: &e.ImageRemoteURL,
+ Disabled: e.Disabled,
+ VisibleInPicker: e.VisibleInPicker,
+ })
+
+ if err != nil {
+ log.Errorf("populateEmojis: couldn't get remote emoji %s: %s", e.URI, err)
+ continue
+ }
+
+ if gotEmoji, err = processingEmoji.LoadEmoji(ctx); err != nil {
+ log.Errorf("populateEmojis: couldn't load remote emoji %s: %s", e.URI, err)
+ continue
+ }
+ }
+
+ // if we get here, we either had the emoji already or we successfully fetched it
+ gotEmojis = append(gotEmojis, gotEmoji)
+ }
+
+ return gotEmojis, nil
+}
diff --git a/internal/federation/dereferencing/status.go b/internal/federation/dereferencing/status.go
index 645910d19..bfbc790d8 100644
--- a/internal/federation/dereferencing/status.go
+++ b/internal/federation/dereferencing/status.go
@@ -406,58 +406,17 @@ func (d *deref) populateStatusAttachments(ctx context.Context, status *gtsmodel.
}
func (d *deref) populateStatusEmojis(ctx context.Context, status *gtsmodel.Status, requestingUsername string) error {
- // At this point we should know:
- // * the AP uri of the emoji
- // * the domain of the emoji
- // * the shortcode of the emoji
- // * the remote URL of the image
- // This should be enough to dereference the emoji
-
- gotEmojis := make([]*gtsmodel.Emoji, 0, len(status.Emojis))
- emojiIDs := make([]string, 0, len(status.Emojis))
-
- for _, e := range status.Emojis {
- var gotEmoji *gtsmodel.Emoji
- var err error
-
- // check if we've already got this emoji in the db
- if gotEmoji, err = d.db.GetEmojiByURI(ctx, e.URI); err != nil && err != db.ErrNoEntries {
- log.Errorf("populateStatusEmojis: error checking database for emoji %s: %s", e.URI, err)
- continue
- }
-
- if gotEmoji == nil {
- // it's new! go get it!
- newEmojiID, err := id.NewRandomULID()
- if err != nil {
- log.Errorf("populateStatusEmojis: error generating id for remote emoji %s: %s", e.URI, err)
- continue
- }
-
- processingEmoji, err := d.GetRemoteEmoji(ctx, requestingUsername, e.ImageRemoteURL, e.Shortcode, newEmojiID, e.URI, &media.AdditionalEmojiInfo{
- Domain: &e.Domain,
- ImageRemoteURL: &e.ImageRemoteURL,
- ImageStaticRemoteURL: &e.ImageRemoteURL,
- Disabled: e.Disabled,
- VisibleInPicker: e.VisibleInPicker,
- })
- if err != nil {
- log.Errorf("populateStatusEmojis: couldn't get remote emoji %s: %s", e.URI, err)
- continue
- }
-
- if gotEmoji, err = processingEmoji.LoadEmoji(ctx); err != nil {
- log.Errorf("populateStatusEmojis: couldn't load remote emoji %s: %s", e.URI, err)
- continue
- }
- }
-
- // if we get here, we either had the emoji already or we successfully fetched it
- gotEmojis = append(gotEmojis, gotEmoji)
- emojiIDs = append(emojiIDs, gotEmoji.ID)
+ emojis, err := d.populateEmojis(ctx, status.Emojis, requestingUsername)
+ if err != nil {
+ return err
}
- status.Emojis = gotEmojis
+ emojiIDs := make([]string, 0, len(emojis))
+ for _, e := range emojis {
+ emojiIDs = append(emojiIDs, e.ID)
+ }
+
+ status.Emojis = emojis
status.EmojiIDs = emojiIDs
return nil
}
diff --git a/internal/federation/federatingdb/update.go b/internal/federation/federatingdb/update.go
index 599544e34..f3a04cbcc 100644
--- a/internal/federation/federatingdb/update.go
+++ b/internal/federation/federatingdb/update.go
@@ -121,7 +121,7 @@ func (f *federatingDB) Update(ctx context.Context, asType vocab.Type) error {
return fmt.Errorf("UPDATE: error converting to account: %s", err)
}
- if updatedAcct.Domain == config.GetHost() {
+ if updatedAcct.Domain == config.GetHost() || updatedAcct.Domain == config.GetAccountDomain() {
// no need to update local accounts
// in fact, if we do this will break the shit out of things so do NOT
return nil
@@ -136,13 +136,8 @@ func (f *federatingDB) Update(ctx context.Context, asType vocab.Type) error {
updatedAcct.ID = requestingAcct.ID
updatedAcct.Language = requestingAcct.Language
- // do the update
- updatedAcct, err = f.db.UpdateAccount(ctx, updatedAcct)
- if err != nil {
- return fmt.Errorf("UPDATE: database error inserting updated account: %s", err)
- }
-
- // pass to the processor for further processing of eg., avatar/header
+ // pass to the processor for further updating of eg., avatar/header, emojis
+ // the actual db insert/update will take place a bit later
f.fedWorker.Queue(messages.FromFederator{
APObjectType: ap.ObjectProfile,
APActivityType: ap.ActivityUpdate,
diff --git a/internal/gtsmodel/account.go b/internal/gtsmodel/account.go
index 20405f9ac..ca5c74208 100644
--- a/internal/gtsmodel/account.go
+++ b/internal/gtsmodel/account.go
@@ -41,6 +41,8 @@ type Account struct {
HeaderMediaAttachment *MediaAttachment `validate:"-" bun:"rel:belongs-to"` // MediaAttachment corresponding to headerMediaAttachmentID
HeaderRemoteURL string `validate:"omitempty,url" bun:",nullzero"` // For a non-local account, where can the header be fetched?
DisplayName string `validate:"-" bun:""` // DisplayName for this account. Can be empty, then just the Username will be used for display purposes.
+ EmojiIDs []string `validate:"dive,ulid" bun:"emojis,array"` // Database IDs of any emojis used in this account's bio, display name, etc
+ Emojis []*Emoji `validate:"-" bun:"attached_emojis,m2m:account_to_emojis"` // Emojis corresponding to emojiIDs. https://bun.uptrace.dev/guide/relations.html#many-to-many-relation
Fields []Field `validate:"-"` // a key/value map of fields that this account has added to their profile
Note string `validate:"-" bun:""` // A note that this account has on their profile (ie., the account's bio/description of themselves)
NoteRaw string `validate:"-" bun:""` // The raw contents of .Note without conversion to HTML, only available when requester = target
@@ -76,6 +78,14 @@ type Account struct {
SuspensionOrigin string `validate:"omitempty,ulid" bun:"type:CHAR(26),nullzero"` // id of the database entry that caused this account to become suspended -- can be an account ID or a domain block ID
}
+// AccountToEmoji is an intermediate struct to facilitate the many2many relationship between an account and one or more emojis.
+type AccountToEmoji struct {
+ AccountID string `validate:"ulid,required" bun:"type:CHAR(26),unique:accountemoji,nullzero,notnull"`
+ Account *Account `validate:"-" bun:"rel:belongs-to"`
+ EmojiID string `validate:"ulid,required" bun:"type:CHAR(26),unique:accountemoji,nullzero,notnull"`
+ Emoji *Emoji `validate:"-" bun:"rel:belongs-to"`
+}
+
// Field represents a key value field on an account, for things like pronouns, website, etc.
// VerifiedAt is optional, to be used only if Value is a URL to a webpage that contains the
// username of the user.
diff --git a/internal/processing/account/delete.go b/internal/processing/account/delete.go
index bf7f60d67..3a5a9c622 100644
--- a/internal/processing/account/delete.go
+++ b/internal/processing/account/delete.go
@@ -259,6 +259,8 @@ selectStatusesLoop:
account.HeaderMediaAttachmentID = ""
account.HeaderRemoteURL = ""
account.Reason = ""
+ account.Emojis = []*gtsmodel.Emoji{}
+ account.EmojiIDs = []string{}
account.Fields = []gtsmodel.Field{}
hideCollections := true
account.HideCollections = &hideCollections
diff --git a/internal/processing/account/update.go b/internal/processing/account/update.go
index 47c4a2b4b..eddaeab27 100644
--- a/internal/processing/account/update.go
+++ b/internal/processing/account/update.go
@@ -27,6 +27,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/ap"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
@@ -46,11 +47,14 @@ func (p *processor) Update(ctx context.Context, account *gtsmodel.Account, form
account.Bot = form.Bot
}
+ var updateEmojis bool
+
if form.DisplayName != nil {
if err := validate.DisplayName(*form.DisplayName); err != nil {
return nil, gtserror.NewErrorBadRequest(err)
}
account.DisplayName = text.SanitizePlaintext(*form.DisplayName)
+ updateEmojis = true
}
if form.Note != nil {
@@ -69,6 +73,30 @@ func (p *processor) Update(ctx context.Context, account *gtsmodel.Account, form
// Set updated HTML-ified note
account.Note = note
+ updateEmojis = true
+ }
+
+ if updateEmojis {
+ // account emojis -- treat the sanitized display name and raw
+ // note like one long text for the purposes of deriving emojis
+ accountEmojiShortcodes := util.DeriveEmojisFromText(account.DisplayName + "\n\n" + account.NoteRaw)
+ account.Emojis = make([]*gtsmodel.Emoji, 0, len(accountEmojiShortcodes))
+ account.EmojiIDs = make([]string, 0, len(accountEmojiShortcodes))
+
+ for _, shortcode := range accountEmojiShortcodes {
+ emoji, err := p.db.GetEmojiByShortcodeDomain(ctx, shortcode, "")
+ if err != nil {
+ if err != db.ErrNoEntries {
+ log.Errorf("error getting local emoji with shortcode %s: %s", shortcode, err)
+ }
+ continue
+ }
+
+ if *emoji.VisibleInPicker && !*emoji.Disabled {
+ account.Emojis = append(account.Emojis, emoji)
+ account.EmojiIDs = append(account.EmojiIDs, emoji.ID)
+ }
+ }
}
if form.Avatar != nil && form.Avatar.Size != 0 {
diff --git a/internal/processing/fromfederator.go b/internal/processing/fromfederator.go
index ad8273869..29d996502 100644
--- a/internal/processing/fromfederator.go
+++ b/internal/processing/fromfederator.go
@@ -369,10 +369,14 @@ func (p *processor) processUpdateAccountFromFederator(ctx context.Context, feder
return err
}
+ // further database updates occur inside getremoteaccount
if _, err := p.federator.GetRemoteAccount(ctx, dereferencing.GetRemoteAccountParams{
- RequestingUsername: federatorMsg.ReceivingAccount.Username,
- RemoteAccountID: incomingAccountURL,
- Blocking: true,
+ RequestingUsername: federatorMsg.ReceivingAccount.Username,
+ RemoteAccountID: incomingAccountURL,
+ RemoteAccountHost: incomingAccount.Domain,
+ RemoteAccountUsername: incomingAccount.Username,
+ PartialAccount: incomingAccount,
+ Blocking: true,
}); err != nil {
return fmt.Errorf("error enriching updated account from federator: %s", err)
}
diff --git a/internal/typeutils/astointernal.go b/internal/typeutils/astointernal.go
index b69bb247e..27464809b 100644
--- a/internal/typeutils/astointernal.go
+++ b/internal/typeutils/astointernal.go
@@ -88,6 +88,13 @@ func (c *converter) ASRepresentationToAccount(ctx context.Context, accountable a
acct.DisplayName = displayName
}
+ // account emojis (used in bio, display name, fields)
+ if emojis, err := ap.ExtractEmojis(accountable); err != nil {
+ log.Infof("ASRepresentationToAccount: error extracting account emojis: %s", err)
+ } else {
+ acct.Emojis = emojis
+ }
+
// TODO: fields aka attachment array
// note aka summary
diff --git a/internal/typeutils/converter_test.go b/internal/typeutils/converter_test.go
index 604888050..f56afcd9d 100644
--- a/internal/typeutils/converter_test.go
+++ b/internal/typeutils/converter_test.go
@@ -473,6 +473,7 @@ type TypeUtilsTestSuite struct {
testAccounts map[string]*gtsmodel.Account
testStatuses map[string]*gtsmodel.Status
testPeople map[string]vocab.ActivityStreamsPerson
+ testEmojis map[string]*gtsmodel.Emoji
typeconverter typeutils.TypeConverter
}
@@ -485,6 +486,7 @@ func (suite *TypeUtilsTestSuite) SetupSuite() {
suite.testAccounts = testrig.NewTestAccounts()
suite.testStatuses = testrig.NewTestStatuses()
suite.testPeople = testrig.NewTestFediPeople()
+ suite.testEmojis = testrig.NewTestEmojis()
suite.typeconverter = typeutils.NewConverter(suite.db)
}
diff --git a/internal/typeutils/internaltoas.go b/internal/typeutils/internaltoas.go
index a678a970f..6194dba82 100644
--- a/internal/typeutils/internaltoas.go
+++ b/internal/typeutils/internaltoas.go
@@ -216,8 +216,33 @@ func (c *converter) AccountToAS(ctx context.Context, a *gtsmodel.Account) (vocab
// set the public key property on the Person
person.SetW3IDSecurityV1PublicKey(publicKeyProp)
- // tag
- // TODO: Any tags used in the summary of this profile
+ // tags
+ tagProp := streams.NewActivityStreamsTagProperty()
+
+ // tag -- emojis
+ emojis := a.Emojis
+ if len(a.EmojiIDs) > len(emojis) {
+ emojis = []*gtsmodel.Emoji{}
+ for _, emojiID := range a.EmojiIDs {
+ emoji, err := c.db.GetEmojiByID(ctx, emojiID)
+ if err != nil {
+ return nil, fmt.Errorf("AccountToAS: error getting emoji %s from database: %s", emojiID, err)
+ }
+ emojis = append(emojis, emoji)
+ }
+ }
+ for _, emoji := range emojis {
+ asEmoji, err := c.EmojiToAS(ctx, emoji)
+ if err != nil {
+ return nil, fmt.Errorf("AccountToAS: error converting emoji to AS emoji: %s", err)
+ }
+ tagProp.AppendTootEmoji(asEmoji)
+ }
+
+ // tag -- hashtags
+ // TODO
+
+ person.SetActivityStreamsTag(tagProp)
// attachment
// Used for profile fields.
@@ -477,11 +502,11 @@ func (c *converter) StatusToAS(ctx context.Context, s *gtsmodel.Status) (vocab.A
}
}
for _, emoji := range emojis {
- asMention, err := c.EmojiToAS(ctx, emoji)
+ asEmoji, err := c.EmojiToAS(ctx, emoji)
if err != nil {
return nil, fmt.Errorf("StatusToAS: error converting emoji to AS emoji: %s", err)
}
- tagProp.AppendTootEmoji(asMention)
+ tagProp.AppendTootEmoji(asEmoji)
}
// tag -- hashtags
diff --git a/internal/typeutils/internaltoas_test.go b/internal/typeutils/internaltoas_test.go
index 83e235113..f2845be02 100644
--- a/internal/typeutils/internaltoas_test.go
+++ b/internal/typeutils/internaltoas_test.go
@@ -26,6 +26,7 @@ import (
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/activity/streams"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/testrig"
)
@@ -34,7 +35,8 @@ type InternalToASTestSuite struct {
}
func (suite *InternalToASTestSuite) TestAccountToAS() {
- testAccount := suite.testAccounts["local_account_1"] // take zork for this test
+ testAccount := >smodel.Account{}
+ *testAccount = *suite.testAccounts["local_account_1"] // take zork for this test
asPerson, err := suite.typeconverter.AccountToAS(context.Background(), testAccount)
suite.NoError(err)
@@ -49,11 +51,33 @@ func (suite *InternalToASTestSuite) TestAccountToAS() {
// this is necessary because the order of multiple 'context' entries is not determinate
trimmed := strings.Split(string(bytes), "\"discoverable\"")[1]
- suite.Equal(`:true,"featured":"http://localhost:8080/users/the_mighty_zork/collections/featured","followers":"http://localhost:8080/users/the_mighty_zork/followers","following":"http://localhost:8080/users/the_mighty_zork/following","icon":{"mediaType":"image/jpeg","type":"Image","url":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpeg"},"id":"http://localhost:8080/users/the_mighty_zork","image":{"mediaType":"image/jpeg","type":"Image","url":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg"},"inbox":"http://localhost:8080/users/the_mighty_zork/inbox","manuallyApprovesFollowers":false,"name":"original zork (he/they)","outbox":"http://localhost:8080/users/the_mighty_zork/outbox","preferredUsername":"the_mighty_zork","publicKey":{"id":"http://localhost:8080/users/the_mighty_zork/main-key","owner":"http://localhost:8080/users/the_mighty_zork","publicKeyPem":"-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwXTcOAvM1Jiw5Ffpk0qn\nr0cwbNvFe/5zQ+Tp7tumK/ZnT37o7X0FUEXrxNi+dkhmeJ0gsaiN+JQGNUewvpSk\nPIAXKvi908aSfCGjs7bGlJCJCuDuL5d6m7hZnP9rt9fJc70GElPpG0jc9fXwlz7T\nlsPb2ecatmG05Y4jPwdC+oN4MNCv9yQzEvCVMzl76EJaM602kIHC1CISn0rDFmYd\n9rSN7XPlNJw1F6PbpJ/BWQ+pXHKw3OEwNTETAUNYiVGnZU+B7a7bZC9f6/aPbJuV\nt8Qmg+UnDvW1Y8gmfHnxaWG2f5TDBvCHmcYtucIZPLQD4trAozC4ryqlmCWQNKbt\n0wIDAQAB\n-----END PUBLIC KEY-----\n"},"summary":"\u003cp\u003ehey yo this is my profile!\u003c/p\u003e","type":"Person","url":"http://localhost:8080/@the_mighty_zork"}`, trimmed)
+ suite.Equal(`:true,"featured":"http://localhost:8080/users/the_mighty_zork/collections/featured","followers":"http://localhost:8080/users/the_mighty_zork/followers","following":"http://localhost:8080/users/the_mighty_zork/following","icon":{"mediaType":"image/jpeg","type":"Image","url":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpeg"},"id":"http://localhost:8080/users/the_mighty_zork","image":{"mediaType":"image/jpeg","type":"Image","url":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg"},"inbox":"http://localhost:8080/users/the_mighty_zork/inbox","manuallyApprovesFollowers":false,"name":"original zork (he/they)","outbox":"http://localhost:8080/users/the_mighty_zork/outbox","preferredUsername":"the_mighty_zork","publicKey":{"id":"http://localhost:8080/users/the_mighty_zork/main-key","owner":"http://localhost:8080/users/the_mighty_zork","publicKeyPem":"-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwXTcOAvM1Jiw5Ffpk0qn\nr0cwbNvFe/5zQ+Tp7tumK/ZnT37o7X0FUEXrxNi+dkhmeJ0gsaiN+JQGNUewvpSk\nPIAXKvi908aSfCGjs7bGlJCJCuDuL5d6m7hZnP9rt9fJc70GElPpG0jc9fXwlz7T\nlsPb2ecatmG05Y4jPwdC+oN4MNCv9yQzEvCVMzl76EJaM602kIHC1CISn0rDFmYd\n9rSN7XPlNJw1F6PbpJ/BWQ+pXHKw3OEwNTETAUNYiVGnZU+B7a7bZC9f6/aPbJuV\nt8Qmg+UnDvW1Y8gmfHnxaWG2f5TDBvCHmcYtucIZPLQD4trAozC4ryqlmCWQNKbt\n0wIDAQAB\n-----END PUBLIC KEY-----\n"},"summary":"\u003cp\u003ehey yo this is my profile!\u003c/p\u003e","tag":[],"type":"Person","url":"http://localhost:8080/@the_mighty_zork"}`, trimmed)
+}
+
+func (suite *InternalToASTestSuite) TestAccountToASWithEmoji() {
+ testAccount := >smodel.Account{}
+ *testAccount = *suite.testAccounts["local_account_1"] // take zork for this test
+ testAccount.Emojis = []*gtsmodel.Emoji{suite.testEmojis["rainbow"]}
+
+ asPerson, err := suite.typeconverter.AccountToAS(context.Background(), testAccount)
+ suite.NoError(err)
+
+ ser, err := streams.Serialize(asPerson)
+ suite.NoError(err)
+
+ bytes, err := json.Marshal(ser)
+ suite.NoError(err)
+
+ // trim off everything up to 'discoverable';
+ // this is necessary because the order of multiple 'context' entries is not determinate
+ trimmed := strings.Split(string(bytes), "\"discoverable\"")[1]
+
+ suite.Equal(`:true,"featured":"http://localhost:8080/users/the_mighty_zork/collections/featured","followers":"http://localhost:8080/users/the_mighty_zork/followers","following":"http://localhost:8080/users/the_mighty_zork/following","icon":{"mediaType":"image/jpeg","type":"Image","url":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpeg"},"id":"http://localhost:8080/users/the_mighty_zork","image":{"mediaType":"image/jpeg","type":"Image","url":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg"},"inbox":"http://localhost:8080/users/the_mighty_zork/inbox","manuallyApprovesFollowers":false,"name":"original zork (he/they)","outbox":"http://localhost:8080/users/the_mighty_zork/outbox","preferredUsername":"the_mighty_zork","publicKey":{"id":"http://localhost:8080/users/the_mighty_zork/main-key","owner":"http://localhost:8080/users/the_mighty_zork","publicKeyPem":"-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwXTcOAvM1Jiw5Ffpk0qn\nr0cwbNvFe/5zQ+Tp7tumK/ZnT37o7X0FUEXrxNi+dkhmeJ0gsaiN+JQGNUewvpSk\nPIAXKvi908aSfCGjs7bGlJCJCuDuL5d6m7hZnP9rt9fJc70GElPpG0jc9fXwlz7T\nlsPb2ecatmG05Y4jPwdC+oN4MNCv9yQzEvCVMzl76EJaM602kIHC1CISn0rDFmYd\n9rSN7XPlNJw1F6PbpJ/BWQ+pXHKw3OEwNTETAUNYiVGnZU+B7a7bZC9f6/aPbJuV\nt8Qmg+UnDvW1Y8gmfHnxaWG2f5TDBvCHmcYtucIZPLQD4trAozC4ryqlmCWQNKbt\n0wIDAQAB\n-----END PUBLIC KEY-----\n"},"summary":"\u003cp\u003ehey yo this is my profile!\u003c/p\u003e","tag":{"icon":{"mediaType":"image/png","type":"Image","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png"},"id":"http://localhost:8080/emoji/01F8MH9H8E4VG3KDYJR9EGPXCQ","name":":rainbow:","type":"Emoji"},"type":"Person","url":"http://localhost:8080/@the_mighty_zork"}`, trimmed)
}
func (suite *InternalToASTestSuite) TestAccountToASWithSharedInbox() {
- testAccount := suite.testAccounts["local_account_1"] // take zork for this test
+ testAccount := >smodel.Account{}
+ *testAccount = *suite.testAccounts["local_account_1"] // take zork for this test
sharedInbox := "http://localhost:8080/sharedInbox"
testAccount.SharedInboxURI = &sharedInbox
@@ -70,7 +94,7 @@ func (suite *InternalToASTestSuite) TestAccountToASWithSharedInbox() {
// this is necessary because the order of multiple 'context' entries is not determinate
trimmed := strings.Split(string(bytes), "\"discoverable\"")[1]
- suite.Equal(`:true,"endpoints":{"sharedInbox":"http://localhost:8080/sharedInbox"},"featured":"http://localhost:8080/users/the_mighty_zork/collections/featured","followers":"http://localhost:8080/users/the_mighty_zork/followers","following":"http://localhost:8080/users/the_mighty_zork/following","icon":{"mediaType":"image/jpeg","type":"Image","url":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpeg"},"id":"http://localhost:8080/users/the_mighty_zork","image":{"mediaType":"image/jpeg","type":"Image","url":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg"},"inbox":"http://localhost:8080/users/the_mighty_zork/inbox","manuallyApprovesFollowers":false,"name":"original zork (he/they)","outbox":"http://localhost:8080/users/the_mighty_zork/outbox","preferredUsername":"the_mighty_zork","publicKey":{"id":"http://localhost:8080/users/the_mighty_zork/main-key","owner":"http://localhost:8080/users/the_mighty_zork","publicKeyPem":"-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwXTcOAvM1Jiw5Ffpk0qn\nr0cwbNvFe/5zQ+Tp7tumK/ZnT37o7X0FUEXrxNi+dkhmeJ0gsaiN+JQGNUewvpSk\nPIAXKvi908aSfCGjs7bGlJCJCuDuL5d6m7hZnP9rt9fJc70GElPpG0jc9fXwlz7T\nlsPb2ecatmG05Y4jPwdC+oN4MNCv9yQzEvCVMzl76EJaM602kIHC1CISn0rDFmYd\n9rSN7XPlNJw1F6PbpJ/BWQ+pXHKw3OEwNTETAUNYiVGnZU+B7a7bZC9f6/aPbJuV\nt8Qmg+UnDvW1Y8gmfHnxaWG2f5TDBvCHmcYtucIZPLQD4trAozC4ryqlmCWQNKbt\n0wIDAQAB\n-----END PUBLIC KEY-----\n"},"summary":"\u003cp\u003ehey yo this is my profile!\u003c/p\u003e","type":"Person","url":"http://localhost:8080/@the_mighty_zork"}`, trimmed)
+ suite.Equal(`:true,"endpoints":{"sharedInbox":"http://localhost:8080/sharedInbox"},"featured":"http://localhost:8080/users/the_mighty_zork/collections/featured","followers":"http://localhost:8080/users/the_mighty_zork/followers","following":"http://localhost:8080/users/the_mighty_zork/following","icon":{"mediaType":"image/jpeg","type":"Image","url":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpeg"},"id":"http://localhost:8080/users/the_mighty_zork","image":{"mediaType":"image/jpeg","type":"Image","url":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg"},"inbox":"http://localhost:8080/users/the_mighty_zork/inbox","manuallyApprovesFollowers":false,"name":"original zork (he/they)","outbox":"http://localhost:8080/users/the_mighty_zork/outbox","preferredUsername":"the_mighty_zork","publicKey":{"id":"http://localhost:8080/users/the_mighty_zork/main-key","owner":"http://localhost:8080/users/the_mighty_zork","publicKeyPem":"-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwXTcOAvM1Jiw5Ffpk0qn\nr0cwbNvFe/5zQ+Tp7tumK/ZnT37o7X0FUEXrxNi+dkhmeJ0gsaiN+JQGNUewvpSk\nPIAXKvi908aSfCGjs7bGlJCJCuDuL5d6m7hZnP9rt9fJc70GElPpG0jc9fXwlz7T\nlsPb2ecatmG05Y4jPwdC+oN4MNCv9yQzEvCVMzl76EJaM602kIHC1CISn0rDFmYd\n9rSN7XPlNJw1F6PbpJ/BWQ+pXHKw3OEwNTETAUNYiVGnZU+B7a7bZC9f6/aPbJuV\nt8Qmg+UnDvW1Y8gmfHnxaWG2f5TDBvCHmcYtucIZPLQD4trAozC4ryqlmCWQNKbt\n0wIDAQAB\n-----END PUBLIC KEY-----\n"},"summary":"\u003cp\u003ehey yo this is my profile!\u003c/p\u003e","tag":[],"type":"Person","url":"http://localhost:8080/@the_mighty_zork"}`, trimmed)
}
func (suite *InternalToASTestSuite) TestOutboxToASCollection() {
diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go
index 2f21f2d19..ca86a1284 100644
--- a/internal/typeutils/internaltofrontend.go
+++ b/internal/typeutils/internaltofrontend.go
@@ -159,8 +159,29 @@ func (c *converter) AccountToAPIAccountPublic(ctx context.Context, a *gtsmodel.A
fields = append(fields, mField)
}
+ // account emojis
emojis := []model.Emoji{}
- // TODO: account emojis
+ gtsEmojis := a.Emojis
+ if len(a.EmojiIDs) > len(gtsEmojis) {
+ gtsEmojis = []*gtsmodel.Emoji{}
+ for _, emojiID := range a.EmojiIDs {
+ emoji, err := c.db.GetEmojiByID(ctx, emojiID)
+ if err != nil {
+ return nil, fmt.Errorf("AccountToAPIAccountPublic: error getting emoji %s from database: %s", emojiID, err)
+ }
+ gtsEmojis = append(gtsEmojis, emoji)
+ }
+ }
+ for _, emoji := range gtsEmojis {
+ if *emoji.Disabled {
+ continue
+ }
+ apiEmoji, err := c.EmojiToAPIEmoji(ctx, emoji)
+ if err != nil {
+ return nil, fmt.Errorf("AccountToAPIAccountPublic: error converting emoji to api emoji: %s", err)
+ }
+ emojis = append(emojis, apiEmoji)
+ }
var acct string
if a.Domain != "" {
@@ -194,7 +215,7 @@ func (c *converter) AccountToAPIAccountPublic(ctx context.Context, a *gtsmodel.A
FollowingCount: followingCount,
StatusesCount: statusesCount,
LastStatusAt: lastStatusAt,
- Emojis: emojis, // TODO: implement this
+ Emojis: emojis,
Fields: fields,
Suspended: suspended,
CustomCSS: a.CustomCSS,
diff --git a/internal/typeutils/internaltofrontend_test.go b/internal/typeutils/internaltofrontend_test.go
index dc92260e1..6028344b4 100644
--- a/internal/typeutils/internaltofrontend_test.go
+++ b/internal/typeutils/internaltofrontend_test.go
@@ -43,6 +43,36 @@ func (suite *InternalToFrontendTestSuite) TestAccountToFrontend() {
suite.Equal(`{"id":"01F8MH1H7YV1Z7D2C8K2730QBF","username":"the_mighty_zork","acct":"the_mighty_zork","display_name":"original zork (he/they)","locked":false,"bot":false,"created_at":"2022-05-20T11:09:18.000Z","note":"\u003cp\u003ehey yo this is my profile!\u003c/p\u003e","url":"http://localhost:8080/@the_mighty_zork","avatar":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpeg","avatar_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpeg","header":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg","header_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg","followers_count":2,"following_count":2,"statuses_count":5,"last_status_at":"2022-05-20T11:37:55.000Z","emojis":[],"fields":[]}`, string(b))
}
+func (suite *InternalToFrontendTestSuite) TestAccountToFrontendWithEmojiStruct() {
+ testAccount := suite.testAccounts["local_account_1"] // take zork for this test
+ testEmoji := suite.testEmojis["rainbow"]
+
+ testAccount.Emojis = []*gtsmodel.Emoji{testEmoji}
+
+ apiAccount, err := suite.typeconverter.AccountToAPIAccountPublic(context.Background(), testAccount)
+ suite.NoError(err)
+ suite.NotNil(apiAccount)
+
+ b, err := json.Marshal(apiAccount)
+ suite.NoError(err)
+ suite.Equal(`{"id":"01F8MH1H7YV1Z7D2C8K2730QBF","username":"the_mighty_zork","acct":"the_mighty_zork","display_name":"original zork (he/they)","locked":false,"bot":false,"created_at":"2022-05-20T11:09:18.000Z","note":"\u003cp\u003ehey yo this is my profile!\u003c/p\u003e","url":"http://localhost:8080/@the_mighty_zork","avatar":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpeg","avatar_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpeg","header":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg","header_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg","followers_count":2,"following_count":2,"statuses_count":5,"last_status_at":"2022-05-20T11:37:55.000Z","emojis":[{"shortcode":"rainbow","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","static_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","visible_in_picker":true}],"fields":[]}`, string(b))
+}
+
+func (suite *InternalToFrontendTestSuite) TestAccountToFrontendWithEmojiIDs() {
+ testAccount := suite.testAccounts["local_account_1"] // take zork for this test
+ testEmoji := suite.testEmojis["rainbow"]
+
+ testAccount.EmojiIDs = []string{testEmoji.ID}
+
+ apiAccount, err := suite.typeconverter.AccountToAPIAccountPublic(context.Background(), testAccount)
+ suite.NoError(err)
+ suite.NotNil(apiAccount)
+
+ b, err := json.Marshal(apiAccount)
+ suite.NoError(err)
+ suite.Equal(`{"id":"01F8MH1H7YV1Z7D2C8K2730QBF","username":"the_mighty_zork","acct":"the_mighty_zork","display_name":"original zork (he/they)","locked":false,"bot":false,"created_at":"2022-05-20T11:09:18.000Z","note":"\u003cp\u003ehey yo this is my profile!\u003c/p\u003e","url":"http://localhost:8080/@the_mighty_zork","avatar":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpeg","avatar_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpeg","header":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg","header_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg","followers_count":2,"following_count":2,"statuses_count":5,"last_status_at":"2022-05-20T11:37:55.000Z","emojis":[{"shortcode":"rainbow","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","static_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","visible_in_picker":true}],"fields":[]}`, string(b))
+}
+
func (suite *InternalToFrontendTestSuite) TestAccountToFrontendSensitive() {
testAccount := suite.testAccounts["local_account_1"] // take zork for this test
apiAccount, err := suite.typeconverter.AccountToAPIAccountSensitive(context.Background(), testAccount)
diff --git a/testrig/db.go b/testrig/db.go
index ae3132835..72446e2bc 100644
--- a/testrig/db.go
+++ b/testrig/db.go
@@ -32,6 +32,7 @@ import (
var testModels = []interface{}{
>smodel.Account{},
+ >smodel.AccountToEmoji{},
>smodel.Application{},
>smodel.Block{},
>smodel.DomainBlock{},
diff --git a/testrig/media/kip-original.gif b/testrig/media/kip-original.gif
new file mode 100644
index 0000000000000000000000000000000000000000..6e83746f620727d938d982df640a72301f3c8bf7
GIT binary patch
literal 1428
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
MLq!)+3%3scKTrkTC;$Ke
literal 0
HcmV?d00001
diff --git a/testrig/media/yell-static.png b/testrig/media/yell-static.png
new file mode 100644
index 0000000000000000000000000000000000000000..9b5d2837e6a0d6d23b3300f5f5306a45fc1230c5
GIT binary patch
literal 10808
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
- {{if .account.DisplayName}}{{.account.DisplayName}}{{else}}{{.account.Username}}{{end}}
+ {{if .account.DisplayName}}{{emojify .account.Emojis (escape .account.DisplayName)}}{{else}}{{.account.Username}}{{end}}
@{{.account.Username}}@{{.instance.AccountDomain}}
- {{ if .account.Note }}{{ .account.Note | noescape }}{{else}}This GoToSocial user hasn't written a bio yet!{{end}}
+ {{ if .account.Note }}{{emojify .account.Emojis (noescape .account.Note)}}{{else}}This GoToSocial user hasn't written a bio yet!{{end}}
diff --git a/web/template/status.tmpl b/web/template/status.tmpl
index 16f724a94..c3b243445 100644
--- a/web/template/status.tmpl
+++ b/web/template/status.tmpl
@@ -1,6 +1,6 @@