Files
626d6bbd94 [MM-61758] Burn on read feature (#34703) (#34710)
* Add read receipt store for burn on read message types

* update mocks

* fix invalidation target

* have consistent case on index creation

* Add temporary posts table

* add mock

* add transaction support

* reflect review comments

* wip: Add reveal endpoint

* user check error id instead

* wip: Add ws events and cleanup for burn on read posts

* add burn endpoint for explicitly burning messages

* add translations

* Added logic to associate files of BoR post with the post

* Added test

* fixes

* disable pinning posts and review comments

* MM-66594 - Burn on read UI integration (#34647)

* MM-66244 - add BoR visual components to message editor

* MM-66246 - BoR visual indicator for sender and receiver

* MM-66607 - bor - add timer countdown and autodeletion

* add the system console max time to live config

* use the max expire at and create global scheduler to register bor messages

* use seconds for BoR config values in BE

* implement the read by text shown in the tooltip logic

* unestack the posts from same receiver and BoR  and fix styling

* avoid opening reply RHS

* remove unused dispatchers

* persis the BoR label in the drafts

* move expiration value to metadata

* adjust unit tests to metadata insted of props

* code clean up and some performance improvements; add period grace for deletion too

* adjust migration serie number

* hide bor messages when config is off

* performance improvements on post component and code clean up

* keep bor existing post functionality if config is disabled

* Add read receipt store for burn on read message types

* Add temporary posts table

* add transaction support

* reflect review comments

* wip: Add reveal endpoint

* user check error id instead

* wip: Add ws events and cleanup for burn on read posts

* avoid reacting to unrevealed bor messages

* adjust migration number

* Add read receipt store for burn on read message types

* have consistent case on index creation

* Add temporary posts table

* add mock

* add transaction support

* reflect review comments

* wip: Add reveal endpoint

* user check error id instead

* wip: Add ws events and cleanup for burn on read posts

* add burn endpoint for explicitly burning messages

* adjust post reveal and type with backend changes

* use real config values, adjust icon usage and style

* adjust the delete from from sender and receiver

* improve self deleting logic by placing in badge, use burn endpoint

* adjust websocket events handling for the read by sender label information

* adjust styling for concealed and error state

* update burn-on-read post event handling for improved recipient tracking and multi-device sync

* replace burn_on_read with type in database migrations and model

* remove burn_on_read metadata from PostMetadata and related structures

* Added logic to associate files of BoR post with the post

* Added test

* adjust migration name and fix linter

* Add read receipt store for burn on read message types

* update mocks

* have consistent case on index creation

* Add temporary posts table

* add mock

* add transaction support

* reflect review comments

* wip: Add reveal endpoint

* user check error id instead

* wip: Add ws events and cleanup for burn on read posts

* add burn endpoint for explicitly burning messages

* Added logic to associate files of BoR post with the post

* Added test

* disable pinning posts and review comments

* show attachment on bor reveal

* remove unused translation

* Enhance burn-on-read post handling and refine previous post ID retrieval logic

* adjust the returning chunk to work with bor messages

* read temp post from master db

* read from master

* show the copy link button to the sender

* revert unnecessary check

* restore correct json tag

* remove unused error handling  and clarify burn-on-read comment

* improve type safety and use proper selectors

* eliminate code duplication in deletion handler

* optimize performance and add documentation

* delete bor message for sender once all receivers reveal it

* add burn on read to scheduled posts

* add feature enable check

* use master to avoid  all read recipients race condition

---------





* squash migrations into single file

* add configuration for the scheduler

* don't run messagehasbeenposted hook

* remove parallel tests on burn on read

* add clean up for closing opened modals from previous tests

* simplify delete menu item rendering

* add cleanup step to close open modals after each test to prevent pollution

* streamline delete button visibility logic for Burn on Read posts

* improve reliability of closing post menu and modals by using body ESC key

---------




(cherry picked from commit dcd1d6b9db)

Co-authored-by: Ibrahim Serdar Acikgoz <[email protected]>
Co-authored-by: Harshil Sharma <[email protected]>
Co-authored-by: Pablo Vélez <[email protected]>
2025-12-11 12:40:21 +01:00

166 lines
6.3 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package localcachelayer
import (
"fmt"
"slices"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/mattermost/mattermost/server/v8/platform/services/cache"
)
type LocalCacheReadReceiptStore struct {
store.ReadReceiptStore
rootStore *LocalCacheStore
}
func (s *LocalCacheReadReceiptStore) handleClusterInvalidateReadReceipts(msg *model.ClusterMessage) {
if err := s.rootStore.readReceiptCache.Purge(); err != nil {
s.rootStore.logger.Error("failed to purge read receipt cache", mlog.Err(err))
}
}
func (s LocalCacheReadReceiptStore) ClearCaches() {
if err := s.rootStore.readReceiptCache.Purge(); err != nil {
s.rootStore.logger.Error("failed to purge read receipt cache", mlog.Err(err))
}
if err := s.rootStore.readReceiptPostReadersCache.Purge(); err != nil {
s.rootStore.logger.Error("failed to purge read receipt post readers cache", mlog.Err(err))
}
if s.rootStore.metrics != nil {
s.rootStore.metrics.IncrementMemCacheInvalidationCounter(s.rootStore.readReceiptCache.Name())
s.rootStore.metrics.IncrementMemCacheInvalidationCounter(s.rootStore.readReceiptPostReadersCache.Name())
}
}
func (s LocalCacheReadReceiptStore) InvalidateReadReceiptForPostsCache(postID string) {
s.rootStore.doInvalidateCacheCluster(s.rootStore.readReceiptPostReadersCache, postID, nil)
if s.rootStore.metrics != nil {
s.rootStore.metrics.IncrementMemCacheInvalidationCounter(s.rootStore.readReceiptPostReadersCache.Name())
}
if externalCache, ok := s.rootStore.readReceiptCache.(cache.ExternalCache); ok {
// For redis, invalidate all keys with pattern "postID:*"
s.rootStore.doInvalidateCacheCluster(externalCache, fmt.Sprintf("%s:*", postID), nil)
} else {
if err := s.rootStore.readReceiptCache.Purge(); err != nil {
s.rootStore.logger.Error("failed to purge read receipt cache", mlog.Err(err))
return
}
}
if s.rootStore.metrics != nil {
s.rootStore.metrics.IncrementMemCacheInvalidationCounter(s.rootStore.readReceiptCache.Name())
}
}
func (s LocalCacheReadReceiptStore) Delete(rctx request.CTX, postID, userID string) error {
defer func() {
s.InvalidateReadReceiptForPostsCache(postID)
}()
return s.ReadReceiptStore.Delete(rctx, postID, userID)
}
func (s LocalCacheReadReceiptStore) DeleteByPost(rctx request.CTX, postID string) error {
defer func(postID string) {
s.InvalidateReadReceiptForPostsCache(postID)
}(postID)
return s.ReadReceiptStore.DeleteByPost(rctx, postID)
}
func (s LocalCacheReadReceiptStore) Save(rctx request.CTX, receipt *model.ReadReceipt) (*model.ReadReceipt, error) {
defer func() {
s.rootStore.doInvalidateCacheCluster(s.rootStore.readReceiptCache, fmt.Sprintf("%s:%s", receipt.PostID, receipt.UserID), nil)
s.rootStore.doInvalidateCacheCluster(s.rootStore.readReceiptPostReadersCache, receipt.PostID, nil)
}()
return s.ReadReceiptStore.Save(rctx, receipt)
}
func (s LocalCacheReadReceiptStore) Update(rctx request.CTX, receipt *model.ReadReceipt) (*model.ReadReceipt, error) {
defer func() {
s.rootStore.doInvalidateCacheCluster(s.rootStore.readReceiptCache, fmt.Sprintf("%s:%s", receipt.PostID, receipt.UserID), nil)
}()
return s.ReadReceiptStore.Update(rctx, receipt)
}
func (s LocalCacheReadReceiptStore) Get(rctx request.CTX, postID, userID string) (*model.ReadReceipt, error) {
// no need to store the entire struct in cache, just the expireAt would be sufficient
// as other two fields are part of the cache key
var expireAt int64
if err := s.rootStore.doStandardReadCache(s.rootStore.readReceiptCache, fmt.Sprintf("%s:%s", postID, userID), &expireAt); err == nil {
return &model.ReadReceipt{
PostID: postID,
UserID: userID,
ExpireAt: expireAt,
}, nil
}
rr, err := s.ReadReceiptStore.Get(rctx, postID, userID)
if err != nil {
return nil, err
}
s.rootStore.doStandardAddToCache(s.rootStore.readReceiptCache, fmt.Sprintf("%s:%s", postID, userID), rr.ExpireAt)
// Update post readers cache: add this userID to the list if not already present
var existingUserIDs []string
if err := s.rootStore.doStandardReadCache(s.rootStore.readReceiptPostReadersCache, postID, &existingUserIDs); err == nil {
// Cache exists: check if userID is already in the list
if !slices.Contains(existingUserIDs, userID) {
// Add userID to the existing list
existingUserIDs = append(existingUserIDs, userID)
s.rootStore.doStandardAddToCache(s.rootStore.readReceiptPostReadersCache, postID, existingUserIDs)
}
} else {
// Cache doesn't exist: create new entry with just this userID
s.rootStore.doStandardAddToCache(s.rootStore.readReceiptPostReadersCache, postID, []string{userID})
}
return rr, nil
}
func (s LocalCacheReadReceiptStore) GetByPost(rctx request.CTX, postID string) ([]*model.ReadReceipt, error) {
// Try to get cached user IDs for this post
var cachedUserIDs []string
if err := s.rootStore.doStandardReadCache(s.rootStore.readReceiptPostReadersCache, postID, &cachedUserIDs); err == nil {
// Cache hit: reconstruct receipts from cached user IDs and individual receipt caches
receipts := make([]*model.ReadReceipt, 0, len(cachedUserIDs))
for _, userID := range cachedUserIDs {
var expireAt int64
if err := s.rootStore.doStandardReadCache(s.rootStore.readReceiptCache, fmt.Sprintf("%s:%s", postID, userID), &expireAt); err == nil {
receipts = append(receipts, &model.ReadReceipt{
PostID: postID,
UserID: userID,
ExpireAt: expireAt,
})
}
}
// If we got all receipts from cache, return them
if len(receipts) == len(cachedUserIDs) {
return receipts, nil
}
}
// Cache miss or partial cache: fetch from underlying store
receipts, err := s.ReadReceiptStore.GetByPost(rctx, postID)
if err != nil {
return nil, err
}
// Cache the user IDs for this post
userIDs := make([]string, len(receipts))
for i, receipt := range receipts {
userIDs[i] = receipt.UserID
// Also ensure individual receipts are cached
s.rootStore.doStandardAddToCache(s.rootStore.readReceiptCache, fmt.Sprintf("%s:%s", postID, receipt.UserID), receipt.ExpireAt)
}
s.rootStore.doStandardAddToCache(s.rootStore.readReceiptPostReadersCache, postID, userIDs)
return receipts, nil
}