mirror of
https://github.com/mattermost/mattermost.git
synced 2026-02-18 18:18:23 -05:00
feat: pluginapi: filewillbedownloaded / sendtoastmessage (#34596)
* feat: filewillbedonwloaded hook * feat: error popup * chore: make generated pluginapi * tests * feat: different errors for different download types * feat: allow toast positions * fix: avoid using deprecated i18n function * feat: add plugin API to show toasts * feat: downloadType parameter * tests: updated tests * chore: make check-style * chore: i18n * chore: missing fields in tests * chore: sorted i18n for webapp * chore: run mmjstool * test: fixed webapp tests with new changes * test: missing mocks * fix: ensure one-file attachments (previews) are handler properly as thumbnails * chore: lint * test: added new logic to tests * chore: lint * Add SendToastMessage API and FileWillBeDownloaded hook - Introduced SendToastMessage method for sending toast notifications to users with customizable options. - Added FileWillBeDownloaded hook to handle file download requests, allowing plugins to control access to files. - Updated related types and constants for file download handling. - Enhanced PluginSettings to include HookTimeoutSeconds for better timeout management. * Update webapp/channels/src/components/single_image_view/single_image_view.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * chore: copilot reviews * test: head requests * chore: linted the webapp * tests: fixed path * test: fixed mocked args * allow sending message to a connection directly * fix: hook thread safety * chore: formatting * chore: remove configuration from system console * chore: release version * chore: update signature * chore: update release version * chore: addressed comments * fix: update file rejection handling to use 403 Forbidden status and include rejection reason header * Fix nil pointer panic in runFileWillBeDownloadedHook The atomic.Value in runFileWillBeDownloadedHook can be nil if no plugins implement the FileWillBeDownloaded hook. This causes a panic when trying to assert the nil interface to string. This fix adds a nil check before the type assertion, defaulting to an empty string (which allows the download) when no hooks have run. Fixes: - TestUploadDataMultipart/success panic - TestUploadDataMultipart/resume_success panic * test: move the logout test last * chore: restored accidential deletion * chore: lint * chore: make generated * refactor: move websocket events to new package * chore: go vet * chore: missing mock * chore: revert incorrect fmt * chore: import ordering * chore: npm i18n-extract * chore: update constants.tsx from master * chore: make i18n-extract * revert: conflict merge * fix: add missing isFileRejected prop to SingleImageView tests * fix: mock fetch in SingleImageView tests for async thumbnail check The component now performs an async fetch to check thumbnail availability before rendering. Tests need to mock fetch and use waitFor to handle the async state updates. * refactor: move hook logic to app layer * chore: update version to 11.5 * Scope file download rejection toast to the requesting connection Thread the Connection-Id header through RunFileWillBeDownloadedHook and sendFileDownloadRejectedEvent so the WebSocket event is sent only to the connection that initiated the download, instead of all connections for the user. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
parent
37a9a30f40
commit
1be8a68dd7
53 changed files with 1664 additions and 68 deletions
|
|
@ -32,10 +32,10 @@ const maxMultipartFormDataBytes = 10 * 1024 // 10Kb
|
|||
|
||||
func (api *API) InitFile() {
|
||||
api.BaseRoutes.Files.Handle("", api.APISessionRequired(uploadFileStream, handlerParamFileAPI)).Methods(http.MethodPost)
|
||||
api.BaseRoutes.File.Handle("", api.APISessionRequiredTrustRequester(getFile)).Methods(http.MethodGet)
|
||||
api.BaseRoutes.File.Handle("/thumbnail", api.APISessionRequiredTrustRequester(getFileThumbnail)).Methods(http.MethodGet)
|
||||
api.BaseRoutes.File.Handle("", api.APISessionRequiredTrustRequester(getFile)).Methods(http.MethodGet, http.MethodHead)
|
||||
api.BaseRoutes.File.Handle("/thumbnail", api.APISessionRequiredTrustRequester(getFileThumbnail)).Methods(http.MethodGet, http.MethodHead)
|
||||
api.BaseRoutes.File.Handle("/link", api.APISessionRequired(getFileLink)).Methods(http.MethodGet)
|
||||
api.BaseRoutes.File.Handle("/preview", api.APISessionRequiredTrustRequester(getFilePreview)).Methods(http.MethodGet)
|
||||
api.BaseRoutes.File.Handle("/preview", api.APISessionRequiredTrustRequester(getFilePreview)).Methods(http.MethodGet, http.MethodHead)
|
||||
api.BaseRoutes.File.Handle("/info", api.APISessionRequired(getFileInfo)).Methods(http.MethodGet)
|
||||
|
||||
api.BaseRoutes.Team.Handle("/files/search", api.APISessionRequiredDisableWhenBusy(searchFilesInTeam)).Methods(http.MethodPost)
|
||||
|
|
@ -583,7 +583,18 @@ func getFile(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
// Run plugin hook before file download
|
||||
rejectionReason := c.App.RunFileWillBeDownloadedHook(c.AppContext, fileInfo, c.AppContext.Session().UserId, r.Header.Get(model.ConnectionId), model.FileDownloadTypeFile)
|
||||
|
||||
if rejectionReason != "" {
|
||||
w.Header().Set(model.HeaderRejectReason, rejectionReason)
|
||||
c.Err = model.NewAppError("getFile", "api.file.get_file.rejected_by_plugin",
|
||||
map[string]any{"Reason": rejectionReason}, "", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
fileReader, err := c.App.FileReader(fileInfo.Path)
|
||||
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
c.Err.StatusCode = http.StatusNotFound
|
||||
|
|
@ -630,6 +641,16 @@ func getFileThumbnail(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// Run plugin hook before file thumbnail download
|
||||
rejectionReason := c.App.RunFileWillBeDownloadedHook(c.AppContext, info, c.AppContext.Session().UserId, r.Header.Get(model.ConnectionId), model.FileDownloadTypeThumbnail)
|
||||
|
||||
if rejectionReason != "" {
|
||||
w.Header().Set(model.HeaderRejectReason, rejectionReason)
|
||||
c.Err = model.NewAppError("getFileThumbnail", "api.file.get_file_thumbnail.rejected_by_plugin",
|
||||
map[string]any{"Reason": rejectionReason}, "", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if info.ThumbnailPath == "" {
|
||||
c.Err = model.NewAppError("getFileThumbnail", "api.file.get_file_thumbnail.no_thumbnail.app_error", nil, "file_id="+info.Id, http.StatusBadRequest)
|
||||
return
|
||||
|
|
@ -741,11 +762,22 @@ func getFilePreview(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// Check if preview exists before running hook (no point in running hook if there's no preview)
|
||||
if info.PreviewPath == "" {
|
||||
c.Err = model.NewAppError("getFilePreview", "api.file.get_file_preview.no_preview.app_error", nil, "file_id="+info.Id, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Run plugin hook before file preview download
|
||||
rejectionReason := c.App.RunFileWillBeDownloadedHook(c.AppContext, info, c.AppContext.Session().UserId, r.Header.Get(model.ConnectionId), model.FileDownloadTypePreview)
|
||||
|
||||
if rejectionReason != "" {
|
||||
w.Header().Set(model.HeaderRejectReason, rejectionReason)
|
||||
c.Err = model.NewAppError("getFilePreview", "api.file.get_file_preview.rejected_by_plugin",
|
||||
map[string]any{"Reason": rejectionReason}, "", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
fileReader, err := c.App.FileReader(info.PreviewPath)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
|
|
@ -839,6 +871,17 @@ func getPublicFile(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// Run plugin hook before public file download (no user session for public files)
|
||||
rejectionReason := c.App.RunFileWillBeDownloadedHook(c.AppContext, info, "", r.Header.Get(model.ConnectionId), model.FileDownloadTypePublic)
|
||||
|
||||
if rejectionReason != "" {
|
||||
w.Header().Set(model.HeaderRejectReason, rejectionReason)
|
||||
c.Err = model.NewAppError("getPublicFile", "api.file.get_public_file.rejected_by_plugin",
|
||||
map[string]any{"Reason": rejectionReason}, "", http.StatusForbidden)
|
||||
utils.RenderWebAppError(c.App.Config(), w, r, c.Err, c.App.AsymmetricSigningKey())
|
||||
return
|
||||
}
|
||||
|
||||
fileReader, err := c.App.FileReader(info.Path)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
|
|
|
|||
|
|
@ -1614,3 +1614,99 @@ func TestSearchFilesAcrossTeams(t *testing.T) {
|
|||
require.Len(t, fileInfos.Order, 1, "wrong search")
|
||||
require.Equal(t, fileInfos.FileInfos[fileInfos.Order[0]].ChannelId, channels[0].Id, "wrong search")
|
||||
}
|
||||
|
||||
// TestHeadRequestsFileEndpoints tests that HEAD requests work correctly for file endpoints
|
||||
func TestHeadRequestsFileEndpoints(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
client := th.Client
|
||||
|
||||
// Upload a test file
|
||||
sent, err := testutils.ReadTestFile("test.png")
|
||||
require.NoError(t, err)
|
||||
|
||||
fileResp, _, err := client.UploadFile(context.Background(), sent, th.BasicChannel.Id, "test.png")
|
||||
require.NoError(t, err)
|
||||
fileId := fileResp.FileInfos[0].Id
|
||||
|
||||
// Helper function to make HEAD requests
|
||||
makeHeadRequest := func(url string) (*http.Response, error) {
|
||||
req, err := http.NewRequest("HEAD", url, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
req.Header.Set(model.HeaderAuth, client.AuthType+" "+client.AuthToken)
|
||||
|
||||
return client.HTTPClient.Do(req)
|
||||
}
|
||||
|
||||
t.Run("HEAD request to file endpoint returns 200 OK", func(t *testing.T) {
|
||||
url := fmt.Sprintf("%s/files/%s", client.APIURL, fileId)
|
||||
resp, err := makeHeadRequest(url)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
require.NotEmpty(t, resp.Header.Get("Content-Type"))
|
||||
})
|
||||
|
||||
t.Run("HEAD request to thumbnail endpoint returns 200 OK", func(t *testing.T) {
|
||||
url := fmt.Sprintf("%s/files/%s/thumbnail", client.APIURL, fileId)
|
||||
resp, err := makeHeadRequest(url)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("HEAD request to preview endpoint returns 200 OK", func(t *testing.T) {
|
||||
url := fmt.Sprintf("%s/files/%s/preview", client.APIURL, fileId)
|
||||
resp, err := makeHeadRequest(url)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("HEAD request returns no body", func(t *testing.T) {
|
||||
url := fmt.Sprintf("%s/files/%s", client.APIURL, fileId)
|
||||
resp, err := makeHeadRequest(url)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, body, "HEAD response should not contain a body")
|
||||
})
|
||||
|
||||
t.Run("HEAD request requires authentication", func(t *testing.T) {
|
||||
url := fmt.Sprintf("%s/files/%s", client.APIURL, fileId)
|
||||
req, err := http.NewRequest("HEAD", url, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Don't set auth header
|
||||
resp, err := client.HTTPClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("HEAD request with invalid file ID returns 400", func(t *testing.T) {
|
||||
// Use an ID that matches the route pattern [A-Za-z0-9]+ but is invalid (not 26 characters)
|
||||
url := fmt.Sprintf("%s/files/%s", client.APIURL, "invalidid")
|
||||
resp, err := makeHeadRequest(url)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("HEAD request for non-existent file returns 404", func(t *testing.T) {
|
||||
url := fmt.Sprintf("%s/files/%s", client.APIURL, model.NewId())
|
||||
resp, err := makeHeadRequest(url)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
require.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import (
|
|||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"maps"
|
||||
|
|
@ -1755,3 +1756,68 @@ func (a *App) RemoveFileFromFileStore(rctx request.CTX, path string) {
|
|||
return
|
||||
}
|
||||
}
|
||||
|
||||
// sendFileDownloadRejectedEvent sends a websocket event to notify the user that their file download was rejected.
|
||||
// When connectionID is provided, the event is only sent to that specific connection.
|
||||
func (a *App) sendFileDownloadRejectedEvent(info *model.FileInfo, userID string, connectionID string, rejectionReason string, downloadType model.FileDownloadType) {
|
||||
if userID == "" {
|
||||
a.Log().Debug("Skipping websocket event for public file download rejection")
|
||||
return
|
||||
}
|
||||
|
||||
message := model.NewWebSocketEvent(model.WebsocketEventFileDownloadRejected, "", info.ChannelId, userID, nil, "")
|
||||
if connectionID != "" {
|
||||
message.GetBroadcast().ConnectionId = connectionID
|
||||
}
|
||||
message.Add("file_id", info.Id)
|
||||
message.Add("file_name", info.Name)
|
||||
message.Add("rejection_reason", rejectionReason)
|
||||
message.Add("channel_id", info.ChannelId)
|
||||
message.Add("post_id", info.PostId)
|
||||
message.Add("download_type", string(downloadType))
|
||||
a.Publish(message)
|
||||
}
|
||||
|
||||
// RunFileWillBeDownloadedHook executes the FileWillBeDownloaded hook with a timeout.
|
||||
// Returns empty string to allow download, or a rejection reason to block it.
|
||||
func (a *App) RunFileWillBeDownloadedHook(rctx request.CTX, fileInfo *model.FileInfo, userID string, connectionID string, downloadType model.FileDownloadType) string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(model.PluginSettingsDefaultHookTimeoutSeconds)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var rejectionReason atomic.Value
|
||||
done := make(chan struct{})
|
||||
pluginCtx := pluginContext(rctx)
|
||||
|
||||
a.Srv().Go(func() {
|
||||
defer close(done)
|
||||
a.ch.RunMultiHook(func(hooks plugin.Hooks, _ *model.Manifest) bool {
|
||||
rejectionReasonFromHook := hooks.FileWillBeDownloaded(pluginCtx, fileInfo, userID, downloadType)
|
||||
rejectionReason.Store(rejectionReasonFromHook)
|
||||
a.Log().Debug("FileWillBeDownloaded hook called",
|
||||
mlog.String("file_id", fileInfo.Id),
|
||||
mlog.String("user_id", userID),
|
||||
mlog.String("download_type", string(downloadType)),
|
||||
mlog.String("rejection_reason", rejectionReasonFromHook))
|
||||
return rejectionReasonFromHook == ""
|
||||
}, plugin.FileWillBeDownloadedID)
|
||||
})
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
rejectionReasonString := ""
|
||||
if loaded := rejectionReason.Load(); loaded != nil {
|
||||
rejectionReasonString = loaded.(string)
|
||||
}
|
||||
if rejectionReasonString != "" {
|
||||
a.sendFileDownloadRejectedEvent(fileInfo, userID, connectionID, rejectionReasonString, downloadType)
|
||||
}
|
||||
return rejectionReasonString
|
||||
case <-ctx.Done():
|
||||
timeoutMessage := rctx.T("api.file.get_file.plugin_hook_timeout")
|
||||
a.Log().Warn("FileWillBeDownloaded hook timed out, blocking download",
|
||||
mlog.String("file_id", fileInfo.Id),
|
||||
mlog.String("user_id", userID))
|
||||
a.sendFileDownloadRejectedEvent(fileInfo, userID, connectionID, timeoutMessage, downloadType)
|
||||
return timeoutMessage
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1097,6 +1097,10 @@ func (api *PluginAPI) PublishWebSocketEvent(event string, payload map[string]any
|
|||
api.app.Publish(ev)
|
||||
}
|
||||
|
||||
func (api *PluginAPI) SendToastMessage(userID, connectionID, message string, options model.SendToastMessageOptions) *model.AppError {
|
||||
return api.app.SendToastMessage(userID, connectionID, message, options)
|
||||
}
|
||||
|
||||
func (api *PluginAPI) HasPermissionTo(userID string, permission *model.Permission) bool {
|
||||
return api.app.HasPermissionTo(userID, permission)
|
||||
}
|
||||
|
|
|
|||
529
server/channels/app/plugin_file_download_hook_test.go
Normal file
529
server/channels/app/plugin_file_download_hook_test.go
Normal file
|
|
@ -0,0 +1,529 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/plugin"
|
||||
"github.com/mattermost/mattermost/server/public/plugin/plugintest"
|
||||
)
|
||||
|
||||
func TestHookFileWillBeDownloaded(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
|
||||
t.Run("rejected", func(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
var mockAPI plugintest.API
|
||||
mockAPI.On("LoadPluginConfiguration", mock.Anything).Return(nil).Maybe()
|
||||
// Allow any logging calls (not verified in this test)
|
||||
mockAPI.On("LogInfo", mock.Anything, mock.Anything).Maybe().Return(nil)
|
||||
mockAPI.On("LogInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil)
|
||||
mockAPI.On("LogInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil)
|
||||
mockAPI.On("LogWarn", mock.Anything, mock.Anything).Maybe().Return(nil)
|
||||
mockAPI.On("LogWarn", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil)
|
||||
mockAPI.On("LogWarn", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil)
|
||||
|
||||
tearDown, _, _ := SetAppEnvironmentWithPlugins(t, []string{
|
||||
`
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/mattermost/mattermost/server/public/plugin"
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
)
|
||||
|
||||
type MyPlugin struct {
|
||||
plugin.MattermostPlugin
|
||||
}
|
||||
|
||||
func (p *MyPlugin) FileWillBeDownloaded(c *plugin.Context, info *model.FileInfo, userID string, downloadType model.FileDownloadType) string {
|
||||
p.API.LogInfo("Rejecting file download", "file_id", info.Id, "user_id", userID)
|
||||
return "Download blocked by security policy"
|
||||
}
|
||||
|
||||
func main() {
|
||||
plugin.ClientMain(&MyPlugin{})
|
||||
}
|
||||
`,
|
||||
}, th.App, func(*model.Manifest) plugin.API { return &mockAPI })
|
||||
defer tearDown()
|
||||
|
||||
// Upload a file first
|
||||
fileInfo, appErr := th.App.UploadFile(th.Context, []byte("test content"), th.BasicChannel.Id, "test.txt")
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, fileInfo)
|
||||
|
||||
// Get the file info to pass to the hook
|
||||
info, appErr := th.App.GetFileInfo(th.Context, fileInfo.Id)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Call the hook through the app method
|
||||
rejectionReason := th.App.RunFileWillBeDownloadedHook(th.Context, info, th.BasicUser.Id, "", model.FileDownloadTypeFile)
|
||||
|
||||
// Verify the file download was rejected
|
||||
assert.NotEmpty(t, rejectionReason)
|
||||
assert.Contains(t, rejectionReason, "blocked by security policy")
|
||||
mockAPI.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("allowed", func(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
var mockAPI plugintest.API
|
||||
mockAPI.On("LoadPluginConfiguration", mock.Anything).Return(nil).Maybe()
|
||||
// Allow any logging calls (not verified in this test)
|
||||
mockAPI.On("LogInfo", mock.Anything, mock.Anything).Maybe().Return(nil)
|
||||
mockAPI.On("LogInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil)
|
||||
mockAPI.On("LogInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil)
|
||||
|
||||
tearDown, _, _ := SetAppEnvironmentWithPlugins(t, []string{
|
||||
`
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/mattermost/mattermost/server/public/plugin"
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
)
|
||||
|
||||
type MyPlugin struct {
|
||||
plugin.MattermostPlugin
|
||||
}
|
||||
|
||||
func (p *MyPlugin) FileWillBeDownloaded(c *plugin.Context, info *model.FileInfo, userID string, downloadType model.FileDownloadType) string {
|
||||
p.API.LogInfo("Allowing file download", "file_id", info.Id, "user_id", userID)
|
||||
// Return empty string to allow download
|
||||
return ""
|
||||
}
|
||||
|
||||
func main() {
|
||||
plugin.ClientMain(&MyPlugin{})
|
||||
}
|
||||
`,
|
||||
}, th.App, func(*model.Manifest) plugin.API { return &mockAPI })
|
||||
defer tearDown()
|
||||
|
||||
// Upload a file
|
||||
fileInfo, appErr := th.App.UploadFile(th.Context, []byte("test content"), th.BasicChannel.Id, "test.txt")
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, fileInfo)
|
||||
|
||||
// Get the file info
|
||||
info, appErr := th.App.GetFileInfo(th.Context, fileInfo.Id)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Call the hook through the app method
|
||||
rejectionReason := th.App.RunFileWillBeDownloadedHook(th.Context, info, th.BasicUser.Id, "", model.FileDownloadTypeFile)
|
||||
|
||||
// Verify the file download was allowed
|
||||
assert.Empty(t, rejectionReason)
|
||||
mockAPI.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("multiple plugins - first rejects", func(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
var mockAPI1 plugintest.API
|
||||
mockAPI1.On("LoadPluginConfiguration", mock.Anything).Return(nil).Maybe()
|
||||
// Allow any logging calls (not verified in this test)
|
||||
mockAPI1.On("LogInfo", mock.Anything).Maybe().Return(nil)
|
||||
mockAPI1.On("LogInfo", mock.Anything, mock.Anything).Maybe().Return(nil)
|
||||
mockAPI1.On("LogInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil)
|
||||
mockAPI1.On("LogInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil)
|
||||
mockAPI1.On("LogWarn", mock.Anything).Maybe().Return(nil)
|
||||
mockAPI1.On("LogWarn", mock.Anything, mock.Anything).Maybe().Return(nil)
|
||||
mockAPI1.On("LogWarn", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil)
|
||||
mockAPI1.On("LogWarn", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil)
|
||||
|
||||
var mockAPI2 plugintest.API
|
||||
mockAPI2.On("LoadPluginConfiguration", mock.Anything).Return(nil).Maybe()
|
||||
// This plugin should NOT be called because first one rejects
|
||||
|
||||
tearDown, _, _ := SetAppEnvironmentWithPlugins(t, []string{
|
||||
// First plugin - rejects
|
||||
`
|
||||
package main
|
||||
import (
|
||||
"github.com/mattermost/mattermost/server/public/plugin"
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
)
|
||||
type MyPlugin struct {
|
||||
plugin.MattermostPlugin
|
||||
}
|
||||
func (p *MyPlugin) FileWillBeDownloaded(c *plugin.Context, info *model.FileInfo, userID string, downloadType model.FileDownloadType) string {
|
||||
p.API.LogWarn("First plugin rejecting", "file_id", info.Id)
|
||||
return "Rejected by first plugin"
|
||||
}
|
||||
func main() {
|
||||
plugin.ClientMain(&MyPlugin{})
|
||||
}
|
||||
`,
|
||||
// Second plugin - should not be called
|
||||
`
|
||||
package main
|
||||
import (
|
||||
"github.com/mattermost/mattermost/server/public/plugin"
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
)
|
||||
type MyPlugin struct {
|
||||
plugin.MattermostPlugin
|
||||
}
|
||||
func (p *MyPlugin) FileWillBeDownloaded(c *plugin.Context, info *model.FileInfo, userID string, downloadType model.FileDownloadType) string {
|
||||
p.API.LogInfo("Second plugin should not be called")
|
||||
return ""
|
||||
}
|
||||
func main() {
|
||||
plugin.ClientMain(&MyPlugin{})
|
||||
}
|
||||
`,
|
||||
}, th.App, func(*model.Manifest) plugin.API {
|
||||
// Alternate between the two mock APIs
|
||||
return &mockAPI1
|
||||
})
|
||||
defer tearDown()
|
||||
|
||||
// Upload a file
|
||||
fileInfo, appErr := th.App.UploadFile(th.Context, []byte("test content"), th.BasicChannel.Id, "test.txt")
|
||||
require.Nil(t, appErr)
|
||||
|
||||
info, appErr := th.App.GetFileInfo(th.Context, fileInfo.Id)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Call hooks - first one should reject, second should not be called
|
||||
rejectionReason := th.App.RunFileWillBeDownloadedHook(th.Context, info, th.BasicUser.Id, "", model.FileDownloadTypeFile)
|
||||
|
||||
assert.NotEmpty(t, rejectionReason)
|
||||
assert.Contains(t, rejectionReason, "Rejected by first plugin")
|
||||
|
||||
// Only first mock API should have been called
|
||||
mockAPI1.AssertExpectations(t)
|
||||
// mockAPI2 should not have been called (no expectations set)
|
||||
})
|
||||
|
||||
t.Run("no plugins installed", func(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
// No plugins - hook should return empty
|
||||
|
||||
fileInfo, appErr := th.App.UploadFile(th.Context, []byte("test content"), th.BasicChannel.Id, "test.txt")
|
||||
require.Nil(t, appErr)
|
||||
|
||||
info, appErr := th.App.GetFileInfo(th.Context, fileInfo.Id)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
rejectionReason := th.App.RunFileWillBeDownloadedHook(th.Context, info, th.BasicUser.Id, "", model.FileDownloadTypeFile)
|
||||
|
||||
// No plugins means no rejection
|
||||
assert.Empty(t, rejectionReason)
|
||||
})
|
||||
}
|
||||
|
||||
// TestHookFileWillBeDownloadedHeadRequests tests that HEAD requests trigger the FileWillBeDownloaded hook
|
||||
func TestHookFileWillBeDownloadedHeadRequests(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
|
||||
t.Run("HEAD request to file endpoint triggers hook - rejection", func(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
var mockAPI plugintest.API
|
||||
mockAPI.On("LoadPluginConfiguration", mock.Anything).Return(nil).Maybe()
|
||||
mockAPI.On("LogInfo", mock.Anything, mock.Anything).Maybe().Return(nil)
|
||||
mockAPI.On("LogInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil)
|
||||
mockAPI.On("LogInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil)
|
||||
mockAPI.On("LogWarn", mock.Anything, mock.Anything).Maybe().Return(nil)
|
||||
mockAPI.On("LogWarn", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil)
|
||||
|
||||
tearDown, _, _ := SetAppEnvironmentWithPlugins(t, []string{
|
||||
`
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/mattermost/mattermost/server/public/plugin"
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
)
|
||||
|
||||
type MyPlugin struct {
|
||||
plugin.MattermostPlugin
|
||||
}
|
||||
|
||||
func (p *MyPlugin) FileWillBeDownloaded(c *plugin.Context, info *model.FileInfo, userID string, downloadType model.FileDownloadType) string {
|
||||
p.API.LogInfo("Blocking file download", "file_id", info.Id, "download_type", string(downloadType))
|
||||
return "File download blocked by security policy"
|
||||
}
|
||||
|
||||
func main() {
|
||||
plugin.ClientMain(&MyPlugin{})
|
||||
}
|
||||
`,
|
||||
}, th.App, func(*model.Manifest) plugin.API { return &mockAPI })
|
||||
defer tearDown()
|
||||
|
||||
// Upload a file first
|
||||
fileInfo, appErr := th.App.UploadFile(th.Context, []byte("test content"), th.BasicChannel.Id, "test.txt")
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, fileInfo)
|
||||
|
||||
// Get the file info to pass to the hook
|
||||
info, appErr := th.App.GetFileInfo(th.Context, fileInfo.Id)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Call the hook through the app method
|
||||
rejectionReason := th.App.RunFileWillBeDownloadedHook(th.Context, info, th.BasicUser.Id, "", model.FileDownloadTypeFile)
|
||||
|
||||
// Verify the file download was rejected
|
||||
assert.NotEmpty(t, rejectionReason)
|
||||
assert.Contains(t, rejectionReason, "blocked by security policy")
|
||||
mockAPI.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("HEAD request to thumbnail endpoint triggers hook - rejection", func(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
var mockAPI plugintest.API
|
||||
mockAPI.On("LoadPluginConfiguration", mock.Anything).Return(nil).Maybe()
|
||||
mockAPI.On("LogInfo", mock.Anything, mock.Anything).Maybe().Return(nil)
|
||||
mockAPI.On("LogInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil)
|
||||
mockAPI.On("LogInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil)
|
||||
mockAPI.On("LogWarn", mock.Anything, mock.Anything).Maybe().Return(nil)
|
||||
mockAPI.On("LogWarn", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil)
|
||||
|
||||
tearDown, _, _ := SetAppEnvironmentWithPlugins(t, []string{
|
||||
`
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/mattermost/mattermost/server/public/plugin"
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
)
|
||||
|
||||
type MyPlugin struct {
|
||||
plugin.MattermostPlugin
|
||||
}
|
||||
|
||||
func (p *MyPlugin) FileWillBeDownloaded(c *plugin.Context, info *model.FileInfo, userID string, downloadType model.FileDownloadType) string {
|
||||
// Only block thumbnail requests
|
||||
if downloadType == model.FileDownloadTypeThumbnail {
|
||||
p.API.LogInfo("Blocking thumbnail download", "file_id", info.Id)
|
||||
return "Thumbnail download blocked"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func main() {
|
||||
plugin.ClientMain(&MyPlugin{})
|
||||
}
|
||||
`,
|
||||
}, th.App, func(*model.Manifest) plugin.API { return &mockAPI })
|
||||
defer tearDown()
|
||||
|
||||
// Upload a file
|
||||
fileInfo, appErr := th.App.UploadFile(th.Context, []byte("test content"), th.BasicChannel.Id, "test.txt")
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, fileInfo)
|
||||
|
||||
// Get the file info
|
||||
info, appErr := th.App.GetFileInfo(th.Context, fileInfo.Id)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Call the hook for thumbnail download type through the app method
|
||||
rejectionReason := th.App.RunFileWillBeDownloadedHook(th.Context, info, th.BasicUser.Id, "", model.FileDownloadTypeThumbnail)
|
||||
|
||||
// Verify the thumbnail download was rejected
|
||||
assert.NotEmpty(t, rejectionReason)
|
||||
assert.Contains(t, rejectionReason, "Thumbnail download blocked")
|
||||
mockAPI.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("HEAD request to preview endpoint triggers hook - rejection", func(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
var mockAPI plugintest.API
|
||||
mockAPI.On("LoadPluginConfiguration", mock.Anything).Return(nil).Maybe()
|
||||
mockAPI.On("LogInfo", mock.Anything, mock.Anything).Maybe().Return(nil)
|
||||
mockAPI.On("LogInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil)
|
||||
mockAPI.On("LogInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil)
|
||||
mockAPI.On("LogWarn", mock.Anything, mock.Anything).Maybe().Return(nil)
|
||||
mockAPI.On("LogWarn", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil)
|
||||
|
||||
tearDown, _, _ := SetAppEnvironmentWithPlugins(t, []string{
|
||||
`
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/mattermost/mattermost/server/public/plugin"
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
)
|
||||
|
||||
type MyPlugin struct {
|
||||
plugin.MattermostPlugin
|
||||
}
|
||||
|
||||
func (p *MyPlugin) FileWillBeDownloaded(c *plugin.Context, info *model.FileInfo, userID string, downloadType model.FileDownloadType) string {
|
||||
// Only block preview requests
|
||||
if downloadType == model.FileDownloadTypePreview {
|
||||
p.API.LogInfo("Blocking preview download", "file_id", info.Id)
|
||||
return "Preview download blocked"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func main() {
|
||||
plugin.ClientMain(&MyPlugin{})
|
||||
}
|
||||
`,
|
||||
}, th.App, func(*model.Manifest) plugin.API { return &mockAPI })
|
||||
defer tearDown()
|
||||
|
||||
// Upload a file
|
||||
fileInfo, appErr := th.App.UploadFile(th.Context, []byte("test content"), th.BasicChannel.Id, "test.txt")
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, fileInfo)
|
||||
|
||||
// Get the file info
|
||||
info, appErr := th.App.GetFileInfo(th.Context, fileInfo.Id)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Create plugin context
|
||||
// Call the hook for preview download type through the app method
|
||||
rejectionReason := th.App.RunFileWillBeDownloadedHook(th.Context, info, th.BasicUser.Id, "", model.FileDownloadTypePreview)
|
||||
|
||||
// Verify the preview download was rejected
|
||||
assert.NotEmpty(t, rejectionReason)
|
||||
assert.Contains(t, rejectionReason, "Preview download blocked")
|
||||
mockAPI.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("HEAD request to file endpoint triggers hook - allowed", func(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
var mockAPI plugintest.API
|
||||
mockAPI.On("LoadPluginConfiguration", mock.Anything).Return(nil).Maybe()
|
||||
mockAPI.On("LogInfo", mock.Anything, mock.Anything).Maybe().Return(nil)
|
||||
mockAPI.On("LogInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil)
|
||||
mockAPI.On("LogInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil)
|
||||
|
||||
tearDown, _, _ := SetAppEnvironmentWithPlugins(t, []string{
|
||||
`
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/mattermost/mattermost/server/public/plugin"
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
)
|
||||
|
||||
type MyPlugin struct {
|
||||
plugin.MattermostPlugin
|
||||
}
|
||||
|
||||
func (p *MyPlugin) FileWillBeDownloaded(c *plugin.Context, info *model.FileInfo, userID string, downloadType model.FileDownloadType) string {
|
||||
p.API.LogInfo("Allowing file download", "file_id", info.Id, "download_type", string(downloadType))
|
||||
return "" // Allow download
|
||||
}
|
||||
|
||||
func main() {
|
||||
plugin.ClientMain(&MyPlugin{})
|
||||
}
|
||||
`,
|
||||
}, th.App, func(*model.Manifest) plugin.API { return &mockAPI })
|
||||
defer tearDown()
|
||||
|
||||
// Upload a file
|
||||
fileInfo, appErr := th.App.UploadFile(th.Context, []byte("test content"), th.BasicChannel.Id, "test.txt")
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, fileInfo)
|
||||
|
||||
// Get the file info
|
||||
info, appErr := th.App.GetFileInfo(th.Context, fileInfo.Id)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Call the hook through the app method
|
||||
rejectionReason := th.App.RunFileWillBeDownloadedHook(th.Context, info, th.BasicUser.Id, "", model.FileDownloadTypeFile)
|
||||
|
||||
// Verify the file download was allowed
|
||||
assert.Empty(t, rejectionReason)
|
||||
mockAPI.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("HEAD and GET with different download types", func(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
downloadTypesReceived := []model.FileDownloadType{}
|
||||
|
||||
var mockAPI plugintest.API
|
||||
mockAPI.On("LoadPluginConfiguration", mock.Anything).Return(nil).Maybe()
|
||||
mockAPI.On("LogInfo", mock.Anything, mock.Anything).Maybe().Return(nil)
|
||||
mockAPI.On("LogInfo", mock.Anything, mock.Anything, mock.Anything).Run(func(args mock.Arguments) {
|
||||
if args.String(0) == "Hook called" {
|
||||
downloadType := args.String(2)
|
||||
downloadTypesReceived = append(downloadTypesReceived, model.FileDownloadType(downloadType))
|
||||
}
|
||||
}).Maybe().Return(nil)
|
||||
mockAPI.On("LogInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil)
|
||||
mockAPI.On("LogInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil)
|
||||
|
||||
tearDown, _, _ := SetAppEnvironmentWithPlugins(t, []string{
|
||||
`
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/mattermost/mattermost/server/public/plugin"
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
)
|
||||
|
||||
type MyPlugin struct {
|
||||
plugin.MattermostPlugin
|
||||
}
|
||||
|
||||
func (p *MyPlugin) FileWillBeDownloaded(c *plugin.Context, info *model.FileInfo, userID string, downloadType model.FileDownloadType) string {
|
||||
p.API.LogInfo("Hook called", "download_type", string(downloadType))
|
||||
return "" // Allow download
|
||||
}
|
||||
|
||||
func main() {
|
||||
plugin.ClientMain(&MyPlugin{})
|
||||
}
|
||||
`,
|
||||
}, th.App, func(*model.Manifest) plugin.API { return &mockAPI })
|
||||
defer tearDown()
|
||||
|
||||
// Upload a file
|
||||
fileInfo, appErr := th.App.UploadFile(th.Context, []byte("test content"), th.BasicChannel.Id, "test.txt")
|
||||
require.Nil(t, appErr)
|
||||
|
||||
info, appErr := th.App.GetFileInfo(th.Context, fileInfo.Id)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Test File download type
|
||||
th.App.RunFileWillBeDownloadedHook(th.Context, info, th.BasicUser.Id, "", model.FileDownloadTypeFile)
|
||||
|
||||
// Test Thumbnail download type
|
||||
th.App.RunFileWillBeDownloadedHook(th.Context, info, th.BasicUser.Id, "", model.FileDownloadTypeThumbnail)
|
||||
|
||||
// Test Preview download type
|
||||
th.App.RunFileWillBeDownloadedHook(th.Context, info, th.BasicUser.Id, "", model.FileDownloadTypePreview)
|
||||
|
||||
// Verify all three download types were received
|
||||
assert.Len(t, downloadTypesReceived, 3)
|
||||
assert.Contains(t, downloadTypesReceived, model.FileDownloadTypeFile)
|
||||
assert.Contains(t, downloadTypesReceived, model.FileDownloadTypeThumbnail)
|
||||
assert.Contains(t, downloadTypesReceived, model.FileDownloadTypePreview)
|
||||
|
||||
mockAPI.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
38
server/channels/app/toast.go
Normal file
38
server/channels/app/toast.go
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
)
|
||||
|
||||
// SendToastMessage sends a toast notification to a specific user or user session via WebSocket.
|
||||
func (a *App) SendToastMessage(userID, connectionID, message string, options model.SendToastMessageOptions) *model.AppError {
|
||||
if userID == "" {
|
||||
return model.NewAppError("SendToastMessage", "app.toast.send_toast_message.user_id.app_error", nil, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if message == "" {
|
||||
return model.NewAppError("SendToastMessage", "app.toast.send_toast_message.message.app_error", nil, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"message": message,
|
||||
"position": options.Position,
|
||||
}
|
||||
|
||||
broadcast := &model.WebsocketBroadcast{
|
||||
UserId: userID,
|
||||
ConnectionId: connectionID,
|
||||
}
|
||||
|
||||
event := model.NewWebSocketEvent(model.WebsocketEventShowToast, "", "", userID, nil, "")
|
||||
event = event.SetBroadcast(broadcast).SetData(payload)
|
||||
|
||||
a.Publish(event)
|
||||
|
||||
return nil
|
||||
}
|
||||
57
server/channels/app/toast_test.go
Normal file
57
server/channels/app/toast_test.go
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
)
|
||||
|
||||
func TestSendToastMessage(t *testing.T) {
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
t.Run("should return error when userID is empty", func(t *testing.T) {
|
||||
options := model.SendToastMessageOptions{
|
||||
Position: "bottom-right",
|
||||
}
|
||||
err := th.App.SendToastMessage("", "test-connection-id", "Test message", options)
|
||||
require.NotNil(t, err)
|
||||
assert.Equal(t, "app.toast.send_toast_message.user_id.app_error", err.Id)
|
||||
})
|
||||
|
||||
t.Run("should return error when message is empty", func(t *testing.T) {
|
||||
options := model.SendToastMessageOptions{
|
||||
Position: "bottom-right",
|
||||
}
|
||||
err := th.App.SendToastMessage(th.BasicUser.Id, "test-connection-id", "", options)
|
||||
require.NotNil(t, err)
|
||||
assert.Equal(t, "app.toast.send_toast_message.message.app_error", err.Id)
|
||||
})
|
||||
|
||||
t.Run("should successfully send toast to all user sessions", func(t *testing.T) {
|
||||
options := model.SendToastMessageOptions{
|
||||
Position: "top-center",
|
||||
}
|
||||
err := th.App.SendToastMessage(th.BasicUser.Id, "test-connection-id", "Test toast message", options)
|
||||
require.Nil(t, err)
|
||||
})
|
||||
|
||||
t.Run("should successfully send toast to specific connection", func(t *testing.T) {
|
||||
options := model.SendToastMessageOptions{
|
||||
Position: "bottom-left",
|
||||
}
|
||||
err := th.App.SendToastMessage(th.BasicUser.Id, "test-connection-id", "Test toast message", options)
|
||||
require.Nil(t, err)
|
||||
})
|
||||
|
||||
t.Run("should successfully send toast without position (default should be used)", func(t *testing.T) {
|
||||
options := model.SendToastMessageOptions{}
|
||||
err := th.App.SendToastMessage(th.BasicUser.Id, "test-connection-id", "Test toast message", options)
|
||||
require.Nil(t, err)
|
||||
})
|
||||
}
|
||||
|
|
@ -72,6 +72,7 @@ func genStore() *mocks.Store {
|
|||
mock.On("ReadReceipt").Return(&mocks.ReadReceiptStore{})
|
||||
mock.On("Recap").Return(&mocks.RecapStore{})
|
||||
mock.On("TemporaryPost").Return(&mocks.TemporaryPostStore{})
|
||||
mock.On("Recap").Return(&mocks.RecapStore{})
|
||||
return mock
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2196,10 +2196,18 @@
|
|||
"id": "api.file.get_file.invalid_flagged_post.app_error",
|
||||
"translation": "Mismatched flagged post ID specified."
|
||||
},
|
||||
{
|
||||
"id": "api.file.get_file.plugin_hook_timeout",
|
||||
"translation": "Plugin hook timed out"
|
||||
},
|
||||
{
|
||||
"id": "api.file.get_file.public_invalid.app_error",
|
||||
"translation": "The public link does not appear to be valid."
|
||||
},
|
||||
{
|
||||
"id": "api.file.get_file.rejected_by_plugin",
|
||||
"translation": "File download rejected by plugin"
|
||||
},
|
||||
{
|
||||
"id": "api.file.get_file_info.app_error",
|
||||
"translation": "Failed to get file info."
|
||||
|
|
@ -2208,10 +2216,22 @@
|
|||
"id": "api.file.get_file_preview.no_preview.app_error",
|
||||
"translation": "File doesn't have a preview image."
|
||||
},
|
||||
{
|
||||
"id": "api.file.get_file_preview.rejected_by_plugin",
|
||||
"translation": "File preview download rejected by plugin"
|
||||
},
|
||||
{
|
||||
"id": "api.file.get_file_thumbnail.no_thumbnail.app_error",
|
||||
"translation": "File doesn't have a thumbnail image."
|
||||
},
|
||||
{
|
||||
"id": "api.file.get_file_thumbnail.rejected_by_plugin",
|
||||
"translation": "File thumbnail download rejected by plugin"
|
||||
},
|
||||
{
|
||||
"id": "api.file.get_public_file.rejected_by_plugin",
|
||||
"translation": "Public file download rejected by plugin"
|
||||
},
|
||||
{
|
||||
"id": "api.file.get_public_link.disabled.app_error",
|
||||
"translation": "Public links have been disabled."
|
||||
|
|
@ -8052,6 +8072,14 @@
|
|||
"id": "app.thread.mark_all_as_read_by_channels.app_error",
|
||||
"translation": "Unable to mark all threads as read by channel"
|
||||
},
|
||||
{
|
||||
"id": "app.toast.send_toast_message.message.app_error",
|
||||
"translation": "Message is required"
|
||||
},
|
||||
{
|
||||
"id": "app.toast.send_toast_message.user_id.app_error",
|
||||
"translation": "User ID is required"
|
||||
},
|
||||
{
|
||||
"id": "app.update_error",
|
||||
"translation": "update error"
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ const (
|
|||
HeaderFirstInaccessiblePostTime = "First-Inaccessible-Post-Time"
|
||||
HeaderFirstInaccessibleFileTime = "First-Inaccessible-File-Time"
|
||||
HeaderRange = "Range"
|
||||
HeaderRejectReason = "X-Reject-Reason"
|
||||
STATUS = "status"
|
||||
StatusOk = "OK"
|
||||
StatusFail = "FAIL"
|
||||
|
|
|
|||
|
|
@ -236,11 +236,12 @@ const (
|
|||
|
||||
OutgoingIntegrationRequestsDefaultTimeout = 30
|
||||
|
||||
PluginSettingsDefaultDirectory = "./plugins"
|
||||
PluginSettingsDefaultClientDirectory = "./client/plugins"
|
||||
PluginSettingsDefaultEnableMarketplace = true
|
||||
PluginSettingsDefaultMarketplaceURL = "https://api.integrations.mattermost.com"
|
||||
PluginSettingsOldMarketplaceURL = "https://marketplace.integrations.mattermost.com"
|
||||
PluginSettingsDefaultDirectory = "./plugins"
|
||||
PluginSettingsDefaultClientDirectory = "./client/plugins"
|
||||
PluginSettingsDefaultEnableMarketplace = true
|
||||
PluginSettingsDefaultMarketplaceURL = "https://api.integrations.mattermost.com"
|
||||
PluginSettingsOldMarketplaceURL = "https://marketplace.integrations.mattermost.com"
|
||||
PluginSettingsDefaultHookTimeoutSeconds = 30
|
||||
|
||||
ComplianceExportDirectoryFormat = "compliance-export-2006-01-02-15h04m"
|
||||
ComplianceExportPath = "export"
|
||||
|
|
|
|||
|
|
@ -15,6 +15,20 @@ const (
|
|||
FileinfoSortBySize = "Size"
|
||||
)
|
||||
|
||||
// FileDownloadType represents the type of file download or access being performed.
|
||||
type FileDownloadType string
|
||||
|
||||
const (
|
||||
// FileDownloadTypeFile represents a full file download request.
|
||||
FileDownloadTypeFile FileDownloadType = "file"
|
||||
// FileDownloadTypeThumbnail represents a thumbnail image request.
|
||||
FileDownloadTypeThumbnail FileDownloadType = "thumbnail"
|
||||
// FileDownloadTypePreview represents a preview image request.
|
||||
FileDownloadTypePreview FileDownloadType = "preview"
|
||||
// FileDownloadTypePublic represents a public link access (unauthenticated).
|
||||
FileDownloadTypePublic FileDownloadType = "public"
|
||||
)
|
||||
|
||||
// GetFileInfosOptions contains options for getting FileInfos
|
||||
type GetFileInfosOptions struct {
|
||||
// UserIds optionally limits the FileInfos to those created by the given users.
|
||||
|
|
|
|||
12
server/public/model/plugin_toast.go
Normal file
12
server/public/model/plugin_toast.go
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package model
|
||||
|
||||
// SendToastMessageOptions contains options for sending a toast message to a user.
|
||||
type SendToastMessageOptions struct {
|
||||
// Position is the position where the toast should appear.
|
||||
// Valid values: "top-left", "top-center", "top-right", "bottom-left", "bottom-center", "bottom-right"
|
||||
// If empty or invalid, defaults to "bottom-right" on the frontend.
|
||||
Position string `json:"position,omitempty"`
|
||||
}
|
||||
|
|
@ -102,6 +102,8 @@ const (
|
|||
WebsocketEventPostRevealed WebsocketEventType = "post_revealed"
|
||||
WebsocketEventPostBurned WebsocketEventType = "post_burned"
|
||||
WebsocketEventBurnOnReadAllRevealed WebsocketEventType = "burn_on_read_all_revealed"
|
||||
WebsocketEventFileDownloadRejected WebsocketEventType = "file_download_rejected"
|
||||
WebsocketEventShowToast WebsocketEventType = "show_toast"
|
||||
|
||||
WebSocketMsgTypeResponse = "response"
|
||||
WebSocketMsgTypeEvent = "event"
|
||||
|
|
|
|||
|
|
@ -860,6 +860,14 @@ type API interface {
|
|||
// Minimum server version: 5.6
|
||||
OpenInteractiveDialog(dialog model.OpenDialogRequest) *model.AppError
|
||||
|
||||
// SendToastMessage sends a toast notification to a specific user or user session.
|
||||
// The userID parameter specifies the user to send the toast to.
|
||||
// If connectionID is set, the toast will only be sent to that specific connection.
|
||||
//
|
||||
// @tag Frontend
|
||||
// Minimum server version: 11.5
|
||||
SendToastMessage(userID, connectionID, message string, options model.SendToastMessageOptions) *model.AppError
|
||||
|
||||
// Plugin Section
|
||||
|
||||
// GetPlugins will return a list of plugin manifests for currently active plugins.
|
||||
|
|
|
|||
|
|
@ -930,6 +930,13 @@ func (api *apiTimerLayer) OpenInteractiveDialog(dialog model.OpenDialogRequest)
|
|||
return _returnsA
|
||||
}
|
||||
|
||||
func (api *apiTimerLayer) SendToastMessage(userID, connectionID, message string, options model.SendToastMessageOptions) *model.AppError {
|
||||
startTime := timePkg.Now()
|
||||
_returnsA := api.apiImpl.SendToastMessage(userID, connectionID, message, options)
|
||||
api.recordTime(startTime, "SendToastMessage", _returnsA == nil)
|
||||
return _returnsA
|
||||
}
|
||||
|
||||
func (api *apiTimerLayer) GetPlugins() ([]*model.Manifest, *model.AppError) {
|
||||
startTime := timePkg.Now()
|
||||
_returnsA, _returnsB := api.apiImpl.GetPlugins()
|
||||
|
|
|
|||
|
|
@ -499,6 +499,43 @@ func (s *hooksRPCServer) UserHasLeftTeam(args *Z_UserHasLeftTeamArgs, returns *Z
|
|||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
hookNameToId["FileWillBeDownloaded"] = FileWillBeDownloadedID
|
||||
}
|
||||
|
||||
type Z_FileWillBeDownloadedArgs struct {
|
||||
A *Context
|
||||
B *model.FileInfo
|
||||
C string
|
||||
D model.FileDownloadType
|
||||
}
|
||||
|
||||
type Z_FileWillBeDownloadedReturns struct {
|
||||
A string
|
||||
}
|
||||
|
||||
func (g *hooksRPCClient) FileWillBeDownloaded(c *Context, fileInfo *model.FileInfo, userID string, downloadType model.FileDownloadType) string {
|
||||
_args := &Z_FileWillBeDownloadedArgs{c, fileInfo, userID, downloadType}
|
||||
_returns := &Z_FileWillBeDownloadedReturns{}
|
||||
if g.implemented[FileWillBeDownloadedID] {
|
||||
if err := g.client.Call("Plugin.FileWillBeDownloaded", _args, _returns); err != nil {
|
||||
g.log.Error("RPC call FileWillBeDownloaded to plugin failed.", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
return _returns.A
|
||||
}
|
||||
|
||||
func (s *hooksRPCServer) FileWillBeDownloaded(args *Z_FileWillBeDownloadedArgs, returns *Z_FileWillBeDownloadedReturns) error {
|
||||
if hook, ok := s.impl.(interface {
|
||||
FileWillBeDownloaded(c *Context, fileInfo *model.FileInfo, userID string, downloadType model.FileDownloadType) string
|
||||
}); ok {
|
||||
returns.A = hook.FileWillBeDownloaded(args.A, args.B, args.C, args.D)
|
||||
} else {
|
||||
return encodableError(fmt.Errorf("Hook FileWillBeDownloaded called but not implemented."))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
hookNameToId["ReactionHasBeenAdded"] = ReactionHasBeenAddedID
|
||||
}
|
||||
|
|
@ -4991,6 +5028,37 @@ func (s *apiRPCServer) OpenInteractiveDialog(args *Z_OpenInteractiveDialogArgs,
|
|||
return nil
|
||||
}
|
||||
|
||||
type Z_SendToastMessageArgs struct {
|
||||
A string
|
||||
B string
|
||||
C string
|
||||
D model.SendToastMessageOptions
|
||||
}
|
||||
|
||||
type Z_SendToastMessageReturns struct {
|
||||
A *model.AppError
|
||||
}
|
||||
|
||||
func (g *apiRPCClient) SendToastMessage(userID, connectionID, message string, options model.SendToastMessageOptions) *model.AppError {
|
||||
_args := &Z_SendToastMessageArgs{userID, connectionID, message, options}
|
||||
_returns := &Z_SendToastMessageReturns{}
|
||||
if err := g.client.Call("Plugin.SendToastMessage", _args, _returns); err != nil {
|
||||
log.Printf("RPC call to SendToastMessage API failed: %s", err.Error())
|
||||
}
|
||||
return _returns.A
|
||||
}
|
||||
|
||||
func (s *apiRPCServer) SendToastMessage(args *Z_SendToastMessageArgs, returns *Z_SendToastMessageReturns) error {
|
||||
if hook, ok := s.impl.(interface {
|
||||
SendToastMessage(userID, connectionID, message string, options model.SendToastMessageOptions) *model.AppError
|
||||
}); ok {
|
||||
returns.A = hook.SendToastMessage(args.A, args.B, args.C, args.D)
|
||||
} else {
|
||||
return encodableError(fmt.Errorf("API SendToastMessage called but not implemented."))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Z_GetPluginsArgs struct {
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ const (
|
|||
GenerateSupportDataID = 45
|
||||
OnSAMLLoginID = 46
|
||||
EmailNotificationWillBeSentID = 47
|
||||
FileWillBeDownloadedID = 48
|
||||
TotalHooksID = iota
|
||||
)
|
||||
|
||||
|
|
@ -239,6 +240,20 @@ type Hooks interface {
|
|||
// Minimum server version: 5.2
|
||||
FileWillBeUploaded(c *Context, info *model.FileInfo, file io.Reader, output io.Writer) (*model.FileInfo, string)
|
||||
|
||||
// FileWillBeDownloaded is invoked when a file is requested for download, but before it is sent to the client.
|
||||
//
|
||||
// To reject a file download, return an non-empty string describing why the file was rejected.
|
||||
// To allow the download, return an empty string.
|
||||
//
|
||||
// The downloadType parameter indicates the type of file access and can be one of:
|
||||
// - model.FileDownloadTypeFile: Full file download
|
||||
// - model.FileDownloadTypeThumbnail: Thumbnail request
|
||||
// - model.FileDownloadTypePreview: Preview image request
|
||||
// - model.FileDownloadTypePublic: Public link access (userID will be empty string in this case)
|
||||
//
|
||||
// Minimum server version: 11.5
|
||||
FileWillBeDownloaded(c *Context, fileInfo *model.FileInfo, userID string, downloadType model.FileDownloadType) string
|
||||
|
||||
// ReactionHasBeenAdded is invoked after the reaction has been committed to the database.
|
||||
//
|
||||
// Note that this method will be called for reactions added by plugins, including the plugin that
|
||||
|
|
|
|||
|
|
@ -164,6 +164,13 @@ func (hooks *hooksTimerLayer) FileWillBeUploaded(c *Context, info *model.FileInf
|
|||
return _returnsA, _returnsB
|
||||
}
|
||||
|
||||
func (hooks *hooksTimerLayer) FileWillBeDownloaded(c *Context, fileInfo *model.FileInfo, userID string, downloadType model.FileDownloadType) string {
|
||||
startTime := timePkg.Now()
|
||||
_returnsA := hooks.hooksImpl.FileWillBeDownloaded(c, fileInfo, userID, downloadType)
|
||||
hooks.recordTime(startTime, "FileWillBeDownloaded", true)
|
||||
return _returnsA
|
||||
}
|
||||
|
||||
func (hooks *hooksTimerLayer) ReactionHasBeenAdded(c *Context, reaction *model.Reaction) {
|
||||
startTime := timePkg.Now()
|
||||
hooks.hooksImpl.ReactionHasBeenAdded(c, reaction)
|
||||
|
|
|
|||
|
|
@ -5203,6 +5203,26 @@ func (_m *API) SendPushNotification(notification *model.PushNotification, userID
|
|||
return r0
|
||||
}
|
||||
|
||||
// SendToastMessage provides a mock function with given fields: userID, connectionID, message, options
|
||||
func (_m *API) SendToastMessage(userID string, connectionID string, message string, options model.SendToastMessageOptions) *model.AppError {
|
||||
ret := _m.Called(userID, connectionID, message, options)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for SendToastMessage")
|
||||
}
|
||||
|
||||
var r0 *model.AppError
|
||||
if rf, ok := ret.Get(0).(func(string, string, string, model.SendToastMessageOptions) *model.AppError); ok {
|
||||
r0 = rf(userID, connectionID, message, options)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.AppError)
|
||||
}
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// SetFileSearchableContent provides a mock function with given fields: fileID, content
|
||||
func (_m *API) SetFileSearchableContent(fileID string, content string) *model.AppError {
|
||||
ret := _m.Called(fileID, content)
|
||||
|
|
|
|||
|
|
@ -119,6 +119,24 @@ func (_m *Hooks) ExecuteCommand(c *plugin.Context, args *model.CommandArgs) (*mo
|
|||
return r0, r1
|
||||
}
|
||||
|
||||
// FileWillBeDownloaded provides a mock function with given fields: c, fileInfo, userID, downloadType
|
||||
func (_m *Hooks) FileWillBeDownloaded(c *plugin.Context, fileInfo *model.FileInfo, userID string, downloadType model.FileDownloadType) string {
|
||||
ret := _m.Called(c, fileInfo, userID, downloadType)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for FileWillBeDownloaded")
|
||||
}
|
||||
|
||||
var r0 string
|
||||
if rf, ok := ret.Get(0).(func(*plugin.Context, *model.FileInfo, string, model.FileDownloadType) string); ok {
|
||||
r0 = rf(c, fileInfo, userID, downloadType)
|
||||
} else {
|
||||
r0 = ret.Get(0).(string)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// FileWillBeUploaded provides a mock function with given fields: c, info, file, output
|
||||
func (_m *Hooks) FileWillBeUploaded(c *plugin.Context, info *model.FileInfo, file io.Reader, output io.Writer) (*model.FileInfo, string) {
|
||||
ret := _m.Called(c, info, file, output)
|
||||
|
|
|
|||
|
|
@ -28,3 +28,12 @@ func (f *FrontendService) OpenInteractiveDialog(dialog model.OpenDialogRequest)
|
|||
func (f *FrontendService) PublishWebSocketEvent(event string, payload map[string]any, broadcast *model.WebsocketBroadcast) {
|
||||
f.api.PublishWebSocketEvent(event, payload, broadcast)
|
||||
}
|
||||
|
||||
// SendToastMessage sends a toast notification to a specific user or user session.
|
||||
// The userID parameter specifies the user to send the toast to.
|
||||
// If connectionID is set, the toast will only be sent to that specific connection.
|
||||
//
|
||||
// Minimum server version: 11.5
|
||||
func (f *FrontendService) SendToastMessage(userID, connectionID, message string, options model.SendToastMessageOptions) error {
|
||||
return normalizeAppErr(f.api.SendToastMessage(userID, connectionID, message, options))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,14 +3,17 @@
|
|||
|
||||
/* eslint-disable max-lines */
|
||||
|
||||
import React from 'react';
|
||||
import {batchActions} from 'redux-batched-actions';
|
||||
|
||||
import type {WebSocketMessage, WebSocketMessages} from '@mattermost/client';
|
||||
import {WebSocketEvents} from '@mattermost/client';
|
||||
import {AlertCircleOutlineIcon, InformationOutlineIcon} from '@mattermost/compass-icons/components';
|
||||
import type {ChannelBookmarkWithFileInfo, UpdateChannelBookmarkResponse} from '@mattermost/types/channel_bookmarks';
|
||||
import type {Channel, ChannelMembership} from '@mattermost/types/channels';
|
||||
import type {Draft} from '@mattermost/types/drafts';
|
||||
import type {Emoji} from '@mattermost/types/emojis';
|
||||
import {FileDownloadTypes} from '@mattermost/types/files';
|
||||
import type {Group, GroupMember} from '@mattermost/types/groups';
|
||||
import type {OpenDialogRequest} from '@mattermost/types/integrations';
|
||||
import type {Post, PostAcknowledgement} from '@mattermost/types/posts';
|
||||
|
|
@ -25,6 +28,7 @@ import type {MMReduxAction} from 'mattermost-redux/action_types';
|
|||
import {
|
||||
ChannelTypes,
|
||||
EmojiTypes,
|
||||
FileTypes,
|
||||
GroupTypes,
|
||||
PostTypes,
|
||||
TeamTypes,
|
||||
|
|
@ -134,7 +138,7 @@ import {setGlobalItem} from 'actions/storage';
|
|||
import {loadProfilesForDM, loadProfilesForGM, loadProfilesForSidebar} from 'actions/user_actions';
|
||||
import {syncPostsInChannel} from 'actions/views/channel';
|
||||
import {setGlobalDraft, transformServerDraft} from 'actions/views/drafts';
|
||||
import {openModal} from 'actions/views/modals';
|
||||
import {openModal, closeModal} from 'actions/views/modals';
|
||||
import {closeRightHandSide} from 'actions/views/rhs';
|
||||
import {resetWsErrorCount} from 'actions/views/system';
|
||||
import {updateThreadLastOpened} from 'actions/views/threads';
|
||||
|
|
@ -143,12 +147,14 @@ import {isThreadOpen, isThreadManuallyUnread} from 'selectors/views/threads';
|
|||
import store from 'stores/redux_store';
|
||||
|
||||
import DialogRouter from 'components/dialog_router';
|
||||
import InfoToast from 'components/info_toast/info_toast';
|
||||
import RemovedFromChannelModal from 'components/removed_from_channel_modal';
|
||||
|
||||
import WebSocketClient from 'client/web_websocket_client';
|
||||
import {loadPlugin, loadPluginsIfNecessary, removePlugin} from 'plugins';
|
||||
import {getHistory} from 'utils/browser_history';
|
||||
import {ActionTypes, Constants, AnnouncementBarMessages, SocketEvents, UserStatuses, ModalIdentifiers, PageLoadContext} from 'utils/constants';
|
||||
import {getIntl} from 'utils/i18n';
|
||||
import {getSiteURL} from 'utils/url';
|
||||
|
||||
import type {ActionFunc, ThunkActionFunc} from 'types/store';
|
||||
|
|
@ -682,6 +688,12 @@ export function handleEvent(msg: WebSocketMessage) {
|
|||
case WebSocketEvents.RecapUpdated:
|
||||
dispatch(handleRecapUpdated(msg));
|
||||
break;
|
||||
case WebSocketEvents.FileDownloadRejected:
|
||||
dispatch(handleFileDownloadRejected(msg));
|
||||
break;
|
||||
case WebSocketEvents.ShowToast:
|
||||
dispatch(handleShowToast(msg));
|
||||
break;
|
||||
default:
|
||||
}
|
||||
|
||||
|
|
@ -2046,3 +2058,104 @@ export function handleRecapUpdated(msg: WebSocketMessages.RecapUpdated): ThunkAc
|
|||
doDispatch(getRecap(recapId));
|
||||
};
|
||||
}
|
||||
|
||||
export function handleFileDownloadRejected(msg: WebSocketMessages.FileDownloadRejected): ThunkActionFunc<void> {
|
||||
return (dispatch, getState) => {
|
||||
const {file_id: fileId, file_name: fileName, rejection_reason: rejectionReason, channel_id: channelId, post_id: postId, download_type: downloadType} = msg.data;
|
||||
|
||||
// Store the rejected file ID in Redux state
|
||||
dispatch({
|
||||
type: FileTypes.FILE_DOWNLOAD_REJECTED,
|
||||
data: {
|
||||
file_id: fileId,
|
||||
file_name: fileName,
|
||||
rejection_reason: rejectionReason,
|
||||
channel_id: channelId,
|
||||
post_id: postId,
|
||||
download_type: downloadType,
|
||||
},
|
||||
});
|
||||
|
||||
// Handle different download types appropriately:
|
||||
// - Thumbnail: Small preview in message list, loaded automatically, no modal, no toast
|
||||
// - Preview: Can be either:
|
||||
// a) Large image in channel list (SingleImageView) - loaded automatically, no modal, no toast
|
||||
// b) Full-screen modal view - user clicked, modal open, close it WITH toast
|
||||
// - File: User clicked download button, close modal WITH toast
|
||||
// - Public: User requested public link, close modal WITH toast
|
||||
|
||||
if (downloadType === FileDownloadTypes.THUMBNAIL) {
|
||||
// Thumbnails are loaded automatically in the background
|
||||
// No modal to close, no toast to show
|
||||
return;
|
||||
}
|
||||
|
||||
if (downloadType === FileDownloadTypes.PREVIEW) {
|
||||
// Check if the file preview modal is actually open
|
||||
const state = getState();
|
||||
const isModalOpen = state.views?.modals?.modalState?.[ModalIdentifiers.FILE_PREVIEW_MODAL]?.open;
|
||||
|
||||
if (!isModalOpen) {
|
||||
// Preview was loaded in channel list (SingleImageView), not in modal
|
||||
// This is an automatic background load, no toast needed
|
||||
return;
|
||||
}
|
||||
|
||||
// Modal is open, so user clicked to view the preview
|
||||
// Close the modal and show toast to explain why
|
||||
// Continue to close modal and show toast below
|
||||
}
|
||||
|
||||
// Close the file preview modal for preview (when open), file, and public rejections
|
||||
dispatch(closeModal(ModalIdentifiers.FILE_PREVIEW_MODAL));
|
||||
|
||||
// Show a toast notification to explain why the modal was closed
|
||||
// Use normalized message format for all file types
|
||||
const intl = getIntl();
|
||||
|
||||
const displayMessage = intl.formatMessage(
|
||||
{id: 'file_download.rejected.file', defaultMessage: 'File access blocked: {reason}'},
|
||||
{reason: rejectionReason},
|
||||
);
|
||||
|
||||
// Show toast notification using the existing InfoToast system
|
||||
dispatch(openModal({
|
||||
modalId: ModalIdentifiers.INFO_TOAST,
|
||||
dialogType: InfoToast,
|
||||
dialogProps: {
|
||||
content: {
|
||||
icon: React.createElement(AlertCircleOutlineIcon, {size: 18}),
|
||||
message: displayMessage,
|
||||
},
|
||||
position: 'bottom-center',
|
||||
onExited: () => {
|
||||
// Close the modal when the toast is dismissed
|
||||
dispatch(closeModal(ModalIdentifiers.INFO_TOAST));
|
||||
},
|
||||
},
|
||||
}));
|
||||
};
|
||||
}
|
||||
|
||||
function handleShowToast(msg: WebSocketMessages.ShowToast): ThunkActionFunc<void> {
|
||||
return (doDispatch) => {
|
||||
const {message, position} = msg.data;
|
||||
if (message) {
|
||||
const toastPosition = position as 'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right' | undefined;
|
||||
doDispatch(openModal({
|
||||
modalId: ModalIdentifiers.INFO_TOAST,
|
||||
dialogType: InfoToast,
|
||||
dialogProps: {
|
||||
content: {
|
||||
message,
|
||||
icon: React.createElement(InformationOutlineIcon, {size: 18}),
|
||||
},
|
||||
position: toastPosition || 'bottom-right',
|
||||
onExited: () => {
|
||||
doDispatch(closeModal(ModalIdentifiers.INFO_TOAST));
|
||||
},
|
||||
},
|
||||
}));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
import React, {useCallback} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {useDispatch} from 'react-redux';
|
||||
import {useDispatch, useSelector} from 'react-redux';
|
||||
|
||||
import {
|
||||
DotsHorizontalIcon,
|
||||
|
|
@ -13,9 +13,11 @@ import {
|
|||
ArrowExpandIcon,
|
||||
OpenInNewIcon,
|
||||
BookOutlineIcon,
|
||||
DownloadOutlineIcon,
|
||||
} from '@mattermost/compass-icons/components';
|
||||
import type {ChannelBookmark, ChannelBookmarkPatch} from '@mattermost/types/channel_bookmarks';
|
||||
|
||||
import {getFile} from 'mattermost-redux/selectors/entities/files';
|
||||
import type {ActionResult} from 'mattermost-redux/types/actions';
|
||||
import {getFileDownloadUrl} from 'mattermost-redux/utils/file_utils';
|
||||
|
||||
|
|
@ -29,6 +31,8 @@ import {ModalIdentifiers} from 'utils/constants';
|
|||
import {getSiteURL, shouldOpenInNewTab} from 'utils/url';
|
||||
import {copyToClipboard} from 'utils/utils';
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
|
||||
import BookmarkDeleteModal from './bookmark_delete_modal';
|
||||
import ChannelBookmarksCreateModal from './channel_bookmarks_create_modal';
|
||||
import {useCanGetPublicLink, useChannelBookmarkPermission} from './utils';
|
||||
|
|
@ -40,6 +44,7 @@ const BookmarkItemDotMenu = ({
|
|||
}: Props) => {
|
||||
const {formatMessage} = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const fileInfo = useSelector((state: GlobalState) => (bookmark?.file_id && getFile(state, bookmark.file_id)) || undefined);
|
||||
|
||||
const siteURL = getSiteURL();
|
||||
const openInNewTab = bookmark.type === 'link' && bookmark.link_url && shouldOpenInNewTab(bookmark.link_url, siteURL);
|
||||
|
|
@ -59,6 +64,7 @@ const BookmarkItemDotMenu = ({
|
|||
const openLabel = formatMessage({id: 'channel_bookmarks.open', defaultMessage: 'Open'});
|
||||
const copyLinkLabel = formatMessage({id: 'channel_bookmarks.copy', defaultMessage: 'Copy link'});
|
||||
const copyFileLabel = formatMessage({id: 'channel_bookmarks.copyFilePublicLink', defaultMessage: 'Get a public link'});
|
||||
const downloadLabel = formatMessage({id: 'channel_bookmarks.download', defaultMessage: 'Download'});
|
||||
const deleteLabel = formatMessage({id: 'channel_bookmarks.delete', defaultMessage: 'Delete'});
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
|
|
@ -106,6 +112,12 @@ const BookmarkItemDotMenu = ({
|
|||
}));
|
||||
}, [bookmark.file_id, dispatch]);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
if (fileInfo) {
|
||||
window.open(getFileDownloadUrl(fileInfo.id), '_blank');
|
||||
}
|
||||
}, [fileInfo]);
|
||||
|
||||
return (
|
||||
<Menu.Container
|
||||
anchorOrigin={{vertical: 'bottom', horizontal: 'right'}}
|
||||
|
|
@ -158,6 +170,16 @@ const BookmarkItemDotMenu = ({
|
|||
aria-label={copyFileLabel}
|
||||
/>
|
||||
)}
|
||||
{bookmark.type === 'file' && fileInfo && (
|
||||
<Menu.Item
|
||||
key='channelBookmarksDownload'
|
||||
id='channelBookmarksDownload'
|
||||
onClick={handleDownload}
|
||||
leadingElement={<DownloadOutlineIcon size={18}/>}
|
||||
labels={<span>{downloadLabel}</span>}
|
||||
aria-label={downloadLabel}
|
||||
/>
|
||||
)}
|
||||
{canDelete && (
|
||||
<Menu.Item
|
||||
key='channelBookmarksDelete'
|
||||
|
|
|
|||
|
|
@ -35,6 +35,31 @@ const CodePreview = ({
|
|||
});
|
||||
|
||||
const [status, setStatus] = useState<'success' | 'loading' | 'fail'>('loading');
|
||||
const [prevFileUrl, setPrevFileUrl] = useState<string | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (fileUrl !== prevFileUrl) {
|
||||
const usedLanguage = SyntaxHighlighting.getLanguageFromFileExtension(fileInfo.extension);
|
||||
|
||||
if (!usedLanguage || fileInfo.size > Constants.CODE_PREVIEW_MAX_FILE_SIZE) {
|
||||
setCodeInfo((prevCodeInfo) => {
|
||||
return {...prevCodeInfo, code: '', lang: ''};
|
||||
});
|
||||
|
||||
setStatus('fail');
|
||||
} else {
|
||||
setCodeInfo((prevCodeInfo) => {
|
||||
return {...prevCodeInfo, code: '', lang: usedLanguage};
|
||||
});
|
||||
|
||||
setStatus('loading');
|
||||
}
|
||||
|
||||
setPrevFileUrl(fileUrl);
|
||||
}
|
||||
}, [fileInfo.extension, fileInfo.size, fileUrl, prevFileUrl]);
|
||||
|
||||
const shouldNotGetCode = !codeInfo.lang || fileInfo.size > Constants.CODE_PREVIEW_MAX_FILE_SIZE;
|
||||
|
||||
useEffect(() => {
|
||||
const usedLanguage = SyntaxHighlighting.getLanguageFromFileExtension(fileInfo.extension);
|
||||
|
|
@ -42,46 +67,54 @@ const CodePreview = ({
|
|||
if (!usedLanguage || fileInfo.size > Constants.CODE_PREVIEW_MAX_FILE_SIZE) {
|
||||
setCodeInfo({code: '', lang: '', highlighted: ''});
|
||||
setStatus('fail');
|
||||
return;
|
||||
}
|
||||
|
||||
setCodeInfo({code: '', lang: usedLanguage, highlighted: ''});
|
||||
setStatus('loading');
|
||||
const handleReceivedCode = async (data: string | Node) => {
|
||||
let code = data as string;
|
||||
const dataAsNode = data as Node;
|
||||
|
||||
const fetchCode = async () => {
|
||||
if (dataAsNode.nodeName === '#document') {
|
||||
code = new XMLSerializer().serializeToString(dataAsNode);
|
||||
}
|
||||
|
||||
getContent?.(code);
|
||||
|
||||
setCodeInfo({
|
||||
...codeInfo,
|
||||
code,
|
||||
highlighted: await SyntaxHighlighting.highlight(codeInfo.lang, code),
|
||||
});
|
||||
|
||||
setStatus('success');
|
||||
};
|
||||
|
||||
const handleReceivedError = () => {
|
||||
setStatus('fail');
|
||||
};
|
||||
|
||||
const getCode = async () => {
|
||||
if (shouldNotGetCode) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(fileUrl);
|
||||
let code = await response.text();
|
||||
|
||||
if (response.headers.get('content-type')?.includes('xml')) {
|
||||
try {
|
||||
const parser = new DOMParser();
|
||||
const xmlDoc = parser.parseFromString(code, 'text/xml');
|
||||
if (xmlDoc.nodeName === '#document') {
|
||||
code = new XMLSerializer().serializeToString(xmlDoc);
|
||||
}
|
||||
} catch {
|
||||
// If XML parsing fails, use the text as-is
|
||||
}
|
||||
const data = await fetch(fileUrl);
|
||||
if (!data.ok) {
|
||||
// Handle HTTP error responses (including 403 Forbidden from plugin rejection)
|
||||
handleReceivedError();
|
||||
return;
|
||||
}
|
||||
|
||||
getContent?.(code);
|
||||
|
||||
const highlighted = await SyntaxHighlighting.highlight(usedLanguage, code);
|
||||
|
||||
setCodeInfo({
|
||||
code,
|
||||
lang: usedLanguage,
|
||||
highlighted,
|
||||
});
|
||||
setStatus('success');
|
||||
const text = await data.text();
|
||||
handleReceivedCode(text);
|
||||
} catch (e) {
|
||||
setStatus('fail');
|
||||
handleReceivedError();
|
||||
}
|
||||
};
|
||||
|
||||
fetchCode();
|
||||
}, [fileUrl, fileInfo.extension, fileInfo.size, getContent]);
|
||||
// Only fetch if status is loading and we have a language
|
||||
if (status === 'loading' && codeInfo.lang && !shouldNotGetCode) {
|
||||
getCode();
|
||||
}
|
||||
}, [codeInfo, fileUrl, prevFileUrl, getContent, shouldNotGetCode, status]);
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ describe('FileAttachment', () => {
|
|||
enablePublicLink: false,
|
||||
pluginMenuItems: [],
|
||||
currentChannel: TestHelper.getChannelMock(),
|
||||
isFileRejected: false,
|
||||
handleFileDropdownOpened: jest.fn(() => null),
|
||||
actions: {
|
||||
openModal: jest.fn(),
|
||||
|
|
|
|||
|
|
@ -86,6 +86,14 @@ export default function FileAttachment(props: Props) {
|
|||
// So skip trying to load.
|
||||
return;
|
||||
}
|
||||
|
||||
// If file is rejected, don't try to load thumbnail - just mark as loaded
|
||||
// so it shows the file icon instead
|
||||
if (props.isFileRejected) {
|
||||
setLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const fileType = getFileType(fileInfo.extension);
|
||||
|
||||
if (!props.disableThumbnail) {
|
||||
|
|
@ -124,6 +132,13 @@ export default function FileAttachment(props: Props) {
|
|||
}
|
||||
}, [props.fileInfo.extension, props.fileInfo.id, props.enableSVGs]);
|
||||
|
||||
// If file becomes rejected, mark as loaded so it shows the file icon
|
||||
useEffect(() => {
|
||||
if (props.isFileRejected) {
|
||||
setLoaded(true);
|
||||
}
|
||||
}, [props.isFileRejected]);
|
||||
|
||||
const onAttachmentClick = (e: React.MouseEvent<HTMLElement, MouseEvent>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
|
|
|||
|
|
@ -23,12 +23,14 @@ type Props = {
|
|||
enableSVGs: boolean;
|
||||
fileInfo: FileInfo | FilePreviewInfo | FilePreviewInfoLimited;
|
||||
disablePreview?: boolean;
|
||||
isRejected?: boolean;
|
||||
};
|
||||
|
||||
const FileThumbnail = ({
|
||||
fileInfo,
|
||||
enableSVGs,
|
||||
disablePreview,
|
||||
isRejected,
|
||||
}: Props) => {
|
||||
const {id, extension, has_preview_image: hasPreviewImage, width = 0, height = 0} = (fileInfo as FileInfo);
|
||||
const mimeType = (fileInfo as FileInfo).mime_type || (fileInfo as FilePreviewInfo | FilePreviewInfoLimited).type;
|
||||
|
|
@ -40,7 +42,8 @@ const FileThumbnail = ({
|
|||
type = getFileTypeFromMime(mimeType);
|
||||
}
|
||||
|
||||
if (id && !disablePreview) {
|
||||
// If the file is rejected, always show the file icon instead of thumbnail
|
||||
if (id && !disablePreview && !isRejected) {
|
||||
if (type === FileTypes.IMAGE) {
|
||||
let className = 'post-image';
|
||||
|
||||
|
|
|
|||
|
|
@ -3,15 +3,24 @@
|
|||
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import type {FileInfo} from '@mattermost/types/files';
|
||||
|
||||
import {isFileRejected} from 'mattermost-redux/selectors/entities/files';
|
||||
import {getConfig} from 'mattermost-redux/selectors/entities/general';
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
|
||||
import FileThumbnail from './file_thumbnail';
|
||||
|
||||
function mapStateToProps(state: GlobalState) {
|
||||
type OwnProps = {
|
||||
fileInfo: FileInfo | {id?: string};
|
||||
};
|
||||
|
||||
function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
|
||||
const fileId = (ownProps.fileInfo as FileInfo)?.id;
|
||||
return {
|
||||
enableSVGs: getConfig(state).EnableSVGs === 'true',
|
||||
isRejected: fileId ? isFileRejected(state, fileId) : false,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,10 @@ import type {ConnectedProps} from 'react-redux';
|
|||
import {bindActionCreators} from 'redux';
|
||||
import type {Dispatch} from 'redux';
|
||||
|
||||
import type {FileInfo} from '@mattermost/types/files';
|
||||
|
||||
import {getCurrentChannel} from 'mattermost-redux/selectors/entities/channels';
|
||||
import {isFileRejected} from 'mattermost-redux/selectors/entities/files';
|
||||
import {getConfig} from 'mattermost-redux/selectors/entities/general';
|
||||
|
||||
import {openModal} from 'actions/views/modals';
|
||||
|
|
@ -20,6 +23,7 @@ import FileAttachment from './file_attachment';
|
|||
|
||||
export type OwnProps = {
|
||||
preventDownload?: boolean;
|
||||
fileInfo: FileInfo;
|
||||
}
|
||||
|
||||
function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
|
||||
|
|
@ -31,6 +35,7 @@ function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
|
|||
enablePublicLink: config.EnablePublicLink === 'true',
|
||||
pluginMenuItems: getFilesDropdownPluginMenuItems(state),
|
||||
currentChannel: getCurrentChannel(state),
|
||||
isFileRejected: isFileRejected(state, ownProps.fileInfo.id),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -44,7 +44,9 @@ export default function FileAttachmentList(props: Props) {
|
|||
return null;
|
||||
}
|
||||
|
||||
if (fileInfos && fileInfos.length === 1 && !fileInfos[0].archived) {
|
||||
// For single image files, use SingleImageView UNLESS the file is rejected
|
||||
// If rejected, we want to show the file attachment card instead
|
||||
if (fileInfos && fileInfos.length === 1 && !fileInfos[0].archived && !props.firstFileRejected) {
|
||||
const fileType = getFileType(fileInfos[0].extension);
|
||||
|
||||
if (fileType === FileTypes.IMAGE || (fileType === FileTypes.SVG && enableSVGs)) {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {PostTypes} from 'mattermost-redux/constants/posts';
|
|||
import {
|
||||
makeGetFilesForEditHistory,
|
||||
makeGetFilesForPost,
|
||||
isFileRejected,
|
||||
} from 'mattermost-redux/selectors/entities/files';
|
||||
import {getConfig} from 'mattermost-redux/selectors/entities/general';
|
||||
|
||||
|
|
@ -66,12 +67,16 @@ function makeMapStateToProps() {
|
|||
fileCount = ownProps.post.filenames.length;
|
||||
}
|
||||
|
||||
// Check if the first file is rejected (for single file view logic)
|
||||
const firstFileRejected = fileInfos.length > 0 ? isFileRejected(state, fileInfos[0].id) : false;
|
||||
|
||||
return {
|
||||
enableSVGs: getConfig(state).EnableSVGs === 'true',
|
||||
fileInfos,
|
||||
fileCount,
|
||||
isEmbedVisible: isEmbedVisible(state, ownProps.post.id),
|
||||
locale: getCurrentLocale(state),
|
||||
firstFileRejected,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -219,12 +219,13 @@ export default class FilePreviewModal extends React.PureComponent<Props, State>
|
|||
|
||||
handleImageLoaded = (index: number) => {
|
||||
this.setState((prevState) => {
|
||||
return {
|
||||
const newState = {
|
||||
loaded: {
|
||||
...prevState.loaded,
|
||||
[index]: true,
|
||||
},
|
||||
};
|
||||
return newState;
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
exports[`components/InfoToast should match snapshot 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="info-toast className toast-appear toast-appear-active"
|
||||
class="info-toast info-toast--bottom-right className toast-appear toast-appear-active"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
.info-toast {
|
||||
position: fixed;
|
||||
z-index: 99;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
display: grid;
|
||||
width: fit-content;
|
||||
min-height: 40px;
|
||||
|
|
@ -34,6 +32,87 @@
|
|||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
// Position variants
|
||||
&--top-left {
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
|
||||
&.toast-appear {
|
||||
transform: translateY(-150px);
|
||||
}
|
||||
|
||||
&.toast-appear-active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
&--top-center {
|
||||
top: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
|
||||
&.toast-appear {
|
||||
transform: translate(-50%, -150px);
|
||||
}
|
||||
|
||||
&.toast-appear-active {
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
}
|
||||
|
||||
&--top-right {
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
|
||||
&.toast-appear {
|
||||
transform: translateY(-150px);
|
||||
}
|
||||
|
||||
&.toast-appear-active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
&--bottom-left {
|
||||
bottom: 16px;
|
||||
left: 16px;
|
||||
|
||||
&.toast-appear {
|
||||
transform: translateY(150px);
|
||||
}
|
||||
|
||||
&.toast-appear-active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
&--bottom-center {
|
||||
bottom: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
|
||||
&.toast-appear {
|
||||
transform: translate(-50%, 150px);
|
||||
}
|
||||
|
||||
&.toast-appear-active {
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
}
|
||||
|
||||
&--bottom-right {
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
|
||||
&.toast-appear {
|
||||
transform: translateY(150px);
|
||||
}
|
||||
|
||||
&.toast-appear-active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.info-toast__undo {
|
||||
|
|
@ -55,11 +134,9 @@
|
|||
// Animations
|
||||
.toast-appear {
|
||||
opacity: 0;
|
||||
transform: translateY(150px);
|
||||
}
|
||||
|
||||
.toast-appear-active {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition: all 500ms ease;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ import {CSSTransition} from 'react-transition-group';
|
|||
|
||||
import './info_toast.scss';
|
||||
|
||||
const VALID_POSITIONS = ['top-left', 'top-center', 'top-right', 'bottom-left', 'bottom-center', 'bottom-right'] as const;
|
||||
export type ToastPosition = typeof VALID_POSITIONS[number];
|
||||
const DEFAULT_POSITION: ToastPosition = 'bottom-right';
|
||||
|
||||
type Props = {
|
||||
content: {
|
||||
icon?: JSX.Element;
|
||||
|
|
@ -15,11 +19,16 @@ type Props = {
|
|||
undo?: () => void;
|
||||
};
|
||||
className?: string;
|
||||
position?: ToastPosition;
|
||||
onExited: () => void;
|
||||
}
|
||||
|
||||
function InfoToast({content, onExited, className}: Props): JSX.Element {
|
||||
function InfoToast({content, onExited, className, position = DEFAULT_POSITION}: Props): JSX.Element {
|
||||
const {formatMessage} = useIntl();
|
||||
|
||||
// Validate position and fallback to default if invalid
|
||||
const validatedPosition = VALID_POSITIONS.includes(position) ? position : DEFAULT_POSITION;
|
||||
|
||||
const closeToast = useCallback(() => {
|
||||
onExited();
|
||||
}, [onExited]);
|
||||
|
|
@ -29,7 +38,7 @@ function InfoToast({content, onExited, className}: Props): JSX.Element {
|
|||
onExited();
|
||||
}, [content.undo, onExited]);
|
||||
|
||||
const toastContainerClassname = classNames('info-toast', className);
|
||||
const toastContainerClassname = classNames('info-toast', `info-toast--${validatedPosition}`, className);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
|
|
|
|||
|
|
@ -6,21 +6,32 @@ import type {ConnectedProps} from 'react-redux';
|
|||
import {bindActionCreators} from 'redux';
|
||||
import type {Dispatch} from 'redux';
|
||||
|
||||
import type {FileInfo} from '@mattermost/types/files';
|
||||
|
||||
import {getFilePublicLink} from 'mattermost-redux/actions/files';
|
||||
import {isFileRejected} from 'mattermost-redux/selectors/entities/files';
|
||||
import {getConfig} from 'mattermost-redux/selectors/entities/general';
|
||||
|
||||
import {toggleEmbedVisibility} from 'actions/post_actions';
|
||||
import {openModal} from 'actions/views/modals';
|
||||
import {getIsRhsOpen} from 'selectors/rhs';
|
||||
|
||||
import SingleImageView from 'components/single_image_view/single_image_view';
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
|
||||
function mapStateToProps(state: GlobalState) {
|
||||
type OwnProps = {
|
||||
fileInfo: FileInfo;
|
||||
};
|
||||
|
||||
function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
|
||||
const isRhsOpen = getIsRhsOpen(state);
|
||||
const config = getConfig(state);
|
||||
|
||||
return {
|
||||
enablePublicLink: config.EnablePublicLink === 'true',
|
||||
isFileRejected: isFileRejected(state, ownProps.fileInfo.id),
|
||||
isRhsOpen,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,10 +5,26 @@ import React from 'react';
|
|||
|
||||
import SingleImageView from 'components/single_image_view/single_image_view';
|
||||
|
||||
import {fireEvent, renderWithContext, screen, userEvent} from 'tests/react_testing_utils';
|
||||
import {fireEvent, renderWithContext, screen, userEvent, waitFor} from 'tests/react_testing_utils';
|
||||
import {TestHelper} from 'utils/test_helper';
|
||||
|
||||
describe('components/SingleImageView', () => {
|
||||
// Mock fetch to simulate successful thumbnail availability check
|
||||
const mockFetch = jest.fn(() =>
|
||||
Promise.resolve({
|
||||
status: 200,
|
||||
headers: new Headers(),
|
||||
} as Response),
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
global.fetch = mockFetch;
|
||||
mockFetch.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
const baseProps = {
|
||||
postId: 'original_post_id',
|
||||
fileInfo: TestHelper.getFileInfoMock({id: 'file_info_id'}),
|
||||
|
|
@ -20,13 +36,19 @@ describe('components/SingleImageView', () => {
|
|||
getFilePublicLink: jest.fn(),
|
||||
},
|
||||
enablePublicLink: false,
|
||||
isFileRejected: false,
|
||||
};
|
||||
|
||||
test('should match snapshot', () => {
|
||||
test('should match snapshot', async () => {
|
||||
const {container} = renderWithContext(
|
||||
<SingleImageView {...baseProps}/>,
|
||||
);
|
||||
|
||||
// Wait for thumbnail availability check to complete
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('img')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(container).toMatchSnapshot();
|
||||
|
||||
// Simulate loaded state by triggering image load
|
||||
|
|
@ -40,7 +62,7 @@ describe('components/SingleImageView', () => {
|
|||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should match snapshot, SVG image', () => {
|
||||
test('should match snapshot, SVG image', async () => {
|
||||
const fileInfo = TestHelper.getFileInfoMock({
|
||||
id: 'svg_file_info_id',
|
||||
name: 'name_svg',
|
||||
|
|
@ -51,6 +73,11 @@ describe('components/SingleImageView', () => {
|
|||
<SingleImageView {...props}/>,
|
||||
);
|
||||
|
||||
// Wait for thumbnail availability check to complete
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('img')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(container).toMatchSnapshot();
|
||||
|
||||
// Simulate loaded state by triggering image load
|
||||
|
|
@ -68,6 +95,11 @@ describe('components/SingleImageView', () => {
|
|||
<SingleImageView {...baseProps}/>,
|
||||
);
|
||||
|
||||
// Wait for thumbnail availability check to complete
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('img')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const img = container.querySelector('img');
|
||||
expect(img).toBeInTheDocument();
|
||||
|
||||
|
|
@ -95,16 +127,26 @@ describe('components/SingleImageView', () => {
|
|||
<SingleImageView {...props}/>,
|
||||
);
|
||||
|
||||
// Wait for thumbnail availability check to complete
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', {name: 'Toggle Embed Visibility'})).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByRole('button', {name: 'Toggle Embed Visibility'}));
|
||||
expect(props.actions.toggleEmbedVisibility).toHaveBeenCalledTimes(1);
|
||||
expect(props.actions.toggleEmbedVisibility).toHaveBeenCalledWith('original_post_id');
|
||||
});
|
||||
|
||||
test('should set loaded state on callback of onImageLoaded on SizeAwareImage component', () => {
|
||||
test('should set loaded state on callback of onImageLoaded on SizeAwareImage component', async () => {
|
||||
const {container} = renderWithContext(
|
||||
<SingleImageView {...baseProps}/>,
|
||||
);
|
||||
|
||||
// Wait for thumbnail availability check to complete
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('.image-loaded')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Initially should not have image-fade-in class (loaded = false)
|
||||
const imageLoadedDiv = container.querySelector('.image-loaded');
|
||||
expect(imageLoadedDiv).not.toHaveClass('image-fade-in');
|
||||
|
|
@ -122,18 +164,23 @@ describe('components/SingleImageView', () => {
|
|||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should correctly pass prop down to surround small images with a container', () => {
|
||||
test('should correctly pass prop down to surround small images with a container', async () => {
|
||||
const {container} = renderWithContext(
|
||||
<SingleImageView {...baseProps}/>,
|
||||
);
|
||||
|
||||
// Wait for thumbnail availability check to complete
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('.file-preview__button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The SizeAwareImage component should receive handleSmallImageContainer=true
|
||||
// This is verified by checking that the component renders correctly
|
||||
// The actual prop passing is internal, but we can verify the component structure
|
||||
expect(container.querySelector('.file-preview__button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should not show filename when image is displayed', () => {
|
||||
test('should not show filename when image is displayed', async () => {
|
||||
const {container} = renderWithContext(
|
||||
<SingleImageView
|
||||
{...baseProps}
|
||||
|
|
@ -141,10 +188,15 @@ describe('components/SingleImageView', () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
// Wait for thumbnail availability check to complete (image-header--expanded indicates full render)
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('.image-header--expanded')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(container.querySelector('.image-header')?.textContent).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('should show filename when image is collapsed', () => {
|
||||
test('should show filename when image is collapsed', async () => {
|
||||
const {container} = renderWithContext(
|
||||
<SingleImageView
|
||||
{...baseProps}
|
||||
|
|
@ -152,11 +204,16 @@ describe('components/SingleImageView', () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
// Wait for thumbnail availability check to complete (toggle button indicates full render)
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('.single-image-view__toggle')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(container.querySelector('.image-header')?.textContent).toEqual(baseProps.fileInfo.name);
|
||||
});
|
||||
|
||||
describe('permalink preview', () => {
|
||||
test('should render with permalink styling if in permalink', () => {
|
||||
test('should render with permalink styling if in permalink', async () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
isInPermalink: true,
|
||||
|
|
@ -164,6 +221,11 @@ describe('components/SingleImageView', () => {
|
|||
|
||||
const {container} = renderWithContext(<SingleImageView {...props}/>);
|
||||
|
||||
// Wait for thumbnail availability check to complete
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('.image-permalink')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(container.querySelector('.image-permalink')).toBeInTheDocument();
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,12 +7,13 @@ import type {KeyboardEvent, MouseEvent} from 'react';
|
|||
|
||||
import type {FileInfo} from '@mattermost/types/files';
|
||||
|
||||
import {getFilePreviewUrl, getFileUrl} from 'mattermost-redux/utils/file_utils';
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
import {getFilePreviewUrl, getFileUrl, getFileThumbnailUrl} from 'mattermost-redux/utils/file_utils';
|
||||
|
||||
import FilePreviewModal from 'components/file_preview_modal';
|
||||
import SizeAwareImage from 'components/size_aware_image';
|
||||
|
||||
import {FileTypes, ModalIdentifiers} from 'utils/constants';
|
||||
import {FileTypes, HttpHeaders, ModalIdentifiers} from 'utils/constants';
|
||||
import {
|
||||
getFileType,
|
||||
} from 'utils/utils';
|
||||
|
|
@ -38,6 +39,8 @@ type State = {
|
|||
width: number;
|
||||
height: number;
|
||||
};
|
||||
thumbnailCheckComplete: boolean;
|
||||
thumbnailRejected: boolean;
|
||||
}
|
||||
|
||||
export default class SingleImageView extends React.PureComponent<Props, State> {
|
||||
|
|
@ -54,13 +57,54 @@ export default class SingleImageView extends React.PureComponent<Props, State> {
|
|||
width: props.fileInfo?.width || 0,
|
||||
height: props.fileInfo?.height || 0,
|
||||
},
|
||||
thumbnailCheckComplete: false,
|
||||
thumbnailRejected: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.mounted = true;
|
||||
this.checkThumbnailAvailability();
|
||||
}
|
||||
|
||||
checkThumbnailAvailability = () => {
|
||||
// Probe the thumbnail endpoint to see if it's rejected by a plugin
|
||||
// This allows plugins to control inline image display via thumbnail rejection when
|
||||
// there's only one file in the post and we try to display it inline as a preview and
|
||||
// not as a thumbnail.
|
||||
const {fileInfo} = this.props;
|
||||
if (!fileInfo || !fileInfo.has_preview_image) {
|
||||
// No preview image, so we'll use the file directly - don't check thumbnail
|
||||
this.setState({thumbnailCheckComplete: true, thumbnailRejected: false});
|
||||
return;
|
||||
}
|
||||
|
||||
const thumbnailUrl = getFileThumbnailUrl(fileInfo.id);
|
||||
|
||||
// Use Client4.getOptions() to get properly authenticated request options
|
||||
// This includes the Bearer token and all required headers
|
||||
const options = Client4.getOptions({method: 'HEAD'});
|
||||
|
||||
fetch(thumbnailUrl, options).then((response) => {
|
||||
if (this.mounted) {
|
||||
// 403 Forbidden with X-Reject-Reason header = rejected by plugin
|
||||
const rejected = response.status === 403 && response.headers.get(HttpHeaders.REJECT_REASON) !== null;
|
||||
this.setState({
|
||||
thumbnailCheckComplete: true,
|
||||
thumbnailRejected: rejected,
|
||||
});
|
||||
}
|
||||
}).catch(() => {
|
||||
// On error, assume not rejected (fail open for compatibility)
|
||||
if (this.mounted) {
|
||||
this.setState({
|
||||
thumbnailCheckComplete: true,
|
||||
thumbnailRejected: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
static getDerivedStateFromProps(props: Props, state: State) {
|
||||
if ((props.fileInfo?.width !== state.dimensions.width) || props.fileInfo.height !== state.dimensions.height) {
|
||||
return {
|
||||
|
|
@ -112,12 +156,50 @@ export default class SingleImageView extends React.PureComponent<Props, State> {
|
|||
const {fileInfo, compactDisplay, isInPermalink} = this.props;
|
||||
const {
|
||||
loaded,
|
||||
thumbnailCheckComplete,
|
||||
thumbnailRejected,
|
||||
} = this.state;
|
||||
|
||||
if (fileInfo === undefined) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
// If thumbnail check not complete yet, don't render the preview
|
||||
// This prevents flashing the image before we know if it should be hidden
|
||||
if (!thumbnailCheckComplete) {
|
||||
return (
|
||||
<div className={classNames('file-view--single')}>
|
||||
<div className='file__image'>
|
||||
<div className='image-header'>
|
||||
<div className='image-name'>{fileInfo.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// If thumbnail was rejected, treat this file as rejected
|
||||
// Show it collapsed with file icon instead of inline preview
|
||||
const effectivelyRejected = this.props.isFileRejected || thumbnailRejected;
|
||||
if (effectivelyRejected) {
|
||||
// Don't show inline preview - return minimal view
|
||||
// User can still click to attempt opening in modal (which will be controlled by preview rejection)
|
||||
return (
|
||||
<div className={classNames('file-view--single')}>
|
||||
<div className='file__image'>
|
||||
<div className='image-header'>
|
||||
<div
|
||||
className='image-name'
|
||||
onClick={this.handleImageClick}
|
||||
>
|
||||
{fileInfo.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const {has_preview_image: hasPreviewImage, id} = fileInfo;
|
||||
const fileURL = getFileUrl(id);
|
||||
const previewURL = hasPreviewImage ? getFilePreviewUrl(id) : fileURL;
|
||||
|
|
@ -245,6 +327,7 @@ export default class SingleImageView extends React.PureComponent<Props, State> {
|
|||
enablePublicLink={this.props.enablePublicLink}
|
||||
getFilePublicLink={this.getFilePublicLink}
|
||||
hideUtilities={this.props.disableActions}
|
||||
isFileRejected={effectivelyRejected}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -93,6 +93,11 @@ export type Props = WrappedComponentProps & {
|
|||
* Prevents display of utility buttons when image in a location that makes them inappropriate
|
||||
*/
|
||||
hideUtilities?: boolean;
|
||||
|
||||
/*
|
||||
* Indicates whether the file has been rejected and should not show preview
|
||||
*/
|
||||
isFileRejected?: boolean;
|
||||
}
|
||||
|
||||
type State = {
|
||||
|
|
@ -212,6 +217,7 @@ export class SizeAwareImage extends React.PureComponent<Props, State> {
|
|||
Reflect.deleteProperty(props, 'onClick');
|
||||
Reflect.deleteProperty(props, 'hideUtilities');
|
||||
Reflect.deleteProperty(props, 'getFilePublicLink');
|
||||
Reflect.deleteProperty(props, 'isFileRejected');
|
||||
Reflect.deleteProperty(props, 'intl');
|
||||
|
||||
let ariaLabelImage = intl.formatMessage({id: 'file_attachment.thumbnail', defaultMessage: 'file thumbnail'});
|
||||
|
|
@ -407,7 +413,8 @@ export class SizeAwareImage extends React.PureComponent<Props, State> {
|
|||
const height = (dimensions?.height ?? 0) * ratio;
|
||||
const width = (dimensions?.width ?? 0) * ratio;
|
||||
|
||||
const miniPreview = getFileMiniPreviewUrl(fileInfo);
|
||||
// Don't show mini preview (blurred thumbnail) if the file is rejected
|
||||
const miniPreview = this.props.isFileRejected ? null : getFileMiniPreviewUrl(fileInfo);
|
||||
|
||||
if (miniPreview) {
|
||||
fallback = (
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {sendEphemeralPost} from 'actions/global_actions';
|
|||
import reduxStore from 'stores/redux_store';
|
||||
|
||||
import {Constants} from 'utils/constants';
|
||||
import {getIntl} from 'utils/i18n';
|
||||
import {isMac} from 'utils/user_agent';
|
||||
|
||||
import type {ParsedCommand} from './app_command_parser';
|
||||
|
|
@ -114,6 +115,13 @@ export const displayError = (err: string, channelID: string, rootID?: string) =>
|
|||
reduxStore.dispatch(sendEphemeralPost(err, channelID, rootID));
|
||||
};
|
||||
|
||||
// Shim of mobile-version intl
|
||||
export const intlShim = {
|
||||
formatMessage: (config: {id: string; defaultMessage?: string}, values?: {[name: string]: any}) => {
|
||||
return getIntl().formatMessage(config, values);
|
||||
},
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const errorMessage = (intl: IntlShape, error: string, _command: string, _position: number): string => {
|
||||
return intl.formatMessage({
|
||||
|
|
|
|||
|
|
@ -16,10 +16,12 @@ import './file_card.scss';
|
|||
type Props = {
|
||||
file?: FileInfo;
|
||||
enableSVGs: boolean;
|
||||
isFileRejected?: boolean;
|
||||
}
|
||||
|
||||
type FileProps = FileInfo & {
|
||||
enableSVGs: boolean;
|
||||
isFileRejected?: boolean;
|
||||
}
|
||||
|
||||
type CardProps = {
|
||||
|
|
@ -35,16 +37,19 @@ function File({
|
|||
mime_type: mimeType,
|
||||
extension,
|
||||
enableSVGs,
|
||||
isFileRejected,
|
||||
}: FileProps) {
|
||||
const imgSrc = useMemo(() => {
|
||||
if (!hasPreviewImage) {
|
||||
if (!hasPreviewImage || isFileRejected) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Don't show blurred preview for rejected files
|
||||
if (miniPreview) {
|
||||
return `data:${mimeType};base64,${miniPreview}`;
|
||||
}
|
||||
return getFileThumbnailUrl(id);
|
||||
}, [id, miniPreview, mimeType, hasPreviewImage]);
|
||||
}, [id, miniPreview, mimeType, hasPreviewImage, isFileRejected]);
|
||||
|
||||
const fileType = getFileType(extension);
|
||||
|
||||
|
|
@ -110,7 +115,7 @@ function Card({children, title, size}: CardProps) {
|
|||
);
|
||||
}
|
||||
|
||||
function FileCard({file, enableSVGs}: Props) {
|
||||
function FileCard({file, enableSVGs, isFileRejected}: Props) {
|
||||
if (!file) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -122,6 +127,7 @@ function FileCard({file, enableSVGs}: Props) {
|
|||
>
|
||||
<File
|
||||
enableSVGs={enableSVGs}
|
||||
isFileRejected={isFileRejected}
|
||||
{...file}
|
||||
/>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import {connect} from 'react-redux';
|
|||
|
||||
import type {FileInfo} from '@mattermost/types/files';
|
||||
|
||||
import {getFile} from 'mattermost-redux/selectors/entities/files';
|
||||
import {getFile, isFileRejected} from 'mattermost-redux/selectors/entities/files';
|
||||
import {getConfig} from 'mattermost-redux/selectors/entities/general';
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
|
|
@ -23,6 +23,7 @@ function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
|
|||
return {
|
||||
file,
|
||||
enableSVGs: config.EnableSVGs === 'true',
|
||||
isFileRejected: isFileRejected(state, ownProps.id),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3714,6 +3714,7 @@
|
|||
"channel_bookmarks.create.title_input.clear_emoji": "Remove emoji",
|
||||
"channel_bookmarks.create.title_input.label": "Title",
|
||||
"channel_bookmarks.delete": "Delete",
|
||||
"channel_bookmarks.download": "Download",
|
||||
"channel_bookmarks.edit": "Edit",
|
||||
"channel_bookmarks.editBookmarkLabel": "Bookmark menu",
|
||||
"channel_bookmarks.open": "Open",
|
||||
|
|
@ -4478,6 +4479,7 @@
|
|||
"feedback.other": "Other",
|
||||
"FIFTY_TO_100": "51-100",
|
||||
"file_attachment.thumbnail": "file thumbnail",
|
||||
"file_download.rejected.file": "File access blocked: {reason}",
|
||||
"file_info_preview.size": "Size ",
|
||||
"file_info_preview.type": "File type ",
|
||||
"file_preview_modal_info.shared_in": "Shared in ~{name}",
|
||||
|
|
|
|||
|
|
@ -15,4 +15,5 @@ export default keyMirror({
|
|||
RECEIVED_FILE_PUBLIC_LINK: null,
|
||||
|
||||
REMOVED_FILE: null,
|
||||
FILE_DOWNLOAD_REJECTED: null,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -229,9 +229,28 @@ function filePublicLink(state: {link: string} = {link: ''}, action: MMReduxActio
|
|||
}
|
||||
}
|
||||
|
||||
export function rejectedFiles(state: Set<string> = new Set(), action: MMReduxAction) {
|
||||
switch (action.type) {
|
||||
case FileTypes.FILE_DOWNLOAD_REJECTED: {
|
||||
const {file_id: fileId} = action.data;
|
||||
if (fileId) {
|
||||
const nextState = new Set(state);
|
||||
nextState.add(fileId);
|
||||
return nextState;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
case UserTypes.LOGOUT_SUCCESS:
|
||||
return new Set();
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export default combineReducers({
|
||||
files,
|
||||
filesFromSearch,
|
||||
fileIdsByPostId,
|
||||
filePublicLink,
|
||||
rejectedFiles,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,6 +25,15 @@ export function getFilePublicLink(state: GlobalState) {
|
|||
return state.entities.files.filePublicLink;
|
||||
}
|
||||
|
||||
export function getRejectedFiles(state: GlobalState): Set<string> {
|
||||
return state.entities.files.rejectedFiles || new Set();
|
||||
}
|
||||
|
||||
export function isFileRejected(state: GlobalState, fileId: string): boolean {
|
||||
const rejectedFiles = getRejectedFiles(state);
|
||||
return rejectedFiles.has(fileId);
|
||||
}
|
||||
|
||||
export function makeGetFileIdsForPost(): (state: GlobalState, postId: string) => string[] {
|
||||
return createSelector(
|
||||
'makeGetFileIdsForPost',
|
||||
|
|
|
|||
|
|
@ -153,6 +153,7 @@ const state: GlobalState = {
|
|||
files: {},
|
||||
filesFromSearch: {},
|
||||
fileIdsByPostId: {},
|
||||
rejectedFiles: new Set(),
|
||||
},
|
||||
emojis: {
|
||||
customEmoji: {},
|
||||
|
|
|
|||
|
|
@ -930,6 +930,10 @@ export const FileTypes = {
|
|||
LICENSE_EXTENSION: '.mattermost-license',
|
||||
};
|
||||
|
||||
export const HttpHeaders = {
|
||||
REJECT_REASON: 'X-Reject-Reason',
|
||||
};
|
||||
|
||||
export const NotificationLevels = {
|
||||
DEFAULT: 'default',
|
||||
ALL: 'all',
|
||||
|
|
|
|||
|
|
@ -91,4 +91,6 @@ export const enum WebSocketEvents {
|
|||
ContentFlaggingReportValueUpdated = 'content_flagging_report_value_updated',
|
||||
RecapUpdated = 'recap_updated',
|
||||
PostTranslationUpdated = 'post_translation_updated',
|
||||
FileDownloadRejected = 'file_download_rejected',
|
||||
ShowToast = 'show_toast',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,6 +92,9 @@ export type WebSocketMessage = (
|
|||
|
||||
Messages.RecapUpdated |
|
||||
|
||||
Messages.FileDownloadRejected |
|
||||
Messages.ShowToast |
|
||||
|
||||
Messages.Plugin |
|
||||
Messages.PluginStatusesChanged |
|
||||
Messages.OpenDialog |
|
||||
|
|
|
|||
|
|
@ -444,6 +444,20 @@ export type OpenDialog = BaseWebSocketMessage<WebSocketEvents.OpenDialog, {
|
|||
dialog: JsonEncodedValue<OpenDialogRequest>;
|
||||
}>;
|
||||
|
||||
export type FileDownloadRejected = BaseWebSocketMessage<WebSocketEvents.FileDownloadRejected, {
|
||||
file_id: string;
|
||||
file_name: string;
|
||||
rejection_reason: string;
|
||||
channel_id: string;
|
||||
post_id: string;
|
||||
download_type: string;
|
||||
}>;
|
||||
|
||||
export type ShowToast = BaseWebSocketMessage<WebSocketEvents.ShowToast, {
|
||||
message: string;
|
||||
position?: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Unknown is used for WebSocket messages which don't come from Mattermost itself. It's primarily intended for use
|
||||
* by plugins.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,29 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
/**
|
||||
* FileDownloadType represents the type of file download or access being performed.
|
||||
*/
|
||||
export type FileDownloadType = 'file' | 'thumbnail' | 'preview' | 'public';
|
||||
|
||||
/**
|
||||
* FileDownloadTypes contains constants for the different types of file downloads.
|
||||
*/
|
||||
export const FileDownloadTypes = {
|
||||
|
||||
/** Full file download request */
|
||||
FILE: 'file' as FileDownloadType,
|
||||
|
||||
/** Thumbnail image request */
|
||||
THUMBNAIL: 'thumbnail' as FileDownloadType,
|
||||
|
||||
/** Preview image request */
|
||||
PREVIEW: 'preview' as FileDownloadType,
|
||||
|
||||
/** Public link access (unauthenticated) */
|
||||
PUBLIC: 'public' as FileDownloadType,
|
||||
} as const;
|
||||
|
||||
export type FileInfo = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
|
|
@ -26,6 +49,7 @@ export type FilesState = {
|
|||
filesFromSearch: Record<string, FileSearchResultItem>;
|
||||
fileIdsByPostId: Record<string, string[]>;
|
||||
filePublicLink?: {link: string};
|
||||
rejectedFiles: Set<string>;
|
||||
};
|
||||
|
||||
export type FileUploadResponse = {
|
||||
|
|
|
|||
Loading…
Reference in a new issue