Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions server/channels/app/post.go
Original file line number Diff line number Diff line change
Expand Up @@ -1232,6 +1232,8 @@ func (a *App) GetPostsSince(rctx request.CTX, options model.GetPostsSinceOptions
return nil, model.NewAppError("GetPostsSince", "app.post.get_posts_since.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}

a.supplementWithTranslationUpdatedPosts(rctx, postList, options.ChannelId, options.Time, options.CollapsedThreads)

if appErr := a.filterInaccessiblePosts(postList, filterPostOptions{assumeSortedCreatedAt: true}); appErr != nil {
return nil, appErr
}
Expand All @@ -1247,6 +1249,73 @@ func (a *App) GetPostsSince(rctx request.CTX, options model.GetPostsSinceOptions
return postList, nil
}

// supplementWithTranslationUpdatedPosts finds posts whose translations were updated after `since`
// and adds them to the post list (Posts map only, not Order) so the client receives fresh translations.
func (a *App) supplementWithTranslationUpdatedPosts(rctx request.CTX, postList *model.PostList, channelID string, since int64, collapsedThreads bool) {
if a.AutoTranslation() == nil || !a.AutoTranslation().IsFeatureAvailable() {
return
}

userID := rctx.Session().UserId
userLang, appErr := a.AutoTranslation().GetUserLanguage(userID, channelID)
if appErr != nil {
rctx.Logger().Debug("Failed to get user language for translation-since supplement", mlog.String("channel_id", channelID), mlog.Err(appErr))
return
}
if userLang == "" {
return
}

translationsMap, err := a.Srv().Store().AutoTranslation().GetTranslationsSinceForChannel(channelID, userLang, since)
if err != nil {
rctx.Logger().Warn("Failed to get translations since for channel", mlog.String("channel_id", channelID), mlog.Err(err))
return
}

// Filter to post IDs not already in the post list
var missingPostIDs []string
for postID := range translationsMap {
if _, exists := postList.Posts[postID]; !exists {
missingPostIDs = append(missingPostIDs, postID)
}
}

if len(missingPostIDs) == 0 {
return
}

posts, err := a.Srv().Store().Post().GetPostsByIds(missingPostIDs)
if err != nil {
rctx.Logger().Warn("Failed to fetch posts for translation-since supplement", mlog.Err(err))
return
}

for _, post := range posts {
if post.DeleteAt != 0 {
continue
}
if collapsedThreads && post.RootId != "" {
continue
}
t, ok := translationsMap[post.Id]
if !ok {
continue
}

if post.Metadata == nil {
post.Metadata = &model.PostMetadata{}
}
if post.Metadata.Translations == nil {
post.Metadata.Translations = make(map[string]*model.PostTranslation)
}
post.Metadata.Translations[t.Lang] = t.ToPostTranslation()

// Add to Posts map only — not to Order — so the client gets the updated translation
// without changing the chronological post list.
postList.Posts[post.Id] = post
}
}

func (a *App) GetSinglePost(rctx request.CTX, postID string, includeDeleted bool) (*model.Post, *model.AppError) {
post, err := a.Srv().Store().Post().GetSingle(rctx, postID, includeDeleted)
if err != nil {
Expand Down
21 changes: 21 additions & 0 deletions server/channels/store/retrylayer/retrylayer.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

60 changes: 60 additions & 0 deletions server/channels/store/sqlstore/autotranslation_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -434,3 +434,63 @@ func (s *SqlAutoTranslationStore) GetLatestPostUpdateAtForChannel(channelID stri
}

func (s *SqlAutoTranslationStore) InvalidatePostTranslationEtag(channelID string) {}

func (s *SqlAutoTranslationStore) GetTranslationsSinceForChannel(channelID, dstLang string, since int64) (map[string]*model.Translation, error) {
query := s.getQueryBuilder().
Select("ObjectType", "ObjectId", "DstLang", "ProviderId", "NormHash", "Text", "Confidence", "Meta", "State", "UpdateAt").
From("Translations").
Where(sq.Eq{"channelid": channelID}).
Where(sq.Eq{"DstLang": dstLang}).
Where(sq.Eq{"ObjectType": model.TranslationObjectTypePost}).
Where(sq.NotEq{"State": string(model.TranslationStateProcessing)}).
Where(sq.Gt{"UpdateAt": since}).
Limit(1000)

var translations []Translation
if err := s.GetReplica().SelectBuilder(&translations, query); err != nil {
return nil, errors.Wrapf(err, "failed to get translations since for channel_id=%s dst_lang=%s", channelID, dstLang)
}

result := make(map[string]*model.Translation, len(translations))
for _, t := range translations {
var translationTypeStr string

meta, err := t.Meta.ToMap()
if err != nil {
continue
}

if v, ok := meta["type"]; ok {
if s, ok := v.(string); ok {
translationTypeStr = s
}
}

objectType := t.ObjectType
if objectType == "" {
objectType = model.TranslationObjectTypePost
}

modelT := &model.Translation{
ObjectID: t.ObjectID,
ObjectType: objectType,
Lang: t.DstLang,
Type: model.TranslationType(translationTypeStr),
Confidence: t.Confidence,
State: model.TranslationState(t.State),
NormHash: t.NormHash,
Meta: meta,
UpdateAt: t.UpdateAt,
}

if modelT.Type == model.TranslationTypeObject {
modelT.ObjectJSON = json.RawMessage(t.Text)
} else {
modelT.Text = t.Text
}

result[t.ObjectID] = modelT
}

return result, nil
}
4 changes: 4 additions & 0 deletions server/channels/store/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -1182,6 +1182,10 @@ type AutoTranslationStore interface {
// InvalidatePostTranslationEtag invalidates the cached post translation etag for a channel.
// This should be called after saving a new post translation.
InvalidatePostTranslationEtag(channelID string)
// GetTranslationsSinceForChannel returns translations updated after `since` for posts in the
// given channel and destination language. Only non-processing translations are returned.
// The result is keyed by post ID.
GetTranslationsSinceForChannel(channelID, dstLang string, since int64) (map[string]*model.Translation, error)
}

type ContentFlaggingStore interface {
Expand Down
30 changes: 30 additions & 0 deletions server/channels/store/storetest/mocks/AutoTranslationStore.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 additions & 0 deletions server/channels/store/timerlayer/timerlayer.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,66 @@ describe('posts', () => {
});
});

describe('preserving CRT thread metadata', () => {
it('should preserve participants, last_reply_at, and is_following when incoming post has null/zero values', () => {
const state = deepFreeze({
post1: {
id: 'post1',
message: 'hello',
update_at: 1000,
participants: [{id: 'user1'}],
last_reply_at: 1234567,
is_following: true,
},
});

const nextState = reducers.handlePosts(state, {
type: PostTypes.RECEIVED_POST,
data: {
id: 'post1',
message: 'hello',
update_at: 1000,
participants: null,
last_reply_at: 0,
is_following: undefined,
},
});

expect(nextState.post1.participants).toEqual([{id: 'user1'}]);
expect(nextState.post1.last_reply_at).toBe(1234567);
expect(nextState.post1.is_following).toBe(true);
});

it('should allow overwriting CRT fields with real values', () => {
const state = deepFreeze({
post1: {
id: 'post1',
message: 'hello',
update_at: 1000,
participants: [{id: 'user1'}],
last_reply_at: 1234567,
is_following: true,
},
});

const nextState = reducers.handlePosts(state, {
type: PostTypes.RECEIVED_POST,
data: {
id: 'post1',
message: 'hello',
update_at: 1001,
participants: [{id: 'user1'}, {id: 'user2'}],
last_reply_at: 2000000,
is_following: false,
},
});

expect(nextState.post1.participants).toEqual([{id: 'user1'}, {id: 'user2'}]);
expect(nextState.post1.last_reply_at).toBe(2000000);
expect(nextState.post1.is_following).toBe(false);
});
});

describe(`deleting a post (${PostTypes.POST_DELETED})`, () => {
it('should mark the post as deleted and remove the rest of the thread', () => {
const state = deepFreeze({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -481,9 +481,19 @@ function handlePostReceived(nextState: any, post: Post, nestedPermalinkLevel?: n
currentState[post.id] = {...currentState[post.id], ...post.metadata};
}

// Edited posts that don't have 'is_following' specified should maintain 'is_following' state
if (post.update_at > 0 && post.is_following == null && currentState[post.id]) {
post.is_following = currentState[post.id].is_following;
// Posts that don't have CRT fields specified should maintain existing state.
// This happens when posts are returned via GetPostsByIds (e.g. translation
// supplement) which doesn't JOIN the Threads/ThreadMemberships tables.
if (post.update_at > 0 && currentState[post.id]) {
if (post.is_following == null) {
post.is_following = currentState[post.id].is_following;
}
if (post.participants == null && currentState[post.id].participants) {
post.participants = currentState[post.id].participants;
}
if (!post.last_reply_at && currentState[post.id].last_reply_at) {
post.last_reply_at = currentState[post.id].last_reply_at;
}
}

if (post.delete_at > 0) {
Expand Down
Loading