diff --git a/cmd/serve_wire.go b/cmd/serve_wire.go index 008fbb7fa..fbaf0df9e 100644 --- a/cmd/serve_wire.go +++ b/cmd/serve_wire.go @@ -60,7 +60,7 @@ func newServer(hub *hub.Hub, db *gorm.DB, repo repository.Repository, fs storage provideQallRoomStateManager, provideQallSoundboard, wire.Struct(new(service.Services), "*"), - wire.Struct(new(Server), "*"), + wire.Struct(new(Server), "L", "SS", "Router", "Hub", "Repo"), wire.Bind(new(repository.ChannelRepository), new(repository.Repository)), wire.Bind(new(repository.FileRepository), new(repository.Repository)), ) diff --git a/docs/v3-api.yaml b/docs/v3-api.yaml index fd51663a5..515ec9723 100644 --- a/docs/v3-api.yaml +++ b/docs/v3-api.yaml @@ -72,6 +72,8 @@ paths: - $ref: "#/components/parameters/untilInQuery" - $ref: "#/components/parameters/inclusiveInQuery" - $ref: "#/components/parameters/orderInQuery" + - $ref: "#/components/parameters/includeAttachments" + - $ref: "#/components/parameters/includeQuotes" responses: "200": description: OK @@ -81,7 +83,7 @@ paths: type: array description: メッセージの配列 items: - $ref: "#/components/schemas/Message" + $ref: "#/components/schemas/DetailedMessage" headers: X-TRAQ-MORE: $ref: "#/components/headers/X-TRAQ-MORE" @@ -4778,6 +4780,42 @@ components: - pinned - stamps - threadId + DetailedMessage: + title: DetailedMessage + type: object + description: メッセージ 詳細 + allOf: + - $ref: "#/components/schemas/Message" + - type: object + properties: + attachments: + type: array + description: 添付ファイルの配列 + items: + $ref: "#/components/schemas/FileInfo" + quotes: + type: array + description: 引用メッセージの配列 + items: + $ref: "#/components/schemas/QuotedMessage" + required: + - attachments + - quotes + QuotedMessage: + title: QuotedMessage + type: object + description: メッセージ 引用 + allOf: + - $ref: "#/components/schemas/Message" + - type: object + properties: + attachments: + type: array + description: 添付ファイルの配列 + items: + $ref: "#/components/schemas/FileInfo" + required: + - attachments MessageStamp: title: MessageStamp type: object @@ -8242,6 +8280,21 @@ components: type: boolean required: false description: 削除されたメッセージを除外するかどうか(デフォルト false) + includeAttachments: + in: query + name: include-attachments + schema: + type: boolean + required: false + description: 添付ファイルを含めるかどうか (デフォルト false) + includeQuotes: + in: query + name: include-quotes + schema: + type: boolean + required: false + description: 引用メッセージを含めるかどうか (デフォルト false) + tags: - name: user diff --git a/model/messages.go b/model/messages.go index e4a71a171..402f88ae0 100644 --- a/model/messages.go +++ b/model/messages.go @@ -23,6 +23,18 @@ type Message struct { Pin *Pin `gorm:"constraint:pins_message_id_messages_id_foreign,OnUpdate:CASCADE,OnDelete:CASCADE"` } +type DetailedMessage struct { + Message + Attachments []*FileMeta + Quotes []*QuotedMessage +} + +type QuotedMessage struct { + Message + + Attachments []*FileMeta +} + // TableName DBの名前を指定するメソッド func (m Message) TableName() string { return "messages" diff --git a/repository/message.go b/repository/message.go index 913f7eb14..d54dc1033 100644 --- a/repository/message.go +++ b/repository/message.go @@ -27,6 +27,8 @@ type MessagesQuery struct { Asc bool ExcludeDMs bool DisablePreload bool + IncludeAttachments bool + IncludeQuotes bool } // ChannelLatestMessagesQuery GetChannelLatestMessages用クエリ diff --git a/repository/mock_repository/mock_file.go b/repository/mock_repository/mock_file.go index f35c7aa70..2abb3330a 100644 --- a/repository/mock_repository/mock_file.go +++ b/repository/mock_repository/mock_file.go @@ -1,5 +1,10 @@ // Code generated by MockGen. DO NOT EDIT. // Source: file.go +// +// Generated by this command: +// +// mockgen -source=file.go -destination=mock_repository/mock_file.go -package=mock_repository +// // Package mock_repository is a generated GoMock package. package mock_repository @@ -18,6 +23,7 @@ import ( type MockFileRepository struct { ctrl *gomock.Controller recorder *MockFileRepositoryMockRecorder + isgomock struct{} } // MockFileRepositoryMockRecorder is the mock recorder for MockFileRepository. @@ -46,7 +52,7 @@ func (m *MockFileRepository) DeleteFileMeta(ctx context.Context, fileID uuid.UUI } // DeleteFileMeta indicates an expected call of DeleteFileMeta. -func (mr *MockFileRepositoryMockRecorder) DeleteFileMeta(ctx, fileID interface{}) *gomock.Call { +func (mr *MockFileRepositoryMockRecorder) DeleteFileMeta(ctx, fileID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteFileMeta", reflect.TypeOf((*MockFileRepository)(nil).DeleteFileMeta), ctx, fileID) } @@ -60,7 +66,7 @@ func (m *MockFileRepository) DeleteFileThumbnail(ctx context.Context, fileID uui } // DeleteFileThumbnail indicates an expected call of DeleteFileThumbnail. -func (mr *MockFileRepositoryMockRecorder) DeleteFileThumbnail(ctx, fileID, thumbnailType interface{}) *gomock.Call { +func (mr *MockFileRepositoryMockRecorder) DeleteFileThumbnail(ctx, fileID, thumbnailType any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteFileThumbnail", reflect.TypeOf((*MockFileRepository)(nil).DeleteFileThumbnail), ctx, fileID, thumbnailType) } @@ -75,7 +81,7 @@ func (m *MockFileRepository) GetFileMeta(ctx context.Context, fileID uuid.UUID) } // GetFileMeta indicates an expected call of GetFileMeta. -func (mr *MockFileRepositoryMockRecorder) GetFileMeta(ctx, fileID interface{}) *gomock.Call { +func (mr *MockFileRepositoryMockRecorder) GetFileMeta(ctx, fileID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetFileMeta", reflect.TypeOf((*MockFileRepository)(nil).GetFileMeta), ctx, fileID) } @@ -91,7 +97,7 @@ func (m *MockFileRepository) GetFileMetas(ctx context.Context, q repository.File } // GetFileMetas indicates an expected call of GetFileMetas. -func (mr *MockFileRepositoryMockRecorder) GetFileMetas(ctx, q interface{}) *gomock.Call { +func (mr *MockFileRepositoryMockRecorder) GetFileMetas(ctx, q any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetFileMetas", reflect.TypeOf((*MockFileRepository)(nil).GetFileMetas), ctx, q) } @@ -106,7 +112,7 @@ func (m *MockFileRepository) IsFileAccessible(ctx context.Context, fileID, userI } // IsFileAccessible indicates an expected call of IsFileAccessible. -func (mr *MockFileRepositoryMockRecorder) IsFileAccessible(ctx, fileID, userID interface{}) *gomock.Call { +func (mr *MockFileRepositoryMockRecorder) IsFileAccessible(ctx, fileID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsFileAccessible", reflect.TypeOf((*MockFileRepository)(nil).IsFileAccessible), ctx, fileID, userID) } @@ -120,7 +126,7 @@ func (m *MockFileRepository) SaveFileMeta(ctx context.Context, meta *model.FileM } // SaveFileMeta indicates an expected call of SaveFileMeta. -func (mr *MockFileRepositoryMockRecorder) SaveFileMeta(ctx, meta, acl interface{}) *gomock.Call { +func (mr *MockFileRepositoryMockRecorder) SaveFileMeta(ctx, meta, acl any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveFileMeta", reflect.TypeOf((*MockFileRepository)(nil).SaveFileMeta), ctx, meta, acl) } diff --git a/router/v3/utils.go b/router/v3/utils.go index 7b718cea9..7c2fc7919 100644 --- a/router/v3/utils.go +++ b/router/v3/utils.go @@ -105,12 +105,14 @@ func getParamClipFolder(c *echo.Context) *model.ClipFolder { } type MessagesQuery struct { - Limit int `query:"limit"` - Offset int `query:"offset"` - Since optional.Of[time.Time] `query:"since"` - Until optional.Of[time.Time] `query:"until"` - Inclusive bool `query:"inclusive"` - Order string `query:"order"` + Limit int `query:"limit"` + Offset int `query:"offset"` + Since optional.Of[time.Time] `query:"since"` + Until optional.Of[time.Time] `query:"until"` + Inclusive bool `query:"inclusive"` + Order string `query:"order"` + IncludeAttachments bool `query:"include-attachments"` + IncludeQuotes bool `query:"include-quotes"` } func (q *MessagesQuery) bind(c *echo.Context) error { @@ -129,12 +131,14 @@ func (q *MessagesQuery) Validate() error { func (q *MessagesQuery) convert() message.TimelineQuery { return message.TimelineQuery{ - Since: q.Since, - Until: q.Until, - Inclusive: q.Inclusive, - Limit: q.Limit, - Offset: q.Offset, - Asc: strings.ToLower(q.Order) == "asc", + Since: q.Since, + Until: q.Until, + Inclusive: q.Inclusive, + Limit: q.Limit, + Offset: q.Offset, + Asc: strings.ToLower(q.Order) == "asc", + IncludeAttachments: q.IncludeAttachments, + IncludeQuotes: q.IncludeQuotes, } } diff --git a/service/message/build_detailed_message_test.go b/service/message/build_detailed_message_test.go new file mode 100644 index 000000000..4c1250523 --- /dev/null +++ b/service/message/build_detailed_message_test.go @@ -0,0 +1,189 @@ +// atode fairu kuttsukeru + +package message + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/gofrs/uuid" + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + + "github.com/traPtitech/traQ/model" + "github.com/traPtitech/traQ/repository" +) + +func fileEmbed(id uuid.UUID) string { + return fmt.Sprintf(`!{"raw":"file","type":"file","id":"%s"}`, id.String()) +} + +func citationEmbed(id uuid.UUID) string { + return fmt.Sprintf(`!{"raw":"message","type":"message","id":"%s"}`, id.String()) +} + +func TestManager_buildDetailedMessage(t *testing.T) { + t.Parallel() + + t.Run("attachments with quotes both disabled", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + mgr, _, _, _ := setupM(ctrl) + m := mgr.(*manager) + + fileID := uuid.NewV3(uuid.Nil, "f1") + mm := &model.Message{ + ID: uuid.NewV3(uuid.Nil, "m1"), + Text: fileEmbed(fileID), + } + + result, _ := m.buildDetailedMessage(context.TODO(), mm, false, false, uuid.NewV3(uuid.Nil, "u1")) + assert.Equal(t, mm.ID, result.ID) + assert.Nil(t, result.Attachments) + assert.Nil(t, result.Quotes) + }) + + t.Run("attachments only", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + mgr, _, repo, _ := setupM(ctrl) + m := mgr.(*manager) + + fileID := uuid.NewV3(uuid.Nil, "f1") + userID := uuid.NewV3(uuid.Nil, "u1") + mm := &model.Message{ID: uuid.NewV3(uuid.Nil, "m1"), Text: fileEmbed(fileID)} + + repo.MockFileRepository.EXPECT().IsFileAccessible(gomock.Any(), fileID, userID).Return(true, nil).AnyTimes() + repo.MockFileRepository.EXPECT().GetFileMeta(gomock.Any(), fileID).Return(&model.FileMeta{ID: fileID}, nil).Times(1) + + result, _ := m.buildDetailedMessage(context.TODO(), mm, true, false, uuid.NewV3(uuid.Nil, "u1")) + if assert.Len(t, result.Attachments, 1) { + assert.Equal(t, fileID, result.Attachments[0].ID) + } + assert.Nil(t, result.Quotes) + }) + + t.Run("GetFileMeta error stops further attachment resolution", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + mgr, _, repo, _ := setupM(ctrl) + m := mgr.(*manager) + + fileID1 := uuid.NewV3(uuid.Nil, "f1") + fileID2 := uuid.NewV3(uuid.Nil, "f2") + userID := uuid.NewV3(uuid.Nil, "u1") + mm := &model.Message{ + ID: uuid.NewV3(uuid.Nil, "m1"), + Text: fileEmbed(fileID1) + fileEmbed(fileID2), + } + + repo.MockFileRepository.EXPECT().IsFileAccessible(gomock.Any(), fileID1, userID).Return(true, nil).AnyTimes() + repo.MockFileRepository.EXPECT().GetFileMeta(gomock.Any(), fileID1).Return(nil, repository.ErrNotFound).Times(1) + + result, _ := m.buildDetailedMessage(context.TODO(), mm, true, false, uuid.NewV3(uuid.Nil, "u1")) + assert.Empty(t, result.Attachments) + }) + + t.Run("quotes only", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + mgr, _, repo, _ := setupM(ctrl) + m := mgr.(*manager) + + quoteID := uuid.NewV3(uuid.Nil, "q1") + mm := &model.Message{ID: uuid.NewV3(uuid.Nil, "m1"), Text: citationEmbed(quoteID)} + + quoted := &model.Message{ID: quoteID, Text: "Quoted Text"} + repo.MockMessageRepository.EXPECT().GetMessages(gomock.Any(), gomock.Any()).Return([]*model.Message{quoted}, false, nil).Times(1) + + result, _ := m.buildDetailedMessage(context.TODO(), mm, false, true, uuid.NewV3(uuid.Nil, "u1")) + assert.Nil(t, result.Attachments) + if assert.Len(t, result.Quotes, 1) { + assert.Equal(t, quoteID, result.Quotes[0].ID) + assert.Empty(t, result.Quotes[0].Attachments) + } + }) + + t.Run("quotes with nested attachment", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + mgr, _, repo, _ := setupM(ctrl) + m := mgr.(*manager) + + quoteID := uuid.NewV3(uuid.Nil, "q1") + nestedFileID := uuid.NewV3(uuid.Nil, "f1") + userID := uuid.NewV3(uuid.Nil, "u1") + mm := &model.Message{ID: uuid.NewV3(uuid.Nil, "m1"), Text: citationEmbed(quoteID)} + quoted := &model.Message{ID: quoteID, Text: fileEmbed(nestedFileID)} + + repo.MockMessageRepository.EXPECT().GetMessages(gomock.Any(), gomock.Any()).Return([]*model.Message{quoted}, false, nil).Times(1) + repo.MockFileRepository.EXPECT().IsFileAccessible(gomock.Any(), nestedFileID, userID).Return(true, nil).AnyTimes() + repo.MockFileRepository.EXPECT().GetFileMeta(gomock.Any(), nestedFileID).Return(&model.FileMeta{ID: nestedFileID}, nil).Times(1) + + result, _ := m.buildDetailedMessage(context.TODO(), mm, false, true, uuid.NewV3(uuid.Nil, "u1")) + if assert.Len(t, result.Quotes, 1) { + if assert.Len(t, result.Quotes[0].Attachments, 1) { + assert.Equal(t, nestedFileID, result.Quotes[0].Attachments[0].ID) + } + } + }) + + t.Run("GetMessages error for quotes results in empty quotes", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + mgr, _, repo, _ := setupM(ctrl) + m := mgr.(*manager) + + quoteID := uuid.NewV3(uuid.Nil, "q1") + mm := &model.Message{ID: uuid.NewV3(uuid.Nil, "m1"), Text: citationEmbed(quoteID)} + + repo.MockMessageRepository.EXPECT().GetMessages(gomock.Any(), gomock.Any()).Return(nil, false, errors.New("db error")).Times(1) + + result, _ := m.buildDetailedMessage(context.TODO(), mm, false, true, uuid.NewV3(uuid.Nil, "u1")) + assert.Empty(t, result.Quotes) + }) +} + +func TestManager_GetTimeline_IncludeFlags(t *testing.T) { + t.Parallel() + + t.Run("passes IncludeAttachments/IncludeQuotes through and resolves attachments", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + mgr, _, repo, _ := setupM(ctrl) + + cid := uuid.NewV3(uuid.Nil, "c1") + fileID := uuid.NewV3(uuid.Nil, "f1") + userID := uuid.NewV3(uuid.Nil, "u1") + msg1 := &model.Message{ + ID: uuid.NewV3(uuid.Nil, "m1"), + ChannelID: cid, + Text: fileEmbed(fileID), + } + + repo.MockMessageRepository.EXPECT().GetMessages(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, q repository.MessagesQuery) ([]*model.Message, bool, error) { + assert.Equal(t, cid, q.Channel) + assert.True(t, q.IncludeAttachments) + assert.False(t, q.IncludeQuotes) + return []*model.Message{msg1}, true, nil + }).Times(1) + + repo.MockFileRepository.EXPECT().IsFileAccessible(gomock.Any(), fileID, userID).Return(true, nil).AnyTimes() + repo.MockFileRepository.EXPECT().GetFileMeta(gomock.Any(), fileID).Return(&model.FileMeta{ID: fileID}, nil).Times(1) + + tl, err := mgr.GetTimeline(context.TODO(), TimelineQuery{ + Channel: cid, + IncludeAttachments: true, + IncludeQuotes: false, + }) + if assert.NoError(t, err) { + assert.True(t, tl.HasMore()) + records := tl.Records() + if assert.Len(t, records, 1) { + assert.Equal(t, msg1.ID, records[0].GetID()) + } + } + }) +} diff --git a/service/message/manager.go b/service/message/manager.go index c0b98be4f..f12d383cd 100644 --- a/service/message/manager.go +++ b/service/message/manager.go @@ -31,6 +31,8 @@ type TimelineQuery struct { Asc bool ExcludeDMs bool DisablePreload bool + IncludeAttachments bool + IncludeQuotes bool } type Manager interface { diff --git a/service/message/manager_impl.go b/service/message/manager_impl.go index 601f7d528..2c510bfe0 100644 --- a/service/message/manager_impl.go +++ b/service/message/manager_impl.go @@ -14,6 +14,7 @@ import ( "github.com/traPtitech/traQ/repository" "github.com/traPtitech/traQ/service/channel" "github.com/traPtitech/traQ/utils" + messageParser "github.com/traPtitech/traQ/utils/message" "github.com/traPtitech/traQ/utils/optional" ) @@ -64,13 +65,90 @@ func (m *manager) get(ctx context.Context, id uuid.UUID) (*message, error) { return m.cache.Get(ctx, id) } +func (m *manager) buildQuotedMessage(ctx context.Context, mm *model.Message, includeAttachments bool, uid uuid.UUID) (*model.QuotedMessage, error) { + attachmentsResult := []*model.FileMeta{} + if includeAttachments { + parseResult := messageParser.Parse(mm.Text) + attachmentsResult = []*model.FileMeta{} + for _, fid := range parseResult.Attachments { + auth, err := m.R.IsFileAccessible(ctx, fid, uid) + if err != nil { + return nil, err + } + if auth { + attachment, err := m.R.GetFileMeta(ctx, fid) + if err != nil { + return nil, err + } + attachmentsResult = append(attachmentsResult, attachment) + } else { + return nil, err + } + } + } + return &model.QuotedMessage{ + Message: *mm, + Attachments: attachmentsResult, + }, nil +} + +func (m *manager) buildDetailedMessage(ctx context.Context, mm *model.Message, includeAttachments bool, includeQuotes bool, uid uuid.UUID) (*model.DetailedMessage, error) { + var attachmentsResult []*model.FileMeta + var citationResult []*model.QuotedMessage + if includeAttachments || includeQuotes { + parseResult := messageParser.Parse(mm.Text) + if includeAttachments { + attachmentsResult = []*model.FileMeta{} + for _, fid := range parseResult.Attachments { + auth, err := m.R.IsFileAccessible(ctx, fid, uid) + if err != nil { + return nil, err + } + if auth { + attachment, err := m.R.GetFileMeta(ctx, fid) + if err != nil { + return nil, err + } + attachmentsResult = append(attachmentsResult, attachment) + } else { + return nil, err + } + } + } + if includeQuotes { + citationResult = []*model.QuotedMessage{} + var err error + quotes, _, err := m.R.GetMessages(ctx, repository.MessagesQuery{IDIn: optional.From((parseResult.Citation))}) + if err != nil { + return nil, err + } + for _, quote := range quotes { + if quote.Channel.IsPublic { + qm, err := m.buildQuotedMessage(ctx, quote, includeAttachments, uid) + if err != nil { + return nil, err + } + citationResult = append(citationResult, qm) + } else { + return nil, ErrNotFound + } + } + } + } + return &model.DetailedMessage{ + Message: *mm, + Attachments: attachmentsResult, + Quotes: citationResult, + }, nil +} + func (m *manager) GetIn(ctx context.Context, ids []uuid.UUID) ([]Message, error) { messages, _, err := m.R.GetMessages(ctx, repository.MessagesQuery{IDIn: optional.From(ids)}) if err != nil { return nil, err } - ret := utils.Map(messages, func(m *model.Message) Message { - return &message{Model: m} + ret := utils.Map(messages, func(mm *model.Message) Message { + return &message{Model: mm} }) return ret, nil } @@ -88,15 +166,24 @@ func (m *manager) GetTimeline(ctx context.Context, query TimelineQuery) (Timelin Asc: query.Asc, ExcludeDMs: query.ExcludeDMs, DisablePreload: query.DisablePreload, + IncludeAttachments: query.IncludeAttachments, + IncludeQuotes: query.IncludeQuotes, } messages, more, err := m.R.GetMessages(ctx, q) if err != nil { return nil, fmt.Errorf("failed to GetMessages: %w", err) } - + records := make([]*model.DetailedMessage, len(messages)) + for i, mm := range messages { + mod, err := m.buildDetailedMessage(ctx, mm, query.IncludeAttachments, query.IncludeQuotes, query.User) + if err != nil { + return nil, err + } + records[i] = mod + } return &timeline{ query: query, - records: messages, + records: records, more: more, preloaded: !q.DisablePreload, retrievedAt: time.Now(), diff --git a/service/message/manager_test.go b/service/message/manager_test.go index 6ba0abb53..23156d05e 100644 --- a/service/message/manager_test.go +++ b/service/message/manager_test.go @@ -11,6 +11,7 @@ type Repo struct { *mock_repository.MockChannelRepository *mock_repository.MockMessageRepository *mock_repository.MockPinRepository + *mock_repository.MockFileRepository testutils.EmptyTestRepository } @@ -19,5 +20,6 @@ func NewMockRepo(ctrl *gomock.Controller) *Repo { MockChannelRepository: mock_repository.NewMockChannelRepository(ctrl), MockMessageRepository: mock_repository.NewMockMessageRepository(ctrl), MockPinRepository: mock_repository.NewMockPinRepository(ctrl), + MockFileRepository: mock_repository.NewMockFileRepository(ctrl), } } diff --git a/service/message/model.go b/service/message/model.go index 15564b16c..e5c2c3583 100644 --- a/service/message/model.go +++ b/service/message/model.go @@ -21,3 +21,9 @@ type Message interface { json.Marshaler } + +type DetailedMessage interface { + Message + GetAttachments() []*model.FileMeta + GetQuotes() []*model.QuotedMessage +} diff --git a/service/message/model_impl.go b/service/message/model_impl.go index 3678ad369..b1b3a788d 100644 --- a/service/message/model_impl.go +++ b/service/message/model_impl.go @@ -21,42 +21,126 @@ type message struct { sync.RWMutex } +/*type detailedMessage struct { + Model *model.DetailedMessage + + stampMap map[uuid.UUID]map[uuid.UUID]model.MessageStamp + stampMapDirty bool + + sync.RWMutex +}*/ + +type quotedMessage struct { + Model *model.QuotedMessage + + stampMap map[uuid.UUID]map[uuid.UUID]model.MessageStamp + stampMapDirty bool + + sync.RWMutex +} + func (m *message) GetID() uuid.UUID { m.RLock() defer m.RUnlock() return m.Model.ID } +/*func (m *detailedMessage) GetID() uuid.UUID { + m.RLock() + defer m.RUnlock() + return m.Model.ID +}*/ + +func (m *quotedMessage) GetID() uuid.UUID { + m.RLock() + defer m.RUnlock() + return m.Model.ID +} + func (m *message) GetUserID() uuid.UUID { m.RLock() defer m.RUnlock() return m.Model.UserID } +/*func (m *detailedMessage) GetUserID() uuid.UUID { + m.RLock() + defer m.RUnlock() + return m.Model.UserID +}*/ + +func (m *quotedMessage) GetUserID() uuid.UUID { + m.RLock() + defer m.RUnlock() + return m.Model.UserID +} + func (m *message) GetChannelID() uuid.UUID { m.RLock() defer m.RUnlock() return m.Model.ChannelID } +/*func (m *detailedMessage) GetChannelID() uuid.UUID { + m.RLock() + defer m.RUnlock() + return m.Model.ChannelID +}*/ + +func (m *quotedMessage) GetChannelID() uuid.UUID { + m.RLock() + defer m.RUnlock() + return m.Model.ChannelID +} + func (m *message) GetText() string { m.RLock() defer m.RUnlock() return m.Model.Text } +/*func (m *detailedMessage) GetText() string { + m.RLock() + defer m.RUnlock() + return m.Model.Text +}*/ + func (m *message) GetCreatedAt() time.Time { m.RLock() defer m.RUnlock() return m.Model.CreatedAt } +/*func (m *detailedMessage) GetCreatedAt() time.Time { + m.RLock() + defer m.RUnlock() + return m.Model.CreatedAt +}*/ + +func (m *quotedMessage) GetCreatedAt() time.Time { + m.RLock() + defer m.RUnlock() + return m.Model.CreatedAt +} + func (m *message) GetUpdatedAt() time.Time { m.RLock() defer m.RUnlock() return m.Model.UpdatedAt } +/*func (m *detailedMessage) GetUpdatedAt() time.Time { + m.RLock() + defer m.RUnlock() + return m.Model.UpdatedAt +}*/ + +func (m *quotedMessage) GetUpdatedAt() time.Time { + m.RLock() + defer m.RUnlock() + return m.Model.UpdatedAt +} + func (m *message) GetStamps() []model.MessageStamp { m.Lock() defer m.Unlock() @@ -76,6 +160,66 @@ func (m *message) GetStamps() []model.MessageStamp { return result } +/*func (m *detailedMessage) GetStamps() []model.MessageStamp { + m.Lock() + defer m.Unlock() + if !m.stampMapDirty { + return m.Model.Stamps + } + + result := make([]model.MessageStamp, 0) + for _, us := range m.stampMap { + for _, ms := range us { + result = append(result, ms) + } + } + + m.Model.Stamps = result + m.stampMapDirty = false + return result +}*/ + +func (m *quotedMessage) GetStamps() []model.MessageStamp { + m.Lock() + defer m.Unlock() + if !m.stampMapDirty { + return m.Model.Stamps + } + + result := make([]model.MessageStamp, 0) + for _, us := range m.stampMap { + for _, ms := range us { + result = append(result, ms) + } + } + + m.Model.Stamps = result + m.stampMapDirty = false + return result +} + +/*func (m *detailedMessage) GetAttachments() []*model.FileMeta { + m.RLock() + defer m.RUnlock() + return m.Model.Attachments +}*/ + +func (m *quotedMessage) GetAttachments() []*model.FileMeta { + m.RLock() + defer m.RUnlock() + return m.Model.Attachments +} + +/*func (m *detailedMessage) GetQuotes() []*model.QuotedMessage { + m.RLock() + defer m.RUnlock() + mq := m.Model.Quotes + sort.SliceStable(mq, func(i, j int) bool { + return (*mq[i]).CreatedAt.Before((*mq[j]).CreatedAt) + }) + return m.Model.Quotes +}*/ + func (m *message) initStampMap() { m.stampMap = map[uuid.UUID]map[uuid.UUID]model.MessageStamp{} for _, ms := range m.Model.Stamps { @@ -124,6 +268,18 @@ func (m *message) GetPin() *model.Pin { return m.Model.Pin } +/*func (m *detailedMessage) GetPin() *model.Pin { + m.RLock() + defer m.RUnlock() + return m.Model.Pin +}*/ + +func (m *quotedMessage) GetPin() *model.Pin { + m.RLock() + defer m.RUnlock() + return m.Model.Pin +} + func (m *message) MarshalJSON() ([]byte, error) { type obj struct { ID uuid.UUID `json:"id"` @@ -151,3 +307,69 @@ func (m *message) MarshalJSON() ([]byte, error) { m.RUnlock() return jsonIter.ConfigFastest.Marshal(v) } + +/*func (m *detailedMessage) MarshalJSON() ([]byte, error) { + type obj struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"userId"` + ChannelID uuid.UUID `json:"channelId"` + Content string `json:"content"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + Pinned bool `json:"pinned"` + Stamps []model.MessageStamp `json:"stamps"` + ThreadID optional.Of[uuid.UUID] `json:"threadId"` // TODO + Attachments []*model.FileMeta `json:"attachments"` + Quotes []*quotedMessage `json:"quotes"` + } + stamps := m.GetStamps() + m.RLock() + quotes := make([]*quotedMessage, len(m.Model.Quotes)) + for i, q := range m.Model.Quotes { + quotes[i] = "edMessage{Model: q} + } + v := &obj{ + ID: m.Model.ID, + UserID: m.Model.UserID, + ChannelID: m.Model.ChannelID, + Content: m.Model.Text, + CreatedAt: m.Model.CreatedAt, + UpdatedAt: m.Model.UpdatedAt, + Pinned: m.Model.Pin != nil, + Stamps: stamps, + Attachments: m.Model.Attachments, + Quotes: quotes, + } + m.RUnlock() + return jsonIter.ConfigFastest.Marshal(v) +}*/ + +func (m *quotedMessage) MarshalJSON() ([]byte, error) { + type obj struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"userId"` + ChannelID uuid.UUID `json:"channelId"` + Content string `json:"content"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + Pinned bool `json:"pinned"` + Stamps []model.MessageStamp `json:"stamps"` + ThreadID optional.Of[uuid.UUID] `json:"threadId"` // TODO + Attachments []*model.FileMeta `json:"attachments"` + } + stamps := m.GetStamps() + m.RLock() + v := &obj{ + ID: m.Model.ID, + UserID: m.Model.UserID, + ChannelID: m.Model.ChannelID, + Content: m.Model.Text, + CreatedAt: m.Model.CreatedAt, + UpdatedAt: m.Model.UpdatedAt, + Pinned: m.Model.Pin != nil, + Stamps: stamps, + Attachments: m.Model.Attachments, + } + m.RUnlock() + return jsonIter.ConfigFastest.Marshal(v) +} diff --git a/service/message/timeline.go b/service/message/timeline.go index 4d5a941a2..4aca99bf7 100644 --- a/service/message/timeline.go +++ b/service/message/timeline.go @@ -6,7 +6,7 @@ import ( type Timeline interface { Query() TimelineQuery - Records() []Message + Records() []DetailedMessage HasMore() bool RetrievedAt() time.Time } diff --git a/service/message/timeline_impl.go b/service/message/timeline_impl.go index 3aadea8a2..226e6dc58 100644 --- a/service/message/timeline_impl.go +++ b/service/message/timeline_impl.go @@ -12,19 +12,47 @@ import ( type timeline struct { query TimelineQuery - records []*model.Message + records []*model.DetailedMessage more bool preloaded bool retrievedAt time.Time man Manager } +// FileInfoOldThumbnail deprecated +type FileInfoOldThumbnail struct { + Mime string `json:"mime"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` +} + +type FileInfoThumbnail struct { + Type string `json:"type"` + Mime string `json:"mime"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` +} + +type FileInfo struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Mime string `json:"mime"` + Size int64 `json:"size"` + MD5 string `json:"md5"` + IsAnimatedImage bool `json:"isAnimatedImage"` + CreatedAt time.Time `json:"createdAt"` + Thumbnail *FileInfoOldThumbnail `json:"thumbnail"` // deprecated + ChannelID optional.Of[uuid.UUID] `json:"channelId"` + UploaderID optional.Of[uuid.UUID] `json:"uploaderId"` + Thumbnails []FileInfoThumbnail `json:"thumbnails"` +} + func (t *timeline) Query() TimelineQuery { return t.query } -func (t *timeline) Records() []Message { - arr := make([]Message, len(t.records)) +func (t *timeline) Records() []DetailedMessage { + arr := make([]DetailedMessage, len(t.records)) for i, record := range t.records { arr[i] = &timelineMessage{Model: record, preloaded: t.preloaded} } @@ -40,7 +68,7 @@ func (t *timeline) RetrievedAt() time.Time { } type timelineMessage struct { - Model *model.Message + Model *model.DetailedMessage preloaded bool } @@ -76,6 +104,14 @@ func (m *timelineMessage) GetPin() *model.Pin { return m.Model.Pin } +func (m *timelineMessage) GetAttachments() []*model.FileMeta { + return m.Model.Attachments +} + +func (m *timelineMessage) GetQuotes() []*model.QuotedMessage { + return m.Model.Quotes +} + func (m *timelineMessage) MarshalJSON() ([]byte, error) { type object struct { ID uuid.UUID `json:"id"` @@ -87,12 +123,43 @@ func (m *timelineMessage) MarshalJSON() ([]byte, error) { } type objectWithPreload struct { object - Pinned bool `json:"pinned"` - Stamps []model.MessageStamp `json:"stamps"` - ThreadID optional.Of[uuid.UUID] `json:"threadId"` // TODO + Pinned bool `json:"pinned"` + Stamps []model.MessageStamp `json:"stamps"` + ThreadID optional.Of[uuid.UUID] `json:"threadId"` // TODO + Attachments []*FileInfo `json:"attachments"` + Quotes []*quotedMessage `json:"quotes"` } var v interface{} if m.preloaded { + quotes := make([]*quotedMessage, len(m.Model.Quotes)) + for i, q := range m.Model.Quotes { + quotes[i] = "edMessage{Model: q} + } + tmp := m.Model.Attachments + fairuinfo := make([]*FileInfo, len(tmp)) + for i, tempu := range tmp { + samuneiru := make([]FileInfoThumbnail, len(tempu.Thumbnails)) + for j, tn := range tempu.Thumbnails { + samuneiru[j] = FileInfoThumbnail{ + Type: tn.Type.String(), + Mime: tn.Mime, + Width: tn.Width, + Height: tn.Height, + } + } + fairuinfo[i] = &FileInfo{ + ID: tempu.ID, + Name: tempu.Name, + Mime: tempu.Mime, + Size: tempu.Size, + MD5: tempu.Hash, + IsAnimatedImage: tempu.IsAnimatedImage, + CreatedAt: tempu.CreatedAt, + ChannelID: tempu.ChannelID, + UploaderID: tempu.CreatorID, + Thumbnails: samuneiru, + } + } v = &objectWithPreload{ object: object{ ID: m.Model.ID, @@ -102,8 +169,10 @@ func (m *timelineMessage) MarshalJSON() ([]byte, error) { CreatedAt: m.Model.CreatedAt, UpdatedAt: m.Model.UpdatedAt, }, - Pinned: m.Model.Pin != nil, - Stamps: m.Model.Stamps, + Pinned: m.Model.Pin != nil, + Stamps: m.Model.Stamps, + Attachments: fairuinfo, + Quotes: quotes, } } else { v = &object{